M3: component properties keep their order on rename; Make slot asks for the name (#1536, #1537) (#1565)

Properties were listed by name, so a rename moved one. Components now keep prop_order (the names as they were made; a rename keeps the place) and every listing follows it. Make slot opens a name field under the frame's Slot row; Enter makes the slot, Escape cancels, a taken name keeps the field open.
This commit is contained in:
BFlat
2026-10-30 04:49:00 +07:00
committed by GitHub
parent 3c1718bf86
commit 7d76b0c673
56 changed files with 589 additions and 135 deletions
+29 -8
View File
@@ -526,8 +526,15 @@ fn text_styles_on_part_of_a_text_are_written_as_values() {
fn sized() -> (Document, [NodeId; 3]) {
let mut doc = Document::new("Sized");
let page = doc.first_page().unwrap();
let main =
add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Card", (0.0, 0.0), (100.0, 60.0), &|_| {});
let main = add(
&mut doc,
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Card",
(0.0, 0.0),
(100.0, 60.0),
&|_| {},
);
add(&mut doc, main, NodeKind::Rectangle, "Box", (10.0, 10.0), (40.0, 40.0), &|n| {
n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale };
});
@@ -623,10 +630,17 @@ fn an_instance_named_after_its_set_has_no_name_of_its_own() {
fn layers_bound_like_their_mains_are_no_overrides_in_another_mode() {
let mut doc = themed();
let page = doc.first_page().unwrap();
let main =
add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Chip", (0.0, 300.0), (80.0, 30.0), &|n| {
let main = add(
&mut doc,
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Chip",
(0.0, 300.0),
(80.0, 30.0),
&|n| {
n.bound.insert("fill".into(), "v-bg".into());
});
},
);
add(&mut doc, main, NodeKind::Text(TextData::new("Chip")), "Chip label", (8.0, 6.0), (40.0, 16.0), &|n| {
n.fills = vec![solid(0.0, 0.0, 0.0)];
n.bound.insert("fill".into(), "v-fg".into());
@@ -662,15 +676,22 @@ fn layers_of_a_swapped_nested_instance_keep_what_they_change() {
let other = add(
&mut doc,
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new() },
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Other",
(0.0, 400.0),
(100.0, 60.0),
&|_| {},
);
add(&mut doc, other, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Dot", (10.0, 10.0), (40.0, 40.0), &|_| {});
let row =
add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Row", (0.0, 500.0), (300.0, 80.0), &|_| {});
let row = add(
&mut doc,
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Row",
(0.0, 500.0),
(300.0, 80.0),
&|_| {},
);
let mut g = Gen { r: Lcg(3), doc, image: String::new(), frames: Vec::new(), layers: Vec::new() };
let thumb = g.instance(card, row, BTreeMap::new());
g.doc.node_mut(thumb).unwrap().name = "Thumb".into();
+2 -2
View File
@@ -76,8 +76,8 @@ impl<'d> Exporter<'d> {
// A name used with two types is two properties (their definitions have their own ids).
let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new();
for v in &variants {
let NodeKind::Component { props, .. } = &v.kind else { continue };
for (name, d) in props {
let NodeKind::Component { props, prop_order, .. } = &v.kind else { continue };
for (name, d) in figcraft_doc::ordered_props(props, prop_order) {
let Some(ty) = prop_type(d) else { continue };
match seen.get(&(name.clone(), ty)) {
Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"),
+6 -2
View File
@@ -109,7 +109,7 @@ impl<'d> Exporter<'d> {
self.warn("boolean groups whose layers combine in different ways use the group's operation");
}
}
NodeKind::Component { description, props } if !nested => {
NodeKind::Component { description, props, prop_order } if !nested => {
if let Some(set) = self.set_of(n.id) {
// A variant: its set holds the definitions; the variant has its own copy of
// each (pointing at the set's), and its values of the variant properties.
@@ -136,7 +136,11 @@ impl<'d> Exporter<'d> {
f.push(("variantPropSpecs", arr(specs)));
}
} else {
let defs: Vec<Kv> = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect();
let defs: Vec<Kv> = figcraft_doc::ordered_props(props, prop_order)
.into_iter()
.enumerate()
.filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i))
.collect();
if !defs.is_empty() {
f.push(("componentPropDefs", arr(defs)));
}
+39 -8
View File
@@ -410,7 +410,7 @@ impl Gen {
/// A component with a boolean and a text property, a component set, and instances of both
/// (with property values and overrides), and one nested two levels deep.
fn components(&mut self, page: NodeId) {
let comp = self.node(page, NodeKind::Component { description: "A button".into(), props: BTreeMap::new() }, "Button");
let comp = self.node(page, NodeKind::Component { description: "A button".into(), props: BTreeMap::new(), prop_order: Vec::new() }, "Button");
let icon = self.node(comp, NodeKind::Rectangle, "Icon");
let label = self.node(comp, NodeKind::Text(TextData::new("Label")), "Label");
{
@@ -449,7 +449,7 @@ impl Gen {
}
// A card holding an instance of the button, and an instance of the card overriding a layer of
// the nested button.
let card = self.node(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Card");
let card = self.node(page, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, "Card");
// A grid whose second cell holds the nested button.
self.doc.node_mut(card).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 1, ..AutoLayout::default() });
let nested = self.instance(comp, card, BTreeMap::new());
@@ -465,7 +465,7 @@ impl Gen {
let set = self.node(page, NodeKind::ComponentSet { description: "States".into() }, "Toggle");
let mut variants = Vec::new();
for name in ["State=Off", "State=On"] {
let v = self.node(set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name);
let v = self.node(set, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, name);
self.node(v, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Knob");
variants.push(v);
}
@@ -1120,7 +1120,17 @@ fn grid_track_sizes_round_trip() {
fn instance_grid_tracks_clear_the_mains_sizes() {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap();
let main = doc
.create(
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Main",
0.0,
0.0,
400.0,
200.0,
)
.unwrap();
let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap();
let layout = AutoLayout {
mode: LayoutMode::Grid,
@@ -1327,8 +1337,17 @@ fn set_with_properties() -> (Document, NodeId) {
}
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let glyph =
doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Glyph", 400.0, 0.0, 16.0, 16.0).unwrap();
let glyph = doc
.create(
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Glyph",
400.0,
0.0,
16.0,
16.0,
)
.unwrap();
doc.create(glyph, NodeKind::Rectangle, "Shape", 0.0, 0.0, 16.0, 16.0).unwrap();
let set = doc.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 300.0, 120.0).unwrap();
let mut variants = Vec::new();
@@ -1337,7 +1356,9 @@ fn set_with_properties() -> (Document, NodeId) {
("Label".to_string(), PropDef::Text { default: "Button".into() }),
("Show icon".to_string(), PropDef::Boolean { default: true }),
]);
let v = doc.create(set, NodeKind::Component { description: String::new(), props }, name, 0.0, 60.0 * i as f64, 120.0, 40.0).unwrap();
let v = doc
.create(set, NodeKind::Component { description: String::new(), props, prop_order: Vec::new() }, name, 0.0, 60.0 * i as f64, 120.0, 40.0)
.unwrap();
let label = doc.create(v, NodeKind::Text(TextData::new("Button")), "Label", 0.0, 0.0, 60.0, 20.0).unwrap();
doc.node_mut(label).unwrap().prop_refs.insert("characters".into(), "Label".into());
// The icon is an instance placed in the variant: like the editor's, its `source` is its
@@ -1466,7 +1487,17 @@ fn set_properties_round_trip() {
fn an_instance_without_its_mains_maximum_size_overrides_it_with_infinity() {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Box", 0.0, 0.0, 100.0, 40.0).unwrap();
let main = doc
.create(
page,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Box",
0.0,
0.0,
100.0,
40.0,
)
.unwrap();
doc.node_mut(main).unwrap().child_layout.max_w = Some(300.0);
doc.create(page, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Box", 0.0, 100.0, 100.0, 40.0).unwrap();
let (bytes, _) = export(&doc, None).unwrap();
+11 -2
View File
@@ -15,8 +15,17 @@ use super::*;
fn large(screens: usize) -> Document {
let mut d = Document::new("Large");
let page = d.first_page().unwrap();
let button =
d.create(page, NodeKind::Component { description: "A button".into(), props: Default::default() }, "Button", 0.0, -80.0, 96.0, 32.0).unwrap();
let button = d
.create(
page,
NodeKind::Component { description: "A button".into(), props: Default::default(), prop_order: Vec::new() },
"Button",
0.0,
-80.0,
96.0,
32.0,
)
.unwrap();
let label = d.create(button, NodeKind::Text(TextData::new("OK")), "Label", 8.0, 8.0, 40.0, 16.0).unwrap();
d.get_mut(label).unwrap().plugin_data.insert("agent".into(), "note".into());
for s in 0..screens {
+11 -1
View File
@@ -19,7 +19,17 @@ fn png() -> Vec<u8> {
fn published() -> (Document, figcraft_doc::NodeId, String) {
let mut d = Document::new("Kit");
let p = d.first_page().unwrap();
let tile = d.create(p, NodeKind::Component { description: String::new(), props: Default::default() }, "Tile", 0.0, 0.0, 10.0, 10.0).unwrap();
let tile = d
.create(
p,
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
"Tile",
0.0,
0.0,
10.0,
10.0,
)
.unwrap();
let (hash, _, _) = add_image(&mut d, png()).unwrap();
d.get_mut(tile).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)];
let snap = d.clone();
+1 -1
View File
@@ -2970,7 +2970,7 @@ fn layer_kind(
let mut path_box = None;
let mut vector_source = None;
let kind = match typ {
"frame" if is_master => NodeKind::Component { description: String::new(), props: BTreeMap::new() },
"frame" if is_master => NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"frame" if v.get("isVariantContainer").and_then(Value::as_bool) == Some(true) => NodeKind::ComponentSet { description: String::new() },
"frame" => NodeKind::Frame,
"group" => NodeKind::Group,
+20 -2
View File
@@ -451,7 +451,16 @@ fn unchanged_penpot_text_and_file_header_are_written_back() {
fn components_instances_and_variants_round_trip() {
let mut doc = Document::new("Library");
let p = page(&doc);
let main = add(&mut doc, p, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Button", 0.0, 0.0, 120.0, 40.0);
let main = add(
&mut doc,
p,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
"Button",
0.0,
0.0,
120.0,
40.0,
);
let label = add(&mut doc, main, NodeKind::Rectangle, "Fill", 0.0, 0.0, 120.0, 40.0);
doc.node_mut(label).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let copy = add(&mut doc, p, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Button copy", 0.0, 100.0, 120.0, 40.0);
@@ -465,7 +474,16 @@ fn components_instances_and_variants_round_trip() {
doc.node_mut(copy).unwrap().source = Some(main);
let set = add(&mut doc, p, NodeKind::ComponentSet { description: String::new() }, "Size", 300.0, 0.0, 200.0, 100.0);
for (name, y) in [("size=small", 0.0), ("size=large", 50.0)] {
add(&mut doc, set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name, 0.0, y, 100.0, 40.0);
add(
&mut doc,
set,
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
name,
0.0,
y,
100.0,
40.0,
);
}
let (back, e, _) = roundtrip(&doc);
+1 -1
View File
@@ -613,7 +613,7 @@ impl<'a> Ctx<'a> {
},
},
"TEXT" => NodeKind::Text(text::text_data(self, j)),
"COMPONENT" => NodeKind::Component { description: description(&self.component_meta, j), props: BTreeMap::new() },
"COMPONENT" => NodeKind::Component { description: description(&self.component_meta, j), props: BTreeMap::new(), prop_order: Vec::new() },
"COMPONENT_SET" => NodeKind::ComponentSet { description: description(&self.set_meta, j) },
"SLICE" => NodeKind::Slice,
_ => return None,
+2 -2
View File
@@ -469,7 +469,7 @@ fn components_sets_instances_and_properties() {
assert_eq!(set.stroke.dashes, vec![10.0, 5.0]);
let def = named(&doc, "State=Default");
let icon = named(&doc, "Icon");
let NodeKind::Component { description, props } = &def.kind else { panic!() };
let NodeKind::Component { description, props, .. } = &def.kind else { panic!() };
assert_eq!(description, "Primary button");
assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "OK".into() }), "{props:?}");
assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: icon.id, preferred: Vec::new() }));
@@ -477,7 +477,7 @@ fn components_sets_instances_and_properties() {
let NodeKind::Component { props: hover_props, .. } = &named(&doc, "State=Hover").kind else { panic!() };
assert!(hover_props.contains_key("Label"), "variants share the set's definitions");
assert!(
matches!(&icon.kind, NodeKind::Component { description, props } if description == "An icon" && props.get("Show") == Some(&PropDef::Boolean { default: true }))
matches!(&icon.kind, NodeKind::Component { description, props, .. } if description == "An icon" && props.get("Show") == Some(&PropDef::Boolean { default: true }))
);
let label = doc.node(def.children[0]).unwrap();
assert_eq!(label.prop_refs.get("characters").map(String::as_str), Some("Label"));