mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 21:36:46 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -808,6 +808,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "القائمة الرئيسية"
|
||||
"Make a component" = "إنشاء مكوّن"
|
||||
"Make slot" = "إنشاء خانة محتوى"
|
||||
"Slot name" = "اسم خانة المحتوى"
|
||||
"Mar" = "مارس"
|
||||
"Margin" = "الهامش"
|
||||
"Mark all as read" = "تحديد الكل كمقروء"
|
||||
|
||||
@@ -776,6 +776,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "প্রধান মেনু"
|
||||
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
|
||||
"Make slot" = "স্লট তৈরি করুন"
|
||||
"Slot name" = "স্লটের নাম"
|
||||
"Mar" = "মার্চ"
|
||||
"Margin" = "মার্জিন"
|
||||
"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন"
|
||||
|
||||
@@ -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é"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "すべて既読にする"
|
||||
|
||||
@@ -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" = "ყველას წაკითხულად მარკირება"
|
||||
|
||||
@@ -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" = "해결됨으로 표시"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -780,6 +780,7 @@ name = "Русский"
|
||||
"Main menu" = "Главное меню"
|
||||
"Make a component" = "Создать компонент"
|
||||
"Make slot" = "Сделать слотом"
|
||||
"Slot name" = "Имя слота"
|
||||
"Mar" = "мар."
|
||||
"Margin" = "Внешний отступ"
|
||||
"Mark all as read" = "Отметить всё как прочитанное"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -774,6 +774,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "เมนูหลัก"
|
||||
"Make a component" = "สร้างคอมโพเนนต์"
|
||||
"Make slot" = "สร้างสล็อต"
|
||||
"Slot name" = "ชื่อสล็อต"
|
||||
"Mar" = "มี.ค."
|
||||
"Margin" = "ระยะขอบนอก"
|
||||
"Mark all as read" = "ทำเครื่องหมายว่าอ่านทั้งหมดแล้ว"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "标为已解决"
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user