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
+1 -1
View File
@@ -102,7 +102,7 @@ fn pages_add_reorder_and_delete() {
fn kind_changes_and_transforms_round_trip() {
let (a, _, frame, kids) = setup();
let mut b = a.document().clone();
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new() };
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new(), prop_order: Vec::new() };
b.get_mut(kids[0]).unwrap().kind = NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.25 };
b.get_mut(kids[0]).unwrap().transform = figcraft_geom::Affine::new([0.5, 0.1, -0.1, 2.0, 1.0 / 3.0, -7.25]);
round_trip(&a, &b);
+15
View File
@@ -65,6 +65,18 @@ pub enum PropDef {
},
}
/// A component's properties in the order they show: the names in `order` first (as they were
/// made; renaming keeps the place), then any others by name (files from before the order was kept).
pub fn ordered_props<'a>(props: &'a BTreeMap<String, PropDef>, order: &[String]) -> Vec<(&'a String, &'a PropDef)> {
let mut out: Vec<(&String, &PropDef)> = Vec::with_capacity(props.len());
for (k, d) in order.iter().filter_map(|k| props.get_key_value(k)).chain(props.iter()) {
if !out.iter().any(|(o, _)| *o == k) {
out.push((k, d));
}
}
out
}
/// What a node is, with per-type data.
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
// Page's guides moved to the node (#1476).
@@ -115,6 +127,9 @@ pub enum NodeKind {
description: String,
#[serde(default)]
props: BTreeMap<String, PropDef>,
/// The order `props` show in, as they were made: see [`ordered_props`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
prop_order: Vec<String>,
},
ComponentSet {
#[serde(default)]
+10
View File
@@ -284,6 +284,16 @@ pub fn default_props(comp: &Node) -> BTreeMap<String, Value> {
out
}
/// Component `c`'s property definitions, in the order they show ([`figcraft_doc::ordered_props`]).
pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
match doc.node(c).map(|n| &n.kind) {
Some(NodeKind::Component { props, prop_order, .. }) => {
figcraft_doc::ordered_props(props, prop_order).into_iter().map(|(k, d)| (k.clone(), d.clone())).collect()
}
_ => Vec::new(),
}
}
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
@@ -7,7 +7,7 @@ fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId
}
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, name)
}
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
+1 -1
View File
@@ -288,7 +288,7 @@ fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>)
o.insert("componentProperties".into(), json!(props));
}
}
NodeKind::Component { description, props } => {
NodeKind::Component { description, props, .. } => {
if !description.is_empty() {
o.insert("description".into(), json!(description));
}
+1 -1
View File
@@ -342,7 +342,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let n = doc.get_mut(target)?;
let was_group = matches!(n.kind, NodeKind::Group);
n.kind = NodeKind::Component { description: String::new(), props: Default::default() };
n.kind = NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() };
if was_group {
n.fills.clear();
n.clip = false;
+1 -1
View File
@@ -331,7 +331,7 @@ pub(super) fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
let Some(mut n) = doc.node(*d).cloned() else { continue };
if *d == i {
let defs = restore_prop_defs(doc, &props);
n.kind = NodeKind::Component { description: String::new(), props: defs };
n.kind = NodeKind::Component { description: String::new(), props: defs, prop_order: Vec::new() };
n.source = None;
n.overrides.clear();
n.prop_refs.clear();
+24 -9
View File
@@ -1,3 +1,5 @@
use figcraft_doc::ordered_props;
use super::instance::preferred_arg;
use super::variants::{fit_set, wrap_in_set};
use super::{
@@ -6,6 +8,7 @@ use super::{
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
variant_options, variant_props, variant_set,
};
use crate::components::prop_list;
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
/// boolean property takes a boolean variable, a text or variant property a string one).
@@ -195,9 +198,22 @@ pub(super) fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap<String, PropDef>
}
}
fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap<String, PropDef>)) -> Result<()> {
if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind {
/// Change component `c`'s property definitions with `f`, keeping their order: the property
/// `rename` names (old, new) keeps its place, new ones go last.
fn set_prop_defs(doc: &mut Document, c: NodeId, rename: Option<(&str, &str)>, f: impl FnOnce(&mut BTreeMap<String, PropDef>)) -> Result<()> {
if let NodeKind::Component { props, prop_order, .. } = &mut doc.get_mut(c)?.kind {
let mut order: Vec<String> = ordered_props(props, prop_order)
.into_iter()
.map(|(k, _)| match rename {
Some((old, new)) if k == old => new.to_string(),
_ => k.clone(),
})
.collect();
f(props);
order.retain(|k| props.contains_key(k));
let added: Vec<String> = props.keys().filter(|k| !order.contains(k)).cloned().collect();
order.extend(added);
*prop_order = order;
}
Ok(())
}
@@ -280,7 +296,7 @@ pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Valu
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")),
};
for c in &comps {
set_prop_defs(doc, *c, |m| {
set_prop_defs(doc, *c, None, |m| {
m.insert(name.clone(), def.clone());
})?;
// Existing instances get the default.
@@ -508,10 +524,9 @@ pub(super) fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
}
let key = new_name.clone().unwrap_or(name.clone());
let nd = new_def.clone();
let old = name.clone();
set_prop_defs(doc, *c, move |m| {
m.remove(&old);
m.insert(key, nd);
set_prop_defs(doc, *c, Some((&name, &key)), |m| {
m.remove(&name);
m.insert(key.clone(), nd);
})?;
if let Some(nn) = &new_name {
rename_prop_refs(doc, *c, &name, Some(nn))?;
@@ -547,7 +562,7 @@ pub(super) fn delete_prop(s: &mut Session, p: &Value) -> Result<Value> {
for c in &comps {
if prop_defs(doc, *c).contains_key(&name) {
found = true;
set_prop_defs(doc, *c, |m| {
set_prop_defs(doc, *c, None, |m| {
m.remove(&name);
})?;
rename_prop_refs(doc, *c, &name, None)?;
@@ -591,7 +606,7 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
}
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
for (k, d) in prop_defs(doc, comp) {
for (k, d) in prop_list(doc, comp) {
let mut v = def_json(&k, &d);
if !takes_value(&d) {
let layers: Vec<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
+1 -1
View File
@@ -173,7 +173,7 @@ pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
"text" => (NodeKind::Text(TextData::default()), "Text"),
"frame" => (NodeKind::Frame, "Frame"),
"section" => (NodeKind::Section { contents_hidden: false }, "Section"),
"component" => (NodeKind::Component { description: String::new(), props: Default::default() }, "Component"),
"component" => (NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() }, "Component"),
"group" => (NodeKind::Group, "Group"),
"slice" => (NodeKind::Slice, "Slice"),
_ => return None,
@@ -31,7 +31,15 @@ fn cached_import() -> Document {
let page = doc.first_page().unwrap();
let store = doc.ensure_library_store();
let main = doc
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
.create(
store,
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
"Cached main",
0.0,
0.0,
20.0,
20.0,
)
.unwrap();
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
let inst = doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
@@ -66,7 +66,8 @@ fn invalid_flags_and_non_frames_keep_the_default_thumbnail() {
choose(&mut s, wide);
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle;
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails");
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() };
s.doc_mut().unwrap().get_mut(wide).unwrap().kind =
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() };
assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame");
}
+9 -2
View File
@@ -82,8 +82,15 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
let page = doc.first_page().ok_or(figcraft_doc::DocError::Invalid("new document has no page".into()))?;
let layers = layers.clamp(10, 2_000_000);
// The shared button component (3 layers), above the screens.
let button =
doc.create(page, NodeKind::Component { description: String::new(), props: Default::default() }, "Button", 0.0, -120.0, 96.0, 32.0)?;
let button = doc.create(
page,
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
"Button",
0.0,
-120.0,
96.0,
32.0,
)?;
let bg = doc.create(button, NodeKind::Rectangle, "Background", 0.0, 0.0, 96.0, 32.0)?;
fill(&mut doc, bg, Color::from_rgb8_const(0x0D, 0x99, 0xFF));
if let Some(n) = doc.node_mut(bg) {
+1 -1
View File
@@ -118,7 +118,7 @@ fn var_id(name: &str) -> String {
}
fn component() -> NodeKind {
NodeKind::Component { description: String::new(), props: BTreeMap::new() }
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }
}
fn text(chars: &str) -> NodeKind {
+2
View File
@@ -427,6 +427,8 @@ pub(crate) mod plugins_tests;
#[cfg(test)]
mod progressive_blur_tests;
#[cfg(test)]
mod prop_order_tests;
#[cfg(test)]
mod property_copy_tests;
#[cfg(test)]
mod proto_behaviour_tests;
+3 -2
View File
@@ -83,7 +83,8 @@ fn node_find_other_page_requires_root_and_does_not_switch_pages() {
fn node_find_instances_match_main_name_and_visit_actual_children_only() {
let mut s = Session::new();
let page = s.page().unwrap();
let main = add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default() }, "CAFÉ [Button]");
let main =
add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() }, "CAFÉ [Button]");
let original = add(&mut s, main, NodeKind::Text(TextData::new("Original")), "Original");
let inst = idv(&ex(&mut s, "instance.create", json!({"component": main})));
let child = s.doc().unwrap().get(inst).unwrap().children[0];
@@ -116,7 +117,7 @@ fn node_find_accepts_every_canonical_type() {
NodeKind::Vector { network: Default::default() },
NodeKind::Text(TextData::new("Text")),
NodeKind::BooleanOp { op: BoolOp::Union },
NodeKind::Component { description: String::new(), props: Default::default() },
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
NodeKind::ComponentSet { description: String::new() },
NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() },
NodeKind::Slice,
+135
View File
@@ -0,0 +1,135 @@
//! Component properties keep the order they were made in: renaming one never moves it (#1536),
//! and Make slot takes the name it is given (#1537).
use figcraft_doc::{NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn idv(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// The property names `component.properties` lists for `id`, in order.
fn names(s: &mut Session, id: NodeId) -> Vec<String> {
ex(s, "component.properties", json!({"id": id}))["properties"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap().to_string())
.collect()
}
fn rename(s: &mut Session, id: NodeId, name: &str, new_name: &str) {
ex(s, "component.editProperty", json!({"id": id, "name": name, "newName": new_name}));
}
/// A "Card" component with five frames made slots one by one ("Slot 1" … "Slot 5"). Returns
/// (card, frames).
fn card_with_slots(s: &mut Session) -> (NodeId, Vec<NodeId>) {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 100, "name": "Card"})));
let frames: Vec<NodeId> = (1..=5)
.map(|k| {
idv(&ex(s, "frame.create", json!({"x": (k - 1) * 100, "y": 0, "width": 90, "height": 90, "parent": f, "name": format!("Slot {k}")})))
})
.collect();
let card = idv(&ex(s, "component.create", json!({"ids": [f]})));
for fr in &frames {
ex(s, "component.makeSlot", json!({"id": fr}));
}
(card, frames)
}
#[test]
fn renaming_a_slot_keeps_its_place() {
let mut s = Session::new();
let (card, _) = card_with_slots(&mut s);
assert_eq!(names(&mut s, card), ["Slot 1", "Slot 2", "Slot 3", "Slot 4", "Slot 5"]);
rename(&mut s, card, "Slot 1", "Icon 1");
rename(&mut s, card, "Slot 2", "Icon 2");
rename(&mut s, card, "Icon 1", "Icone 1");
assert_eq!(names(&mut s, card), ["Icone 1", "Icon 2", "Slot 3", "Slot 4", "Slot 5"], "the issue's steps");
// Renamed to sort first by name, the last one still stays last.
rename(&mut s, card, "Slot 5", "A");
assert_eq!(names(&mut s, card), ["Icone 1", "Icon 2", "Slot 3", "Slot 4", "A"]);
// Undo puts the old name back in its place; a deleted property leaves the others in order.
s.undo().unwrap();
assert_eq!(names(&mut s, card), ["Icone 1", "Icon 2", "Slot 3", "Slot 4", "Slot 5"]);
ex(&mut s, "component.deleteProperty", json!({"id": card, "name": "Icon 2"}));
assert_eq!(names(&mut s, card), ["Icone 1", "Slot 3", "Slot 4", "Slot 5"]);
// The order is saved with the file.
let saved = serde_json::to_string(s.doc().unwrap()).unwrap();
let back: figcraft_doc::Document = serde_json::from_str(&saved).unwrap();
assert_eq!(back.node(card).map(|n| &n.kind), s.doc().unwrap().node(card).map(|n| &n.kind));
}
#[test]
fn every_kind_of_property_keeps_its_place_on_rename() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Size=Small"})));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Go", "x": 10, "y": 10, "parent": f})));
let body = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 90, "height": 90, "parent": f, "name": "Body"})));
let icon_f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 20, "height": 20, "name": "Icon"})));
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_f]})));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": icon, "x": 60, "y": 10, "parent": f})));
let small = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
// Made in an order that isn't by name: Zoom (boolean), Label (text), Glyph (instance swap), Body (slot).
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean"}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Label", "type": "text", "layer": label}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Glyph", "type": "instanceSwap", "layer": nested}));
ex(&mut s, "component.makeSlot", json!({"id": body}));
assert_eq!(names(&mut s, small), ["Zoom", "Label", "Glyph", "Body"]);
for (old, new) in [("Zoom", "A zoom"), ("Label", "B label"), ("Glyph", "C glyph"), ("Body", "D body")] {
rename(&mut s, small, old, new);
}
assert_eq!(names(&mut s, small), ["A zoom", "B label", "C glyph", "D body"]);
rename(&mut s, small, "A zoom", "Zz");
assert_eq!(names(&mut s, small), ["Zz", "B label", "C glyph", "D body"]);
// In a set the shared properties keep their order on every variant, after the variant ones
// (which keep theirs when renamed too).
ex(&mut s, "component.addVariant", json!({"id": small, "property": "Size", "value": "Large"}));
let set = s.doc().unwrap().node(small).and_then(|n| n.parent).unwrap();
ex(&mut s, "component.addProperty", json!({"id": set, "name": "State", "type": "variant", "default": "Rest"}));
assert_eq!(names(&mut s, set), ["Size", "State", "Zz", "B label", "C glyph", "D body"]);
rename(&mut s, set, "Size", "Width");
rename(&mut s, set, "C glyph", "Alpha");
assert_eq!(names(&mut s, set), ["Width", "State", "Zz", "B label", "Alpha", "D body"]);
let variants: Vec<NodeId> = s.doc().unwrap().node(set).unwrap().children.clone();
for v in variants {
assert_eq!(names(&mut s, v), ["Width", "State", "Zz", "B label", "Alpha", "D body"]);
}
// An instance lists them in the same order.
let inst = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
assert_eq!(names(&mut s, inst), ["Width", "State", "Zz", "B label", "Alpha", "D body"]);
}
#[test]
fn make_slot_takes_the_name_typed() {
let mut s = Session::new();
let (card, frames) = card_with_slots(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": card, "name": "Frame 12"})));
let r = ex(&mut s, "component.makeSlot", json!({"id": f, "name": " Avatar "}));
assert_eq!(r["name"], "Avatar");
assert_eq!(crate::components::slot_name(s.doc().unwrap(), f).as_deref(), Some("Avatar"));
assert_eq!(names(&mut s, card).last().map(String::as_str), Some("Avatar"), "a new slot goes last");
// A name taken, or one a property can't have, is refused.
for bad in ["Slot 1", "a=b", ""] {
let other = frames[4];
ex(&mut s, "component.removeSlot", json!({"id": other}));
assert!(s.execute("component.makeSlot", &json!({"id": other, "name": bad})).is_err(), "{bad:?}");
ex(&mut s, "component.makeSlot", json!({"id": other, "name": "Slot 5"}));
}
// Files from before the order was kept list their properties by name.
let doc = s.doc_mut().unwrap();
if let Some(NodeKind::Component { prop_order, .. }) = doc.node_mut(card).map(|n| &mut n.kind) {
prop_order.clear();
}
let mut by_name = names(&mut s, card);
let listed = by_name.clone();
by_name.sort();
assert_eq!(listed, by_name);
}
+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"));
+1
View File
@@ -808,6 +808,7 @@ status = "draft — needs native review"
"Main menu" = "القائمة الرئيسية"
"Make a component" = "إنشاء مكوّن"
"Make slot" = "إنشاء خانة محتوى"
"Slot name" = "اسم خانة المحتوى"
"Mar" = "مارس"
"Margin" = "الهامش"
"Mark all as read" = "تحديد الكل كمقروء"
+1
View File
@@ -776,6 +776,7 @@ status = "draft — needs native review"
"Main menu" = "প্রধান মেনু"
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
"Make slot" = "স্লট তৈরি করুন"
"Slot name" = "স্লটের নাম"
"Mar" = "মার্চ"
"Margin" = "মার্জিন"
"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন"
+1
View File
@@ -761,6 +761,7 @@ status = "draft — needs native review"
"Main menu" = "Hlavní nabídka"
"Make a component" = "Vytvořit komponentu"
"Make slot" = "Vytvořit slot"
"Slot name" = "Název slotu"
"Mar" = "března"
"Margin" = "Vnější okraj"
"Mark all as read" = "Označit vše jako přečtené"
+1
View File
@@ -740,6 +740,7 @@ status = "draft — needs native review"
"Main component deleted" = "Hauptkomponente gelöscht"
"Main menu" = "Hauptmenü"
"Make slot" = "Slot erstellen"
"Slot name" = "Slot-Name"
"Margin" = "Rand"
"Mark all as read" = "Alle als gelesen markieren"
"Mark as resolved" = "Als erledigt markieren"
+1
View File
@@ -813,6 +813,7 @@ name = "English"
"Main menu" = "Main menu"
"Make a component" = "Make a component"
"Make slot" = "Make slot"
"Slot name" = "Slot name"
"Mar" = "Mar"
"Margin" = "Margin"
"Mark all as read" = "Mark all as read"
+1
View File
@@ -801,6 +801,7 @@ status = "draft — needs native review"
"Main menu" = "Menú principal"
"Make a component" = "Crea un componente"
"Make slot" = "Convertir en ranura"
"Slot name" = "Nombre de la ranura"
"Mar" = "mar"
"Margin" = "Margen"
"Mark all as read" = "Marcar todo como leído"
+1
View File
@@ -778,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Päävalikko"
"Make a component" = "Luo komponentti"
"Make slot" = "Luo sisältöpaikka"
"Slot name" = "Sisältöpaikan nimi"
"Mar" = "maalisk."
"Margin" = "Marginaali"
"Mark all as read" = "Merkitse kaikki luetuiksi"
+1
View File
@@ -778,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Menu principal"
"Make a component" = "Créer un composant"
"Make slot" = "Créer un emplacement"
"Slot name" = "Nom de l'emplacement"
"Mar" = "mars"
"Margin" = "Marge"
"Mark all as read" = "Tout marquer comme lu"
+1
View File
@@ -768,6 +768,7 @@ status = "draft — needs native review"
"Main menu" = "Menu utama"
"Make a component" = "Buat component"
"Make slot" = "Jadikan slot"
"Slot name" = "Nama slot"
"Mar" = "Mar"
"Margin" = "Margin"
"Mark all as read" = "Tandai semua sudah dibaca"
+1
View File
@@ -751,6 +751,7 @@ status = "draft — needs native review"
"Main menu" = "Menu principale"
"Make a component" = "Crea un componente"
"Make slot" = "Crea slot"
"Slot name" = "Nome dello slot"
"Mar" = "mar"
"Margin" = "Margine"
"Mark all as read" = "Segna tutto come letto"
+1
View File
@@ -772,6 +772,7 @@ status = "draft — needs native review"
"Main menu" = "メインメニュー"
"Make a component" = "コンポーネントを作成"
"Make slot" = "スロットにする"
"Slot name" = "スロット名"
"Mar" = "3月"
"Margin" = "マージン"
"Mark all as read" = "すべて既読にする"
+1
View File
@@ -785,6 +785,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Main menu" = "მთავარი მენიუ"
"Make a component" = "შექმენით კომპონენტი"
"Make slot" = "სლოტად გადაქცევა"
"Slot name" = "სლოტის სახელი"
"Mar" = "მარ"
"Margin" = "ველი"
"Mark all as read" = "ყველას წაკითხულად მარკირება"
+1
View File
@@ -736,6 +736,7 @@ status = "draft — needs native review"
"Main component deleted" = "메인 컴포넌트가 삭제됨"
"Main menu" = "메인 메뉴"
"Make slot" = "슬롯으로 만들기"
"Slot name" = "슬롯 이름"
"Margin" = "여백"
"Mark all as read" = "모두 읽음으로 표시"
"Mark as resolved" = "해결됨으로 표시"
+1
View File
@@ -778,6 +778,7 @@ status = "draft — needs native review"
"Main menu" = "Hoofdmenu"
"Make a component" = "Maak een component"
"Make slot" = "Slot maken"
"Slot name" = "Slotnaam"
"Mar" = "mrt."
"Margin" = "Marge"
"Mark all as read" = "Markeer alles als gelezen"
+1
View File
@@ -780,6 +780,7 @@ name = "Polski"
"Main menu" = "Menu główne"
"Make a component" = "Utwórz komponent"
"Make slot" = "Utwórz gniazdo (slot)"
"Slot name" = "Nazwa gniazda"
"Mar" = "Mar"
"Margin" = "Margines"
"Mark all as read" = "Oznacz wszystkie jako przeczytane"
+1
View File
@@ -735,6 +735,7 @@ status = "draft — needs native review"
"Main component deleted" = "Componente principal excluído"
"Main menu" = "Menu principal"
"Make slot" = "Transformar em slot"
"Slot name" = "Nome do slot"
"Margin" = "Margem"
"Mark all as read" = "Marcar tudo como lido"
"Mark as resolved" = "Marcar como resolvido"
+1
View File
@@ -780,6 +780,7 @@ name = "Русский"
"Main menu" = "Главное меню"
"Make a component" = "Создать компонент"
"Make slot" = "Сделать слотом"
"Slot name" = "Имя слота"
"Mar" = "мар."
"Margin" = "Внешний отступ"
"Mark all as read" = "Отметить всё как прочитанное"
+1
View File
@@ -802,6 +802,7 @@ name = "Svenska"
"Main menu" = "Huvudmeny"
"Make a component" = "Skapa en komponent"
"Make slot" = "Gör till plats"
"Slot name" = "Platsens namn"
"Mar" = "Mar"
"Margin" = "Marginal"
"Mark all as read" = "Märk alla som lästa"
+1
View File
@@ -774,6 +774,7 @@ status = "draft — needs native review"
"Main menu" = "เมนูหลัก"
"Make a component" = "สร้างคอมโพเนนต์"
"Make slot" = "สร้างสล็อต"
"Slot name" = "ชื่อสล็อต"
"Mar" = "มี.ค."
"Margin" = "ระยะขอบนอก"
"Mark all as read" = "ทำเครื่องหมายว่าอ่านทั้งหมดแล้ว"
+1
View File
@@ -765,6 +765,7 @@ name = "Türkçe"
"Main menu" = "Ana menü"
"Make a component" = "Bileşen yap"
"Make slot" = "Yuva yap"
"Slot name" = "Yuva adı"
"Mar" = "Mar"
"Margin" = "Kenar boşluğu"
"Mark all as read" = "Tümünü okundu olarak işaretle"
+1
View File
@@ -752,6 +752,7 @@ status = "draft — needs native review"
"Main menu" = "Trình đơn chính"
"Make a component" = "Tạo thành phần"
"Make slot" = "Tạo ô chứa"
"Slot name" = "Tên ô chứa"
"Mar" = "Thg 3"
"Margin" = "Lề"
"Mark all as read" = "Đánh dấu tất cả là đã đọc"
+1
View File
@@ -741,6 +741,7 @@ status = "draft — needs native review"
"Main component deleted" = "主组件已删除"
"Main menu" = "主菜单"
"Make slot" = "设为插槽"
"Slot name" = "插槽名称"
"Margin" = "边距"
"Mark all as read" = "全部标为已读"
"Mark as resolved" = "标为已解决"
+2
View File
@@ -137,6 +137,8 @@ mod live_text_tests;
#[cfg(test)]
mod local_fonts_tests;
#[cfg(test)]
mod make_slot_naming_tests;
#[cfg(test)]
mod measure_nudge_tests;
#[cfg(test)]
mod menu_aim_tests;
@@ -0,0 +1,83 @@
//! Make slot asks for the slot's name first, driven through egui headless (#1537): picking it in
//! the Slot menu of a frame inside a component shows a name field with the frame's name selected;
//! what is typed names the slot on Enter, and Escape makes nothing.
use egui::Event;
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use figcraft_engine::components::slot_name;
use serde_json::json;
use crate::component_swap_default_tests::{centre, click, tree};
use crate::layers_drag_tests::Ui;
/// A "Card" component holding a selected frame "Frame 12". Returns (ui, frame).
fn setup() -> (Ui, NodeId) {
let mut s = Session::new();
let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap());
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Card"})).unwrap());
let frame = id(s.execute("frame.create", &json!({"x": 10, "y": 50, "width": 200, "height": 100, "parent": card, "name": "Frame 12"})).unwrap());
s.execute("component.create", &json!({"ids": [card]})).unwrap();
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
let u = Ui::new(s);
u.ctx.enable_accesskit();
(u, frame)
}
fn key(key: egui::Key) -> Event {
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
/// Open the frame's Slot menu and pick Make slot; returns the name field's value then.
fn pick_make_slot(u: &mut Ui) -> Option<String> {
let combo = tree(u).into_iter().find(|n| n.label() == Some("Slot") && n.value() == Some("Not bound")).expect("the frame has a Slot row");
click(u, centre(&combo));
let item = tree(u).into_iter().find(|n| n.label() == Some("Make slot")).expect("the Slot menu offers Make slot");
click(u, centre(&item));
u.frame(vec![]);
u.frame(vec![]);
tree(u).into_iter().find(|n| n.label() == Some("Slot name")).and_then(|n| n.value().map(str::to_string))
}
fn slot_of(u: &Ui, frame: NodeId) -> Option<String> {
slot_name(u.app.session.doc().unwrap(), frame)
}
#[test]
fn make_slot_names_the_slot_with_what_is_typed() {
let (mut u, frame) = setup();
assert_eq!(pick_make_slot(&mut u).as_deref(), Some("Frame 12"), "the field suggests the frame's name");
assert_eq!(slot_of(&u, frame), None, "nothing is made before the name is confirmed");
// The suggestion is selected: typing replaces it.
u.frame(vec![Event::Text("Avatar".into())]);
u.frame(vec![key(egui::Key::Enter)]);
u.frame(vec![]);
assert_eq!(slot_of(&u, frame).as_deref(), Some("Avatar"));
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
assert!(tree(&mut u).iter().all(|n| n.label() != Some("Slot name")), "the naming row closes");
}
#[test]
fn escape_cancels_make_slot() {
let (mut u, frame) = setup();
assert!(pick_make_slot(&mut u).is_some());
u.frame(vec![key(egui::Key::Escape)]);
u.frame(vec![]);
assert_eq!(slot_of(&u, frame), None);
assert!(tree(&mut u).iter().all(|n| n.label() != Some("Slot name")));
assert_eq!(u.app.session.ds().unwrap().selection, [frame], "the Escape only cancels the naming");
}
#[test]
fn a_name_taken_keeps_the_field_open() {
let (mut u, frame) = setup();
let card = u.app.session.doc().unwrap().node(frame).and_then(|n| n.parent).unwrap();
u.app.session.execute("component.addProperty", &json!({"id": card, "name": "Taken", "type": "boolean"})).unwrap();
assert!(pick_make_slot(&mut u).is_some());
u.frame(vec![Event::Text("Taken".into())]);
u.frame(vec![key(egui::Key::Enter)]);
u.frame(vec![]);
assert_eq!(slot_of(&u, frame), None);
let field = tree(&mut u).into_iter().find(|n| n.label() == Some("Slot name")).expect("the field stays to fix the name");
assert_eq!(field.value(), Some("Taken"));
}
+132 -77
View File
@@ -14,7 +14,7 @@ use egui::{Id, Sense, Ui, vec2};
use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType};
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,
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, prop_list, slot_def, slot_edited, slots_of, variant_conflicts,
variant_options, variant_props, variant_set,
};
use serde_json::{Value, json};
@@ -207,13 +207,6 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer
});
}
fn prop_defs(doc: &Document, c: NodeId) -> std::collections::BTreeMap<String, PropDef> {
match doc.node(c).map(|n| &n.kind) {
Some(NodeKind::Component { props, .. }) => props.clone(),
_ => Default::default(),
}
}
/// Properties of a component or component set.
fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
let t = Tokens::get(ui.ctx());
@@ -224,11 +217,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
} else {
Some(owner)
};
let defs = first.map(|c| prop_defs(doc, c)).unwrap_or_default();
let defs = first.map(|c| prop_list(doc, c)).unwrap_or_default();
let variants = if is_set { variant_options(doc, owner) } else { Vec::new() };
let comps = local_components(app, ui, doc);
widgets::separator(ui);
let existing: Vec<String> = defs.keys().cloned().chain(variants.iter().map(|(k, _)| k.clone())).collect();
let existing: Vec<String> = defs.iter().map(|(k, _)| k.clone()).chain(variants.iter().map(|(k, _)| k.clone())).collect();
let fresh = (1..).map(|k| format!("Property {k}")).find(|n| !existing.contains(n)).unwrap_or_else(|| "Property".into());
let plus = header_with_plus(ui, tr("Properties"), tr("Create component property"));
menu_popup(&plus).width(180.0).show(|ui| {
@@ -396,9 +389,24 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
let t = Tokens::get(ui.ctx());
let Some(comp) = owning_component(doc, n.id) else { return };
let defs = prop_defs(doc, comp);
// The English label also names a new property (document content); it shows translated.
let mut fields: Vec<(&str, &str, &str)> = vec![("visible", tr_noop("Visibility"), "boolean")];
let defs = prop_list(doc, comp);
widgets::separator(ui);
row(ui, 36.0, |ui| {
ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text));
});
for field in bind_fields(n) {
bind_row(app, ui, n, &defs, field);
}
slot_name_row(app, ui, n);
}
/// A field of a layer a component property can control: (field, label, property type). The
/// English label also names a new property (document content); it shows translated.
type BindField = (&'static str, &'static str, &'static str);
/// The fields of layer `n` that component properties can control.
fn bind_fields(n: &Node) -> Vec<BindField> {
let mut fields: Vec<BindField> = vec![("visible", tr_noop("Visibility"), "boolean")];
if n.kind.text().is_some() {
fields.push(("characters", tr_noop("Text"), "text"));
}
@@ -408,71 +416,118 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
if matches!(n.kind, NodeKind::Frame) {
fields.push(("slot", tr_noop("Slot"), "slot"));
}
widgets::separator(ui);
row(ui, 36.0, |ui| {
ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text));
fields
}
/// `base`, or `base 2`, `base 3`…: the first name no property in `defs` has.
fn fresh_name(base: &str, defs: &[(String, PropDef)]) -> String {
(1..).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|x| !defs.iter().any(|(k, _)| k == x)).unwrap_or_default()
}
/// The row of one field of layer `n`: its icon, its label and a menu that binds it to a property
/// of type `ty` (or makes one).
fn bind_row(app: &mut FigApp, ui: &mut Ui, n: &Node, defs: &[(String, PropDef)], field: BindField) {
let t = Tokens::get(ui.ctx());
let (key, label, _) = field;
let bound = n.prop_refs.get(key).cloned();
row(ui, 32.0, |ui| {
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
let icon = match key {
"visible" => Icon::Eye,
"characters" => Icon::Text,
"slot" => Icon::Slot,
_ => Icon::Instance,
};
icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary });
widgets::label(ui, tr(label), t.text_secondary);
let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into());
let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property"));
crate::a11y::combo(&resp, tr(label), &shown, crate::a11y::menu_open(&resp));
menu_popup(&resp).width(180.0).show(|ui| bind_menu(app, ui, n, defs, field, bound.is_some()));
});
for (field, label, ty) in fields {
let matching: Vec<&String> = defs
.iter()
.filter(|(_, d)| {
matches!(
(ty, d),
("boolean", PropDef::Boolean { .. })
| ("text", PropDef::Text { .. })
| ("instanceSwap", PropDef::InstanceSwap { .. })
| ("slot", PropDef::Slot { .. })
)
})
.map(|(k, _)| k)
.collect();
let bound = n.prop_refs.get(field).cloned();
row(ui, 32.0, |ui| {
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
let icon = match field {
"visible" => Icon::Eye,
"characters" => Icon::Text,
"slot" => Icon::Slot,
_ => Icon::Instance,
};
icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary });
widgets::label(ui, tr(label), t.text_secondary);
let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into());
let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property"));
menu_popup(&resp).width(180.0).show(|ui| {
for k in &matching {
if menu_item(ui, k, "") {
app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": field}));
ui.close();
}
}
if ty == "slot" {
if bound.is_none() && menu_item(ui, tr("Make slot"), "") {
// Named after the frame.
app.run("component.makeSlot", json!({"id": n.id}));
ui.close();
}
} else if menu_item(ui, tr("Create property…"), "") {
let existing: Vec<String> = defs.keys().cloned().collect();
let name = (1..)
.map(|k| if k == 1 { label.to_string() } else { format!("{label} {k}") })
.find(|x| !existing.contains(x))
.unwrap_or_default();
app.run("component.addProperty", json!({"id": n.id, "name": name, "type": ty}));
ui.close();
}
if ty == "slot" {
if bound.is_some() && menu_item(ui, tr("Remove slot"), "") {
app.run("component.removeSlot", json!({"id": n.id}));
ui.close();
}
} else if bound.is_some() && menu_item(ui, tr("Detach property"), "") {
app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": field}));
ui.close();
}
});
});
}
/// The menu of a field's row ([`bind_row`]): the properties of its type, then making one (a slot
/// is named first, in [`slot_name_row`]) and unbinding (removing the slot).
fn bind_menu(app: &mut FigApp, ui: &mut Ui, n: &Node, defs: &[(String, PropDef)], (key, label, ty): BindField, bound: bool) {
let fits = |d: &PropDef| {
matches!(
(ty, d),
("boolean", PropDef::Boolean { .. })
| ("text", PropDef::Text { .. })
| ("instanceSwap", PropDef::InstanceSwap { .. })
| ("slot", PropDef::Slot { .. })
)
};
for (k, _) in defs.iter().filter(|(_, d)| fits(d)) {
if menu_item(ui, k, "") {
app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": key}));
ui.close();
}
}
let slot = ty == "slot";
if slot && !bound && menu_item(ui, tr("Make slot"), "") {
let base = if n.name.trim().is_empty() { label } else { n.name.as_str() };
ui.data_mut(|d| d.insert_temp(slot_naming_id(n.id), (fresh_name(base, defs), true)));
ui.close();
}
if !slot && menu_item(ui, tr("Create property…"), "") {
app.run("component.addProperty", json!({"id": n.id, "name": fresh_name(label, defs), "type": ty}));
ui.close();
}
if slot && bound && menu_item(ui, tr("Remove slot"), "") {
app.run("component.removeSlot", json!({"id": n.id}));
ui.close();
}
if !slot && bound && menu_item(ui, tr("Detach property"), "") {
app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": key}));
ui.close();
}
}
/// Where Make slot keeps the name being typed for frame `id` (and whether its field takes the
/// keyboard next frame).
fn slot_naming_id(id: NodeId) -> Id {
Id::unique(("make_slot_name", id))
}
/// Make slot's naming step, once it was picked for frame `n`: a name field that takes the keyboard
/// with the suggested name selected, and a Make slot button. Enter or the button makes the slot
/// with that name; Escape cancels.
fn slot_name_row(app: &mut FigApp, ui: &mut Ui, n: &Node) {
let id = slot_naming_id(n.id);
let Some((mut name, mut focus)) = ui.data(|d| d.get_temp::<(String, bool)>(id)) else { return };
let mut open = !n.prop_refs.contains_key("slot");
row(ui, 32.0, |ui| {
ui.add_space(22.0);
let w = (ui.available_width() - 96.0).max(60.0);
let field = id.with("field");
// Asked for until it has it: the click that picked Make slot takes the focus away again.
if focus && !crate::a11y::field_focused(ui.ctx()) {
ui.memory_mut(|m| m.request_focus(field));
widgets::select_all_next(ui, field, &name);
}
let r = widgets::text_input(ui, field, &mut name, w, tr("Slot name"));
crate::a11y::text(&r, tr("Slot name"), &name);
focus &= !r.has_focus();
let ok = !name.trim().is_empty();
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let make = widgets::button(ui, tr("Make slot"), true, ok).clicked() || (enter && ok);
// A refused name (one taken) keeps the field open to fix it.
if make && app.run("component.makeSlot", json!({"id": n.id, "name": name.trim()})).is_some() {
open = false;
}
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
open = false;
}
});
ui.data_mut(|d| {
if open {
d.insert_temp(id, (name, focus));
} else {
d.remove::<(String, bool)>(id);
}
});
}
/// Overridden property groups of a layer inside an instance, each with a reset button.
@@ -667,7 +722,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
}
}
// Boolean, text and instance-swap properties.
for (k, d) in prop_defs(doc, *component) {
for (k, d) in prop_list(doc, *component) {
let v = props.get(&k).cloned().unwrap_or_else(|| figcraft_engine::components::prop_default_value(&d));
row(ui, 32.0, |ui| {
prop_label(ui, &k, type_icon(&d));
+1 -1
View File
@@ -704,7 +704,7 @@ pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) {
/// with the editor already focused, which then gains no focus). Call it before adding the editor:
/// the text is selected at once, so a key typed in the frame the edit starts (the frame after a
/// double-click or F2 is often the one with the first key) replaces it.
fn select_all_next(ui: &Ui, te_id: Id, text: &str) {
pub(crate) fn select_all_next(ui: &Ui, te_id: Id, text: &str) {
ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true));
select_all(ui, te_id, text);
}