From 57e455dfa78944e2aae9821366cd9fc507293c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:14:54 +0800 Subject: [PATCH 01/98] engine: fill rules as vector.fillRule and vector.reverseLoop Two vector commands behind the fill-rule panel, split out so they can land on their own (the panel is held for a maintainer design decision): - vector.fillRule {ids? | id?, region?, rule: nonZero|evenOdd} sets the winding rule a filled area fills by (the whole layer when region is left out). - vector.reverseLoop {ids? | id?, region, loop} turns one loop round, which is how a hole is made and unmade. reverseLoop no longer swaps the stored ends of a segment a loop shares with a neighbour: reversing the loop's list already flips this loop's traversal of the shared segment, so flipping its segment would turn the neighbour's area round too (bug called out in review). A new test builds two areas that share a segment and checks the shared segment and the neighbour's fill are left alone. Adds the commands to the Vector menu (catalog/parity), their locale labels, and vector_fill_rule_tests (rule per area or whole layer, junk refused, shared segment). --- crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/structure.rs | 132 ++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/vector_fill_rule_tests.rs | 228 ++++++++++++++++++++ crates/ui-egui/locales/cs.toml | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/id.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/ru.toml | 2 + crates/ui-egui/locales/sv.toml | 2 + crates/ui-egui/locales/tr.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + 16 files changed, 388 insertions(+) create mode 100644 crates/engine/src/vector_fill_rule_tests.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index c8f34fc1..6bee5048 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -235,6 +235,8 @@ pub static MENU: &[MenuItem] = &[ m!("Vector", "Delete and heal selection", "vector.deleteHeal"), m!("Vector", "Simplify vector", "vector.simplify"), m!("Vector", "Reverse path direction", "vector.reverse"), + m!("Vector", "Set fill rule", "vector.fillRule"), + m!("Vector", "Reverse loop", "vector.reverseLoop"), m!("Vector", "Offset path", "vector.offset"), // Plugins m!("Plugins", "Run last plugin", "plugins.runLast"), diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 500fa021..c38052ec 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -103,6 +103,20 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "vector.fillRule", + "Set fill rule", + "{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}", + true, + fill_rule + ), + spec!( + "vector.reverseLoop", + "Reverse loop", + "{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)", + true, + reverse_loop + ), spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { @@ -719,3 +733,121 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result { } Ok(json!({"ids": fitted})) } + +/// The vector network of `id`, when it is a vector layer. +fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> { + match &mut doc.get_mut(id).ok()?.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + } +} + +/// The id a fill rule is written as. +fn rule_id(rule: WindingRule) -> &'static str { + match rule { + WindingRule::NonZero => "nonZero", + WindingRule::EvenOdd => "evenOdd", + } +} + +/// `region` as an index: absent (or null) means every area. +fn area_index(cmd: &str, p: &Value) -> Result> { + match get(p, "region") { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?; + if !(0.0..1e9).contains(&k) || k.fract() != 0.0 { + return Err(bad(cmd, "`region` out of range")); + } + Ok(Some(k as usize)) + } + } +} + +/// Segments listed by more than one loop (of any region): reversing a loop must leave them as they +/// are, or a neighbouring area that shares the segment would turn round with it — flipping the +/// neighbour's fill or stroke. Reversing the loop's list already flips this loop's traversal of the +/// shared segment, so its stored direction is left alone. +fn shared_segments(net: &VectorNetwork) -> std::collections::HashSet { + let mut count = std::collections::HashMap::::new(); + for region in &net.regions { + for lp in ®ion.loops { + for &s in lp { + *count.entry(s).or_insert(0) += 1; + } + } + } + count.into_iter().filter(|(_, c)| *c > 1).map(|(s, _)| s).collect() +} + +fn fill_rule(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.fillRule", s, p)?; + let rule = match string(p, "rule").as_deref().map(str::trim) { + Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd, + Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero, + _ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")), + }; + let only = area_index("vector.fillRule", p)?; + let doc = s.doc_mut()?; + let mut count = 0usize; + for id in ids { + let Some(net) = network_mut(doc, id) else { continue }; + match only { + Some(r) => { + if let Some(region) = net.regions.get_mut(r) { + region.winding = rule; + count += 1; + } + } + None => { + for region in &mut net.regions { + region.winding = rule; + count += 1; + } + } + } + } + if count == 0 { + return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into())); + } + Ok(json!({"regions": count, "rule": rule_id(rule)})) +} + +/// Turn one loop of a filled area round: its segments are walked the other way, so the area it +/// bounds counts the other way in the winding (which turns a hole into a filled part and back). +fn reverse_loop(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.reverseLoop", s, p)?; + let region = req_index("vector.reverseLoop", p, "region")?; + let index = req_index("vector.reverseLoop", p, "loop")?; + let doc = s.doc_mut()?; + let mut reversed = false; + for id in ids { + let Some(net) = network_mut(doc, id) else { continue }; + let Some(loop_) = net.regions.get(region).and_then(|r| r.loops.get(index)).cloned() else { continue }; + // A loop is walked in the order its segments are listed, so the list turned round walks the + // ring the other way. A loop of one or two segments only turns round when its segments are + // stored the other way too (list reversal alone is a no-op for them). + // A segment shared with another loop must NOT be swapped: it belongs to a neighbouring area + // whose winding we leave alone, and reversing the list already flips this loop's traversal + // of it. + let shared = shared_segments(net); + let turned: Vec = loop_.iter().rev().copied().collect(); + for &seg in &turned { + if shared.contains(&seg) { + continue; + } + if let Some(seg) = net.segments.get_mut(seg) { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); + } + } + if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) { + *l = turned; + reversed = true; + } + } + if !reversed { + return Err(EngineError::Disabled("vector.reverseLoop".into(), "no such loop (select a vector with filled areas)".into())); + } + Ok(json!({"reversed": true})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8796dc9c..5e29ae44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1790,6 +1790,8 @@ mod variables_io_tests; #[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] +mod vector_fill_rule_tests; +#[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; diff --git a/crates/engine/src/vector_fill_rule_tests.rs b/crates/engine/src/vector_fill_rule_tests.rs new file mode 100644 index 00000000..db96b669 --- /dev/null +++ b/crates/engine/src/vector_fill_rule_tests.rs @@ -0,0 +1,228 @@ +//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and +//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade. +//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not +//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the +//! direction of a loop doesn't matter). + +use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; +use figcraft_geom::{BezPath, Point}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn net(s: &Session, id: NodeId) -> VectorNetwork { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("not a vector: {other:?}"), + } +} + +/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner +/// one the same way round (so non-zero fills the hole too until it is turned round). +fn ring() -> VectorNetwork { + let mut path = BezPath::new(); + for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] { + path.move_to((x0, y0)); + path.line_to((x1, y0)); + path.line_to((x1, y1)); + path.line_to((x0, y1)); + path.close_path(); + } + let mut net = VectorNetwork::from_path(&path); + net.regions[0].winding = WindingRule::NonZero; + net +} + +/// A selected vector layer holding `ring()`. +fn ringed(s: &mut Session) -> NodeId { + let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = NodeId(id); + ex(s, "vector.edit.begin", json!({"id": id})); + ex(s, "vector.edit.end", json!({})); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network } => *network = ring(), + other => panic!("not a vector: {other:?}"), + } + } + ex(s, "select.set", json!({"ids": [id]})); + id +} + +/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by +/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must +/// not flip, or a neighbour's fill or stroke would turn round with it. +fn two_squares() -> VectorNetwork { + // 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100) + // s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top), + // s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top); + // the right area's left edge is s1 walked the other way, so the squares share one segment. + // region 0: left square A→B→E→F→A = [s0, s1, s2, s3]. + // region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards). + VectorNetwork { + vertices: vec![ + Vertex::new(0.0, 0.0), + Vertex::new(50.0, 0.0), + Vertex::new(100.0, 0.0), + Vertex::new(100.0, 100.0), + Vertex::new(50.0, 100.0), + Vertex::new(0.0, 100.0), + ], + segments: vec![ + Segment::line(0, 1), + Segment::line(1, 4), + Segment::line(4, 5), + Segment::line(5, 0), + Segment::line(1, 2), + Segment::line(2, 3), + Segment::line(3, 4), + ], + regions: vec![ + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_squares`]. +fn squared(s: &mut Session) -> NodeId { + let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = NodeId(id); + ex(s, "vector.edit.begin", json!({"id": id})); + ex(s, "vector.edit.end", json!({})); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network } => *network = two_squares(), + other => panic!("not a vector: {other:?}"), + } + } + ex(s, "select.set", json!({"ids": [id]})); + id +} + +/// The point in the middle of the hole, and one on the square's left band. +const HOLE: Point = Point::new(50.0, 50.0); +const BAND: Point = Point::new(10.0, 50.0); + +#[test] +fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() { + let mut s = Session::new(); + let id = ringed(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 1); + assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's"); + assert!(n.region_contains(0, BAND), "the band fills"); + assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way"); + // Turning the inner loop round makes it a hole under non-zero. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + assert!(n.region_contains(0, BAND), "the band still fills"); + assert!(!n.region_contains(0, HOLE), "the hole is empty"); + assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there"); + // Undo turns it back. + s.undo().unwrap(); + assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop"); +} + +#[test] +fn the_rule_switches_every_area_or_only_the_one_asked_for() { + let mut s = Session::new(); + let id = ringed(&mut s); + // Even-odd leaves the hole empty whatever way its loop runs. + let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd"))); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::EvenOdd); + assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing"); + // And back. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"})); + assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again"); + // One area of several, by index. + let n = net(&s, id); + let mut two = n.clone(); + two.regions.push(n.regions[0].clone()); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network } => *network = two, + other => panic!("not a vector: {other:?}"), + } + } + ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"})); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule"); + assert_eq!(n.regions[1].winding, WindingRule::EvenOdd); + // Every area at once. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd)); +} + +#[test] +fn junk_parameters_are_refused_and_change_nothing() { + let mut s = Session::new(); + let id = ringed(&mut s); + let before = net(&s, id); + for p in [ + json!({"id": id, "rule": "nope"}), + json!({"id": id}), + json!({"id": id, "rule": 7}), + json!({"id": id, "rule": "evenOdd", "region": -1}), + json!({"id": id, "rule": "evenOdd", "region": 1.5}), + json!({"id": id, "rule": "evenOdd", "region": "x"}), + json!({"id": id, "rule": "evenOdd", "region": 99}), + ] { + assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused"); + } + for p in [ + json!({"id": id}), + json!({"id": id, "region": 0}), + json!({"id": id, "region": 0, "loop": 99}), + json!({"id": id, "region": 99, "loop": 0}), + json!({"id": id, "region": -2, "loop": 0}), + ] { + assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused"); + } + assert_eq!(net(&s, id), before, "nothing changed"); + // A rectangle that was never a vector is refused too. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err()); + assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err()); +} + +#[test] +fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() { + let mut s = Session::new(); + let id = squared(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas, side by side"); + // Both areas fill under non-zero: a point in each. + assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills"); + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills"); + // The vertical edge between them is one segment listed by both areas' loops. + const SHARED: usize = 1; + assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared"); + let before = n.segments[SHARED]; + let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap(); + assert_ne!(left_winding, 0, "the left area winds once"); + // Reverse the left area's only loop. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + // The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's + // segment (reversing the list already turns this loop's traversal of it the other way). + assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was"); + // The neighbour's fill is unchanged: it still fills its interior. + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills"); + // The reversed loop really turned round: its winding at a left-area point flipped sign. + assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed"); + // Undo restores the shared segment and the loop. + s.undo().unwrap(); + assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too"); +} diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 2459d104..7d176d1b 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -2151,11 +2151,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Nástroj úprav vektoru" "vector.edit.transform" = "Transformovat body" "vector.edit.widthPoint" = "Bod šířky" +"vector.fillRule" = "Nastavit pravidlo výplně" "vector.flatten" = "Sloučit do vektoru" "vector.join" = "Spojit výběr" "vector.offset" = "Odsadit cestu" "vector.outlineStroke" = "Převést tah na obrysy" "vector.reverse" = "Obrátit směr cesty" +"vector.reverseLoop" = "Obrátit smyčku" "vector.simplify" = "Zjednodušit vektor" "vector.smoothJoin" = "Hladce spojit výběr" "version.autosave" = "Automaticky uložit verzi" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b679feb7..bd609450 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -2093,11 +2093,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Vektorbearbeitungswerkzeug" "vector.edit.transform" = "Punkte transformieren" "vector.edit.widthPoint" = "Breitenpunkt" +"vector.fillRule" = "Füllregel festlegen" "vector.flatten" = "Zusammenfügen" "vector.join" = "Auswahl verbinden" "vector.offset" = "Pfad versetzen" "vector.outlineStroke" = "Kontur in Pfad umwandeln" "vector.reverse" = "Pfadrichtung umkehren" +"vector.reverseLoop" = "Schleife umdrehen" "vector.simplify" = "Vektor vereinfachen" "vector.smoothJoin" = "Auswahl glatt verbinden" "version.autosave" = "Version automatisch speichern" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 2849f4ec..f05c5bdd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2183,11 +2183,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Herramienta de edición vectorial" "vector.edit.transform" = "Transformar puntos" "vector.edit.widthPoint" = "Punto de ancho" +"vector.fillRule" = "Establecer regla de relleno" "vector.flatten" = "Acoplar" "vector.join" = "Unir selección" "vector.offset" = "Desplazar trazado" "vector.outlineStroke" = "Contornear trazo" "vector.reverse" = "Invertir dirección del trazado" +"vector.reverseLoop" = "Invertir bucle" "vector.simplify" = "Simplificar vector" "vector.smoothJoin" = "Unir selección con suavizado" "version.autosave" = "Guardar versión automáticamente" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1c561f15..621eb725 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -2084,11 +2084,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Outil de modification vectorielle" "vector.edit.transform" = "Transformer les points" "vector.edit.widthPoint" = "Point de largeur" +"vector.fillRule" = "Définir la règle de remplissage" "vector.flatten" = "Aplatir" "vector.join" = "Joindre la sélection" "vector.offset" = "Décaler le tracé" "vector.outlineStroke" = "Convertir le contour en tracé" "vector.reverse" = "Inverser le sens du tracé" +"vector.reverseLoop" = "Inverser la boucle" "vector.simplify" = "Simplifier le vecteur" "vector.smoothJoin" = "Joindre la sélection en lissant" "version.autosave" = "Enregistrer automatiquement une version" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 7a0a3c3c..e196f88b 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -2155,11 +2155,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Alat edit vector" "vector.edit.transform" = "Transformasi titik" "vector.edit.widthPoint" = "Titik lebar" +"vector.fillRule" = "Atur aturan isian" "vector.flatten" = "Satukan" "vector.join" = "Gabungkan pilihan" "vector.offset" = "Offset path" "vector.outlineStroke" = "Outline stroke" "vector.reverse" = "Balik arah path" +"vector.reverseLoop" = "Balik loop" "vector.simplify" = "Sederhanakan vector" "vector.smoothJoin" = "Gabungkan pilihan dengan halus" "version.autosave" = "Simpan versi otomatis" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b09db3bd..533170af 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -2168,11 +2168,13 @@ status = "draft — needs native review" "vector.edit.tool" = "ベクター編集ツール" "vector.edit.transform" = "ポイントを変形" "vector.edit.widthPoint" = "幅のポイント" +"vector.fillRule" = "塗りつぶし規則を設定" "vector.flatten" = "平坦化" "vector.join" = "選択範囲を連結" "vector.offset" = "パスのオフセット" "vector.outlineStroke" = "線のアウトライン化" "vector.reverse" = "パスの方向を反転" +"vector.reverseLoop" = "ループを反転" "vector.simplify" = "ベクターを単純化" "vector.smoothJoin" = "選択範囲を滑らかに連結" "version.autosave" = "自動保存バージョン" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f2cfcfff..09ebef0d 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -2084,11 +2084,13 @@ status = "draft — needs native review" "vector.edit.tool" = "벡터 편집 도구" "vector.edit.transform" = "점 변형" "vector.edit.widthPoint" = "너비 점" +"vector.fillRule" = "칠 규칙 설정" "vector.flatten" = "평면화" "vector.join" = "선택 항목 연결" "vector.offset" = "패스 오프셋" "vector.outlineStroke" = "선 윤곽선 만들기" "vector.reverse" = "패스 방향 반전" +"vector.reverseLoop" = "루프 반전" "vector.simplify" = "벡터 단순화" "vector.smoothJoin" = "선택 항목 부드럽게 연결" "version.autosave" = "자동 저장 버전" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index e9e1300c..59802d73 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2083,11 +2083,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Ferramenta de edição vetorial" "vector.edit.transform" = "Transformar pontos" "vector.edit.widthPoint" = "Ponto de largura" +"vector.fillRule" = "Definir regra de preenchimento" "vector.flatten" = "Nivelar" "vector.join" = "Unir seleção" "vector.offset" = "Deslocar caminho" "vector.outlineStroke" = "Contornar traço" "vector.reverse" = "Inverter direção do caminho" +"vector.reverseLoop" = "Inverter laço" "vector.simplify" = "Simplificar vetor" "vector.smoothJoin" = "Unir seleção com suavização" "version.autosave" = "Salvar versão automaticamente" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2023b692..bf8e29c7 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -2153,11 +2153,13 @@ name = "Русский" "vector.edit.tool" = "Инструмент редактирования вектора" "vector.edit.transform" = "Трансформировать точки" "vector.edit.widthPoint" = "Точка ширины" +"vector.fillRule" = "Задать правило заливки" "vector.flatten" = "Свести" "vector.join" = "Соединить выделение" "vector.offset" = "Сместить путь" "vector.outlineStroke" = "Преобразовать обводку в контур" "vector.reverse" = "Обратить направление пути" +"vector.reverseLoop" = "Развернуть контур" "vector.simplify" = "Упростить вектор" "vector.smoothJoin" = "Плавно соединить выделение" "version.autosave" = "Автосохранить версию" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 51f16508..96e8b83a 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -2176,11 +2176,13 @@ name = "Svenska" "vector.edit.tool" = "Vektorredigeringsverktyg" "vector.edit.transform" = "Transformera punkter" "vector.edit.widthPoint" = "Breddpunkt" +"vector.fillRule" = "Ange fyllnadsregel" "vector.flatten" = "Platta till" "vector.join" = "Sammanfoga markering" "vector.offset" = "Förskjut bana" "vector.outlineStroke" = "Konvertera kontur till form" "vector.reverse" = "Vänd banans riktning" +"vector.reverseLoop" = "Vänd slinga" "vector.simplify" = "Förenkla vektor" "vector.smoothJoin" = "Sammanfoga markering mjukt" "version.autosave" = "Autospara version" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index cc002654..68daa4b3 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -2155,11 +2155,13 @@ name = "Türkçe" "vector.edit.tool" = "Vektör düzenleme aracı" "vector.edit.transform" = "Noktaları dönüştür" "vector.edit.widthPoint" = "Genişlik noktası" +"vector.fillRule" = "Dolgu kuralını ayarla" "vector.flatten" = "Düzleştir" "vector.join" = "Seçimi birleştir" "vector.offset" = "Yolu ötele" "vector.outlineStroke" = "Konturu ana hatlara dönüştür" "vector.reverse" = "Yol yönünü ters çevir" +"vector.reverseLoop" = "Döngüyü çevir" "vector.simplify" = "Vektörü sadeleştir" "vector.smoothJoin" = "Seçimi yumuşak birleştir" "version.autosave" = "Otomatik kayıt sürümü" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 702cae7b..7a8d9b96 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -2085,11 +2085,13 @@ status = "draft — needs native review" "vector.edit.tool" = "矢量编辑工具" "vector.edit.transform" = "变换点" "vector.edit.widthPoint" = "宽度点" +"vector.fillRule" = "设置填充规则" "vector.flatten" = "拼合" "vector.join" = "连接所选项" "vector.offset" = "偏移路径" "vector.outlineStroke" = "轮廓化描边" "vector.reverse" = "反转路径方向" +"vector.reverseLoop" = "反转环" "vector.simplify" = "简化矢量" "vector.smoothJoin" = "平滑连接所选项" "version.autosave" = "自动保存版本" From d489235d2c9ef689cb2321e7ddb28aaf57383a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:35:50 +0800 Subject: [PATCH 02/98] engine: short loops in vector.reverseLoop really turn round reverseLoop reversed the list and skipped shared segments, which only turns a loop of three or more round: a one-segment loop has no order at all, and a two-segment ring reads the same whichever way its list is turned, so both answered reversed: true without moving. Three or more segments: reverse the list and leave every stored end alone (a shared one belongs to the neighbour too). One or two: store one segment the other way round, skipping any segment another short loop lists; when none is left to swap, refuse with a clear error and leave the document as it was. Tests: two 2-segment areas over one line that share it, the shared segment listed first in one area and last in the other; each reversal flips that area's winding sign and leaves the shared segment and the neighbour alone. A shared one-segment loop is refused. The 4-segment squares stay as the long-loop case. Drop the two catalog.rs menu lines: they aren't in the reference app's menus, and the Vector menu runs commands without parameters, so both would fail there. --- crates/engine/src/catalog.rs | 2 - crates/engine/src/cmd/structure.rs | 97 +++++++++++------- crates/engine/src/vector_fill_rule_tests.rs | 108 +++++++++++++++++--- 3 files changed, 154 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 6bee5048..c8f34fc1 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -235,8 +235,6 @@ pub static MENU: &[MenuItem] = &[ m!("Vector", "Delete and heal selection", "vector.deleteHeal"), m!("Vector", "Simplify vector", "vector.simplify"), m!("Vector", "Reverse path direction", "vector.reverse"), - m!("Vector", "Set fill rule", "vector.fillRule"), - m!("Vector", "Reverse loop", "vector.reverseLoop"), m!("Vector", "Offset path", "vector.offset"), // Plugins m!("Plugins", "Run last plugin", "plugins.runLast"), diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index c38052ec..d38b3dfc 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -764,20 +764,20 @@ fn area_index(cmd: &str, p: &Value) -> Result> { } } -/// Segments listed by more than one loop (of any region): reversing a loop must leave them as they -/// are, or a neighbouring area that shares the segment would turn round with it — flipping the -/// neighbour's fill or stroke. Reversing the loop's list already flips this loop's traversal of the -/// shared segment, so its stored direction is left alone. -fn shared_segments(net: &VectorNetwork) -> std::collections::HashSet { - let mut count = std::collections::HashMap::::new(); - for region in &net.regions { - for lp in ®ion.loops { - for &s in lp { - *count.entry(s).or_insert(0) += 1; +/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends +/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop +/// of three or more segments is left out: its ring is walked in list order, which the stored ends +/// don't decide. +fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (r, region) in net.regions.iter().enumerate() { + for (l, lp) in region.loops.iter().enumerate() { + if (r, l) != skip && lp.len() <= 2 { + out.extend(lp.iter().copied()); } } } - count.into_iter().filter(|(_, c)| *c > 1).map(|(s, _)| s).collect() + out } fn fill_rule(s: &mut Session, p: &Value) -> Result { @@ -813,41 +813,66 @@ fn fill_rule(s: &mut Session, p: &Value) -> Result { Ok(json!({"regions": count, "rule": rule_id(rule)})) } +/// One loop of a vector, worked out but not yet written back: its segments in the new order and, +/// for a loop of one or two segments, the one to store the other way. +struct LoopFlip { + id: NodeId, + turned: Vec, + store: Option, +} + /// Turn one loop of a filled area round: its segments are walked the other way, so the area it /// bounds counts the other way in the winding (which turns a hole into a filled part and back). +/// +/// How a loop is walked depends on how many segments it has: +/// - three or more: the order they are listed in, each segment turned to continue from the point +/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared +/// segment's ends belong to its neighbour too); +/// - two: the same ring either way round, so one of the two has to be stored the other way instead; +/// - one: only its stored ends say which way it runs, so they are swapped. +/// +/// A short loop can only turn round by re-storing a segment, which is refused when another short +/// loop lists that segment too: it would turn that area round as well. fn reverse_loop(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.reverseLoop", s, p)?; - let region = req_index("vector.reverseLoop", p, "region")?; - let index = req_index("vector.reverseLoop", p, "loop")?; + let cmd = "vector.reverseLoop"; + let ids = req_targets(cmd, s, p)?; + let region = req_index(cmd, p, "region")?; + let index = req_index(cmd, p, "loop")?; let doc = s.doc_mut()?; - let mut reversed = false; + // Work every loop out before touching the document: one that can't turn round refuses the call + // and the others stay as they were. + let mut flips: Vec = Vec::new(); for id in ids { + let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue }; + let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue }; + if loop_.is_empty() { + continue; + } + let mut store = None; + if loop_.len() <= 2 { + let busy = short_loop_segments(network, (region, index)); + let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else { + return Err(EngineError::Disabled( + cmd.into(), + "the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(), + )); + }; + store = Some(pick); + } + flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store }); + } + if flips.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into())); + } + for LoopFlip { id, turned, store } in flips { let Some(net) = network_mut(doc, id) else { continue }; - let Some(loop_) = net.regions.get(region).and_then(|r| r.loops.get(index)).cloned() else { continue }; - // A loop is walked in the order its segments are listed, so the list turned round walks the - // ring the other way. A loop of one or two segments only turns round when its segments are - // stored the other way too (list reversal alone is a no-op for them). - // A segment shared with another loop must NOT be swapped: it belongs to a neighbouring area - // whose winding we leave alone, and reversing the list already flips this loop's traversal - // of it. - let shared = shared_segments(net); - let turned: Vec = loop_.iter().rev().copied().collect(); - for &seg in &turned { - if shared.contains(&seg) { - continue; - } - if let Some(seg) = net.segments.get_mut(seg) { - std::mem::swap(&mut seg.a, &mut seg.b); - std::mem::swap(&mut seg.ta, &mut seg.tb); - } + if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); } if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) { *l = turned; - reversed = true; } } - if !reversed { - return Err(EngineError::Disabled("vector.reverseLoop".into(), "no such loop (select a vector with filled areas)".into())); - } Ok(json!({"reversed": true})) } diff --git a/crates/engine/src/vector_fill_rule_tests.rs b/crates/engine/src/vector_fill_rule_tests.rs index db96b669..8a8fe850 100644 --- a/crates/engine/src/vector_fill_rule_tests.rs +++ b/crates/engine/src/vector_fill_rule_tests.rs @@ -37,8 +37,8 @@ fn ring() -> VectorNetwork { net } -/// A selected vector layer holding `ring()`. -fn ringed(s: &mut Session) -> NodeId { +/// A selected vector layer holding `network`. +fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId { let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); let id = NodeId(id); ex(s, "vector.edit.begin", json!({"id": id})); @@ -46,7 +46,7 @@ fn ringed(s: &mut Session) -> NodeId { { let doc = s.doc_mut().unwrap(); match &mut doc.get_mut(id).unwrap().kind { - NodeKind::Vector { network } => *network = ring(), + NodeKind::Vector { network: net } => *net = network, other => panic!("not a vector: {other:?}"), } } @@ -54,6 +54,11 @@ fn ringed(s: &mut Session) -> NodeId { id } +/// A selected vector layer holding `ring()`. +fn ringed(s: &mut Session) -> NodeId { + vector_layer(s, ring()) +} + /// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by /// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must /// not flip, or a neighbour's fill or stroke would turn round with it. @@ -91,19 +96,43 @@ fn two_squares() -> VectorNetwork { /// A selected vector layer holding [`two_squares`]. fn squared(s: &mut Session) -> NodeId { - let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); - let id = NodeId(id); - ex(s, "vector.edit.begin", json!({"id": id})); - ex(s, "vector.edit.end", json!({})); - { - let doc = s.doc_mut().unwrap(); - match &mut doc.get_mut(id).unwrap().kind { - NodeKind::Vector { network } => *network = two_squares(), - other => panic!("not a vector: {other:?}"), - } + vector_layer(s, two_squares()) +} + +/// Two areas of two segments each over the same line: one curve above it and one below. Both areas +/// list the line's segment — the first area first (`[line, above]`), the second area last +/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so +/// these only turn round when a segment is stored the other way. +fn two_lenses() -> VectorNetwork { + // 0:A(0,0) 1:B(100,0) + // s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it. + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![ + Segment::line(0, 1), + Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) }, + Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) }, + ], + regions: vec![ + Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_lenses`]. +fn lensed(s: &mut Session) -> NodeId { + vector_layer(s, two_lenses()) +} + +/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight +/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them. +fn one_shared_segment() -> VectorNetwork { + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }], + regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }], } - ex(s, "select.set", json!({"ids": [id]})); - id } /// The point in the middle of the hole, and one on the square's left band. @@ -226,3 +255,52 @@ fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() s.undo().unwrap(); assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too"); } + +#[test] +fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() { + let mut s = Session::new(); + let id = lensed(&mut s); + const SHARED: usize = 0; + const ABOVE: Point = Point::new(50.0, -25.0); + const BELOW: Point = Point::new(50.0, 25.0); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas over the same line"); + assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first"); + assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last"); + assert!(n.region_contains(0, ABOVE), "the area above the line fills"); + assert!(n.region_contains(1, BELOW), "the area below it fills"); + let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap()); + assert_ne!(above, 0, "the area above winds once"); + assert_ne!(below, 0, "the area below winds once"); + let shared = n.segments[SHARED]; + // Turn the area that lists the shared segment first round: a two-segment ring reads the same + // either way, so this only works by storing one of its segments the other way. + assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true)); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends"); + assert!(n.region_contains(0, ABOVE), "the area still fills"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round"); + assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not"); + // And the area that lists it last. + ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0})); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "still untouched"); + assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was"); + // Undo turns each back. + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back"); + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first"); +} + +#[test] +fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() { + let mut s = Session::new(); + let id = vector_layer(&mut s, one_shared_segment()); + let before = net(&s, id); + let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("shares its segments"), "the error says why: {message}"); + assert_eq!(net(&s, id), before, "nothing changed"); +} From 8d5a29dfda4817fb40a1d9338c233987ba45102f Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Sat, 17 Oct 2026 16:49:22 +0200 Subject: [PATCH 03/98] M5: one presentation tab per page of a file Main plays a presentation in a single "Present" tab. Now each page of a file gets its own tab, named "file - page", and the name follows renames. Play on a page that has a tab shows that tab as it was; from another start frame it starts again. A tab keeps its own copy of the prototype: it plays on after its file closes, and a saved file opened again gets its tabs back. A tab whose file is closed or not in front never reads or changes the file in front (device, responsive, comments). Escape or the tab's X closes that tab and goes back to its file, or Home. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 26 +-- crates/ui-egui/src/present.rs | 38 +++- crates/ui-egui/src/present_tab_tests.rs | 146 ++++++++++++++++ crates/ui-egui/src/present_view_tests.rs | 4 +- crates/ui-egui/src/tabs.rs | 210 +++++++++++++++++------ docs/prototyping-parity.md | 4 +- 6 files changed, 352 insertions(+), 76 deletions(-) create mode 100644 crates/ui-egui/src/present_tab_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fe766c36..4561e5a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -181,6 +181,8 @@ mod popup_width_tests; #[cfg(test)] mod present_links_tests; #[cfg(test)] +mod present_tab_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; @@ -538,10 +540,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, - /// A presentation kept alive while its design tab or Home is in front. - pub paused_present: Option, - /// The file that owns the presentation tab. - pub present_uid: Option, + pub presents: Vec, + /// The tab shown last. It is on screen while `present` has a value. + pub present_active: Option, + pub present_next: u64, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -692,8 +694,9 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, - paused_present: None, - present_uid: None, + presents: Vec::new(), + present_active: None, + present_next: 1, present_view: present::PresentView::default(), preview: None, picker: None, @@ -1015,12 +1018,7 @@ impl FigApp { "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - let mut p = present::Presenter::new(start); - p.view = self.ui.present_view; - self.ui.present = Some(p); - self.ui.paused_present = None; - self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); - self.ui.home = false; + tabs::open_present(self, start); } } "present.options" => { @@ -1563,6 +1561,7 @@ impl FigApp { // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; self.end_pen_outside_design(); + tabs::relink_presents(self); tabs::tab_bar(self, ui); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved @@ -1749,7 +1748,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), + "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, @@ -1803,6 +1802,7 @@ impl FigApp { state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); + state["presentTabs"] = tabs::present_tabs_inspect(self); state } } diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..a579d376 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -330,6 +330,10 @@ impl Presenter { true } + pub fn start(&self) -> NodeId { + self.start + } + pub fn new(start: NodeId) -> Self { Self { current: start, @@ -1642,12 +1646,26 @@ enum BarAction { Inspect, } +pub struct PresentTab { + pub id: u64, + /// Empty while the tab is on screen: then the player is in `app.ui.present`. + pub pres: Option, + pub doc_uid: u64, + pub path: Option, + pub page: NodeId, + pub file: String, + pub page_name: String, +} + /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. pub fn show(app: &mut FigApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; - if let Ok(ds) = app.session.ds() { + // A tab can play a file that is closed or not in front. Then it must not read or change + // the file in front. + let live = crate::tabs::present_owner_active(app); + if live && let Ok(ds) = app.session.ds() { pres.sync(&ds.doc, now); } poll_pads(app, &mut pres, &ctx); @@ -1684,7 +1702,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, _ => {} } - if pres.view.comments + if live + && pres.view.comments && let Some((frame, place, clip)) = pres.shown { crate::comments::presenter(app, &ctx, frame, place, clip); @@ -1694,17 +1713,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { finish(app, &mut pres); // The options carry over to the next presentation. app.ui.present_view = pres.view; - if let Some(BarAction::Responsive(on)) = action { + if live && let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } - if let Some(device) = picked_device { + if live && let Some(device) = picked_device { choose_device(app, &mut pres, &device); } - if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.present_uid = None; - return; - } app.ui.present = Some(pres); + if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(id) = app.ui.present_active { + crate::tabs::close_present(app, id); + } else { + app.ui.present = None; + } + } } /// Device choices in the presenter use the screen's real dimensions and reflow a copy of the diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs new file mode 100644 index 00000000..58f3c980 --- /dev/null +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -0,0 +1,146 @@ +use egui::{Key, Pos2, pos2}; +use serde_json::{Value, json}; + +use crate::present_tests::{Ui, id, setup}; + +const FILE_TAB: Pos2 = pos2(100.0, 20.0); + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); + u.frame(vec![]); +} + +fn tabs(u: &Ui) -> Value { + u.app.inspect()["presentTabs"].clone() +} + +fn presenting(u: &Ui) -> bool { + u.app.inspect()["presenting"] == json!(true) +} + +/// The middle of presentation tab `k` and of its close button, after `files` file tabs named +/// "Untitled". +fn present_tab(u: &Ui, files: usize, k: usize) -> (Pos2, Pos2) { + let width = |chars: usize, lo: f32, hi: f32| (12.0 * 0.6 * chars as f32 + 64.0).clamp(lo, hi); + let mut x = 52.0 + files as f32 * width("Untitled".len(), 120.0, 220.0); + let names: Vec = tabs(u).as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + for n in names.iter().take(k) { + x += width(n.chars().count(), 140.0, 260.0); + } + let w = width(names[k].chars().count(), 140.0, 260.0); + (pos2(x + w / 2.0, 20.0), pos2(x + w - 16.0, 20.0)) +} + +#[test] +fn the_tab_is_named_after_the_file_and_page_and_its_x_closes_it() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + assert!(presenting(&u)); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + click(&mut u, FILE_TAB); + assert!(!presenting(&u)); + assert_eq!(tabs(&u)[0]["active"], false); + let (tab, close) = present_tab(&u, 1, 0); + click(&mut u, tab); + assert!(presenting(&u)); + click(&mut u, close); + assert_eq!((presenting(&u), tabs(&u)), (false, json!([]))); + assert!(!u.app.ui.home, "back to its file"); +} + +#[test] +fn play_again_shows_the_tab_as_it_was_and_another_page_opens_a_new_one() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + click(&mut u, FILE_TAB); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1, "same page: the same tab"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "it shows the prototype as it was"); + u.run("proto.present", json!({"start": b})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1); + assert_eq!(u.app.inspect()["presenter"]["history"], json!([]), "another start frame starts again"); + + click(&mut u, FILE_TAB); + let page = id(&u.run("page.add", json!({"name": "Second"}))); + u.run("page.select", json!({"id": page})); + let c = id(&u.run("node.create", json!({"type": "frame", "name": "C", "x": 0, "y": 0, "width": 200, "height": 200}))); + u.run("proto.present", json!({"start": c})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}, {"name": "Untitled - Second", "active": true}])); + let (first, _) = present_tab(&u, 1, 0); + click(&mut u, first); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}, {"name": "Untitled - Second", "active": false}])); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b)); +} + +#[test] +fn the_presentation_plays_on_when_its_file_closes() { + let (mut u, a, b, _) = setup(); + let hot = id(&u.run("node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 0, "width": 393, "height": 80}))); + u.run("proto.interaction.add", json!({"id": hot, "dest": b})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0, "force": true})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}])); + let (tab, _) = present_tab(&u, 0, 0); + click(&mut u, tab); + assert!(presenting(&u)); + let spot = u.app.inspect()["presenter"]["hotspots"][0]["rect"].clone(); + let r: Vec = spot.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + click(&mut u, pos2((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)); + u.frames(60, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the prototype still plays"); + u.key(pos2(700.0, 500.0), Key::Escape); + assert_eq!(tabs(&u), json!([])); + assert!(u.app.ui.home, "no file to go back to: Home"); +} + +#[test] +fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.new", json!({})); + u.run("file.close", json!({"index": 0, "force": true})); + let (tab, _) = present_tab(&u, 1, 0); + click(&mut u, tab); + u.frames(5, Vec::new); + assert!(presenting(&u)); + assert!(!crate::tabs::present_owner_active(&u.app)); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "it keeps playing its own copy"); + assert!(!u.app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn a_saved_file_opened_again_gets_its_tab_back() { + let (mut u, a, ..) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-present-tab-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Proto.figcraft").to_string_lossy().to_string(); + u.run("file.saveAs", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0})); + u.run("file.open", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + u.key(pos2(700.0, 500.0), Key::Escape); + assert!(!u.app.ui.home, "back to its file"); + assert_eq!(tabs(&u), json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn renaming_the_file_or_the_page_renames_the_tab() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.rename", json!({"name": "Playground"})); + u.run("page.rename", json!({"name": "Calendar"})); + u.frame(vec![]); + assert_eq!(tabs(&u), json!([{"name": "Playground - Calendar", "active": false}])); + u.run("file.close", json!({"index": 0, "force": true})); + u.frame(vec![]); + assert_eq!(tabs(&u)[0]["name"], "Playground - Calendar", "the names stay when the file closes"); +} diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 8fdd5eb0..42647dc0 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -106,14 +106,14 @@ fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { u.button(design_tab, true); u.button(design_tab, false); assert!(u.app.ui.present.is_none()); - assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + assert_eq!(presenter(&u)["current"], json!(b)); let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); u.button(preview_tab, true); u.button(preview_tab, false); - assert!(u.app.ui.paused_present.is_none()); + assert!(u.app.ui.present.is_some()); assert_eq!(presenter(&u)["current"], json!(b)); } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index c10edcfd..94cfe19b 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -3,11 +3,13 @@ //! window buttons; the window's edges resize it. use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use figcraft_doc::NodeId; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon}; +use crate::present::{PresentTab, Presenter}; use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; @@ -113,9 +115,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); if hresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.ui.home = true; } x += 44.0; @@ -141,7 +141,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }) .collect(); // Leave the new-tab button, theme toggle, window controls and a draggable gap free. - let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; + let present_w: f32 = app.ui.presents.iter().map(present_tab_width).sum(); let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); let active_tab = document_tabs.iter().position(|&i| i == app.session.active); let layout = tab_layout(&preferred, available, active_tab); @@ -282,37 +282,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); x += OVERFLOW_WIDTH; } - // Presentations live in their own tab. Switching back to the design keeps the player + // Presentations live in their own tabs. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. - if app.ui.present.is_some() || app.ui.paused_present.is_some() { - let w = 128.0; - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); - let close_r = Rect::from_min_max(body_r.right_top(), r.max); - let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); - let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); - let active = app.ui.present.is_some(); - crate::a11y::tab(&resp, tr("Present"), active); - if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() || close_resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + let mut to_present = None; + let mut close_present_tab = None; + for tab in &app.ui.presents { + let active = app.ui.present.is_some() && app.ui.present_active == Some(tab.id); + let (w, clicked, closed) = present_tab(ui, &t, tab, x, rect, active); + if clicked && !active { + to_present = Some(tab.id); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); - ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); - if resp.hovered() || close_resp.hovered() || active { - if close_resp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - } - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); + if closed { + close_present_tab = Some(tab.id); } x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); @@ -325,9 +306,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); if nresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("file.new", json!({})); app.ui.home = false; } @@ -355,12 +334,15 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } } if let Some(i) = switch { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("doc.switch", json!({"index": i})); app.ui.home = false; } + if let Some(id) = close_present_tab { + close_present(app, id); + } else if let Some(id) = to_present { + show_present(app, id); + } if let Some((i, at)) = detach { // Where the pointer let go, on the screen. let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0))); @@ -388,11 +370,6 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } else { app.run("file.close", json!({"index": i})); } - if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } } if !opening.is_empty() { ui.ctx().request_repaint(); @@ -400,16 +377,145 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } -fn resume_presentation(app: &mut FigApp) { - let Some(uid) = app.ui.present_uid else { return }; - let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else { - app.ui.paused_present = None; - app.ui.present_uid = None; +fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, x: f32, bar: Rect, active: bool) -> (f32, bool, bool) { + let label = present_tab_name(tab); + let w = present_tab_width(tab); + let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, HEIGHT)); + let resp = ui.interact(r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + crate::a11y::tab(&resp, &label, active); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + crate::bidi::paint_name(ui.painter(), pos2(x + 34.0, r.center().y), &label, regular(12.0), t.text, w - 62.0); + let mut closed = false; + if resp.hovered() || active { + let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); + let cresp = ui.interact(cr, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + closed = cresp.clicked(); + } + (w, resp.clicked() && !closed, closed || resp.middle_clicked()) +} + +fn present_tab_width(tab: &PresentTab) -> f32 { + (regular(12.0).size * 0.6 * present_tab_name(tab).chars().count() as f32 + 64.0).clamp(140.0, 260.0) +} + +fn present_tab_name(tab: &PresentTab) -> String { + if tab.page_name.is_empty() { tab.file.clone() } else { format!("{} - {}", tab.file, tab.page_name) } +} + +pub fn present_tabs_inspect(app: &FigApp) -> serde_json::Value { + let on_screen = |id| app.ui.present.is_some() && app.ui.present_active == Some(id); + app.ui.presents.iter().map(|t| json!({"name": present_tab_name(t), "active": on_screen(t.id)})).collect() +} + +pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { + app.ui.present.as_ref().or_else(|| { + let id = app.ui.present_active?; + app.ui.presents.iter().find(|t| t.id == id)?.pres.as_ref() + }) +} + +fn owner(app: &FigApp, uid: u64) -> Option { + app.session.docs.iter().position(|d| d.uid == uid && app.shows(uid)) +} + +pub fn present_owner_active(app: &FigApp) -> bool { + let Some(tab) = app.ui.present_active.and_then(|id| app.ui.presents.iter().find(|t| t.id == id)) else { return true }; + owner(app, tab.doc_uid).is_some_and(|i| i == app.session.active) +} + +fn pause_present(app: &mut FigApp) { + let Some(p) = app.ui.present.take() else { return }; + let id = app.ui.present_active; + if let Some(t) = app.ui.presents.iter_mut().find(|t| Some(t.id) == id) { + t.pres = Some(p); + } +} + +pub fn open_present(app: &mut FigApp, start: NodeId) { + let Ok(ds) = app.session.ds() else { return }; + let (uid, path) = (ds.uid, ds.path.clone()); + let page = ds.doc.page_of(start).unwrap_or(ds.page); + let file = ds.doc.name.clone(); + let page_name = ds.doc.node(page).map(|n| n.name.clone()).unwrap_or_default(); + let mut pres = Presenter::new(start); + pres.view = app.ui.present_view; + let same = |t: &&PresentTab| t.page == page && (t.doc_uid == uid || (path.is_some() && t.path == path)); + if let Some(id) = app.ui.presents.iter().find(same).map(|t| t.id) { + show_present(app, id); + if app.ui.present.as_ref().is_some_and(|p| p.start() != start) { + app.ui.present = Some(pres); + } return; - }; - app.run("doc.switch", json!({"index": index})); + } + pause_present(app); + let id = app.ui.present_next; + app.ui.present_next += 1; + app.ui.presents.push(PresentTab { id, pres: None, doc_uid: uid, path, page, file, page_name }); + app.ui.present = Some(pres); + app.ui.present_active = Some(id); app.ui.home = false; - app.ui.present = app.ui.paused_present.take(); +} + +fn show_present(app: &mut FigApp, id: u64) { + app.ui.home = false; + if app.ui.present.is_some() && app.ui.present_active == Some(id) { + return; + } + pause_present(app); + let Some(tab) = app.ui.presents.iter_mut().find(|t| t.id == id) else { return }; + let Some(p) = tab.pres.take() else { return }; + let uid = tab.doc_uid; + if let Some(i) = owner(app, uid).filter(|i| *i != app.session.active) { + app.run("doc.switch", json!({"index": i})); + } + app.ui.present = Some(p); + app.ui.present_active = Some(id); +} + +pub fn close_present(app: &mut FigApp, id: u64) { + let Some(k) = app.ui.presents.iter().position(|t| t.id == id) else { return }; + let tab = app.ui.presents.remove(k); + if app.ui.present_active != Some(id) { + return; + } + app.ui.present_active = None; + if app.ui.present.take().is_none() { + return; + } + match owner(app, tab.doc_uid) { + Some(i) => { + if i != app.session.active { + app.run("doc.switch", json!({"index": i})); + } + app.ui.home = false; + } + None => app.ui.home = true, + } +} + +/// A file that opens again has a new uid. Find it by its path to give its tabs back. +pub fn relink_presents(app: &mut FigApp) { + let docs = &app.session.docs; + for t in &mut app.ui.presents { + let found = docs.iter().find(|d| d.uid == t.doc_uid).or_else(|| docs.iter().find(|d| t.path.is_some() && d.path == t.path)); + if let Some(d) = found { + t.doc_uid = d.uid; + t.path = d.path.clone(); + t.file = d.doc.name.clone(); + if let Some(n) = d.doc.node(t.page) { + t.page_name = n.name.clone(); + } + } + } } /// What the tab of document `uid` says about its live session: others get in (`true`: a session diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index e4cf8c7f..c0a7c008 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-17 · **Change:** minor (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-17 · **Change:** minor (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, @@ -54,6 +54,7 @@ case pins the current behaviour). | Open link | pass | always a new tab or browser window (opening in place would leave the prototype) | | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` | | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` | +| Presentation tabs: one per page of a file, named "file - page" (renames follow). Play on a page that has a tab shows it as it was (from another start frame it starts again). Tabs keep their own copy of the prototype and play on after their file closes; a saved file opened again gets its tabs back. A tab never changes a file that isn't in front. Its X or Escape ends it and goes back to its file, or Home | **fixed** | one "Present" tab: a second presentation replaced the first, Play restarted it, and closing the file ended it; `present_tab_tests` | | Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead | | Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` | | Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` | @@ -100,6 +101,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 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. | | 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | From bbe691cb43f0739e60913214e87cac5609b82d96 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Sun, 18 Oct 2026 10:47:35 +0200 Subject: [PATCH 04/98] M5: presentation tabs: test both owner guards, document the tab functions Review follow-up. The closed-owner test now picks a device and switches Responsive in the presenter and checks that the file in front keeps its prototype settings, so removing either `live` guard in present::show fails it. The present harness gets frame_output for the AccessKit tree. The new tab functions get doc comments. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/present.rs | 1 + crates/ui-egui/src/present_tab_tests.rs | 46 +++++++++++++++++++++++++ crates/ui-egui/src/present_tests.rs | 9 ++++- crates/ui-egui/src/tabs.rs | 13 +++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index a579d376..184e7a9f 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -330,6 +330,7 @@ impl Presenter { true } + /// The frame the presentation started from (Restart goes back there). pub fn start(&self) -> NodeId { self.start } diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs index 58f3c980..610c2ab1 100644 --- a/crates/ui-egui/src/present_tab_tests.rs +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -1,4 +1,6 @@ +use egui::accesskit::{Node, Role}; use egui::{Key, Pos2, pos2}; +use figcraft_doc::proto::PrototypeSettings; use serde_json::{Value, json}; use crate::present_tests::{Ui, id, setup}; @@ -19,6 +21,41 @@ fn presenting(u: &Ui) -> bool { u.app.inspect()["presenting"] == json!(true) } +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click_label(u: &mut Ui, label: &str) { + let all = nodes(u); + let n = all.iter().find(|n| n.label().is_some_and(|l| l.trim() == label)).unwrap_or_else(|| panic!("no {label:?} on screen")); + click(u, centre(n)); +} + +/// The presenter bar's Options button: it has no label, and sits right of Show comments. +fn open_options(u: &mut Ui) { + let all = nodes(u); + let comments = all.iter().find(|n| n.label() == Some("Show comments (Shift+C)")).unwrap().bounds().unwrap(); + let options = all + .iter() + .filter(|n| n.role() == Role::Unknown && n.label().is_none()) + .filter(|n| n.bounds().is_some_and(|b| b.y0 == comments.y0 && b.x0 > comments.x1)) + .min_by(|a, b| a.bounds().unwrap().x0.total_cmp(&b.bounds().unwrap().x0)) + .expect("the Options button"); + click(u, centre(options)); +} + +fn front_settings(u: &Ui) -> PrototypeSettings { + let page = u.app.session.page().unwrap(); + crate::present::page_settings(u.app.session.doc().unwrap(), page) +} + /// The middle of presentation tab `k` and of its close button, after `files` file tabs named /// "Untitled". fn present_tab(u: &Ui, files: usize, k: usize) -> (Pos2, Pos2) { @@ -109,6 +146,15 @@ fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { assert!(presenting(&u)); assert!(!crate::tabs::present_owner_active(&u.app)); assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "it keeps playing its own copy"); + let before = front_settings(&u); + click_label(&mut u, "iPhone 16"); + click_label(&mut u, "iPhone 17 402×874"); + assert!(presenting(&u)); + for size in ["Fixed size", "Responsive"] { + open_options(&mut u); + click_label(&mut u, size); + } + assert_eq!(front_settings(&u), before, "picking a device or Responsive leaves the file in front alone"); assert!(!u.app.session.ds().unwrap().is_dirty()); } diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index a1e4ebe2..c1a28cd4 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -23,11 +23,18 @@ impl Ui { } pub(crate) fn frame(&mut self, events: Vec) { + self.frame_output(events); + } + + /// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled). + pub(crate) fn frame_output(&mut self, events: Vec) -> egui::FullOutput { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; - self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out } pub(crate) fn frames(&mut self, n: usize, events: impl Fn() -> Vec) { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 94cfe19b..ed1f692d 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -377,6 +377,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } +/// One presentation tab at `x`: its width, whether it was clicked and whether it was closed (its +/// X or a middle click). fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, x: f32, bar: Rect, active: bool) -> (f32, bool, bool) { let label = present_tab_name(tab); let w = present_tab_width(tab); @@ -407,15 +409,18 @@ fn present_tab_width(tab: &PresentTab) -> f32 { (regular(12.0).size * 0.6 * present_tab_name(tab).chars().count() as f32 + 64.0).clamp(140.0, 260.0) } +/// "file - page", or the file alone when the page has no name. fn present_tab_name(tab: &PresentTab) -> String { if tab.page_name.is_empty() { tab.file.clone() } else { format!("{} - {}", tab.file, tab.page_name) } } +/// The presentation tabs for `ui.inspect`: each one's name and whether it is on screen. pub fn present_tabs_inspect(app: &FigApp) -> serde_json::Value { let on_screen = |id| app.ui.present.is_some() && app.ui.present_active == Some(id); app.ui.presents.iter().map(|t| json!({"name": present_tab_name(t), "active": on_screen(t.id)})).collect() } +/// The presenter on screen, else the paused one of the active presentation tab. pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { app.ui.present.as_ref().or_else(|| { let id = app.ui.present_active?; @@ -423,15 +428,19 @@ pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { }) } +/// The index of the open file with `uid`, when a file tab shows it. fn owner(app: &FigApp, uid: u64) -> Option { app.session.docs.iter().position(|d| d.uid == uid && app.shows(uid)) } +/// The presentation on screen plays the file in front (or no tab holds it). Only then may it read +/// and change that file. pub fn present_owner_active(app: &FigApp) -> bool { let Some(tab) = app.ui.present_active.and_then(|id| app.ui.presents.iter().find(|t| t.id == id)) else { return true }; owner(app, tab.doc_uid).is_some_and(|i| i == app.session.active) } +/// Puts the presenter on screen back into its tab, where it keeps its state. fn pause_present(app: &mut FigApp) { let Some(p) = app.ui.present.take() else { return }; let id = app.ui.present_active; @@ -440,6 +449,8 @@ fn pause_present(app: &mut FigApp) { } } +/// Presents from `start`: the tab of its page in this file shows again (from `start` when that +/// changed), else a new tab opens. pub fn open_present(app: &mut FigApp, start: NodeId) { let Ok(ds) = app.session.ds() else { return }; let (uid, path) = (ds.uid, ds.path.clone()); @@ -465,6 +476,7 @@ pub fn open_present(app: &mut FigApp, start: NodeId) { app.ui.home = false; } +/// Brings tab `id` to the front and its file too, when that file is open. fn show_present(app: &mut FigApp, id: u64) { app.ui.home = false; if app.ui.present.is_some() && app.ui.present_active == Some(id) { @@ -481,6 +493,7 @@ fn show_present(app: &mut FigApp, id: u64) { app.ui.present_active = Some(id); } +/// Closes tab `id`. If it was on screen, its file comes to the front, or Home when it is closed. pub fn close_present(app: &mut FigApp, id: u64) { let Some(k) = app.ui.presents.iter().position(|t| t.id == id) else { return }; let tab = app.ui.presents.remove(k); From 4a73289a91f3f8b9b88c018d8f7837087188fd23 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Mon, 19 Oct 2026 22:34:27 +0200 Subject: [PATCH 05/98] M5: presentation tabs: keep them off the window controls, close X on every frame Presentation tabs now take at most half the free strip while file tabs share it. They shrink to the minimum tab width first, then the rest go into an overflow menu, so they never reach the new-tab button, theme toggle or window buttons. The close X has its own hit rect next to the tab body and is registered every frame, so a click on it can no longer select the tab instead. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/tabs.rs | 89 ++++++++++++++++++++++------- 17 files changed, 84 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index eea0e8dc..4a4eda3c 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -898,6 +898,7 @@ status = "draft — needs native review" "Open branch" = "Otevřít větev" "Open file" = "Otevřít soubor" "Open files" = "Otevřít soubory" +"Open presentations" = "Otevřené prezentace" "Open in focus view" = "Otevřít v zaměřeném pohledu" "Open in new window" = "Otevřít v novém okně" "Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..e752b70a 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -866,6 +866,7 @@ status = "draft — needs native review" "Open branch" = "Branch öffnen" "Open file" = "Datei öffnen" "Open files" = "Geöffnete Dateien" +"Open presentations" = "Geöffnete Präsentationen" "Open in focus view" = "In Fokusansicht öffnen" "Open in new window" = "In neuem Fenster öffnen" "Open invite links in FigCraft" = "Einladungslinks in FigCraft öffnen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ccf833be..0d849918 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -920,6 +920,7 @@ name = "English" "Open branch" = "Open branch" "Open file" = "Open file" "Open files" = "Open files" +"Open presentations" = "Open presentations" "Open in focus view" = "Open in focus view" "Open in new window" = "Open in new window" "Open invite links in FigCraft" = "Open invite links in FigCraft" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1187c658..598a152c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -921,6 +921,7 @@ status = "draft — needs native review" "Open branch" = "Abrir rama" "Open file" = "Abrir archivo" "Open files" = "Archivos abiertos" +"Open presentations" = "Presentaciones abiertas" "Open in focus view" = "Abrir en vista de enfoque" "Open in new window" = "Abrir en una ventana nueva" "Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..5ff69193 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -918,6 +918,7 @@ status = "draft — needs native review" "Open branch" = "Avaa haara" "Open file" = "Avaa tiedosto" "Open files" = "Avaa tiedostoja" +"Open presentations" = "Avoimet esitykset" "Open in focus view" = "Avaa kohdistusnäkymässä" "Open in new window" = "Avaa uudessa ikkunassa" "Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..4445c248 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -914,6 +914,7 @@ status = "draft — needs native review" "Open branch" = "Ouvrir la branche" "Open file" = "Ouvrir un fichier" "Open files" = "Fichiers ouverts" +"Open presentations" = "Présentations ouvertes" "Open in focus view" = "Ouvrir en vue ciblée" "Open in new window" = "Ouvrir dans une nouvelle fenêtre" "Open invite links in FigCraft" = "Ouvrir les liens d’invitation dans FigCraft" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index f21e1ca3..d6e8b84a 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -902,6 +902,7 @@ status = "draft — needs native review" "Open branch" = "Buka branch" "Open file" = "Buka file" "Open files" = "Buka file" +"Open presentations" = "Presentasi yang terbuka" "Open in focus view" = "Buka di tampilan fokus" "Open in new window" = "Buka di jendela baru" "Open invite links in FigCraft" = "Buka link undangan di FigCraft" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index c42790f3..d3888548 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -907,6 +907,7 @@ status = "draft — needs native review" "Open branch" = "ブランチを開く" "Open file" = "ファイルを開く" "Open files" = "開いているファイル" +"Open presentations" = "開いているプレゼンテーション" "Open in focus view" = "フォーカスビューで開く" "Open in new window" = "新しいウィンドウで開く" "Open invite links in FigCraft" = "招待リンクを FigCraft で開く" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b33b36b9..af8d9364 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -862,6 +862,7 @@ status = "draft — needs native review" "Open branch" = "브랜치 열기" "Open file" = "파일 열기" "Open files" = "열린 파일" +"Open presentations" = "열린 프레젠테이션" "Open in focus view" = "포커스 보기로 열기" "Open in new window" = "새 창에서 열기" "Open invite links in FigCraft" = "초대 링크를 FigCraft에서 열기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..75cc2a24 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -918,6 +918,7 @@ status = "draft — needs native review" "Open branch" = "Branch openen" "Open file" = "Bestand openen" "Open files" = "Geopende bestanden" +"Open presentations" = "Geopende presentaties" "Open in focus view" = "Openen in focusweergave" "Open in new window" = "Openen in een nieuw venster" "Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..8051ee93 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -915,6 +915,7 @@ status = "draft — needs native review" "Open branch" = "Otwórz gałąź" "Open file" = "Otwórz plik" "Open files" = "Otwórz pliki" +"Open presentations" = "Otwarte prezentacje" "Open in focus view" = "Otwórz w widoku skupienia" "Open in new window" = "Otwórz w nowym oknie" "Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index b41d827a..d26f7926 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -861,6 +861,7 @@ status = "draft — needs native review" "Open branch" = "Abrir branch" "Open file" = "Abrir arquivo" "Open files" = "Arquivos abertos" +"Open presentations" = "Apresentações abertas" "Open in focus view" = "Abrir na visualização em foco" "Open in new window" = "Abrir em nova janela" "Open invite links in FigCraft" = "Abrir links de convite no FigCraft" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6dd68a17..2f9f5773 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -901,6 +901,7 @@ name = "Русский" "Open branch" = "Открыть ветку" "Open file" = "Открыть файл" "Open files" = "Открыть файлы" +"Open presentations" = "Открытые презентации" "Open in focus view" = "Открыть в режиме фокуса" "Open in new window" = "Открыть в новом окне" "Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..07602311 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -909,6 +909,7 @@ name = "Svenska" "Open branch" = "Öppna gren" "Open file" = "Öppna fil" "Open files" = "Öppna filer" +"Open presentations" = "Öppna presentationer" "Open in focus view" = "Öppna i fokusvy" "Open in new window" = "Öppna i nytt fönster" "Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index bd8bb7c9..fe45eaee 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -902,6 +902,7 @@ name = "Türkçe" "Open branch" = "Dalı aç" "Open file" = "Dosya aç" "Open files" = "Dosyaları aç" +"Open presentations" = "Açık sunumlar" "Open in focus view" = "Odak görünümünde aç" "Open in new window" = "Yeni pencerede aç" "Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..dd5f0d00 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -863,6 +863,7 @@ status = "draft — needs native review" "Open branch" = "打开分支" "Open file" = "打开文件" "Open files" = "打开的文件" +"Open presentations" = "打开的演示" "Open in focus view" = "在聚焦视图中打开" "Open in new window" = "在新窗口中打开" "Open invite links in FigCraft" = "在 FigCraft 中打开邀请链接" diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index ed1f692d..adb1d08d 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -141,8 +141,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }) .collect(); // Leave the new-tab button, theme toggle, window controls and a draggable gap free. - let present_w: f32 = app.ui.presents.iter().map(present_tab_width).sum(); - let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0); + let present_pref: Vec = app.ui.presents.iter().map(present_tab_width).collect(); + let present_on = app.ui.present.as_ref().and(app.ui.present_active); + let active_present = present_on.and_then(|id| app.ui.presents.iter().position(|p| p.id == id)); + let present_layout = tab_layout(&present_pref, present_room(room, &present_pref, !document_tabs.is_empty()), active_present); + let present_used = present_layout.widths.iter().sum::() + if present_layout.overflow { OVERFLOW_WIDTH } else { 0.0 }; + let available = (room - present_used).max(0.0); let active_tab = document_tabs.iter().position(|&i| i == app.session.active); let layout = tab_layout(&preferred, available, active_tab); let mut tab_rects = Vec::with_capacity(layout.widths.len()); @@ -286,9 +291,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { // state (current screen, navigation history and scroll position) for the return trip. let mut to_present = None; let mut close_present_tab = None; - for tab in &app.ui.presents { - let active = app.ui.present.is_some() && app.ui.present_active == Some(tab.id); - let (w, clicked, closed) = present_tab(ui, &t, tab, x, rect, active); + for (tab, &w) in app.ui.presents.iter().skip(present_layout.first).zip(&present_layout.widths) { + let active = present_on == Some(tab.id); + let (clicked, closed) = present_tab(ui, &t, tab, Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)), active); if clicked && !active { to_present = Some(tab.id); } @@ -298,6 +303,29 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + if present_layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("present_overflow"), Sense::click()).tip(tr("Open presentations")); + crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + crate::right::menu_popup(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for tab in &app.ui.presents { + let on = present_on == Some(tab.id); + if crate::right::menu_check(ui, on, &present_tab_name(tab), "") { + if !on { + to_present = Some(tab.id); + } + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new")); @@ -377,32 +405,38 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } -/// One presentation tab at `x`: its width, whether it was clicked and whether it was closed (its -/// X or a middle click). -fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, x: f32, bar: Rect, active: bool) -> (f32, bool, bool) { +/// One presentation tab in `r`: whether it was clicked and whether it was closed (its X or a +/// middle click). +fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, r: Rect, active: bool) -> (bool, bool) { let label = present_tab_name(tab); - let w = present_tab_width(tab); - let r = Rect::from_min_size(pos2(x, bar.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + let cresp = ui.interact(close_r, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); crate::a11y::tab(&resp, &label, active); + let hovered = resp.hovered() || cresp.hovered(); if active { ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { + } else if hovered { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); - crate::bidi::paint_name(ui.painter(), pos2(x + 34.0, r.center().y), &label, regular(12.0), t.text, w - 62.0); - let mut closed = false; - if resp.hovered() || active { - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - let cresp = ui.interact(cr, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 34.0, r.center().y), &label, regular(12.0), t.text, r.width() - 62.0); + if hovered || active { + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); if cresp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); } icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - closed = cresp.clicked(); } - (w, resp.clicked() && !closed, closed || resp.middle_clicked()) + let closed = cresp.clicked() || resp.middle_clicked() || cresp.middle_clicked(); + (resp.clicked(), closed) +} + +/// The part of the strip's free `room` the presentation tabs may use: all they want, but at most +/// half of it while file tabs share the strip, so they shrink or overflow before covering controls. +fn present_room(room: f32, preferred: &[f32], files: bool) -> f32 { + preferred.iter().sum::().min(if files { room / 2.0 } else { room }) } fn present_tab_width(tab: &PresentTab) -> f32 { @@ -612,9 +646,22 @@ pub fn resize_edges(ctx: &egui::Context) { #[cfg(test)] mod layout_tests { - use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, present_room, tab_drop_position, tab_layout}; use egui::{Rect, pos2}; + #[test] + fn many_presentation_tabs_stay_inside_their_share_of_the_strip() { + let preferred = vec![260.0; 8]; + let room = 900.0; + let layout = tab_layout(&preferred, present_room(room, &preferred, true), Some(7)); + assert!(layout.overflow); + assert!(layout.first <= 7 && 7 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= room / 2.0 + 0.01); + assert_eq!(present_room(room, &preferred, false), room); + assert_eq!(present_room(room, &[200.0], true), 200.0); + } + #[test] fn tabs_shrink_to_fit_before_overflowing() { let preferred = vec![120.0; 10]; From 414aa1b63c1c8a89db256e2b213dc142fd57905a Mon Sep 17 00:00:00 2001 From: xiaou61 <113765024+xiaou61@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:04:13 +0800 Subject: [PATCH 06/98] M1: write grid track sizes in .fig export (#465) The import has read `gridColumnsSizing` / `gridRowsSizing` since #630, but the export wrote only equal tracks and warned about it, so a grid with a fixed column beside two weighted ones came back equal after a save and reload. Write the sizing map next to the order map, keyed by the same track ids: fixed px, fractional fill and content-sized tracks keep their sizes. A track the format has no name for (negative or out-of-range size, or a document track the frame doesn't list) keeps its id and reads back equal, with the existing warning. Co-authored-by: xiaou61 <113765024+xiaou61@users.noreply.github.com> --- crates/format/src/fig/export.rs | 50 +++++++++++++++++++++++---- crates/format/src/fig/export_tests.rs | 39 +++++++++++++++++++++ crates/format/src/fig/map.rs | 2 +- docs/file-format-parity.md | 9 ++--- 4 files changed, 89 insertions(+), 11 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 0cad2122..190f4055 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -25,10 +25,10 @@ use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ - AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, - LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, - WindingRule, + AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, + LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, + VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; @@ -168,7 +168,12 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3 +message GridTrackSizeFunction GridTrackSizingType type=1, float value=2 +message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2 +message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2 +message GridTrackSizeMap GridTrackSizeEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -277,6 +282,20 @@ fn s(x: &str) -> Kv<'static> { fn b(x: bool) -> Kv<'static> { Kv::Bool(x) } + +/// A track's size as the format's enum name and value, when both sizing functions agree (the +/// importer keeps only those). `None`: the format has no name for it, so an equal track stands in. +fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> { + let value = track.value; + let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value); + match track.sizing { + Sizing::Fixed if fits => Some(("FIXED", value)), + Sizing::Fill if fits && value > 0.0 => Some(("FLEX", value)), + Sizing::Hug => Some(("HUG", 0.0)), + _ => None, + } +} + fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } @@ -1415,9 +1434,28 @@ impl<'d> Exporter<'d> { )]) }; f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { + // Sizing is keyed by track id like the order map. A track the document doesn't set is + // left out, and one the format can't hold keeps its id (the import equalises it). + let sizing = |tracks: &[GridTrack], ids: &[u32]| -> Option> { + let mut entries = Vec::new(); + for (i, id) in ids.iter().enumerate() { + let Some(track) = tracks.get(i) else { continue }; + if let Some((kind, value)) = grid_track_size(track) { + let size = || o(vec![("type", e(kind)), ("value", fl(value))]); + entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + } + } + (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) + }; + let (column_sizing, row_sizing) = (sizing(&l.column_tracks, &cols), sizing(&l.row_tracks, &rows)); + if l.column_tracks.iter().chain(&l.row_tracks).any(|t| grid_track_size(t).is_none()) { self.warn("grid tracks of fixed or content size were written as equal tracks"); } + for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { + if let Some(v) = sizing { + f.push((name, v)); + } + } self.tracks.insert(key, (cols, rows)); } else { let spread = l.content_align == ContentAlign::SpaceBetween; diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..adb7be6d 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1022,6 +1022,45 @@ fn junk_documents_never_panic() { } } +/// Grid track sizes reach the file and come back: fixed px, fractional fill and hug tracks, and a +/// track the format has no name for leaves an equal track behind instead of failing the write. +#[test] +fn grid_track_sizes_round_trip() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let grid = doc.create(page, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + { + let node = doc.node_mut(grid).unwrap(); + node.layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 3, + grid_rows: 2, + column_tracks: vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()], + row_tracks: vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)], + ..AutoLayout::default() + }); + } + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(iw.is_empty(), "{iw:?}"); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks, vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]); + assert_eq!(l.row_tracks, vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)]); + + // A negative fixed track is no size the format can name: the write still succeeds, the track + // falls back to an equal one, and the warning says so. + let mut odd = doc.clone(); + if let Some(l) = odd.node_mut(grid).and_then(|n| n.layout.as_mut()) { + l.column_tracks[0] = GridTrack::fixed(-5.0); + } + let (bytes, warnings) = export(&odd, None).unwrap(); + assert_eq!(warnings, vec!["grid tracks of fixed or content size were written as equal tracks".to_string()]); + let (back, _) = import(&bytes, "File").unwrap(); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal"); +} + /// A frame's own min/max size survives whatever its parent is, not only under auto layout. #[test] fn frame_size_limits_survive_without_auto_layout_parent() { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..ec0dd8e4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -24,7 +24,7 @@ const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; const MAX_GRID_TRACKS: usize = 1_000; -const MAX_GRID_TRACK_VALUE: f64 = 1e6; +pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index adf0b0f2..6b020d72 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -122,10 +122,10 @@ each. image adjustments, glass settings other than frost, springs other than Gentle (written as ease out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation, - leading trim, OpenType features, per-layer operations inside boolean groups, grid tracks of fixed - or content size, different stroke ends on lines. Not written at all: stroke outlines of dashed and - per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines - of text (a reader lays text out with its own fonts), version history and branches. + leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends + on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or + outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out + with its own fonts), version history and branches. - **Checked by opening written files in other software:** the example documents and a small test file open there, and layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. A file saved again there and read back keeps the layer tree and properties; two @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 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 sizing the format has no name for (a negative or out-of-range size, a constrained min/max) keep their id and read back equal, with the existing warning. Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From ea2fa7c367731823a404d3cd37fcd2b5ea9ead90 Mon Sep 17 00:00:00 2001 From: xiaou61 <113765024+xiaou61@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:08:21 +0800 Subject: [PATCH 07/98] M1: say that the automation roots reach only the headless side (#835) The CLI help and docs/mcp.md described the roots as confining whatever the run touches, which reads as if they also applied to `mcp --attach`. They don't: an attached server runs the commands inside the app, and the app confines them from its own flags. VectorCraft documents the same split, and the missing sentence is what made the difference confusing, as #835 reports. Co-authored-by: xiaou61 <113765024+xiaou61@users.noreply.github.com> --- apps/figcraft-cli/src/main.rs | 9 +++++---- docs/cli.md | 4 +++- docs/mcp.md | 4 ++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..7c5fe485 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi `--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders must exist) confine the files a subcommand touches: reads only inside a read or write root, writes only inside a write root, everything else refused -- so giving no write root denies writing -altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, -`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes -as the user who started it, as before. A running app confines itself: start it with -`figcraft --control PORT` and the same flags (`--attach` refuses them). +altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands, +and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a +subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is +confined by the app it attaches to, not by flags passed to the CLI: start that app with +`figcraft --control PORT` and the same roots (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; /// The automation roots a file subcommand was given, if any. Omitting both leaves the run diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..22ee9fa8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -186,7 +186,9 @@ Serve MCP on stdio: headless by default, or attached to a running app started wi `figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing folders) confine the files the agent's calls read (inside a read or write root) and write (inside a write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)). -With `--attach` they are refused: start the app with them instead. The headless session can host, join and publish +They apply to the headless session only, and that is why `--attach` refuses them: an attached server +is confined by the app it attaches to, from the flags that app was started with. The headless +session can host, join and publish live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`, `collab.manageFile` for the owner token and `collab.report`) and use plugin directories (`plugin.directory.*`); the one-shot `run` can't (no network link). diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..30882b41 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -34,6 +34,10 @@ of the window itself (`screenshot` with `window`) needs it on screen: it is then ### Confining file access +The roots below belong to the **headless** side: the session `figcraft-cli mcp` runs itself, with +no window. A server attached with `--attach` runs the commands inside the app instead, so the app +confines them — from the flags it was started with, not from anything passed to the CLI. + ```sh figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out ``` From beb87d86965dfc507050bab2cd6c9349d18ab4d8 Mon Sep 17 00:00:00 2001 From: xiaou61 <113765024+xiaou61@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:14:02 +0800 Subject: [PATCH 08/98] M1: describe the fallback the export actually takes (#465) The warning still said fixed and content-sized tracks were written as equal tracks, which the export no longer does. It now reports only the tracks whose size the format can't hold, and only for the tracks an exported grid has. Co-authored-by: xiaou61 <113765024+xiaou61@users.noreply.github.com> --- crates/format/src/fig/export.rs | 19 ++++++++++++------- crates/format/src/fig/export_tests.rs | 2 +- docs/file-format-parity.md | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 190f4055..2d02539e 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1436,20 +1436,25 @@ impl<'d> Exporter<'d> { f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); // Sizing is keyed by track id like the order map. A track the document doesn't set is // left out, and one the format can't hold keeps its id (the import equalises it). - let sizing = |tracks: &[GridTrack], ids: &[u32]| -> Option> { + let mut dropped = false; + let sizing = |tracks: &[GridTrack], ids: &[u32], dropped: &mut bool| -> Option> { let mut entries = Vec::new(); for (i, id) in ids.iter().enumerate() { let Some(track) = tracks.get(i) else { continue }; - if let Some((kind, value)) = grid_track_size(track) { - let size = || o(vec![("type", e(kind)), ("value", fl(value))]); - entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + match grid_track_size(track) { + Some((kind, value)) => { + let size = || o(vec![("type", e(kind)), ("value", fl(value))]); + entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + } + None => *dropped = true, } } (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) }; - let (column_sizing, row_sizing) = (sizing(&l.column_tracks, &cols), sizing(&l.row_tracks, &rows)); - if l.column_tracks.iter().chain(&l.row_tracks).any(|t| grid_track_size(t).is_none()) { - self.warn("grid tracks of fixed or content size were written as equal tracks"); + let column_sizing = sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = sizing(&l.row_tracks, &rows, &mut dropped); + if dropped { + self.warn("grid tracks whose size the format can't hold were written as equal tracks"); } for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { if let Some(v) = sizing { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index adb7be6d..cc47371f 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1055,7 +1055,7 @@ fn grid_track_sizes_round_trip() { l.column_tracks[0] = GridTrack::fixed(-5.0); } let (bytes, warnings) = export(&odd, None).unwrap(); - assert_eq!(warnings, vec!["grid tracks of fixed or content size were written as equal tracks".to_string()]); + assert_eq!(warnings, vec!["grid tracks whose size the format can't hold were written as equal tracks".to_string()]); let (back, _) = import(&bytes, "File").unwrap(); let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6b020d72..a745d8c3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -165,7 +165,7 @@ each. | Date | Change | Summary | |---|---|---| -| 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 sizing the format has no name for (a negative or out-of-range size, a constrained min/max) keep their id and read back equal, with the existing warning. 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 | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From b788b55286accda85f72bb2e846f9db7ba81b88d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:43:51 +0200 Subject: [PATCH 09/98] Keep a solid paint's own opacity when binding a colour variable Fixes #923 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/color/src/lib.rs | 26 ++++++++++++++++++++---- crates/engine/src/binding_lists_tests.rs | 2 +- crates/engine/src/bound_alpha_tests.rs | 22 ++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 2 ++ crates/engine/src/cmd/styles.rs | 16 +++++++++++---- crates/format/src/fig/assets_tests.rs | 5 ++++- crates/format/src/fig/export_tests.rs | 2 +- crates/format/src/rest/props.rs | 2 +- 8 files changed, 65 insertions(+), 12 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 8e9f98d9..d7ff9089 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -537,7 +537,7 @@ pub enum PaintKind { } /// One fill or stroke paint. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Paint { #[serde(flatten)] pub kind: PaintKind, @@ -547,6 +547,17 @@ pub struct Paint { pub visible: bool, #[serde(default)] pub blend: BlendMode, + /// The opacity holds the alpha of a typed solid colour (see [`Paint::solid`]) rather than an + /// opacity set on its own: a colour variable bound to the paint replaces it. + #[serde(default, rename = "colorAlpha", skip_serializing_if = "std::ops::Not::not")] + pub color_alpha: bool, +} + +/// Paints compare by what they paint; where the opacity came from doesn't matter. +impl PartialEq for Paint { + fn eq(&self, other: &Self) -> bool { + self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend + } } fn yes() -> bool { @@ -555,11 +566,17 @@ fn yes() -> bool { impl Paint { pub fn solid(color: Color) -> Self { - Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal } + Self { + kind: PaintKind::Solid { color: color.with_alpha(1.0) }, + opacity: color.a, + visible: true, + blend: BlendMode::Normal, + color_alpha: color.a < 1.0, + } } pub fn gradient(g: Gradient) -> Self { - Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false } } pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { @@ -575,12 +592,13 @@ impl Paint { opacity: 1.0, visible: true, blend: BlendMode::Normal, + color_alpha: false, } } /// Layer `source` (a node id) tiled at its own size. pub fn pattern(source: u64) -> Self { - Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false } } /// The solid colour (with the paint opacity folded into alpha), if solid. diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 9db9975a..8b221efd 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); + assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs index b7210f0f..c54f0fb7 100644 --- a/crates/engine/src/bound_alpha_tests.rs +++ b/crates/engine/src/bound_alpha_tests.rs @@ -129,3 +129,25 @@ fn code_gives_a_translucent_stroke_its_alpha_once() { let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); } + +#[test] +fn binding_keeps_an_opacity_set_on_the_paint() { + // A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only. + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"})); + let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]}); + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"})); + let p = s.doc().unwrap().node(r).unwrap().fills[0].clone(); + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) }); + assert!(near(p.opacity, 0.15), "{}", p.opacity); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15)); + // An opacity set on a `#RRGGBBAA` fill is the user's too, and stays. + let (mut s, r) = setup("#FF0000", "#FF0000"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25})); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r)); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..88079c07 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1365,10 +1365,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; if c.a < 1.0 { slot.opacity = c.a; + slot.color_alpha = true; } } if let Some(o) = num(p, "opacity") { slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = false; } if let Some(v) = boolean(p, "visible") { slot.visible = v; diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index af029e5f..dd93bac3 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -805,9 +805,9 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { } /// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts -/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the -/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a -/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +/// an opacity that holds the alpha a typed colour had (see `Paint::solid`) at 1, so the variable's +/// alpha applies once; an opacity set on its own, before or while bound, multiplies on top and +/// stays. Unbinding moves the colour's alpha back into the opacity, where a typed colour keeps it. pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { use super::lists::{BoundList, list_binding}; let Some((list, i, "color")) = list_binding(field) else { return }; @@ -819,7 +819,15 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie if let Some(p) = paints.get_mut(i) && let PaintKind::Solid { color } = &mut p.kind { - p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + if bind { + if p.color_alpha { + p.opacity = 1.0; + } + p.color_alpha = false; + } else if color.a < 1.0 { + p.opacity *= color.a; + p.color_alpha = true; + } color.a = 1.0; } } diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 4777be18..b48dd9d3 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -104,7 +104,10 @@ fn themed() -> Document { add(&mut doc, frame, NodeKind::Rectangle, "Swatch", (10.0, 10.0), (80.0, 60.0), &|n| { let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK); g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }]; - n.fills = vec![solid(0.5, 0.5, 0.5), Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }]; + n.fills = vec![ + solid(0.5, 0.5, 0.5), + Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false }, + ]; n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)]; n.effects = vec![Effect { kind: EffectKind::DropShadow(Shadow { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..8d93d51e 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint { p.kind } }; - Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS) } + Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: false } } fn effect(r: &mut Lcg) -> Effect { diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index efb7710d..ff06c737 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -139,7 +139,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option { return None; } }; - Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p) }) + Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha: false }) } /// Gradient handles are already in the node's unit box (start, end, width), like ours. From be0fcb5795db3bda65428601be9470beec6f3f4e Mon Sep 17 00:00:00 2001 From: kolla <78259478+kollakek1@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:15:14 +0300 Subject: [PATCH 10/98] fix(ui): keep the style picker open while editing a style's colour The colour picker's Window area is keyed by egui as Id::new(Some(title)), but the style picker looked it up as Id::new("color_picker"), so the lookup always missed and any click in the picker counted as an outside click that closed the editor (and the picker with it). Give the window a fixed id and query it through colorpicker::window_id(), with a regression test. --- crates/ui-egui/src/colorpicker.rs | 9 ++++++++ crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/style_picker_tests.rs | 28 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index d6aad31d..f6a19f72 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -435,6 +435,12 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { slider_knob(ui, rect, color.a, Id::new("cp_alpha")) } +/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by +/// `Id::new(Some(title))`) so the style picker can tell a click inside the picker from one outside. +pub fn window_id() -> Id { + Id::new("color_picker") +} + pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { return }; // A layer's picker belongs to the selection it was opened for: once that changes, close it @@ -463,6 +469,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut close_now = false; egui::Window::new("color_picker") + // A fixed area id so the style picker can tell a click in here from a click outside + // (`Window::new` alone would key the area by `Id::new(Some(title))`). + .id(window_id()) .title_bar(false) .fixed_pos(pos) .fixed_size(vec2(240.0, 0.0)) diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 29c9c5a9..22713000 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -909,7 +909,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Clicks in the colour picker editing this picker's style, or in a menu the editor opened, // keep it open. let picker_rect = - app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); + app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id()))); // So do clicks in the variable picker binding one of its fields (Escape closes that first). let variables = crate::variable_ui::picker_rect(ctx); let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some(); diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index becc42ce..ad2c2971 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -164,6 +164,34 @@ fn chars_linked_to_several_styles_show_mixed() { assert_eq!(text_link(&app, &doc, &[t]), None); } +#[test] +fn a_click_in_the_colour_picker_keeps_the_style_editor_open() { + use crate::layers_drag_tests::Ui; + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let mut u = Ui::new(s); + open_for_selection(&mut u.app, StyleSlot::Fill, Some("Brand")); + for _ in 0..3 { + u.frame(vec![]); + } + let id = u.app.session.doc().unwrap().style("Brand").unwrap().id.clone(); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Style { id, index: 0 }); + for _ in 0..3 { + u.frame(vec![]); + } + let cp = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("colour picker shows"); + let p = cp.center(); + u.button(p, true); + u.button(p, false); + u.frame(vec![]); + assert!(u.app.ui.style_picker.is_some(), "the style editor stays open while the colour picker is used"); + assert!( + matches!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::Style { .. })), + "the colour picker stays open too: {:?}", + u.app.ui.picker + ); +} + #[test] fn local_style_drops_move_into_the_folder_above() { let st = |id: &str, name: &str| Style { From 12bb6503bd5a4602f6433cf1468c3b903c9bff78 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 17:00:21 +0200 Subject: [PATCH 11/98] M1: readable dropdown rows in the light theme (#972, #955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel dropdowns (prototype device, Preferences, blend modes, versions…) draw their rows with `right::menu_row` on the theme's popup fill, which is white in the light theme. Since 5a4b7c27 those rows painted their labels in `menu_text`, white in both themes because it belongs to the dark menu bar, so the options were invisible until hovered. Rows now take the theme's text (`t.text`, shortcuts `t.text_secondary`) and white only on the green hover highlight, as before that commit. The menu bar, tool flyout and style/variable pickers paint their own rows on `t.menu` and are unchanged. Regression test: a dropdown opened headless in both themes keeps its labels at 4.5:1 and shortcuts at 3:1 against the popup fill. --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menu_contrast_tests.rs | 84 +++++++++++++++++++++++ crates/ui-egui/src/right.rs | 6 +- 3 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/menu_contrast_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..05bd84cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -151,6 +151,8 @@ mod measure_nudge_tests; #[cfg(test)] mod menu_aim_tests; #[cfg(test)] +mod menu_contrast_tests; +#[cfg(test)] mod menu_input_tests; #[cfg(test)] mod missing_fonts_tests; diff --git a/crates/ui-egui/src/menu_contrast_tests.rs b/crates/ui-egui/src/menu_contrast_tests.rs new file mode 100644 index 00000000..f3a9b1bd --- /dev/null +++ b/crates/ui-egui/src/menu_contrast_tests.rs @@ -0,0 +1,84 @@ +//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's +//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless. + +use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2}; + +use crate::right::{menu_check, menu_item, menu_popup}; +use crate::theme::{self, Tokens}; + +fn input(t: &mut f64, events: Vec) -> RawInput { + *t += 1.0 / 60.0; + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(*t), events, ..Default::default() } +} + +fn press(p: Pos2, pressed: bool) -> Vec { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +fn color(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The text painted on the foreground (menu) layers: (text, colour). +fn menu_texts(out: &egui::FullOutput) -> Vec<(String, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + Shape::Text(t) => out.push((t.galley.text().to_string(), t.fallback_color)), + _ => {} + } + } + let mut texts = Vec::new(); + for c in &out.shapes { + walk(&c.shape, &mut texts); + } + texts +} + +/// Open a dropdown with `tokens` and return its rows' text colours, with the pointer away from them. +fn rows(tokens: Tokens) -> Vec<(String, Color32)> { + let ctx = egui::Context::default(); + theme::apply(&ctx, tokens); + let mut t = 0.0; + let mut frame = |events: Vec| { + let mut out = ctx.run_ui(input(&mut t, events), |ui| { + let (_, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), egui::Sense::click()); + menu_popup(&resp).show(|ui| { + menu_check(ui, true, "iPhone 17 Pro Max", ""); + menu_check(ui, false, "Pixel 9", ""); + menu_item(ui, "Copy", "Cmd+C"); + }); + }); + out.textures_delta.clear(); + out + }; + let at = pos2(12.0, 12.0); + frame(vec![]); + frame(press(at, true)); + frame(press(at, false)); + // Past the popup's fade-in, so the colours are the ones it settles on. + for _ in 0..30 { + frame(vec![Event::PointerMoved(pos2(790.0, 590.0))]); + } + let out = frame(vec![]); + assert!(ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == Order::Foreground)), "the menu opened"); + menu_texts(&out) +} + +#[test] +fn dropdown_rows_read_on_the_popup_in_both_themes() { + for tokens in [Tokens::light(), Tokens::dark()] { + // The popup's own fill (`window_fill`), what the rows are drawn on. + let fill = color(tokens.panel); + let texts = rows(tokens); + let keys = crate::toolbar::pretty_shortcut("Cmd+C"); + // Labels at WCAG AA for text; the dimmer shortcut at least 3:1. + for (label, min) in [("iPhone 17 Pro Max", 4.5), ("Pixel 9", 4.5), ("Copy", 4.5), (keys.as_str(), 3.0)] { + let Some((_, col)) = texts.iter().find(|(s, _)| s.trim() == label || s.ends_with(label)) else { + panic!("{label} painted (dark {}): {texts:?}", tokens.dark) + }; + let ratio = color(*col).contrast(fill); + assert!(ratio >= min, "{label} at {ratio:.2}:1 on the popup (dark {}): {texts:?}", tokens.dark); + } + } +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..0f212898 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -286,11 +286,13 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = t.menu_text; + // These menus sit on the theme's popup fill (white in the light theme), not on the dark + // `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972). + let col = if hovered { t.menu_text } else { t.text }; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); - ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim }); + ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary }); } art(ui.painter(), r, col); resp From f619dca7164a81522718a564443f85469bbb92b1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 17:41:30 +0200 Subject: [PATCH 12/98] M1: `=` zooms in as `-` zooms out (#973) Plain `-` zoomed out but plain `=` (the `+` key without Shift) did nothing, so a canvas zoomed out with `-` couldn't be zoomed back in the same way; held down, `-` reaches the 2% floor and `=` leaves you there. Ctrl+Shift+= zoomed in while Ctrl+Shift+- did nothing either. Found with a trace of the input on GNOME Wayland: Ctrl+wheel, Ctrl+=, Ctrl+-, Ctrl+Shift+= and `-` all reach the canvas and zoom; `=` and Ctrl+Shift+- arrive too but no binding takes them. `view.zoomIn` gains `=` and `view.zoomOut` gains `Shift+Cmd+-`, pinned in the shortcut parity table; neither key was bound elsewhere and the menus still show Ctrl/Cmd and `+`/`-`. A keymap test checks each zoom-in key has its zoom-out twin on both platforms. --- crates/engine/src/keymap.rs | 6 ++++-- crates/engine/src/keymap_tests.rs | 19 +++++++++++++++++++ crates/engine/src/shortcut_parity_tests.rs | 4 ++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index c5e3c334..edc886f1 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -220,8 +220,10 @@ pub static KEYS: &[Binding] = &[ k(View, "view.prevPage", &["PageUp"]), k(View, "view.nextPage", &["PageDown"]), // Zoom - k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]), - k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]), + // Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=` + // alone (the `+` key without Shift) zooms in (#973). + k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]), + k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]), k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]), k(Zoom, "view.zoomFit", &["Shift+1"]), k(Zoom, "view.zoomSelection", &["Shift+2"]), diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 38775d3b..f624ecfb 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -168,6 +168,25 @@ fn display_per_platform() { assert_eq!(primary("polygon.none", mac), None); } +/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US +/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck). +#[test] +fn zoom_keys_come_in_pairs() { + let none = Mods::default(); + let shift = mods(true, false, false, false); + let cmd = mods(false, false, true, false); + let shift_cmd = mods(true, false, true, false); + for p in PLATFORMS { + for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] { + assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}"); + assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}"); + } + // The menus still show Cmd/Ctrl and `+`. + assert_eq!(primary("view.zoomIn", p), Some("Cmd+=")); + assert_eq!(primary("view.zoomOut", p), Some("Cmd+-")); + } +} + #[test] fn tool_keys_come_from_the_keymap() { assert_eq!(ToolId::Move.shortcut(), "V"); diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index cf7d5d47..e690467b 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -162,8 +162,8 @@ static ROWS: &[Row] = &[ doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""), doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""), doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""), - pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]), - pin(Zoom, "view.zoomOut", &["Shift+-"], &[]), + pin(Zoom, "view.zoomIn", &["Shift+Cmd+=", "="], &[]), + pin(Zoom, "view.zoomOut", &["Shift+-", "Shift+Cmd+-"], &[]), pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]), pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]), pin(Zoom, "view.nextFrame", &["N", "End"], &[]), From 5ab748d207724710076998cae295ac002a487bde Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:14:33 +0500 Subject: [PATCH 13/98] M7: keep interface size menu focus inside popup --- crates/ui-egui/src/dialogs.rs | 32 +++++++++++++++++++++++++++- crates/ui-egui/src/ui_scale_tests.rs | 22 ++++++++++++++----- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..85d8ca15 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -498,12 +498,42 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { + let focused = ui.memory(|memory| memory.focused()); + let step = ui.input(|input| { + if input.key_pressed(egui::Key::ArrowDown) || (input.key_pressed(egui::Key::Tab) && !input.modifiers.shift) { + 1 + } else if input.key_pressed(egui::Key::ArrowUp) || (input.key_pressed(egui::Key::Tab) && input.modifiers.shift) { + -1 + } else { + 0 + } + }); + let mut rows = Vec::with_capacity(crate::ui_scale::SIZES.len()); for size in crate::ui_scale::SIZES { - if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { + let label = percent(size); + let selected = (chosen - size).abs() < 1e-6; + let row = crate::right::menu_row(ui, &format!("{}{label}", if selected { "✓ " } else { " " }), "", 0.0, |_, _, _| {}); + crate::a11y::menu_item(&row, &label, "", Some(selected)); + if row.has_focus() { + ui.memory_mut(|memory| memory.set_focus_lock_filter(row.id, egui::EventFilter { tab: true, ..Default::default() })); + } + rows.push(row.id); + if row.clicked() { app.run("pref.uiScale", json!({"scale": size})); ui.close(); } } + // Spatial focus can jump to a control behind the popup when the menu overlaps the + // Preferences window. Once an item is focused, walk the menu's own rows instead. + if step != 0 + && let Some(index) = rows.iter().position(|id| Some(*id) == focused) + { + let next = index.saturating_add_signed(step).min(rows.len() - 1); + ui.memory_mut(|memory| { + memory.request_focus(rows[next]); + memory.move_focus(egui::FocusDirection::None); + }); + } }); }); crate::widgets::label( diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 018c963a..4f2a478b 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -157,14 +157,18 @@ fn the_interface_size_menu_works_from_the_keyboard() { use egui::{Event, Key, Modifiers, Popup}; // A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle. - let press = |u: &mut Ui, key: Key| { - u.frame(vec![ - Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, - Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, - ]); + let press_mod = |u: &mut Ui, key: Key, modifiers: Modifiers| { + u.frame_with( + vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); u.frame(vec![]); u.frame(vec![]); }; + let press = |u: &mut Ui, key: Key| press_mod(u, key, Modifiers::NONE); let open = |u: &mut Ui| { // The Preferences dialog is not modal for the keyboard: Tab crosses the main window first. let mut tabs = 0; @@ -201,6 +205,14 @@ fn the_interface_size_menu_works_from_the_keyboard() { press(&mut u, Key::ArrowDown); } assert_eq!(focused_name(&u.ctx), "125%"); + for expected in ["150%", "175%", "200%", "200%"] { + press(&mut u, Key::Tab); + assert_eq!(focused_name(&u.ctx), expected, "Tab stays in the size menu"); + } + for expected in ["175%", "150%", "125%"] { + press_mod(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(focused_name(&u.ctx), expected, "Shift+Tab stays in the size menu"); + } press(&mut u, Key::Enter); assert_eq!(chosen(&u), json!(1.25)); assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu"); From 795567f01697460c91dc7bfd1fb53a9d9193da78 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Wed, 21 Oct 2026 23:37:09 +0200 Subject: [PATCH 14/98] M1: a selected unfilled shape drags from inside its box (#910) Co-authored-by: Cursor --- .../engine/src/selection_behaviour_tests.rs | 30 +++++++++++++++++++ crates/engine/src/tools.rs | 12 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..3131e9d0 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -324,6 +324,36 @@ fn shift_drag_moves_the_selection() { assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); } +#[test] +fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { + let mut s = Session::new(); + let k = scene(&mut s); + for i in [k.r, k.c] { + ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + } + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + // (selection before, drag from, selection after, the layers that move by (10, 10), derivation) + let rows: Vec<(Vec, (f64, f64), Vec, Vec, &str)> = vec![ + (vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"), + (vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"), + (vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"), + (vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"), + ]; + for (before, from, after, moved, why) in rows { + let home: Vec = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect(); + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) { + let d = if moved.contains(i) { 10.0 } else { 0.0 }; + assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}"); + } + if !moved.is_empty() { + assert!(s.undo().unwrap(), "{why}: one undo step"); + } + } +} + #[test] fn alt_shift_drag_on_a_selected_layer_duplicates_it() { let mut s = Session::new(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..8d296a8b 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1191,11 +1191,21 @@ impl Session { .node(leaf) .is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n)) }; - let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) .filter(|leaf| ev.cmd || !board_background(*leaf)) .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, or the frame or canvas behind it) drags the selection. + let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) => { + Some(first) + } + _ => target, + }; match target { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). From 84b48bc797557624e670d9b39ea0b85995df1b00 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Thu, 22 Oct 2026 00:19:13 +0200 Subject: [PATCH 15/98] M1: the drag-from-inside fallback tests the selection's turned box (#910) Co-authored-by: Cursor --- crates/engine/src/selection_behaviour_tests.rs | 13 +++++++++++++ crates/engine/src/tools.rs | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index 3131e9d0..ef5869b2 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -352,6 +352,19 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { assert!(s.undo().unwrap(), "{why}: one undo step"); } } + // A turned layer's box is turned with it: the corners of its page-aligned bounds are outside. + ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}})); + let b = s.doc().unwrap().world_bounds(k.r).unwrap(); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE); + assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + let c = b.center(); + drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE); + assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags"); + // Pixel snapping rounds the turned box's origin, so the move is about the pointer's. + let d = at(&s, k.r) - b.origin(); + assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}"); } #[test] diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 8d296a8b..4e411d28 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1201,7 +1201,7 @@ impl Session { // inside, or the frame or canvas behind it) drags the selection. let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { - Some(first) if label.is_none() && !ev.shift && behind_sel(target) && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) => { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { Some(first) } _ => target, From 3d41302f2359dc900efe723e68da474d3685f6be Mon Sep 17 00:00:00 2001 From: vxMacDev <77440838+vxMacDev@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:47:01 -0500 Subject: [PATCH 16/98] =?UTF-8?q?fix(engine):=20show=20the=20Pen's=20Shift?= =?UTF-8?q?=2045=C2=B0=20step=20in=20its=20preview=20line=20(#970)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Shift-click with the Pen already put the next point at a 45° step from the last one, but the line drawn to the pointer while moving ignored Shift and followed the pointer, so holding Shift looked like it did nothing. The line now ends where the click will put the point. Both use one helper, at_45_degrees. --- crates/engine/src/pen_overlay_tests.rs | 21 +++++++++++++++++++++ crates/engine/src/tools.rs | 13 ++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs index 7e0064d2..7f6dba3c 100644 --- a/crates/engine/src/pen_overlay_tests.rs +++ b/crates/engine/src/pen_overlay_tests.rs @@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() { let ov = &s.tool.overlay; assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); } + +#[test] +fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() { + // #970: with Shift the band shows where the point will land, at a 45° step from the last + // anchor (the same length as the pointer is away), not under the pointer. + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + at(&mut s, PointerKind::Down, 100.0, 100.0); + at(&mut s, PointerKind::Up, 100.0, 100.0); + let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor"); + assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}"); + s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!(y0, 100.0, "the point landed on the band"); + assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..d8fd43e7 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -413,6 +413,13 @@ pub(crate) enum Gesture { }, } +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + /// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in /// and out handles and the one before's out handle, those that aren't zero. fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { @@ -1085,6 +1092,8 @@ impl Session { guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } @@ -2094,9 +2103,7 @@ impl Session { if ev.shift && let Some((last, _, _)) = pen.points.last() { - let v = p - *last; - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - p = *last + Vec2::from_angle(ang) * v.hypot(); + p = at_45_degrees(*last, p); } pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); self.tool.gesture = Gesture::PenDrag { anchor: p }; From aa5cc840e5b4b9a346e5721b90a280e4b17ff26c Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Thu, 22 Oct 2026 07:46:23 +0200 Subject: [PATCH 17/98] M1: re-run CI (#910) From 4b71d2c69e717b789156476fe8de43c040598d0b Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Thu, 22 Oct 2026 09:13:19 +0200 Subject: [PATCH 18/98] M1: individual corner fields sit at their corners, each with its own icon (#1002) Co-authored-by: Cursor --- crates/ui-egui/src/corner_individual_tests.rs | 75 +++++++++++++++++++ crates/ui-egui/src/icons.rs | 24 +++++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/right.rs | 62 +++++++-------- 4 files changed, 125 insertions(+), 38 deletions(-) create mode 100644 crates/ui-egui/src/corner_individual_tests.rs diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs new file mode 100644 index 00000000..5a36f073 --- /dev/null +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -0,0 +1,75 @@ +//! 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. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A rectangle rounded only on its left corners (top left and bottom left 30), selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [r], "props": {"topLeftRadius": 30, "bottomLeftRadius": 30}})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + (Ui::new(s), r) +} + +/// The rectangle's radii as `[top left, top right, bottom right, bottom left]`. +fn radii(u: &Ui, r: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(r).unwrap().corners.radii +} + +/// A full press-and-release at `p`, then settling one frame. +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// A press-and-release of Enter, committing the focused number field. +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// Corner field `k` (an index into the radii). +fn field(u: &Ui, k: usize) -> Rect { + u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect +} + +/// 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] +fn corner_fields_sit_at_their_corners_and_set_their_own() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let (tl, tr, br, bl) = (field(&u, 0).center(), field(&u, 1).center(), field(&u, 2).center(), field(&u, 3).center()); + assert!(tl.x < tr.x && (tl.y - tr.y).abs() < 1.0, "top row: top left, then top right"); + assert!(bl.x < br.x && (bl.y - br.y).abs() < 1.0, "bottom row: bottom left, then bottom right"); + assert!(bl.y > tl.y && (bl.x - tl.x).abs() < 1.0, "bottom left sits under top left"); + let history = u.app.session.undo_depth().0; + click(&mut u, br); + u.frame(vec![Event::Text("12".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 5eb7d209..10dc4c83 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,11 @@ icons! { FlipH, FlipV, CornerRadius, + /// Individual corners: the rounded corner each field sets. + CornerTopLeft, + CornerTopRight, + CornerBottomRight, + CornerBottomLeft, Angle, IndependentCorners, /// Individual padding: a frame with one inward tick per side, each set alone. @@ -708,7 +713,10 @@ fn draw(p: &P, icon: Icon) { p.poly(&[(3.5, 5.5), (12.5, 5.5), (12.5, 1.5)], true); p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } - CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0), + CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0), + CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0), Angle => { // Two rays from a vertex, the arc between them marking the angle (all unhinted, so // the arc meets the rays at every size). @@ -1323,4 +1331,18 @@ mod tests { let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); assert!(inside, "the handle covers the blade's base at {base:?}"); } + + /// Each corner's icon shows its own corner: no two of the four draw alike. + #[test] + fn corner_icons_differ() { + let drawn: Vec = [Icon::CornerTopLeft, Icon::CornerTopRight, Icon::CornerBottomRight, Icon::CornerBottomLeft] + .iter() + .map(|&icon| format!("{:?}", shapes_at_32(icon))) + .collect(); + for (i, a) in drawn.iter().enumerate() { + for b in &drawn[i + 1..] { + assert_ne!(a, b); + } + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..59f614b4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -93,6 +93,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod corner_individual_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod design_paste_tests; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..7d21e45f 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -879,44 +879,32 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); if indep_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); - let keys = [ - ("topLeftRadius", tr("Top left radius")), - ("topRightRadius", tr("Top right radius")), - ("bottomRightRadius", tr("Bottom right radius")), - ("bottomLeftRadius", tr("Bottom left radius")), + // `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. + let corners = [ + ("topLeftRadius", tr("Top left radius"), Icon::CornerTopLeft, 0), + ("topRightRadius", tr("Top right radius"), Icon::CornerTopRight, 1), + ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), + ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().take(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().skip(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); + for pair in corners.chunks(2) { + row(ui, 32.0, |ui| { + for (key, name, icon, k) in pair { + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + key, + Id::new(("corner", *k)), + Lead::Icon(*icon, name), + radii.map(|r| r[*k]), + "", + col, + ); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } + }); + } let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { From e6067549bcfee77c9a58292b70ef4d2fb62aae77 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 22 Oct 2026 09:21:24 +0200 Subject: [PATCH 19/98] M7: Type settings keeps its width with long labels (#1029) --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/type_settings_tests.rs | 29 ++++++++++++++ crates/ui-egui/src/typography.rs | 46 +++++++++++------------ 4 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 crates/ui-egui/src/type_settings_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..e6f3f52b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..69c21bea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1877,5 +1877,8 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { #[cfg(test)] mod selection_spacing_tests; +#[cfg(test)] +mod type_settings_tests; + #[cfg(test)] mod typography_scrub_tests; diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs new file mode 100644 index 00000000..675abfa0 --- /dev/null +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -0,0 +1,29 @@ +//! Regression for #1029: a Type settings row whose label is wider than its column (French "Retrait +//! de paragraphe") stays inside the popover, which otherwise widened a little every frame. +use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; + +#[test] +fn a_long_label_does_not_widen_the_type_settings_popover() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + let id = Id::new("ty_row_test"); + let mut widths = Vec::new(); + for i in 0..30 { + let raw = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; + ctx.run_ui(raw, |ui| { + // As `egui::Popup::width` shows it: a default width the area grows past to fit. + Area::new(id).fixed_pos(Pos2::ZERO).default_width(264.0).show(ui.ctx(), |ui| { + for label in ["List spacing", "Retrait de paragraphe, plus long que la colonne"] { + crate::typography::field_row(ui, label, |ui, fw| ui.allocate_exact_size(vec2(fw, 20.0), egui::Sense::hover())); + } + }); + }) + .textures_delta + .clear(); + widths.push(ctx.memory(|m| m.area_rect(id)).expect("the area is shown").width()); + } + let (first, last) = (widths[1], widths[widths.len() - 1]); + assert!((last - first).abs() < 0.01, "the row widens the popover every frame: {first} → {last}"); + assert!((last - 264.0).abs() < 0.01, "{last}"); +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 06185d0c..eefe7c9b 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -468,17 +468,15 @@ fn type_settings( // Indent applies to start-aligned text only; elsewhere the field stays visible but off. let start_aligned = common(doc, ids, |n| n.kind.text().map(|t| t.align_h != TextAlignH::Center && t.align_h != TextAlignH::Justified)).flatten(); - ui.horizontal(|ui| { - ui.add_enabled_ui(start_aligned != Some(false), |ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); - let field = widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { - app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); - } - }) - .response - .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); - }); + ui.add_enabled_ui(start_aligned != Some(false), |ui| { + let field = + field_row(ui, tr("Paragraph indent"), |ui, fw| widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", fw, 1.0)); + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { + app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); + } + }) + .response + .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); ui.add_space(4.0); for (key, label) in [("hangingPunctuation", tr("Hanging punctuation")), ("hangingList", tr("Hanging list"))] { let all_on = ids.iter().all(|i| { @@ -508,16 +506,13 @@ fn type_settings( if resize == Some(TextAutoResize::Truncate) { let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten(); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Max lines")).font(regular(11.0)).color(t.text))); - let shown = max.map(f64::from).filter(|m| *m > 0.0); - let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) }; - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, w - 118.0, 1.0) - { - app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); - } - }); + let shown = max.map(f64::from).filter(|m| *m > 0.0); + let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) }; + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + field_row(ui, tr("Max lines"), |ui, fw| widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, fw, 1.0)) + { + app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); + } } } else if let (2, Some(face)) = (tab, &vf) { variable_axes(app, ui, ids, styles, face); @@ -542,13 +537,14 @@ fn type_settings( } } -/// A Type settings row: a label, then `add` with the rest of the width. -fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { +/// A Type settings row: a label, then `add` with the rest of the width. A label wider than its +/// column (French, say) narrows the field rather than the row outgrowing the popover, which then +/// widened every frame (#1029). +pub(crate) fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); ui.horizontal(|ui| { ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(label).font(regular(11.0)).color(t.text))); - add(ui, w - 118.0) + add(ui, ui.available_width()) }) .inner } From 8ee0218d1964f66ca42b5cb07cd60ce27c32c3c9 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 09:33:12 +0200 Subject: [PATCH 20/98] M3: a variant switch takes the new variant's resizing unless the instance set its own Switching an instance to another variant copied the old root's sizing and min/max back over the new variant's on the rebuild, so an instance switched from a hugging variant to a fixed-width one whose children fill kept hugging at the old width. The next edit then turned those children Fixed, as a child can't fill an axis its parent hugs. Resizing still equal to the old variant's was never the instance's own, so on a sibling-variant switch it now follows the new variant; sizing or min/max the instance set itself stays. --- crates/engine/src/components.rs | 22 +++++++ .../engine/src/components_behaviour_tests.rs | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..191a6a7c 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -335,6 +335,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { } } +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + /// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { @@ -779,6 +800,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { let differs = diff_groups(prev, &comp); root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); } carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 7a5c1f90..a95e1268 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() { healthy(&s); } +/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide, +/// "A" filling it). Returns (set, row, stacked). +fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let mut variants = Vec::new(); + for (k, (name, props, child)) in [ + ("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})), + ( + "Layout=Stacked", + json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}), + json!({"sizingH": "fill"}), + ), + ] + .into_iter() + .enumerate() + { + let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props}))); + ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child})); + variants.push(c); + } + let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()}))); + (set, variants[0], variants[1]) +} + +#[test] +fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() { + let mut s = Session::new(); + let (set, row, stacked) = layout_set(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert_eq!(component_of(&s, i), row); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}})); + assert_eq!(component_of(&s, i), stacked); + let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400}))); + let (n, d) = (node(&s, i), node(&s, direct)); + assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed"); + assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked"); + let a = layer(&s, i, "A").unwrap(); + assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance"); + // Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override. + let col = idv(&ex( + &mut s, + "node.create", + json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}), + )); + ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col})); + ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}})); + let a = layer(&s, i, "A").unwrap(); + assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills"); + assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's"); + assert_eq!(node(&s, a).width, node(&s, i).width); + // Resizing the instance set itself stays across a switch. + let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); + ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}})); + ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}})); + assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays"); + assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked"); + healthy(&s); +} + #[test] fn variant_combinations_order_default_and_conflicts() { let mut s = Session::new(); From 857762e65c174a4afb36756d24eff4bf617c2ff5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:36:21 +0000 Subject: [PATCH 21/98] Treat U+2007 FIGURE SPACE as non-breaking in line breaking Found while testing crates/text/src/breaks.rs:42 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/text/src/breaks.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 3f5458ea..8012de33 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -39,7 +39,7 @@ fn class(c: char) -> Class { return Class::Close; } match c { - ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}' => Class::Space, + ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{2006}' | '\u{2008}'..='\u{200B}' => Class::Space, '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, '\u{2E80}'..='\u{2FFF}' | '\u{3003}'..='\u{33FF}' @@ -98,3 +98,15 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { } at.min(index) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn figure_space_is_non_breaking() { + assert!(!break_between('\u{2007}', '0')); + assert!(!break_between('1', '\u{2007}')); + assert!(!is_space('\u{2007}')); + } +} From 0449edf64582a065220b8028670ca41c5c069e2b Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 09:37:35 +0200 Subject: [PATCH 22/98] M8: save-format tests keep their temp folders apart SaveFiles named each test's folder after the process id and the clock in nanoseconds. The tests run in parallel and the macOS clock counts microseconds, so two of them often got the same folder and one failed with AlreadyExists (14 of 30 runs here). A counter now keeps the names apart. --- crates/engine/src/save_format_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index d635808e..fc4ca775 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -15,8 +15,12 @@ struct SaveFiles { impl SaveFiles { fn new() -> Self { + // Tests run in parallel and the clock can read the same for two of them (macOS counts + // microseconds), so a counter keeps their folders apart. + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}-{n}", std::process::id())); std::fs::create_dir(&dir).unwrap(); Self { dir, files: Vec::new() } } From a713897d876da6a7bbef2c48c950f8e33aff77dc Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 09:54:27 +0200 Subject: [PATCH 23/98] M6: SVG import fills open subpaths the way SVG does SVG fills every subpath as if it were closed; the import filled only subpaths that close themselves (a z, or an end within 1e-6 of the start). Logos exported from PDF round relative coordinates to three decimals, so their outlines end about 0.001 short of where they started and most of their letters came in empty. A filled path now has its open subpaths closed before it becomes a vector network: an end within 1/1000 of the path's size snaps onto the start, any other open subpath of two or more segments gets a closing segment. Stroke-only paths are unchanged. A filled and stroked path with a really open subpath becomes a fill layer (closed) under a stroke layer (open), so the stroke stays open as SVG draws it. --- crates/svg/src/import/mod.rs | 100 +++++++++++++++++++--- crates/svg/tests/corpus.rs | 18 ++++ crates/svg/tests/corpus/open_subpaths.svg | 18 ++++ 3 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 crates/svg/tests/corpus/open_subpaths.svg diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index fdae7156..521146fb 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -377,15 +377,38 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { return; } path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut network = VectorNetwork::from_path(&path); let rule = if fill.is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { WindingRule::EvenOdd } else { WindingRule::NonZero }; - for r in &mut network.regions { - r.winding = rule; + let vector = |path: &BezPath, filled: bool| { + let mut network = VectorNetwork::from_path(path); + for r in &mut network.regions { + r.winding = rule; + } + if !filled { + network.regions.clear(); + } + NodeKind::Vector { network } + }; + let at = Affine::translate((bb.x0, bb.y0)); + // Fill closes every subpath; a stroke only those that close themselves. A filled + // and stroked path with a really open subpath becomes a fill layer and a stroke layer. + let tol = CLOSE_TOLERANCE * bb.width().max(bb.height()); + let (snapped, closed) = (closed_for_fill(&path, tol, false), closed_for_fill(&path, tol, true)); + if let (Some(_), Some(_)) = (fill, stroke) + && snapped != closed + { + let fill_only = + Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height()); + let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height()); + let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)]; + if p.paint_order() == usvg::PaintOrder::StrokeAndFill { + layers.reverse(); + } + for n in layers { + cx.add(n, parent); + } + return; } - if fill.is_none() { - network.regions.clear(); - } - (NodeKind::Vector { network }, Affine::translate((bb.x0, bb.y0)), bb.width(), bb.height(), 0.0) + (vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0) } }; let fallback = match kind { @@ -395,12 +418,18 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { _ => "Vector", }; let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h); - n.transform = transform; n.corners.radii = [radius; 4]; - let target = Target { to_node: transform.inverse() * world, size: (w, h) }; - n.fills = fill.and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); + let n = paint_path(cx, p, n, transform, world, true, true); + cx.add(n, parent); +} + +/// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke. +fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node { + n.transform = transform; + let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) }; + n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); n.strokes.clear(); - if let Some(s) = stroke { + if let Some(s) = p.stroke().filter(|_| with_stroke) { n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect(); n.stroke = stroke_props(s, length_scale(world)); // A stroke painted under the fill shows only its outer half. @@ -409,7 +438,54 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { n.stroke.weight /= 2.0; } } - cx.add(n, parent); + n +} + +/// How far (as a share of the path's larger side) a subpath may end from its start and still be +/// closed onto it: exporters that round coordinates leave closed outlines this far apart. +const CLOSE_TOLERANCE: f64 = 1e-3; + +/// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all` +/// (SVG fills every subpath as if closed), every other subpath of two or more segments closed +/// with a straight segment. +fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath { + use figcraft_geom::kurbo::PathEl; + let mut out = BezPath::new(); + let mut sub: Vec = Vec::new(); + let flush = |sub: &mut Vec, out: &mut BezPath| { + let start = match sub.first() { + Some(PathEl::MoveTo(p)) => *p, + _ => { + sub.clear(); + return; + } + }; + let open = !matches!(sub.last(), Some(PathEl::ClosePath)); + let segments = sub.len() - 1; + if open && segments >= 2 { + let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol); + if near && let Some(last) = sub.last_mut() { + match last { + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => *p = start, + PathEl::MoveTo(_) | PathEl::ClosePath => {} + } + } + if near || all { + sub.push(PathEl::ClosePath); + } + } + for e in sub.drain(..) { + out.push(e); + } + }; + for e in path.elements() { + if matches!(e, PathEl::MoveTo(_)) { + flush(&mut sub, &mut out); + } + sub.push(*e); + } + flush(&mut sub, &mut out); + out } /// A recognised primitive placed by `world`: its kind, transform, size and corner radius. `None` diff --git a/crates/svg/tests/corpus.rs b/crates/svg/tests/corpus.rs index 35f6f9f4..ac0c38dd 100644 --- a/crates/svg/tests/corpus.rs +++ b/crates/svg/tests/corpus.rs @@ -243,6 +243,24 @@ fn groups_keep_their_ids_and_transforms_bake_into_shapes() { assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR"); } +#[test] +fn fills_close_open_subpaths() { + let (d, f) = imported("open_subpaths"); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + // One filled layer: all four subpaths are fill loops, the 0.001 gap and the triangle included. + let filled = layer(&d, f, "filled"); + assert_eq!((filled.kind.type_name(), loops(filled), filled.strokes.len()), ("VECTOR", 4, 0)); + // Filled and stroked with an open triangle: a fill layer under the stroke, which stays open. + let (fill, stroke) = (layer(&d, f, "stroked fill"), layer(&d, f, "stroked")); + assert_eq!((loops(fill), fill.fills.len(), fill.strokes.len()), (2, 1, 0)); + assert_eq!((loops(stroke), stroke.fills.len(), stroke.strokes.len()), (0, 0, 1)); + let order: Vec<&str> = d.node(f).unwrap().children.iter().filter_map(|c| d.node(*c)).map(|n| n.name.as_str()).collect(); + assert_eq!(order, ["filled", "stroked fill", "stroked"], "the fill paints first, as SVG paints fill then stroke"); +} + #[test] fn basic_shapes_stay_rectangles_ellipses_and_lines() { let (d, f) = imported("primitives"); diff --git a/crates/svg/tests/corpus/open_subpaths.svg b/crates/svg/tests/corpus/open_subpaths.svg new file mode 100644 index 00000000..2d944942 --- /dev/null +++ b/crates/svg/tests/corpus/open_subpaths.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + From e8c47428741292340a41ca9394b31fd02536cedc Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 10:27:34 +0200 Subject: [PATCH 24/98] M6: the open-subpath fixture covers stroke-first paint order A third row in open_subpaths.svg: a filled and stroked path that is only 0.001 short stays one layer, and an open triangle with paint-order="stroke" puts its stroke layer under its fill layer. --- crates/svg/tests/corpus.rs | 5 ++++- crates/svg/tests/corpus/open_subpaths.svg | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/svg/tests/corpus.rs b/crates/svg/tests/corpus.rs index ac0c38dd..24803c40 100644 --- a/crates/svg/tests/corpus.rs +++ b/crates/svg/tests/corpus.rs @@ -257,8 +257,11 @@ fn fills_close_open_subpaths() { let (fill, stroke) = (layer(&d, f, "stroked fill"), layer(&d, f, "stroked")); assert_eq!((loops(fill), fill.fills.len(), fill.strokes.len()), (2, 1, 0)); assert_eq!((loops(stroke), stroke.fills.len(), stroke.strokes.len()), (0, 0, 1)); + // Only near-closed ends: one layer, closed, stroke and all. + let near = layer(&d, f, "near"); + assert_eq!((loops(near), near.fills.len(), near.strokes.len()), (1, 1, 1)); let order: Vec<&str> = d.node(f).unwrap().children.iter().filter_map(|c| d.node(*c)).map(|n| n.name.as_str()).collect(); - assert_eq!(order, ["filled", "stroked fill", "stroked"], "the fill paints first, as SVG paints fill then stroke"); + assert_eq!(order, ["filled", "stroked fill", "stroked", "near", "under", "under fill"], "paint order: fill then stroke, or the stroke first"); } #[test] diff --git a/crates/svg/tests/corpus/open_subpaths.svg b/crates/svg/tests/corpus/open_subpaths.svg index 2d944942..daed50b7 100644 --- a/crates/svg/tests/corpus/open_subpaths.svg +++ b/crates/svg/tests/corpus/open_subpaths.svg @@ -1,10 +1,14 @@ - + + stroked: 0.001 short (closed, stroke and all); an open triangle (stroked open). Third row, + filled and stroked: only 0.001 short (one layer); an open triangle with its stroke under + the fill. --> + + @@ -15,4 +19,10 @@ + + + + + + From 79e722d3407b65ebc16ba7030e3bf979b6ae3ee1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:56:12 +0000 Subject: [PATCH 25/98] Keep literal true/false distinct from variables named true/false Found while testing crates/doc/src/proto_expr_parse.rs:462 (variable lookup before the true/false literals at :466) with crates/doc/src/proto_expr.rs:151/186 (display writes the literal as bare `true` and var_ref writes such a variable bare) Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/proto_expr_parse.rs | 13 ++++++++----- crates/doc/src/proto_expr_parse_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 5214a2d6..54615ee0 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -459,16 +459,19 @@ impl Parser<'_> { if self.at_keyword("mode") && self.next_after(4) == Some('(') { return self.mode_of(); } - if let Some((id, n)) = self.variable_here() { - self.i += n; - return self.in_mode(id); - } + let var = self.variable_here(); + // A bare `true`/`false` is the literal, also when a variable has that name (it reads + // as `{true}`); longer names that start with it ("true value") stay variables. for (word, v) in [("true", true), ("false", false)] { - if self.at_keyword(word) { + if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) { self.i += word.len(); return Ok(Expr::Value(VariableValue::Boolean(v))); } } + if let Some((id, n)) = var { + self.i += n; + return self.in_mode(id); + } let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect(); if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) } } diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index e106121d..8d4c1e1a 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -195,3 +195,25 @@ fn names_that_look_like_literals_read_back_as_variables() { assert_eq!(parse("1"), n(1.0)); assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); } + +#[test] +fn literal_true_false_survive_variables_named_true_false() { + use ExprOp::*; + let lit = |x: bool| Expr::Value(VariableValue::Boolean(x)); + for name in ["true", "false", "TRUE"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") { + var.name = name.into(); + } + for e in [ + Expr::op(Equals, vec![Expr::var("b"), lit(true)]), + Expr::op(Equals, vec![Expr::var("b"), lit(false)]), + Expr::op(Not, vec![lit(false)]), + Expr::var("b"), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b"))); + } +} From 24a5855cab91135c20cfc8904c4925c87629c92d Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 09:27:59 +0200 Subject: [PATCH 26/98] M3: style links given in a call apply after its values apply_order gave fillStyle, strokeStyle, effectStyle, gridStyle and textStyle the rank of plain values, so the JSON map's byte order decided: fills, strokes, effects, grids, layoutGrids and variationSettings sort after their link and overrode it (node.set then unlinked the style; node.create kept a link its values contradicted). Style links now apply after the values and x/y, just before boundVariables. A deviation set in a later call still unlinks. --- crates/engine/src/cmd/props.rs | 8 ++- crates/engine/src/styles_behaviour_tests.rs | 67 +++++++++++++++++++++ docs/mcp.md | 8 ++- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..015bcc5a 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -372,14 +372,16 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { } } -/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable -/// bindings last so paints set in the same call don't detach them. +/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style +/// links after the values so a link wins over values its style sets, variable bindings last so +/// paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, - "boundVariables" => 4, + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, + "boundVariables" => 5, _ => 1, } } diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index ff7af50b..de9da5a5 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option { } } +/// The key linking a style of `kind` (a paint style links fills). +fn style_key(kind: &str) -> String { + if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") } +} + +/// `node.create` params for an unselected layer that takes `kind` styles, with `props`. +fn new_layer(kind: &str, props: Value) -> Value { + if kind == "text" { + json!({"type": "text", "text": "Hej", "select": false, "props": props}) + } else { + json!({"type": "frame", "select": false, "props": props}) + } +} + #[test] fn a_style_holds_the_whole_property_and_links_its_source() { // Documented: colour styles can hold several fills (gradients, images); effect styles combine @@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() { let _ = st; } +#[test] +fn a_style_link_wins_over_values_in_the_same_call() { + // Docs silent on one call setting both (a linked layer shows its style's values; editing one + // detaches it); pinned: the link wins whatever the keys are called, on node.create and + // node.set, and a deviation set in a later call unlinks as before. + let mut s = Session::new(); + let rows = styled(&mut s); + let pairs: [(&str, &str, Value); 12] = [ + ("paint", "fill", json!("#123456")), + ("paint", "fills", json!(["#123456"])), + ("stroke", "stroke", json!("#123456")), + ("stroke", "strokes", json!(["#123456"])), + ("effect", "effects", json!([])), + ("grid", "grids", json!([])), + ("grid", "layoutGrids", json!([])), + ("text", "fontStyle", json!("Bold")), + ("text", "lineHeight", json!(40)), + ("text", "textDecoration", json!("underline")), + ("text", "letterSpacing", json!(5)), + ("text", "variationSettings", json!({"wght": 700})), + ]; + for (kind, key, value) in pairs { + let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone(); + let link_key = style_key(kind); + let props = json!({key: value.clone(), link_key.as_str(): st.clone()}); + let made = id(run(&mut s, "node.create", new_layer(kind, props.clone()))); + let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) }; + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked"); + assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values"); + } + run(&mut s, "node.set", json!({"id": set, "props": {key: value}})); + assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks"); + assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies"); + } +} + +#[test] +fn a_style_link_leaves_values_its_style_does_not_set() { + // Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set. + let mut s = Session::new(); + let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1; + let props = json!({"textStyle": st, "textAlign": "center"}); + let made = id(run(&mut s, "node.create", new_layer("text", props.clone()))); + let set = text(&mut s, 400.0); + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked"); + assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies"); + } +} + #[test] fn applying_a_style_replaces_variables_bound_to_that_property() { // Documented: applying a style sets the property to the style's values. Docs silent on bound diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..0e774d13 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -267,9 +267,11 @@ Or in a project's `.mcp.json`: - **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list (`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables - bound to what the style sets; a text style goes on text layers only. Names are trimmed per `/` - folder part. `style.move {styles, before?, folder?}` reorders (and with `folder`, renames into it), - `style.duplicate {style}` adds " copy" right below, `style.renameFolder {folder, name}`. + bound to what the style sets; a text style goes on text layers only. In one call, a style link + (`fillStyle`, `textStyle`…) wins over values it sets; set a deviation in a later call, which + unlinks it. Names are trimmed per `/` folder part. `style.move {styles, before?, folder?}` + reorders (and with `folder`, renames into it), `style.duplicate {style}` adds " copy" right + below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the layers using them (they keep their last value) and give aliases to them their values. An From 93c0bb9ce225b191f4993114ab821c6091195863 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:20:19 +0000 Subject: [PATCH 27/98] =?UTF-8?q?Keep=20`not=20`=20from=20reading=20b?= =?UTF-8?q?ack=20as=20a=20variable=20named=20"not=20=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while testing crates/doc/src/proto_expr.rs:172 (display writes `not {x}` with a bare var_ref) with crates/doc/src/proto_expr_parse.rs:262 (not() checks variable names before the `not` keyword) Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/proto_expr.rs | 7 ++++++- crates/doc/src/proto_expr_parse_tests.rs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index f4404dc7..d5441b28 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -169,7 +169,12 @@ impl Expr { }) .collect(); match (op, parts.as_slice()) { - (ExprOp::Not, [x]) => format!("not {x}"), + // `not done` reads as a variable named "not done" if there is one: then + // `not (done)`. + (ExprOp::Not, [x]) => { + let bare = format!("not {x}"); + if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") } + } (ExprOp::Negate, [x]) => format!("-{x}"), _ => parts.join(&format!(" {} ", op.symbol())), } diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index e106121d..e2d4592a 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -195,3 +195,27 @@ fn names_that_look_like_literals_read_back_as_variables() { assert_eq!(parse("1"), n(1.0)); assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); } + +#[test] +fn not_of_a_variable_reads_back_when_a_name_starts_with_not() { + use ExprOp::*; + let mut v = vars(); + let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone(); + for (id, name) in [("done", "done"), ("nd", "not done")] { + let mut x = b.clone(); + x.id = id.into(); + x.name = name.into(); + v.variables.push(x); + } + let not_done = Expr::op(Not, vec![Expr::var("done")]); + for e in [ + not_done.clone(), + Expr::op(And, vec![Expr::var("b"), not_done.clone()]), + Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]), + Expr::op(Not, vec![not_done.clone()]), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}"); + } + assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare"); +} From 6db58d32cb17ff18e4f1e32613ab5df4955e942c Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 09:57:31 +0200 Subject: [PATCH 28/98] M4: individual stroke weights follow the corner radius like CSS borders stroke_area used rect_side_strokes only for square corners; a rounded rect-like layer with individual weights fell through to a uniform ring at stroke.weight (canvas, SVG, PDF, shadows, shape builder, outline stroke). pathops::rounded_side_strokes now draws them as CSS does: the outer edge is the rounded rect (the fill outline for inside strokes), the inner edge is inset by each side's weight with elliptical corners max(0, R - weight), so border-l-4 rounded-r-lg is a straight bar and a weight that differs across a rounded corner tapers through it. Center and outside grow the outer box and each non-zero radius by half or all of the weight (CSS has only inside). The inner edge runs against the outer one, so the ring is a ring under non-zero too; where it could leave the outer edge (radii shrunk to fit a narrow inner box, smoothed outer corners) it is cut out instead. --- crates/pathops/src/lib.rs | 90 ++++++++++++++ .../pathops/src/rounded_side_strokes_tests.rs | 114 ++++++++++++++++++ crates/render/src/draw.rs | 8 +- crates/render/src/tests.rs | 21 ++++ crates/svg/src/tests.rs | 20 +++ 5 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 crates/pathops/src/rounded_side_strokes_tests.rs diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index fb9cfcfe..ca2ee0b6 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -13,6 +13,8 @@ mod brush_tests; mod ops_tests; mod points; #[cfg(test)] +mod rounded_side_strokes_tests; +#[cfg(test)] mod sides_tests; pub mod width; #[cfg(test)] @@ -451,6 +453,94 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> p } +/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer +/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's +/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with +/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). An even-odd +/// ring whose inner edge runs against the outer one, so it is a ring under non-zero too. +pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath { + let [t, r, b, l] = sides.map(non_negative); + if t + r + b + l <= 0.0 { + return BezPath::new(); + } + let f = match align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => 0.5, + StrokeAlign::Outside => 1.0, + }; + // Clamped like the fill: at most half the shorter side. + let max_r = non_negative(w.min(h)) / 2.0; + let radii = radii.map(|v| non_negative(v).min(max_r)); + // Each corner's radius with the weights across and down it, clockwise from the top-left. + let corners = [(radii[0], l, t), (radii[1], r, t), (radii[2], r, b), (radii[3], l, b)]; + let outer = if align == StrokeAlign::Inside { + figcraft_geom::rounded_rect(w, h, radii, smoothing) + } else { + let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) }); + elliptical_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown) + }; + let k = 1.0 - f; + let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return outer; + } + let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0)))); + // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or the + // outer ones are smoothed: then it may leave the outer edge. + ring(outer, &elliptical_rect(inner, inset), fit < 1.0 || (align == StrokeAlign::Inside && smoothing > 0.0)) +} + +fn non_negative(v: f64) -> f64 { + if v.is_finite() { v.max(0.0) } else { 0.0 } +} + +/// `outer` with `hole` taken out: cut out of it when the hole may leave it, else as an even-odd +/// ring whose hole runs against it. +fn ring(outer: BezPath, hole: &BezPath, cut: bool) -> BezPath { + if cut && let Ok(p) = boolean_step(&outer, hole, BoolOp::Subtract) { + return p; + } + let mut p = outer; + p.extend(hole.reverse_subpaths().elements().iter().copied()); + p +} + +/// Corner radii `(rx, ry)` for the inner box `inner`, square where either is 0, all shrunk by one +/// factor (returned too) where two on an edge add up to more than it, as CSS shrinks radii. +fn fit_corners(inner: figcraft_geom::Rect, corners: [(f64, f64); 4]) -> ([(f64, f64); 4], f64) { + let c = corners.map(|(rx, ry)| if rx > 0.0 && ry > 0.0 { (rx, ry) } else { (0.0, 0.0) }); + let [tl, tr, br, bl] = c; + let edges = [(inner.width(), tl.0 + tr.0), (inner.width(), bl.0 + br.0), (inner.height(), tl.1 + bl.1), (inner.height(), tr.1 + br.1)]; + let fit = edges.iter().filter(|(_, sum)| *sum > 0.0).fold(1.0_f64, |m, (len, sum)| m.min(len / sum)); + (c.map(|(rx, ry)| (rx * fit, ry * fit)), fit) +} + +/// Rectangle `r` with an `(rx, ry)` quarter ellipse at each corner `[tl, tr, br, bl]`, drawn like +/// `figcraft_geom::rounded_rect` (circular corners come out the same). +fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> BezPath { + use std::f64::consts::{FRAC_PI_2, PI}; + // Each corner, the direction of its ellipse centre from it, and the angle its quarter starts at. + let quarters = [ + (Point::new(r.x1, r.y0), (-1.0, 1.0), tr, -FRAC_PI_2), + (Point::new(r.x1, r.y1), (-1.0, -1.0), br, 0.0), + (Point::new(r.x0, r.y1), (1.0, -1.0), bl, FRAC_PI_2), + (Point::new(r.x0, r.y0), (1.0, 1.0), tl, PI), + ]; + let mut p = BezPath::new(); + p.move_to((r.x0 + tl.0, r.y0)); + for (corner, (sx, sy), (rx, ry), start) in quarters { + if rx > 0.0 && ry > 0.0 { + let c = Point::new(corner.x + sx * rx, corner.y + sy * ry); + p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin())); + kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e)); + } else { + p.line_to(corner); + } + } + p.close_path(); + p +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs new file mode 100644 index 00000000..ee0e8281 --- /dev/null +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -0,0 +1,114 @@ +//! Per-side stroke weights on rounded rectangles, drawn like CSS borders: square corners, a bar +//! along a rounded edge, tapers through corners, alignments, inner corners that no longer fit, +//! smoothing and degenerate weights. + +use figcraft_doc::StrokeAlign; +use figcraft_geom::kurbo::{BezPath, Point, Shape}; +use figcraft_geom::rounded_rect; + +use crate::{rect_side_strokes, rounded_side_strokes}; + +const W: f64 = 120.0; +const H: f64 = 60.0; + +/// The area of a ring whose hole runs against its outer edge. +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn painted(p: &BezPath, x: f64, y: f64) -> bool { + p.winding(Point::new(x, y)) % 2 != 0 +} + +#[test] +fn square_corners_paint_what_rect_side_strokes_paints() { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let sides = [1.0, 2.0, 3.0, 4.0]; + let a = area(&rounded_side_strokes(W, H, [0.0; 4], 0.0, sides, align)); + assert!((a - area(&rect_side_strokes(W, H, sides, align))).abs() < 1e-6, "{align:?}: {a}"); + } +} + +/// `border-l-4 rounded-r-lg`. +#[test] +fn a_left_border_on_a_right_rounded_box_is_a_straight_bar() { + let bar = rounded_side_strokes(W, H, [0.0, 8.0, 8.0, 0.0], 0.0, [0.0, 0.0, 0.0, 4.0], StrokeAlign::Inside); + assert!((area(&bar) - 4.0 * H).abs() < 1e-6, "{}", area(&bar)); + assert!(painted(&bar, 2.0, 30.0)); + for (x, y) in [(119.9, 30.0), (117.0, 1.0), (60.0, 0.1), (60.0, 59.9), (4.1, 30.0)] { + assert!(!painted(&bar, x, y), "({x}, {y})"); + } +} + +#[test] +fn one_weight_on_every_side_is_the_uniform_ring() { + let ring = rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], StrokeAlign::Inside); + let want = area(&rounded_rect(W, H, [8.0; 4], 0.0)) - area(&rounded_rect(W - 4.0, H - 4.0, [6.0; 4], 0.0)); + assert!((area(&ring) - want).abs() < 1e-6, "{} vs {want}", area(&ring)); + assert_eq!(ring.winding(Point::new(60.0, 30.0)), 0, "a hole under non-zero too"); +} + +#[test] +fn center_and_outside_grow_the_box_and_its_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [8.0 + out; 4], 0.0)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [8.0 - inset; 4], 0.0)); + assert!((a - (outer - hole)).abs() < 1e-6, "{align:?}: {a} vs {}", outer - hole); + } +} + +/// 8 × 7 inner ellipses at the bottom corners. +#[test] +fn a_bottom_border_tapers_into_the_bottom_corners() { + let bottom = rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0, 0.0, 1.0, 0.0], StrokeAlign::Inside); + assert!(painted(&bottom, 60.0, 59.5) && painted(&bottom, 8.0, 59.5) && painted(&bottom, 112.0, 59.5)); + for (x, y) in [(60.0, 0.5), (0.5, 30.0), (0.5, 52.0), (119.5, 52.0), (4.0, 4.0)] { + assert!(!painted(&bottom, x, y), "({x}, {y})"); + } +} + +#[test] +fn a_weight_past_the_radius_leaves_square_inner_corners() { + let top = rounded_side_strokes(W, H, [8.0; 4], 0.0, [10.0, 0.0, 0.0, 2.0], StrokeAlign::Inside); + assert!(!painted(&top, 2.2, 10.2) && !painted(&top, 119.8, 10.2)); + assert!(painted(&top, 60.0, 9.8) && painted(&top, 1.8, 30.0)); +} + +/// A weight past `w − R` leaves inner corners wider than the inner box: they shrink to fit, so the +/// hole stays inside the outer edge, across or down. +#[test] +fn inner_corners_shrink_to_fit_a_narrow_inner_box() { + let wide = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 12.0], StrokeAlign::Inside); + assert!(painted(&wide, 6.0, 10.0) && !painted(&wide, 16.0, 10.0)); + assert!(!painted(&wide, 17.5, 3.0) && !painted(&wide, 17.5, 17.0), "nothing past the outer edge"); + let tall = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [12.0, 0.0, 0.0, 0.0], StrokeAlign::Inside); + assert!(painted(&tall, 10.0, 6.0) && !painted(&tall, 10.0, 16.0)); + assert!(!painted(&tall, 3.0, 17.5) && !painted(&tall, 17.0, 17.5), "nothing past the outer edge"); + let pill = rounded_side_strokes(30.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 25.0], StrokeAlign::Inside); + assert!(painted(&pill, 12.0, 10.0) && !painted(&pill, 27.0, 10.0)); + assert!(!painted(&pill, 24.0, 0.5) && !painted(&pill, 24.0, 19.5), "nothing past the outer edge"); +} + +#[test] +fn smoothed_corners_keep_the_stroke_inside_the_fill() { + let smooth = rounded_side_strokes(W, H, [16.0; 4], 0.6, [0.0, 0.0, 2.0, 0.0], StrokeAlign::Inside); + let fill = rounded_rect(W, H, [16.0; 4], 0.6); + let grid = (0..1200).flat_map(|i| (0..600).map(move |j| Point::new(f64::from(i) * 0.1 + 0.05, f64::from(j) * 0.1 + 0.05))); + let stray = grid.filter(|pt| painted(&smooth, pt.x, pt.y) && (pt.y < H / 2.0 || fill.winding(*pt) == 0)); + assert_eq!(stray.count(), 0, "painted outside the fill or along the top"); + assert!(painted(&smooth, 60.0, 59.5)); +} + +#[test] +fn weights_wider_than_the_box_leave_the_outer_edge() { + let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside); + assert!((area(&full) - area(&rounded_rect(20.0, 10.0, [4.0; 4], 0.0))).abs() < 1e-6); +} + +#[test] +fn no_weight_paints_nothing() { + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0; 4], StrokeAlign::Inside).elements().is_empty()); + let junk = [f64::NAN, -4.0, f64::INFINITY, 0.0]; + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, junk, StrokeAlign::Inside).elements().is_empty()); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..3718df26 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { // Per-side strokes on rectangles/frames. if let Some(sides) = props.sides && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) - && n.corners.uniform() == Some(0.0) { - return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); + let c = &n.corners; + return Arc::new(if c.uniform() == Some(0.0) { + figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align) + } else { + figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align) + }); } // Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines. if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..a5a59831 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -129,6 +129,27 @@ fn strokes_align() { assert_eq!(px(&pix, 30, 8)[0], 0xF5); } +#[test] +fn side_strokes_follow_rounded_corners() { + // border-l-4 rounded-r-lg: a straight 4 px bar on the left, nothing on the other edges. + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let pix = render_view(&d, p, &view(150, 80), &Options::default()).unwrap(); + for x in 10..14 { + assert_eq!(px(&pix, x, 40), [0, 0, 0, 255], "x {x}"); + } + for (x, y) in [(14, 40), (129, 40), (126, 12), (126, 67), (70, 10), (70, 69)] { + assert_eq!(px(&pix, x, y), [255, 255, 255, 255], "({x}, {y})"); + } +} + #[test] fn mask_clips_siblings() { let mut d = figcraft_doc::Document::new("t"); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..fa8877c1 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -135,6 +135,26 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); } +#[test] +fn side_strokes_on_rounded_corners_export_as_their_area() { + // border-l-4 rounded-r-lg: the stroke path is the 4 px bar (60 px tall), drawn even-odd. + use figcraft_geom::Shape; + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + n.stroke.align = figcraft_doc::StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap(); + let stroke = svg.split(" Date: Thu, 22 Oct 2026 10:12:37 +0200 Subject: [PATCH 29/98] M3: a swapped nested instance keeps its outer main's overrides A Button main's override on its icon instance's glyph (a red fill) was lost when an instance swapped the icon: the outer rebuild re-copies the glyph from the main's layer with no overrides of its own, so the nested rebuild matched it by name and carried nothing. Before that rebuild, each old layer of the swapped instance now takes the overrides held along the source chain it had before this rebuild, through layers inside instances in the mains that place the swapped instance. Geometry, slot content and groups the layer overrides itself are left out. Matched by name, the values carry onto the new main's layer as the instance's own: inherited at the swap, frozen after, and back to the icon main's value on reset. An icon main's own overrides stay with it. --- crates/engine/src/components.rs | 72 ++++++++- crates/engine/src/nested_override_tests.rs | 176 +++++++++++++++++++++ docs/mcp.md | 4 +- 3 files changed, 249 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..a91b8129 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -830,9 +830,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT } // Nested instances showing another main than the main's copy (swapped): rebuild from it, // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect(); + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); for n in nested { - let below: BTreeMap> = unused + let mut below: BTreeMap> = unused .iter() .filter(|(_, x)| { let mut cur = x.parent; @@ -847,10 +848,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT }) .map(|(i, x)| (*i, x.clone())) .collect(); + take_placing_overrides(doc, n, &old, &mut below); sync_instance_depth(doc, n, depth + 1, below); } } +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + /// Whether main components `a` and `b` are two variants of one set. fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) diff --git a/crates/engine/src/nested_override_tests.rs b/crates/engine/src/nested_override_tests.rs index f684a693..0d61edf7 100644 --- a/crates/engine/src/nested_override_tests.rs +++ b/crates/engine/src/nested_override_tests.rs @@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() { assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty()); healthy(&s); } + +/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property +/// `Icon` on the nested icon: (icon, shape, button, nested). +fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let (icon_main, shape, button, nested) = button(s); + let main_shape = copy_of(s, nested, shape); + ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}})); + ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested})); + (icon_main, shape, button, nested) +} + +/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`. +fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) { + ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}})); + healthy(s); +} + +#[test] +fn a_main_override_carries_onto_a_swapped_nested_instance() { + // Documented: overrides survive a swap when layer names match, and a swapped icon takes the + // colour the main component set (forum thread with staff replies). Rows: by the instance-swap + // property and by instance.swap, onto a matching and an unmatched name. The main's size + // override is geometry and stays out; the instance's own override wins over the main's. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}})); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph"); + for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + if by_property { + swap_icon(&mut s, i, to); + } else { + let inested = copy_of(&s, i, nested); + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to})); + healthy(&s); + } + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}"); + assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out"); + } + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600}))); + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}})); + swap_icon(&mut s, i, other); + assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins"); +} + +#[test] +fn a_main_override_carries_onto_a_swap_one_level_deeper() { + // As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"}))); + let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar}))); + let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]}))); + let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800}))); + let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested)); + ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other})); + healthy(&s); + assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000"); +} + +#[test] +fn a_swapped_nested_instance_keeps_the_main_override_it_took() { + // Documented (staff reply): later edits of the main's icon colour don't reach instances that + // swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon + // main's own fill. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500}))); + swap_icon(&mut s, swapped, other); + let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}})); + healthy(&s); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + s.undo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into())); + s.redo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0]; + ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"})); + healthy(&s); + assert_eq!(glyph(&s, swapped), "111111"); +} + +#[test] +fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() { + // Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a + // swap to it, its edits still reach the instance; a second swap doesn't carry them on. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let dot = icon(&mut s, 200.0, "Dot", "Shape"); + let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name}))); + let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f}))); + if let Some(c) = fill { + let shape = kids(s, d)[0]; + ex(s, "node.set", json!({"id": shape, "props": {"fill": c}})); + } + (idv(&ex(s, "component.create", json!({"ids": [f]}))), d) + }; + let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff")); + let (other3, _) = with_dot(&mut s, 400.0, "Other3", None); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + swap_icon(&mut s, i, other2); + let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape_in(&s)), "0000FF"); + let shape2 = kids(&s, dot2)[0]; + ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}})); + healthy(&s); + assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it"); + swap_icon(&mut s, i, other3); + assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3"); +} + +#[test] +fn a_swapped_out_icons_own_overrides_stay_with_it() { + // Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the + // Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built + // on Base, shows Base's own fill. + let mut s = Session::new(); + let base = icon(&mut s, 0.0, "Base", "Shape"); + let on_base = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name}))); + let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f}))); + (idv(&ex(s, "component.create", json!({"ids": [f]}))), b) + }; + let (a, a_base) = on_base(&mut s, 100.0, "A"); + let (b, _) = on_base(&mut s, 200.0, "B"); + let a_shape = kids(&s, a_base)[0]; + ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f}))); + let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800}))); + swap_icon(&mut s, i, b); + let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape), "111111"); + assert!(node(&s, shape).overrides.is_empty()); +} + +#[test] +fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() { + // Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's + // slot doesn't carry onto the swapped-in card, which shows its own default content. + let mut s = Session::new(); + let card = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name}))); + let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}})); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + ex(s, "component.makeSlot", json!({"id": content})); + c + }; + let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B")); + let chip = icon(&mut s, 400.0, "Chip", "Dot"); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f}))); + let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true})); + ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700}))); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}})); + healthy(&s); + let content = kids(&s, copy_of(&s, i, nested))[0]; + let names: Vec = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect(); + assert_eq!(names, ["B default"]); + assert!(!node(&s, content).overrides.contains(SLOT)); +} diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..485bfe48 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -720,7 +720,9 @@ most when it can't be reached). - `instance.swap {ids?, component}` shows another component in the instances (overrides on layers with matching names stay; property values with the same names too). Nested instances can be - swapped on their own (an override of the instance they're in). + swapped on their own (an override of the instance they're in); a swapped nested instance keeps + what its outer main overrode on matching layers as its own, so later edits of those in the main + don't reach it. - `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under it (innermost first), the first showing a variant of the same component set, else the innermost (never one inside the dropped component); only text layers keep their overrides. The Assets From 73a8bb83d67df3505a1e97eabcea0d71b8e3ce0d Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 22 Oct 2026 12:24:24 +0200 Subject: [PATCH 30/98] M7: test Type settings' width through the whole app in Georgian and French (#1029) --- .../tests/type_settings_long_labels.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 crates/ui-egui/tests/type_settings_long_labels.rs diff --git a/crates/ui-egui/tests/type_settings_long_labels.rs b/crates/ui-egui/tests/type_settings_long_labels.rs new file mode 100644 index 00000000..8410e3cf --- /dev/null +++ b/crates/ui-egui/tests/type_settings_long_labels.rs @@ -0,0 +1,44 @@ +//! #1029, through the whole app: in a language whose labels run past Type settings' label column, +//! the popover keeps its width while it stays open. French does with Inter (release builds, the +//! report); Georgian does with egui's own fonts too (CI, which builds without craft-fonts). Its own +//! test binary, since the UI language is one setting for the whole process. +#![allow(clippy::unwrap_used)] + +use egui::{Id, Popup, Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +/// The Type settings popover's width over the frames it is open, the UI in `language`. +fn widths(language: &str) -> Vec { + let mut s = Session::new(); + s.execute("pref.language", &json!({"language": language})).unwrap(); + // Truncating text also shows Max lines. + let text = s.execute("text.create", &json!({"text": "Type", "props": {"textAutoResize": "truncate"}})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [text]})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(NoServices)); + let open = Id::new("ty_settings_open"); + let mut widths = Vec::new(); + for i in 0..40 { + if i == 3 { + Popup::open_id(&ctx, open); + } + let raw = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; + ctx.run_ui(raw, |ui| app.ui(ui)).textures_delta.clear(); + widths.extend(ctx.memory(|m| m.area_rect(open)).map(|r| r.width())); + } + widths +} + +#[test] +fn type_settings_keeps_its_width_with_labels_longer_than_their_column() { + for language in ["ka", "fr"] { + let w = widths(language); + assert!(w.len() > 30, "{language}: Type settings is open ({} frames)", w.len()); + assert_ne!(figcraft_ui_egui::i18n::tr("Paragraph indent"), "Paragraph indent", "{language} is shown"); + let (first, last) = (w[1], w[w.len() - 1]); + assert!((last - first).abs() < 0.01, "{language}: Type settings widens every frame: {first} → {last}"); + } +} From 6394e96005ff4dbc8649045a979c32fe1fa4da93 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 10:41:53 +0200 Subject: [PATCH 31/98] M3: a variable mode set on a layer inside an instance is an override A variable mode chosen on a layer inside an instance (the icon inside a Close button, recoloured through an icon colour collection) wasn't an override group. A full rebuild of the instance starts each layer over from its main and carries only override groups, so the mode fell back to the main's. Library import always rebuilds, as do main edits such as a new auto layout direction, so a modal's recoloured close icon came into other files in its default colour. "modes" is now an override group: setting a mode inside an instance records it, rebuilds carry it, rebasing a nested instance finds it, the component panel lists it, and resetting it brings back the main's mode. A top-level instance's own modes stay its own and record no override, like its resizing. --- crates/engine/src/cmd/component.rs | 2 +- crates/engine/src/components.rs | 27 ++++++---- .../src/components_rebase_source_tests.rs | 14 ++++++ crates/engine/src/instance_modes_tests.rs | 49 +++++++++++++++++++ crates/engine/src/library_variables_tests.rs | 36 ++++++++++++++ crates/ui-egui/src/component_panel.rs | 1 + 6 files changed, 119 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6a5b1012..01914640 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { spec!( "component.resetOverride", "Reset override", - "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}", true, reset_override ), diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..f011625d 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -12,9 +12,10 @@ //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, //! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers -//! inside an instance, `component` (a swapped nested instance), `props` (property values of a -//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own -//! export settings, interactions and resizing are its own, like its place. `position` +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `component` (a swapped nested instance), `props` (property values of a nested instance) and +//! `slot` (a slot holding the instance's own content). An instance's own export settings, +//! interactions, resizing and variable modes are its own, like its place. `position` //! overrides of layers inside an instance are still read and kept, but new ones are refused //! ([`check_override_edit`]): those layers take their place and constraints from the main. //! @@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "size", "resizing", "position", + "modes", "component", "props", SLOT, @@ -415,6 +417,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); } "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { *b = *a; @@ -1049,6 +1052,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if a.interactions != b.interactions { out.push("interactions"); } + if a.var_modes != b.var_modes { + out.push("modes"); + } if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { if x != y { out.push("component"); @@ -1146,10 +1152,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, resizing, export settings and interactions are its own, not - // overrides. + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1969,7 +1975,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .filter(|g| placed_source.is_some() || g != "resizing") + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) .collect(); let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; if named { @@ -1993,8 +1999,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } if let Some(n) = doc.node_mut(inst) { n.source = root_base; - // A top-level instance's resizing is its own (a nested one's overrides its main layer's). - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); n.overrides.extend(root_groups); } } diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs index b272b82f..94eae71f 100644 --- a/crates/engine/src/components_rebase_source_tests.rs +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); } +#[test] +fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() { + let mut s = scene(); + let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]); + s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone(); + both(&mut s); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode"); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + rebase_instance(&mut s.doc, detached); + let n = s.doc.node(detached).unwrap(); + assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own"); +} + #[test] fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { let mut s = scene(); diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 79007622..0836048f 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -126,3 +126,52 @@ fn detaching_keeps_the_mode_it_took_from_its_main() { assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); } + +/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button +/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal). +fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) { + tone(s); + let icon = swatch(s, "Icon", None); + let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame}))); + set_mode(s, button_icon, json!("A")); + let button = id(run(s, "component.create", json!({"ids": [frame]}))); + let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer}))); + let nested = s.doc().unwrap().node(close).unwrap().children[0]; + set_mode(s, nested, json!("B")); + run(s, "component.create", json!({"ids": [outer]})); + (button, button_icon, nested) +} + +#[test] +fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() { + let mut s = Session::new(); + let (button, button_icon, nested) = modal(&mut s); + assert_eq!(fill(&s, nested), "0000FF"); + // An edit the copies can't take as they are rebuilds the Button instance. + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy"); + // The main's layer choosing another mode doesn't reach it either. + set_mode(&mut s, button_icon, json!("B")); + set_mode(&mut s, button_icon, Value::Null); + assert_eq!(fill(&s, nested), "0000FF"); + run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"})); + assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)"); + assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty()); +} + +#[test] +fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { + let mut s = Session::new(); + let (button, _, _) = modal(&mut s); + // A Button placed on the page, its icon recoloured through the icon's mode. + let placed = instance(&mut s, button); + let icon = s.doc().unwrap().node(placed).unwrap().children[0]; + set_mode(&mut s, icon, json!("B")); + assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"})); + assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it"); + run(&mut s, "component.resetInstance", json!({"ids": [placed]})); + assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); +} diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4fc778a7..7104164b 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -344,3 +344,39 @@ fn library_style_bindings_ignore_unrelated_local_variables() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn modes_set_inside_nested_instances_come_along() { + let d = dir("nested-modes"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + theme(&mut lib); + let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(&mut lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(&mut lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(&mut lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(&mut lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(&mut lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(&mut lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(&mut lib, "component.create", json!({"ids": [m]})); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + let copy = |s: &Session| { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] + }; + assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); + // An update that rebuilds the Button copies keeps it too. + ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, copy(&s)), "FFFFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 2b2573c7..4db613ab 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -521,6 +521,7 @@ fn group_label(g: &str) -> String { "grids" => tr("Layout grids"), "exports" => tr("Export settings"), "interactions" => tr("Interactions"), + "modes" => tr("Variable modes"), "size" => tr("Size"), "resizing" => tr("Resizing"), "position" => tr("Position"), From f1c206509f89c5a4d26c98f2b8219fcc0640d6fd Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 13:41:51 +0200 Subject: [PATCH 32/98] M3: files from before the modes override keep the modes set inside their instances A file or library saved before "modes" was an override group has a mode chosen on a layer inside an instance without its marker, so the next rebuild or library import still put the main's mode back. Copies follow their main layer's modes when the main changes them, so a layer inside an instance on other modes chose them itself. mark_layer_modes marks those when a file opens and when a published library is read from a file or a server. Layers on their main's modes and top-level instances stay unmarked. --- crates/engine/src/components.rs | 17 ++++++ crates/engine/src/instance_modes_tests.rs | 19 +++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/library.rs | 9 ++- crates/engine/src/library_server.rs | 2 +- crates/engine/src/library_variables_tests.rs | 60 ++++++++++++++------ 6 files changed, 90 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index f011625d..c0612bcd 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -116,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { enclosing_instance(doc, id).is_some() } +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + /// The slot property frame `id` stands for (its name and preferred components): a frame bound to /// a slot property of its component, or an instance's copy of one (followed through its sources). pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 0836048f..43de3ca4 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -175,3 +175,22 @@ fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { run(&mut s, "component.resetInstance", json!({"ids": [placed]})); assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); } + +#[test] +fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() { + let mut s = Session::new(); + let (button, _, nested) = modal(&mut s); + // Saved before `modes` was an override group: the copy has its mode but no marker. + s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id())); + let p = path.to_string_lossy().into_owned(); + run(&mut s, "file.saveAs", json!({"path": p})); + let mut s = Session::new(); + run(&mut s, "file.open", json!({"path": p})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose"); + assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..d878d6ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -413,6 +413,7 @@ impl DocState { } doc.link_layer_text_styles(); cmd::styles::apply_paint_bindings_on_load(&mut doc); + components::mark_layer_modes(&mut doc); // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 26863561..9740182c 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -553,11 +553,18 @@ fn stat(_path: &str) -> std::result::Result { Err(NO_FILES.into()) } +/// A library read from a file or a server, with the modes chosen inside its instances marked when +/// it was published before they were overrides ([`crate::components::mark_layer_modes`]). +pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary { + crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc)); + lib +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(settled(lib)), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), Err(e) => Err(format!("{path}: {e}")), } diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index 99d5f29a..10852b5f 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result> { /// The library in an archive from a server (untrusted: read with the library file's limits). pub fn read_archive(bytes: &[u8]) -> std::result::Result { match figcraft_format::read_library(Cursor::new(bytes)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(crate::library::settled(lib)), Ok(None) => Err("the server sent a file without a library".into()), Err(e) => Err(format!("the server sent a library that can't be read ({e})")), } diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 7104164b..b4a4171a 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -345,32 +345,42 @@ fn library_style_bindings_ignore_unrelated_local_variables() { let _ = std::fs::remove_dir_all(&d); } +/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main, +/// the icon copy in Modal). +fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) { + theme(lib); + let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(lib, "component.create", json!({"ids": [m]})); + (button, nested) +} + +/// The icon inside the Button inside library Modal instance `inst`. +fn modal_icon(s: &Session, inst: NodeId) -> NodeId { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] +} + #[test] fn modes_set_inside_nested_instances_come_along() { let d = dir("nested-modes"); let lib_path = path_str(&d.join("kit.figcraft")); let mut lib = Session::new(); - theme(&mut lib); - let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); - ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); - let icon = idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))); - let b = idv(&ex(&mut lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); - ex(&mut lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); - let button = idv(&ex(&mut lib, "component.create", json!({"ids": [b]}))); - let m = idv(&ex(&mut lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); - let close = idv(&ex(&mut lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); - let nested = lib.doc().unwrap().node(close).unwrap().children[0]; - ex(&mut lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); - ex(&mut lib, "component.create", json!({"ids": [m]})); + let (button, _) = nested_mode_kit(&mut lib); let lid = publish_to(&mut lib, &lib_path); let mut s = Session::new(); ex(&mut s, "library.add", json!({"path": lib_path})); let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); - let copy = |s: &Session| { - let doc = s.doc().unwrap(); - doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] - }; + let copy = |s: &Session| modal_icon(s, inst); assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); // An update that rebuilds the Button copies keeps it too. ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); @@ -380,3 +390,21 @@ fn modes_set_inside_nested_instances_come_along() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() { + let d = dir("nested-modes-old"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (_, nested) = nested_mode_kit(&mut lib); + // Published before `modes` was an override group: the copy has its mode but no marker. + lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} From 6d748536b6f9460beb5168a13de1e157fe524931 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:01:37 +0000 Subject: [PATCH 33/98] Drop XML-forbidden control characters from live-text SVG export Found while testing crates/svg/src/lib.rs:153 (esc, used for tspan text at :598, :608 and :613) Co-Authored-By: Claude Opus 5.5 --- crates/svg/src/lib.rs | 10 +++++++++- crates/svg/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..f5a23b1b 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -150,8 +150,16 @@ fn blend_style(b: figcraft_color::BlendMode) -> String { } } +/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters +/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + s.chars() + .filter(|&c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}')) + .collect::() + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) } fn hex(c: Color) -> String { diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..1aafc987 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -135,6 +135,34 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); } +/// Control characters in text (a pasted vertical-tab soft break, U+0001) are not allowed in XML: +/// live-text export must leave them out so the SVG still parses, for plain and per-run spans. +#[test] +fn live_text_export_drops_xml_forbidden_control_characters() { + use figcraft_doc::{StyleRun, TextData}; + figcraft_text::scan_system_fonts(); + for styled in [false, true] { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("line one\u{b}line two\u{1}"); + text.style.font_size = 24.0; + if styled { + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 5, end: 12, style }); + text.normalize(); + } + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 40.0).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap(); + assert!(!svg.contains('\u{b}') && !svg.contains('\u{1}'), "{svg:?}"); + assert!(svg.contains("line") && svg.contains("two"), "{svg}"); + let mut d2 = Document::new("t2"); + let p2 = d2.first_page().unwrap(); + assert!(import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").is_ok(), "{svg:?}"); + } +} + #[test] fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { use figcraft_doc::{StrokeJoin, VectorNetwork}; From 3d5f3e79464f728aa4490349cb2cded5814ecdc6 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 16:41:30 +0200 Subject: [PATCH 34/98] M5: prototype actions lay out what they change like the canvas does Change to rebuilt the instance from the new variant, and set variable and set variable mode re-applied bindings, but the player never laid the result out: no text sizing, no auto layout. A hugging button with a longer label than its main showed the other variant at the main's width, its label clipped, and a text bound to a variable kept its old size. These actions now run the engine's normalize on the player's document afterwards, the pass that follows every edit on the canvas: text resizes to what it shows, instances follow and auto layout reflows. --- crates/ui-egui/src/present.rs | 19 +++- crates/ui-egui/src/present_behaviour_tests.rs | 93 +++++++++++++++++++ docs/prototyping-parity.md | 1 + 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..a538db4b 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -511,6 +511,16 @@ impl Presenter { self.rev += 1; } + /// After an action changed layers of `design` (since `before`): text resizes to what it shows, + /// instances follow and auto layout reflows, as after an edit on the canvas; then + /// [`relayout`](Self::relayout). + fn settle(&mut self, before: &Document) { + if let Some(d) = self.design.as_mut() { + figcraft_engine::normalize(d, before); + } + self.relayout(); + } + /// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames /// keep their size). Lays out again only when it changes. fn respond_to(&mut self, size: Option<(f64, f64)>) { @@ -879,28 +889,31 @@ impl Presenter { } self.begin(tr, dur, easing, now); let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); if let Some(n) = d.node_mut(inst) && let NodeKind::Instance { component, .. } = &mut n.kind { *component = *dest; } figcraft_engine::components::sync_instance(d, inst); - self.relayout(); + self.settle(&before); true } Action::SetVariable { variable, value, expr } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref()); if set { - self.relayout(); + self.settle(&before); } set } Action::SetVariableMode { collection, mode } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); if set { - self.relayout(); + self.settle(&before); } set } diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs index 791e0d3f..e9a036f2 100644 --- a/crates/ui-egui/src/present_behaviour_tests.rs +++ b/crates/ui-egui/src/present_behaviour_tests.rs @@ -454,6 +454,99 @@ fn interactive_components_change_in_place() { } } +/// A hugging auto layout frame (in `parent`, else on the page) holding text `chars`: (frame, text). +fn hugging_label(s: &mut Session, parent: Option, name: &str, x: f64, chars: &str) -> (NodeId, NodeId) { + let f = id(&ex(s, "frame.create", json!({"x": x, "y": 600, "width": 60, "height": 30, "name": name, "parent": parent}))); + let t = id(&ex(s, "text.create", json!({"text": chars, "x": 0, "y": 0, "parent": f}))); + ex(s, "node.set", json!({"ids": [f], "props": {"layout": "horizontal", "padding": 8, "sizingH": "hug", "sizingV": "hug"}})); + (f, t) +} + +fn size(d: &Document, n: NodeId) -> (f64, f64) { + d.node(n).map(|n| (n.width, n.height)).unwrap() +} + +#[test] +fn change_to_lays_out_the_instance_around_its_own_content() { + // Change to shows the other variant in place: the instance keeps its overrides (a longer + // label), so it is laid out around them, as switching the variant on the canvas does. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let variants: Vec = ["Default", "Hover"] + .iter() + .enumerate() + .map(|(k, state)| { + let (f, _) = hugging_label(&mut s, None, &format!("State={state}"), k as f64 * 150.0, "Button"); + id(&ex(&mut s, "component.create", json!({"ids": [f]}))) + }) + .collect(); + let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + on(&mut s, variants[0], json!({"type": "onClick"}), json!({"type": "changeTo", "dest": variants[1]}), json!({"type": "instant"}), 0); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let button = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen}))); + let label = s.doc().unwrap().node(button).unwrap().children[0]; + ex(&mut s, "text.set", json!({"id": label, "characters": "A much longer label"})); + let (main, canvas, canvas_label) = { + let d = s.doc().unwrap(); + (size(d, variants[1]), size(d, button), size(d, label)) + }; + // Without any font nothing is measured and both sizes are the main's. + if figcraft_text::layout_node(s.doc().unwrap().node(label).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()) { + assert!(canvas.0 > main.0 + 20.0, "on the canvas the instance hugs its longer label"); + } + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(button, 0, None, 1.0)); + let d = p.doc.as_ref().unwrap(); + assert!(matches!(d.node(button).unwrap().kind, NodeKind::Instance { component, .. } if component == variants[1])); + assert_eq!((size(d, button), size(d, label)), (canvas, canvas_label), "the Hover variant, laid out around the instance's label"); +} + +/// A screen holding a hugging chip whose text is bound to string variable `label` of collection +/// "Copy" (modes A "Short" and B "A much longer label"), the chip's click running `action`: +/// (session, screen, chip, text). +fn bound_chip(action: Value) -> (Session, NodeId, NodeId, NodeId) { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Copy", "modes": ["A", "B"]})); + ex( + &mut s, + "variable.create", + json!({"collection": "Copy", "name": "label", "type": "string", "values": {"A": "Short", "B": "A much longer label"}}), + ); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let (chip, text) = hugging_label(&mut s, Some(screen), "Chip", 20.0, "Short"); + ex(&mut s, "variable.bind", json!({"ids": [text], "field": "characters", "variable": "label"})); + on(&mut s, chip, json!({"type": "onClick"}), action, json!({"type": "instant"}), 0); + (s, screen, chip, text) +} + +#[test] +fn set_variable_and_set_mode_lay_out_the_text_they_change() { + // A text bound to a string variable shows what an interaction sets, measured: the hugging + // chip around it grows with it, to the size the canvas gives the same text. + let cases = [ + ( + json!({"type": "setVariable", "variable": "label", "value": "A much longer label"}), + "variable.set", + json!({"variable": "label", "value": "A much longer label"}), + ), + (json!({"type": "setVariableMode", "collection": "Copy", "mode": "B"}), "variable.setMode", json!({"collection": "Copy", "mode": "B"})), + ]; + for (action, canvas_cmd, canvas_params) in cases { + let (mut s, screen, chip, text) = bound_chip(action.clone()); + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(chip, 0, None, 1.0), "{action}"); + let page = s.doc().unwrap().page_of(screen).unwrap(); + let mut params = canvas_params; + if canvas_cmd == "variable.setMode" { + params["ids"] = json!([page]); + } + ex(&mut s, canvas_cmd, params); + let (d, canvas) = (p.doc.as_ref().unwrap(), s.doc().unwrap()); + assert_eq!((size(d, chip), size(d, text)), (size(canvas, chip), size(canvas, text)), "{action}: the chip grows with its text"); + } +} + #[test] fn a_drag_started_transition_follows_the_pointer() { let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400); diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index e4cf8c7f..3adb30c0 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -56,6 +56,7 @@ case pins the current behaviour). | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` | | Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead | | Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` | +| Change to, set variable and set variable mode lay out what they change as the canvas does: text sizes to what it shows, hugging frames around it | **fixed** | the player didn't lay out after them: an instance with a longer label changed to the main's width, and a bound text kept its old size; `change_to_lays_out_the_instance_around_its_own_content`, `set_variable_and_set_mode_lay_out_the_text_they_change` | | Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` | | Set variable mode, then a conditional or set variable later in the same interaction: they see the new mode | pass | `set_variable_mode_changes_what_later_expressions_read` | | A set variable writes every mode of the variable, so a lookup of any mode reads the new value; a frame inside the screen that chose a mode keeps it after set variable mode | silent | pinned as stated | From ad9116ff5c8b59b10c4a1b95f29a4ae613f80c6f Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Thu, 22 Oct 2026 17:01:21 +0200 Subject: [PATCH 35/98] M5: the prototyping parity status line and history name the layout fix --- docs/prototyping-parity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 3adb30c0..fed6a639 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-17 · **Change:** minor (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-22 · **Change:** minor (Change to, set variable and set variable mode lay out what they change in the player, as the canvas does) · **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, @@ -101,6 +101,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 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 | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | From 2437a65d1bbd2ad8b2645028a095da08a5f56f99 Mon Sep 17 00:00:00 2001 From: lileeei Date: Thu, 22 Oct 2026 23:04:57 +0800 Subject: [PATCH 36/98] M1: fill/stroke props accept "none" and null (#1036) node.create, node.set and the other prop appliers map fill/stroke "none" (either case) and null to an empty fills/strokes list, as fills: [] already did (undocumented), so an outline-only shape is one call - agents write "none" for no paint. KEYS and docs/mcp.md document it; the error names the accepted forms. fill_none_tests covers create, set, undo, text and the error. --- ROADMAP.md | 4 +- crates/engine/src/cmd/props.rs | 23 ++++++-- crates/engine/src/fill_none_tests.rs | 88 ++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + docs/mcp.md | 2 + 5 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/fill_none_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..d92ae3ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (`fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..83e2830a 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ @@ -372,6 +372,11 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { } } +/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does. +fn paint_none(v: &Value) -> bool { + v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")) +} + /// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable /// bindings last so paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { @@ -462,13 +467,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } } "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, diff --git a/crates/engine/src/fill_none_tests.rs b/crates/engine/src/fill_none_tests.rs new file mode 100644 index 00000000..74fe6231 --- /dev/null +++ b/crates/engine/src/fill_none_tests.rs @@ -0,0 +1,88 @@ +//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the +//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn create_rect(s: &mut Session, fill: Value) -> NodeId { + id(ex( + s, + "node.create", + json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1} + }), + )) +} + +#[test] +fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() { + for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, none.clone()); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(n.fills.is_empty(), "{none:?}: fills not empty"); + assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept"); + } +} + +#[test] +fn fill_none_clears_an_existing_fill_as_one_undo_step() { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); + ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); +} + +#[test] +fn stroke_none_or_null_clears_the_strokes() { + for none in [json!("none"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}})); + assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}"); + } +} + +#[test] +fn text_with_fill_none_gets_no_automatic_colour() { + let mut s = Session::new(); + let t = id(ex( + &mut s, + "node.create", + json!({ + "type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"} + }), + )); + assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty()); +} + +#[test] +fn an_unknown_string_still_names_the_key() { + let mut s = Session::new(); + let e = s + .execute( + "node.create", + &json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": "nope"} + }), + ) + .unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..8e369adf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1675,6 +1675,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod fill_none_tests; +#[cfg(test)] mod finite_geometry_tests; #[cfg(test)] mod frame_guides_tests; diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..484255c2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -144,6 +144,8 @@ Or in a project's `.mcp.json`: there is none, so they are page coordinates then). Groups and boolean groups have no coordinate space of their own: a layer in a group is placed in the frame (or on the page) the group is in. `inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates. + In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the + same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call. - Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around layers puts those fully inside it in it; sections go only on the page or in sections (moves, drags, pastes and new sections elsewhere are refused or land on the page); resizing a section From be6a547ff424113a599415223f989f58d54e8716 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 22 Oct 2026 18:01:15 +0200 Subject: [PATCH 37/98] M7: the Type settings test checks it stays open at 264 px every frame (#1029) --- crates/ui-egui/tests/type_settings_long_labels.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/type_settings_long_labels.rs b/crates/ui-egui/tests/type_settings_long_labels.rs index 8410e3cf..113b4daf 100644 --- a/crates/ui-egui/tests/type_settings_long_labels.rs +++ b/crates/ui-egui/tests/type_settings_long_labels.rs @@ -27,7 +27,11 @@ fn widths(language: &str) -> Vec { let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; ctx.run_ui(raw, |ui| app.ui(ui)).textures_delta.clear(); - widths.extend(ctx.memory(|m| m.area_rect(open)).map(|r| r.width())); + // From the frame after the one it opened on (egui's sizing pass). + if i > 3 { + assert!(Popup::is_id_open(&ctx, open), "{language}: Type settings closed on frame {i}"); + widths.push(ctx.memory(|m| m.area_rect(open)).unwrap().width()); + } } widths } @@ -36,9 +40,8 @@ fn widths(language: &str) -> Vec { fn type_settings_keeps_its_width_with_labels_longer_than_their_column() { for language in ["ka", "fr"] { let w = widths(language); - assert!(w.len() > 30, "{language}: Type settings is open ({} frames)", w.len()); assert_ne!(figcraft_ui_egui::i18n::tr("Paragraph indent"), "Paragraph indent", "{language} is shown"); - let (first, last) = (w[1], w[w.len() - 1]); - assert!((last - first).abs() < 0.01, "{language}: Type settings widens every frame: {first} → {last}"); + // The width it asks for (`Popup::width`), every frame. + assert!(w.iter().all(|x| (x - 264.0).abs() < 0.01), "{language}: Type settings widens: {} → {}", w[0], w[w.len() - 1]); } } From 83b9f74665f33ec0c25bbf66406981101d3fca1b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:04:10 +0200 Subject: [PATCH 38/98] Write a final Greek sigma in lower-case text layout Found while testing crates/text/src/layout.rs:1070 (shape_segment calls TextCase::apply_after one char at a time; crates/doc/src/text.rs:124 apply_after) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/text/src/layout.rs | 14 ++++++++++++++ crates/text/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 26ae3d33..f844b571 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1035,6 +1035,18 @@ fn is_emoji(c: char) -> bool { matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') } +/// The lower case of the capital sigma at `i`: the final form ς or σ, chosen from the letters +/// around it in the whole text (as lower-casing the whole string would), not from the lone char. +fn lower_sigma(chars: &[char], i: usize) -> char { + // Enough context to step over any realistic run of combining marks next to the sigma. + const CONTEXT: usize = 32; + let before: String = chars.get(i.saturating_sub(CONTEXT)..i).unwrap_or(&[]).iter().collect(); + let after: String = chars.iter().skip(i + 1).take(CONTEXT).collect(); + let lower = format!("{before}\u{3A3}{after}").to_lowercase(); + // Only the sigma's lower case depends on context, so the text before it keeps its length. + lower.get(before.to_lowercase().len()..).and_then(|s| s.chars().next()).unwrap_or('σ') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, @@ -1065,6 +1077,8 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe for u in c.to_uppercase() { mapped.push((u, src, true)); } + } else if style.case == TextCase::Lower && *c == 'Σ' { + mapped.push((lower_sigma(chars, src), src, false)); } else { let mut buf = [0u8; 4]; for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ebf5889c..4475fbbc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -497,3 +497,25 @@ fn the_caret_after_a_line_break_sits_on_the_next_line() { let l = layout(&TextData::new("ab\n"), None, None); assert_eq!(l.caret(3), (0.0, 1)); } + +#[test] +fn lower_case_writes_final_sigma() { + if !have_fonts() { + return; + } + let gids = |t: &TextData| layout(t, None, None).glyphs.iter().map(|g| (g.gid, g.char_start)).collect::>(); + let want = gids(&TextData::new("οδος ας")); + let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().map(|g| g.face.clone()) else { return }; + if face.glyph_for('σ') == face.glyph_for('ς') { + eprintln!("face has no distinct final sigma; skipping"); + return; + } + let mut t = TextData::new("ΟΔΟΣ ΑΣ"); + t.style.case = figcraft_doc::TextCase::Lower; + assert_eq!(gids(&t), want, "one run"); + // The word split across two styled runs: the final form is still chosen from the whole text. + let mut other = t.style.clone(); + other.font_size += 4.0; + t.runs.push(figcraft_doc::StyleRun { start: 3, end: 4, style: other }); + assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs"); +} From 21d666465fda16e0c9cc54bbbd8b6f8121c3bac1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:34:42 +0200 Subject: [PATCH 39/98] Remove all leftover temp files at server startup Fixes #1129 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/store.rs | 13 ++++++++++++- apps/figcraft-server/src/store_tests.rs | 26 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..8c6226a5 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -344,6 +344,9 @@ impl Store { /// [`open`](Self::open), or `None` when someone else holds the directory. pub fn try_open(root: &Path, cfg: StoreConfig) -> Result, String> { let Some(lock) = lock(root)? else { return Ok(None) }; + for name in [KEY, BLOCKED, REPORTS] { + let _ = fs::remove_file(disk::temp_of(&root.join(name))); + } let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; @@ -425,12 +428,20 @@ impl Store { /// A file folder's description and links, after checking its snapshot and log. Leftover /// temporary files (writes cut short) are removed. fn check(&self, path: &Path) -> Result { - for name in [SNAPSHOT, LOG, META, LINKS] { + for name in [SNAPSHOT, LOG, META, LINKS, USAGE] { let tmp = disk::temp_of(&path.join(name)); if tmp.exists() { let _ = fs::remove_file(&tmp); } } + if let Ok(entries) = fs::read_dir(path) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with("library-") && name.ends_with(".bin.tmp") { + let _ = fs::remove_file(e.path()); + } + } + } let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?; let links: Vec = serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?; diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..6625a0dd 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -313,3 +313,29 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +#[test] +fn every_leftover_temporary_file_is_removed_at_startup() { + let dir = TempDir::new("leftovers"); + let id = { + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let id = publish(&mut store, "A").0; + store.flush(); + id + }; + let folder = dir.path().join(FILES).join(&id); + let leftovers = [ + dir.path().join("blocked.json.tmp"), + dir.path().join("reports.json.tmp"), + dir.path().join("server.key.tmp"), + folder.join("usage.json.tmp"), + folder.join("library-1.bin.tmp"), + ]; + for path in &leftovers { + fs::write(path, b"half").unwrap(); + } + let _store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for path in &leftovers { + assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display()); + } +} From 82779ec0f29976b535389c1ee14324c1b7cbd8c8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:39:39 +0200 Subject: [PATCH 40/98] Guard file generation increments against u64 overflow Fixes #1128 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/store.rs | 11 ++++++++-- apps/figcraft-server/src/store_tests.rs | 27 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..cbd0e977 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1167,7 +1167,10 @@ impl Store { Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } } // A new snapshot and log of the next generation, so appends start clean. - _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?, + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } }; self.entry(file)?.log = Some(log); Ok(server) @@ -1243,7 +1246,11 @@ impl Store { fn compact(&mut self, file: &str, server: &Server) { self.compactions += 1; let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - match self.rewrite(file, generation + 1, server) { + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { Ok(log) => { if let Ok(e) = self.entry(file) { e.log = Some(log); diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..17623f2f 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -313,3 +313,30 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +#[test] +fn a_maxed_out_generation_does_not_wrap() { + let dir = TempDir::new("maxgen"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Max"); + let path = dir.path().join(FILES).join(&id); + let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + // Loading: no log goes with the snapshot, so the next generation would overflow. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap(); + fs::remove_file(path.join(LOG)).unwrap(); + assert!(store.open(&id).is_err()); + // Compacting: the open log is at the largest generation. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap(); + let _ = fs::remove_file(path.join(LOG)); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..4 { + rename(&mut server, &mut a, &format!("P{i}")); + store.changed(&id, &mut server); + store.flush(); + } + let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + assert_eq!(snap, u64::MAX); +} From 8c46b733e6c087b2631c1e57c88de0f8bdab415b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:43:04 +0200 Subject: [PATCH 41/98] Restrict a pre-existing data directory to 0700 Fixes #1127 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/store.rs | 4 ++++ apps/figcraft-server/src/store_tests.rs | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..0cf62685 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -302,6 +302,10 @@ pub fn lock(root: &Path) -> Result, String> { #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700); dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?; + // `DirBuilder` only applies the mode to directories it creates; tighten an existing one too. + #[cfg(unix)] + fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700)) + .map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?; let f = OpenOptions::new() .create(true) .truncate(false) diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..582c1bc8 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -229,6 +229,17 @@ fn one_server_per_data_directory() { assert_eq!(again.key(), key, "the key is kept"); } +#[cfg(unix)] +#[test] +fn an_existing_data_directory_is_made_private() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new("perms"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(crate::store::lock(dir.path()).unwrap().is_some()); + let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); +} + #[test] fn temporary_directories_are_removed() { let path = { From 57e08729438b8900895a73c00f158d6a97296a46 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:56:58 +0200 Subject: [PATCH 42/98] Bound the library/plugins close drain with a deadline Fixes #1120 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/collab-net/src/manage.rs | 37 +++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6e995bdb..dc3ec315 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -206,10 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result Result<(), String> { @@ -240,3 +245,31 @@ fn reply(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option< }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A server that keeps pinging and never answers the close must not pin `close` forever. + #[test] + fn close_returns_against_a_server_that_keeps_pinging() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + let Ok(mut ws) = tungstenite::accept(s) else { return }; + while ws.send(Message::Ping(Bytes::new())).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + }); + let limits = NetLimits { idle_timeout: Duration::from_millis(300), ..NetLimits::default() }; + let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false }; + let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + close(ws); + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(3)).is_ok(), "close() kept draining a pinging server"); + } +} From 7e114c3420ed93578cf68871acb9041151bcbcfc Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:03:10 +0200 Subject: [PATCH 43/98] Never drop Sync events when the live inbox is full Fixes #1119 Co-Authored-By: Claude Opus 5.5 --- crates/collab-net/src/inbox.rs | 87 +++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/collab-net/src/inbox.rs b/crates/collab-net/src/inbox.rs index fb505a0a..1ec00e67 100644 --- a/crates/collab-net/src/inbox.rs +++ b/crates/collab-net/src/inbox.rs @@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence}; pub type Waker = Arc; /// Most discrete events kept between two drains (joins, leaves, status); the oldest go first. +/// Document sync events don't count and are never dropped: the sync client treats a missing +/// batch as a fatal sequence gap, so losing one would end the session. const MAX_EVENTS: usize = 10_000; #[derive(Default)] struct State { - events: VecDeque, + /// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order. + next: u64, + /// [`Event::Sync`] events, never dropped. + syncs: VecDeque<(u64, Event)>, + /// Every other event, capped at [`MAX_EVENTS`]. + others: VecDeque<(u64, Event)>, presence: BTreeMap, } @@ -43,11 +50,17 @@ impl Inbox { if let Event::PeerLeft(id) = &e { s.presence.remove(id); } - if s.events.len() >= MAX_EVENTS { - log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); - s.events.pop_front(); + let n = s.next; + s.next += 1; + if matches!(e, Event::Sync(_)) { + s.syncs.push_back((n, e)); + return; } - s.events.push_back(e); + if s.others.len() >= MAX_EVENTS { + log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); + s.others.pop_front(); + } + s.others.push_back((n, e)); }); } @@ -62,8 +75,70 @@ impl Inbox { /// peer. pub fn drain(&self) -> Vec { let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner); - let mut out: Vec = s.events.drain(..).collect(); + let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable(); + let mut others = std::mem::take(&mut s.others).into_iter().peekable(); + let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len()); + loop { + let sync_first = match (syncs.peek(), others.peek()) { + (Some((a, _)), Some((b, _))) => a < b, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + let next = if sync_first { syncs.next() } else { others.next() }; + out.extend(next.map(|(_, e)| e)); + } out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p))); out } } + +#[cfg(test)] +mod tests { + use super::*; + use figcraft_collab::message::ServerMsg; + + fn reject(batch_id: u64) -> Event { + Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() }) + } + + fn sync_ids(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id), + _ => None, + }) + .collect() + } + + #[test] + fn a_full_inbox_never_drops_sync_events() { + let inbox = Inbox::new(Arc::new(|| {})); + inbox.push(reject(1)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + inbox.push(reject(2)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + let out = inbox.drain(); + assert_eq!(sync_ids(&out), vec![1, 2]); + assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len()); + } + + #[test] + fn sync_events_past_the_cap_are_all_kept_in_order() { + let inbox = Inbox::new(Arc::new(|| {})); + let n = MAX_EVENTS as u64 + 5; + for id in 0..n { + inbox.push(reject(id)); + } + inbox.push(Event::PeerLeft(7)); + let out = inbox.drain(); + let ids = sync_ids(&out); + assert_eq!(ids.len() as u64, n, "Sync events dropped"); + assert!(ids.iter().copied().eq(0..n), "Sync events out of order"); + } +} From 67ae35b1553d4870a01c1d5f8c1f0d88f3f9acbd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:11:15 +0200 Subject: [PATCH 44/98] Match widget data check depth to serde_json's recursion limit Fixes #1118 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/plugins.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..49efe3d3 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -155,7 +155,8 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { /// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys. fn writes_widget_data(params: &Value, depth: usize) -> bool { let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX))); - depth < 64 + // serde_json refuses nesting past 128, so nothing deeper can reach here. + depth < 128 && match params { Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)), Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)), @@ -592,3 +593,16 @@ pub fn specs() -> Vec { spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage), ] } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widget_data_is_found_at_any_depth_serde_accepts() { + let depth = 100; + let text = format!("{}{{\"pluginData\":{{\"{}x\":1}}}}{}", "{\"a\":".repeat(depth), crate::widgets::DATA_PREFIX, "}".repeat(depth)); + let v: Value = serde_json::from_str(&text).unwrap(); + assert!(writes_widget_data(&v, 0)); + } +} From 27ea0d4bdcb6ee1c1a0c250b33f9550972691a34 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Thu, 22 Oct 2026 14:14:09 -0500 Subject: [PATCH 45/98] FreeBSD package.sh: package the aarch64 cross build The release workflow now cross-compiles FreeBSD aarch64 inside the x86_64 VM (storytold/craft-common-actions#50) and sets CARGO_BUILD_TARGET=aarch64-unknown-freebsd. package.sh now reads the binaries from target//release when CARGO_BUILD_TARGET is set, names the archive after the target instead of uname -m, and skips the --version run when the binary is for another architecture than the machine. x86_64 packages are unchanged. Part of storytold/craft-common-actions#39. --- packaging/freebsd/package.sh | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index bb9a7799..107da888 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -31,12 +31,18 @@ while [ $# -gt 0 ]; do done # FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones. -MACHINE="$(uname -m)" -case "$MACHINE" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; -esac +release_arch() { + case "$1" in + amd64 | x86_64) echo x86_64 ;; + arm64 | aarch64) echo aarch64 ;; + *) echo "unsupported architecture $1" >&2; return 2 ;; + esac +} +HOST_ARCH="$(release_arch "$(uname -m)")" +# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to +# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. +ARCH="$HOST_ARCH" +if [ -n "${CARGO_BUILD_TARGET:-}" ]; then ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")"; fi if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 @@ -55,7 +61,8 @@ STAGE="$WORK/$BASENAME" rm -rf "$WORK" mkdir -p "$WORK" -BIN="$CARGO_TARGET_DIR/release" +# Cargo puts a cross build under target//release. +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" OUT_DIR="$DIST" if [ "$DRY_RUN" = 1 ]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. @@ -99,7 +106,11 @@ mkdir -p "$OUT_DIR" tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" -"$STAGE/bin/$(smoke_bin)" --version +if [ "$DRY_RUN" = 1 ] || [ "$ARCH" = "$HOST_ARCH" ]; then + "$STAGE/bin/$(smoke_bin)" --version +else + echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine" +fi if [ "$DRY_RUN" = 1 ]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz" From ca30791fd7991ce60f1312237f42436a751591ea Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:22:20 +0200 Subject: [PATCH 46/98] M0.9: figcraft-cli export names files as the app's export does (#1154) export into a folder replaced every character outside letters, digits, '-', '_', space and '.' with '_', so a frame called "Homepage - desktop (live + verbeteringen)" became "Homepage - desktop _live _ verbeteringen_.png". The app's file.exportLayers keeps those names and only replaces what file systems refuse. The CLI now uses the engine's safe_file_name, and docs/cli.md says which characters are replaced. Also adds my own entry to contributors/people.toml. --- apps/figcraft-cli/src/files.rs | 12 ++++++++++-- apps/figcraft-cli/tests/cli.rs | 24 ++++++++++++++++++++++++ contributors/people.toml | 5 +++++ crates/engine/src/cmd/file.rs | 6 ++++-- docs/cli.md | 4 ++++ 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46215599..111518b3 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -207,9 +207,11 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Ok(ExitCode::SUCCESS) } +/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`], +/// so `/` becomes `_` too), without leading or trailing spaces and dots. fn sanitize(name: &str) -> String { - let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); - let s = s.trim().trim_matches('.').to_string(); + let s = figcraft_engine::cmd::file::safe_file_name(name); + let s = s.trim().trim_matches('.').trim().to_string(); if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } } @@ -411,6 +413,12 @@ mod tests { assert_eq!(sanitize("Home / Hero"), "Home _ Hero"); assert_eq!(sanitize("../.."), "_"); assert_eq!(sanitize(""), "layer"); + // The same characters the app's export keeps (#1154): only what file systems refuse goes. + assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)"); + assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é"); + assert_eq!(sanitize("a\\b:c*d?e\"fh|i\tj"), "a_b_c_d_e_f_g_h_i_j"); + assert_eq!(sanitize(" .hidden. "), "hidden"); + assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100); } #[test] diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index eb42b8d8..c2215040 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -81,6 +81,30 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`, +/// `%`, `#` and commas stay, what file systems refuse becomes `_`. +#[test] +fn export_file_names_keep_what_the_app_keeps() { + let d = tmp("names"); + let script = d.join("s.json"); + let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"]; + let steps: Vec = names + .iter() + .enumerate() + .map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}})) + .collect(); + std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap(); + let file = d.join("doc.figcraft"); + assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success()); + let dir = d.join("out"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] { + assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::>()); + } + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn run_takes_the_documented_gradient_shorthand() { // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". diff --git a/contributors/people.toml b/contributors/people.toml index 27307935..6a92a2a9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,8 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..28fa7a1e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1169,8 +1169,10 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } -/// `name` without path separators and characters file systems reject. -fn safe_file_name(name: &str) -> String { +/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and +/// control characters become `_`); everything else is kept. `figcraft-cli export` names its files +/// by this rule too. +pub fn safe_file_name(name: &str) -> String { name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() } diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..f01de3b4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -51,6 +51,10 @@ page 1 "Screens": 380 nodes, 4 top-level - `` is a directory: one file per top-level frame of the page (default the first page), named after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer. + The name is kept as it is, except for the characters file systems refuse (`/ \ : * ? " < > |` + and control characters), which become `_`, and leading or trailing spaces and dots, which are + dropped. The app's export replaces the same characters (there a `/` makes a folder). Two layers + with the same name get `Name 2`, `Name 3`… - `` has an image extension: one file with the given `--node`, or the whole page composited. - `--node` takes a layer id (`12` or `0:12`) or a layer name. - `--format fig` (or an `` ending in `.fig`) writes the whole file as `.fig`, experimental: From 40315a123d00db5f69f3ea089bf155b6add914dc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:25:24 +0200 Subject: [PATCH 47/98] M0.9: --help and -h after a figcraft-cli subcommand print the usage (#1155) `figcraft-cli mcp --help` failed with "unknown option --help" and exit code 2; only a leading --help worked. The argument parser now returns CliError::Help for --help or -h among a subcommand's options (not as an option's value), and main prints the usage on stdout with exit code 0. docs/cli.md says so. --- apps/figcraft-cli/src/args.rs | 17 ++++++++++++++++- apps/figcraft-cli/src/main.rs | 8 +++++++- apps/figcraft-cli/tests/cli.rs | 33 +++++++++++++++++++++++++++++++++ docs/cli.md | 4 +++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..c224f9a7 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -1,6 +1,6 @@ //! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` //! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s. -//! Unknown options are usage errors. +//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`]. use std::collections::BTreeMap; @@ -30,6 +30,10 @@ impl Args { Some((n, v)) => (n, Some(v.to_string())), None => (name, None), }; + // `--help` / `-h` anywhere among the options (not as an option's value) asks for help. + if inline.is_none() && matches!(name, "help" | "h") { + return Err(CliError::Help); + } if valued.contains(&name) { let v = match inline { Some(v) => v, @@ -135,4 +139,15 @@ mod tests { let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap(); assert_eq!(a.positional, ["-5"]); } + + #[test] + fn help_is_asked_for_anywhere_but_as_a_value() { + for argv in [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"]] { + assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}"); + } + let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap(); + assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h"))); + // `--help=x` is no request for help, just an unknown option. + assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_)))); + } } diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..65fd3aa8 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -27,7 +27,7 @@ USAGE: figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] - figcraft-cli help | --help | --version + figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too) Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are @@ -74,6 +74,10 @@ fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { Ok(code) => code, + Err(CliError::Help) => { + println!("{USAGE}"); + ExitCode::SUCCESS + } Err(CliError::Usage(msg)) => { eprintln!("error: {msg}\n\n{USAGE}"); ExitCode::from(2) @@ -87,6 +91,8 @@ fn main() -> ExitCode { #[derive(Debug)] pub enum CliError { + /// `--help` or `-h` after a subcommand: usage on stdout, exit code 0. + Help, /// Bad arguments: usage is printed, exit code 2. Usage(String), /// The operation failed: exit code 1. diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index eb42b8d8..0d6fbe79 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -40,6 +40,39 @@ fn bad_arguments_exit_2_without_panicking() { assert_eq!(out.status.code(), Some(1)); } +/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first +/// (#1155), whatever else is on the line; nothing runs. +#[test] +fn help_after_a_subcommand_prints_the_usage() { + for args in [ + &["--help"][..], + &["-h"], + &["help"], + &["mcp", "--help"], + &["mcp", "-h"], + &["info", "--help"], + &["export", "--help"], + &["export", "x.figcraft", "-o", "out", "--help"], + &["convert", "-h"], + &["render", "--help"], + &["run", "--help"], + &["commands", "--help"], + &["plugin", "publish", "--help"], + &["perf", "--help"], + &["mcp", "--attach", "notaport", "--help"], + ] { + let out = bin().args(args).stdin(Stdio::null()).output().unwrap(); + assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}"); + assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr)); + } + // A value that happens to be `-h` is still a value, not a request for help. + let out = bin().args(["commands", "--filter", "--help"]).output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text"); +} + #[test] fn run_export_info_convert() { let d = tmp("run"); diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..0975f4fd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,7 +4,9 @@ FigCraft without a window: inspect, export, convert and render documents, run co commands, serve MCP and measure performance. Build it with `cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`). -Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does +`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit +with code 2; failures exit with 1. The CLI never panics on input. ## Fonts From e366f61e8f7c16f8785b20714bfb9a47446324c4 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Thu, 22 Oct 2026 14:32:12 -0500 Subject: [PATCH 48/98] FreeBSD package.sh: accept only FreeBSD targets, check cross-built binaries Review follow-up. CARGO_BUILD_TARGET must now be a *-unknown-freebsd triple, so a Linux or macOS target can no longer be packaged under a FreeBSD name, and it is read before uname -m, so an unsupported host does not block a valid cross build. When the binaries are for another architecture and cannot run --version, file(1) must report each as a FreeBSD ELF executable for the target architecture. Part of storytold/craft-common-actions#39. --- packaging/freebsd/package.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 107da888..b2348cc3 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -38,11 +38,15 @@ release_arch() { *) echo "unsupported architecture $1" >&2; return 2 ;; esac } -HOST_ARCH="$(release_arch "$(uname -m)")" # The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to # aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. -ARCH="$HOST_ARCH" -if [ -n "${CARGO_BUILD_TARGET:-}" ]; then ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")"; fi +case "${CARGO_BUILD_TARGET:-}" in + "") ARCH="$(release_arch "$(uname -m)")" ;; + *-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; + *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a FreeBSD target" >&2; exit 2 ;; +esac +# Only binaries for this machine run here (the --version check at the end). +HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)" if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 @@ -109,6 +113,14 @@ echo "wrote $OUT_DIR/$BASENAME.tar.gz" if [ "$DRY_RUN" = 1 ] || [ "$ARCH" = "$HOST_ARCH" ]; then "$STAGE/bin/$(smoke_bin)" --version else + # They cannot run here, so check what they are instead. + want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64 + for b in "$STAGE"/bin/*; do + case "$(file -b "$b")" in + ELF*"$want"*FreeBSD*) ;; + *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; + esac + done echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine" fi if [ "$DRY_RUN" = 1 ]; then From c6dba8c8f34bb37fef9dbcba4115b5a50beb5702 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:34:58 +0200 Subject: [PATCH 49/98] Reject non-finite coordinates in control-channel ui.input Fixes #1112 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control_roots_tests.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d35609a..69837bc2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -336,7 +336,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?; let pos = |v: &Value| -> Option { let a = v.as_array()?; - Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) + let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + p.is_finite().then_some(p) }; let mut events = Vec::new(); let mut pads = Vec::new(); diff --git a/crates/ui-egui/src/control_roots_tests.rs b/crates/ui-egui/src/control_roots_tests.rs index 35454bb5..763f00e6 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -90,3 +90,13 @@ fn window_captures_are_written_through_the_roots_or_returned() { }); assert!(d.0.join("out/shot.png").is_file() && !d.0.join("outside/shot.png").exists()); } + +#[test] +fn ui_input_rejects_non_finite_coordinates() { + let (mut app, _d) = confined_app("input"); + for ev in [json!({"move": [1e300, 5]}), json!({"down": [5, 1e300]}), json!({"scroll": [1e300, 0]})] { + let r = handle(&mut app, "ui.input", json!({"events": [ev]})); + assert!(r.is_err(), "{ev}: {r:?}"); + } + assert!(app.control_input.iter().all(|e| !matches!(e, egui::Event::PointerMoved(p) if !p.is_finite()))); +} From eb07717783cc51b21c65a08475d9e8300362a116 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:43:52 +0200 Subject: [PATCH 50/98] M0.9: trim mixed edge spaces and dots, document the name limits (#1154) Review: " . .name. . " kept inner dots at the edges; trim spaces and dots in one pass. docs/cli.md and safe_file_name's comment give the 100 and 200 character limits. --- apps/figcraft-cli/src/files.rs | 5 +++-- crates/engine/src/cmd/file.rs | 5 +++-- docs/cli.md | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 111518b3..b3778063 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -208,10 +208,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult { } /// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`], -/// so `/` becomes `_` too), without leading or trailing spaces and dots. +/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters. fn sanitize(name: &str) -> String { let s = figcraft_engine::cmd::file::safe_file_name(name); - let s = s.trim().trim_matches('.').trim().to_string(); + let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string(); if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } } @@ -418,6 +418,7 @@ mod tests { assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é"); assert_eq!(sanitize("a\\b:c*d?e\"fh|i\tj"), "a_b_c_d_e_f_g_h_i_j"); assert_eq!(sanitize(" .hidden. "), "hidden"); + assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed"); assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100); } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 28fa7a1e..efd1fcb7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1170,8 +1170,9 @@ fn export_path(name: &str) -> String { } /// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and -/// control characters become `_`); everything else is kept. `figcraft-cli export` names its files -/// by this rule too. +/// control characters become `_`); everything else is kept, up to the first 200 characters. +/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before +/// the number and extension it adds). pub fn safe_file_name(name: &str) -> String { name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() } diff --git a/docs/cli.md b/docs/cli.md index f01de3b4..8f427194 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -53,8 +53,8 @@ page 1 "Screens": 380 nodes, 4 top-level after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer. The name is kept as it is, except for the characters file systems refuse (`/ \ : * ? " < > |` and control characters), which become `_`, and leading or trailing spaces and dots, which are - dropped. The app's export replaces the same characters (there a `/` makes a folder). Two layers - with the same name get `Name 2`, `Name 3`… + dropped. A name is cut at 100 characters. The app's export replaces the same characters (there a + `/` makes a folder). Two layers with the same name get `Name 2`, `Name 3`… - `` has an image extension: one file with the given `--node`, or the whole page composited. - `--node` takes a layer id (`12` or `0:12`) or a layer name. - `--format fig` (or an `` ending in `.fig`) writes the whole file as `.fig`, experimental: From bb2e56a7ea67f66470c66f6cddea31dbdf2cd557 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:45:29 +0200 Subject: [PATCH 51/98] M0.9: --help wins over an earlier bad option (#1155) Review: `mcp --bogus --help` stopped at --bogus with exit 2. The parser now keeps the first bad option and reports it only when no --help or -h follows. --- apps/figcraft-cli/src/args.rs | 25 +++++++++++++++++-------- apps/figcraft-cli/tests/cli.rs | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index c224f9a7..3fe17e80 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -16,6 +16,8 @@ pub struct Args { impl Args { pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { let mut a = Args::default(); + // The first bad option, reported once the rest of the line is checked for `--help`. + let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { let name = if let Some(n) = arg.strip_prefix("--") { @@ -35,18 +37,22 @@ impl Args { return Err(CliError::Help); } if valued.contains(&name) { - let v = match inline { - Some(v) => v, - None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, - }; - a.values.entry(name.to_string()).or_default().push(v); + match inline.or_else(|| it.next().cloned()) { + Some(v) => a.values.entry(name.to_string()).or_default().push(v), + None => { + bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value"))); + } + } } else if flags.contains(&name) && inline.is_none() { a.flags.push(name.to_string()); } else { - return Err(CliError::Usage(format!("unknown option `{arg}`"))); + bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`"))); } } - Ok(a) + match bad { + Some(e) => Err(e), + None => Ok(a), + } } pub fn value(&self, k: &str) -> Option { @@ -142,7 +148,10 @@ mod tests { #[test] fn help_is_asked_for_anywhere_but_as_a_value() { - for argv in [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"]] { + // Also after a bad option: help wins over the usage error (#1161 review). + for argv in + [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]] + { assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}"); } let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap(); diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 0d6fbe79..23580016 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -60,6 +60,7 @@ fn help_after_a_subcommand_prints_the_usage() { &["plugin", "publish", "--help"], &["perf", "--help"], &["mcp", "--attach", "notaport", "--help"], + &["mcp", "--bogus", "--help"], ] { let out = bin().args(args).stdin(Stdio::null()).output().unwrap(); assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); From 8f0982b5bd0397174ab1b98a9734c1c5528be75a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:46:32 +0200 Subject: [PATCH 52/98] M7: Cmd/Ctrl+S saves while a panel field has the keyboard (#1157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the keyboard in a Design panel field (X, W, a colour…), the shortcut handler returned before looking at any key, so Cmd/Ctrl+S and Shift+Cmd/Ctrl+S did nothing: no save, no message, the tab kept its unsaved dot. Those two now work from a field: the field lets go of the keyboard, which commits what was typed as clicking away does, and the save runs the next frame. --- crates/ui-egui/src/field_tests.rs | 33 +++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/shortcuts.rs | 37 ++++++++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index fea0be8e..75f064f6 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -370,6 +370,39 @@ fn arithmetic_commits_tab_moves_on_and_escape_restores() { assert_eq!(geom(&u, a).0, 78.0); } +#[test] +fn the_save_shortcut_saves_from_a_field_with_what_was_typed() { + // Cmd/Ctrl+S with the keyboard in a panel field saved nothing (#1157). It commits the field + // first (as clicking away does), then saves; the field lets go of the keyboard. + let cmd = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-save-from-field-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("doc.figcraft"); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + for (shortcut, mods) in [("Cmd/Ctrl+S", cmd), ("Shift+Cmd/Ctrl+S on a file with a path", cmd.plus(Modifiers::SHIFT))] { + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + assert!(crate::a11y::field_focused(&u.ctx), "{shortcut}: the field has the keyboard"); + let x = if mods.shift { "66" } else { "77" }; + u.frame(vec![Event::Text(x.into())]); + key(&mut u, Key::S, mods); + u.frame(vec![]); + assert_eq!(geom(&u, a).0, x.parse::().unwrap(), "{shortcut}: what was typed is committed"); + assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); + if mods.shift { + // Save as asks where (a dialog this harness doesn't answer): nothing is written yet. + continue; + } + assert!(!u.app.session.ds().unwrap().is_dirty(), "{shortcut}: saved"); + let mut back = Session::new(); + back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let b = back.doc().unwrap().world_bounds(a).unwrap(); + assert_eq!(b.x0, 77.0, "{shortcut}: the file on disk has the typed value"); + } + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn mixed_values_take_edits_relative_to_each_layer() { let (mut u, a, b) = setup(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..3eaadf95 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -631,6 +631,9 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which + /// commits what was typed) and the command runs the next frame (`shortcuts::handle`). + pub after_field: Option<&'static str>, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, @@ -748,6 +751,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..6ce75cbb 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -97,8 +97,16 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); - // Text fields in panels own the keyboard (also the key that ends their editing). + // A save asked for from a field last frame: the field has committed by now. + if let Some(id) = app.ui.after_field.take() { + run_binding(app, id); + } + // Text fields in panels own the keyboard (also the key that ends their editing), except for + // the save shortcuts. if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { + if app.ui.palette.is_none() { + save_from_field(app, ctx); + } return; } let events: Vec = ctx.input(|i| i.events.clone()); @@ -137,6 +145,33 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe } } +/// Commands whose shortcut works while a panel field has the keyboard: they don't edit text, and +/// a file that can't be saved from a field looks like a file that can't be saved (#1157). +const FROM_A_FIELD: [&str; 2] = ["file.save", "file.saveAs"]; + +/// Cmd/Ctrl+S (or Shift+Cmd/Ctrl+S) pressed in a panel field: the field lets go of the keyboard, +/// which commits what was typed as clicking away does, and the save runs next frame, after that +/// commit. +fn save_from_field(app: &mut FigApp, ctx: &Context) { + let id = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + Event::Key { key, physical_key, pressed: true, repeat: false, modifiers } if modifiers.command => { + lookup(keymap::Context::Canvas, *key, *physical_key, *modifiers).map(|b| b.id).filter(|id| FROM_A_FIELD.contains(id)) + } + _ => None, + }) + }); + if let Some(id) = id { + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + app.ui.after_field = Some(id); + ctx.request_repaint(); + } +} + /// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), /// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, /// whether the key press arrived (browser) or only a paste event and the release (desktop). From b5e5c2100e6a5b675aca95a7789b7927b0875d7a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:48:28 +0200 Subject: [PATCH 53/98] Make SVG unique_id linear for repeated layer names Fixes #1106 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 13 ++++++++++++- crates/svg/src/tests.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..5058946a 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -79,6 +79,8 @@ struct W<'a> { clip: Option, /// `id` attributes written so far. used_ids: std::collections::BTreeSet, + /// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`. + next_suffix: std::collections::HashMap, } /// ` clip-path="url(#…)"` for a clip path id (else nothing). @@ -549,7 +551,15 @@ impl W<'_> { fn unique_id(&mut self, name: &str) -> String { let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); let base = if base.is_empty() { "layer".to_string() } else { base }; - let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base); + let next = self.next_suffix.entry(base.clone()).or_insert(1); + let id = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base}_{k}") }; + if !self.used_ids.contains(&candidate) { + break candidate; + } + }; self.used_ids.insert(id.clone()); id } @@ -1004,6 +1014,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu mask: None, clip: None, used_ids: Default::default(), + next_suffix: Default::default(), }; for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..2258ca20 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -159,3 +159,32 @@ fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke"); assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline"); } + +#[test] +fn unique_id_is_linear_for_repeated_names() { + let d = Document::new("t"); + let opts = SvgOptions::default(); + let mut w = W { + doc: &d, + roots: &[], + page: Affine::IDENTITY, + defs: String::new(), + body: String::new(), + next: 0, + opts: &opts, + mask: None, + clip: None, + used_ids: Default::default(), + next_suffix: Default::default(), + }; + assert_eq!(w.unique_id("a"), "a"); + assert_eq!(w.unique_id("a_3"), "a_3"); + assert_eq!(w.unique_id("a"), "a_2"); + assert_eq!(w.unique_id("a"), "a_4"); + let start = std::time::Instant::now(); + for _ in 0..30_000 { + w.unique_id("Rectangle"); + } + assert_eq!(w.unique_id("Rectangle"), "Rectangle_30001"); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "unique_id is quadratic: {:?}", start.elapsed()); +} From 477b25e63fb2526a7daf717d76f5cb75aacb1fb3 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Thu, 22 Oct 2026 14:49:35 -0500 Subject: [PATCH 54/98] FreeBSD package.sh: accept only executables in the cross-build check Review follow-up. The file(1) check for binaries that cannot run here now needs an executable (file says "executable" or "pie executable") or an ELF with an interpreter, which is how an older file(1) still shows a PIE it calls a shared object. FreeBSD libraries, object files and core files for the target architecture are refused. Part of storytold/craft-common-actions#39. --- packaging/freebsd/package.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index b2348cc3..87e5e29f 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -113,11 +113,12 @@ echo "wrote $OUT_DIR/$BASENAME.tar.gz" if [ "$DRY_RUN" = 1 ] || [ "$ARCH" = "$HOST_ARCH" ]; then "$STAGE/bin/$(smoke_bin)" --version else - # They cannot run here, so check what they are instead. + # They cannot run here, so check what they are instead: executables, not libraries or objects. + # An older file(1) calls a PIE a shared object; its interpreter still marks it as a program. want="$ARCH"; [ "$ARCH" != x86_64 ] || want=x86-64 for b in "$STAGE"/bin/*; do case "$(file -b "$b")" in - ELF*"$want"*FreeBSD*) ;; + ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;; *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; esac done From d9cb5cd21685469962f02bd37b869b42fa6382f5 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 22:05:36 +0200 Subject: [PATCH 55/98] M7: the save-from-field test answers Save as and checks both files (#1157) --- crates/ui-egui/src/field_tests.rs | 47 ++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 75f064f6..dd63988a 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -370,36 +370,55 @@ fn arithmetic_commits_tab_moves_on_and_escape_restores() { assert_eq!(geom(&u, a).0, 78.0); } +/// Answers a Save as dialog with `path`, once, and counts the dialogs asked for. +struct SaveAsPicker { + path: Option, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAsPicker { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + Box::pin(std::future::ready(self.path.take())) + } +} + +/// The x of layer `a` in the file at `path`, opened again. +fn saved_x(path: &std::path::Path, a: NodeId) -> f64 { + let mut back = Session::new(); + back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + back.doc().unwrap().world_bounds(a).unwrap().x0 +} + #[test] fn the_save_shortcut_saves_from_a_field_with_what_was_typed() { // Cmd/Ctrl+S with the keyboard in a panel field saved nothing (#1157). It commits the field // first (as clicking away does), then saves; the field lets go of the keyboard. + // Shift+Cmd/Ctrl+S does the same through Save as. let cmd = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; let (mut u, a, _) = setup(); let dir = std::env::temp_dir().join(format!("figcraft-save-from-field-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("doc.figcraft"); + let (path, copy) = (dir.join("doc.figcraft"), dir.join("copy.figcraft")); u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - for (shortcut, mods) in [("Cmd/Ctrl+S", cmd), ("Shift+Cmd/Ctrl+S on a file with a path", cmd.plus(Modifiers::SHIFT))] { + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + for (shortcut, mods, x, file) in [("Cmd/Ctrl+S", cmd, 77.0, &path), ("Shift+Cmd/Ctrl+S", cmd.plus(Modifiers::SHIFT), 66.0, ©)] { let (p, _) = field(&mut u, "X"); click(&mut u, p); assert!(crate::a11y::field_focused(&u.ctx), "{shortcut}: the field has the keyboard"); - let x = if mods.shift { "66" } else { "77" }; - u.frame(vec![Event::Text(x.into())]); + u.frame(vec![Event::Text(x.to_string())]); key(&mut u, Key::S, mods); - u.frame(vec![]); - assert_eq!(geom(&u, a).0, x.parse::().unwrap(), "{shortcut}: what was typed is committed"); - assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); - if mods.shift { - // Save as asks where (a dialog this harness doesn't answer): nothing is written yet. - continue; + for _ in 0..3 { + u.frame(vec![]); } + assert_eq!(geom(&u, a).0, x, "{shortcut}: what was typed is committed"); + assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); assert!(!u.app.session.ds().unwrap().is_dirty(), "{shortcut}: saved"); - let mut back = Session::new(); - back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); - let b = back.doc().unwrap().world_bounds(a).unwrap(); - assert_eq!(b.x0, 77.0, "{shortcut}: the file on disk has the typed value"); + assert_eq!(saved_x(file, a), x, "{shortcut}: the file on disk has the typed value"); } + assert_eq!(asked.get(), 1, "only Save as asked where"); + assert_eq!(saved_x(&path, a), 77.0, "Save as left the first file alone"); std::fs::remove_dir_all(&dir).ok(); } From 51a4509c8729f4053a2e4984b017ed9544a838f2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:16:23 +0200 Subject: [PATCH 56/98] Refuse library versions that leave no newer one to publish Fixes #1092 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/hardening_tests.rs | 22 +++++++++++++++++++++ apps/figcraft-server/src/store.rs | 11 +++++++++++ 2 files changed, 33 insertions(+) diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 086539ca..92b17710 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() { assert_eq!(served(fetch(&mut store)), (2, v2)); assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted"); } + +#[test] +fn a_library_publish_cannot_claim_the_highest_version() { + let dir = TempDir::new("libmax"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone(); + let mut archive = |version: u64| { + lib.manifest.version = version; + figcraft_engine::library_server::archive(&lib).unwrap() + }; + let high = 1u64 << 32; + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version"); + assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in"); + assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it"); + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused"); + assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in"); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..467efbab 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -334,6 +334,11 @@ fn write_key(root: &Path) -> Result { Ok(key) } +/// The highest version a file's first library publish may claim. Publishing counts up by one (a +/// library carried over from another server keeps its number), so no real history gets near it. +/// Later publishes may go one past the version on the server, so there is always a next one. +const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32; + impl Store { /// Use the data directory `root` (made if missing). Fails when another server or command holds /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). @@ -973,6 +978,12 @@ impl Store { if m.version <= old.version { return Err(format!("version {} is on the server already: publish again to follow it", old.version)); } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); } // Each version has a file of its own, written before the description names it: a failed // description write or a crash in between leaves the previous version served whole, never From eafa1a1d06c4ee8f0605839db96842181ffad0f2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:20:49 +0200 Subject: [PATCH 57/98] Never overwrite a file's log when it can't be read Fixes #1090 Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-server/src/store.rs | 19 ++++++++- apps/figcraft-server/src/store_tests.rs | 51 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..c6d62462 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1110,7 +1110,24 @@ impl Store { let mut issues = Vec::new(); let mut fresh_log = true; let mut kept = 0; - match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) { + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { Ok(log) if log.generation == generation => { fresh_log = false; for entry in log.entries { diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..e7434b45 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -313,3 +313,54 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +#[test] +fn a_log_that_cant_be_read_is_never_overwritten() { + let dir = TempDir::new("unreadlog"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let log_path = dir.path().join(FILES); + let last = "Confirmed page name number 39 with some padding"; + let id = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..40 { + rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding")); + store.changed(&id, &mut server); + } + assert!(store.flush().is_empty()); + id + }; + let log_path = log_path.join(&id).join(LOG); + let before = fs::read(&log_path).unwrap(); + // The cap lowered just under the log: it can't be read now, but it must still be there after. + let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() }; + { + let mut store = Store::open(dir.path(), lowered).unwrap(); + let opened = store.open(&id); + assert!(opened.is_err(), "the file isn't opened without its log"); + assert!(!store.problems().is_empty(), "the problem is reported"); + assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was"); + } + let mut store = Store::open(dir.path(), c).unwrap(); + let server = store.open(&id).unwrap(); + assert_eq!(page_name(&server), last, "every confirmed edit comes back"); +} + +#[test] +fn a_log_that_isnt_one_is_moved_aside() { + let dir = TempDir::new("badlog"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let folder = dir.path().join(FILES).join(&id); + let junk = b"not a log at all, but maybe worth keeping".to_vec(); + fs::write(folder.join(LOG), &junk).unwrap(); + drop(store); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + store.open(&id).unwrap(); + assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems()); + let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk)); + assert!(kept, "the old log's bytes are kept beside the file"); +} From 83dac86bc5819f8dcbd4e3582df2c415958b1bde Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 22:23:11 +0200 Subject: [PATCH 58/98] M8: the unsaved-changes question on close offers Save (#1170) Closing a window with unsaved changes asked "Close the window and lose them?" with only Close without saving and Cancel. It now asks Don't Save / Cancel / Save, as PhotoCraft and VectorCraft do. Save saves each file of the window with unsaved changes and closes; a file without a path (or saved as .fig) gets Save as first, and closing goes on once that saved it. A failed save or a cancelled Save as keeps the window open with its changes. The two changed strings are updated in every catalog. --- crates/ui-egui/locales/bn.toml | 4 +- crates/ui-egui/locales/cs.toml | 4 +- crates/ui-egui/locales/de.toml | 4 +- crates/ui-egui/locales/en.toml | 4 +- crates/ui-egui/locales/es.toml | 4 +- crates/ui-egui/locales/fi.toml | 4 +- crates/ui-egui/locales/fr.toml | 4 +- crates/ui-egui/locales/id.toml | 4 +- crates/ui-egui/locales/ja.toml | 4 +- crates/ui-egui/locales/ka.toml | 4 +- crates/ui-egui/locales/ko.toml | 4 +- crates/ui-egui/locales/nl.toml | 4 +- crates/ui-egui/locales/pl.toml | 4 +- crates/ui-egui/locales/pt-BR.toml | 4 +- crates/ui-egui/locales/ru.toml | 4 +- crates/ui-egui/locales/sv.toml | 4 +- crates/ui-egui/locales/tr.toml | 4 +- crates/ui-egui/locales/zh-Hans.toml | 4 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/windows.rs | 92 +++++++++++++++++-- crates/ui-egui/src/windows_tests.rs | 131 ++++++++++++++++++++++++++++ 21 files changed, 255 insertions(+), 44 deletions(-) diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index adedc983..b113f401 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -249,7 +249,6 @@ status = "draft — needs native review" "Close tab" = "ট্যাব বন্ধ করুন" "Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" "Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" -"Close without saving" = "সেভ না করেই বন্ধ করুন" "Code" = "কোড" "Code syntax" = "কোড সিনট্যাক্স" "Collapse" = "সংকুচিত করুন" @@ -405,6 +404,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" "Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" "Divider" = "ডিভাইডার" +"Don't Save" = "সংরক্ষণ করবেন না" "Don't truncate" = "ট্রাঙ্কেট করবেন না" "Done" = "সম্পন্ন" "Done editing" = "সম্পাদনা শেষ" @@ -1430,7 +1430,7 @@ status = "draft — needs native review" "Unread" = "অপঠিত" "Unresolve" = "অমীমাংসিত করুন" "Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। উইন্ডোটি বন্ধ করলে কি সেগুলো হারিয়ে যাবে?" +"Unsaved changes in {names}. Save them before closing?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। বন্ধ করার আগে সেগুলো সংরক্ষণ করবেন?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" "Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" "Up" = "উপরে" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index eea0e8dc..72201d9b 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -240,7 +240,6 @@ status = "draft — needs native review" "Close tab" = "Zavřít kartu" "Close version history" = "Zavřít historii verzí" "Close when clicking outside" = "Zavřít při kliknutí mimo" -"Close without saving" = "Zavřít bez uložení" "Code" = "Kód" "Code syntax" = "Syntaxe kódu" "Collapse" = "Sbalit" @@ -395,6 +394,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmístit vodorovné rozestupy" "Distribute vertical spacing" = "Rozmístit svislé rozestupy" "Divider" = "Oddělovač" +"Don't Save" = "Neukládat" "Don't truncate" = "Nezkracovat" "Done" = "Hotovo" "Done editing" = "Dokončit úpravy" @@ -1399,7 +1399,7 @@ status = "draft — needs native review" "Unread" = "Nepřečtené" "Unresolve" = "Znovu otevřít" "Unsaved changes" = "Neuložené změny" -"Unsaved changes in {names}. Close the window and lose them?" = "Neuložené změny v {names}. Zavřít okno a zahodit je?" +"Unsaved changes in {names}. Save them before closing?" = "Neuložené změny v {names}. Uložit je před zavřením?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" "Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" "Up" = "Nahoru" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..4ee9b075 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -235,7 +235,6 @@ status = "draft — needs native review" "Close tab" = "Tab schließen" "Close version history" = "Versionsverlauf schließen" "Close when clicking outside" = "Bei Klick außerhalb schließen" -"Close without saving" = "Ohne Speichern schließen" "Code" = "Code" "Code syntax" = "Code-Syntax" "Collapse" = "Einklappen" @@ -387,6 +386,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale Abstände verteilen" "Distribute vertical spacing" = "Vertikale Abstände verteilen" "Divider" = "Trennlinie" +"Don't Save" = "Nicht speichern" "Don't truncate" = "Nicht kürzen" "Done" = "Fertig" "Done editing" = "Bearbeitung beenden" @@ -1352,7 +1352,7 @@ status = "draft — needs native review" "Unread" = "Ungelesen" "Unresolve" = "Wieder öffnen" "Unsaved changes" = "Nicht gespeicherte Änderungen" -"Unsaved changes in {names}. Close the window and lose them?" = "Nicht gespeicherte Änderungen in {names}. Fenster schließen und Änderungen verwerfen?" +"Unsaved changes in {names}. Save them before closing?" = "Nicht gespeicherte Änderungen in {names}. Vor dem Schließen speichern?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst ({save}) oder schließen Sie den Tab mit {alt}+Klick, um sie zu verwerfen" "Unsaved changes: save first, or close again with {key} to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst oder schließen Sie erneut mit {key}, um sie zu verwerfen" "Up" = "Nach oben" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..35962f65 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -249,7 +249,6 @@ name = "English" "Close tab" = "Close tab" "Close version history" = "Close version history" "Close when clicking outside" = "Close when clicking outside" -"Close without saving" = "Close without saving" "Code" = "Code" "Code syntax" = "Code syntax" "Collapse" = "Collapse" @@ -406,6 +405,7 @@ name = "English" "Distribute horizontal spacing" = "Distribute horizontal spacing" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" +"Don't Save" = "Don't Save" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -1443,7 +1443,7 @@ name = "English" "Unread" = "Unread" "Unresolve" = "Unresolve" "Unsaved changes" = "Unsaved changes" -"Unsaved changes in {names}. Close the window and lose them?" = "Unsaved changes in {names}. Close the window and lose them?" +"Unsaved changes in {names}. Save them before closing?" = "Unsaved changes in {names}. Save them before closing?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" "Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" "Up" = "Up" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..2fc17c41 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -250,7 +250,6 @@ status = "draft — needs native review" "Close tab" = "Cerrar pestaña" "Close version history" = "Cerrar historial de versiones" "Close when clicking outside" = "Cerrar al hacer clic fuera" -"Close without saving" = "Cerrar sin guardar" "Code" = "Código" "Code syntax" = "Sintaxis de código" "Collapse" = "Contraer" @@ -407,6 +406,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espacio horizontal" "Distribute vertical spacing" = "Distribuir espacio vertical" "Divider" = "Separador" +"Don't Save" = "No guardar" "Don't truncate" = "No truncar" "Done" = "Listo" "Done editing" = "Terminar edición" @@ -1444,7 +1444,7 @@ status = "draft — needs native review" "Unread" = "No leídos" "Unresolve" = "Reabrir" "Unsaved changes" = "Cambios sin guardar" -"Unsaved changes in {names}. Close the window and lose them?" = "Hay cambios sin guardar en {names}. ¿Cerrar la ventana y perderlos?" +"Unsaved changes in {names}. Save them before closing?" = "Hay cambios sin guardar en {names}. ¿Guardarlos antes de cerrar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" "Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" "Up" = "Arriba" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..0d4a3f0f 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -245,7 +245,6 @@ status = "draft — needs native review" "Close tab" = "Sulje välilehti" "Close version history" = "Sulje versiohistoria" "Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" -"Close without saving" = "Sulje tallentamatta" "Code" = "Koodi" "Code or invite link" = "Koodi tai kutsulinkki" "Code syntax" = "Koodin syntaksi" @@ -407,6 +406,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Jaa vaakavälit tasan" "Distribute vertical spacing" = "Jaa pystyvälit tasan" "Divider" = "Erotin" +"Don't Save" = "Älä tallenna" "Don't truncate" = "Älä lyhennä" "Done" = "Valmis" "Done editing" = "Lopeta muokkaus" @@ -1434,7 +1434,7 @@ status = "draft — needs native review" "Unread" = "Lukematon" "Unresolve" = "Merkitse ratkaisemattomaksi" "Unsaved changes" = "Tallentamattomat muutokset" -"Unsaved changes in {names}. Close the window and lose them?" = "Tallentamattomia muutoksia kohteissa {names}. Suljetaanko ikkuna ja hylätään ne?" +"Unsaved changes in {names}. Save them before closing?" = "Tallentamattomia muutoksia kohteissa {names}. Tallennetaanko ne ennen sulkemista?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" "Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" "Up" = "Ylös" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..a312406e 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -244,7 +244,6 @@ status = "draft — needs native review" "Close tab" = "Fermer l’onglet" "Close version history" = "Fermer l’historique des versions" "Close when clicking outside" = "Fermer en cliquant à l’extérieur" -"Close without saving" = "Fermer sans enregistrer" "Code" = "Code" "Code syntax" = "Syntaxe du code" "Collapse" = "Réduire" @@ -404,6 +403,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Répartir l’espacement horizontal" "Distribute vertical spacing" = "Répartir l’espacement vertical" "Divider" = "Séparateur" +"Don't Save" = "Ne pas enregistrer" "Don't truncate" = "Ne pas tronquer" "Done" = "Terminé" "Done editing" = "Terminer la modification" @@ -1428,7 +1428,7 @@ status = "draft — needs native review" "Unread" = "Non lus" "Unresolve" = "Rouvrir" "Unsaved changes" = "Modifications non enregistrées" -"Unsaved changes in {names}. Close the window and lose them?" = "Modifications non enregistrées dans {names}. Fermer la fenêtre et les perdre ?" +"Unsaved changes in {names}. Save them before closing?" = "Modifications non enregistrées dans {names}. Les enregistrer avant de fermer ?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Modifications non enregistrées : enregistrez d’abord ({save}) ou fermez l’onglet avec {alt}+clic pour les abandonner" "Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner" "Up" = "Haut" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index f21e1ca3..a8c7913d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -244,7 +244,6 @@ status = "draft — needs native review" "Close tab" = "Tutup tab" "Close version history" = "Tutup riwayat versi" "Close when clicking outside" = "Tutup saat klik di luar" -"Close without saving" = "Tutup tanpa menyimpan" "Code" = "Kode" "Code syntax" = "Sintaks kode" "Collapse" = "Ciutkan" @@ -399,6 +398,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Sebarkan jarak horizontal" "Distribute vertical spacing" = "Sebarkan jarak vertikal" "Divider" = "Pembatas" +"Don't Save" = "Jangan simpan" "Don't truncate" = "Jangan potong" "Done" = "Selesai" "Done editing" = "Selesai mengedit" @@ -1403,7 +1403,7 @@ status = "draft — needs native review" "Unread" = "Belum dibaca" "Unresolve" = "Buka kembali" "Unsaved changes" = "Perubahan belum disimpan" -"Unsaved changes in {names}. Close the window and lose them?" = "Perubahan belum disimpan di {names}. Tutup jendela dan buang perubahan?" +"Unsaved changes in {names}. Save them before closing?" = "Perubahan belum disimpan di {names}. Simpan sebelum menutup?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" "Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" "Up" = "Atas" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index c42790f3..503560af 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -245,7 +245,6 @@ status = "draft — needs native review" "Close tab" = "タブを閉じる" "Close version history" = "バージョン履歴を閉じる" "Close when clicking outside" = "外側をクリックしたら閉じる" -"Close without saving" = "保存せずに閉じる" "Code" = "コード" "Code syntax" = "コード構文" "Collapse" = "折りたたむ" @@ -402,6 +401,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平方向に等間隔に分布" "Distribute vertical spacing" = "垂直方向に等間隔に分布" "Divider" = "区切り線" +"Don't Save" = "保存しない" "Don't truncate" = "切り詰めない" "Done" = "完了" "Done editing" = "編集を完了" @@ -1411,7 +1411,7 @@ status = "draft — needs native review" "Unread" = "未読" "Unresolve" = "未解決に戻す" "Unsaved changes" = "未保存の変更" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} に未保存の変更がある。ウィンドウを閉じて変更を破棄する?" +"Unsaved changes in {names}. Save them before closing?" = "{names} に未保存の変更がある。閉じる前に保存する?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "未保存の変更:先に保存する({save})か、{alt}-クリックでタブを閉じて破棄" "Unsaved changes: save first, or close again with {key} to discard" = "未保存の変更:先に保存するか、もう一度 {key} で閉じて破棄" "Up" = "上" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 5b73b4fb..85eeeaff 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -251,7 +251,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Close tab" = "ჩანართის დახურვა" "Close version history" = "ვერსიების ისტორიის დახურვა" "Close when clicking outside" = "დახურვა გარეთ დაწკაპუნებისას" -"Close without saving" = "დახურვა შენახვის გარეშე" "Code" = "კოდი" "Code syntax" = "კოდის სინტაქსი" "Collapse" = "ჩაკეცვა" @@ -407,6 +406,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Distribute horizontal spacing" = "ჰორიზონტალური შუალედების განაწილება" "Distribute vertical spacing" = "ვერტიკალური შუალედების განაწილება" "Divider" = "გამყოფი" +"Don't Save" = "არ შეინახო" "Don't truncate" = "შეკვეცის გარეშე" "Done" = "მზადაა" "Done editing" = "რედაქტირების დასრულება" @@ -1432,7 +1432,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Unread" = "წაუკითხავი" "Unresolve" = "ხელახლა გახსნა" "Unsaved changes" = "შეუნახავი ცვლილებები" -"Unsaved changes in {names}. Close the window and lose them?" = "შეუნახავი ცვლილებები: {names}. დაიხუროს ფანჯარა და დაიკარგოს ისინი?" +"Unsaved changes in {names}. Save them before closing?" = "შეუნახავი ცვლილებები: {names}. შევინახოთ დახურვამდე?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ({save}) ან გასაუქმებლად დახურეთ ჩანართი {alt}+დაწკაპუნებით" "Unsaved changes: save first, or close again with {key} to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ან გასაუქმებლად ხელახლა დახურეთ {key}-ით" "Up" = "ზემოთ" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b33b36b9..10132e9b 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -235,7 +235,6 @@ status = "draft — needs native review" "Close tab" = "탭 닫기" "Close version history" = "버전 기록 닫기" "Close when clicking outside" = "바깥쪽을 클릭하면 닫기" -"Close without saving" = "저장하지 않고 닫기" "Code" = "코드" "Code syntax" = "코드 구문" "Collapse" = "접기" @@ -386,6 +385,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "수평 간격 균등 분포" "Distribute vertical spacing" = "수직 간격 균등 분포" "Divider" = "구분선" +"Don't Save" = "저장 안 함" "Don't truncate" = "자르지 않음" "Done" = "완료" "Done editing" = "편집 완료" @@ -1343,7 +1343,7 @@ status = "draft — needs native review" "Unread" = "읽지 않음" "Unresolve" = "미해결로 되돌리기" "Unsaved changes" = "저장되지 않은 변경 사항" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}에 저장되지 않은 변경 사항이 있음. 창을 닫고 변경 사항을 버릴까요?" +"Unsaved changes in {names}. Save them before closing?" = "{names}에 저장되지 않은 변경 사항이 있음. 닫기 전에 저장할까요?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "저장되지 않은 변경 사항: 먼저 저장({save})하거나 {alt}-클릭으로 탭을 닫아 버리세요" "Unsaved changes: save first, or close again with {key} to discard" = "저장되지 않은 변경 사항: 먼저 저장하거나 {key}로 다시 닫아 버리세요" "Up" = "위" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..2591bd34 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -245,7 +245,6 @@ status = "draft — needs native review" "Close tab" = "Tabblad sluiten" "Close version history" = "Versiegeschiedenis sluiten" "Close when clicking outside" = "Sluiten bij klikken erbuiten" -"Close without saving" = "Sluiten zonder opslaan" "Code" = "Code" "Code or invite link" = "Code of uitnodigingslink" "Code syntax" = "Code-syntaxis" @@ -407,6 +406,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale afstand verdelen" "Distribute vertical spacing" = "Verticale afstand verdelen" "Divider" = "Scheidslijn" +"Don't Save" = "Niet opslaan" "Don't truncate" = "Niet afkappen" "Done" = "Klaar" "Done editing" = "Klaar met bewerken" @@ -1434,7 +1434,7 @@ status = "draft — needs native review" "Unread" = "Ongelezen" "Unresolve" = "Oplossing ongedaan maken" "Unsaved changes" = "Niet-opgeslagen wijzigingen" -"Unsaved changes in {names}. Close the window and lose them?" = "Niet-opgeslagen wijzigingen in {names}. Het venster sluiten en ze verliezen?" +"Unsaved changes in {names}. Save them before closing?" = "Niet-opgeslagen wijzigingen in {names}. Opslaan voor het sluiten?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" "Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" "Up" = "Omhoog" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..e1f35e91 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -247,7 +247,6 @@ status = "draft — needs native review" "Close tab" = "Zamknij kartę" "Close version history" = "Zamknij historię wersji" "Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" -"Close without saving" = "Zamknij bez zapisywania" "Code" = "Kod" "Code syntax" = "Składnia kodu" "Collapse" = "Zwiń" @@ -404,6 +403,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmieść poziomo" "Distribute vertical spacing" = "Rozmieść pionowo" "Divider" = "Rozdzielacz" +"Don't Save" = "Nie zapisuj" "Don't truncate" = "Nie obcinaj tekstu" "Done" = "Gotowe" "Done editing" = "Zakończ edycję" @@ -1429,7 +1429,7 @@ status = "draft — needs native review" "Unread" = "Nieprzeczytane" "Unresolve" = "Cofnij rozwiązanie" "Unsaved changes" = "Niezapisane zmiany" -"Unsaved changes in {names}. Close the window and lose them?" = "Niezapisane zmiany w: {names}. Zamknąć okno i utracić je?" +"Unsaved changes in {names}. Save them before closing?" = "Niezapisane zmiany w: {names}. Zapisać je przed zamknięciem?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" "Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" "Up" = "W górę" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..1006c502 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -233,7 +233,6 @@ status = "draft — needs native review" "Close tab" = "Fechar aba" "Close version history" = "Fechar histórico de versões" "Close when clicking outside" = "Fechar ao clicar fora" -"Close without saving" = "Fechar sem salvar" "Code" = "Código" "Code syntax" = "Sintaxe de código" "Collapse" = "Recolher" @@ -385,6 +384,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espaçamento horizontal" "Distribute vertical spacing" = "Distribuir espaçamento vertical" "Divider" = "Divisor" +"Don't Save" = "Não salvar" "Don't truncate" = "Não truncar" "Done" = "Concluído" "Done editing" = "Concluir edição" @@ -1342,7 +1342,7 @@ status = "draft — needs native review" "Unread" = "Não lidos" "Unresolve" = "Reabrir" "Unsaved changes" = "Alterações não salvas" -"Unsaved changes in {names}. Close the window and lose them?" = "Há alterações não salvas em {names}. Fechar a janela e perdê-las?" +"Unsaved changes in {names}. Save them before closing?" = "Há alterações não salvas em {names}. Salvá-las antes de fechar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Alterações não salvas: salve primeiro ({save}) ou feche a aba com {alt}+clique para descartá-las" "Unsaved changes: save first, or close again with {key} to discard" = "Alterações não salvas: salve primeiro ou feche de novo com {key} para descartá-las" "Up" = "Para cima" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e173a92..ab06ea40 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -247,7 +247,6 @@ name = "Русский" "Close tab" = "Закрыть вкладку" "Close version history" = "Закрыть историю версий" "Close when clicking outside" = "Закрывать при нажатии снаружи" -"Close without saving" = "Закрыть без сохранения" "Code" = "Код" "Code syntax" = "Синтаксис кода" "Collapse" = "Свернуть" @@ -406,6 +405,7 @@ name = "Русский" "Distribute horizontal spacing" = "Распределить по горизонтали" "Distribute vertical spacing" = "Распределить по вертикали" "Divider" = "Разделитель" +"Don't Save" = "Не сохранять" "Don't truncate" = "Не обрезать" "Done" = "Готово" "Done editing" = "Завершить редактирование" @@ -1443,7 +1443,7 @@ name = "Русский" "Unread" = "Непрочитанные" "Unresolve" = "Снова открыть" "Unsaved changes" = "Несохранённые изменения" -"Unsaved changes in {names}. Close the window and lose them?" = "Несохранённые изменения в {names}. Закрыть окно и потерять их?" +"Unsaved changes in {names}. Save them before closing?" = "Несохранённые изменения в {names}. Сохранить их перед закрытием?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" "Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" "Up" = "Вверх" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..6072a9a0 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -244,7 +244,6 @@ name = "Svenska" "Close tab" = "Stäng flik" "Close version history" = "Stäng versionshistorik" "Close when clicking outside" = "Stäng vid klick utanför" -"Close without saving" = "Stäng utan att spara" "Code" = "Kod" "Code syntax" = "Kodsyntax" "Collapse" = "Komprimera" @@ -401,6 +400,7 @@ name = "Svenska" "Distribute horizontal spacing" = "Fördela horisontellt avstånd" "Distribute vertical spacing" = "Fördela vertikalt avstånd" "Divider" = "Avdelare" +"Don't Save" = "Spara inte" "Don't truncate" = "Korta inte av" "Done" = "Klart" "Done editing" = "Klar med redigeringen" @@ -1418,7 +1418,7 @@ name = "Svenska" "Unread" = "Olästa" "Unresolve" = "Markera som olöst" "Unsaved changes" = "Osparade ändringar" -"Unsaved changes in {names}. Close the window and lose them?" = "Osparade ändringar i {names}. Vill du stänga fönstret och förlora dem?" +"Unsaved changes in {names}. Save them before closing?" = "Osparade ändringar i {names}. Vill du spara dem innan du stänger?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem" "Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem" "Up" = "Upp" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index bd8bb7c9..fcabc8db 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -244,7 +244,6 @@ name = "Türkçe" "Close tab" = "Sekmeyi kapat" "Close version history" = "Sürüm geçmişini kapat" "Close when clicking outside" = "Dışarı tıklanınca kapat" -"Close without saving" = "Kaydetmeden kapat" "Code" = "Kod" "Code syntax" = "Kod söz dizimi" "Collapse" = "Daralt" @@ -399,6 +398,7 @@ name = "Türkçe" "Distribute horizontal spacing" = "Yatay aralığı dağıt" "Distribute vertical spacing" = "Dikey aralığı dağıt" "Divider" = "Ayırıcı" +"Don't Save" = "Kaydetme" "Don't truncate" = "Kısaltma yapma" "Done" = "Bitti" "Done editing" = "Düzenlemeyi bitir" @@ -1403,7 +1403,7 @@ name = "Türkçe" "Unread" = "Okunmamış" "Unresolve" = "Çözülmedi yap" "Unsaved changes" = "Kaydedilmemiş değişiklikler" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} içinde kaydedilmemiş değişiklikler var. Pencere kapatılıp bunlar kaybedilsin mi?" +"Unsaved changes in {names}. Save them before closing?" = "{names} içinde kaydedilmemiş değişiklikler var. Kapatmadan önce kaydedilsin mi?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" "Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" "Up" = "Yukarı" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..9f9ef4e0 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -236,7 +236,6 @@ status = "draft — needs native review" "Close tab" = "关闭标签页" "Close version history" = "关闭版本历史" "Close when clicking outside" = "点击外部时关闭" -"Close without saving" = "关闭但不保存" "Code" = "代码" "Code syntax" = "代码语法" "Collapse" = "折叠" @@ -387,6 +386,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平间距分布" "Distribute vertical spacing" = "垂直间距分布" "Divider" = "分隔线" +"Don't Save" = "不保存" "Don't truncate" = "不截断" "Done" = "完成" "Done editing" = "完成编辑" @@ -1344,7 +1344,7 @@ status = "draft — needs native review" "Unread" = "未读" "Unresolve" = "取消解决" "Unsaved changes" = "未保存的更改" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} 中有未保存的更改。要关闭窗口并丢弃这些更改吗?" +"Unsaved changes in {names}. Save them before closing?" = "{names} 中有未保存的更改。关闭前要保存吗?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "有未保存的更改:请先保存({save}),或按住 {alt} 点击关闭标签页以丢弃" "Unsaved changes: save first, or close again with {key} to discard" = "有未保存的更改:请先保存,或再次按 {key} 关闭以丢弃" "Up" = "向上" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..d0130ccc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -657,6 +657,9 @@ pub struct UiState { pub online: online::OnlineUi, /// The window was asked to close with unsaved changes: the question shows. pub confirm_close: bool, + /// The file a Save as started from the unsaved-changes question is saving: the window closes + /// once it is saved, and stays open if the dialog is cancelled or the save fails. + pub close_after_save_as: Option, } impl Default for UiState { @@ -760,6 +763,7 @@ impl Default for UiState { live: live::LiveUi::default(), online: online::OnlineUi::default(), confirm_close: false, + close_after_save_as: None, } } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 1740c9a7..81fa5d58 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -322,8 +322,32 @@ fn close_current(app: &mut FigApp, ctx: &Context) { ctx.request_repaint_of(ViewportId::ROOT); } -/// The unsaved-changes question when a window closes. +/// What the unsaved-changes question was answered with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CloseAnswer { + /// Save every file with unsaved changes, then close (Save as for a file without a path). + Save, + /// Close and lose the changes. + DontSave, + /// Keep the window open. + Cancel, +} + +/// The unsaved-changes question when a window closes: Don't Save / Cancel / Save, as the other +/// Craft apps ask it. pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { + // A Save as this question started: once its dialog is answered, closing goes on if the file + // was saved; cancelled or failed, the window stays open. + if let Some(uid) = app.ui.close_after_save_as { + if app.pending_dialog.is_some() { + return; + } + app.ui.close_after_save_as = None; + if app.session.docs.iter().find(|d| d.uid == uid).is_none_or(|d| !d.is_dirty()) { + request_close(app, ctx); + } + return; + } if !app.ui.confirm_close { return; } @@ -333,20 +357,72 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { return; } let t = crate::theme::Tokens::get(ctx); - let (mut open, mut discard, mut cancel) = (true, false, false); + let mut open = true; + let mut answer = None; crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); - crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text); + crate::widgets::label(ui, &trf("Unsaved changes in {names}. Save them before closing?", &[("names", &names.join(", "))]), t.text); ui.add_space(12.0); ui.horizontal(|ui| { - discard = crate::widgets::button(ui, tr("Close without saving"), true, true).clicked(); - cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); + if crate::widgets::button(ui, tr("Don't Save"), false, true).clicked() { + answer = Some(CloseAnswer::DontSave); + } + if crate::widgets::button(ui, tr("Cancel"), false, true).clicked() { + answer = Some(CloseAnswer::Cancel); + } + if crate::widgets::button(ui, tr("Save"), true, true).clicked() { + answer = Some(CloseAnswer::Save); + } }); }); - app.ui.confirm_close = open && !discard && !cancel; - if discard { - close_current(app, ctx); + if !open { + answer = Some(CloseAnswer::Cancel); } + if let Some(a) = answer { + answer_close(app, ctx, a); + } +} + +/// Act on the unsaved-changes question's answer. +pub(crate) fn answer_close(app: &mut FigApp, ctx: &Context, answer: CloseAnswer) { + app.ui.confirm_close = false; + match answer { + CloseAnswer::Cancel => {} + CloseAnswer::DontSave => close_current(app, ctx), + CloseAnswer::Save => save_then_close(app, ctx), + } +} + +/// Save each file of this window with unsaved changes, then close it. A file without a path (or +/// one saved as .fig, which is never overwritten silently) asks where with Save as, and closing goes +/// on when that dialog saved it ([`confirm_close`]). A save that fails shows why and keeps the +/// window open. +fn save_then_close(app: &mut FigApp, ctx: &Context) { + let files: Vec = app.session.docs.iter().filter(|d| app.shows(d.uid) && d.is_dirty() && d.branch_of.is_none()).map(|d| d.uid).collect(); + for uid in files { + let Some(i) = app.session.docs.iter().position(|d| d.uid == uid) else { continue }; + app.session.active = i; + let native = app + .session + .docs + .get(i) + .and_then(|d| d.path.as_deref()) + .is_some_and(|p| !std::path::Path::new(p).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + if !native { + app.ui.close_after_save_as = Some(uid); + app.save_as_dialog(); + if app.pending_dialog.is_none() { + // No dialog could start: stay open. + app.ui.close_after_save_as = None; + } + ctx.request_repaint(); + return; + } + if app.run("file.save", json!({})).is_none() { + return; + } + } + close_current(app, ctx); } /// Before the main window draws: windows asked to close close, closed files leave their windows diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 99276baa..c5f93a29 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -170,3 +170,134 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(ui.try_recv().is_err(), "answered in the other window's frame"); assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } + +/// Answers Save as with `path` (`None`: cancelled) and counts the dialogs asked for. +struct SaveAs { + path: Option, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAs { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + Box::pin(std::future::ready(self.path.take())) + } +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-close-save-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The main window with one file that has unsaved changes, saved at `path` first when given, and +/// the window asked to close (the question shows). +fn asking(path: Option<&std::path::Path>, save_as: Option) -> (egui::Context, FigApp, std::rc::Rc>) { + let mut s = Session::new(); + if let Some(p) = path { + s.execute("file.saveAs", &json!({"path": p.to_string_lossy()})).unwrap(); + } + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(SaveAs { path: save_as, asked: asked.clone() })); + frame(&ctx, &mut app, false); + frame(&ctx, &mut app, true); + assert!(app.ui.confirm_close, "the question shows"); + (ctx, app, asked) +} + +fn rects_in(path: &std::path::Path) -> usize { + let mut s = Session::new(); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + s.doc().unwrap().nodes().filter(|n| n.name.starts_with("Rectangle")).count() +} + +/// A few frames, as the window draws them. +fn settle(ctx: &egui::Context, app: &mut FigApp) { + for _ in 0..4 { + frame(ctx, app, false); + } +} + +#[test] +fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { + // As the other Craft apps ask it (#1170): Don't Save, Cancel, and Save as the default. + let (ctx, mut app, _) = asking(None, None); + ctx.enable_accesskit(); + frame(&ctx, &mut app, false); + let out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let buttons: Vec = + nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Button).filter_map(|(_, n)| n.label().map(str::to_string)).collect(); + for want in ["Don't Save", "Cancel", "Save"] { + assert!(buttons.iter().any(|b| b == want), "{want} in {buttons:?}"); + } + assert!(!buttons.iter().any(|b| b == "Close without saving")); +} + +#[test] +fn save_saves_a_file_with_a_path_and_then_closes() { + let d = temp_dir("path"); + let path = d.join("a.figcraft"); + let (ctx, mut app, asked) = asking(Some(&path), None); + assert_eq!(rects_in(&path), 0); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 1, "saved where it was"); + assert_eq!(asked.get(), 0, "no Save as for a file with a path"); + assert!(app.ui.quit, "then the window closes"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn save_asks_where_for_an_untitled_file_and_closes_once_saved() { + let d = temp_dir("untitled"); + let path = d.join("new.figcraft"); + let (ctx, mut app, asked) = asking(None, Some(path.clone())); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(asked.get(), 1, "Save as asks where"); + assert_eq!(rects_in(&path), 1, "saved there"); + assert!(app.ui.quit, "then the window closes"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn a_cancelled_save_as_or_a_failed_save_keeps_the_window_open() { + // Save as cancelled: nothing is written, the file keeps its changes and the window stays. + let (ctx, mut app, asked) = asking(None, None); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(asked.get(), 1); + assert!(!app.ui.quit && !app.ui.confirm_close, "open, and not asking again"); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(app.ui.close_after_save_as, None); + + // The save fails (its folder is gone): the error shows and the window stays. + let d = temp_dir("gone"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), None); + std::fs::remove_dir_all(&d).unwrap(); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert!(!app.ui.quit, "a failed save keeps the window open"); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn dont_save_closes_and_cancel_keeps_the_window() { + let d = temp_dir("answers"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), None); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Cancel); + settle(&ctx, &mut app); + assert!(!app.ui.quit && !app.ui.confirm_close); + frame(&ctx, &mut app, true); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::DontSave); + settle(&ctx, &mut app); + assert!(app.ui.quit, "Don't Save closes"); + assert_eq!(rects_in(&path), 0, "without saving"); + std::fs::remove_dir_all(&d).ok(); +} From 4f39406fcd96da3c8556bcfee32f85f94b100bdf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:30:07 +0200 Subject: [PATCH 59/98] Keep the hello size limit until a connection is admitted Fixes #1087 Co-Authored-By: Claude Opus 5.5 --- crates/collab-net/src/hub.rs | 24 +++++++-- crates/collab-net/src/preauth_tests.rs | 72 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 crates/collab-net/src/preauth_tests.rs diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..fe621d98 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -402,6 +402,8 @@ enum HubIn { sock: TcpStream, /// Its size (in flight until handled). bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, }, Msg { conn: u64, @@ -946,9 +948,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { end(Some(BUSY)); } else { - ws.set_config(|c| *c = config(max)); - let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok()); - if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); let _ = hub.send(HubIn::Gone { conn }); } @@ -1074,6 +1080,8 @@ struct Pending { out: Line, sock: TcpStream, ip: IpAddr, + /// Told when it is taken in. + admitted: SyncSender<()>, } /// A connection the hub took in. @@ -1195,7 +1203,7 @@ impl Hub { fn handle(&mut self, m: HubIn) { match m { - HubIn::Open { conn, ip, first, out, sock, bytes } => { + HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => { self.in_flight.give(bytes); // The operator (with the server key) gets in from anywhere; everyone else is // checked (blocked addresses, connection rates). @@ -1204,7 +1212,7 @@ impl Hub { log::info!("event=refused ip={ip} reason={why:?}"); return refuse(&out, &why); } - let c = Pending { out, sock, ip }; + let c = Pending { out, sock, ip, admitted }; match first { ClientMsg::Hello(hello) => self.join(conn, hello, c), ClientMsg::Manage(m) => self.manage(conn, &m, c), @@ -1399,6 +1407,7 @@ impl Hub { if let Some(c) = old.and_then(|old| self.conns.remove(&old)) { shutdown(&c.sock); } + let _ = c.admitted.try_send(()); self.conns .insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip }); self.queue(&file, more); @@ -1444,6 +1453,7 @@ impl Hub { /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { + let _ = c.admitted.try_send(()); self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); self.answer(conn, first); } @@ -1747,3 +1757,7 @@ mod queue_tests; #[cfg(test)] #[path = "barred_tests.rs"] mod barred_tests; + +#[cfg(test)] +#[path = "preauth_tests.rs"] +mod preauth_tests; diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/preauth_tests.rs new file mode 100644 index 00000000..5ae91665 --- /dev/null +++ b/crates/collab-net/src/preauth_tests.rs @@ -0,0 +1,72 @@ +//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it +//! can't have a message of the full size read while its token is unchecked. + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal}; +use figcraft_collab::{Client, Server}; +use tungstenite::{Bytes, Message}; + +use super::{Admission, Backend, Closing, Hosting}; +use crate::net::NetLimits; + +/// A backend that refuses every hello, once it is told to. +struct Slow(Receiver<()>); + +impl Backend for Slow { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + let _ = self.0.recv(); + Err("wrong token".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn closing(&self) -> Closing { + Closing::Ended + } +} + +fn connect(addr: SocketAddr) -> tungstenite::WebSocket { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0 +} + +#[test] +fn an_unadmitted_connection_cant_send_a_large_message() { + let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }; + let (release, wait) = channel(); + let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap(); + let addr = hosting.addr(); + let sender = std::thread::spawn(move || { + let mut ws = connect(addr); + let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap(); + ws.send(Message::Binary(Bytes::from(hello))).unwrap(); + let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) }); + let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap()))); + let mut refused = None; + while let Ok(m) = ws.read() { + if let Message::Binary(b) = m + && let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20) + { + refused = Some(reason); + } + } + refused + }); + let mut peak = 0; + let until = Instant::now() + Duration::from_secs(2); + while Instant::now() < until { + peak = peak.max(hosting.in_flight_bytes()); + std::thread::sleep(Duration::from_millis(2)); + } + release.send(()).unwrap(); + let refused = sender.join().unwrap(); + hosting.stop(); + assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked"); + assert_eq!(refused.as_deref(), Some("wrong token")); +} From 30149d765d193103c3b8ddd960ac1f41c689df14 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Thu, 22 Oct 2026 15:31:37 -0500 Subject: [PATCH 60/98] FreeBSD package.sh: never run a FreeBSD target's binary on another OS Review follow-up. With CARGO_BUILD_TARGET set on a host that is not FreeBSD (say an x86_64 Mac building x86_64-unknown-freebsd), the architectures match but the binary still cannot run, so the --version run is skipped there and the file(1) check is used instead. Part of storytold/craft-common-actions#39. --- packaging/freebsd/package.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 87e5e29f..7a3ae533 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -45,8 +45,12 @@ case "${CARGO_BUILD_TARGET:-}" in *-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a FreeBSD target" >&2; exit 2 ;; esac -# Only binaries for this machine run here (the --version check at the end). +# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built +# on another OS never does. HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)" +if [ -n "${CARGO_BUILD_TARGET:-}" ] && [ "$(uname -s)" != FreeBSD ]; then + HOST_ARCH="$(uname -s) $HOST_ARCH" +fi if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 From d77e0021706991e82811822dd5d4805716c41a4e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 22:35:42 +0200 Subject: [PATCH 61/98] M8: one Save goes through every file needing Save as; Enter answers Save (#1170) Review: after a Save as, closing called request_close, which asked the question again while another file still had changes. It now carries on with save_then_close. Save is the default answer: Enter picks it when no button has the keyboard. --- crates/ui-egui/src/windows.rs | 7 +++- crates/ui-egui/src/windows_tests.rs | 64 ++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 81fa5d58..6afcf1b1 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -343,8 +343,9 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { return; } app.ui.close_after_save_as = None; + // Saved: go on with the other files, then close. if app.session.docs.iter().find(|d| d.uid == uid).is_none_or(|d| !d.is_dirty()) { - request_close(app, ctx); + save_then_close(app, ctx); } return; } @@ -374,6 +375,10 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { answer = Some(CloseAnswer::Save); } }); + // Save is the default: Enter answers it when no button has the keyboard. + if answer.is_none() && ui.memory(|m| m.focused().is_none()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(CloseAnswer::Save); + } }); if !open { answer = Some(CloseAnswer::Cancel); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index c5f93a29..a4e29212 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -171,16 +171,18 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } -/// Answers Save as with `path` (`None`: cancelled) and counts the dialogs asked for. +/// Answers each Save as with the next of `paths` (none left: cancelled) and counts the dialogs +/// asked for. struct SaveAs { - path: Option, + paths: Vec, asked: std::rc::Rc>, } impl crate::Services for SaveAs { fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { self.asked.set(self.asked.get() + 1); - Box::pin(std::future::ready(self.path.take())) + let next = if self.paths.is_empty() { None } else { Some(self.paths.remove(0)) }; + Box::pin(std::future::ready(next)) } } @@ -193,7 +195,7 @@ fn temp_dir(tag: &str) -> std::path::PathBuf { /// The main window with one file that has unsaved changes, saved at `path` first when given, and /// the window asked to close (the question shows). -fn asking(path: Option<&std::path::Path>, save_as: Option) -> (egui::Context, FigApp, std::rc::Rc>) { +fn asking(path: Option<&std::path::Path>, save_as: Vec) -> (egui::Context, FigApp, std::rc::Rc>) { let mut s = Session::new(); if let Some(p) = path { s.execute("file.saveAs", &json!({"path": p.to_string_lossy()})).unwrap(); @@ -201,7 +203,7 @@ fn asking(path: Option<&std::path::Path>, save_as: Option) - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); let asked = std::rc::Rc::new(std::cell::Cell::new(0)); let ctx = egui::Context::default(); - let mut app = FigApp::new(s, Box::new(SaveAs { path: save_as, asked: asked.clone() })); + let mut app = FigApp::new(s, Box::new(SaveAs { paths: save_as, asked: asked.clone() })); frame(&ctx, &mut app, false); frame(&ctx, &mut app, true); assert!(app.ui.confirm_close, "the question shows"); @@ -224,7 +226,7 @@ fn settle(ctx: &egui::Context, app: &mut FigApp) { #[test] fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { // As the other Craft apps ask it (#1170): Don't Save, Cancel, and Save as the default. - let (ctx, mut app, _) = asking(None, None); + let (ctx, mut app, _) = asking(None, vec![]); ctx.enable_accesskit(); frame(&ctx, &mut app, false); let out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); @@ -241,7 +243,7 @@ fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { fn save_saves_a_file_with_a_path_and_then_closes() { let d = temp_dir("path"); let path = d.join("a.figcraft"); - let (ctx, mut app, asked) = asking(Some(&path), None); + let (ctx, mut app, asked) = asking(Some(&path), vec![]); assert_eq!(rects_in(&path), 0); crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); settle(&ctx, &mut app); @@ -255,7 +257,7 @@ fn save_saves_a_file_with_a_path_and_then_closes() { fn save_asks_where_for_an_untitled_file_and_closes_once_saved() { let d = temp_dir("untitled"); let path = d.join("new.figcraft"); - let (ctx, mut app, asked) = asking(None, Some(path.clone())); + let (ctx, mut app, asked) = asking(None, vec![path.clone()]); crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); settle(&ctx, &mut app); assert_eq!(asked.get(), 1, "Save as asks where"); @@ -267,7 +269,7 @@ fn save_asks_where_for_an_untitled_file_and_closes_once_saved() { #[test] fn a_cancelled_save_as_or_a_failed_save_keeps_the_window_open() { // Save as cancelled: nothing is written, the file keeps its changes and the window stays. - let (ctx, mut app, asked) = asking(None, None); + let (ctx, mut app, asked) = asking(None, vec![]); crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); settle(&ctx, &mut app); assert_eq!(asked.get(), 1); @@ -278,7 +280,7 @@ fn a_cancelled_save_as_or_a_failed_save_keeps_the_window_open() { // The save fails (its folder is gone): the error shows and the window stays. let d = temp_dir("gone"); let path = d.join("a.figcraft"); - let (ctx, mut app, _) = asking(Some(&path), None); + let (ctx, mut app, _) = asking(Some(&path), vec![]); std::fs::remove_dir_all(&d).unwrap(); crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); settle(&ctx, &mut app); @@ -290,7 +292,7 @@ fn a_cancelled_save_as_or_a_failed_save_keeps_the_window_open() { fn dont_save_closes_and_cancel_keeps_the_window() { let d = temp_dir("answers"); let path = d.join("a.figcraft"); - let (ctx, mut app, _) = asking(Some(&path), None); + let (ctx, mut app, _) = asking(Some(&path), vec![]); crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Cancel); settle(&ctx, &mut app); assert!(!app.ui.quit && !app.ui.confirm_close); @@ -301,3 +303,43 @@ fn dont_save_closes_and_cancel_keeps_the_window() { assert_eq!(rects_in(&path), 0, "without saving"); std::fs::remove_dir_all(&d).ok(); } + +#[test] +fn one_save_saves_every_untitled_file_of_the_window_then_closes() { + // Two new files with changes: Save asks where for each in turn, then the window closes, + // without asking the question again in between (#1173 review). + let d = temp_dir("two"); + let (a, b) = (d.join("a.figcraft"), d.join("b.figcraft")); + let (ctx, mut app, asked) = asking(None, vec![a.clone(), b.clone()]); + app.session.execute("file.new", &json!({"name": "Second"})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + for _ in 0..8 { + frame(&ctx, &mut app, false); + assert!(!app.ui.confirm_close, "not asked again"); + } + assert_eq!(asked.get(), 2); + assert_eq!((rects_in(&a), rects_in(&b)), (1, 1), "both saved"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn enter_answers_save() { + let d = temp_dir("enter"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + frame(&ctx, &mut app, false); + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + let input = RawInput { events: vec![enter(true), enter(false)], ..Default::default() }; + ctx.run_ui(input, |ui| { + crate::windows::on_close_request(&mut app, ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 1, "Enter saved"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} From d7d6bc8429e9f41d7dce7e178ce123d4cdd3b505 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:49:58 +0200 Subject: [PATCH 62/98] Enforce per-address storage caps per IPv6 /64, /56 and /48 Fixes #1073 Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-server/src/public_tests.rs | 58 +++++++++++++++++++++++ apps/figcraft-server/src/store.rs | 60 ++++++++++++++++++------ 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 978d9e8e..f93b3ac1 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() { assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY); } +#[test] +fn caps_per_address_count_the_wider_ipv6_blocks() { + let dir = TempDir::new("block-caps"); + let mut c = open(dir.path()); + c.max_files_per_ip = 1; + c.publishes_per_hour = 1000; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + // A /48 keeps 4 times one address's files: /64s in different /56s of it. + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + // A /56 keeps twice one address's files. + store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap(); + store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap(); + let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + drop(store); + // Bytes: one address keeps one file's worth, so a /48 keeps 4 files. + let probe = TempDir::new("block-caps-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-caps-bytes"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + s.hosting.max_bytes_per_ip = size; + let mut store = Store::open(dir.path(), s).unwrap(); + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err(); + assert!(e.contains("MiB it may keep"), "{e}"); +} + +#[test] +fn edits_count_the_wider_ipv6_blocks_too() { + use figcraft_collab_net::hub::Backend; + let probe = TempDir::new("block-room-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-room"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + // One address keeps 2.25 files' worth, so its /56 keeps 4.5. + s.hosting.max_bytes_per_ip = size * 2 + size / 4; + let mut store = Store::open(dir.path(), s).unwrap(); + let mut ids = Vec::new(); + for i in 0..4 { + ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id); + } + let server = Backend::open(&mut store, &ids[0]).unwrap(); + // Its own /64 has room for this, but its /56 doesn't. + let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err(); + assert!(e.contains("have reached their"), "{e}"); + Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap(); +} + #[test] fn idle_files_published_without_the_key_expire() { let dir = TempDir::new("expiry"); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..0442f878 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -42,7 +42,7 @@ use figcraft_collab::message::{ PluginReply, PluginReq, ReportInfo, Usage, }; use figcraft_collab::{Change, Replica, Server, same_secret}; -use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key}; +use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key}; use figcraft_collab_net::token; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -514,6 +514,34 @@ impl Store { self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) } + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + /// Keep a document published without the key from `ip`, within the caps on what one address /// and everyone may keep: the file, its links and its owner token. pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { @@ -525,19 +553,21 @@ impl Store { if !self.abuse.allow(ip, Action::Publish, Instant::now()) { return Err("too many files published from your address lately: try again in an hour".into()); } - let ip_text = ip.to_string(); - let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect(); - if mine.len() >= max_files { - return Err(format!( - "your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires", - mine.len() - )); + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } } - let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum(); let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); let fits = |n: u64| { - if used_ip.saturating_add(n) > max_ip { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20)); + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } } if used_all.saturating_add(n) > max_all { return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); @@ -1090,9 +1120,11 @@ impl Store { let h = &self.cfg.hosting; let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum(); - if used_ip.saturating_add(n) > max_ip { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20)); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } } let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); if used_all.saturating_add(n) > max_all { From b8741162cd52e0d456eaf6b4f5b6c1fdf5bf508f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:57:58 +0200 Subject: [PATCH 63/98] Refuse text.loadFont path for plugins Fixes #1071 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/plugins.rs | 3 +++ crates/engine/src/plugins_tests.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..879c4113 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -132,6 +132,9 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + if command == "text.loadFont" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the font as `data`".into()); + } // `path` here is a file on disk (elsewhere it is a vector path or an action's place). if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { return Some("plugins can't write files: leave out `path` to get the result back".into()); diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7f6c3740 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -164,6 +164,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("file.saveAs", json!({"path": target})), ("file.open", json!({"path": "C:/Windows/win.ini"})), ("image.place", json!({"path": "secret.png"})), + ("text.loadFont", json!({"path": "/etc/hosts"})), ("library.add", json!({"path": "lib.figcraft"})), ("plugin.run", json!({"id": "org.test.deny"})), ("plugin.remove", json!({"id": "org.test.deny"})), From c80d1e7ae761bd0385f7688e2b07444d205e23cd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:04:22 +0200 Subject: [PATCH 64/98] Shut kicked and removed connections when their close can't be queued Fixes #1070 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/collab-net/src/barred_tests.rs | 44 +++++++++++++++++++++++++++ crates/collab-net/src/hub.rs | 23 ++++++++++---- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs index c9355a5f..7942e766 100644 --- a/crates/collab-net/src/barred_tests.rs +++ b/crates/collab-net/src/barred_tests.rs @@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() { assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); assert!(hub.barred.contains_key("other"), "another file's stay"); } + +#[test] +fn a_kicked_connection_with_a_full_queue_is_shut() { + use std::io::Read; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc::sync_channel; + use std::time::Duration; + + use super::{Conn, Kind, Line, Out}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_side, _) = listener.accept().unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + // Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone. + let _writer = server_side.try_clone().unwrap(); + let (tx, _rx) = sync_channel(2); + while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {} + let out = Line { + tx, + queued: Arc::default(), + block: Arc::new(AtomicUsize::new(0)), + all: Arc::new(AtomicUsize::new(0)), + max: 1 << 20, + max_block: usize::MAX, + max_all: usize::MAX, + }; + let ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::new(), + conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + hub.kick(&[1], "kicked"); + assert!(hub.conns.is_empty()); + let mut buf = [0u8; 1]; + assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..6761b2d5 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -811,10 +811,15 @@ fn encode(msg: &ServerMsg) -> Option { /// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +fn refused(out: &Line, why: &str) -> bool { if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { let _ = out.tx.try_send(Out::Bytes(b, None)); } - let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))); + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() } /// What the server tells a client when it is too busy to take more from it. @@ -1429,7 +1434,9 @@ impl Hub { barred.insert(c.ip); } log::info!("event=removed file={file} client={id} ip={}", c.ip); - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() { + shutdown(&c.sock); + } self.gone(conn); Ok(()) } @@ -1543,8 +1550,10 @@ impl Hub { // People in the file are told the session is over for them, as when a host stops (they // keep their copy; nothing retries); a library request is refused. for conn in &people { - if let Some(c) = self.conns.get(conn) { - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())); + if let Some(c) = self.conns.get(conn) + && c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err() + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1593,8 +1602,10 @@ impl Hub { /// Refuse connections with `why` and drop them at once (nothing more they send counts). fn kick(&mut self, conns: &[u64], why: &str) { for conn in conns { - if let Some(c) = self.conns.get(conn) { - refuse(&c.out, why); + if let Some(c) = self.conns.get(conn) + && !refused(&c.out, why) + { + shutdown(&c.sock); } self.gone(*conn); } From 03354ec7d3419949db98f51133d6aee744d9b336 Mon Sep 17 00:00:00 2001 From: zuujiscom Date: Thu, 22 Oct 2026 16:06:19 -0500 Subject: [PATCH 65/98] FreeBSD package.sh: accept only the two supported FreeBSD targets Review follow-up. CARGO_BUILD_TARGET must be aarch64-unknown-freebsd or x86_64-unknown-freebsd, the two the release builds; anything else, including made-up triples such as aarch64-foo-unknown-freebsd, stops with exit 2. Part of storytold/craft-common-actions#39. --- packaging/freebsd/package.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index 7a3ae533..fafa51f7 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -42,8 +42,8 @@ release_arch() { # aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. case "${CARGO_BUILD_TARGET:-}" in "") ARCH="$(release_arch "$(uname -m)")" ;; - *-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; - *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a FreeBSD target" >&2; exit 2 ;; + aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; + *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a supported FreeBSD target" >&2; exit 2 ;; esac # Only binaries for this machine run here (the --version check at the end); a FreeBSD target built # on another OS never does. From d5de4be1ab8649f5fe86398ba77f68d415821a76 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:08:25 +0200 Subject: [PATCH 66/98] Enforce an absolute pre-hello deadline against ping slowloris Fixes #1068 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/collab-net/src/hub.rs | 13 +++++++---- crates/collab-net/src/loopback_tests.rs | 31 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..d9f7e4ee 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -820,8 +820,8 @@ fn refuse(out: &Line, why: &str) { /// What the server tells a client when it is too busy to take more from it. const BUSY: &str = "the server is busy: try again in a moment"; -/// Read the next binary message (skipping pings) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize) -> Result, String> { +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { loop { return match ws.read() { Ok(Message::Binary(b)) => { @@ -830,6 +830,10 @@ fn next_message(ws: &mut WebSocket, max: usize) -> Result(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) } Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) if deadline.is_some_and(|d| Instant::now() >= d) => { + Err("no hello in time".into()) + } Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, Ok(Message::Close(_)) => Ok(None), Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), @@ -851,6 +855,7 @@ fn too_large(max: usize) -> String { /// One connection's reader (the writer runs on its own thread). fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -923,7 +928,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit log::info!("event=refused ip={ip} reason=connections_per_address"); end(Some("too many connections from your address: close some and try again")); } else { - match next_message(&mut ws, limits.max_hello) { + match next_message(&mut ws, limits.max_hello, Some(hello_deadline)) { Ok(Some(( first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) @@ -981,7 +986,7 @@ fn read_loop( let mut bucket = Bucket::new(limits.rate, limits.burst); let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); loop { - match next_message(ws, max) { + match next_message(ws, max, None) { Ok(Some((msg, bytes))) => { let now = Instant::now(); if !bucket.take(now) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index a043d8c7..464d8714 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -670,3 +670,34 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { assert!(!welcomed(&gp.events)); host.stop(); } + +#[test] +fn pings_do_not_keep_a_connection_without_a_hello_alive() { + let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default()); + let addr: SocketAddr = invite.server.host.parse().unwrap(); + let mut r = Raw::connect(addr); + let t0 = Instant::now(); + let mut closed = None; + while t0.elapsed() < Duration::from_secs(6) { + if r.0.send(Message::Ping(Bytes::new())).is_err() || r.0.flush().is_err() { + closed = Some(t0.elapsed()); + break; + } + std::thread::sleep(Duration::from_millis(300)); + // Drain what the host sent back (pongs, the close); an error means it hung up. + r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap(); + match r.0.read() { + Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + closed = Some(t0.elapsed()); + break; + } + Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + closed = Some(t0.elapsed()); + break; + } + _ => {} + } + } + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); + host.stop(); +} From 5a53425f519d1933ecc0c1797a248b571e270304 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:14:57 +0200 Subject: [PATCH 67/98] Make is_ancestor fail closed on deep or looping parent chains Fixes #1066 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/lib.rs | 5 +++-- crates/doc/src/tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d131b2f0..e0889b53 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -453,8 +453,9 @@ impl Document { return true; } guard += 1; - if guard > 10_000 { - return false; + // More hops than nodes means the parent chain loops; fail closed so callers reject the move. + if guard > self.nodes.len() { + return true; } cur = self.node(c).and_then(|n| n.parent); } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..a25db50c 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -464,3 +464,15 @@ fn text_style_empty_range_links_and_unlinks_all() { e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } + +#[test] +fn is_ancestor_holds_for_very_deep_chains() { + let mut d = Document::new("x"); + let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap(); + let mut leaf = root; + for _ in 0..10_005 { + leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap(); + } + assert!(d.is_ancestor(root, leaf)); + assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); +} From 8aa456b198d3f1f34f60275811e812e2060af7c8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:23:47 +0200 Subject: [PATCH 68/98] Stop stale next_id from making pastes overwrite existing nodes Fixes #1064 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 15 ++++++++++++++- crates/doc/src/tests.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d131b2f0..4fdecd89 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -269,7 +269,16 @@ impl Document { /// A fresh unused id (in this document's client space during a collaboration session; see /// [`set_client_id`](Self::set_client_id)). pub fn alloc_id(&mut self) -> NodeId { - self.next_id.alloc() + // A file's saved `next_id` can be stale (behind ids already in use): skip taken ids. + let mut id = self.next_id.alloc(); + while self.nodes.contains_key(&id) { + let next = self.next_id.alloc(); + if next == id { + break; + } + id = next; + } + id } /// The id [`alloc_id`](Self::alloc_id) returns next. @@ -821,6 +830,10 @@ impl Document { return Err(DocError::NotContainer(parent)); } let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?; + // Never overwrite an existing node with a copy. + if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i)) { + return Err(DocError::Invalid(format!("node {taken} already exists"))); + } for n in nodes { let Some(new_id) = map.get(&n.id) else { continue }; let mut c = n.clone(); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..cc1f240c 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -464,3 +464,38 @@ fn text_style_empty_range_links_and_unlinks_all() { e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } + +#[test] +fn stale_next_id_never_overwrites_existing_nodes() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(1); + let mut bad: Document = serde_json::from_value(v).unwrap(); + let before: Vec<(NodeId, String, Option)> = + [p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect(); + let n = bad.node_count(); + let copy = bad.duplicate_into(f, p, None).unwrap(); + assert!(![p, f, a].contains(©)); + assert_eq!(bad.node_count(), n + 2); + let copies = bad.duplicate_all_into(&[f], p).unwrap(); + assert_eq!(bad.node_count(), n + 4); + assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c))); + for (i, name, parent) in &before { + let node = bad.node(*i).unwrap(); + assert_eq!((&node.name, node.parent), (name, *parent)); + } + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // A paste whose id map points at an existing node fails and changes nothing. + let nodes: Vec = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect(); + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, p); + let snapshot = bad.clone(); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); + assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name); + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); +} From c857f8d0f3dd7385b84ac74a44425248f74975c4 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:30:11 +0200 Subject: [PATCH 69/98] Cap the issue list returned by Document::validate Fixes #1058 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/lib.rs | 23 +++++++++++++++-------- crates/doc/src/tests.rs | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d131b2f0..4edaeb7e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -984,39 +984,46 @@ impl Document { /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { + // Hostile files can break an invariant per child; keep only the first few reports. + const MAX_ISSUES: usize = 100; + fn note(issues: &mut Vec, msg: impl FnOnce() -> String) { + if issues.len() < MAX_ISSUES { + issues.push(msg()); + } + } let mut issues = Vec::new(); for (id, n) in self.nodes.iter() { if n.id != *id { - issues.push(format!("node key {id} has id {}", n.id)); + note(&mut issues, || format!("node key {id} has id {}", n.id)); } let mut seen = std::collections::BTreeSet::new(); for c in &n.children { if !seen.insert(*c) { - issues.push(format!("child {c} listed twice in {id}")); + note(&mut issues, || format!("child {c} listed twice in {id}")); } match self.node(*c) { Some(cn) if cn.parent == Some(*id) => {} - Some(_) => issues.push(format!("child {c} of {id} has another parent")), - None => issues.push(format!("child {c} of {id} missing")), + Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")), + None => note(&mut issues, || format!("child {c} of {id} missing")), } } if let Some(p) = n.parent && !self.node(p).is_some_and(|pn| pn.children.contains(id)) { - issues.push(format!("{id} not listed in parent {p}")); + 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)); if n.is_page() != listed { - issues.push(format!("page list mismatch for {id}")); + note(&mut issues, || format!("page list mismatch for {id}")); } if n.is_page() && n.parent.is_some() { - issues.push(format!("page {id} has a parent")); + note(&mut issues, || format!("page {id} has a parent")); } } let reachable = self.reachable(); for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) { - issues.push(format!("{id} is not under a page (cycle or detached)")); + note(&mut issues, || format!("{id} is not under a page (cycle or detached)")); } issues } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..e64705b9 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() { assert_eq!(bad.subtree(p), vec![p, r]); } +#[test] +fn validate_caps_the_issue_list() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]); + let bad: Document = serde_json::from_value(v).unwrap(); + let issues = bad.validate(); + assert!(!issues.is_empty()); + assert!(issues.len() <= 100, "{}", issues.len()); +} + #[test] fn auto_layout_loads_files_without_grid_tracks() { // Saved before grid tracks, cells and spans existed. From 1f2a8104bfd79343be891c5d08f14b2571baec42 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 22 Oct 2026 23:21:27 +0200 Subject: [PATCH 70/98] M4: vector edit transform box around points of several layers With points selected in more than one edited layer the transform box now wraps them all, 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 are mapped in its own coordinates and the whole drag is one undo step. A click inside clears every layer's points. --- .../src/vector_multi_behaviour_tests.rs | 16 ++-- crates/engine/src/vectoredit.rs | 2 +- crates/engine/src/vectoredit/pointer.rs | 14 +-- crates/engine/src/vectoredit/transform.rs | 60 ++++++++++--- .../engine/src/vectoredit/transform_tests.rs | 90 +++++++++++++++++++ docs/gaps.md | 7 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 3 +- 8 files changed, 168 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..b1e6a948 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() { assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer"); - // Dragging one selected point drags them all. - let active = editing(&s).unwrap().id; - let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) }; - drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE); - assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all"); + // Dragging one selected point drags them all (one on the transform box's edge: its corners + // scale, as with points of one layer). + let atr = point_at(&s, a, (100.0, 0.0)); + ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true})); + drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE); + assert!( + near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), + "dragging one selected point moves them all" + ); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step"); + ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); // Corner radius, mirroring and deleting reach every layer's selected points. ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10})); assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..3b79dd4b 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -4,7 +4,7 @@ //! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent). //! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the //! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`, -//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and +//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and //! hovering, hatching and selecting filled regions in [`regions`]. pub mod ops; diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..fc6f7891 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -262,7 +262,7 @@ impl Session { } /// Every edited layer's selected points (segment ends included) other than the active one's. - fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { + pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { ve.others .iter() .filter_map(|o| { @@ -310,7 +310,7 @@ impl Session { let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) }; match bh { BoxHit::Inside => { - let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?; + let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?; self.begin_interaction("Move points")?; self.vgesture(g); } @@ -320,7 +320,7 @@ impl Session { self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 }); } BoxHit::Handle(handle) => { - let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[])); + let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others)); self.begin_interaction("Scale points")?; self.vgesture(VGesture::BoxScale { b, handle, targets, pick }); } @@ -883,7 +883,7 @@ impl Session { let q = b.frame.inverse() * (ev.p() + c); let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt); let m = b.onto(x0, y0, x1, y1); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; } VGesture::BoxRotate { b, start_angle, .. } => { let c = b.center(); @@ -892,7 +892,7 @@ impl Session { angle = (angle / 15.0).round() * 15.0; } let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees()); self.vgesture(VGesture::BoxRotate { b, start_angle, angle }); } @@ -1010,6 +1010,10 @@ impl Session { set_state(self, |e| { e.vertices.clear(); e.segments.clear(); + for o in &mut e.others { + o.vertices.clear(); + o.segments.clear(); + } })?; } } diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..ca6e59e2 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -1,11 +1,11 @@ //! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the -//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt -//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift: -//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in -//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, +//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners +//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just +//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves +//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, //! and while points are selected the flip and 90°/180° rotation commands act on them. -use figcraft_doc::Document; +use figcraft_doc::{Document, NodeId}; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec}; const EPS: f64 = 1e-9; /// The selected points' box: the page transform of its top-left corner (axes along the vector's -/// own), its size in those axes and the points in it. +/// own), its size in those axes and the points in it: the active layer's, and other edited +/// layers' in `others`. #[derive(Clone, Debug, PartialEq)] pub struct PointBox { pub frame: Affine, pub width: f64, pub height: f64, pub points: Vec, + pub others: Vec<(NodeId, Vec)>, } impl PointBox { + /// How many points the box wraps, in every layer. + pub fn point_count(&self) -> usize { + self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::() + } + /// Corners in page coordinates (tl, tr, br, bl). pub fn corners(&self) -> [Point; 4] { let (f, w, h) = (self.frame, self.width, self.height); @@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option { if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { return None; } - Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points }) + Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] }) +} + +/// The box of the selected points of every edited layer, in the page's axes (the layers' own may +/// differ). +fn layers_box(doc: &Document, ve: &VectorEdit) -> Option { + let points = selected_points(network(doc, ve.id)?, ve); + let others = Session::others_moving(doc, ve); + let page = |id: NodeId, verts: &[usize]| -> Vec { + let w = doc.world_transform(id); + network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default() + }; + let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v))); + let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| { + (a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y)) + }); + let (width, height) = (x1 - x0, y1 - y0); + if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { + return None; + } + Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others }) } /// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more -/// points that aren't all in one place, all in the active layer. -/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for. +/// points that aren't all in one place, in any of the edited layers. pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option { - if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() { + if ve.tool != VTool::Move || ve.anchor.is_some() { return None; } - point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS)) + let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) }; + b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS)) } /// What the transform box does under the pointer. @@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option, p: Point, zoom: /// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then /// re-fit the vector. -pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> { +pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> { let w = doc.world_transform(id); let local = w.inverse() * m * w; edit(doc, id, |net| ops::transform_vertices(net, verts, local)) } +/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`. +pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> { + transform_points(doc, id, &b.points, m)?; + for (o, verts) in &b.others { + transform_points(doc, *o, verts, m)?; + } + Ok(()) +} + /// Transform the selected points by `f(box)` (page space) when editing with points selected and /// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on /// layers). diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..73a49aab 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -307,3 +307,93 @@ fn edit_mode_transform_commands_survive_junk() { } } } + +/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one +/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points +/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags +/// land exactly. +fn two_layers(s: &mut Session) -> (NodeId, NodeId) { + let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let (a, b) = (square(0.0), square(200.0)); + ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}})); + ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}})); + ex(s, "view.snapGeometry", json!({"on": false})); + ex(s, "view.snapObjects", json!({"on": false})); + ex(s, "view.snapPixel", json!({"on": false})); + ex(s, "vector.edit.begin", json!({"ids": [a, b]})); + let n = net(s, a); + ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]})); + ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true})); + assert_eq!(ve(s).id, b, "the layer named last is the active one"); + (a, b) +} + +/// Every selected point's page position, layer by layer. +fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> { + let e = ve(s); + let parts = e.parts(); + [a, b] + .iter() + .flat_map(|id| { + let part = parts.iter().find(|p| p.id == *id).unwrap(); + part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::>() + }) + .collect() +} + +#[test] +fn the_box_wraps_points_selected_in_several_layers() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + assert_eq!(before.len(), 6); + // On the page's axes: `b` is turned, so neither layer's own axes fit both. + let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y))); + let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y))); + let c = s.vector_edit_box().expect("a box around the points of both layers"); + assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})"); + + // The bottom-right corner dragged to twice the width scales every point in both layers. + drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1)); + for (id, v, p) in &before { + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } + + // From just outside the top-right corner, a quarter turn clockwise about the box's centre. + let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let from = pt(x1 + 10.0, y0 - 10.0); + let d = from - ctr; + drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x)); + for (id, v, p) in &before { + let r = *p - ctr; + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } +} + +#[test] +fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + // Clear of both networks: between the two squares, inside the box. + let doc = s.doc().unwrap().clone(); + assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none()); + assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside)); + drag(&mut s, (150.0, 50.0), (170.0, 60.0)); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along"); + } + s.undo().unwrap(); + click(&mut s, 150.0, 50.0); + assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..35a71a12 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -308,11 +308,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). ### 13. Vector tail: none left +- **Landed (2026-10-22):** the transform box around points selected in several layers at once, in + the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags + them, each in its own layer and one undo step for all (`transform_tests`). - **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, the transform box around - points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. + selected open path. Left: corner smoothing beyond rectangles and frames, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..f914bf26 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -97,7 +97,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 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 | | 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; the transform box around points of several layers; 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 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 | | 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 | @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d87103f0..f89b3f67 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); with points selected in several layers the transform box wraps them all in the page's axes and scales, turns and drags them in one undo step (`transform_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | 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); 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; 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 | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | From b3e65c89f90f6b922d543e18b10f3b51ad269290 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:46:52 +0200 Subject: [PATCH 71/98] Refuse non-local library paths in the library Cache Fixes #1056 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/library.rs | 5 +++++ crates/engine/src/library_path_tests.rs | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 26863561..198afd4a 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -491,6 +491,11 @@ impl Cache { None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())), }; } + // The path comes from a document: a network share would be contacted (and on Windows get + // the opener's credentials) by the stat below. + if !craft_docpath::is_local(path) { + return Err(EngineError::Other(format!("{path}: a library is opened only from this computer"))); + } let stamp = stat(path); // Checked before the cache: the limit is the caller's. if let Ok(st) = &stamp diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs index f15105dc..de40128a 100644 --- a/crates/engine/src/library_path_tests.rs +++ b/crates/engine/src/library_path_tests.rs @@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() { let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist"); assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}"); } + +#[test] +fn the_library_cache_refuses_a_path_off_this_computer() { + let mut cache = crate::library::Cache::default(); + for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] { + let e = cache.load(path).expect_err(&format!("{path} must be refused")); + assert!(e.to_string().contains("only from this computer"), "{path}: {e}"); + } +} From b57c67782f8060d9f77f9140e99360d24228f124 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:54:59 +0200 Subject: [PATCH 72/98] Deny library.publishToServer to plugins Fixes #1055 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/plugins.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..252b55fa 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -107,6 +107,8 @@ const DENIED: &[&str] = &[ "plugins.", "libraries.", "library.add", + // Uploads the open document to a server. + "library.publishToServer", "edit.undo", "edit.redo", "edit.repeat", @@ -592,3 +594,15 @@ pub fn specs() -> Vec { spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage), ] } + +#[cfg(test)] +mod refusal_tests { + use super::*; + + #[test] + fn read_only_plugin_cannot_publish_to_server() { + let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap(); + let r = refusal(&m, "library.publishToServer", &json!({})); + assert!(r.is_some(), "library.publishToServer uploads the document and must be refused"); + } +} From 375001983c0f106e8b50ab53ec7b9f9ccbd82727 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:07:07 +0000 Subject: [PATCH 73/98] Strip bidi control characters from names and report reasons Fixes #1126 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/store.rs | 14 ++++++++++++-- apps/figcraft-server/src/store_tests.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..1b9b93e3 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -291,7 +291,17 @@ fn valid_id(s: &str) -> bool { /// A name others may see: trimmed, no control characters, at most 256 characters. fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(256).collect() + s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect() +} + +/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters. +fn clean_reason(s: &str) -> String { + s.trim().chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).take(MAX_REASON).collect() +} + +/// Bidirectional format characters, which could reorder what a terminal shows. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } /// Take the data directory's lock: `None` when someone else holds it. @@ -748,7 +758,7 @@ impl Store { /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect(); + let reason = clean_reason(reason); if reason.is_empty() { return Err("say what is wrong with the file".into()); } diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..52aeb23c 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -313,3 +313,20 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +const BIDI: [char; 12] = + ['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}']; + +#[test] +fn names_and_report_reasons_lose_bidi_controls() { + let dir = TempDir::new("bidi"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for (n, m) in BIDI.into_iter().enumerate() { + let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap(); + let (id, _, _) = publish(&mut store, &format!("a{m}b")); + assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32); + store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap(); + assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32); + store.remove(&id).unwrap(); + } +} From ffe61d1880cffa0e1edd8c51788b6e246aeb6166 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:13:46 +0200 Subject: [PATCH 74/98] Accept a version string for the plugin manifest api field Fixes #1040 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/plugin/src/manifest.rs | 21 ++++++++++++++++++++- crates/plugin/src/tests.rs | 9 +++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..4bd3590f 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -44,7 +44,7 @@ pub struct Manifest { pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings /// need 2, widgets 3). - #[serde(default = "api_version")] + #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] pub permissions: Permissions, @@ -61,6 +61,25 @@ fn api_version() -> u32 { 1 } +/// `api` as a whole number or a version string (`"1.0.0"`, whose major part is the API version). +fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Api { + Number(u32), + Text(String), + } + match Api::deserialize(d)? { + Api::Number(n) => Ok(n), + Api::Text(s) => s + .trim() + .split('.') + .next() + .and_then(|major| major.parse().ok()) + .ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))), + } +} + /// What a plugin may do. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Permissions { diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..a6e2a587 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -462,3 +462,12 @@ fn bad_manifests_are_refused() { assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::()); } } + +#[test] +fn api_may_be_a_version_string() { + assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1); + assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2); + assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2); + assert!(manifest(json!({"api": "4.0.0"})).is_err()); + assert!(manifest(json!({"api": "x"})).is_err()); +} From 49c0d4c789496a5d74b7c6e2055bdfe3f70aa9b2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:17:35 +0000 Subject: [PATCH 75/98] Don't run the expiry sweep from offline CLI commands Fixes #1091 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft-server/src/public_tests.rs | 16 ++++++++++++++++ apps/figcraft-server/src/store.rs | 8 +++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 978d9e8e..a77502bf 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -196,6 +196,22 @@ fn idle_files_published_without_the_key_expire() { assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id)); } +#[test] +fn offline_commands_do_not_sweep_idle_files() { + let dir = TempDir::new("offline-sweep"); + let mut c = open(dir.path()); + c.idle_days = 30.0; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap(); + store.age(&public.id, 31 * 86_400); + drop(store); + let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap(); + drop(store); + assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file"); + let _serving = Store::open(dir.path(), c.store()).unwrap(); + assert!(!dir.path().join("files").join(&public.id).exists()); +} + #[test] fn rate_limits_and_blocks_refuse_with_a_reason() { let dir = TempDir::new("limits"); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..cdb33405 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -338,10 +338,13 @@ impl Store { /// Use the data directory `root` (made if missing). Fails when another server or command holds /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). pub fn open(root: &Path, cfg: StoreConfig) -> Result { - Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display())) + let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?; + store.sweep(); + Ok(store) } - /// [`open`](Self::open), or `None` when someone else holds the directory. + /// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or + /// `None` when someone else holds the directory. pub fn try_open(root: &Path, cfg: StoreConfig) -> Result, String> { let Some(lock) = lock(root)? else { return Ok(None) }; let key = load_key(root)?; @@ -374,7 +377,6 @@ impl Store { store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); - store.sweep(); Ok(Some(store)) } From 3b440d01e647449f94fcf5ec5dfd119b1a6462b2 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Thu, 22 Oct 2026 18:39:53 -0600 Subject: [PATCH 76/98] #237: test that wrapped colour picker swatches stay inset inside the picker The "On this page" swatches were indented by a single add_space, so once they wrapped the later rows started at the picker's left edge and ran past its right edge. The test fills a page with 40 distinct colours, opens the picker and checks every painted swatch lies at least 16 pt inside both sides of the picker window. Without the fix the rightmost swatch ends 3 pt inside the edge instead of 16. --- .../src/colorpicker_selection_tests.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 57aae197..fff5536a 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -52,3 +52,54 @@ fn picker_closes_when_another_layer_is_selected() { u.frame(vec![]); assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); } + +// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's +// left edge (only the first row was indented) and ran past its right edge. +#[test] +fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { + use egui::Shape; + let mut s = Session::new(); + let mut first = None; + for i in 0..40 { + let v = s + .execute( + "shape.rectangle", + &json!({"x": i * 30, "y": 0, "width": 20, "height": 20, "props": {"fill": format!("#{:02X}{:02X}80", i * 6, 255 - i * 6)}}), + ) + .unwrap(); + first.get_or_insert(NodeId(v["id"].as_u64().unwrap())); + } + let a = first.unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } + let out = u.frame_output(vec![]); + // The picker is the only floating window open. + let picker = u + .ctx + .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id))) + .expect("the picker is open"); + // A swatch is a 14 pt square with a 1 pt outline. + let swatches: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::Rect(r) if r.stroke.width == 1.0 && (r.rect.width() - 14.0).abs() < 0.01 && (r.rect.height() - 14.0).abs() < 0.01 => Some(r.rect), + _ => None, + }) + .filter(|r| picker.contains_rect(*r)) + .collect(); + let mut rows: Vec = swatches.iter().map(|r| r.min.y).collect(); + rows.sort_by(f32::total_cmp); + rows.dedup_by(|a, b| (*a - *b).abs() < 1.0); + assert!(rows.len() >= 2, "expected the page colours to wrap onto several rows, got {} swatches in {} row(s)", swatches.len(), rows.len()); + for r in &swatches { + assert!( + r.min.x >= picker.min.x + 16.0 - 0.5 && r.max.x <= picker.max.x - 16.0 + 0.5, + "swatch {r:?} is not inset 16 pt from both sides of the picker {picker:?}" + ); + } +} From af0b150736ccbcf52c9f46aae9a108f63477a3a3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 02:12:13 +0200 Subject: [PATCH 77/98] M3: instance-swap properties in the Dev Mode component playground --- crates/ui-egui/src/component_panel.rs | 82 +++++++++++++++++---------- crates/ui-egui/src/dev_mode.rs | 18 ++++-- crates/ui-egui/src/dev_mode_tests.rs | 79 +++++++++++++++++++++++++- docs/components-parity.md | 5 +- docs/target-app-parity.md | 2 +- 5 files changed, 147 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 2b2573c7..b869e1c0 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -88,7 +88,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) /// Local components (and component sets, not their variants), for swap menus: (id, label), page /// by page. Listed once per document revision (a file with thousands of layers isn't walked every /// frame). -fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { +pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { type Cached = ((u64, u64), std::sync::Arc>); let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default(); let id = Id::new("component_panel_local_components"); @@ -708,35 +708,9 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::InstanceSwap { preferred, .. } => { - let cur = figcraft_engine::params::id_of(&v); - let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); - let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); - menu_popup(&resp).width(200.0).show(|ui| { - let t = Tokens::get(ui.ctx()); - let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); - let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { - let current = cur == Some(c); - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); - } - ui.close(); - } - }; - let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); - if !preferred.is_empty() { - heading(ui, tr("Preferred")); - for (c, name) in &preferred { - pick(ui, *c, name); - } - heading(ui, tr("All components")); - } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps { - pick(ui, *c, name); - } - }); - }); + if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); + } } PropDef::Variant { .. } => {} PropDef::Slot { preferred } => { @@ -749,6 +723,54 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } } +/// The dropdown of an instance-swap property showing component `cur`: its menu lists the +/// property's preferred components first (when it has any), then every component in the file. The +/// dropdown, and the component picked (picking the current one picks nothing). `name` labels it for +/// assistive technology. +pub(crate) fn swap_dropdown( + ui: &mut Ui, + doc: &Document, + cur: Option, + preferred: &[NodeId], + comps: &[(NodeId, String)], + w: f32, + name: &str, +) -> (egui::Response, Option) { + let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); + if !name.is_empty() { + crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp)); + } + let mut picked = None; + menu_popup(&resp).width(200.0).show(|ui| { + let t = Tokens::get(ui.ctx()); + let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); + let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { + let current = cur == Some(c); + if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { + if !current { + picked = Some(c); + } + ui.close(); + } + }; + let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); + if !preferred.is_empty() { + heading(ui, tr("Preferred")); + for (c, name) in &preferred { + pick(ui, *c, name); + } + heading(ui, tr("All components")); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (c, name) in comps { + pick(ui, *c, name); + } + }); + }); + (resp, picked) +} + fn prop_label(ui: &mut Ui, name: &str, icon: Icon) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover()); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 0b429820..735da4bb 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -281,13 +281,14 @@ pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String { } /// The component playground, for a selected instance, component or component set: a preview of the -/// component and a control for each variant, boolean and text property. The values tried here -/// change the preview and the code only (returned when any is set: the Code section shows it), -/// never the document. +/// component and a control for each variant, boolean, text and instance-swap property. The values +/// tried here change the preview and the code only (returned when any is set: the Code section +/// shows it), never the document. pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option { figcraft_engine::playground::component_of(doc, id)?; let t = Tokens::get(ui.ctx()); let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None }; + let comps = crate::component_panel::local_components(app, ui, doc); let play = &mut app.ui.dev.playground; if play.target != Some(id) { *play = Playground { target: Some(id), ..Playground::default() }; @@ -357,7 +358,16 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod set = Some((name.to_string(), json!(new.chars().take(10_000).collect::()))); } }), - // Instance swaps and slots show as the component has them. + "instanceSwap" => row(ui, 32.0, |ui| { + widgets::label(ui, name, t.text_secondary); + let preferred: Vec = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); + let w = ui.available_width().max(60.0); + if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1 + { + set = Some((name.to_string(), json!(c.0))); + } + }), + // Slots show as the component has them. _ => {} } } diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 941f1b2c..3e2ea9ba 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -1,8 +1,10 @@ //! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens //! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view -//! does; the focus view's Escape; the annotation filter. +//! does; the focus view's Escape; the annotation filter; the playground's instance-swap menu. -use egui::{Key, pos2}; +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -80,3 +82,76 @@ fn annotation_filter_and_categories_panel() { assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized)); assert_eq!(CategoryFilter::parse(&doc, "nope"), None); } + +/// A "Button" component whose nested "Heart" instance is its instance-swap property "Icon", a +/// second icon "Star", and an instance of the button selected in the Inspect tab. Returns (ui, +/// instance, heart, star). +fn swap_setup() -> (crate::layers_drag_tests::Ui, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let icon = |s: &mut Session, name: &str, x: i32| { + let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 24, "height": 24, "name": name})).unwrap()); + id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()) + }; + let (heart, star) = (icon(&mut s, "Heart", 0), icon(&mut s, "Star", 40)); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 120, "height": 40, "name": "Button"})).unwrap()); + let nested = id(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 108, "parent": f})).unwrap()); + let button = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap(); + let inst = id(&s.execute("instance.create", &json!({"component": button, "x": 0, "y": 300})).unwrap()); + s.execute("select.set", &json!({"ids": [inst]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.ctx.enable_accesskit(); + (u, inst, heart, star) +} + +fn tree(u: &mut crate::layers_drag_tests::Ui) -> Vec { + u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut crate::layers_drag_tests::Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +/// The playground's dropdown for property `name`, scrolled into view. +fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option { + for _ in 0..20 { + let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some(name))?; + if centre(&combo).y < 860.0 { + return Some(combo); + } + let wheel = + Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } + } + None +} + +#[test] +fn the_playground_swaps_an_instance_in_the_preview_only() { + let (mut u, inst, heart, star) = swap_setup(); + let (doc, undo) = (u.app.session.ds().unwrap().doc.clone(), u.app.session.undo_depth()); + let combo = playground_combo(&mut u, "Icon").expect("the playground has a dropdown for the instance-swap property"); + assert_eq!(combo.value(), Some("Heart")); + // Open it and pick Star: the item that wasn't there before the menu opened. + let before: Vec = tree(&mut u).iter().filter(|n| n.label() == Some("Star")).map(centre).collect(); + click(&mut u, centre(&combo)); + let item = tree(&mut u).into_iter().find(|n| n.label() == Some("Star") && !before.contains(¢re(n))).expect("Star in the menu"); + click(&mut u, centre(&item)); + let combo = playground_combo(&mut u, "Icon").unwrap(); + assert_eq!(combo.value(), Some("Star"), "the playground shows the swapped icon"); + // The file isn't touched: the instance still has the heart, nothing to undo. + assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc)); + assert_eq!(u.app.session.undo_depth(), undo); + let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap(); + assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}"); +} diff --git a/docs/components-parity.md b/docs/components-parity.md index fad4c6fe..7f7c162f 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-20 · **Change:** minor (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-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) 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 @@ -29,7 +29,7 @@ accounts). | Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` | | Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) | | Instances pasted into other files follow their mains | done | missing main restorable | -| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) | +| Component playground (try properties without placing) | partial | variant, boolean, text and instance-swap properties in the Inspect tab (the swap menu lists the preferred components first, then every component in the file, as in the Design panel) and `component.playground`; slots have no control there yet | | Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 | The behaviour of instances, overrides, variants and swaps is checked against the public @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..5f88498b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -104,7 +104,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | -| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | +| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | From 410c1f10e263f586981d101fbe93758a5ab932b8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 03:38:33 +0200 Subject: [PATCH 78/98] The colour picker's eyedropper applies the colour to the paint the picker edits (Reported in storytold/vectorcraft#3342) --- crates/engine/src/cmd/view.rs | 9 +- crates/engine/src/fx_behaviour_tests.rs | 23 +++++ crates/engine/src/tools.rs | 10 ++- crates/ui-egui/src/colorpicker.rs | 16 +++- .../src/colorpicker_eyedropper_tests.rs | 90 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 6 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/colorpicker_eyedropper_tests.rs diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0592046e..c9715e77 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -534,7 +534,14 @@ pub fn specs() -> Vec { spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), - spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)), + // `apply: false` is for the UI colour picker: the eyedropper only samples (into + // `ToolState::picked`) and the picker writes the colour to the paint it edits. + spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| { + let r = tool(s, ToolId::Eyedropper)?; + s.tool.pick_only = !boolean(p, "apply").unwrap_or(true); + s.tool.picked = None; + Ok(r) + }), ] } diff --git a/crates/engine/src/fx_behaviour_tests.rs b/crates/engine/src/fx_behaviour_tests.rs index eb8da9b2..d9d93592 100644 --- a/crates/engine/src/fx_behaviour_tests.rs +++ b/crates/engine/src/fx_behaviour_tests.rs @@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() { assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); } +#[test] +fn the_eyedropper_of_a_colour_picker_samples_without_filling() { + // Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker, + // which writes it to its own paint (a stroke, an effect...): the fill stays as it was. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000"); + let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let undo = s.undo_depth().0; + s.execute("edit.pickColor", &json!({"apply": false})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); + assert_eq!(s.undo_depth().0, undo, "nothing to undo"); + assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper"); + // The I key's eyedropper fills the selection again. + s.execute("edit.pickColor", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.picked, None); +} + #[test] fn blend_modes_and_effects_through_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..b5e6538e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -462,6 +462,11 @@ pub struct ToolState { pub labels: Vec<(NodeId, [Point; 4])>, /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). pub pencil_brush: Option, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, } /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most @@ -923,6 +928,7 @@ impl Session { } self.tool.tool = t; self.tool.gesture = Gesture::None; + self.tool.pick_only = false; // The tool's cursor shows before the pointer moves. self.tool.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; @@ -2032,7 +2038,9 @@ impl Session { 255, ); let sel = ds.selection.clone(); - if !sel.is_empty() { + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; } self.recent_colors.retain(|x| *x != c); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index d6aad31d..96188631 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -436,7 +436,14 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { } pub fn show(app: &mut FigApp, ctx: &egui::Context) { - let Some(target) = app.ui.picker.clone() else { return }; + let Some(target) = app.ui.picker.clone() else { + // A colour sampled for a picker that has closed has no paint to go to. + app.session.tool.picked = None; + if app.session.tool.pick_only { + app.run("tool.move", json!({})); + } + return; + }; // A layer's picker belongs to the selection it was opened for: once that changes, close it // rather than keep editing layers that are no longer selected. let stale = match &target { @@ -470,6 +477,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { .open(&mut open) .show(ctx, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). + if let Some(c) = app.session.tool.picked.take() { + set_color(app, &target, &mut paint, c, stop_id(), ui); + } // Header. let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); @@ -772,8 +783,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ) .clicked() { - app.ui.picker = None; - app.run("edit.pickColor", json!({})); + app.run("edit.pickColor", json!({"apply": false})); return; } let (c2, d2) = hue_slider(ui, hr, &mut hsb); diff --git a/crates/ui-egui/src/colorpicker_eyedropper_tests.rs b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs new file mode 100644 index 00000000..51856b48 --- /dev/null +++ b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs @@ -0,0 +1,90 @@ +//! The eyedropper of a colour picker, driven through egui headless: the colour it samples goes to +//! the paint the picker edits (a stroke, an effect), not to the layer's fill (vectorcraft#3342). + +use egui::accesskit::Role; +use egui::{Event, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::{Session, ToolId}; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn rect(s: &mut Session, x: f64, props: serde_json::Value) -> NodeId { + let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 60, "height": 60, "props": props})).unwrap(); + NodeId(v["id"].as_u64().unwrap()) +} + +/// The middle of the picker's eyedropper button. +fn eyedropper_button(u: &mut Ui) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Pick color (I)")).expect("the eyedropper button"); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![]); +} + +#[test] +fn the_pickers_eyedropper_applies_the_colour_to_the_paint_it_edits() { + for effect in [false, true] { + // A red layer to sample, and a green one with a blue stroke (and a shadow) to edit. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + let red = rect(&mut s, 0.0, json!({"fill": "#FF0000"})); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF", "strokeWeight": 2})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let target = if effect { + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + PickerTarget::Effect { ids: vec![b], index: 0, secondary: false } + } else { + PickerTarget::Paint { fills: false, index: 0, ids: vec![b] } + }; + let mut u = Ui::new(s); + u.app.ui.picker = Some(target.clone()); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper, "the button picks up the eyedropper"); + let undo = u.app.session.undo_depth().0; + let on_red = u.app.to_screen(30.0, 30.0); + click(&mut u, on_red); + let doc = u.app.session.doc().unwrap(); + let n = doc.node(b).unwrap(); + let hex = |p: Option| p.map(|c| c.to_hex()).unwrap_or_default(); + let picked = if effect { hex(n.effects[0].color(false)) } else { hex(n.strokes[0].solid_color()) }; + assert_eq!(picked, "FF0000", "the picker's paint takes the colour (effect: {effect})"); + assert_eq!(hex(n.fills[0].solid_color()), "00FF00", "the fill is left alone (effect: {effect})"); + assert!(doc.node(red).is_some()); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "picking ends the eyedropper"); + assert_eq!(u.app.ui.picker, Some(target), "the picker stays open on its paint"); + assert_eq!(u.app.session.undo_depth().0, undo + 1, "one undo step"); + } +} + +#[test] +fn closing_the_picker_drops_its_eyedropper() { + // The colour sampled after the picker closed has no paint to go to: it must not wait for the + // next picker, and the eyedropper gives way to the Move tool. + let mut s = Session::new(); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF"})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![b] }); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper); + u.app.ui.picker = None; + u.app.session.tool.picked = figcraft_color::Color::from_hex("#FF0000"); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.tool.tool, ToolId::Move); + assert_eq!(u.app.session.tool.picked, None, "a colour sampled for a closed picker is dropped"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..00d94e22 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ mod canvas_fixes_tests; #[cfg(test)] mod canvas_image_rect_tests; #[cfg(test)] +mod colorpicker_eyedropper_tests; +#[cfg(test)] mod colorpicker_selection_tests; #[cfg(test)] mod comments_tests; From 2a6e460a7b8961ce8670418138cabc62ff47e93a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 10:17:56 +0200 Subject: [PATCH 79/98] Let the box of another layer's points win over a region of the active one With points selected only on another edited layer, a press inside the combined box over an unselected filled region of the active layer picked the region instead of dragging the box. Other layers' selections now count in the same priority check; a press on an already selected region still drags the regions. --- crates/engine/src/vectoredit/pointer.rs | 6 ++-- .../engine/src/vectoredit/transform_tests.rs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index fc6f7891..ed13ed70 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -428,11 +428,11 @@ impl Session { } /// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part - /// there). Inside the transform box around points selected in their own right, the box keeps - /// its drag unless the region is already selected. + /// there). Inside the transform box around points selected in their own right (in any edited + /// layer), the box keeps its drag unless the region is already selected. fn region_under(doc: &Document, ve: &VectorEdit, h: Option, ev: &PointerEvent) -> Option { let r = region_hit(doc, ve, ev.p(), ev.zoom)?; - let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty(); + let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected(); let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside); (!in_box || ve.regions.contains(&r)).then_some(r) } diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index 73a49aab..f29ed2a5 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -397,3 +397,32 @@ fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() { click(&mut s, 150.0, 50.0); assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points"); } + +#[test] +fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() { + // `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected: + // the press at the box's centre lands on `a`'s region too. + let mut s = Session::new(); + let mut square = |x: f64, size: f64| { + crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap() + }; + let (a, b) = (square(0.0, 300.0), square(100.0, 50.0)); + ex(&mut s, "view.snapGeometry", json!({"on": false})); + ex(&mut s, "view.snapObjects", json!({"on": false})); + ex(&mut s, "view.snapPixel", json!({"on": false})); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]})); + ex(&mut s, "vector.edit.select", json!({"id": a, "add": true})); + let doc = s.doc().unwrap().clone(); + let e = ve(&s); + assert!(e.id == a && e.vertices.is_empty() && e.others_selected()); + assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none()); + assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press"); + assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside)); + let before: Vec = (0..4).map(|v| page_pt(&s, b, v)).collect(); + drag(&mut s, (125.0, 125.0), (145.0, 135.0)); + assert!(ve(&s).regions.is_empty(), "the region isn't selected"); + for (v, p) in before.iter().enumerate() { + assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along"); + } +} From 42502a81d72229a5415fbc6a68670af37a523b14 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:59:36 +0300 Subject: [PATCH 80/98] Control server: wait after a failed accept instead of retrying at once The accepting thread read listener.incoming().flatten(), which drops a failed accept and asks again at once. While accepting keeps failing, for example when the process has run out of file descriptors, that thread kept a core busy. It now waits 100 ms after a failed accept. The loop goes through a small accepted helper so the wait can be tested. --- apps/figcraft/src/control_server.rs | 11 ++++++++++- apps/figcraft/src/control_server_tests.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 1c91840f..ff9c2fbe 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -16,6 +16,10 @@ const MAX_LINE: usize = 32 * 1024 * 1024; const MAX_CONNECTIONS: usize = 8; const MAX_QUEUED_REQUESTS: usize = 8; const TIMEOUT: Duration = Duration::from_secs(60); +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); /// Deferred requests for a hidden window must not make the UI's queue grow without a bound. pub fn drain_queue(queue: &Queue, target: &mut Vec) { @@ -38,7 +42,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { eprintln!("FigCraft control server listening on 127.0.0.1:{port}"); let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { let mut workers = Vec::new(); - for stream in listener.incoming().flatten() { + for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT); } }); @@ -47,6 +51,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { } } +/// The connections `incoming` accepts, calling `wait` after each failed accept. +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) { workers.retain(|worker| !worker.is_finished()); if workers.len() >= MAX_CONNECTIONS { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 008170fb..011b0613 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -131,6 +131,18 @@ fn idle_and_partial_frame_connections_expire() { } } +/// A failed accept is followed by a wait before the next one, so a listener that keeps failing +/// does not spin, and the connections around the failures still come through. +#[test] +fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); +} + #[test] fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { let pending = queue(); From 068634b586b5a4a1f24f0698a1362dc8caa7dad1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:53:25 +0200 Subject: [PATCH 81/98] Honour even-odd fill rule when hit testing vectors Found while testing crates/doc/src/geometry.rs:75 (hits_node_itself, the `_` arm, using path.winding(lp) != 0); compare VectorNetwork::fill_contains at crates/doc/src/vector.rs:321 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/geometry.rs | 11 ++++++++++- crates/doc/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 2a7b42a6..4c48713a 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -72,7 +72,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } - _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)), + _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))), + } +} + +/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of +/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it). +fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool { + match &n.kind { + NodeKind::Vector { network } => network.fill_contains(lp), + _ => path.winding(lp) != 0, } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..4b82fbac 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -464,3 +464,31 @@ fn text_style_empty_range_links_and_unlinks_all() { e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } + +#[test] +fn hit_test_even_odd_vector_hole_misses() { + let ring = |rule| { + let sq = |o: f64, s: f64, base: usize| { + ( + [(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)), + [Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)], + ) + }; + let (v1, s1) = sq(0.0, 200.0, 0); + let (v2, s2) = sq(60.0, 80.0, 4); + VectorNetwork { + vertices: v1.into_iter().chain(v2).collect(), + segments: s1.into_iter().chain(s2).collect(), + regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }], + } + }; + let mut d = Document::new("x"); + let p = page(&d); + let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo)); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled"); + let mut d = Document::new("x"); + let p = page(&d); + let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz)); +} From 95db91def982e6d2357999aedbd5d3cb8bf9d1cc Mon Sep 17 00:00:00 2001 From: Armin <6575829+seltzdesign@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:04:28 +0200 Subject: [PATCH 82/98] M5: a top-level frame taller than the device scrolls without an overflow setting On a device, a presented frame bigger than the screen now scrolls through its whole size whatever its overflow, as Figma documents for top-level frames. Before, "No scrolling" frames only clipped, so the landing page template's long Phone frame couldn't be scrolled in Present. Content past a frame's own edges still scrolls only on the axes its overflow allows. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/present.rs | 31 +++++++++++++++++++--------- crates/ui-egui/src/present_tests.rs | 32 +++++++++++++++++++++++++++++ docs/mcp.md | 3 ++- docs/prototyping-parity.md | 3 ++- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..42e1079d 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -4,8 +4,9 @@ //! The player works on its own copy of the document (interactive components, variables and scroll //! offsets change it without touching the user's file) and copies the editor's document again //! whenever it changes, so the preview pane follows edits live. On a device, the presented frame -//! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when -//! the frame's overflow allows it. A responsive prototype's frame is instead resized to the screen +//! is sized to the device's screen ([`fit_viewport`]): a frame bigger than the screen scrolls +//! through its whole size (as in Figma, with no overflow setting), while content past the frame's +//! own edges scrolls only as its overflow allows and is otherwise clipped. A responsive prototype's frame is instead resized to the screen //! (the device's, else the player's whole area) and laid out again, its constraints and auto layout //! responding (`figcraft_engine::cmd::proto::respond`, on the player's copy, from the design each //! time). Device bodies are drawn in code in the device's colour. Every frame is rendered through @@ -1170,11 +1171,13 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// How far `frame`'s content can scroll when `viewport` (page units, from its top-left) of it /// shows, as a device's screen shows a top-level frame: the frame itself is content then, so a -/// frame taller than the screen scrolls through its whole height. Without a viewport the frame -/// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. +/// frame bigger than the screen scrolls through its whole size whatever its overflow (as in +/// Figma, a top-level frame needs no overflow setting for that). Without a viewport the frame +/// shows at its own size and what overflows it scrolls. Content past the frame's own edges +/// scrolls only on the axes its overflow allows. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if n.overflow == Overflow::None { + if n.overflow == Overflow::None && viewport.is_none() { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1191,6 +1194,13 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 x1 += l.padding[1]; y1 += l.padding[2]; } + // Axes the overflow doesn't scroll clip what lies past the frame's edges. + if !matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { + x1 = 0.0; + } + if !matches!(n.overflow, Overflow::Vertical | Overflow::Both) { + y1 = 0.0; + } let (vw, vh) = match viewport { Some(v) => { x1 = x1.max(n.width); @@ -1199,8 +1209,7 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 } None => (n.width, n.height), }; - let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let (rx, ry) = ((x1 - vw).max(0.0), (y1 - vh).max(0.0)); (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -2356,7 +2365,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible && (n.overflow != Overflow::None || *f == root))) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -3144,9 +3153,11 @@ mod tests { assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); p.scroll_to(0.0, 99_999.0); assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); - // Without scrolling the screen only clips; without a device the frame shows whole. + // A top-level frame taller than the screen scrolls without an overflow setting (its own + // range still clips nested content); without a device the frame shows whole. d.get_mut(a).unwrap().overflow = Overflow::None; - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 0.0)); + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); if let Some(pg) = d.page_of(a) && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind { diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index a1e4ebe2..002e012e 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -175,6 +175,38 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { assert_eq!(u.scroll_y(), 0.0); } +/// A long top-level frame scrolls on a phone with no overflow setting, as in Figma: the landing +/// page template's Phone frame ("No scrolling", hugging its height) scrolls by the wheel. +#[test] +fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() { + let mut s = Session::new(); + s.execute("file.newFromTemplate", &json!({"template": "landing-page"})).unwrap(); + s.execute("proto.settings", &json!({"device": "iPhone 16", "responsive": true})).unwrap(); + let doc = s.doc().unwrap(); + let page = doc.first_page().unwrap(); + let phone = doc.top_frames(page).into_iter().find(|f| doc.node(*f).unwrap().name == "Phone").unwrap(); + assert_eq!(doc.node(phone).unwrap().overflow, figcraft_doc::proto::Overflow::None); + let mut u = Ui::new(s); + u.run("proto.preview", json!({"start": phone})); + let range = u.preview()["scrollRange"][1].as_f64().unwrap(); + assert!(range > 1000.0, "the page is far taller than the screen: {range}"); + let c = u.pane().center(); + let wheel = move || { + vec![ + Event::PointerMoved(c), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ] + }; + u.frames(6, wheel); + u.frames(20, Vec::new); + assert!(u.scroll_y() > 0.0, "{}", u.scroll_y()); +} + #[test] fn full_window_presenter_shares_the_player() { let (mut u, a, ..) = setup(); diff --git a/docs/mcp.md b/docs/mcp.md index d73cf78f..32d2a16a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -635,7 +635,8 @@ rotation?, color?, width?, height?, responsive?}` (`None`, `iPhone 16`, `iPhone `Black`, `White`, `Pink`, `Teal`, `Blue` for the iPhone 16 (an error lists them), `""` for the default; `width` / `height` set the custom size's screen, 1–10000 points, and pick `Custom size`, which is a bare screen without a body). On a device the presented frame is sized to the device's -screen: what doesn't fit is clipped, or scrolls when the frame's `overflow` allows it, while `fixed` +screen: a frame bigger than the screen scrolls through its whole size whatever its `overflow`, content +past the frame's own edges scrolls only as its `overflow` allows (else it's clipped), and `fixed` layers stay on screen (pinned to the edges their constraints name). With `responsive: true` (saved in the file) the presented frame is instead resized to the device's screen, or without a device to the presenter's window at 100%, and its constraints and auto layout respond as when it is resized diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index e4cf8c7f..c4aeb1ce 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -86,7 +86,7 @@ case pins the current behaviour). | Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas | | Interactive components: instances play their variant's interactions, overridden per trigger | done | | | Drag-driven animations that follow the pointer, in their direction | done | | -| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put, as the frame's overflow setting allows ("No scrolling" frames clip); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | +| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | | 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 | | @@ -100,6 +100,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 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-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | From 6494743b6e7a3598b323a781d5d5da6f35431acc Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:55:35 +0000 Subject: [PATCH 83/98] Fix duplicate SVG ids when a layer name matches a generated id Found while testing crates/svg/src/lib.rs:549 (unique_id) with :162 (id) and :387 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 9 +++++++-- crates/svg/src/tests.rs | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..16942934 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -160,8 +160,13 @@ fn hex(c: Color) -> String { impl W<'_> { fn id(&mut self, prefix: &str) -> String { - self.next += 1; - format!("{prefix}{}", self.next) + loop { + self.next += 1; + let id = format!("{prefix}{}", self.next); + if self.used_ids.insert(id.clone()) { + return id; + } + } } /// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing. diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..6706899f 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -159,3 +159,14 @@ fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { assert!(svg_with(StrokeJoin::Miter).contains("stroke-width=\"2\""), "equal joins stay a stroke"); assert!(!svg_with(StrokeJoin::Round).contains("stroke-width="), "a differing join is drawn as an outline"); } + +#[test] +fn layer_ids_never_clash_with_generated_ids() { + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "clip1", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(f).unwrap().clip = true; + d.create(f, NodeKind::Rectangle, "R", 0.0, 0.0, 50.0, 50.0).unwrap(); + let svg = export_svg(&d, &[f], &SvgOptions { include_ids: true, ..SvgOptions::default() }).unwrap(); + assert_eq!(svg.matches("id=\"clip1\"").count(), 1, "{svg}"); +} From f00b62ba2d8737f673fd995fcc716b5d56dc27c3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 09:26:44 +0700 Subject: [PATCH 84/98] Merge fix: the server store tests keep each test closed --- apps/figcraft-server/src/store_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index a24a4102..bd1d9c32 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -374,6 +374,7 @@ fn a_log_that_isnt_one_is_moved_aside() { assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems()); let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk)); assert!(kept, "the old log's bytes are kept beside the file"); +} const BIDI: [char; 12] = ['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}']; @@ -418,7 +419,6 @@ fn a_maxed_out_generation_does_not_wrap() { let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); assert_eq!(snap, u64::MAX); } -} #[test] fn every_leftover_temporary_file_is_removed_at_startup() { From 1f9effd25d16c9e224477c9a83908c8972f98493 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 09:34:40 +0700 Subject: [PATCH 85/98] #660: a file saved as Proto.figcraft presents as Proto (titles follow file names on main) --- crates/ui-egui/src/present_tab_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs index 610c2ab1..1868babb 100644 --- a/crates/ui-egui/src/present_tab_tests.rs +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -170,7 +170,8 @@ fn a_saved_file_opened_again_gets_its_tab_back() { u.run("file.close", json!({"index": 0})); u.run("file.open", json!({"path": path})); u.run("proto.present", json!({"start": a})); - assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + // Saved as Proto.figcraft: the file's title follows its name. + assert_eq!(tabs(&u), json!([{"name": "Proto - Page 1", "active": true}])); u.key(pos2(700.0, 500.0), Key::Escape); assert!(!u.app.ui.home, "back to its file"); assert_eq!(tabs(&u), json!([])); From bd80829a2786b8ee6907061e254515d0a23fc1c1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:30:49 +0700 Subject: [PATCH 86/98] Batch r2-1 review: a paint keeps its own opacity through unbind and rebind color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag: binding a colour variable takes only that part out, unbinding puts the variable's alpha back in, and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not. --- crates/color/src/lib.rs | 21 +++++++++++++-------- crates/engine/src/bound_alpha_tests.rs | 11 +++++++++++ crates/engine/src/cmd/edit.rs | 4 ++-- crates/engine/src/cmd/styles.rs | 19 +++++++++---------- crates/format/src/fig/assets_tests.rs | 2 +- crates/format/src/fig/export_tests.rs | 2 +- crates/format/src/rest/props.rs | 5 ++++- crates/format/src/rest/tests.rs | 1 + 8 files changed, 42 insertions(+), 23 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 84149dfb..1fff7667 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -559,10 +559,11 @@ pub struct Paint { pub visible: bool, #[serde(default)] pub blend: BlendMode, - /// The opacity holds the alpha of a typed solid colour (see [`Paint::solid`]) rather than an - /// opacity set on its own: a colour variable bound to the paint replaces it. - #[serde(default, rename = "colorAlpha", skip_serializing_if = "std::ops::Not::not")] - pub color_alpha: bool, + /// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]), + /// the rest being an opacity set on its own: a colour variable bound to the paint replaces + /// this part only. 1: all of it is the paint's own. + #[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")] + pub color_alpha: f32, } /// Paints compare by what they paint; where the opacity came from doesn't matter. @@ -576,6 +577,10 @@ fn yes() -> bool { true } +fn is_one(x: &f32) -> bool { + *x == 1.0 +} + impl Paint { pub fn solid(color: Color) -> Self { Self { @@ -583,12 +588,12 @@ impl Paint { opacity: color.a, visible: true, blend: BlendMode::Normal, - color_alpha: color.a < 1.0, + color_alpha: color.a, } } pub fn gradient(g: Gradient) -> Self { - Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false } + Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { @@ -604,13 +609,13 @@ impl Paint { opacity: 1.0, visible: true, blend: BlendMode::Normal, - color_alpha: false, + color_alpha: 1.0, } } /// Layer `source` (a node id) tiled at its own size. pub fn pattern(source: u64) -> Self { - Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false } + Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } /// The solid colour (with the paint opacity folded into alpha), if solid. diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs index c54f0fb7..fb15425d 100644 --- a/crates/engine/src/bound_alpha_tests.rs +++ b/crates/engine/src/bound_alpha_tests.rs @@ -151,3 +151,14 @@ fn binding_keeps_an_opacity_set_on_the_paint() { ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r)); } + +#[test] +fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() { + // A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again. + let (mut s, r) = setup("#FF000080", "#FF000080"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] { + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable})); + assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r)); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 60a96b31..5ed92aec 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1375,12 +1375,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; if c.a < 1.0 { slot.opacity = c.a; - slot.color_alpha = true; + slot.color_alpha = c.a; } } if let Some(o) = num(p, "opacity") { slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; - slot.color_alpha = false; + slot.color_alpha = 1.0; } if let Some(v) = boolean(p, "visible") { slot.visible = v; diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index dd93bac3..e5ac145e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { ok() } -/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts -/// an opacity that holds the alpha a typed colour had (see `Paint::solid`) at 1, so the variable's -/// alpha applies once; an opacity set on its own, before or while bound, multiplies on top and -/// stays. Unbinding moves the colour's alpha back into the opacity, where a typed colour keeps it. +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the +/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies +/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding +/// moves the colour's alpha back into the opacity, where a typed colour keeps it. pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { use super::lists::{BoundList, list_binding}; let Some((list, i, "color")) = list_binding(field) else { return }; @@ -820,13 +820,12 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie && let PaintKind::Solid { color } = &mut p.kind { if bind { - if p.color_alpha { - p.opacity = 1.0; - } - p.color_alpha = false; - } else if color.a < 1.0 { + // What's left is the opacity set on its own (unknown under a colour alpha of 0: 1). + p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + p.color_alpha = 1.0; + } else { p.opacity *= color.a; - p.color_alpha = true; + p.color_alpha *= color.a; } color.a = 1.0; } diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index b48dd9d3..a271a908 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -106,7 +106,7 @@ fn themed() -> Document { g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }]; n.fills = vec![ solid(0.5, 0.5, 0.5), - Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false }, + Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }, ]; n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)]; n.effects = vec![Effect { diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index ae7da3ed..2edefcaa 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint { p.kind } }; - Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: false } + Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 } } fn effect(r: &mut Lcg) -> Effect { diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index ff06c737..f5be3d27 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option> { fn paint(cx: &mut Ctx, p: &Value) -> Option { let ty = text(p, "type")?; let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32; + // The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`). + let mut color_alpha = 1.0; let kind = match ty { "SOLID" => { let c = p.get("color").and_then(color).unwrap_or(Color::BLACK); opacity *= c.a; + color_alpha = c.a; PaintKind::Solid { color: c.with_alpha(1.0) } } "GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)), @@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option { return None; } }; - Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha: false }) + Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha }) } /// Gradient handles are already in the node's unit box (start, end, width), like ours. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 1b3f85b6..f9acf2ea 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() { let s = &named(&doc, "Solid").fills[0]; assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0)); assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha"); + assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces"); assert!(!s.visible && s.blend == BlendMode::Screen); let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() }; assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0))); From 8dcba0684b115ff5f9dc8a8a7425d2bc54e8da20 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:08 +0700 Subject: [PATCH 87/98] Batch r2-1 review: .fig export grid sizing in helpers; instance layers clear inherited track sizes auto_layout's grid branch moves to grid_layout and grid_sizing (SonarCloud cognitive complexity). unset() clears gridColumnsSizing and gridRowsSizing, so an instance layer with equal tracks no longer reads back its main layer's track sizes. --- crates/format/src/fig/export.rs | 91 ++++++++++++++------------- crates/format/src/fig/export_tests.rs | 36 +++++++++++ 2 files changed, 83 insertions(+), 44 deletions(-) diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 2d02539e..234e1459 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -296,6 +296,23 @@ fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> { } } +/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't +/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets +/// `dropped`. +fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option> { + let mut entries = Vec::new(); + for (track, id) in tracks.iter().zip(ids) { + match grid_track_size(track) { + Some((kind, value)) => { + let size = || o(vec![("type", e(kind)), ("value", fl(value))]); + entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + } + None => *dropped = true, + } + } + (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) +} + fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } @@ -1419,49 +1436,7 @@ impl<'d> Exporter<'d> { ("stackReverseZIndex", b(l.reverse_z)), ]); if grid { - // Instance layers share their main layer's track ids, so unchanged tracks and the cells - // their layers sit in still match. - let key = self.main_layer(n.id); - let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); - let (cols, rows) = match self.tracks.get(&key) { - Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), - _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), - }; - let map = |ids: &[u32]| { - o(vec![( - "entries", - arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()), - )]) - }; - f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - // Sizing is keyed by track id like the order map. A track the document doesn't set is - // left out, and one the format can't hold keeps its id (the import equalises it). - let mut dropped = false; - let sizing = |tracks: &[GridTrack], ids: &[u32], dropped: &mut bool| -> Option> { - let mut entries = Vec::new(); - for (i, id) in ids.iter().enumerate() { - let Some(track) = tracks.get(i) else { continue }; - match grid_track_size(track) { - Some((kind, value)) => { - let size = || o(vec![("type", e(kind)), ("value", fl(value))]); - entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); - } - None => *dropped = true, - } - } - (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) - }; - let column_sizing = sizing(&l.column_tracks, &cols, &mut dropped); - let row_sizing = sizing(&l.row_tracks, &rows, &mut dropped); - if dropped { - self.warn("grid tracks whose size the format can't hold were written as equal tracks"); - } - for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { - if let Some(v) = sizing { - f.push((name, v)); - } - } - self.tracks.insert(key, (cols, rows)); + self.grid_layout(n, l, f); } else { let spread = l.content_align == ContentAlign::SpaceBetween; f.extend([ @@ -1472,6 +1447,34 @@ impl<'d> Exporter<'d> { } } + /// A grid layout's track gaps, order and sizes. + fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + // Instance layers share their main layer's track ids, so unchanged tracks and the cells + // their layers sit in still match. + let key = self.main_layer(n.id); + let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); + let (cols, rows) = match self.tracks.get(&key) { + Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), + _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), + }; + let map = |ids: &[u32]| { + o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))]) + }; + f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); + let mut dropped = false; + let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped); + if dropped { + self.warn("grid tracks whose size the format can't hold were written as equal tracks"); + } + for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { + if let Some(v) = sizing { + f.push((name, v)); + } + } + self.tracks.insert(key, (cols, rows)); + } + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { @@ -2417,7 +2420,7 @@ fn unset(key: &str) -> Option> { "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { o(vec![("guid", guid(u32::MAX, u32::MAX))]) } - "variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]), + "variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]), "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { arr(Vec::new()) diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 2edefcaa..1f0fe172 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1061,6 +1061,42 @@ fn grid_track_sizes_round_trip() { assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal"); } +/// An instance layer whose grid tracks are equal doesn't read back its main layer's track sizes. +#[test] +fn instance_grid_tracks_clear_the_mains_sizes() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap(); + let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + let layout = AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 2, + grid_rows: 1, + column_tracks: vec![GridTrack::fixed(120.0); 2], + ..AutoLayout::default() + }; + doc.node_mut(grid).unwrap().layout = Some(layout.clone()); + let inst = doc.alloc_id(); + let mut n = doc.node(main).unwrap().clone(); + (n.id, n.kind, n.parent, n.children) = (inst, NodeKind::Instance { component: main, props: BTreeMap::new() }, None, Vec::new()); + doc.insert(n, page, None).unwrap(); + let mut layer = doc.node(grid).unwrap().clone(); + (layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid)); + layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout }); + doc.insert(layer, inst, None).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "File").unwrap(); + let tracks = |inside: bool| { + let n = back.nodes().find(|n| { + n.name == "Grid" + && back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))) == inside + }); + n.and_then(|n| n.layout.clone()).unwrap().column_tracks + }; + assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]); + assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true)); +} + /// A frame's own min/max size survives whatever its parent is, not only under auto layout. #[test] fn frame_size_limits_survive_without_auto_layout_parent() { From 3334ec6c16378dccd16ad79526695086c93a663f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:10 +0700 Subject: [PATCH 88/98] Batch r2-1 review: vector_up in smaller helpers (SonarCloud cognitive complexity) --- crates/engine/src/vectoredit/pointer.rs | 55 ++++++++++++++----------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index ed13ed70..4721d6bd 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -1007,14 +1007,7 @@ impl Session { } else { self.cancel_interaction()?; if click_clears { - set_state(self, |e| { - e.vertices.clear(); - e.segments.clear(); - for o in &mut e.others { - o.vertices.clear(); - o.segments.clear(); - } - })?; + self.clear_points()?; } } } @@ -1041,23 +1034,7 @@ impl Session { } })?, VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?, - VGesture::BoxScale { pick, .. } => { - let ds = self.ds()?; - let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); - match pick.filter(|_| clicked) { - Some(v) => { - self.cancel_interaction()?; - set_state(self, |e| { - if let Some(i) = e.vertices.iter().position(|x| *x == v) { - e.vertices.remove(i); - } else { - e.vertices.push(v); - } - })?; - } - None => self.commit_interaction()?, - } - } + VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?, VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?, VGesture::Width(g) => self.width_up(g)?, } @@ -1067,6 +1044,34 @@ impl Session { Ok(()) } + /// Clears the point selection of the active layer and of the other layers being edited. + fn clear_points(&mut self) -> Result<()> { + set_state(self, |e| { + e.vertices.clear(); + e.segments.clear(); + for o in &mut e.others { + o.vertices.clear(); + o.segments.clear(); + } + }) + } + + /// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's + /// point `pick` toggles it in the selection; anything else keeps the scale. + fn box_scale_up(&mut self, pick: Option) -> Result<()> { + let ds = self.ds()?; + let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); + let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() }; + self.cancel_interaction()?; + set_state(self, |e| { + if let Some(i) = e.vertices.iter().position(|x| *x == v) { + e.vertices.remove(i); + } else { + e.vertices.push(v); + } + }) + } + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its /// points, merges into that point (in the same undo step as the move), which stays selected. fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { From aac76ebeca6f1a276c0623abec0745e8b41f2762 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:13 +0700 Subject: [PATCH 89/98] Batch r2-1 review: Save on close waits a frame for a panel field to commit A click on Save takes the keyboard from a focused panel field, which commits next frame: the files save then, as the field-save shortcut does. OsStr::to_str as a method reference (SonarCloud). --- crates/ui-egui/src/lib.rs | 4 ++++ crates/ui-egui/src/windows.rs | 19 ++++++++++++------- crates/ui-egui/src/windows_tests.rs | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21510ba9..e85b000e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -679,6 +679,9 @@ pub struct UiState { /// The file a Save as started from the unsaved-changes question is saving: the window closes /// once it is saved, and stays open if the dialog is cancelled or the save fails. pub close_after_save_as: Option, + /// Save was answered: the files save next frame, once a panel field the click took the + /// keyboard from has committed what was typed (as [`UiState::after_field`]). + pub save_on_close: bool, } impl Default for UiState { @@ -786,6 +789,7 @@ impl Default for UiState { online: online::OnlineUi::default(), confirm_close: false, close_after_save_as: None, + save_on_close: false, } } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 6afcf1b1..11356725 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -336,6 +336,10 @@ pub(crate) enum CloseAnswer { /// The unsaved-changes question when a window closes: Don't Save / Cancel / Save, as the other /// Craft apps ask it. pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { + if std::mem::take(&mut app.ui.save_on_close) { + save_then_close(app, ctx); + return; + } // A Save as this question started: once its dialog is answered, closing goes on if the file // was saved; cancelled or failed, the window stays open. if let Some(uid) = app.ui.close_after_save_as { @@ -394,7 +398,10 @@ pub(crate) fn answer_close(app: &mut FigApp, ctx: &Context, answer: CloseAnswer) match answer { CloseAnswer::Cancel => {} CloseAnswer::DontSave => close_current(app, ctx), - CloseAnswer::Save => save_then_close(app, ctx), + CloseAnswer::Save => { + app.ui.save_on_close = true; + ctx.request_repaint(); + } } } @@ -407,12 +414,10 @@ fn save_then_close(app: &mut FigApp, ctx: &Context) { for uid in files { let Some(i) = app.session.docs.iter().position(|d| d.uid == uid) else { continue }; app.session.active = i; - let native = app - .session - .docs - .get(i) - .and_then(|d| d.path.as_deref()) - .is_some_and(|p| !std::path::Path::new(p).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + let native = + app.session.docs.get(i).and_then(|d| d.path.as_deref()).is_some_and(|p| { + !std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig")) + }); if !native { app.ui.close_after_save_as = Some(uid); app.save_as_dialog(); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index a4e29212..fe63cd86 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -253,6 +253,21 @@ fn save_saves_a_file_with_a_path_and_then_closes() { std::fs::remove_dir_all(&d).ok(); } +#[test] +fn save_waits_for_a_panel_field_to_commit() { + // The click on Save takes the keyboard from a panel field, which commits what was typed next + // frame: the save comes after that commit (stood in for by a command run before the frame). + let d = temp_dir("field"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 2, "the committed edit is saved too"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn save_asks_where_for_an_untitled_file_and_closes_once_saved() { let d = temp_dir("untitled"); From 5d7eb04d4410ff000307e5d14defe9e71f5e8c54 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:15 +0700 Subject: [PATCH 90/98] Batch r2-1 review: the hello and close-drain deadlines bound every read Duplex::read_until gives a reader a deadline: each socket read waits at most what's left of it. The server reads the handshake and hello under the hello deadline, so a hello trickled in fragments can't outlast it; manage's close drains under CLOSE_DRAIN, so a silent server can't hold it for the idle timeout. --- crates/collab-net/src/hub.rs | 17 +++++++--- crates/collab-net/src/loopback_tests.rs | 27 ++++++++++++---- crates/collab-net/src/manage.rs | 43 ++++++++++++++++++------- crates/collab-net/src/net.rs | 19 +++++++++-- 4 files changed, 83 insertions(+), 23 deletions(-) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 2f87334d..c2202919 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -837,8 +837,11 @@ fn next_message(ws: &mut WebSocket, max: usize, deadline: Option(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) } Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), - // Pings keep the per-read timeout alive, but not the overall deadline (slowloris). - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) if deadline.is_some_and(|d| Instant::now() >= d) => { + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { Err("no hello in time".into()) } Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, @@ -871,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit } let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); let cfg = config(limits.max_client_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). @@ -935,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit log::info!("event=refused ip={ip} reason=connections_per_address"); end(Some("too many connections from your address: close some and try again")); } else { - match next_message(&mut ws, limits.max_hello, Some(hello_deadline)) { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { Ok(Some(( first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) @@ -978,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit drop(counted); // The block's counters go with the last handle on them (the table forgets it then). drop(block); + ws.get_mut().read_until(None); linger(&mut ws); drop(out); // The writer closes the socket once it has sent what's queued (a refusal, a close). diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 464d8714..d5a38d9d 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -2,7 +2,7 @@ //! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and //! reconnecting. Every test stops its host and closes its sockets. -use std::io::Read; +use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -671,15 +671,16 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { host.stop(); } -#[test] -fn pings_do_not_keep_a_connection_without_a_hello_alive() { +/// How long a connection that never says hello stays open while `poke` sends it something every +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. +fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option { let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default()); let addr: SocketAddr = invite.server.host.parse().unwrap(); let mut r = Raw::connect(addr); let t0 = Instant::now(); - let mut closed = None; + let (mut closed, mut first) = (None, true); while t0.elapsed() < Duration::from_secs(6) { - if r.0.send(Message::Ping(Bytes::new())).is_err() || r.0.flush().is_err() { + if !poke(&mut r, std::mem::take(&mut first)) { closed = Some(t0.elapsed()); break; } @@ -698,6 +699,20 @@ fn pings_do_not_keep_a_connection_without_a_hello_alive() { _ => {} } } - assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); host.stop(); + closed +} + +#[test] +fn pings_do_not_keep_a_connection_without_a_hello_alive() { + let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); +} + +#[test] +fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() { + // A binary message begun and never finished: one masked byte per frame, each in time for the + // read timeout. + let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}"); } diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index dc3ec315..2f0683c7 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -210,11 +210,12 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result Result<(), String> { @@ -250,19 +251,18 @@ fn reply(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option< mod tests { use super::*; - /// A server that keeps pinging and never answers the close must not pin `close` forever. - #[test] - fn close_returns_against_a_server_that_keeps_pinging() { + /// Whether `close` returns within 3 s against a server that never answers the close and does + /// `serve` instead, with reads waiting up to `idle`. + fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket) + Send + 'static) -> bool { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); std::thread::spawn(move || { let (s, _) = listener.accept().unwrap(); - let Ok(mut ws) = tungstenite::accept(s) else { return }; - while ws.send(Message::Ping(Bytes::new())).is_ok() { - std::thread::sleep(Duration::from_millis(10)); + if let Ok(ws) = tungstenite::accept(s) { + serve(ws); } }); - let limits = NetLimits { idle_timeout: Duration::from_millis(300), ..NetLimits::default() }; + let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() }; let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false }; let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap(); let (tx, rx) = std::sync::mpsc::channel(); @@ -270,6 +270,27 @@ mod tests { close(ws); let _ = tx.send(()); }); - assert!(rx.recv_timeout(Duration::from_secs(3)).is_ok(), "close() kept draining a pinging server"); + rx.recv_timeout(Duration::from_secs(3)).is_ok() + } + + /// A server that keeps pinging and never answers the close must not pin `close` forever. + #[test] + fn close_returns_against_a_server_that_keeps_pinging() { + let pinging = closes_in_time(Duration::from_millis(300), |mut ws| { + while ws.send(Message::Ping(Bytes::new())).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + }); + assert!(pinging, "close() kept draining a pinging server"); + } + + /// Nor one that goes quiet, however long the idle timeout. + #[test] + fn close_returns_against_a_silent_server() { + let silent = closes_in_time(Duration::from_secs(10), |ws| { + std::thread::sleep(Duration::from_secs(5)); + drop(ws); + }); + assert!(silent, "close() waited out the idle timeout"); } } diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 8c841e9c..c6628662 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -33,14 +33,16 @@ pub struct Duplex { tls: Option, /// What the bytes read count against (a server's reader), if anything. meter: Option, + /// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]). + deadline: Option, } impl Duplex { /// Two handles on `stream` (for the reader and the writer) and one to shut it down with. pub fn split(stream: TcpStream, tls: Option) -> io::Result<(Duplex, Duplex, TcpStream)> { let write = Arc::new(Mutex::new(stream.try_clone()?)); - let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None }; - let b = Duplex { read: stream.try_clone()?, write, tls, meter: None }; + let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None }; + let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None }; Ok((a, b, stream)) } @@ -49,6 +51,12 @@ impl Duplex { self.meter = Some(meter); } + /// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in + /// time (a message trickled in can't outlast it). `None`: only the read timeout applies. + pub fn read_until(&mut self, deadline: Option) { + self.deadline = deadline; + } + /// What was read so far became a message (handed on): it no longer counts as being read. pub fn read_done(&mut self) { if let Some(m) = self.meter.as_mut() { @@ -187,6 +195,13 @@ impl Duplex { impl Read for Duplex { fn read(&mut self, buf: &mut [u8]) -> io::Result { + if let Some(d) = self.deadline { + let left = d.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(io::ErrorKind::TimedOut.into()); + } + self.read.set_read_timeout(Some(left))?; + } let n = match &self.tls { Some(t) => tls::read(t, &mut self.read, &self.write, buf), None => self.read.read(buf), From 46b02d44e64a64ad8b88bc08208b1c0a047fbdc9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:17 +0700 Subject: [PATCH 91/98] Batch r2-1 review: paste_nodes refuses two nodes mapped to one new id --- crates/doc/src/lib.rs | 5 +++-- crates/doc/src/tests.rs | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1eb1fb0b..d4c556b9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -837,8 +837,9 @@ impl Document { return Err(DocError::NotContainer(parent)); } let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?; - // Never overwrite an existing node with a copy. - if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i)) { + // Never overwrite an existing node, or another copy, with a copy. + let mut copied = std::collections::BTreeSet::new(); + if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) { return Err(DocError::Invalid(format!("node {taken} already exists"))); } for n in nodes { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 38cab817..1a006c26 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -512,6 +512,12 @@ fn stale_next_id_never_overwrites_existing_nodes() { assert_eq!(bad, snapshot); assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name); assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // So does one that maps two nodes to the same new id. + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, map[&f]); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); } #[test] From d114c6ed92d9e60a2ab83506d967fbfdcdcb6072 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:19 +0700 Subject: [PATCH 92/98] Batch r2-1 review: CLI export file names stay within 200 bytes --- apps/figcraft-cli/src/files.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index b3778063..4574730e 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -208,11 +208,12 @@ pub fn info(path: &str, as_json: bool) -> CliResult { } /// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`], -/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters. +/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters +/// and 200 bytes (file systems allow 255, and a number and the extension still fit). fn sanitize(name: &str) -> String { let s = figcraft_engine::cmd::file::safe_file_name(name); - let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string(); - if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } + let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()); + if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() } } /// What `export` exports and how. @@ -420,6 +421,7 @@ mod tests { assert_eq!(sanitize(" .hidden. "), "hidden"); assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed"); assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100); + assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters"); } #[test] From c45c693278dc78712721c782f94349e717a9e3ea Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:22 +0700 Subject: [PATCH 93/98] Batch r2-1 review: a report reason blank after cleaning is refused --- apps/figcraft-server/src/store.rs | 3 ++- apps/figcraft-server/src/store_tests.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index a60d482d..ab65c2d8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -296,7 +296,8 @@ fn clean_name(s: &str) -> String { /// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters. fn clean_reason(s: &str) -> String { - s.trim().chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).take(MAX_REASON).collect() + let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect(); + s.trim().chars().take(MAX_REASON).collect() } /// Bidirectional format characters, which could reorder what a terminal shows. diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index bd1d9c32..d8c5b915 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -391,6 +391,11 @@ fn names_and_report_reasons_lose_bidi_controls() { assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32); store.remove(&id).unwrap(); } + // A reason that is only control characters says nothing. + let (id, _, _) = publish(&mut store, "blank"); + for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] { + assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}"); + } } #[test] From 31d4c6eba4e899d24ddbc95de7e1811e4be5ea5f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:24 +0700 Subject: [PATCH 94/98] Batch r2-1 review: per-side strokes follow smoothed corners for every alignment --- crates/pathops/src/lib.rs | 22 ++++++++++++++----- .../pathops/src/rounded_side_strokes_tests.rs | 10 +++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index ca2ee0b6..324af4d4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -456,8 +456,9 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> /// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer /// edge is the rounded rectangle (center and outside grow its box by half or all of each side's /// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with -/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). An even-odd -/// ring whose inner edge runs against the outer one, so it is a ring under non-zero too. +/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). Corners +/// that stay circular are smoothed like the shape's. An even-odd ring whose inner edge runs against +/// the outer one, so it is a ring under non-zero too. pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath { let [t, r, b, l] = sides.map(non_negative); if t + r + b + l <= 0.0 { @@ -477,7 +478,7 @@ pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sid figcraft_geom::rounded_rect(w, h, radii, smoothing) } else { let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) }); - elliptical_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown) + corner_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown, smoothing) }; let k = 1.0 - f; let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k); @@ -485,9 +486,18 @@ pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sid return outer; } let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0)))); - // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or the - // outer ones are smoothed: then it may leave the outer edge. - ring(outer, &elliptical_rect(inner, inset), fit < 1.0 || (align == StrokeAlign::Inside && smoothing > 0.0)) + // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or + // smoothed: then it may leave the outer edge. + ring(outer, &corner_rect(inner, inset, smoothing), fit < 1.0 || smoothing > 0.0) +} + +/// Rectangle `r` with `(rx, ry)` corners, smoothed like `figcraft_geom::rounded_rect` where every +/// corner is circular (smoothing is defined for those only). +fn corner_rect(r: figcraft_geom::Rect, corners: [(f64, f64); 4], smoothing: f64) -> BezPath { + if smoothing > 0.0 && corners.iter().all(|(rx, ry)| rx == ry) { + return kurbo::Affine::translate((r.x0, r.y0)) * figcraft_geom::rounded_rect(r.width(), r.height(), corners.map(|c| c.0), smoothing); + } + elliptical_rect(r, corners) } fn non_negative(v: f64) -> f64 { diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs index ee0e8281..9c2f387e 100644 --- a/crates/pathops/src/rounded_side_strokes_tests.rs +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -100,6 +100,16 @@ fn smoothed_corners_keep_the_stroke_inside_the_fill() { assert!(painted(&smooth, 60.0, 59.5)); } +#[test] +fn center_and_outside_strokes_follow_smoothed_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [16.0; 4], 0.6, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [16.0 + out; 4], 0.6)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [16.0 - inset; 4], 0.6)); + assert!((a - (outer - hole)).abs() < 1e-3, "{align:?}: {a} vs {}", outer - hole); + } +} + #[test] fn weights_wider_than_the_box_leave_the_outer_edge() { let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside); From d0db758684ca6d48b562c14ce08be21c2d51683c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:26 +0700 Subject: [PATCH 95/98] Batch r2-1 review: SVG ids unique as written; a filled open curve closes for its fill --- crates/svg/src/import/mod.rs | 7 ++++--- crates/svg/src/lib.rs | 17 +++++++++-------- crates/svg/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index 521146fb..3aed363a 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -446,8 +446,8 @@ fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, worl const CLOSE_TOLERANCE: f64 = 1e-3; /// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all` -/// (SVG fills every subpath as if closed), every other subpath of two or more segments closed -/// with a straight segment. +/// (SVG fills every subpath as if closed), every other subpath that can enclose an area (two or +/// more segments, or one curve) closed with a straight segment. fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath { use figcraft_geom::kurbo::PathEl; let mut out = BezPath::new(); @@ -462,7 +462,8 @@ fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath { }; let open = !matches!(sub.last(), Some(PathEl::ClosePath)); let segments = sub.len() - 1; - if open && segments >= 2 { + let curve = matches!(sub.last(), Some(PathEl::QuadTo(..) | PathEl::CurveTo(..))); + if open && (segments >= 2 || curve) { let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol); if near && let Some(last) = sub.last_mut() { match last { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 38abc14e..656307d9 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -155,13 +155,12 @@ fn blend_style(b: figcraft_color::BlendMode) -> String { /// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters /// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out. fn esc(s: &str) -> String { - s.chars() - .filter(|&c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}')) - .collect::() - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) + s.chars().filter(|&c| xml_char(c)).collect::().replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Whether XML 1.0 allows `c` (see [`esc`]). +fn xml_char(c: char) -> bool { + !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}') } fn hex(c: Color) -> String { @@ -562,7 +561,9 @@ impl W<'_> { /// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…). fn unique_id(&mut self, name: &str) -> String { - let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); + // As written: what `esc` leaves out isn't part of it. + let base: String = + name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).filter(|&c| xml_char(c)).collect(); let base = if base.is_empty() { "layer".to_string() } else { base }; let next = self.next_suffix.entry(base.clone()).or_insert(1); let id = loop { diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 3936c37c..dedeca3b 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -229,6 +229,9 @@ fn unique_id_is_linear_for_repeated_names() { assert_eq!(w.unique_id("a_3"), "a_3"); assert_eq!(w.unique_id("a"), "a_2"); assert_eq!(w.unique_id("a"), "a_4"); + // Unique as written, without what XML leaves out. + assert_eq!(w.unique_id("b\u{fffe}"), "b"); + assert_eq!(w.unique_id("b\u{ffff}"), "b_2"); let start = std::time::Instant::now(); for _ in 0..30_000 { w.unique_id("Rectangle"); @@ -247,3 +250,17 @@ fn layer_ids_never_clash_with_generated_ids() { let svg = export_svg(&d, &[f], &SvgOptions { include_ids: true, ..SvgOptions::default() }).unwrap(); assert_eq!(svg.matches("id=\"clip1\"").count(), 1, "{svg}"); } + +#[test] +fn a_filled_open_curve_is_closed_for_its_fill() { + // SVG fills an open subpath as if closed: one curve and the line back enclose an area. + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let svg = r##""##; + import_svg(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap(); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + assert_eq!(d.nodes().map(loops).sum::(), 1); +} From 8af2efd1b9582a14e1ebf39f57aa52b5dcaabd36 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:29 +0700 Subject: [PATCH 96/98] Batch r2-1 review: U+2007 glues beside ideographs too --- crates/text/src/breaks.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 8012de33..52284d0b 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -19,6 +19,8 @@ enum Class { Hyphen, /// Everything else (letters, digits, other symbols): no break inside a run of them. Other, + /// The figure space: glues what's on either side, ideographs included. + Glue, } /// Opening brackets and quotes (line-end prohibited). @@ -40,6 +42,7 @@ fn class(c: char) -> Class { } match c { ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{2006}' | '\u{2008}'..='\u{200B}' => Class::Space, + '\u{2007}' => Class::Glue, '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, '\u{2E80}'..='\u{2FFF}' | '\u{3003}'..='\u{33FF}' @@ -60,8 +63,8 @@ pub(crate) fn break_between(a: char, b: char) -> bool { use Class::*; match (class(a), class(b)) { // Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line, - // no opening mark ends one. - (_, Space | Close) | (Open, _) => false, + // no opening mark ends one; glue holds on both sides. + (_, Space | Close | Glue) | (Open | Glue, _) => false, (Space | Hyphen | Ideo, _) | (_, Ideo) => true, _ => false, } @@ -107,6 +110,7 @@ mod tests { fn figure_space_is_non_breaking() { assert!(!break_between('\u{2007}', '0')); assert!(!break_between('1', '\u{2007}')); + assert!(!break_between('日', '\u{2007}') && !break_between('\u{2007}', '日'), "nor beside ideographs"); assert!(!is_space('\u{2007}')); } } From 9f60f5470d5e8232092b526693fbf4f5916cf2aa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:31:31 +0700 Subject: [PATCH 97/98] Batch r2-1 review: status lines and rows in ROADMAP, prototyping and UI parity --- ROADMAP.md | 2 +- docs/prototyping-parity.md | 9 +++++---- docs/ui-parity.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d32eb906..05b7ed65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; 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; Windows QA pass with eight fixes; 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%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index d8173a11..2ded779d 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,14 +1,15 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (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-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) **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, read from controllers on Windows and in the browser. Every rule of the public documentation on triggers, actions, overlays, animations, easing, scrolling and interactive components is a test case with what it implies (`crates/engine/src/proto_behaviour_tests.rs`, 7 -tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 12 tests driving the -player, 21 gestures among them; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and +tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 14 tests driving the +player, 21 gestures among them; presentation tabs: `crates/ui-egui/src/present_tab_tests.rs`, 6 +tests; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and `crates/ui-egui/src/present_tail_tests.rs`, 5 tests), and all pass. Not yet checked against larger real prototypes. Remaining: 2–5 h. @@ -88,7 +89,7 @@ case pins the current behaviour). | Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas | | Interactive components: instances play their variant's interactions, overridden per trigger | done | | | Drag-driven animations that follow the pointer, in their direction | done | | -| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | +| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens a tab for the page, named "file - page", which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | | 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 | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e999ed46..2b8c66be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | 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 | From e3e39bbc4dc94d58a70775fae17ee0d7a486a95f Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 24 Oct 2026 13:33:26 +0700 Subject: [PATCH 98/98] M6: Dev Mode measurements kept on the canvas, a read-only layers panel (#1248) * M6: Dev Mode measurements kept on the canvas, a read-only layers panel Measurements placed between two layers, along a side or to the layer around one (devMode.addMeasurement / removeMeasurement / measurements), stored on the layer they start from (saved, synced, undoable; old files open without them), drawn in Dev Mode as labelled redlines that follow the layers and listed, placed and removed in the Inspect tab. In Dev Mode the layers panel only selects and expands. * M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar) --- ROADMAP.md | 6 +- crates/collab/src/change.rs | 1 + crates/collab/src/merge.rs | 12 +- crates/doc/src/lib.rs | 6 + crates/doc/src/node.rs | 15 ++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/components.rs | 5 + crates/engine/src/dev_measurements.rs | 222 ++++++++++++++++++++ crates/engine/src/dev_measurements_tests.rs | 204 ++++++++++++++++++ crates/engine/src/devinspect.rs | 8 +- crates/engine/src/distance.rs | 2 +- crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 4 + crates/format/src/fig/export.rs | 9 +- crates/ui-egui/locales/bn.toml | 13 ++ crates/ui-egui/locales/cs.toml | 13 ++ crates/ui-egui/locales/de.toml | 13 ++ crates/ui-egui/locales/en.toml | 10 + crates/ui-egui/locales/es-419.toml | 2 + crates/ui-egui/locales/es.toml | 13 ++ crates/ui-egui/locales/fi.toml | 13 ++ crates/ui-egui/locales/fr.toml | 13 ++ crates/ui-egui/locales/id.toml | 13 ++ crates/ui-egui/locales/ja.toml | 13 ++ crates/ui-egui/locales/ka.toml | 13 ++ crates/ui-egui/locales/ko.toml | 13 ++ crates/ui-egui/locales/nl.toml | 13 ++ crates/ui-egui/locales/pl.toml | 13 ++ crates/ui-egui/locales/pt-BR.toml | 13 ++ crates/ui-egui/locales/ru.toml | 13 ++ crates/ui-egui/locales/sv.toml | 13 ++ crates/ui-egui/locales/tr.toml | 13 ++ crates/ui-egui/locales/zh-Hans.toml | 13 ++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/dev_mode.rs | 130 +++++++++++- crates/ui-egui/src/devmode_tail_tests.rs | 78 +++++++ crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/layers.rs | 24 ++- crates/ui-egui/src/measure_overlay.rs | 7 +- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/right.rs | 2 + docs/control-protocol.md | 3 +- docs/dev-mode-parity.md | 15 +- docs/file-format-parity.md | 4 +- docs/gaps.md | 20 +- docs/mcp.md | 20 +- docs/target-app-parity.md | 2 +- 48 files changed, 1005 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/dev_measurements.rs create mode 100644 crates/engine/src/dev_measurements_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 05b7ed65..69d3bf73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-24** · Dev Mode tail (M6, 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. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. @@ -328,6 +329,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Dev Mode tail (M6, 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. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 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. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 57bb9cf2..b273ba34 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Measurements => measurements, Board => board, } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 5132ce75..acd40397 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -6,11 +6,12 @@ //! values, or a layer deleted on one side while the other side edited it or something inside it //! (including adding layers to it or moving layers into or out of it). The layer lists whose items //! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype -//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides -//! changing different items of the list (or one adding items at its end) is no conflict, only the -//! same item changed both ways or the list shortened on a side while the other changed it. Nothing -//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the -//! other side's changes to it are left out. Everything else from both sides is kept. +//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by +//! item: both sides changing different items of the list (or one adding items at its end) is no +//! conflict, only the same item changed both ways or the list shortened on a side while the other +//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for +//! one side, and the other side's changes to it are left out. Everything else from both sides is +//! kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -176,6 +177,7 @@ const PER_ITEM: &[Prop] = &[ // Not `CodeLinks`: there is one per language, and lining them up by position could leave two // for one language (the second hidden), so they merge whole. Prop::DevResources, + Prop::Measurements, ]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c556b9..70797dd8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -856,6 +856,12 @@ impl Document { { *component = *m; } + // A measurement copied with the layer it measures to measures to the copy; one to a + // layer left behind isn't copied. + c.measurements.retain_mut(|m| match m.to { + Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(), + None => true, + }); // A connector (or label) copied with what it joins joins the copies. if let Some(b) = c.board.as_mut() { b.remap(map); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 649d5e56..ca06fe53 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -237,6 +237,17 @@ pub struct DevResource { pub name: String, } +/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another +/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers' +/// current bounds, so it follows them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Measurement { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, +} + /// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its /// callouts are drawn in. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -518,6 +529,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// Dev Mode measurements that start from this layer, in the order they were added. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub measurements: Vec, /// On a board: what this object is (a sticky note, a connector, a stamp…). #[serde(default, skip_serializing_if = "Option::is_none")] pub board: Option, @@ -631,6 +645,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + measurements: Vec::new(), board: None, } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 60d08963..049c0d6f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -99,6 +99,7 @@ fn registry() -> &'static Registry { list.extend(crate::code_connect::specs()); list.extend(crate::annotations::specs()); list.extend(crate::dev_resources::specs()); + list.extend(crate::dev_measurements::specs()); list.extend(crate::distance::specs()); list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 91402b58..87496c37 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -302,6 +302,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.code_links.clear(); // The main's dev resources show on its instances without being copied. root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); // So do the variable modes the main sets (see `Document::inherited_modes`). @@ -720,6 +722,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT let prev = old.get(&nid); // Annotations belong to the layer they were added to, not to its copies. n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); if let Some(o) = prev { carry_overrides(o, &mut n, Carry::Values); keep_laid_out(doc, orig, o, &mut n); @@ -797,6 +800,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT fresh.code_links.clear(); // An instance's own dev resources (its main's are listed with them, not copied). fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); fresh.locked = root.locked; fresh.visible = root.visible; fresh.var_modes = root.var_modes.clone(); @@ -1591,6 +1595,7 @@ fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option, +} + +impl Placed { + /// Whether it starts or ends at layer `id`. + pub fn touches(&self, id: NodeId) -> bool { + self.from == id || self.measurement.to == Some(id) + } + + /// The distances it shows, as on its labels. + pub fn labels(&self) -> Vec<&str> { + self.lines.iter().filter_map(|g| g.label.as_deref()).collect() + } +} + +/// The line along side `side` of box `b`, labelled with that side's length. +fn side_line(b: Rect, side: Side) -> Vec { + let (p, q) = match side { + Side::Top => (Point::new(b.x0, b.y0), Point::new(b.x1, b.y0)), + Side::Bottom => (Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)), + Side::Left => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), + Side::Right => (Point::new(b.x1, b.y0), Point::new(b.x1, b.y1)), + Side::Auto => return Vec::new(), + }; + let mut out = Vec::new(); + measured(&mut out, p, q); + out +} + +/// The lines measurement `m` from layer `from` draws, in page space (none when a layer is gone). +pub fn lines(doc: &Document, from: NodeId, m: Measurement) -> Vec { + let Some(a) = doc.world_bounds(from) else { return Vec::new() }; + match (m.to, m.side) { + (Some(to), _) => doc.world_bounds(to).map(|b| between(a, b)).unwrap_or_default(), + (None, Some(side)) => side_line(a, side), + (None, None) => Vec::new(), + } +} + +/// The measurements on `page` whose layers are both there, in layer order (bounded). +pub fn on_page(doc: &Document, page: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![page]; + let mut visited = 0usize; + while let Some(id) = stack.pop() { + visited += 1; + if out.len() >= MAX_LISTED || visited > 200_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + for (index, m) in n.measurements.iter().enumerate() { + if m.to.is_some_and(|t| t == id || doc.page_of(t) != Some(page)) { + continue; + } + out.push(Placed { from: id, index, measurement: *m, lines: lines(doc, id, *m) }); + } + stack.extend(n.children.iter().rev().copied()); + } + out +} + +fn placed_json(doc: &Document, p: &Placed) -> Value { + let name = |id: NodeId| doc.node(id).map(|n| n.name.clone()).unwrap_or_default(); + let mut j = json!({"from": p.from, "fromName": name(p.from), "index": p.index, "labels": p.labels(), "lines": p.lines}); + if let Some(t) = p.measurement.to { + j["to"] = json!(t); + j["toName"] = json!(name(t)); + } + if let Some(s) = p.measurement.side { + j["side"] = json!(s); + } + j +} + +/// The layer a command starts from: `from`, else the first selected layer; never a page. +fn from_layer(cmd: &str, s: &Session, p: &Value) -> Result { + let id = match opt_id(cmd, p, "from")? { + Some(id) => id, + None => s.selection().first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `from`"))?, + }; + let n = s.doc()?.node(id).ok_or(EngineError::NoNode(id))?; + if n.is_page() { + return Err(bad(cmd, "measure from a layer, not a page")); + } + Ok(id) +} + +/// The `side` parameter: top, right, bottom or left. +fn side_param(cmd: &str, p: &Value) -> Result> { + match get(p, "side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Side::from_id(v).filter(|s| *s != Side::Auto).map(Some).ok_or_else(|| bad(cmd, "side: top, right, bottom or left")), + Some(_) => Err(bad(cmd, "side: top, right, bottom or left")), + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.addMeasurement"; + let from = from_layer(cmd, s, p)?; + let (to, side) = (opt_id(cmd, p, "to")?, side_param(cmd, p)?); + let doc = s.doc()?; + let n = doc.node(from).ok_or(EngineError::NoNode(from))?; + let to = match (to, side) { + (Some(_), Some(_)) => return Err(bad(cmd, "pass `to` or `side`, not both")), + (Some(t), None) => Some(t), + (None, Some(_)) => None, + // Neither: to the layer around it. + (None, None) => Some( + n.parent + .filter(|q| doc.node(*q).is_some_and(|q| !q.is_page())) + .ok_or_else(|| bad(cmd, "pass `to` or `side` (the layer is at the top of its page)"))?, + ), + }; + if let Some(t) = to { + let target = doc.node(t).ok_or(EngineError::NoNode(t))?; + if t == from { + return Err(bad(cmd, "measure to another layer, or across a `side` of this one")); + } + if target.is_page() || doc.page_of(t) != doc.page_of(from) { + return Err(bad(cmd, "measure to a layer on the same page")); + } + } + let m = Measurement { to, side }; + // The same two layers measured from the other end are the same measurement. + let back = to.and_then(|t| doc.node(t)).is_some_and(|t| t.measurements.contains(&Measurement { to: Some(from), side: None })); + if n.measurements.contains(&m) || back { + return Err(bad(cmd, "that measurement is already there")); + } + if n.measurements.len() >= MAX_PER_LAYER { + return Err(bad(cmd, format!("a layer starts at most {MAX_PER_LAYER} measurements"))); + } + let drawn = lines(doc, from, m); + let n = s.doc_mut()?.get_mut(from)?; + n.measurements.push(m); + Ok(json!({"from": from, "index": n.measurements.len() - 1, "lines": drawn})) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.removeMeasurement"; + let from = from_layer(cmd, s, p)?; + let index = opt_index(cmd, p, "index")?; + let n = s.doc_mut()?.get_mut(from)?; + let count = n.measurements.len(); + if count == 0 { + return Err(bad(cmd, "no measurement starts from this layer")); + } + let removed = match index { + Some(i) if i < count => { + n.measurements.remove(i); + 1 + } + Some(i) => return Err(bad(cmd, format!("no measurement {i} (the layer starts {count})"))), + None => std::mem::take(&mut n.measurements).len(), + }; + Ok(json!({"removed": removed})) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.measurements"; + let doc = s.doc()?; + let page = match opt_id(cmd, p, "page")? { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad(cmd, "`page` must be a page id")), + None => s.page()?, + }; + let only = opt_id(cmd, p, "id")?; + Ok(Value::Array(on_page(doc, page).iter().filter(|m| only.is_none_or(|id| m.touches(id))).map(|m| placed_json(doc, m)).collect())) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "devMode.addMeasurement", + "Add measurement", + "{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}", + true, + add + ), + spec!( + "devMode.removeMeasurement", + "Remove measurement", + "{from? (default the first selected layer), index? (omit: every measurement starting from the layer)} → {removed}", + true, + remove + ), + spec!( + "devMode.measurements", + "List measurements", + "{page? (default the current page), id? (only those from or to this layer)} → [{from, fromName, index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}] (page space)", + false, + list + ), + ] +} diff --git a/crates/engine/src/dev_measurements_tests.rs b/crates/engine/src/dev_measurements_tests.rs new file mode 100644 index 00000000..695b8a7e --- /dev/null +++ b/crates/engine/src/dev_measurements_tests.rs @@ -0,0 +1,204 @@ +//! Measurements kept on the canvas: placed between two layers, across a side or to the layer +//! around one, following the layers, removed, undoable, copied with what they measure, refused on +//! bad input, synced, saved (old files without them still open) and allowed in Dev Mode. + +use figcraft_collab::{Change, Prop, Replica}; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session, x: f64, w: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": 20, "width": w, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&ok(s, "shape.rectangle", p)["id"]).unwrap() +} + +/// A frame with two rectangles 30 apart: (frame, a, b). +fn setup() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100}))["id"]).unwrap(); + let a = rect(&mut s, 20.0, 50.0, Some(f)); + let b = rect(&mut s, 100.0, 60.0, Some(f)); + (s, f, a, b) +} + +fn labels(s: &mut Session, id: Option) -> Vec> { + let p = id.map_or(json!({}), |i| json!({"id": i})); + ok(s, "devMode.measurements", p) + .as_array() + .unwrap() + .iter() + .map(|m| m["labels"].as_array().unwrap().iter().map(|l| l.as_str().unwrap().to_string()).collect()) + .collect() +} + +#[test] +fn measurements_are_placed_follow_their_layers_and_are_removed() { + let (mut s, f, a, b) = setup(); + let m = ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert_eq!((&m["from"], &m["index"]), (&json!(a), &json!(0))); + assert_eq!(m["lines"][0]["label"], "30", "the gap between them"); + // Across a side: the side's length; with neither, to the frame around it. + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "top"})); + ok(&mut s, "select.set", json!({"ids": [a]})); + ok(&mut s, "devMode.addMeasurement", json!({})); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["20", "230", "20", "40"], vec!["60"]]); + let listed = ok(&mut s, "devMode.measurements", json!({"id": b})); + assert_eq!(listed.as_array().unwrap().len(), 2, "from or to the layer"); + assert_eq!((&listed[1]["side"], &listed[1]["to"]), (&json!("top"), &Value::Null)); + assert_eq!(listed[0]["toName"], s.doc().unwrap().node(b).unwrap().name.as_str()); + // They follow the layers. + ok(&mut s, "node.set", json!({"id": b, "props": {"x": 110, "width": 80}})); + assert_eq!(labels(&mut s, Some(b)), [vec!["40"], vec!["80"]]); + // Undo takes back an edit, and a placement. + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, Some(b)), [vec!["30"], vec!["60"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 2); + ok(&mut s, "edit.redo", json!({})); + // A deleted layer takes its measurements off the canvas; undo brings them back. + ok(&mut s, "edit.delete", json!({"ids": [b]})); + assert_eq!(labels(&mut s, None), [vec!["20", "230", "20", "40"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 3); + // Removed one by one, or all of a layer's. + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a, "index": 1}))["removed"], 1); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["60"]]); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "left"})); + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a}))["removed"], 2); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a})).is_err(), "nothing left"); + assert_eq!(labels(&mut s, None), [vec!["60"]]); + let _ = f; +} + +#[test] +fn bad_measurements_are_refused() { + let (mut s, f, a, b) = setup(); + let page = s.page().unwrap(); + let other = id_of(&ok(&mut s, "page.add", json!({}))["id"]).unwrap(); + let far = rect(&mut s, 0.0, 10.0, Some(other)); + ok(&mut s, "page.select", json!({"id": page})); + let top = rect(&mut s, 400.0, 10.0, None); + ok(&mut s, "select.none", json!({})); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": page}), + json!({"from": a, "to": far}), + json!({"from": a, "to": 99_999}), + json!({"from": page, "side": "top"}), + json!({"from": a, "to": b, "side": "top"}), + json!({"from": a, "side": "middle"}), + json!({"from": a, "side": "auto"}), + json!({"from": a, "side": 3}), + json!({"from": "x", "side": "top"}), + json!({"from": top}), + json!({"side": "top"}), + ] { + assert!(s.execute("devMode.addMeasurement", &p).is_err(), "{p}"); + } + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "to": b})).is_err(), "already there"); + assert!(s.execute("devMode.addMeasurement", &json!({"from": b, "to": a})).is_err(), "the same two layers"); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a, "index": 5})).is_err()); + assert!(s.execute("devMode.measurements", &json!({"page": a})).is_err()); + assert_eq!(ok(&mut s, "devMode.measurements", json!({"page": other})), json!([])); + // A layer starts a bounded number. + for _ in 1..crate::dev_measurements::MAX_PER_LAYER { + let t = rect(&mut s, 200.0, 5.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": t})); + } + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "side": "top"})).is_err(), "capped"); + assert_eq!(s.doc().unwrap().node(a).unwrap().measurements.len(), crate::dev_measurements::MAX_PER_LAYER); +} + +#[test] +fn copies_measure_what_was_copied_with_them() { + let (mut s, f, a, b) = setup(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "bottom"})); + // The frame copied: the copy of `a` measures to the copy of `b`. + ok(&mut s, "edit.duplicate", json!({"ids": [f]})); + let copy = s.selection()[0]; + let doc = s.doc().unwrap(); + let kids = doc.node(copy).unwrap().children.clone(); + let ca = doc.node(kids[0]).unwrap(); + assert_eq!(ca.measurements.len(), 2); + assert_eq!(ca.measurements[0].to, Some(kids[1])); + // `a` alone copied: only its side measurement comes along. + ok(&mut s, "edit.duplicate", json!({"ids": [a]})); + let lone = s.selection()[0]; + let m = &s.doc().unwrap().node(lone).unwrap().measurements; + assert_eq!((m.len(), m[0].to, m[0].side), (1, None, Some(figcraft_doc::Side::Bottom))); +} + +#[test] +fn measurements_sync_save_and_old_files_open() { + let (mut s, _, a, b) = setup(); + let before = s.doc().unwrap().clone(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "right"})); + // A change collaborators receive. + let d = figcraft_collab::diff(&Replica::new(before), s.doc().unwrap()).unwrap(); + assert!(d.changes.iter().any(|c| matches!(c, Change::Set { id, prop: Prop::Measurements, .. } if *id == a)), "{:?}", d.changes); + // What agents inspect. + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), b, 0)["measurements"], json!([{"side": "right"}])); + // Saved and opened again. + let doc = s.doc().unwrap(); + let bytes = figcraft_format::save_bytes(doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(back.node(a).unwrap().measurements, doc.node(a).unwrap().measurements); + assert_eq!(back.node(b).unwrap().measurements, doc.node(b).unwrap().measurements); + // A file from before measurements (no such field) opens with none. + fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("measurements"); + o.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old = figcraft_format::to_json(doc).unwrap(); + assert!(old.to_string().contains("\"measurements\"")); + strip(&mut old); + let (back, _) = figcraft_format::from_json(&old.to_string()).unwrap(); + assert!(back.node(a).unwrap().measurements.is_empty() && back.node(b).unwrap().name == doc.node(b).unwrap().name); +} + +#[test] +fn dev_mode_places_measurements() { + use crate::devinspect::dev_mode_allows; + for id in ["devMode.addMeasurement", "devMode.removeMeasurement", "devMode.measurements"] { + assert!(dev_mode_allows(id), "{id}"); + } +} + +#[test] +fn instances_keep_their_own_measurements_across_main_edits() { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40}))["id"]).unwrap(); + let r = rect(&mut s, 5.0, 10.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": r, "side": "top"})); + let main = id_of(&ok(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap(); + let inst = id_of(&ok(&mut s, "instance.create", json!({"component": main, "x": 200, "y": 0}))["id"]).unwrap(); + let inner = s.doc().unwrap().node(inst).unwrap().children[0]; + // The main's measurements aren't copied onto its instances. + assert!(s.doc().unwrap().node(inner).unwrap().measurements.is_empty()); + ok(&mut s, "devMode.addMeasurement", json!({"from": inner})); + ok(&mut s, "devMode.addMeasurement", json!({"from": inst, "side": "left"})); + ok(&mut s, "node.set", json!({"id": r, "props": {"opacity": 0.5}})); + ok(&mut s, "node.set", json!({"id": main, "props": {"width": 120}})); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(inner).unwrap().measurements[0].to, Some(inst), "kept across a main edit"); + assert_eq!(doc.node(inst).unwrap().measurements.len(), 1); + assert_eq!(doc.node(r).unwrap().measurements.len(), 1); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..7a15610f 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -219,11 +219,11 @@ pub struct DevItem { } /// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the -/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections, -/// comments). Choosing a tool other than moving around and commenting is refused too, and so is -/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. +/// document, and Dev Mode's own edits (annotations, statuses, dev resources, measurements, code +/// connections, comments). Choosing a tool other than moving around and commenting is refused too, +/// and so is Revert to saved, which isn't an undo step but throws the design's unsaved edits away. pub fn dev_mode_allows(id: &str) -> bool { - const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + const OWN: [&str; 6] = ["annotation.", "dev.", "devMode.", "devResource.", "codeConnect.", "comment."]; match id.strip_prefix("tool.") { Some(t) => matches!(t, "move" | "hand" | "comment"), None if id == "file.revert" => false, diff --git a/crates/engine/src/distance.rs b/crates/engine/src/distance.rs index c86aeaf0..62679e38 100644 --- a/crates/engine/src/distance.rs +++ b/crates/engine/src/distance.rs @@ -34,7 +34,7 @@ pub fn label(d: f64) -> String { } /// A labelled distance line, unless it has no length. -fn measured(out: &mut Vec, a: Point, b: Point) { +pub(crate) fn measured(out: &mut Vec, a: Point, b: Point) { let d = (b - a).hypot(); if d > EPS && d.is_finite() { out.push(Guide { label: Some(label(d)), ..Guide::new(a, b) }); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d30f5f51..33162314 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -144,6 +144,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.code_links.is_empty() { o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } let dev = crate::dev_resources::list_json(doc, n.id); if dev.as_array().is_some_and(|a| !a.is_empty()) { o.insert("devResources".into(), dev); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2cd224e7..3fde12eb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod dev_measurements; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -1657,6 +1658,8 @@ mod dark_page_text_tests; #[cfg(test)] mod design_paste_tests; #[cfg(test)] +mod dev_measurements_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..14a6a3bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -395,6 +395,10 @@ fn every_command_survives_junk_params() { // Links (dev resources) and indexes as junk. json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}), json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}), + // Dev Mode measurements: layers, sides, pages and indexes as junk. + json!({"from": "x", "to": -1, "side": [1], "index": 1e308, "page": {"x": 1}}), + json!({"from": 2, "to": 2, "side": "middle", "index": -1, "page": 2, "id": "nope"}), + json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), // Plugin directories: ids, versions, servers and confirmations as junk. diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 234e1459..dad09d95 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1134,8 +1134,13 @@ impl<'d> Exporter<'d> { if !n.guides.is_empty() { self.warn("ruler guides were left out"); } - if !n.annotations.is_empty() || !n.dev_resources.is_empty() || !n.code_links.is_empty() || !n.dev_status.is_none() { - self.warn("Dev Mode annotations, statuses, resources and code links were left out"); + if !n.annotations.is_empty() + || !n.dev_resources.is_empty() + || !n.code_links.is_empty() + || !n.measurements.is_empty() + || !n.dev_status.is_none() + { + self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out"); } if !n.plugin_data.is_empty() { self.warn("plugin data was left out"); diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bc7c948c..364c9632 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." "Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" "Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add measurement" = "মাপ যোগ করুন" "Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" "Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" "Add mode" = "মোড যোগ করুন" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All supported properties" = "সমর্থিত সব প্রপার্টি" "All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" "All variables" = "সব ভেরিয়েবল" +"Along the bottom" = "নিচের কিনারা বরাবর" +"Along the left" = "বাম কিনারা বরাবর" +"Along the right" = "ডান কিনারা বরাবর" +"Along the top" = "উপরের কিনারা বরাবর" "Alpha" = "আলফা" "Alphabetical" = "বর্ণানুক্রমিক" "Alt+drag" = "Alt+ড্র্যাগ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "মৌলিক বিষয়" "Before" = "আগে" "Bend" = "বাঁক" +"Between the selected layers" = "নির্বাচিত লেয়ারগুলোর মাঝে" "Bevel" = "বেভেল" "Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" "Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" @@ -683,6 +689,7 @@ status = "draft — needs native review" "Jun" = "জুন" "Just now" = "এইমাত্র" "Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep a measurement on the canvas for developers" = "ডেভেলপারদের জন্য ক্যানভাসে একটি মাপ রাখুন" "Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" "Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" @@ -772,6 +779,7 @@ status = "draft — needs native review" "Maximize" = "বড় করুন" "May" = "মে" "Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Measurements" = "মাপ" "Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" "Merge regions" = "অঞ্চলগুলো মার্জ করুন" "Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" @@ -1087,6 +1095,7 @@ status = "draft — needs native review" "Remove guide" = "গাইড সরান" "Remove interaction" = "ইন্টারঅ্যাকশন সরান" "Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove measurement" = "মাপ সরান" "Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" "Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" "Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" @@ -1420,6 +1429,7 @@ status = "draft — needs native review" "Title" = "শিরোনাম" "Title case" = "টাইটেল কেস" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"To the layer around it" = "চারপাশের লেয়ার পর্যন্ত" "Toggle" = "টগল" "Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" "Toggle visibility" = "দৃশ্যমানতা টগল করুন" @@ -1776,6 +1786,9 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"devMode.addMeasurement" = "মাপ যোগ করুন" +"devMode.measurements" = "মাপের তালিকা" +"devMode.removeMeasurement" = "মাপ সরান" "file.newBoard" = "নতুন বোর্ড" "tool.connector" = "সংযোগকারী" "tool.eraser" = "ইরেজার" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e7a2dec1..b05427b2 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Přidat soubor knihovny…" "Add max height" = "Přidat maximální výšku" "Add max width" = "Přidat maximální šířku" +"Add measurement" = "Přidat měření" "Add min height" = "Přidat minimální výšku" "Add min width" = "Přidat minimální šířku" "Add mode" = "Přidat režim" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Všechna písma" "All supported properties" = "Všechny podporované vlastnosti" "All variables" = "Všechny proměnné" +"Along the bottom" = "Podél dolní hrany" +"Along the left" = "Podél levé hrany" +"Along the right" = "Podél pravé hrany" +"Along the top" = "Podél horní hrany" "Alpha" = "Alfa" "Alphabetical" = "Abecedně" "Alt+drag" = "Alt+tažení" @@ -152,6 +157,7 @@ status = "draft — needs native review" "Basics" = "Základy" "Before" = "Před" "Bend" = "Ohnout" +"Between the selected layers" = "Mezi vybranými vrstvami" "Bevel" = "Zkosený" "Binary assets added" = "Přidané binární soubory" "Binary assets removed" = "Odstraněné binární soubory" @@ -668,6 +674,7 @@ status = "draft — needs native review" "Jun" = "června" "Just now" = "Právě teď" "Justify" = "Do bloku" +"Keep a measurement on the canvas for developers" = "Ponechat měření na plátně pro vývojáře" "Keep appearance" = "Zachovat vzhled" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." "Keep tool selected after use" = "Po použití ponechat nástroj vybraný" @@ -757,6 +764,7 @@ status = "draft — needs native review" "Maximize" = "Maximalizovat" "May" = "května" "Measure to the selection" = "Měřit k výběru" +"Measurements" = "Měření" "Merge into main" = "Sloučit do hlavního souboru" "Merge regions" = "Sloučit oblasti" "Merge the branch into the main file (one undo step) and archive it" = "Sloučit větev do hlavního souboru (jeden krok zpět) a archivovat ji" @@ -1066,6 +1074,7 @@ status = "draft — needs native review" "Remove guide" = "Odebrat vodítko" "Remove interaction" = "Odebrat interakci" "Remove library from this file" = "Odebrat knihovnu z tohoto souboru" +"Remove measurement" = "Odebrat měření" "Remove min and max" = "Odebrat minimum a maximum" "Remove min and max height" = "Odebrat minimální a maximální výšku" "Remove min and max width" = "Odebrat minimální a maximální šířku" @@ -1391,6 +1400,7 @@ status = "draft — needs native review" "Title" = "Název" "Title case" = "Velká počáteční písmena slov" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Chcete-li sdílet tuto knihovnu s týmem, publikujte soubor na server (Sdílet ▸ Publikovat na server…) a pak tam publikujte knihovnu." +"To the layer around it" = "K vrstvě, která ji obsahuje" "Toggle" = "Přepnout" "Toggle light/dark theme" = "Přepnout světlý/tmavý motiv" "Toggle visibility" = "Přepnout viditelnost" @@ -1840,6 +1850,9 @@ status = "draft — needs native review" "dev.focus" = "Zaměřený pohled" "dev.resolveChange" = "Vyřešit změnu" "dev.unfocus" = "Opustit zaměřený pohled" +"devMode.addMeasurement" = "Přidat měření" +"devMode.measurements" = "Vypsat měření" +"devMode.removeMeasurement" = "Odebrat měření" "devResource.add" = "Přidat zdroj pro vývojáře" "devResource.edit" = "Upravit zdroj pro vývojáře" "devResource.list" = "Vypsat zdroje pro vývojáře" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 816fecf1..da2a177a 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -52,6 +52,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheksdatei hinzufügen…" "Add max height" = "Max. Höhe hinzufügen" "Add max width" = "Max. Breite hinzufügen" +"Add measurement" = "Messung hinzufügen" "Add min height" = "Min. Höhe hinzufügen" "Add min width" = "Min. Breite hinzufügen" "Add mode" = "Modus hinzufügen" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "Alle Schriften" "All supported properties" = "Alle unterstützten Eigenschaften" "All variables" = "Alle Variablen" +"Along the bottom" = "Entlang der Unterkante" +"Along the left" = "Entlang der linken Kante" +"Along the right" = "Entlang der rechten Kante" +"Along the top" = "Entlang der Oberkante" "Alpha" = "Alpha" "Alt+drag" = "Alt+Ziehen" "An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "Grundlagen" "Before" = "Vorher" "Bend" = "Biegen" +"Between the selected layers" = "Zwischen den ausgewählten Ebenen" "Bevel" = "Abgeflacht" "Binary assets added" = "Binärdateien hinzugefügt" "Binary assets removed" = "Binärdateien entfernt" @@ -652,6 +658,7 @@ status = "draft — needs native review" "Join|stroke" = "Ecke" "Just now" = "Gerade eben" "Justify" = "Blocksatz" +"Keep a measurement on the canvas for developers" = "Eine Messung für Entwickler auf der Arbeitsfläche behalten" "Keep appearance" = "Aussehen beibehalten" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„Aussehen beibehalten“ wandelt jeden Farbwert in {profile} um, damit die Farben gleich aussehen. „Werte beibehalten“ lässt die Zahlen unverändert, sodass sich die Farben verschieben." "Keep tool selected after use" = "Werkzeug nach Verwendung ausgewählt lassen" @@ -734,6 +741,7 @@ status = "draft — needs native review" "Max lines" = "Max. Zeilen" "Maximize" = "Maximieren" "Measure to the selection" = "Bis zur Auswahl messen" +"Measurements" = "Messungen" "Merge into main" = "In Hauptdatei zusammenführen" "Merge regions" = "Bereiche vereinen" "Merge the branch into the main file (one undo step) and archive it" = "Führt den Branch in die Hauptdatei zusammen (ein Rückgängig-Schritt) und archiviert ihn" @@ -1034,6 +1042,7 @@ status = "draft — needs native review" "Remove guide" = "Hilfslinie entfernen" "Remove interaction" = "Interaktion entfernen" "Remove library from this file" = "Bibliothek aus dieser Datei entfernen" +"Remove measurement" = "Messung entfernen" "Remove min and max" = "Min. und Max. entfernen" "Remove min and max height" = "Min. und max. Höhe entfernen" "Remove min and max width" = "Min. und max. Breite entfernen" @@ -1352,6 +1361,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Großschreibung am Wortanfang" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Um diese Bibliothek mit Ihrem Team zu teilen, veröffentlichen Sie die Datei auf einem Server (Teilen ▸ Auf Server veröffentlichen…) und veröffentlichen Sie dort dann die Bibliothek." +"To the layer around it" = "Bis zur umgebenden Ebene" "Toggle" = "Umschalten" "Toggle light/dark theme" = "Zwischen hellem und dunklem Design wechseln" "Toggle visibility" = "Sichtbarkeit umschalten" @@ -1795,6 +1805,9 @@ status = "draft — needs native review" "dev.focus" = "Fokusansicht" "dev.resolveChange" = "Änderung erledigen" "dev.unfocus" = "Fokusansicht verlassen" +"devMode.addMeasurement" = "Messung hinzufügen" +"devMode.measurements" = "Messungen auflisten" +"devMode.removeMeasurement" = "Messung entfernen" "devResource.add" = "Entwicklerressource hinzufügen" "devResource.edit" = "Entwicklerressource bearbeiten" "devResource.list" = "Entwicklerressourcen auflisten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 83cfcbe1..e1ed336e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -54,6 +54,7 @@ name = "English" "Add library file…" = "Add library file…" "Add max height" = "Add max height" "Add max width" = "Add max width" +"Add measurement" = "Add measurement" "Add min height" = "Add min height" "Add min width" = "Add min width" "Add mode" = "Add mode" @@ -93,6 +94,10 @@ name = "English" "All supported properties" = "All supported properties" "All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" +"Along the bottom" = "Along the bottom" +"Along the left" = "Along the left" +"Along the right" = "Along the right" +"Along the top" = "Along the top" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" "Alt+drag" = "Alt+drag" @@ -157,6 +162,7 @@ name = "English" "Basics" = "Basics" "Before" = "Before" "Bend" = "Bend" +"Between the selected layers" = "Between the selected layers" "Bevel" = "Bevel" "Binary assets added" = "Binary assets added" "Binary assets removed" = "Binary assets removed" @@ -688,6 +694,7 @@ name = "English" "Jun" = "Jun" "Just now" = "Just now" "Justify" = "Justify" +"Keep a measurement on the canvas for developers" = "Keep a measurement on the canvas for developers" "Keep appearance" = "Keep appearance" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." "Keep tool selected after use" = "Keep tool selected after use" @@ -778,6 +785,7 @@ name = "English" "Maximize" = "Maximize" "May" = "May" "Measure to the selection" = "Measure to the selection" +"Measurements" = "Measurements" "Merge into main" = "Merge into main" "Merge regions" = "Merge regions" "Merge the branch into the main file (one undo step) and archive it" = "Merge the branch into the main file (one undo step) and archive it" @@ -1097,6 +1105,7 @@ name = "English" "Remove guide" = "Remove guide" "Remove interaction" = "Remove interaction" "Remove library from this file" = "Remove library from this file" +"Remove measurement" = "Remove measurement" "Remove min and max" = "Remove min and max" "Remove min and max height" = "Remove min and max height" "Remove min and max width" = "Remove min and max width" @@ -1439,6 +1448,7 @@ name = "English" "Title" = "Title" "Title case" = "Title case" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." +"To the layer around it" = "To the layer around it" "Toggle" = "Toggle" "Toggle light/dark theme" = "Toggle light/dark theme" "Toggle visibility" = "Toggle visibility" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 7756fba7..6ef81633 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Add library file…" = "Agregar archivo de biblioteca…" "Add max height" = "Agregar alto máximo" "Add max width" = "Agregar ancho máximo" +"Add measurement" = "Agregar medición" "Add min height" = "Agregar alto mínimo" "Add min width" = "Agregar ancho mínimo" "Add mode" = "Agregar modo" @@ -95,6 +96,7 @@ status = "draft — needs native review" "comment.add" = "Agregar comentario" "component.addProperty" = "Agregar propiedad de componente" "component.addVariant" = "Agregar variante" +"devMode.addMeasurement" = "Agregar medición" "devResource.add" = "Agregar recurso de desarrollo" "effect.add" = "Agregar efecto" "export.add" = "Agregar configuración de exportación" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e172584c..29596514 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,13 @@ name = "Español" status = "draft — needs native review" [ui] +"Add measurement" = "Añadir medición" +"Along the bottom" = "A lo largo del borde inferior" +"Along the left" = "A lo largo del borde izquierdo" +"Along the right" = "A lo largo del borde derecho" +"Along the top" = "A lo largo del borde superior" "Arrow at the end" = "Flecha al final" +"Between the selected layers" = "Entre las capas seleccionadas" "Check" = "Visto" "Chevron" = "Cheurón" "Compatibility notes" = "Notas de compatibilidad" @@ -689,6 +695,7 @@ status = "draft — needs native review" "Jun" = "jun" "Just now" = "Justo ahora" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Mantén una medición en el lienzo para desarrolladores" "Keep appearance" = "Mantener apariencia" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." "Keep tool selected after use" = "Mantener la herramienta tras usarla" @@ -779,6 +786,7 @@ status = "draft — needs native review" "Maximize" = "Maximizar" "May" = "may" "Measure to the selection" = "Medir hasta la selección" +"Measurements" = "Mediciones" "Merge into main" = "Fusionar en la principal" "Merge regions" = "Fusionar regiones" "Merge the branch into the main file (one undo step) and archive it" = "Fusiona la rama en el archivo principal (un paso de deshacer) y la archiva" @@ -1098,6 +1106,7 @@ status = "draft — needs native review" "Remove guide" = "Quitar guía" "Remove interaction" = "Quitar interacción" "Remove library from this file" = "Quitar biblioteca de este archivo" +"Remove measurement" = "Quitar medición" "Remove min and max" = "Quitar mín. y máx." "Remove min and max height" = "Quitar alto mín. y máx." "Remove min and max width" = "Quitar ancho mín. y máx." @@ -1440,6 +1449,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Tipo título" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartir esta biblioteca con tu equipo, publica el archivo en un servidor (Compartir ▸ Publicar en servidor…) y luego publica allí la biblioteca." +"To the layer around it" = "Hasta la capa que la contiene" "Toggle" = "Activar/desactivar" "Toggle light/dark theme" = "Cambiar tema claro/oscuro" "Toggle visibility" = "Mostrar/ocultar" @@ -1904,6 +1914,9 @@ status = "draft — needs native review" "dev.focus" = "Vista de enfoque" "dev.resolveChange" = "Resolver cambio" "dev.unfocus" = "Salir de la vista de enfoque" +"devMode.addMeasurement" = "Añadir medición" +"devMode.measurements" = "Listar mediciones" +"devMode.removeMeasurement" = "Quitar medición" "devResource.add" = "Añadir recurso de desarrollo" "devResource.edit" = "Editar recurso de desarrollo" "devResource.list" = "Listar recursos de desarrollo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index f18ffeaf..fe979678 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Lisää kirjastotiedosto…" "Add max height" = "Lisää enimmäiskorkeus" "Add max width" = "Lisää enimmäisleveys" +"Add measurement" = "Lisää mittaus" "Add min height" = "Lisää vähimmäiskorkeus" "Add min width" = "Lisää vähimmäisleveys" "Add mode" = "Lisää tila" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Kaikki tuetut ominaisuudet" "All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" "All variables" = "Kaikki muuttujat" +"Along the bottom" = "Alareunaa pitkin" +"Along the left" = "Vasenta reunaa pitkin" +"Along the right" = "Oikeaa reunaa pitkin" +"Along the top" = "Yläreunaa pitkin" "Alpha" = "Alfa" "Alphabetical" = "Aakkosjärjestys" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Perusteet" "Before" = "Ennen" "Bend" = "Taivutus" +"Between the selected layers" = "Valittujen tasojen välillä" "Bevel" = "Viiste" "Binary assets added" = "Lisätyt binääriresurssit" "Binary assets removed" = "Poistetut binääriresurssit" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "kesäk." "Just now" = "Juuri nyt" "Justify" = "Tasaa molemmat reunat" +"Keep a measurement on the canvas for developers" = "Säilytä mittaus piirtoalueella kehittäjille" "Keep appearance" = "Säilytä ulkoasu" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." "Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Suurenna" "May" = "toukok." "Measure to the selection" = "Mittaa valintaan" +"Measurements" = "Mittaukset" "Merge into main" = "Yhdistä päähaaraan" "Merge regions" = "Yhdistä alueet" "Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Poista apuviiva" "Remove interaction" = "Poista vuorovaikutus" "Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove measurement" = "Poista mittaus" "Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" "Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" "Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" @@ -1425,6 +1434,7 @@ status = "draft — needs native review" "Title" = "Otsikko" "Title case" = "Sanojen isot alkukirjaimet" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"To the layer around it" = "Ympäröivään tasoon" "Toggle" = "Vaihda tila" "Toggle light/dark theme" = "Vaihda vaalea/tumma teema" "Toggle visibility" = "Vaihda näkyvyys" @@ -1883,6 +1893,9 @@ status = "draft — needs native review" "dev.focus" = "Kohdistusnäkymä" "dev.resolveChange" = "Ratkaise muutos" "dev.unfocus" = "Poistu kohdistusnäkymästä" +"devMode.addMeasurement" = "Lisää mittaus" +"devMode.measurements" = "Luettele mittaukset" +"devMode.removeMeasurement" = "Poista mittaus" "devResource.add" = "Lisää kehittäjäresurssi" "devResource.edit" = "Muokkaa kehittäjäresurssia" "devResource.list" = "Luettele kehittäjäresurssit" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index e5823983..bb771e9f 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Ajouter un fichier de bibliothèque…" "Add max height" = "Ajouter une hauteur max." "Add max width" = "Ajouter une largeur max." +"Add measurement" = "Ajouter une mesure" "Add min height" = "Ajouter une hauteur min." "Add min width" = "Ajouter une largeur min." "Add mode" = "Ajouter un mode" @@ -93,6 +94,10 @@ status = "draft — needs native review" "All fonts" = "Toutes les polices" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" +"Along the bottom" = "Le long du bord inférieur" +"Along the left" = "Le long du bord gauche" +"Along the right" = "Le long du bord droit" +"Along the top" = "Le long du bord supérieur" "Alpha" = "Alpha" "Alphabetical" = "Alphabétique" "Alt+drag" = "Alt+glisser" @@ -157,6 +162,7 @@ status = "draft — needs native review" "Basics" = "Essentiel" "Before" = "Avant" "Bend" = "Courber" +"Between the selected layers" = "Entre les calques sélectionnés" "Bevel" = "Biseau" "Binary assets added" = "Ressources binaires ajoutées" "Binary assets removed" = "Ressources binaires supprimées" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" +"Keep a measurement on the canvas for developers" = "Garder une mesure sur le canevas pour les développeurs" "Keep appearance" = "Conserver l’apparence" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Conserver l’apparence convertit chaque valeur de couleur en {profile} pour que les couleurs restent identiques. Conserver les valeurs laisse les nombres tels quels, si bien que les couleurs changent." "Keep tool selected after use" = "Garder l’outil sélectionné après usage" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Agrandir" "May" = "mai" "Measure to the selection" = "Mesurer jusqu’à la sélection" +"Measurements" = "Mesures" "Merge into main" = "Fusionner dans le principal" "Merge regions" = "Fusionner les régions" "Merge the branch into the main file (one undo step) and archive it" = "Fusionne la branche dans le fichier principal (une seule étape d’annulation) et l’archive" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Retirer le repère" "Remove interaction" = "Retirer l’interaction" "Remove library from this file" = "Retirer la bibliothèque de ce fichier" +"Remove measurement" = "Retirer la mesure" "Remove min and max" = "Retirer min. et max." "Remove min and max height" = "Retirer la hauteur min. et max." "Remove min and max width" = "Retirer la largeur min. et max." @@ -1426,6 +1435,7 @@ status = "draft — needs native review" "Title" = "Titre" "Title case" = "Casse de titre" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Pour partager cette bibliothèque avec votre équipe, publiez le fichier sur un serveur (Partager ▸ Publier sur un serveur…), puis publiez-y la bibliothèque." +"To the layer around it" = "Jusqu’au calque qui le contient" "Toggle" = "Activer/désactiver" "Toggle light/dark theme" = "Basculer entre thème clair et sombre" "Toggle visibility" = "Afficher/masquer" @@ -1884,6 +1894,9 @@ status = "draft — needs native review" "dev.focus" = "Vue ciblée" "dev.resolveChange" = "Résoudre la modification" "dev.unfocus" = "Quitter la vue ciblée" +"devMode.addMeasurement" = "Ajouter une mesure" +"devMode.measurements" = "Lister les mesures" +"devMode.removeMeasurement" = "Retirer la mesure" "devResource.add" = "Ajouter une ressource de développement" "devResource.edit" = "Modifier la ressource de développement" "devResource.list" = "Lister les ressources de développement" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index c06f2dff..a516db9f 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Tambah file pustaka…" "Add max height" = "Tambah tinggi maks" "Add max width" = "Tambah lebar maks" +"Add measurement" = "Tambah pengukuran" "Add min height" = "Tambah tinggi min" "Add min width" = "Tambah lebar min" "Add mode" = "Tambah mode" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All fonts" = "Semua font" "All supported properties" = "Semua properti yang didukung" "All variables" = "Semua variabel" +"Along the bottom" = "Sepanjang sisi bawah" +"Along the left" = "Sepanjang sisi kiri" +"Along the right" = "Sepanjang sisi kanan" +"Along the top" = "Sepanjang sisi atas" "Alpha" = "Alfa" "Alphabetical" = "Alfabetis" "Alt+drag" = "Alt+seret" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Dasar" "Before" = "Sebelum" "Bend" = "Lengkungan" +"Between the selected layers" = "Di antara layer yang dipilih" "Bevel" = "Bevel" "Binary assets added" = "Aset biner ditambahkan" "Binary assets removed" = "Aset biner dihapus" @@ -672,6 +678,7 @@ status = "draft — needs native review" "Jun" = "Jun" "Just now" = "Baru saja" "Justify" = "Rata kiri-kanan" +"Keep a measurement on the canvas for developers" = "Simpan pengukuran di kanvas untuk developer" "Keep appearance" = "Pertahankan tampilan" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." "Keep tool selected after use" = "Tetap pilih alat setelah dipakai" @@ -761,6 +768,7 @@ status = "draft — needs native review" "Maximize" = "Maksimalkan" "May" = "Mei" "Measure to the selection" = "Ukur ke pilihan" +"Measurements" = "Pengukuran" "Merge into main" = "Merge ke main" "Merge regions" = "Gabungkan area" "Merge the branch into the main file (one undo step) and archive it" = "Merge branch ke file utama (satu langkah urungkan) lalu arsipkan" @@ -1070,6 +1078,7 @@ status = "draft — needs native review" "Remove guide" = "Hapus garis bantu" "Remove interaction" = "Hapus interaksi" "Remove library from this file" = "Hapus pustaka dari file ini" +"Remove measurement" = "Hapus pengukuran" "Remove min and max" = "Hapus min dan maks" "Remove min and max height" = "Hapus tinggi min dan maks" "Remove min and max width" = "Hapus lebar min dan maks" @@ -1395,6 +1404,7 @@ status = "draft — needs native review" "Title" = "Judul" "Title case" = "Huruf Awal Kapital" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Untuk membagikan pustaka ini kepada tim Anda, publikasikan file ke server (Bagikan ▸ Publikasikan ke server…), lalu publikasikan pustaka di sana." +"To the layer around it" = "Ke layer yang membungkusnya" "Toggle" = "Alihkan" "Toggle light/dark theme" = "Alihkan tema terang/gelap" "Toggle visibility" = "Alihkan visibilitas" @@ -1844,6 +1854,9 @@ status = "draft — needs native review" "dev.focus" = "Tampilan fokus" "dev.resolveChange" = "Selesaikan perubahan" "dev.unfocus" = "Keluar dari tampilan fokus" +"devMode.addMeasurement" = "Tambah pengukuran" +"devMode.measurements" = "Daftar pengukuran" +"devMode.removeMeasurement" = "Hapus pengukuran" "devResource.add" = "Tambah sumber daya dev" "devResource.edit" = "Edit sumber daya dev" "devResource.list" = "Daftar sumber daya dev" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index d7ee7287..641ec1f6 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -57,6 +57,7 @@ status = "draft — needs native review" "Add library file…" = "ライブラリファイルを追加…" "Add max height" = "最大の高さを追加" "Add max width" = "最大の幅を追加" +"Add measurement" = "測定を追加" "Add min height" = "最小の高さを追加" "Add min width" = "最小の幅を追加" "Add mode" = "モードを追加" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All fonts" = "すべてのフォント" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" +"Along the bottom" = "下辺に沿って" +"Along the left" = "左辺に沿って" +"Along the right" = "右辺に沿って" +"Along the top" = "上辺に沿って" "Alpha" = "アルファ" "Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "基本" "Before" = "変更前" "Bend" = "曲げ" +"Between the selected layers" = "選択したレイヤーの間" "Bevel" = "ベベル" "Binary assets added" = "追加されたバイナリアセット" "Binary assets removed" = "削除されたバイナリアセット" @@ -679,6 +685,7 @@ status = "draft — needs native review" "Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" +"Keep a measurement on the canvas for developers" = "開発者向けに測定をキャンバスに残す" "Keep appearance" = "見た目を保持" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「見た目を保持」はすべてのカラー値を {profile} に変換し、色の見た目を保つ。「値を保持」は数値をそのまま残すため、色が変わる。" "Keep tool selected after use" = "使用後もツールを選択したままにする" @@ -768,6 +775,7 @@ status = "draft — needs native review" "Maximize" = "最大化" "May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" +"Measurements" = "測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" "Merge the branch into the main file (one undo step) and archive it" = "ブランチをメインファイルにマージし(1 回の取り消し単位)、アーカイブする" @@ -1078,6 +1086,7 @@ status = "draft — needs native review" "Remove guide" = "ガイドを削除" "Remove interaction" = "インタラクションを削除" "Remove library from this file" = "このファイルからライブラリを削除" +"Remove measurement" = "測定を削除" "Remove min and max" = "最小と最大を削除" "Remove min and max height" = "最小と最大の高さを削除" "Remove min and max width" = "最小と最大の幅を削除" @@ -1409,6 +1418,7 @@ status = "draft — needs native review" "Title" = "タイトル" "Title case" = "タイトルケース" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "このライブラリをチームと共有するには、ファイルをサーバーに公開し(共有 ▸ サーバーに公開…)、そこでライブラリを公開する。" +"To the layer around it" = "親レイヤーまで" "Toggle" = "切り替え" "Toggle light/dark theme" = "ライト/ダークテーマを切り替え" "Toggle visibility" = "表示を切り替え" @@ -1864,6 +1874,9 @@ status = "draft — needs native review" "dev.focus" = "フォーカスビュー" "dev.resolveChange" = "変更を解決" "dev.unfocus" = "フォーカスビューを終了" +"devMode.addMeasurement" = "測定を追加" +"devMode.measurements" = "測定を一覧表示" +"devMode.removeMeasurement" = "測定を削除" "devResource.add" = "開発リソースを追加" "devResource.edit" = "開発リソースを編集" "devResource.list" = "開発リソースを一覧表示" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 42f78be6..b556e8d7 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -57,6 +57,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add library file…" = "ბიბლიოთეკის ფაილის დამატება…" "Add max height" = "მაქსიმალური სიმაღლის დამატება" "Add max width" = "მაქსიმალური სიგანის დამატება" +"Add measurement" = "გაზომვის დამატება" "Add min height" = "მინიმალური სიმაღლის დამატება" "Add min width" = "მინიმალური სიგანის დამატება" "Add mode" = "რეჟიმის დამატება" @@ -96,6 +97,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "All supported properties" = "ყველა მხარდაჭერილი თვისება" "All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" +"Along the bottom" = "ქვედა კიდის გასწვრივ" +"Along the left" = "მარცხენა კიდის გასწვრივ" +"Along the right" = "მარჯვენა კიდის გასწვრივ" +"Along the top" = "ზედა კიდის გასწვრივ" "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" @@ -160,6 +165,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Basics" = "საფუძვლები" "Before" = "მანამდე" "Bend" = "მოხრა" +"Between the selected layers" = "მონიშნულ შრეებს შორის" "Bevel" = "წაკვეთილი" "Binary assets added" = "დამატებული ბინარული ფაილები" "Binary assets removed" = "წაშლილი ბინარული ფაილები" @@ -685,6 +691,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Jun" = "ივნ" "Just now" = "ახლახან" "Justify" = "ორმხრივი სწორება" +"Keep a measurement on the canvas for developers" = "გაზომვის შენახვა ტილოზე დეველოპერებისთვის" "Keep appearance" = "იერსახის შენარჩუნება" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." "Keep tool selected after use" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" @@ -774,6 +781,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Maximize" = "გაშლა" "May" = "მაი" "Measure to the selection" = "მონიშნულამდე გაზომვა" +"Measurements" = "გაზომვები" "Merge into main" = "მთავარში შერწყმა" "Merge regions" = "არეების შერწყმა" "Merge the branch into the main file (one undo step) and archive it" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" @@ -1089,6 +1097,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove guide" = "დამხმარე ხაზის მოცილება" "Remove interaction" = "ურთიერთქმედების წაშლა" "Remove library from this file" = "ბიბლიოთეკის ამ ფაილიდან ამოშლა" +"Remove measurement" = "გაზომვის წაშლა" "Remove min and max" = "მინ. და მაქს. მოხსნა" "Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" "Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" @@ -1422,6 +1431,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Title" = "დასახელება" "Title case" = "სათაურის რეგისტრი" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "ამ ბიბლიოთეკის გუნდთან გასაზიარებლად გამოაქვეყნეთ ფაილი სერვერზე (გაზიარება ▸ სერვერზე გამოქვეყნება…), შემდეგ იქ გამოაქვეყნეთ ბიბლიოთეკა." +"To the layer around it" = "გარშემო მყოფ შრემდე" "Toggle" = "გადართვა" "Toggle light/dark theme" = "ღია/ბნელი თემის გადართვა" "Toggle visibility" = "ხილვადობის ჩართვა/გამორთვა" @@ -1879,6 +1889,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "dev.focus" = "ფოკუსის ხედი" "dev.resolveChange" = "ცვლილების გადაწყვეტა" "dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devMode.addMeasurement" = "გაზომვის დამატება" +"devMode.measurements" = "გაზომვების სია" +"devMode.removeMeasurement" = "გაზომვის წაშლა" "devResource.add" = "დეველოპერის რესურსის დამატება" "devResource.edit" = "დეველოპერის რესურსის რედაქტირება" "devResource.list" = "დეველოპერის რესურსების სია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 66eb54c2..053348f2 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "라이브러리 파일 추가…" "Add max height" = "최대 높이 추가" "Add max width" = "최대 너비 추가" +"Add measurement" = "측정 추가" "Add min height" = "최소 높이 추가" "Add min width" = "최소 너비 추가" "Add mode" = "모드 추가" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "모든 글꼴" "All supported properties" = "지원되는 모든 속성" "All variables" = "모든 변수" +"Along the bottom" = "아래쪽 가장자리를 따라" +"Along the left" = "왼쪽 가장자리를 따라" +"Along the right" = "오른쪽 가장자리를 따라" +"Along the top" = "위쪽 가장자리를 따라" "Alpha" = "알파" "Alt+drag" = "Alt+드래그" "An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "기본" "Before" = "변경 전" "Bend" = "구부리기" +"Between the selected layers" = "선택한 레이어 사이" "Bevel" = "베벨" "Binary assets added" = "추가된 바이너리 에셋" "Binary assets removed" = "제거된 바이너리 에셋" @@ -648,6 +654,7 @@ status = "draft — needs native review" "Join|stroke" = "선 연결" "Just now" = "방금" "Justify" = "양쪽 정렬" +"Keep a measurement on the canvas for developers" = "개발자를 위해 캔버스에 측정 유지" "Keep appearance" = "모양 유지" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "\"모양 유지\"는 모든 색상 값을 {profile}(으)로 변환해 색이 똑같아 보이게 함. \"값 유지\"는 숫자를 그대로 두므로 색이 달라짐." "Keep tool selected after use" = "사용 후에도 도구 선택 유지" @@ -730,6 +737,7 @@ status = "draft — needs native review" "Max lines" = "최대 줄 수" "Maximize" = "최대화" "Measure to the selection" = "선택 항목까지 측정" +"Measurements" = "측정" "Merge into main" = "메인에 병합" "Merge regions" = "영역 병합" "Merge the branch into the main file (one undo step) and archive it" = "브랜치를 메인 파일에 병합(실행 취소 한 번)하고 보관" @@ -1028,6 +1036,7 @@ status = "draft — needs native review" "Remove guide" = "안내선 제거" "Remove interaction" = "인터랙션 제거" "Remove library from this file" = "이 파일에서 라이브러리 제거" +"Remove measurement" = "측정 제거" "Remove min and max" = "최소 및 최대 제거" "Remove min and max height" = "최소 및 최대 높이 제거" "Remove min and max width" = "최소 및 최대 너비 제거" @@ -1343,6 +1352,7 @@ status = "draft — needs native review" "Title" = "제목" "Title case" = "제목 대소문자" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "이 라이브러리를 팀과 공유하려면 파일을 서버에 게시(공유 ▸ 서버에 게시…)한 다음 그곳에서 라이브러리를 게시하세요." +"To the layer around it" = "감싸는 레이어까지" "Toggle" = "전환" "Toggle light/dark theme" = "라이트/다크 테마 전환" "Toggle visibility" = "표시 여부 전환" @@ -1786,6 +1796,9 @@ status = "draft — needs native review" "dev.focus" = "포커스 보기" "dev.resolveChange" = "변경 사항 해결" "dev.unfocus" = "포커스 보기 종료" +"devMode.addMeasurement" = "측정 추가" +"devMode.measurements" = "측정 목록" +"devMode.removeMeasurement" = "측정 제거" "devResource.add" = "개발 리소스 추가" "devResource.edit" = "개발 리소스 편집" "devResource.list" = "개발 리소스 목록" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 84f13545..bc4e0cfa 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheekbestand toevoegen…" "Add max height" = "Maximale hoogte toevoegen" "Add max width" = "Maximale breedte toevoegen" +"Add measurement" = "Meting toevoegen" "Add min height" = "Minimale hoogte toevoegen" "Add min width" = "Minimale breedte toevoegen" "Add mode" = "Modus toevoegen" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Alle ondersteunde eigenschappen" "All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" +"Along the bottom" = "Langs de onderkant" +"Along the left" = "Langs de linkerkant" +"Along the right" = "Langs de rechterkant" +"Along the top" = "Langs de bovenkant" "Alpha" = "Alpha" "Alphabetical" = "Alfabetisch" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Basis" "Before" = "Voor" "Bend" = "Buiging" +"Between the selected layers" = "Tussen de geselecteerde lagen" "Bevel" = "Afgeschuind" "Binary assets added" = "Binaire assets toegevoegd" "Binary assets removed" = "Verwijderde binaire assets" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "jun." "Just now" = "Zojuist" "Justify" = "Uitvullen" +"Keep a measurement on the canvas for developers" = "Een meting voor ontwikkelaars op het canvas houden" "Keep appearance" = "Uiterlijk behouden" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." "Keep tool selected after use" = "Tool na gebruik geselecteerd houden" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Maximaliseren" "May" = "mei" "Measure to the selection" = "Meten tot de selectie" +"Measurements" = "Metingen" "Merge into main" = "Samenvoegen in main" "Merge regions" = "Gebieden samenvoegen" "Merge the branch into the main file (one undo step) and archive it" = "Voeg de branch samen met het hoofdbestand (één keer ongedaan maken) en archiveer hem" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Hulplijn verwijderen" "Remove interaction" = "Interactie verwijderen" "Remove library from this file" = "Bibliotheek uit dit bestand verwijderen" +"Remove measurement" = "Meting verwijderen" "Remove min and max" = "Min en max verwijderen" "Remove min and max height" = "Minimale en maximale hoogte verwijderen" "Remove min and max width" = "Minimale en maximale breedte verwijderen" @@ -1425,6 +1434,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Hoofdletter per woord" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om deze bibliotheek met je team te delen, publiceer het bestand naar een server (Delen ▸ Publiceren naar server…), publiceer daarna de bibliotheek." +"To the layer around it" = "Tot de laag eromheen" "Toggle" = "Omschakelen" "Toggle light/dark theme" = "Wisselen tussen licht/donker thema" "Toggle visibility" = "Zichtbaarheid omschakelen" @@ -1882,6 +1892,9 @@ status = "draft — needs native review" "dev.focus" = "Focusweergave" "dev.resolveChange" = "Wijziging oplossen" "dev.unfocus" = "Focusweergave verlaten" +"devMode.addMeasurement" = "Meting toevoegen" +"devMode.measurements" = "Metingen weergeven" +"devMode.removeMeasurement" = "Meting verwijderen" "devResource.add" = "Dev-resource toevoegen" "devResource.edit" = "Dev-resource bewerken" "devResource.list" = "Dev-resources weergeven" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 7f301a6e..25f287f9 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "Dodaj plik biblioteki…" "Add max height" = "Dodaj maksymalną wysokość" "Add max width" = "Dodaj maksymalną szerokość" +"Add measurement" = "Dodaj pomiar" "Add min height" = "Dodaj minimalną wysokość" "Add min width" = "Dodaj minimalną szerokość" "Add mode" = "Dodaj tryb" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All supported properties" = "Wszystkie obsługiwane właściwości" "All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" +"Along the bottom" = "Wzdłuż dolnej krawędzi" +"Along the left" = "Wzdłuż lewej krawędzi" +"Along the right" = "Wzdłuż prawej krawędzi" +"Along the top" = "Wzdłuż górnej krawędzi" "Alpha" = "Alfa" "Alphabetical" = "Alfabetycznie" "Alt+drag" = "Alt+przeciągnięcie" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Podstawy" "Before" = "Przed" "Bend" = "Wygnij" +"Between the selected layers" = "Między zaznaczonymi warstwami" "Bevel" = "Ścięte" "Binary assets added" = "Dodane pliki binarne" "Binary assets removed" = "Usunięte pliki binarne" @@ -682,6 +688,7 @@ status = "draft — needs native review" "Jun" = "Cze" "Just now" = "Przed chwilą" "Justify" = "Wyjustuj" +"Keep a measurement on the canvas for developers" = "Zachowaj pomiar na płótnie dla programistów" "Keep appearance" = "Zachowaj wygląd" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." "Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" @@ -771,6 +778,7 @@ status = "draft — needs native review" "Maximize" = "Maksymalizuj" "May" = "maja" "Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Measurements" = "Pomiary" "Merge into main" = "Scal z gałęzią główną" "Merge regions" = "Scal obszary" "Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" @@ -1087,6 +1095,7 @@ status = "draft — needs native review" "Remove guide" = "Usuń linię pomocniczą" "Remove interaction" = "Usuń interakcję" "Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove measurement" = "Usuń pomiar" "Remove min and max" = "Usuń min. i maks." "Remove min and max height" = "Usuń minimalną i maksymalną wysokość" "Remove min and max width" = "Usuń minimalną i maksymalną szerokość" @@ -1420,6 +1429,7 @@ status = "draft — needs native review" "Title" = "Tytuł" "Title case" = "Jak W Tytule" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"To the layer around it" = "Do warstwy nadrzędnej" "Toggle" = "Przełącz" "Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" "Toggle visibility" = "Przełącz widoczność" @@ -1878,6 +1888,9 @@ status = "draft — needs native review" "dev.focus" = "Widok skupienia" "dev.resolveChange" = "Rozwiąż zmianę" "dev.unfocus" = "Wyjdź z widoku skupienia" +"devMode.addMeasurement" = "Dodaj pomiar" +"devMode.measurements" = "Lista pomiarów" +"devMode.removeMeasurement" = "Usuń pomiar" "devResource.add" = "Dodaj zasób programistyczny" "devResource.edit" = "Edytuj zasób programistyczny" "devResource.list" = "Lista zasobów programistycznych" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 22fffb17..b0b8dea0 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Adicionar arquivo de biblioteca…" "Add max height" = "Adicionar altura máx." "Add max width" = "Adicionar largura máx." +"Add measurement" = "Adicionar medição" "Add min height" = "Adicionar altura mín." "Add min width" = "Adicionar largura mín." "Add mode" = "Adicionar modo" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Todas as fontes" "All supported properties" = "Todas as propriedades compatíveis" "All variables" = "Todas as variáveis" +"Along the bottom" = "Ao longo da borda inferior" +"Along the left" = "Ao longo da borda esquerda" +"Along the right" = "Ao longo da borda direita" +"Along the top" = "Ao longo da borda superior" "Alpha" = "Alfa" "Alt+drag" = "Alt+arrastar" "An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão" @@ -147,6 +152,7 @@ status = "draft — needs native review" "Basics" = "Básico" "Before" = "Antes" "Bend" = "Curvar" +"Between the selected layers" = "Entre as camadas selecionadas" "Bevel" = "Chanfro" "Binary assets added" = "Arquivos binários adicionados" "Binary assets removed" = "Arquivos binários removidos" @@ -647,6 +653,7 @@ status = "draft — needs native review" "Join|stroke" = "Junção" "Just now" = "Agora mesmo" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Manter uma medição no canvas para desenvolvedores" "Keep appearance" = "Manter aparência" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Manter aparência converte cada valor de cor para {profile} para que as cores pareçam iguais. Manter valores deixa os números como estão, então as cores mudam." "Keep tool selected after use" = "Manter a ferramenta selecionada após o uso" @@ -729,6 +736,7 @@ status = "draft — needs native review" "Max lines" = "Máx. de linhas" "Maximize" = "Maximizar" "Measure to the selection" = "Medir até a seleção" +"Measurements" = "Medições" "Merge into main" = "Mesclar no principal" "Merge regions" = "Mesclar regiões" "Merge the branch into the main file (one undo step) and archive it" = "Mescla a branch no arquivo principal (uma etapa de desfazer) e a arquiva" @@ -1027,6 +1035,7 @@ status = "draft — needs native review" "Remove guide" = "Remover guia" "Remove interaction" = "Remover interação" "Remove library from this file" = "Remover biblioteca deste arquivo" +"Remove measurement" = "Remover medição" "Remove min and max" = "Remover mín. e máx." "Remove min and max height" = "Remover altura mín. e máx." "Remove min and max width" = "Remover largura mín. e máx." @@ -1342,6 +1351,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Iniciais maiúsculas" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartilhar esta biblioteca com sua equipe, publique o arquivo em um servidor (Compartilhar ▸ Publicar no servidor…) e depois publique a biblioteca lá." +"To the layer around it" = "Até a camada que a contém" "Toggle" = "Alternar" "Toggle light/dark theme" = "Alternar tema claro/escuro" "Toggle visibility" = "Alternar visibilidade" @@ -1899,6 +1909,9 @@ status = "draft — needs native review" "dev.focus" = "Visualização em foco" "dev.resolveChange" = "Resolver alteração" "dev.unfocus" = "Sair da visualização em foco" +"devMode.addMeasurement" = "Adicionar medição" +"devMode.measurements" = "Listar medições" +"devMode.removeMeasurement" = "Remover medição" "devResource.add" = "Adicionar recurso de desenvolvimento" "devResource.edit" = "Editar recurso de desenvolvimento" "devResource.list" = "Listar recursos de desenvolvimento" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index baba56fd..ed1cc0eb 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -54,6 +54,7 @@ name = "Русский" "Add library file…" = "Добавить файл библиотеки…" "Add max height" = "Добавить максимальную высоту" "Add max width" = "Добавить максимальную ширину" +"Add measurement" = "Добавить измерение" "Add min height" = "Добавить минимальную высоту" "Add min width" = "Добавить минимальную ширину" "Add mode" = "Добавить режим" @@ -93,6 +94,10 @@ name = "Русский" "All supported properties" = "Все поддерживаемые свойства" "All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" +"Along the bottom" = "Вдоль нижнего края" +"Along the left" = "Вдоль левого края" +"Along the right" = "Вдоль правого края" +"Along the top" = "Вдоль верхнего края" "Alpha" = "Альфа" "Alphabetical" = "По алфавиту" "Alt+drag" = "Alt+перетаскивание" @@ -157,6 +162,7 @@ name = "Русский" "Basics" = "Основы" "Before" = "До" "Bend" = "Изгиб" +"Between the selected layers" = "Между выбранными слоями" "Bevel" = "Скошенное" "Binary assets added" = "Добавлено бинарных ресурсов" "Binary assets removed" = "Удалено бинарных ресурсов" @@ -686,6 +692,7 @@ name = "Русский" "Jun" = "июн." "Just now" = "Только что" "Justify" = "По ширине" +"Keep a measurement on the canvas for developers" = "Оставить измерение на холсте для разработчиков" "Keep appearance" = "Сохранить внешний вид" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." "Keep tool selected after use" = "Оставлять инструмент выбранным после использования" @@ -776,6 +783,7 @@ name = "Русский" "Maximize" = "Развернуть окно" "May" = "май" "Measure to the selection" = "Измерить до выделения" +"Measurements" = "Измерения" "Merge into main" = "Объединить с основной" "Merge regions" = "Объединить области" "Merge the branch into the main file (one undo step) and archive it" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" @@ -1094,6 +1102,7 @@ name = "Русский" "Remove guide" = "Удалить направляющую" "Remove interaction" = "Удалить взаимодействие" "Remove library from this file" = "Удалить библиотеку из этого файла" +"Remove measurement" = "Удалить измерение" "Remove min and max" = "Убрать минимум и максимум" "Remove min and max height" = "Убрать минимальную и максимальную высоту" "Remove min and max width" = "Убрать минимальную и максимальную ширину" @@ -1433,6 +1442,7 @@ name = "Русский" "Title" = "Заголовок" "Title case" = "Каждое Слово С Прописной" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Чтобы поделиться библиотекой с командой, опубликуйте файл на сервере («Поделиться» ▸ «Опубликовать на сервере…»), затем опубликуйте там библиотеку." +"To the layer around it" = "До родительского слоя" "Toggle" = "Переключить" "Toggle light/dark theme" = "Переключить светлую/тёмную тему" "Toggle visibility" = "Переключить видимость" @@ -1895,6 +1905,9 @@ name = "Русский" "dev.focus" = "Режим фокуса" "dev.resolveChange" = "Разрешить изменение" "dev.unfocus" = "Выйти из режима фокуса" +"devMode.addMeasurement" = "Добавить измерение" +"devMode.measurements" = "Список измерений" +"devMode.removeMeasurement" = "Удалить измерение" "devResource.add" = "Добавить ресурс для разработки" "devResource.edit" = "Изменить ресурс для разработки" "devResource.list" = "Список ресурсов для разработки" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index be25d38e..bcb819e5 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -54,6 +54,7 @@ name = "Svenska" "Add library file…" = "Lägg till biblioteksfil…" "Add max height" = "Lägg till maxhöjd" "Add max width" = "Lägg till maxbredd" +"Add measurement" = "Lägg till mått" "Add min height" = "Lägg till minhöjd" "Add min width" = "Lägg till minbredd" "Add mode" = "Lägg till läge" @@ -92,6 +93,10 @@ name = "Svenska" "All fonts" = "Alla typsnitt" "All supported properties" = "Alla egenskaper som stöds" "All variables" = "Alla variabler" +"Along the bottom" = "Längs nederkanten" +"Along the left" = "Längs vänsterkanten" +"Along the right" = "Längs högerkanten" +"Along the top" = "Längs överkanten" "Alpha" = "Alfa" "Alphabetical" = "I bokstavsordning" "Alt+drag" = "Alt+dra" @@ -156,6 +161,7 @@ name = "Svenska" "Basics" = "Grunder" "Before" = "Före" "Bend" = "Böj" +"Between the selected layers" = "Mellan de markerade lagren" "Bevel" = "Fasning" "Binary assets added" = "Binära resurser tillagda" "Binary assets removed" = "Binära resurser borttagna" @@ -679,6 +685,7 @@ name = "Svenska" "Jun" = "jun" "Just now" = "Nyss" "Justify" = "Marginaljustera" +"Keep a measurement on the canvas for developers" = "Behåll ett mått på arbetsytan för utvecklare" "Keep appearance" = "Behåll utseende" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." "Keep tool selected after use" = "Behåll verktyget markerat efter användning" @@ -768,6 +775,7 @@ name = "Svenska" "Maximize" = "Maximera" "May" = "Maj" "Measure to the selection" = "Mät avståndet till markeringen" +"Measurements" = "Mått" "Merge into main" = "Sammanfoga med main" "Merge regions" = "Sammanfoga regioner" "Merge the branch into the main file (one undo step) and archive it" = "Sammanfoga grenen med huvudfilen (ett ångrasteg) och arkivera den" @@ -1079,6 +1087,7 @@ name = "Svenska" "Remove guide" = "Ta bort stödlinje" "Remove interaction" = "Ta bort interaktion" "Remove library from this file" = "Ta bort biblioteket från den här filen" +"Remove measurement" = "Ta bort mått" "Remove min and max" = "Ta bort min och max" "Remove min and max height" = "Ta bort min- och maxhöjd" "Remove min and max width" = "Ta bort min- och maxbredd" @@ -1410,6 +1419,7 @@ name = "Svenska" "Title" = "Titel" "Title case" = "Inledande versaler" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om du vill dela biblioteket med ditt team publicerar du filen på en server (Dela ▸ Publicera på server…) och publicerar sedan biblioteket där." +"To the layer around it" = "Till lagret runt det" "Toggle" = "Visa/dölj" "Toggle light/dark theme" = "Växla mellan ljust och mörkt tema" "Toggle visibility" = "Visa/dölj" @@ -1864,6 +1874,9 @@ name = "Svenska" "dev.focus" = "Fokusvy" "dev.resolveChange" = "Lös ändring" "dev.unfocus" = "Lämna fokusvy" +"devMode.addMeasurement" = "Lägg till mått" +"devMode.measurements" = "Lista mått" +"devMode.removeMeasurement" = "Ta bort mått" "devResource.add" = "Lägg till utvecklarresurs" "devResource.edit" = "Redigera utvecklarresurs" "devResource.list" = "Lista utvecklarresurser" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 5d15c9c9..4275f06a 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -55,6 +55,7 @@ name = "Türkçe" "Add library file…" = "Kitaplık dosyası ekle…" "Add max height" = "En büyük yükseklik ekle" "Add max width" = "En büyük genişlik ekle" +"Add measurement" = "Ölçüm ekle" "Add min height" = "En küçük yükseklik ekle" "Add min width" = "En küçük genişlik ekle" "Add mode" = "Mod ekle" @@ -92,6 +93,10 @@ name = "Türkçe" "All fonts" = "Tüm yazı tipleri" "All supported properties" = "Desteklenen tüm özellikler" "All variables" = "Tüm değişkenler" +"Along the bottom" = "Alt kenar boyunca" +"Along the left" = "Sol kenar boyunca" +"Along the right" = "Sağ kenar boyunca" +"Along the top" = "Üst kenar boyunca" "Alpha" = "Alfa" "Alphabetical" = "Alfabetik" "Alt+drag" = "Alt+sürükle" @@ -156,6 +161,7 @@ name = "Türkçe" "Basics" = "Temel bilgiler" "Before" = "Önce" "Bend" = "Bük" +"Between the selected layers" = "Seçili katmanlar arasında" "Bevel" = "Eğimli" "Binary assets added" = "Eklenen ikili varlıklar" "Binary assets removed" = "Kaldırılan ikili varlıklar" @@ -672,6 +678,7 @@ name = "Türkçe" "Jun" = "Haz" "Just now" = "Az önce" "Justify" = "İki yana yasla" +"Keep a measurement on the canvas for developers" = "Geliştiriciler için tuvalde bir ölçüm tut" "Keep appearance" = "Görünümü koru" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." "Keep tool selected after use" = "Kullanımdan sonra aracı seçili tut" @@ -761,6 +768,7 @@ name = "Türkçe" "Maximize" = "Ekranı kapla" "May" = "May" "Measure to the selection" = "Seçime kadar ölç" +"Measurements" = "Ölçümler" "Merge into main" = "Ana dosyaya birleştir" "Merge regions" = "Bölgeleri birleştir" "Merge the branch into the main file (one undo step) and archive it" = "Dalı ana dosyaya birleştir (tek geri alma adımı) ve arşivle" @@ -1070,6 +1078,7 @@ name = "Türkçe" "Remove guide" = "Kılavuzu kaldır" "Remove interaction" = "Etkileşimi kaldır" "Remove library from this file" = "Kitaplığı bu dosyadan kaldır" +"Remove measurement" = "Ölçümü kaldır" "Remove min and max" = "En küçük ve en büyüğü kaldır" "Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" "Remove min and max width" = "En küçük ve en büyük genişliği kaldır" @@ -1395,6 +1404,7 @@ name = "Türkçe" "Title" = "Başlık" "Title case" = "Başlık biçimi" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Bu kitaplığı ekibinizle paylaşmak için dosyayı bir sunucuya yayımlayın (Paylaş ▸ Sunucuya yayımla…), sonra kitaplığı orada yayımlayın." +"To the layer around it" = "Çevreleyen katmana kadar" "Toggle" = "Aç/Kapat" "Toggle light/dark theme" = "Açık/koyu temayı değiştir" "Toggle visibility" = "Görünürlüğü değiştir" @@ -1844,6 +1854,9 @@ name = "Türkçe" "dev.focus" = "Odak görünümü" "dev.resolveChange" = "Değişikliği çöz" "dev.unfocus" = "Odak görünümünden çık" +"devMode.addMeasurement" = "Ölçüm ekle" +"devMode.measurements" = "Ölçümleri listele" +"devMode.removeMeasurement" = "Ölçümü kaldır" "devResource.add" = "Geliştirici kaynağı ekle" "devResource.edit" = "Geliştirici kaynağını düzenle" "devResource.list" = "Geliştirici kaynaklarını listele" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 7add99c5..da070c0e 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -54,6 +54,7 @@ status = "draft — needs native review" "Add library file…" = "添加库文件…" "Add max height" = "添加最大高度" "Add max width" = "添加最大宽度" +"Add measurement" = "添加测量" "Add min height" = "添加最小高度" "Add min width" = "添加最小宽度" "Add mode" = "添加模式" @@ -91,6 +92,10 @@ status = "draft — needs native review" "All fonts" = "全部字体" "All supported properties" = "所有支持的属性" "All variables" = "全部变量" +"Along the bottom" = "沿底边" +"Along the left" = "沿左边" +"Along the right" = "沿右边" +"Along the top" = "沿顶边" "Alpha" = "Alpha" "Alt+drag" = "Alt+拖动" "An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接" @@ -150,6 +155,7 @@ status = "draft — needs native review" "Basics" = "基础" "Before" = "之前" "Bend" = "弯曲" +"Between the selected layers" = "所选图层之间" "Bevel" = "斜角" "Binary assets added" = "新增的二进制资源" "Binary assets removed" = "删除的二进制资源" @@ -649,6 +655,7 @@ status = "draft — needs native review" "Join|stroke" = "转角" "Just now" = "刚刚" "Justify" = "两端对齐" +"Keep a measurement on the canvas for developers" = "为开发者在画布上保留测量" "Keep appearance" = "保持外观" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "“保持外观”会将每个颜色值转换为 {profile},使颜色看起来不变。“保持数值”则保留数值不变,颜色会有偏移。" "Keep tool selected after use" = "使用后保持工具选中" @@ -731,6 +738,7 @@ status = "draft — needs native review" "Max lines" = "最大行数" "Maximize" = "最大化" "Measure to the selection" = "测量到所选项的距离" +"Measurements" = "测量" "Merge into main" = "合并到主文件" "Merge regions" = "合并区域" "Merge the branch into the main file (one undo step) and archive it" = "将分支合并到主文件(一步撤销)并归档" @@ -1029,6 +1037,7 @@ status = "draft — needs native review" "Remove guide" = "移除参考线" "Remove interaction" = "移除交互" "Remove library from this file" = "从此文件中移除库" +"Remove measurement" = "移除测量" "Remove min and max" = "移除最小值和最大值" "Remove min and max height" = "移除最小和最大高度" "Remove min and max width" = "移除最小和最大宽度" @@ -1344,6 +1353,7 @@ status = "draft — needs native review" "Title" = "标题" "Title case" = "首字母大写" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "要与团队共享此库,请先将文件发布到服务器(分享 ▸ 发布到服务器…),然后在那里发布库。" +"To the layer around it" = "到外层图层" "Toggle" = "切换显示" "Toggle light/dark theme" = "切换浅色/深色主题" "Toggle visibility" = "切换可见性" @@ -1787,6 +1797,9 @@ status = "draft — needs native review" "dev.focus" = "聚焦视图" "dev.resolveChange" = "解决更改" "dev.unfocus" = "退出聚焦视图" +"devMode.addMeasurement" = "添加测量" +"devMode.measurements" = "列出测量" +"devMode.removeMeasurement" = "移除测量" "devResource.add" = "添加开发资源" "devResource.edit" = "编辑开发资源" "devResource.list" = "列出开发资源" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b13f5a8..5ced1edc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,6 +244,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview); painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } + crate::dev_mode::refresh_measurements(app); app.session.tool.labels = overlays(app, &painter, &t); crate::frame_labels::rename_field(app, ui); // The zoom tool's area. diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index dcece9b2..8783b0dd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -3,22 +3,25 @@ //! categories and the filter of the callouts, the handoff status of the selected frames (with the //! focus view and Compare changes), the list of frames and sections that are ready for dev, the //! focus view's bar, Connect to code (a component's link to its code component), dev resources -//! (links for developers) and the plugins that only read the document. +//! (links for developers), measurements kept on the canvas and the plugins that only read the +//! document. use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{Annotation, AnnotationCategory, CodeLink, DevStatus, Document, NodeId}; use figcraft_engine::annotations::{self, PINNABLE, Pinnable}; use figcraft_engine::code_connect; use figcraft_engine::codegen::{CodeLang, code_for}; +use figcraft_engine::dev_measurements::{self, Placed}; use figcraft_engine::devinspect::{self, DevItem, Prop}; use figcraft_engine::plugins::Access; +use figcraft_engine::snap::Guide; use figcraft_engine::{components, dev_resources}; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; -use crate::right::{menu_check, menu_item_with, menu_popup, prop_row, row}; +use crate::right::{menu_check, menu_item, menu_item_with, menu_popup, prop_row, row}; use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold}; use crate::widgets::{self, DropLead, PAD}; use crate::{FigApp, RightTab}; @@ -94,6 +97,9 @@ pub struct DevUi { pub compare: Option, annotated: Vec, annotated_key: Option<(u64, u64, NodeId)>, + /// The measurements kept on the current page, with their lines. + measured: Vec, + measured_key: Option<(u64, u64, NodeId)>, ready: Vec, ready_key: Option<(u64, u64, usize)>, /// Every ready-for-dev frame (the focus view's previous / next). @@ -190,6 +196,35 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter) { } } +/// Bring the current page's measurements up to date (when the document, its revision or the page +/// changed). +pub fn refresh_measurements(app: &mut FigApp) { + let Ok(ds) = app.session.ds() else { return }; + let key = (ds.uid, ds.revision, ds.page); + if app.ui.dev.measured_key != Some(key) { + app.ui.dev.measured = dev_measurements::on_page(&ds.doc, ds.page); + app.ui.dev.measured_key = Some(key); + } +} + +/// The lines of the measurements kept on the canvas, drawn with the hover measurements (Inspect +/// tab only; both layers shown; the focus view's frame only). +pub fn saved_lines(app: &FigApp) -> impl Iterator { + let ds = app.session.ds().ok().filter(|_| active(app)); + let focus = ds.and_then(figcraft_engine::DocState::focused); + app.ui + .dev + .measured + .iter() + .filter(move |m| { + ds.is_some_and(|ds| { + let shown = |id: NodeId| ds.doc.node(id).is_some_and(|n| n.visible); + shown(m.from) && m.measurement.to.is_none_or(shown) && focus.is_none_or(|f| ds.doc.is_ancestor(f, m.from)) + }) + }) + .flat_map(|m| &m.lines) +} + /// One annotation as a card: its category, its note (up to six lines) and its pinned properties /// with their values. struct Callout { @@ -690,6 +725,97 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: ui.add_space(4.0); } +/// The menu label of measuring along `side`. +fn side_label(side: figcraft_doc::Side) -> &'static str { + use figcraft_doc::Side; + match side { + Side::Top => tr("Along the top"), + Side::Right => tr("Along the right"), + Side::Bottom => tr("Along the bottom"), + Side::Left | Side::Auto => tr("Along the left"), + } +} + +/// Measurements kept on the canvas: those from or to the selected layer (with nothing selected, +/// every one on the page), each with its distances; a click selects the layer it starts from and +/// − removes it. With a layer selected, a menu places a new one: between two selected layers, to +/// the layer around it or along one of its sides. +pub fn measurements_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + use figcraft_doc::Side; + refresh_measurements(app); + let t = Tokens::get(ui.ctx()); + let first = sel.first().copied().filter(|id| doc.node(*id).is_some_and(|n| !n.is_page())); + let rows: Vec<&Placed> = app.ui.dev.measured.iter().filter(|m| first.is_none_or(|id| m.touches(id))).collect(); + if first.is_none() && rows.is_empty() { + return; + } + let mut action: Option<(&str, serde_json::Value)> = None; + widgets::separator(ui); + widgets::section_header(ui, tr("Measurements"), &[], rows.is_empty()); + for m in &rows { + if let Some(a) = measurement_row(ui, &t, doc, m) { + action = Some(a); + } + } + if let Some(id) = first { + let around = doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| !p.is_page()); + let w = ui.available_width() - PAD * 2.0; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, tr("Add measurement"), w, Some(Icon::Measure)) + .on_hover_text(tr("Keep a measurement on the canvas for developers")); + menu_popup(&resp).show(|ui| { + if let [a, b, ..] = sel + && menu_item(ui, tr("Between the selected layers"), "") + { + action = Some(("devMode.addMeasurement", json!({"from": a, "to": b}))); + } + if around && menu_item(ui, tr("To the layer around it"), "") { + action = Some(("devMode.addMeasurement", json!({"from": id}))); + } + for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] { + if menu_item(ui, side_label(side), "") { + action = Some(("devMode.addMeasurement", json!({"from": id, "side": side}))); + } + } + }); + }); + } + if let Some((cmd, params)) = action { + app.run(cmd, params); + } + ui.add_space(4.0); +} + +/// One measurement in the Inspect tab, on two lines: what it measures, and its distances below. A +/// click selects the layer it starts from, − removes it: the command to run. +fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Option<(&'static str, serde_json::Value)> { + let name = |id: NodeId| doc.node(id).map_or(String::new(), |n| n.name.clone()); + let mut action = None; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(PAD / 2.0, 1.0)), CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Measure, t.icon_secondary); + let what = match (m.measurement.to, m.measurement.side) { + (Some(to), _) => format!("{} → {}", name(m.from), name(to)), + (None, side) => format!("{} · {}", name(m.from), side.map_or("", side_label)), + }; + let value = m.labels().join(" · "); + let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); + let x = r.min.x + PAD + 22.0; + let text_w = del.min.x - 6.0 - x; + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &what, text_w), regular(11.0), t.text); + ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &value, text_w), regular(10.0), t.text_secondary); + crate::a11y::button(&resp, &format!("{what}: {value}")); + if resp.clicked() { + action = Some(("select.set", json!({"ids": [m.from]}))); + } + if widgets::icon_button_at(ui, Id::new(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { + action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index}))); + } + action +} + /// The category of annotation `a` as a dropdown: the one picked (`Value::Null` for none). fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option { let current = annotations::category_of(doc, a); diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 7008e9e5..b7901083 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -108,3 +108,81 @@ fn dev_mode_pastes_only_a_comments_link() { crate::menus::paste(&mut app, None, None); assert_eq!(app.ui.comments.open, Some(c), "the link opens its thread"); } + +#[test] +fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.app.ui.expanded.insert(f); + u.frame(vec![]); + let order = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(f).unwrap().children.clone(); + let before = order(&u); + let (ra, rb) = (u.row_at(a), u.row_at(b)); + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + u.button(p, true); + u.button(p, false); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + // A click selects. + click(&mut u, ra); + assert_eq!(u.app.session.selection(), [a]); + // A double click doesn't rename, and says why. + click(&mut u, ra); + assert!(u.app.ui.rename.is_none(), "no rename"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + rest(&mut u); + // The eye and padlock spots don't hide or lock (the row is selected instead). + let (eye, padlock) = (272.0, 250.0); + for x in [eye, padlock] { + u.frame(vec![Event::PointerMoved(Pos2::new(x, rb.y))]); + click(&mut u, Pos2::new(x, rb.y)); + rest(&mut u); + } + 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. + 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"); + // 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)); +} + +#[test] +fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.run("devMode.addMeasurement", json!({"from": a, "to": b})); + let labels = |u: &Ui| crate::dev_mode::saved_lines(&u.app).filter_map(|g| g.label.clone()).collect::>(); + u.frame(vec![]); + assert!(labels(&u).is_empty(), "the design doesn't show them"); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + assert_eq!(labels(&u), ["20"]); + u.run("node.set", json!({"id": b, "props": {"y": 110}})); + u.frame(vec![]); + assert_eq!(labels(&u), ["40"], "it follows the layer"); + // Hidden layers' measurements aren't drawn. + u.run("node.set", json!({"id": b, "props": {"visible": false}})); + u.frame(vec![]); + assert!(labels(&u).is_empty()); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 8275df5e..2e5af4cb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -209,6 +209,8 @@ icons! { Connector, Shapes, Stamp, + /// A measurement: a dimension line with arrowheads between two end ticks. + Measure, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -1180,6 +1182,13 @@ fn draw(p: &P, icon: Icon) { p.circle(8.0, 8.0, 6.0); p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); } + Measure => { + p.line(2.5, 4.0, 2.5, 12.0); + p.line(13.5, 4.0, 13.5, 12.0); + p.line(3.0, 8.0, 13.0, 8.0); + p.poly(&[(5.5, 5.5), (3.0, 8.0), (5.5, 10.5)], false); + p.poly(&[(10.5, 5.5), (13.0, 8.0), (10.5, 10.5)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..0973fd42 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -704,6 +704,11 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename = None; + } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Layers"), semibold(11.0), t.text); @@ -825,7 +830,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), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), if dev { Sense::click() } else { Sense::click_and_drag() }); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -923,14 +928,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock. let hidden = swept(false).unwrap_or(!n.visible); let locked = swept(true).unwrap_or(n.locked); - let show_lock = over || locked || row.locked_above; - let show_eye = over || hidden; + // In Dev Mode they only show what is set. + let show_lock = (over && !dev) || locked || row.locked_above; + let show_eye = (over && !dev) || hidden; let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); if show_eye { + icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); + } + if show_eye && !dev { let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click_and_drag()); a11y::toggle(&eresp, tr("Hidden"), hidden); - icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); if eresp.clicked() { vis = Some(row.id); } @@ -939,10 +947,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } if show_lock { - let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); - a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary }; icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col); + } + if show_lock && !dev { + let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); + a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); if lresp.clicked() { lock = Some(row.id); } @@ -953,7 +963,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { clicked = Some((row.id, ui.input(|i| i.modifiers))); } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "object.rename") { app.ui.rename = Some(Rename { id: row.id, start: true }); } if resp.secondary_clicked() { diff --git a/crates/ui-egui/src/measure_overlay.rs b/crates/ui-egui/src/measure_overlay.rs index 053ce861..ed8f019b 100644 --- a/crates/ui-egui/src/measure_overlay.rs +++ b/crates/ui-egui/src/measure_overlay.rs @@ -12,8 +12,9 @@ const GAP: f32 = 4.0; /// Padding inside a label's pill. const PAD: Vec2 = vec2(4.0, 2.0); -/// Draw the tool overlay's guides, measurement labels and measured box; labels keep clear of -/// `avoid` (the selection's size label). +/// Draw the tool overlay's guides, measurement labels and measured box, and in Dev Mode the +/// measurements kept on the canvas; labels keep clear of `avoid` (the selection's size label) and +/// of one another. pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option) { let ov = &app.session.tool.overlay; let line = Stroke::new(1.0, t.guide); @@ -21,7 +22,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option = Vec::new(); - for g in &ov.guides { + for g in ov.guides.iter().chain(crate::dev_mode::saved_lines(app)) { let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); if g.x0 == g.x1 && g.y0 == g.y1 { for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c48fa401..eeb25e3d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -109,8 +109,8 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { /// 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: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still -/// edit in Dev Mode; route them through here if people trip over it. +/// shortcut: files dropped on the canvas still edit in Dev Mode; route them through here if people +/// trip over it. 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/right.rs b/crates/ui-egui/src/right.rs index 6738f300..e3aba95b 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2243,6 +2243,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { widgets::note(ui, tr("Hover other layers to measure the distance"), t.text_secondary); crate::dev_mode::ready_list(app, ui); crate::dev_mode::categories_section(app, ui); + crate::dev_mode::measurements_section(app, ui, &doc, &sel); crate::dev_mode::plugins_section(app, ui); return; }; @@ -2251,6 +2252,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { crate::dev_mode::status(app, ui, &doc, &sel); crate::dev_mode::annotations_section(app, ui, &doc, first); crate::dev_mode::dev_resources_section(app, ui, &doc, first); + crate::dev_mode::measurements_section(app, ui, &doc, &sel); use figcraft_engine::devinspect::ColorFormat; let fmt_id = Id::new("inspect_color_format"); let fmt = ui.data(|d| d.get_temp::(fmt_id)).and_then(|i| ColorFormat::ALL.get(i).copied()).unwrap_or_default(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3941bd8a..f86ce764 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -131,7 +131,8 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun `devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev), `annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers, -its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with +its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Dev Mode measurements +starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written with `node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings; only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index bf3cc737..7f8a7fbe 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,16 +1,16 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) -**Breadth ~97%, ready ~76% (estimated).** The inspect surface is broad, components connect +**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect to the code components of a repository (with their imports, also inside generated layer trees), -changes compare with any saved version and the canvas only inspects in Dev Mode. Left: measurements -kept on the canvas, a read-only layers panel in Dev Mode, publishing mappings from the code -repository. Remaining: 2–4 h. +changes compare with any saved version, the canvas and the layers panel only inspect in Dev Mode, +and measurements stay on the canvas for everyone. Left: publishing mappings from the code +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, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel and dropped files 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); files dropped on the canvas still edit (ours to tighten) | | 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 | @@ -20,7 +20,7 @@ repository. Remaining: 2–4 h. | Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | | Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository | | Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations | -| Measurements (redlines on hover, Alt in the design) | partial | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas for others aren't there | +| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | | Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them | @@ -28,6 +28,7 @@ repository. Remaining: 2–4 h. | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ff796416..73fbc602 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -20,7 +20,7 @@ and prototype interactions are still unchecked there. | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | -| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer | — | +| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | ## Import (place / paste / drop) @@ -115,7 +115,7 @@ each. properties, arc fields, font weight: written as the values they give), text styles on part of a text (written as values), variables' publishing, code syntax and inheritance settings, extended collections (written as collections of their own), comments, Dev Mode annotations, statuses, - resources and code links, plugin data, ruler + resources, measurements and code links, plugin data, ruler guides, prototype device settings, slot properties and slot content, brush strokes (written plain), strokes wider on one side of their path (written as filled outlines, or with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG), diff --git a/docs/gaps.md b/docs/gaps.md index 40f97032..f4f29c07 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 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-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) 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 @@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: measurements kept on the canvas, a read-only layers panel, publishing mappings from the code repository; the editor extension is left to the MCP server (import lines, compare with versions and an inspect-only canvas landed 2026-10-15) | Feature | 2–4 | [dev-mode-parity](dev-mode-parity.md) | +| 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | @@ -386,11 +386,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ready list filters and badges Changed items (`inspect.readyForDev {status: changed}`); statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels. -- **Missing:** measurements kept on the canvas for others (only hover redlines); the layers panel - and 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. -- **Estimate:** 2–4 h. +- **Landed (2026-10-24):** measurements kept on the canvas: between two layers, along one side of + a layer or to the layer around it (`devMode.addMeasurement` / `removeMeasurement` / + `measurements`), stored on the layer they start from (saved, synced, undoable; old files open + 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. +- **Estimate:** 1–2 h. ### 17. Branching and merging - **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches @@ -471,6 +476,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index ccaeb401..8ffe773c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1392,6 +1392,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path are clipped to 200 characters; a layer holds at most 32 links. Links are data: never fetched or opened by the engine (the app opens one when it is clicked). `get_node` and `inspect_document` list them as `devResources`; the Inspect tab lists, adds, edits and removes them. +- **Measurements kept on the canvas:** distances a developer leaves on the canvas for everyone. + `devMode.addMeasurement {from? (default the first selected layer), to?, side?}` → `{from, index, + lines}` places one between `from` and `to` (a layer on the same page), along one side of `from` + (`side`: `top`, `right`, `bottom` or `left`: that side's length) or, with neither, to the layer + around `from`; `devMode.removeMeasurement {from?, index?}` → `{removed}` (no index: every one + starting from the layer); both undoable. `devMode.measurements {page?, id?}` → `[{from, fromName, + index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}]` lists those on a + page (default the current one; `id`: only those from or to that layer), with their lines in page + space measured from the layers' current bounds, so they follow the layers. A measurement is + stored on the layer it starts from (`get_node` lists them as `measurements: [{to} | {side}]`), + saved with the file and synced; the same two layers are measured once (either way round), a + layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed + or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied + with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them + (with nothing selected, every one on the page), selects a measurement's layer on a click, removes + one with −, and places new ones from its Add measurement menu. - **Annotations:** notes for developers on any layer, each with optional pinned properties that Dev Mode shows with their current values (and the style or variable behind them). `annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id, @@ -1428,8 +1444,8 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev - resources, code connections and comments still work); choosing a drawing tool in the toolbar - goes back to the design. Commands sent over MCP or the control channel aren't affected. + resources, measurements, code connections and comments still work); choosing a drawing tool in + the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected. - **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the file keeps a copy of it and everything in it as it was just before that edit (saved in the `.figcraft` file only while there is a change; resolving the change, marking it again, another diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a7d3402e..e196eb64 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -110,7 +110,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | -| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | +| 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | | 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 |