From 594dc8c6cf50a03b6d88abce877794f286f759b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 19 Oct 2026 22:20:57 -0400 Subject: [PATCH 01/24] Release 0.8.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86bccfa3..1d047aa4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1413,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.7.0" +version = "0.8.0" dependencies = [ "arboard", "eframe", @@ -1440,7 +1440,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1469,7 +1469,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1492,7 +1492,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo", "serde", @@ -1501,7 +1501,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.7.0" +version = "0.8.0" dependencies = [ "kurbo", "serde", @@ -1564,7 +1564,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1572,7 +1572,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1588,7 +1588,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-color", "serde", @@ -1627,7 +1627,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.7.0" +version = "0.8.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1645,7 +1645,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.7.0" +version = "0.8.0" dependencies = [ "crc32fast", "ctrlc", @@ -1669,7 +1669,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1685,7 +1685,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1699,7 +1699,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.7.0" +version = "0.8.0" dependencies = [ "base64 0.22.1", "egui", @@ -1724,7 +1724,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.7.0" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -5934,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.7.0" +version = "0.8.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 932da46f..1eef32c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.7.0" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From bbe1a12eeb1b91c843e3b531bd6cd14ed36a7b11 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:29:27 +1100 Subject: [PATCH 02/24] test(ui-egui): idle harness scans system fonts before its first frame --- crates/ui-egui/src/idle_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 6ae19546..192073b3 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -26,6 +26,10 @@ pub(crate) struct Harness { impl Harness { pub(crate) fn new(s: Session) -> Self { + // The one system font scan bumps the layout generation, which makes the canvas render + // again: scanned here, before the first frame, rather than by whichever test happens to + // run first while this one waits for idle (#333). + figcraft_text::scan_system_fonts(); Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 } } From bcd5b6e6329e1e2aabab75a8bef5b6e2a816075e Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:18:13 +1100 Subject: [PATCH 03/24] #903: Combine as variants in the context menu and Design panel; Add variant on a lone component --- crates/engine/src/cmd/component.rs | 92 +++++++-- crates/engine/src/lib.rs | 2 + crates/engine/src/variants_discovery_tests.rs | 179 ++++++++++++++++++ crates/ui-egui/src/component_panel.rs | 27 ++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 13 ++ .../ui-egui/src/variants_discovery_tests.rs | 111 +++++++++++ docs/components-parity.md | 2 +- 8 files changed, 406 insertions(+), 22 deletions(-) create mode 100644 crates/engine/src/variants_discovery_tests.rs create mode 100644 crates/ui-egui/src/variants_discovery_tests.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index bf99c592..9023b1fa 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -56,11 +56,17 @@ pub fn specs() -> Vec { s.set_selection(out.iter().filter_map(id_of).collect()); Ok(json!({"ids": out})) }), - spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine), + spec!( + "component.combineVariants", + "Combine as variants", + "{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")", + true, + combine + ), spec!( "component.addVariant", "Add variant", - "{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", + "{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)", true, add_variant ), @@ -331,12 +337,11 @@ fn create(s: &mut Session, p: &Value) -> Result { } /// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { - let cmd = "component.combineVariants"; +fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { - return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into())); + return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); } let bounds = doc.bounds_of(ids).unwrap_or_default(); let pw = doc.world_transform(parent); @@ -358,22 +363,71 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { Ok(set) } -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - return Err(bad(cmd, "select components only")); + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None } - let ids = z_sorted(doc, &ids); - let set = wrap_in_set(doc, &ids)?; +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; for (k, i) in ids.iter().enumerate() { - // Name variants "Property 1=Variant N" unless already in prop=value form. let node = doc.get_mut(*i)?; if !node.name.contains('=') { node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); } } + Ok(set) +} + +fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; s.set_selection(vec![set]); Ok(json!({"id": set})) } @@ -397,15 +451,19 @@ fn fit_set(doc: &mut Document, set: NodeId) { fn add_variant(s: &mut Session, p: &Value) -> Result { let cmd = "component.addVariant"; - let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; let doc = s.doc_mut()?; let (set, base) = match doc.get(t)?.kind { NodeKind::ComponentSet { .. } => { let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; (t, last) } - NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t), - _ => return Err(bad(cmd, "select a component set or variant")), + NodeKind::Component { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), }; let options = variant_options(doc, set); let mut pairs = variant_props(&doc.get(base)?.name); @@ -1103,7 +1161,7 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { .unwrap_or_else(|| "Default".into()); let set = match owner { Owner::Set(s) => s, - Owner::Comp(c) => wrap_in_set(doc, &[c])?, + Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, }; for c in Owner::Set(set).components(doc) { let cn = doc.get_mut(c)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0871eebf..68f80578 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1881,6 +1881,8 @@ mod variables_import_shapes_tests; #[cfg(test)] mod variables_io_tests; #[cfg(test)] +mod variants_discovery_tests; +#[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] mod vector_multi_behaviour_tests; diff --git a/crates/engine/src/variants_discovery_tests.rs b/crates/engine/src/variants_discovery_tests.rs new file mode 100644 index 00000000..b792e727 --- /dev/null +++ b/crates/engine/src/variants_discovery_tests.rs @@ -0,0 +1,179 @@ +//! Combine as variants and Add variant where people look for them (#903): the predicates the +//! context menu and the Design panel offer them by, combining refusing components that are +//! variants already (it used to put a set inside a set), and Add variant on a main component that +//! isn't in a set yet making it one. + +use serde_json::json; + +use crate::Session; +use crate::cmd::component::{can_add_variant, can_combine_variants}; +use crate::components::tests::{ex, healthy, idv, kids, node}; +use figcraft_doc::*; + +/// A main component named `name` at `x`, made from a 120×40 frame with a "Bg" rectangle. +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 120, "height": 40, "name": name}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg"}})); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn undo_len(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +fn sets(s: &Session) -> usize { + s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).count() +} + +#[test] +fn combine_is_offered_for_two_or_more_main_components_only() { + let mut s = Session::new(); + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 400}))); + let doc = s.doc().unwrap(); + assert!(can_combine_variants(doc, &[a, b])); + assert!(!can_combine_variants(doc, &[]), "nothing selected"); + assert!(!can_combine_variants(doc, &[a]), "one component (the command still takes it)"); + assert!(!can_combine_variants(doc, &[a, a]), "the same component twice"); + assert!(!can_combine_variants(doc, &[a, frame]), "a frame isn't a component"); + assert!(!can_combine_variants(doc, &[a, inst]), "an instance isn't a main component"); + + let c = component(&mut s, "C", 400.0); + let d = component(&mut s, "D", 600.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c, d]}))); + let doc = s.doc().unwrap(); + assert!(!can_combine_variants(doc, &[c, d]), "variants of one set"); + assert!(!can_combine_variants(doc, &[c, a]), "a variant and a lone component"); + assert!(!can_combine_variants(doc, &[set, a]), "a set isn't a component"); + + // Components in different frames still combine. + let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 100}))); + let e = component(&mut s, "E", 0.0); + ex(&mut s, "node.reparent", json!({"ids": [e], "parent": holder})); + assert!(can_combine_variants(s.doc().unwrap(), &[a, e])); +} + +#[test] +fn add_variant_is_offered_for_sets_variants_and_lone_components() { + let mut s = Session::new(); + let a = component(&mut s, "A", 0.0); + let b = component(&mut s, "B", 200.0); + let lone = component(&mut s, "Lone", 400.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50}))); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": lone, "x": 0, "y": 400}))); + let doc = s.doc().unwrap(); + assert!(can_add_variant(doc, set)); + assert!(can_add_variant(doc, a)); + assert!(can_add_variant(doc, lone)); + assert!(!can_add_variant(doc, frame)); + assert!(!can_add_variant(doc, inst)); + assert!(!can_add_variant(doc, kids(&s, inst)[0]), "a layer inside an instance"); + assert!(!can_add_variant(doc, NodeId(999_999))); +} + +#[test] +fn combining_variants_again_is_refused_instead_of_nesting_sets() { + let mut s = Session::new(); + let a = component(&mut s, "State=Default", 0.0); + let b = component(&mut s, "State=Hover", 200.0); + let lone = component(&mut s, "State=Pressed", 400.0); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}))); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + for ids in [vec![a, b], vec![a, lone]] { + let err = s.execute("component.combineVariants", &json!({"ids": ids})).unwrap_err(); + assert!(err.to_string().contains("variants already"), "{err}"); + } + assert_eq!(*s.doc().unwrap(), before, "a refused combine changes nothing"); + assert_eq!(undo_len(&s), steps); + assert_eq!(sets(&s), 1); + assert_eq!(kids(&s, set), vec![a, b]); + // One lone component still makes a set of one (other commands rely on it). + ex(&mut s, "component.combineVariants", json!({"ids": [lone]})); + assert_eq!(sets(&s), 2); + healthy(&s); +} + +#[test] +fn combining_refuses_nested_components_and_components_inside_instances() { + let mut s = Session::new(); + // A "Card" component holding an "Icon" component. + let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + let icon = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 20, "height": 20, "name": "Icon", "parent": card}))); + let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon]}))); + let card = idv(&ex(&mut s, "component.create", json!({"ids": [card]}))); + let other = component(&mut s, "Other", 300.0); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300}))); + let inner = kids(&s, inst).into_iter().find(|c| matches!(node(&s, *c).kind, NodeKind::Component { .. })).expect("the instance's copy of Icon"); + + let doc = s.doc().unwrap(); + assert!(!can_combine_variants(doc, &[card, icon]), "a component and one inside it"); + assert!(can_combine_variants(doc, &[icon, other]), "a nested component with another one is fine"); + assert!(!can_combine_variants(doc, &[inner, other]), "a component inside an instance"); + let before = s.doc().unwrap().clone(); + let err = s.execute("component.combineVariants", &json!({"ids": [card, icon]})).unwrap_err(); + assert!(err.to_string().contains("inside it"), "{err}"); + assert_eq!(*s.doc().unwrap(), before); +} + +#[test] +fn add_variant_on_a_lone_component_makes_it_a_set_in_one_step() { + let mut s = Session::new(); + let c = component(&mut s, "Button", 0.0); + let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 0, "y": 300}))); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + + let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c}))); + let set = node(&s, c).parent.unwrap(); + let set_node = node(&s, set); + assert!(matches!(set_node.kind, NodeKind::ComponentSet { .. })); + assert_eq!(set_node.name, "Button", "the set takes the component's name"); + assert_eq!(kids(&s, set), vec![c, v]); + assert_eq!(node(&s, c).name, "Property 1=Default"); + assert_eq!(node(&s, v).name, "Property 1=Variant2"); + assert_eq!(s.selection(), vec![v]); + assert!(set_node.height >= 40.0 * 2.0 + 20.0, "the set wraps both variants: {}", set_node.height); + assert!(matches!(node(&s, inst).kind, NodeKind::Instance { component, .. } if component == c), "instances keep their main"); + assert_eq!(undo_len(&s), steps + 1, "wrapping and adding are one undo step"); + healthy(&s); + + s.undo().unwrap(); + assert_eq!(*s.doc().unwrap(), before, "undo puts the lone component back"); +} + +#[test] +fn add_variant_on_a_lone_component_keeps_prop_value_names_and_rolls_back_on_errors() { + let mut s = Session::new(); + let c = component(&mut s, "Size=Small", 0.0); + let before = s.doc().unwrap().clone(); + let steps = undo_len(&s); + assert!(s.execute("component.addVariant", &json!({"id": c, "property": "Nope", "value": "x"})).is_err()); + assert_eq!(*s.doc().unwrap(), before, "a refused Add variant leaves no set behind"); + assert_eq!(undo_len(&s), steps); + + let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c, "property": "Size", "value": "Large"}))); + assert_eq!(node(&s, c).name, "Size=Small"); + assert_eq!(node(&s, v).name, "Size=Large"); + healthy(&s); +} + +#[test] +fn add_variant_on_a_lone_component_in_auto_layout_keeps_its_place() { + let mut s = Session::new(); + let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "name": "Row"}))); + ex(&mut s, "layout.add", json!({"ids": [row]})); + let first = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row}))); + let c = component(&mut s, "Chip", 0.0); + ex(&mut s, "node.reparent", json!({"ids": [c], "parent": row})); + let last = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row}))); + + ex(&mut s, "component.addVariant", json!({"id": c})); + let set = node(&s, c).parent.unwrap(); + assert_eq!(kids(&s, row), vec![first, set, last], "the set takes the component's place in the row"); + assert_eq!(kids(&s, set).len(), 2); + healthy(&s); +} diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 6c9ff7d4..2b2573c7 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -2,8 +2,9 @@ //! //! - An instance: its main component (with a swap menu), variant dropdowns, property values, //! the selected layer's overrides, and Reset / Detach / Go to main / Push changes. -//! - A component or component set: its properties (add, rename, default, delete) and, for a set, -//! the variant property matrix. +//! - A component or component set: its properties (add, rename, default, delete), Add variant +//! (a lone main component becomes a set) and, for a set, the variant property matrix. +//! - Several main components: Combine as variants. //! - A layer inside a component: bind its visibility / text / instance to a property, or make a //! frame a slot. //! - A slot inside an instance (and an instance's slot properties): fill it with components @@ -11,6 +12,7 @@ use egui::{Id, Sense, Ui, vec2}; use figcraft_doc::*; +use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants}; use figcraft_engine::components::{ PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts, variant_options, variant_props, variant_set, @@ -27,7 +29,10 @@ use crate::widgets; /// Entry point (called from the Design panel header for every selection). pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - let [id] = sel else { return }; + let [id] = sel else { + combine_ui(app, ui, doc, sel); + return; + }; let Some(n) = doc.node(*id) else { return }; match &n.kind { NodeKind::Instance { .. } => instance_ui(app, ui, doc, n), @@ -57,6 +62,19 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No } } +/// Two or more main components selected: a button combines them into a component set. +fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + if !can_combine_variants(doc, sel) { + return; + } + row(ui, 32.0, |ui| { + let label = crate::i18n::command("component.combineVariants", "Combine as variants"); + if widgets::dropdown(ui, &label, 160.0, Some(Icon::ComponentSet)).clicked() { + app.run("component.combineVariants", json!({"ids": sel})); + } + }); +} + /// A component's description: a multi-line field, saved when it loses focus. fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); @@ -365,7 +383,8 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }); } } - if is_set { + // A lone main component too: Add variant makes it a set of itself and the new variant. + if can_add_variant(doc, owner) { row(ui, 32.0, |ui| { if widgets::dropdown(ui, tr("Add variant"), 120.0, Some(Icon::Plus)).clicked() { app.run("component.addVariant", json!({"id": owner})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 072aec62..363b5d33 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -215,6 +215,8 @@ mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; #[cfg(test)] +mod variants_discovery_tests; +#[cfg(test)] mod vector_panel_tests; #[cfg(test)] mod vector_tail_ui_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e81a7c49..9659c6b3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -853,6 +853,14 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { None } }); + // Two or more main components combine into a component set. Judged on the selection + // the command acts on: a layer row right-clicked in a multi-selection passes only itself. + let combine = app + .session + .doc() + .ok() + .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) + .map(|_| i18n::command("component.combineVariants", "Combine as variants")); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -910,6 +918,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, entry); } + if let Some(label) = &combine + && let Some(at) = entries.iter().position(|e| e.1 == "component.create") + { + entries.insert(at + 1, (label, "component.combineVariants")); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); diff --git a/crates/ui-egui/src/variants_discovery_tests.rs b/crates/ui-egui/src/variants_discovery_tests.rs new file mode 100644 index 00000000..e153d982 --- /dev/null +++ b/crates/ui-egui/src/variants_discovery_tests.rs @@ -0,0 +1,111 @@ +//! Combine as variants and Add variant where people look for them (#903), driven through egui +//! headless: the Design panel offers Combine for two or more main components and Add variant for +//! a lone one, and the canvas / layers context menu lists Combine only when it applies. + +use egui::Pos2; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; +use crate::present_tests::id; + +const COMBINE: &str = "Combine as variants"; + +/// A session with lone main components named A, B and C side by side, and a frame. Returns +/// (session, [a, b, c], frame). +fn doc() -> (Session, [NodeId; 3], NodeId) { + let mut s = Session::new(); + let mut comps = Vec::new(); + for (k, name) in ["A", "B", "C"].iter().enumerate() { + let f = id(&s.execute("frame.create", &json!({"x": k * 160, "y": 0, "width": 120, "height": 40, "name": name})).unwrap()); + comps.push(id(&s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 200, "width": 60, "height": 60})).unwrap()); + (s, [comps[0], comps[1], comps[2]], frame) +} + +fn select(u: &mut Ui, ids: &[NodeId]) { + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + u.frame(vec![]); +} + +/// The text painted this frame. +fn shows(u: &mut Ui, s: &str) -> Option { + let shapes = flat(&u.frame_output(vec![]).shapes); + has_text(&shapes, s).then(|| text_center(&shapes, s)) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); +} + +fn set_of(u: &Ui, c: NodeId) -> Option { + let doc = u.app.session.doc().unwrap(); + doc.node(c).and_then(|n| n.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +fn undo_len(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +#[test] +fn the_design_panel_combines_two_selected_components() { + let (s, [a, b, _], _) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a, b]); + let at = shows(&mut u, COMBINE).expect("the Design panel offers Combine as variants"); + let undo = undo_len(&u); + click(&mut u, at); + let set = set_of(&u, a).expect("combined into a set"); + assert_eq!(set_of(&u, b), Some(set)); + assert_eq!(u.app.session.selection(), vec![set]); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + assert!(shows(&mut u, COMBINE).is_none(), "a set selected: nothing to combine"); +} + +#[test] +fn the_design_panel_offers_combine_only_when_it_applies() { + // The cases are the engine's (`can_combine_variants`); this pins the panel to it. + let (s, [a, ..], frame) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a, frame]); + assert!(shows(&mut u, COMBINE).is_none(), "a component and a frame"); +} + +#[test] +fn the_design_panel_adds_a_variant_to_a_lone_component() { + let (s, [a, ..], _) = doc(); + let mut u = Ui::new(s); + select(&mut u, &[a]); + let at = shows(&mut u, "Add variant").expect("a lone main component offers Add variant"); + click(&mut u, at); + let set = set_of(&u, a).expect("the component became a set"); + assert_eq!(u.app.session.doc().unwrap().node(set).unwrap().children.len(), 2); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn the_context_menu_lists_combine_for_two_selected_components() { + let (s, [a, b, _], _) = doc(); + let mut u = Ui::new(s); + // One component: no Combine, Create component as before. + select(&mut u, &[a]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a])); + assert!(shows(&mut u, COMBINE).is_none()); + assert!(shows(&mut u, "Create component").is_some()); + u.app.ui.context_menu = None; + // Two components, right-clicked on one layer row (the menu gets only that row): judged on + // the selection, which is what the command combines. + select(&mut u, &[a, b]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![a])); + u.frame(vec![]); + let at = shows(&mut u, COMBINE).expect("the context menu offers Combine as variants"); + click(&mut u, at); + let set = set_of(&u, a).expect("combined into a set"); + assert_eq!(set_of(&u, b), Some(set)); + assert!(u.app.ui.context_menu.is_none(), "the menu closes"); +} diff --git a/docs/components-parity.md b/docs/components-parity.md index dcd93305..fad4c6fe 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -16,7 +16,7 @@ accounts). | Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances | | What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | -| Component sets / variants, add variant, switch | done | | +| Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) | | A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | From b5a315a7482002343fcb2a4411b4ea6c3b82d2f3 Mon Sep 17 00:00:00 2001 From: RiceDee <345114586+RiceDee@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:32:16 +1100 Subject: [PATCH 04/24] #903: name a new component set from its variants' path, not their first property A component set took its name from the first variant's name up to the first '/' or '=', so variants named "State=Default, Size=Small" made a set called "State". The set now takes the path before the last '/' ("Button/State=Hover" -> "Button", "Icons/Arrow/Dir=Up" -> "Icons/Arrow"), keeps a plain name as is ("Button"), and otherwise gets the next numbered default ("Component 1"), as a new component does. --- crates/engine/src/cmd/component.rs | 15 +++++---- .../engine/src/components_behaviour_tests.rs | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index bf99c592..47ea804c 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -330,6 +330,14 @@ fn create(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": target})) } +/// The name a new component set takes from its first variant: the path before the last '/' +/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name +/// ("State=Default, Size=Small") names no set. +fn set_name_from(variant: &str) -> Option { + let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); + (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) +} + /// Wrap components in a new component set (named after the first) placed around them. fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { let cmd = "component.combineVariants"; @@ -341,12 +349,7 @@ fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result { let bounds = doc.bounds_of(ids).unwrap_or_default(); let pw = doc.world_transform(parent); let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids - .first() - .and_then(|i| doc.node(*i)) - .map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string()) - .filter(|n| !n.is_empty() && !n.contains(',')) - .unwrap_or_else(|| "Component".into()); + let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); let set = doc.alloc_id(); let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index ff78f73b..7a5c1f90 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -879,3 +879,35 @@ fn auto_layout_instances_follow_their_main() { assert_eq!(node(&s, i).child_layout.min_w, Some(80.0)); healthy(&s); } + +#[test] +fn a_new_component_set_is_named_after_its_first_variant() { + // "Button/State=Hover" groups under "Button": the set takes the path before the last '/'. + // A bare property name ("State=Default, Size=Small") names no set, and neither does an empty + // name: the set gets the next numbered default, like a new component. A plain name is kept. + let rows = [ + ("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"), + ("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"), + ("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""), + ("", "Component 1", "empty name: the numbered default"), + ]; + for (name, expected, why) in rows { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20}))); + ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}})); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name"); + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]}))); + assert_eq!(node(&s, set).name, expected, "{why}"); + healthy(&s); + } + + // Add variant on a lone component (a variant property on it) wraps it in a set named after it. + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"}))); + let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"}))); + assert_eq!(node(&s, set).name, "Button"); + assert_eq!(node(&s, c).name, "State=Default"); + healthy(&s); +} From 49586b1c47d649e5598502227a592630e1c23e66 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:30:01 +0000 Subject: [PATCH 05/24] Reject malformed alpha and extra args in Color::parse_css Found while testing crates/color/src/lib.rs:165 (also :173) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 16 ++++++++++++++-- crates/color/src/tests.rs | 10 ++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 8e9f98d9..fdad0fa7 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -162,7 +162,13 @@ impl Color { p.parse::().ok().map(|v| v / 255.0) } }; - let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + let alpha = match parts.get(3) { + Some(a) => parse_alpha(a)?, + None => 1.0, + }; + if parts.len() > 4 { + return None; + } return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha)); } if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) { @@ -170,7 +176,13 @@ impl Color { let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; - let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0); + let alpha = match parts.get(3) { + Some(a) => parse_alpha(a)?, + None => 1.0, + }; + if parts.len() > 4 { + return None; + } return Some(Hsl { h, s, l }.to_color().with_alpha(alpha)); } if t == "transparent" { diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 4cf875bb..2216b6fa 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -114,3 +114,13 @@ fn infinite_channels_clamp() { let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); } + +#[test] +fn malformed_alpha_or_extra_args_rejected() { + assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None); + assert_eq!(Color::parse_css("hsla(0,0%,0%,abc)"), None); + assert_eq!(Color::parse_css("rgb(1,2,3,4,5)"), None); + assert_eq!(Color::parse_css("hsl(0,0%,0%,0.5,1)"), None); + assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0); + assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5); +} From 23463f719b01153ee89bba9c47b6d44fefee03de Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:23:42 +0500 Subject: [PATCH 06/24] M7: keep saved document titles in sync with filenames --- crates/engine/src/cmd/file.rs | 11 +++++-- crates/engine/src/lib.rs | 4 ++- crates/engine/src/live_access_tests.rs | 4 +-- crates/engine/src/save_format_tests.rs | 40 ++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8e59b303..7380711f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -542,7 +542,8 @@ fn load_with( return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); } let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - if loaded.doc.name.is_empty() { + // Older files saved an untouched default title even after the user chose a filename. + if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { loaded.doc.name = file_stem(name_hint); } Ok((loaded, false)) @@ -853,8 +854,14 @@ fn save_to(s: &mut Session, path: String) -> Result { } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - let bytes = file_bytes(ds)?; + let mut snapshot = FileSnapshot::of(ds); + let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); + if follows_path && ds.path.as_deref() != Some(path.as_str()) { + Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + } + let bytes = snapshot.encode()?; write(&path, &bytes)?; + ds.doc = snapshot.doc; ds.path = Some(path.clone()); ds.mark_saved(); ds.revision += 1; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0871eebf..a220dbc4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1062,7 +1062,9 @@ impl Session { }; let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; let changed = !Arc::ptr_eq(&ds.doc, &bdoc); - if changed { + // Saving may adopt the chosen filename as the document title. The encoded snapshot is + // already final; normalising it again would detach it from the saved baseline. + if changed && !matches!(spec.id, "file.save" | "file.saveAs") { normalize(ds.doc_mut(), &bdoc); } let edit = changed && ds.interaction.is_none(); diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index 7f5a4488..c2c75bb5 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -265,7 +265,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() { s.execute("file.new", &json!({})).unwrap(); let offline_path = saved_file(&mut s); s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().name, "Untitled"); + assert_eq!(s.doc().unwrap().name, offline_path.file_stem().unwrap().to_string_lossy()); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().versions.len(), 1); assert_eq!(s.live_of(uid).doc_uid, Some(uid)); @@ -273,7 +273,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() { s.execute("doc.switch", &json!({"index": 0})).unwrap(); s.execute("collab.leave", &json!({})).unwrap(); s.execute("file.revert", &json!({})).unwrap(); - assert_eq!(s.doc().unwrap().name, "Untitled"); + assert_eq!(s.doc().unwrap().name, path.file_stem().unwrap().to_string_lossy()); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().versions.len(), 1); assert_eq!(s.live().doc_uid, None); diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index 4473ef7c..d635808e 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -89,6 +89,46 @@ fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String] assert_eq!(s.prefs.recent_files, recent); } +#[test] +fn first_native_save_uses_chosen_filename_for_an_untitled_document() { + let mut files = SaveFiles::new(); + let path = files.path("Redis.figcraft"); + let copy = files.path("Redis copy.figcraft"); + let manual_copy = files.path("Other.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Redis"); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis"); + + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Redis"); + + s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Redis copy"); + s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap(); + s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap(); + assert_eq!(s.ds().unwrap().doc.name, "Chosen title"); +} + +#[test] +fn opening_an_older_file_uses_its_filename_only_for_the_default_title() { + let mut files = SaveFiles::new(); + let old_path = files.path("Redis.figcraft"); + let named_path = files.path("Other.figcraft"); + let mut original = Session::new(); + std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap(); + original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap(); + std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap(); + + let mut reopened = Session::empty(); + reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Redis"); + reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap(); + assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title"); +} + fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document { let bytes = std::fs::read(path).unwrap(); assert_eq!(result["format"], "fig"); From fc37c808aaa83773a27f94e629efe156f7ef5f1a Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Tue, 20 Oct 2026 15:27:27 +0200 Subject: [PATCH 07/24] M7: a document dropped on the canvas opens as a new file (#916) A .figcraft, .fig or file JSON dropped on the canvas ran file.import, which added its pages to the file in front. It now opens as a file of its own, as on the home screen, in the browser too. Images and SVG still import at the drop point; File > Import still adds pages. - pending_open::open_dropped queues a dropped file as file.open; the home screen and the canvas share it. - Dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). - The tab-switch import test drops an SVG: it pinned where an import lands, using a .figcraft only as a file to import. --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 11 ++++-- crates/ui-egui/src/home.rs | 12 ++----- crates/ui-egui/src/home_tests.rs | 55 +++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 6 ++++ crates/ui-egui/src/pending_open.rs | 10 ++++++ 6 files changed, 79 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4e0d088d..f43cd015 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-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 | | 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 | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 49163986..46451ae0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,7 +183,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); - crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import")); + // A browser names no dragged file until the drop, so documents there still say "import". + let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document))); + crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") }); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -504,10 +506,15 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) { return false; } - // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]). + // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]); + // a dropped document opens as a file of its own, as on the home screen. let dropped = ctx.input(|i| i.raw.dropped_files.clone()); for f in dropped { let path = f.path().to_path_buf(); + if crate::is_document(&path) { + crate::pending_open::open_dropped(app, &f); + continue; + } let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); let (params, read) = if path.exists() { diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index c174242a..b532d9a7 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -180,19 +180,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { - let path = f.path().to_path_buf(); - if !crate::opens(&path) { + if !crate::opens(f.path()) { app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); continue; } - // `opens` saw an extension, so there is a file name to tell the format by. - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let (params, read) = if path.exists() { - (json!({"path": path.to_string_lossy()}), None) - } else { - (json!({"name": name}), Some(crate::dropped_bytes(&f, &name))) - }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); + crate::pending_open::open_dropped(app, &f); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c74c35bf..ebbf6d95 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -668,9 +668,8 @@ fn dropping_a_native_file_on_the_home_screen_opens_it() { #[test] fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() { let dir = Temp::new(); - let path = dir.path("Canvas import.figcraft"); - let mut source = Session::new(); - source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let path = dir.path("Canvas import.svg"); + std::fs::write(&path, r#""#).unwrap(); let mut session = Session::new(); let target_uid = session.ds().unwrap().uid; @@ -696,7 +695,55 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc u.frame(vec![]); assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front"); - assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); + let layers = |uid: u64| { + let doc = &u.app.session.docs.iter().find(|doc| doc.uid == uid).unwrap().doc; + doc.node(doc.pages[0]).unwrap().children.len() + }; + assert_eq!((layers(target_uid), layers(other_uid)), (1, 0), "the drop lands in the tab that received it"); +} + +#[test] +fn dropping_a_document_on_canvas_opens_it_as_a_new_file() { + let dir = Temp::new(); + let path = dir.path("Dropped.figcraft"); + let mut saved = Session::new(); + saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let before = u.app.session.doc().unwrap().clone(); + let s = drop_file(&mut u, &path); + assert!(has_text(&s, "Drop to open"), "dragging a document over the canvas says it opens"); + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Opening as a new file"), "the card says so too"); + u.frame(vec![]); + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.session.docs.len(), 2, "a tab of its own"); + assert_eq!(u.app.session.ds().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()), "in front"); + assert_eq!(u.app.session.docs[0].doc.as_ref(), &before, "the file that was open gets no pages added"); +} + +#[test] +fn a_document_dropped_on_canvas_without_a_file_on_disk_opens_as_a_new_file() { + // Every drop in the browser (#916). + let dir = Temp::new(); + let saved = dir.path("Saved.figcraft"); + Session::new().execute("file.saveAs", &json!({"path": saved.to_string_lossy()})).unwrap(); + let bytes = std::fs::read(&saved).unwrap(); + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes("Shared.figcraft".into(), bytes)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 2, "a tab of its own"); + assert_eq!(u.app.session.docs[0].doc.pages.len(), 1, "no pages added to the open file"); + assert_eq!(u.app.session.docs[1].path, None, "with no save path made up for it"); } #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 072aec62..0fd22c53 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1817,6 +1817,12 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } +/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: +/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. +pub(crate) fn is_document(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) +} + /// Reads the contents of dropped file `name` that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 501367c0..f5e978c6 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -32,6 +32,16 @@ pub struct ImportTarget { pub name: String, } +/// Queues dropped file `f` to open as a document of its own: by path, or by its contents when there +/// is no file on disk to read. Callers checked its extension, so there is a name to tell the format by. +pub(crate) fn open_dropped(app: &mut FigApp, f: &egui::DroppedFileHandle) { + let path = f.path(); + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let (params, read) = + if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(f, &name))) }; + app.ui.pending_opens.push(PendingOpen { label: name, params, command: "file.open", target: None, read }); +} + fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { let target = match pending.target { Some(target) => target, From 7c68ebf183b8c3ebcd6befa97b60ed7ec9905348 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:27:09 +0000 Subject: [PATCH 08/24] Reject non-hex characters in Local::from_link token Found while testing crates/collab/src/code.rs:~150 (Local::from_link, u64::from_str_radix on the token) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/collab/src/code.rs | 2 +- crates/collab/src/code_tests.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/collab/src/code.rs b/crates/collab/src/code.rs index c046682a..6eebe9f7 100644 --- a/crates/collab/src/code.rs +++ b/crates/collab/src/code.rs @@ -135,7 +135,7 @@ impl Local { let query = link.strip_prefix("figcraft://join?")?; let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v)); let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?; - let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?; + let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? }; (get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local) } diff --git a/crates/collab/src/code_tests.rs b/crates/collab/src/code_tests.rs index 2dd902b3..31bd3488 100644 --- a/crates/collab/src/code_tests.rs +++ b/crates/collab/src/code_tests.rs @@ -104,6 +104,7 @@ fn only_local_session_links_have_a_code() { "figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a", "figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a", "figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az", + "figcraft://join?host=10.0.0.1:47900&file=local&token=local-+0000000001", "figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a", "ws://192.168.1.20:47900/?file=local&token=local-0123456789a", "", From 92290dd208f4423a9de1e0402eff93a675cd200a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 20 Oct 2026 11:32:08 -0400 Subject: [PATCH 09/24] docs: add SonarCloud and CodeRabbit badges to README (#820) * docs: add SonarCloud and CodeRabbit badges to README * docs: put SonarCloud and CodeRabbit badges on their own line --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ae4c9f24..63bc739b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ macOS · Windows · Linux · FreeBSD · Web License: MIT OR Apache-2.0 Status: early alpha +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

From d9c924c3ba7114dff9141c667aec56d3764f3af9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 20 Oct 2026 11:39:30 -0400 Subject: [PATCH 10/24] Skip CodeRabbit reviews on Dependabot PRs (#937) Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot]. Co-authored-by: Claude Opus 5.5 --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..2408084c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" From 8b198ea364b9cc1c5646c69fe6999cdf677eb1bb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:41:47 +0000 Subject: [PATCH 11/24] Strip default port in same_server when the address has a path Found while testing crates/engine/src/online.rs:118 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/online.rs | 7 ++++++- crates/engine/src/online_tests.rs | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/online.rs b/crates/engine/src/online.rs index b982580e..66713732 100644 --- a/crates/engine/src/online.rs +++ b/crates/engine/src/online.rs @@ -120,7 +120,12 @@ pub fn same_server(a: &str, b: &str) -> bool { let s = plain_server(s).to_ascii_lowercase(); let s = s.trim_end_matches('/'); let port = if s.starts_with("wss://") { ":443" } else { ":80" }; - s.strip_suffix(port).unwrap_or(s).to_string() + let start = s.find("://").map_or(0, |i| i + 3); + let end = s[start..].find('/').map_or(s.len(), |i| start + i); + match s[start..end].strip_suffix(port) { + Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]), + None => s.to_string(), + } }; norm(a) == norm(b) } diff --git a/crates/engine/src/online_tests.rs b/crates/engine/src/online_tests.rs index 1e341b14..c211c505 100644 --- a/crates/engine/src/online_tests.rs +++ b/crates/engine/src/online_tests.rs @@ -583,3 +583,11 @@ fn debug_output_of_a_record_leaves_the_secrets_out() { no_secrets("debug", &text); assert!(text.contains("share.example") && text.contains(".."), "{text}"); } + +#[test] +fn same_server_ignores_the_default_port_before_a_path() { + use crate::online::same_server; + assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/")); + assert!(same_server("ws://host:80/x", "ws://host/x")); + assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/")); +} From bf3fc3cc6e8f181d3a7a19d2cf0a5efae43a7bbb Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:23:47 +0500 Subject: [PATCH 12/24] M7: dismiss comment draft when switching tools --- crates/ui-egui/src/comments_tests.rs | 14 +++++++++++++- crates/ui-egui/src/lib.rs | 3 +++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index 696ff322..f1b32a4b 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -7,7 +7,7 @@ use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_engine::Session; use serde_json::{Value, json}; -use crate::comments::{cluster, insert_mention, mention_matches, mention_query}; +use crate::comments::{cluster, insert_mention, mention_matches, mention_query, start_draft}; use crate::present_tests::{Ui, id}; fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 { @@ -41,6 +41,18 @@ fn comments(u: &Ui) -> Value { u.app.inspect()["comments"].clone() } +#[test] +fn switching_from_comment_to_move_closes_the_unposted_composer() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "comment"})); + start_draft(&mut u.app, 100.0, 80.0); + assert!(comments(&u)["draft"].is_array()); + + u.app.run("tool.set", json!({"tool": "move"})); + u.frame(vec![]); + assert!(comments(&u)["draft"].is_null()); +} + #[test] fn mention_helpers() { // The `@query` before the cursor. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 072aec62..06114286 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -834,6 +834,9 @@ impl FigApp { /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { let r = self.session.execute(id, ¶ms); + if id == "tool.set" && r.is_ok() && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { + self.ui.comments.draft = None; + } self.handle_ui_requests(); r.map_err(|e| self.failed(id, &e)).ok() } From 1c79c1646fefb531006459d8a2c7a53fcc43c06a Mon Sep 17 00:00:00 2001 From: Siade Lucas Date: Tue, 20 Oct 2026 12:53:21 -0300 Subject: [PATCH 13/24] ui: scroll active page into view when selection changes --- contributors/people.toml | 4 +++ crates/ui-egui/src/layers.rs | 5 ++++ crates/ui-egui/src/layers_behaviour_tests.rs | 27 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ 4 files changed, 39 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index b9172891..27307935 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -24,6 +24,10 @@ display_name = "VanTan" real_name = "Tyler Jennings" display_name = "tylerjenningsw" +[people.SiadeLucas] +real_name = "Siade Lucas" +sync_github_name = true + [people.Silvertree2010] display_name = "Netter" sync_github_name = false diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index dfab115a..212ae413 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -229,6 +229,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); + let scroll_to_cur = app.ui.pages_seen_page != Some(cur); + app.ui.pages_seen_page = Some(cur); // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { @@ -238,6 +240,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if scroll_to_cur && id == cur { + ui.scroll_to_rect(r, None); + } if !filtering { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); } diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index b963ee82..cb62540c 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -507,3 +507,30 @@ fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() { // A panel too short for both keeps the three rows. assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW); } + +/// Selecting a page that is far down the list automatically scrolls it into view. +#[test] +fn selecting_a_distant_page_scrolls_it_into_view() { + let mut s = Session::new(); + let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + for _ in 0..40 { + s.execute("page.add", &json!({})).unwrap(); + } + let pages = s.doc().unwrap().pages.clone(); + let first = pages[0]; + let last = *pages.last().unwrap(); + s.execute("page.select", &json!({"id": first})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let layer = u.row_at(rect); + u.frame(vec![]); + // Select the last page. + u.app.run("page.select", json!({"id": last})); + for _ in 0..15 { + u.frame(vec![]); + } + assert_eq!(u.app.ui.pages_seen_page, Some(last)); + let opened = pages_down_to(&mut u, layer.y - 60.0); + let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); + assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 072aec62..fb44b60a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -495,6 +495,8 @@ pub struct UiState { pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, + /// The active page when last drawn in the pages list, to scroll it into view when selected. + pub pages_seen_page: Option, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, @@ -667,6 +669,7 @@ impl Default for UiState { page_search: false, page_filter: String::new(), pages_height: None, + pages_seen_page: None, expanded: BTreeSet::new(), rename: None, rename_page: None, From 8f3afbf6a13e7b7547f4ab4040e88660de48d2e7 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:04:04 +0500 Subject: [PATCH 14/24] M7: clear comment drafts for control-channel tool changes --- crates/ui-egui/src/comments_tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 11 ++++++++--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/comments_tests.rs b/crates/ui-egui/src/comments_tests.rs index f1b32a4b..eea2278e 100644 --- a/crates/ui-egui/src/comments_tests.rs +++ b/crates/ui-egui/src/comments_tests.rs @@ -53,6 +53,23 @@ fn switching_from_comment_to_move_closes_the_unposted_composer() { assert!(comments(&u)["draft"].is_null()); } +#[test] +fn control_channel_tool_switch_closes_the_unposted_composer() { + let mut u = Ui::new(Session::new()); + u.app.run("tool.set", json!({"tool": "comment"})); + start_draft(&mut u.app, 100.0, 80.0); + assert!(comments(&u)["draft"].is_array()); + + let invalid = json!({"command": "tool.set", "params": {"tool": "not-a-tool"}}); + assert!(crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &invalid).is_err()); + assert!(comments(&u)["draft"].is_array()); + + crate::control::handle(&mut u.app, &egui::Context::default(), "engine.execute", &json!({"command": "tool.set", "params": {"tool": "move"}})) + .unwrap(); + u.frame(vec![]); + assert!(comments(&u)["draft"].is_null()); +} + #[test] fn mention_helpers() { // The `@query` before the cursor. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a4a8dd15..2d35609a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -239,6 +239,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); + app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); // Programmatic calls never open dialogs. app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); app.handle_ui_requests(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 06114286..d728dc27 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -831,12 +831,17 @@ impl FigApp { } } + /// Dismiss the composer after a successful switch away from the Comment tool. + pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) { + if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { + self.ui.comments.draft = None; + } + } + /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { let r = self.session.execute(id, ¶ms); - if id == "tool.set" && r.is_ok() && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { - self.ui.comments.draft = None; - } + self.clear_comment_draft_after_tool_set(id, r.is_ok()); self.handle_ui_requests(); r.map_err(|e| self.failed(id, &e)).ok() } From 77f4271061d2ca4fd011764b256f9126d4236672 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:03:45 +0000 Subject: [PATCH 15/24] Fix trim_num saturating whole numbers beyond i64 range Found while testing crates/doc/src/text.rs:64 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/text.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 097dd189..458426ad 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -60,6 +60,9 @@ impl LetterSpacing { } pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } let r = (v * 100.0).round() / 100.0; if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } } @@ -760,6 +763,15 @@ impl TextData { mod case_tests { use super::*; + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + #[test] fn title_case_respects_context() { assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); From 59f3b3b73343489d737a4c5c4c1bd8f129fd3b14 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:31:03 +0200 Subject: [PATCH 16/24] Fix Title Case restarting mid-word across styled runs in SVG export Found while testing crates/svg/src/lib.rs:589 (also :604 and :611) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 6 ++++-- crates/svg/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 1433b942..55176aaf 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -586,7 +586,8 @@ impl W<'_> { } let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len()); let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect(); - let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) { Some(fills) => { let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into()); @@ -601,7 +602,8 @@ impl W<'_> { continue; } let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect(); - let text = st.case.apply(text.trim_end_matches(['\n', '\r'])); + let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied(); + let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev); let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y)); let _ = write!(spans, "{}", num(x), num(y), esc(&text)); } diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 5a333d07..8dece77f 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -116,6 +116,25 @@ fn text_run_fill_survives_live_text_export() { assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b") && svg.contains(">a"), "{svg}"); } +#[test] +fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { + use figcraft_doc::{StyleRun, TextCase, TextData}; + figcraft_text::scan_system_fonts(); + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("hello"); + text.style.font_size = 24.0; + text.style.case = TextCase::Title; + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 2, end: 3, style }); + text.normalize(); + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 200.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(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); +} + #[test] fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() { use figcraft_doc::{StrokeJoin, VectorNetwork}; From f53f37dfe0c647871a85dafaf2ee3a9a7c825b0a Mon Sep 17 00:00:00 2001 From: Siade Lucas Date: Tue, 20 Oct 2026 21:28:09 -0300 Subject: [PATCH 17/24] ui: scope page tracker by document UID and update on render Include document UID in pages_seen_page to correctly handle page selection and scroll tracking when switching between documents. Record page as seen only after the active row renders so that pages filtered out by search scroll into view when the filter is cleared. --- crates/ui-egui/src/layers.rs | 15 +++++++--- crates/ui-egui/src/layers_behaviour_tests.rs | 31 +++++++++++++++++++- crates/ui-egui/src/lib.rs | 4 +-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 212ae413..f172b309 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -229,8 +229,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let mut move_to: Option<(NodeId, String)> = None; // Pages are dragged up and down the list to reorder them. let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages"); - let scroll_to_cur = app.ui.pages_seen_page != Some(cur); - app.ui.pages_seen_page = Some(cur); + let page_key = (uid, cur); + let scroll_to_cur = app.ui.pages_seen_page != Some(page_key); + let mut current_row_rendered = false; // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { @@ -240,8 +241,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); - if scroll_to_cur && id == cur { - ui.scroll_to_rect(r, None); + if id == cur { + current_row_rendered = true; + if scroll_to_cur { + ui.scroll_to_rect(r, None); + } } if !filtering { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); @@ -306,6 +310,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } reorder.finish(ui) }); + if current_row_rendered { + app.ui.pages_seen_page = Some(page_key); + } // A drop outside the list's visible area (and the row gap at its edges) isn't on a page row, // even one scrolled out of view. if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| moved.inner_rect.expand2(vec2(0.0, 3.0)).contains(p)) { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index cb62540c..4d987e66 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -529,8 +529,37 @@ fn selecting_a_distant_page_scrolls_it_into_view() { for _ in 0..15 { u.frame(vec![]); } - assert_eq!(u.app.ui.pages_seen_page, Some(last)); + assert_eq!(u.app.ui.pages_seen_page, Some((u.app.session.ds().unwrap().uid, last))); let opened = pages_down_to(&mut u, layer.y - 60.0); let top = pages.iter().position(|p| Some(p) == opened.first()).unwrap(); assert!(top > 20, "the list scrolled past its first pages to reveal the selection (top row: page {top})"); } + +/// If the active page is filtered out by search, it is not marked as seen +/// until the filter is cleared and its row actually renders. +#[test] +fn filtered_out_active_page_is_seen_only_after_rendering() { + let mut s = Session::new(); + let p1 = s.doc().unwrap().pages[0]; + let p2 = id(s.execute("page.add", &json!({})).unwrap()); + s.execute("page.rename", &json!({"id": p1, "name": "Alpha"})).unwrap(); + s.execute("page.rename", &json!({"id": p2, "name": "Beta"})).unwrap(); + s.execute("page.select", &json!({"id": p1})).unwrap(); + let uid = s.ds().unwrap().uid; + let mut u = Ui::new(s); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p1))); + + // Filter to only "Alpha", then select "Beta" (p2). + u.app.ui.page_search = true; + u.app.ui.page_filter = "Alpha".into(); + u.app.run("page.select", json!({"id": p2})); + u.frame(vec![]); + + // Beta was filtered out and not rendered, so pages_seen_page has not recorded it. + assert_ne!(u.app.ui.pages_seen_page, Some((uid, p2))); + + // Clear filter: Beta renders now and is recorded. + u.app.ui.page_filter.clear(); + u.frame(vec![]); + assert_eq!(u.app.ui.pages_seen_page, Some((uid, p2))); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb44b60a..8801238d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -495,8 +495,8 @@ pub struct UiState { pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, - /// The active page when last drawn in the pages list, to scroll it into view when selected. - pub pages_seen_page: Option, + /// The document and active page last drawn in the pages list. + pub pages_seen_page: Option<(u64, NodeId)>, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, From 2e12bdfd91a3c59d5dda02e3738f3e5cbf40d2f5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:46:23 +0200 Subject: [PATCH 18/24] Keep open subpaths open in ReverseSubpaths::reverse_subpaths Found while testing crates/geom/src/lib.rs:287 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/geom/src/lib.rs | 4 +++- crates/geom/src/tests.rs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index da351e5b..e0a4dde0 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -284,7 +284,9 @@ impl ReverseSubpaths for BezPath { kurbo::PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3), } } - out.close_path(); + if matches!(seg_path.elements().last(), Some(kurbo::PathEl::ClosePath)) { + out.close_path(); + } } out } diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index b99d7d8c..15e24d49 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -108,3 +108,18 @@ fn set_rotation_keeps_center() { } } } + +#[test] +fn reverse_subpaths_preserves_open_and_closed() { + let mut open = BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((10.0, 0.0)); + open.line_to((10.0, 10.0)); + let r = ::reverse_subpaths(&open); + assert!(!r.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath)), "{:?}", r.elements()); + + let mut closed = open.clone(); + closed.close_path(); + let r = ::reverse_subpaths(&closed); + assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements()); +} From 35da7b7cff99ca7f5aba7574d0c72155461e7cf1 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 06:05:08 -0400 Subject: [PATCH 19/24] feat(window): adopt shared craft-window for Linux decorations (depends on craft-libs#217) (#971) * feat(window): adopt shared craft-window for Linux decorations Replace FigCraft's bespoke Wayland xdg-decoration probe (apps/figcraft/src/linux_window.rs) with the shared craft-window crate from craft-libs, so every Craft app agrees on who draws the window frame per desktop. The decision is now made once, before the viewport is built, by craft_window::Choice::probe(Prefix::new("FIGCRAFT")) and mapped onto FigCraft's existing TitleBar enum: Frame::Desktop => TitleBar::Native (TitleBar::Inset on macOS, unchanged) Frame::App(_) => TitleBar::Custom This drops the old runtime send_viewport_cmd(Decorations(..)) switch and the linux_window probe. macOS keeps its inset traffic lights and Windows keeps the system frame exactly as before. The only behaviour change: on KDE and other Wayland compositors with server-side decorations (Sway, Hyprland, COSMIC) FigCraft now keeps the native frame instead of always drawing its own custom title bar, matching the rest of the suite. NOT MERGEABLE YET: craft-window is pinned to the unmerged feature branch from storytold/craft-libs#217 and must be repointed to a merged rev before this can land. * fix(window): preserve Windows titlebar; pin craft-window to merged rev - Keep FigCraft's own custom title bar on Windows. craft-window returns Frame::Desktop on Windows, and the mapper was turning that into TitleBar::Native, flipping Windows from its existing undecorated custom bar to the system frame. Map Frame::Desktop to Custom on Windows (as macOS maps to Inset); only Linux/BSD Frame::Desktop becomes Native. This keeps macOS and Windows unchanged; the sole change stays KDE/Wayland. - Repoint craft-window from the (now-merged) feature branch to the squash commit on storytold/craft-libs (craft-libs#217), and commit Cargo.lock. Verified: cargo build -p figcraft and cargo test -p figcraft (53 tests) pass against the merged dependency; rustfmt clean. --- Cargo.lock | 409 +++++++++++++----------------- Cargo.toml | 3 + apps/figcraft/Cargo.toml | 9 +- apps/figcraft/src/linux_window.rs | 112 -------- apps/figcraft/src/main.rs | 96 +++++-- 5 files changed, 260 insertions(+), 369 deletions(-) delete mode 100644 apps/figcraft/src/linux_window.rs diff --git a/Cargo.lock b/Cargo.lock index 1d047aa4..e5941f57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -244,9 +244,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", @@ -258,7 +258,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -392,7 +392,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -427,7 +427,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -491,15 +491,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -510,10 +501,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -521,6 +515,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -582,6 +586,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -605,7 +633,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -672,9 +700,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -766,9 +794,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -829,6 +857,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -861,11 +895,20 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -951,9 +994,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -961,21 +1004,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -988,7 +1031,7 @@ checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1038,7 +1081,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1313,7 +1356,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.6", + "font-types 0.12.7", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1335,9 +1378,9 @@ checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1416,6 +1459,7 @@ name = "figcraft" version = "0.8.0" dependencies = [ "arboard", + "craft-window", "eframe", "egui", "env_logger", @@ -1427,11 +1471,9 @@ dependencies = [ "fmv-macos-events", "image", "log", - "raw-window-handle", "rfd", "serde_json", "wat", - "wayland-client", "webbrowser", "windows", "winit", @@ -1740,9 +1782,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -1810,9 +1852,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.6" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1857,7 +1899,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1908,7 +1950,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2606,9 +2648,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2635,7 +2677,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2677,9 +2719,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -3236,7 +3278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3492,15 +3534,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -3533,16 +3566,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3598,16 +3632,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -3619,24 +3643,11 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] [[package]] name = "rand_core" @@ -3646,11 +3657,11 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -3723,7 +3734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3734,7 +3745,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3745,7 +3756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.6", + "font-types 0.12.7", "once_cell", ] @@ -3769,9 +3780,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3916,9 +3927,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", @@ -3940,9 +3951,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4077,14 +4088,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4101,7 +4112,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4235,9 +4246,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -4401,9 +4412,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4418,7 +4429,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4480,7 +4491,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4566,9 +4577,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "pin-project-lite", ] @@ -4687,7 +4698,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.10.3", + "rand", "sha1", "thiserror 2.0.21", ] @@ -4750,9 +4761,9 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-properties" @@ -4844,9 +4855,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "js-sys", "serde_core", @@ -4963,7 +4974,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -4978,12 +4989,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.261.0", + "wasmparser 0.262.0", ] [[package]] @@ -5038,9 +5049,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -5049,9 +5060,9 @@ dependencies = [ [[package]] name = "wast" -version = "261.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", @@ -5062,9 +5073,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.261.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ "wast", ] @@ -5242,9 +5253,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" dependencies = [ "rustls-pki-types", ] @@ -5575,7 +5586,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5584,16 +5595,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5611,31 +5613,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5653,96 +5638,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5806,9 +5743,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -5959,7 +5896,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6031,7 +5968,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -6039,9 +5976,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -6071,18 +6008,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.62" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6106,7 +6043,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6146,23 +6083,21 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" dependencies = [ "arbitrary", "crc32fast", "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.21", "zopfli", ] @@ -6180,9 +6115,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -6207,9 +6142,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -6222,14 +6157,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -6242,6 +6177,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 1eef32c7..bb06ba2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,9 @@ figcraft-plugin = { path = "crates/plugin" } # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the +# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index fac4842b..6196d08d 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -16,6 +16,10 @@ figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-format = { workspace = true } figcraft-text = { workspace = true } +# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the +# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged +# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable. +craft-window = { workspace = true } egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } serde_json = { workspace = true } @@ -45,7 +49,6 @@ wat = { workspace = true } winresource = "0.1" [target.'cfg(target_os = "linux")'.dependencies] -# Use vector window controls without a title-font renderer or embedded Cantarell. +# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor +# probe that used to need raw-window-handle and wayland-client here now lives in craft-window. winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } -raw-window-handle = "0.6" -wayland-client = "0.31" diff --git a/apps/figcraft/src/linux_window.rs b/apps/figcraft/src/linux_window.rs deleted file mode 100644 index f47b61e0..00000000 --- a/apps/figcraft/src/linux_window.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Request winit's frame only on Wayland compositors without xdg-decoration. -//! Desktop/session names cannot identify the backend of an individual window. - -use std::os::unix::net::UnixStream; -use std::path::PathBuf; -use std::sync::mpsc; -use std::time::Duration; - -use raw_window_handle::{HasDisplayHandle, RawDisplayHandle}; -use wayland_client::globals::{GlobalListContents, registry_queue_init}; -use wayland_client::protocol::wl_registry; -use wayland_client::{Connection, Dispatch, QueueHandle}; - -/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET. -/// Never consume, duplicate or query that inherited connection: there may be no -/// independently addressable display, or WAYLAND_DISPLAY may name another server. -pub fn display_socket() -> Option { - if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) { - return None; - } - let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?; - let path = PathBuf::from(display); - if path.is_absolute() { - Some(path) - } else { - std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path)) - } -} - -pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option) -> bool { - let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_))); - if !wayland { - log::debug!("window controls: non-Wayland window, retaining custom controls"); - return false; - } - let server_decorations = socket.and_then(probe); - log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}"); - native_frame(wayland, server_decorations) -} - -fn native_frame(wayland: bool, server_decorations: Option) -> bool { - wayland && server_decorations == Some(false) -} - -fn probe(socket: PathBuf) -> Option { - // The compositor must not be able to block startup on a registry roundtrip. - // On failure/timeout retain FigCraft's own working title bar and buttons. - let (send, receive) = mpsc::sync_channel(1); - std::thread::Builder::new() - .name("wayland-decorations".into()) - .spawn(move || { - let _ = send.send(registry_has_decorations(socket)); - }) - .ok()?; - receive.recv_timeout(Duration::from_millis(500)).ok().flatten() -} - -fn registry_has_decorations(socket: PathBuf) -> Option { - // A fresh connection leaves winit's event queue and inherited descriptors alone. - let stream = UnixStream::connect(socket).ok()?; - let connection = Connection::from_socket(stream).ok()?; - let (globals, _queue) = registry_queue_init::(&connection).ok()?; - Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1"))) -} - -struct Registry; - -impl Dispatch for Registry { - fn event( - _state: &mut Self, - _registry: &wl_registry::WlRegistry, - _event: wl_registry::Event, - _data: &GlobalListContents, - _connection: &Connection, - _queue: &QueueHandle, - ) { - } -} - -#[cfg(test)] -mod tests { - #[test] - fn only_wayland_without_server_decorations_uses_the_frame() { - for (wayland, server_decorations, expected) in [ - (false, None, false), - (false, Some(false), false), - (false, Some(true), false), - (true, Some(true), false), - (true, Some(false), true), - (true, None, false), - ] { - assert_eq!(super::native_frame(wayland, server_decorations), expected); - } - } - - #[test] - fn an_unavailable_display_keeps_the_custom_controls() { - assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None); - } - - #[test] - fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> { - let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id())); - let listener = std::os::unix::net::UnixListener::bind(&socket)?; - let start = std::time::Instant::now(); - assert_eq!(super::probe(socket.clone()), None); - assert!(start.elapsed() < std::time::Duration::from_secs(2)); - drop(listener); - let _ = std::fs::remove_file(socket); - Ok(()) - } -} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 00856c5d..0b71149a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -21,8 +21,6 @@ mod args; mod control_server; mod gamepad; mod graphics; -#[cfg(target_os = "linux")] -mod linux_window; mod recovery; mod services; mod startup; @@ -222,8 +220,13 @@ fn main() -> eframe::Result { .with_inner_size([1440.0, 900.0]) .with_min_inner_size([800.0, 500.0]) .with_drag_and_drop(true); - // Start frameless on Linux; the app creator checks the actual display backend - // before enabling a client-side frame. Other windows inherit that decision. + // Who draws the window frame is decided once, before the viewport is built, by the shared + // craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is + // kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the + // decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway, + // Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter — + // which draws no server-side frame — leaves it to the app's own custom title bar. This replaces + // the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE. fig.ui.title_bar = desktop_title_bar(); viewport = window_chrome(viewport, fig.ui.title_bar); if let Some(icon) = icon { @@ -232,8 +235,6 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - #[cfg(target_os = "linux")] - let linux_display = linux_window::display_socket(); // Registered before the event loop starts, so it catches the Finder event that launched the app // as well as later ones. Lives until the event loop returns; the app creator only borrows it. #[cfg(target_os = "macos")] @@ -244,11 +245,6 @@ fn main() -> eframe::Result { "FigCraft", options, Box::new(move |_cc| { - #[cfg(target_os = "linux")] - if linux_window::needs_native_frame(_cc, linux_display) { - let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native); - _cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true)); - } Ok(Box::new(App { queue, ctx_slot, @@ -274,9 +270,35 @@ fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool { !first_run && (!start_on_home || control) } +/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app +/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR` +/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the +/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager +/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one). fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { + let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT")); + title_bar_for(choice.into_frame()) +} + +/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`]. +/// +/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop` +/// means the desktop draws it, but FigCraft keeps each platform's existing look there: +/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged. +/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used +/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that. +/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only +/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame. +fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar { use figcraft_ui_egui::TitleBar; - if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } + match frame { + craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset, + // Windows kept FigCraft's own title bar before this change; keep it. + craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom, + craft_window::Frame::Desktop => TitleBar::Native, + // The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override). + craft_window::Frame::App(_) => TitleBar::Custom, + } } fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder { @@ -288,15 +310,55 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T } } -#[cfg(all(test, target_os = "linux"))] -mod linux_window_tests { +#[cfg(test)] +mod title_bar_tests { + use figcraft_ui_egui::TitleBar; + + /// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override) + /// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the + /// decorations so FigCraft draws its own. #[test] - fn linux_starts_with_the_existing_frameless_title_bar() { - let bar = super::desktop_title_bar(); - assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom); + fn the_app_frame_maps_to_the_custom_title_bar() { + let layout = craft_window::Layout::default(); + let bar = super::title_bar_for(craft_window::Frame::App(layout)); + assert_eq!(bar, TitleBar::Custom); let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); assert_eq!(viewport.decorations, Some(false)); } + + /// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic + /// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's + /// native frame. The Linux case is the only behaviour change: KDE and the other + /// server-side-decoration compositors now keep their native frame. + #[test] + fn the_desktop_frame_is_mapped_per_platform() { + let bar = super::title_bar_for(craft_window::Frame::Desktop); + if cfg!(target_os = "macos") { + assert_eq!(bar, TitleBar::Inset); + } else if cfg!(target_os = "windows") { + // Windows kept its own title bar before and must keep it now. + assert_eq!(bar, TitleBar::Custom); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(false)); + } else { + assert_eq!(bar, TitleBar::Native); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.decorations, Some(true)); + } + } + + /// macOS is untouched by this change: its inset title bar with the system traffic lights stays, + /// and `window_chrome` still asks for the full-size content view rather than toggling the + /// window decorations. + #[test] + #[cfg(target_os = "macos")] + fn macos_keeps_its_inset_title_bar() { + let bar = super::desktop_title_bar(); + assert_eq!(bar, TitleBar::Inset); + let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar); + assert_eq!(viewport.fullsize_content_view, Some(true)); + assert_eq!(viewport.title_shown, Some(false)); + } } #[cfg(test)] From 28de91d9ae72fc7fbc2199ac5ee2c8be237980d5 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 21 Oct 2026 13:56:51 -0400 Subject: [PATCH 20/24] Release 0.9.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e5941f57..459c8320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1456,7 +1456,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.8.0" +version = "0.9.0" dependencies = [ "arboard", "craft-window", @@ -1482,7 +1482,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1534,7 +1534,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo", "serde", @@ -1543,7 +1543,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1555,7 +1555,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1583,7 +1583,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1598,7 +1598,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.8.0" +version = "0.9.0" dependencies = [ "kurbo", "serde", @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1614,7 +1614,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1630,7 +1630,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1639,7 +1639,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1656,7 +1656,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-color", "serde", @@ -1669,7 +1669,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1687,7 +1687,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.8.0" +version = "0.9.0" dependencies = [ "crc32fast", "ctrlc", @@ -1711,7 +1711,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1741,7 +1741,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.8.0" +version = "0.9.0" dependencies = [ "base64 0.22.1", "egui", @@ -1766,7 +1766,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.8.0" +version = "0.9.0" dependencies = [ "eframe", "egui", @@ -5871,7 +5871,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.8.0" +version = "0.9.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index bb06ba2c..6cc17546 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 0ab000b74b29e2f691b59386f46997107453fc3b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 15:46:57 -0400 Subject: [PATCH 21/24] ci: add SonarCloud scan via shared reusable workflow Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main with secrets: inherit. Adds SonarCloud badge to README where missing and removes the redundant sonar-project.properties (config now passed by the reusable workflow). --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/sonarcloud.yml diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..ecee45a6 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit From c1a4f2d954edee6c00a8e2f28decef01b7555682 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 16:00:36 -0400 Subject: [PATCH 22/24] ci: add sonar-project.properties for CI-based analysis Marks the project ineligible for SonarCloud Automatic Analysis, resolving the CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds. --- sonar-project.properties | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sonar-project.properties diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..92562d54 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,2 @@ +sonar.projectKey=storytold_figcraft +sonar.organization=storytold From cb7966556ccc54cd47caaaacced815c8b275b885 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:11 -0400 Subject: [PATCH 23/24] security: enforce HTTPS on curl downloads (SonarCloud S6506) (#1016) Add --proto '=https' to curl invocations so redirects cannot downgrade to plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions). --- packaging/linux/package.sh | 2 +- packaging/server/install.sh | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 20da2f76..d38dc0bc 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -121,7 +121,7 @@ if has appimage; then if [ -z "$TOOL" ]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" if [ ! -x "$TOOL" ]; then - curl -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" + curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi fi diff --git a/packaging/server/install.sh b/packaging/server/install.sh index 11c489a3..826c2bb4 100755 --- a/packaging/server/install.sh +++ b/packaging/server/install.sh @@ -147,7 +147,7 @@ FILES="$HERE" # where the units and the Caddyfile come from from_release() { # download and check the release archive; sets NEW_BIN and FILES local version="$VERSION" name dir if [ -z "$version" ]; then - version="$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 + version="$(curl --proto '=https' -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1 [ -n "$version" ] || return 1 fi name="figcraft-server-$version-linux-$ARCH" @@ -155,8 +155,8 @@ from_release() { # download and check the release archive; sets NEW_BIN and FILE rm -rf "$dir" mkdir -p "$dir" say "Downloading $name" - curl -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 - curl -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 + curl --proto '=https' -fsSL -o "$dir/$name.tar.gz" "https://github.com/$REPO/releases/download/v$version/$name.tar.gz" || return 1 + curl --proto '=https' -fsSL -o "$dir/SHA256SUMS.txt" "https://github.com/$REPO/releases/download/v$version/SHA256SUMS.txt" || return 1 (cd "$dir" && grep " $name.tar.gz\$" SHA256SUMS.txt | sha256sum -c --quiet -) || die "the download doesn't match SHA256SUMS.txt" tar -C "$dir" -xzf "$dir/$name.tar.gz" NEW_BIN="$dir/$name/figcraft-server" From a86897dc919b8ae7fb0142fe7e0034c10715f7d9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 21 Oct 2026 19:36:23 -0400 Subject: [PATCH 24/24] ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637) (#1015) Pin third-party actions to full-length commit SHAs (with version comments) instead of mutable tags/branches to prevent supply-chain tampering via retagged releases. Resolves SonarCloud rule githubactions:S7637. --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9700a8e9..3dfa31d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,12 +42,12 @@ jobs: libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ libgl1-mesa-dev libgtk-3-dev - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: components: clippy, rustfmt # Only main saves caches; PRs restore main's, so PR caches don't evict main's. - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true @@ -75,10 +75,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: key: wasm32 save-if: ${{ github.ref == 'refs/heads/main' }}