mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 22:36:51 -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:
@@ -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