Merge pull request #908 from RiceDee/feat/903-variants-discoverability

#903: Combine as variants in the context menu and Design panel; Add variant on a lone component
This commit is contained in:
BFlat
2026-10-21 16:01:25 +07:00
committed by GitHub
8 changed files with 406 additions and 22 deletions
+75 -17
View File
@@ -56,11 +56,17 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
}
/// Wrap components in a new component set (named after the first) placed around them.
fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
let cmd = "component.combineVariants";
fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
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<NodeId> {
Ok(set)
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
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::<BTreeSet<_>>().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<NodeId> {
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<Value> {
let cmd = "component.combineVariants";
let mut seen = BTreeSet::new();
let ids: Vec<NodeId> = 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<Value> {
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<Value> {
.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)?;
+2
View File
@@ -1883,6 +1883,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;
@@ -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);
}
+23 -4
View File
@@ -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}));
+2
View File
@@ -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;
+13
View File
@@ -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"), ("-", "")]);
@@ -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<Pos2> {
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<NodeId> {
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");
}
+1 -1
View File
@@ -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 | |