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] #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 | |