From 894df233b2a25310723b95549511e39cde90ffba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 21:10:46 +0000 Subject: [PATCH 01/26] Interface editor: typed create, delete and duplicate edits Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 23 ++ blockloom-app/src/shell.rs | 2 +- blockloom-app/tests/sync.rs | 3 + blockloom-core/src/ui_framework.rs | 229 ++++++++++++++++++ blockloom-qt/qml/UiDesigner.qml | 51 +++- .../tests/qml/tst_InterfaceViewport.qml | 26 ++ docs/interface-editor-plan.md | 20 +- 7 files changed, 340 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad5a8070..11df059e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3060,6 +3060,29 @@ close. In the Game view the editor holds it on the world's behalf (see Game view above). The one safety net is that showing a modal while the pointer is locked logs a warning, since a locked hidden cursor can't press anything. +### Interface editor + +Plan and progress: `docs/interface-editor-plan.md`. The Interface workspace +(`UiDesigner.qml`) draws no layout of its own: `preview_interface` runs the +document through the player's retained widget renderer in an idle design +session and the reply carries each widget's computed transform, size, clips, +visibility and paint order, tagged with a document revision and viewport +generation. QML picks and draws handles from that geometry only. + +Edits are `blockloom_core::ui_framework::UiEdit` (`apply_edit` validates and +swaps atomically): `Move`, `Resize`, `SetProperty` (canonical paths), +`Reparent` (free or flow), `Reorder`, `Create`, `Delete` and `Duplicate`. A +gesture is `begin_interface_edit` -> `update_interface_edit` (replaces the +draft, never saves) -> `commit_interface_edit` (one undo step) or +`cancel_interface_edit`; the same commands serve shell and MCP. `Create` +appends a widget (a Canvas parent forces it free, any other parent flow), +`Delete` removes the subtree and clears `scroll_target`s into it, `Duplicate` +copies the subtree after the original as `new_id` and `new_id.`, +remapping parents and `scroll_target`s inside the copy only. Blocks and +scripts that name the original ids are not rewritten. `set_interface` stays +the whole-document import path. A new edit shape needs the `update-interface-edit` +summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. + ### Android devices and the emulator The Devices tab (`DevicesPanel.qml`, `DevicePanel.qml`) lists what adb sees, diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 4395a395..c0dce1fd 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -962,7 +962,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position} or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. Property paths: element.kind/content/anchor/modal, layout (object or null). Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. Property paths: element.kind/content/anchor/modal, layout (object or null). Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-app/tests/sync.rs b/blockloom-app/tests/sync.rs index ef2ec746..b347773b 100644 --- a/blockloom-app/tests/sync.rs +++ b/blockloom-app/tests/sync.rs @@ -684,6 +684,9 @@ fn interface_property_and_parent_transactions_round_trip_and_undo() { json!({"kind": "Reparent", "id": "child", "parent": "", "placement": {"mode": "Free", "offset": [40,60], "size": [80,30]}}), json!({"kind": "SetProperty", "id": "child", "property": {"path": "layout", "value": null}}), json!({"kind": "Reorder", "id": "canvas", "index": 1}), + json!({"kind": "Create", "widget": {"element": {"id": "fresh", "parent": "canvas"}}}), + json!({"kind": "Duplicate", "id": "canvas", "new_id": "canvas2"}), + json!({"kind": "Delete", "id": "canvas"}), ] { let before = backend.dispatch("get_state", json!({})).unwrap()["project"]["world"]["interface"] diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index f525a5f8..651ca3f0 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -329,6 +329,22 @@ pub enum UiEdit { parent: String, placement: UiPlacement, }, + /// Appends a new widget; a Canvas parent makes it free, any other parent flow. + Create { + widget: Box, + }, + /// Removes a widget and its descendants. + Delete { + id: String, + }, + /// Copies a subtree after the original under fresh ids (`new_id` for the + /// root, `new_id.old` below it). `offset` moves a free-placed root. + Duplicate { + id: String, + new_id: String, + #[serde(default)] + offset: Option<[f32; 2]>, + }, Move { id: String, offset: [f32; 2], @@ -350,12 +366,23 @@ impl UiDocument { } fn apply_edit_inner(&mut self, edit: &UiEdit) -> Result<(), String> { + match edit { + UiEdit::Create { widget } => return self.create_widget(widget), + UiEdit::Delete { id } => return self.delete_subtree(id), + UiEdit::Duplicate { id, new_id, offset } => { + return self.duplicate_subtree(id, new_id, *offset); + } + _ => {} + } let id = match edit { UiEdit::Move { id, .. } | UiEdit::Resize { id, .. } | UiEdit::SetProperty { id, .. } | UiEdit::Reparent { id, .. } | UiEdit::Reorder { id, .. } => id, + UiEdit::Create { .. } | UiEdit::Delete { .. } | UiEdit::Duplicate { .. } => { + unreachable!() + } }; let index = self .widgets @@ -495,6 +522,130 @@ impl UiDocument { } impl UiDocument { + fn find(&self, id: &str) -> Option { + self.widgets.iter().position(|w| w.element.id == id) + } + + /// Indices of `id` and everything below it, in document order. + fn subtree(&self, id: &str) -> Vec { + let mut ids = vec![id.to_string()]; + let mut i = 0; + while i < ids.len() { + let parent = ids[i].clone(); + for w in &self.widgets { + if w.element.parent == parent && !ids.contains(&w.element.id) { + ids.push(w.element.id.clone()); + } + } + i += 1; + } + (0..self.widgets.len()) + .filter(|i| ids.contains(&self.widgets[*i].element.id)) + .collect() + } + + fn create_widget(&mut self, widget: &UiWidget) -> Result<(), String> { + if !widget.world_actor.is_empty() { + return Err("Projected widgets cannot be created in the editor".into()); + } + let mut widget = widget.clone(); + let parent = widget.element.parent.clone(); + if !parent.is_empty() { + let target = self + .find(&parent) + .map(|i| &self.widgets[i]) + .ok_or_else(|| format!("Unknown parent: {parent}"))?; + if !target.world_actor.is_empty() { + return Err("Cannot create inside a projected widget".into()); + } + let free = target.element.kind == UiKind::Canvas; + if free { + widget.layout.get_or_insert_with(UiLayout::default).absolute = true; + } else if let Some(layout) = &mut widget.layout { + layout.absolute = false; + } + } + if let Some(layout) = &widget.layout { + layout.validate_edit()?; + } + self.widgets.push(widget); + Ok(()) + } + + fn delete_subtree(&mut self, id: &str) -> Result<(), String> { + if self.find(id).is_none() { + return Err(format!("Unknown widget: {id}")); + } + let gone = self.subtree(id); + let removed: HashSet = gone + .iter() + .map(|i| self.widgets[*i].element.id.clone()) + .collect(); + let mut slot = 0; + self.widgets.retain(|_| { + slot += 1; + !gone.contains(&(slot - 1)) + }); + for w in &mut self.widgets { + if removed.contains(&w.scroll_target) { + w.scroll_target.clear(); + } + } + Ok(()) + } + + fn duplicate_subtree( + &mut self, + id: &str, + new_id: &str, + offset: Option<[f32; 2]>, + ) -> Result<(), String> { + let root = self + .find(id) + .ok_or_else(|| format!("Unknown widget: {id}"))?; + if !self.widgets[root].world_actor.is_empty() { + return Err("Projected widgets cannot be duplicated".into()); + } + let members = self.subtree(id); + let rename = |old: &str| { + if old == id { + new_id.to_string() + } else { + format!("{new_id}.{old}") + } + }; + let old_ids: HashSet = members + .iter() + .map(|i| self.widgets[*i].element.id.clone()) + .collect(); + let mut copies: Vec = members.iter().map(|i| self.widgets[*i].clone()).collect(); + for w in &mut copies { + if old_ids.contains(&w.element.parent) { + w.element.parent = rename(&w.element.parent); + } + if old_ids.contains(&w.scroll_target) { + w.scroll_target = rename(&w.scroll_target); + } + w.element.id = rename(&w.element.id); + } + if let Some(offset) = offset { + let root_copy = copies.iter_mut().find(|w| w.element.id == new_id).unwrap(); + let parent = &root_copy.element.parent; + let free = parent.is_empty() + || root_copy.layout.as_ref().is_some_and(|l| l.absolute) + || self + .find(parent) + .is_some_and(|i| self.widgets[i].element.kind == UiKind::Canvas); + if !free { + return Err("This widget is positioned by its parent layout".into()); + } + root_copy.element.offset = offset; + } + let at = members.iter().max().copied().unwrap_or(root) + 1; + self.widgets.splice(at..at, copies); + Ok(()) + } + pub fn validate(&self) -> Result<(), String> { if self .reference_size @@ -751,6 +902,84 @@ mod tests { ); } + #[test] + fn create_delete_and_duplicate_keep_ids_and_references_consistent() { + let mut doc: UiDocument = serde_json::from_value(serde_json::json!({"widgets": [ + {"element": {"id": "canvas", "kind": "Canvas"}}, + {"element": {"id": "flow", "kind": "VerticalBox"}}, + {"element": {"id": "menu", "parent": "canvas", "offset": [5, 6]}, "layout": {"absolute": true}}, + {"element": {"id": "list", "parent": "menu"}, "scroll_target": "bar"}, + {"element": {"id": "bar", "parent": "menu"}, "scroll_target": "outside"}, + {"element": {"id": "outside"}} + ]})) + .unwrap(); + let ids = |d: &UiDocument| { + d.widgets + .iter() + .map(|w| w.element.id.clone()) + .collect::>() + }; + let parse = |value| serde_json::from_value::(value).unwrap(); + doc.apply_edit(&parse(serde_json::json!({"kind": "Create", "widget": {"element": {"id": "a", "parent": "canvas"}}}))).unwrap(); + assert!(doc.widgets[6].layout.as_ref().unwrap().absolute); + doc.apply_edit(&parse(serde_json::json!({"kind": "Create", "widget": {"element": {"id": "b", "parent": "flow"}, "layout": {"absolute": true}}}))).unwrap(); + assert!(!doc.widgets[7].layout.as_ref().unwrap().absolute); + for value in [ + serde_json::json!({"kind": "Create", "widget": {"element": {"id": "a"}}}), + serde_json::json!({"kind": "Create", "widget": {"element": {"id": ""}}}), + serde_json::json!({"kind": "Create", "widget": {"element": {"id": "c", "parent": "nope"}}}), + serde_json::json!({"kind": "Create", "widget": {"element": {"id": "c"}, "world_actor": "x"}}), + serde_json::json!({"kind": "Delete", "id": "nope"}), + serde_json::json!({"kind": "Duplicate", "id": "menu", "new_id": "a"}), + serde_json::json!({"kind": "Duplicate", "id": "b", "new_id": "b2", "offset": [1, 1]}), + ] { + let before = doc.clone(); + assert!(doc.apply_edit(&parse(value)).is_err()); + assert_eq!(doc, before); + } + doc.apply_edit(&parse(serde_json::json!({"kind": "Duplicate", "id": "menu", "new_id": "menu2", "offset": [21, 22]}))).unwrap(); + assert_eq!( + ids(&doc), + [ + "canvas", + "flow", + "menu", + "list", + "bar", + "menu2", + "menu2.list", + "menu2.bar", + "outside", + "a", + "b" + ] + ); + assert_eq!(doc.widgets[5].element.offset, [21., 22.]); + assert_eq!(doc.widgets[2].element.offset, [5., 6.]); + assert_eq!(doc.widgets[6].element.parent, "menu2"); + assert_eq!(doc.widgets[6].scroll_target, "menu2.bar"); + assert_eq!(doc.widgets[7].scroll_target, "outside"); + assert_eq!(doc.widgets[3].scroll_target, "bar"); + doc.apply_edit(&parse(serde_json::json!({"kind": "Delete", "id": "bar"}))) + .unwrap(); + assert_eq!(doc.widgets[3].scroll_target, ""); + doc.apply_edit(&parse(serde_json::json!({"kind": "Delete", "id": "menu"}))) + .unwrap(); + assert_eq!( + ids(&doc), + [ + "canvas", + "flow", + "menu2", + "menu2.list", + "menu2.bar", + "outside", + "a", + "b" + ] + ); + } + #[test] fn typed_edits_preserve_layout_and_reject_invalid_dimensions() { let mut document = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 66446bfd..7cf3f45e 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -131,6 +131,7 @@ Item { g.busy = false; if (g.canceled || root.gesture !== g) return; root.document = next; + if (g.select !== undefined) root.selectedId = g.select; root.flushEdit(g); }, function(e) { root.failEdit(g, e); }); } else if (g.released) { @@ -401,21 +402,46 @@ Item { style[styleState.currentText][field] = value; extra("style", style); } + function structuralEdit(edit, select) { + if (!designing || gesture) return false; + const g = {kind: edit.kind, id: "", original: copy(document), token: null, bound: null, committing: false, + edit: edit, sent: null, busy: false, released: true, canceled: false, select: select}; + gesture = g; + forceActiveFocus(); + const backend = app; + app.invoke("begin_interface_edit", {revision: savedRevision}, function(token) { + g.token = token; + if (g.canceled) backend.invoke("cancel_interface_edit", {token: token}); + else root.flushEdit(g); + }, function(e) { root.failEdit(g, e); }); + return true; + } + function uniqueId(base) { + let n = 1; + while (document.widgets.some(w => w.element.id === base + n)) ++n; + return base + n; + } + readonly property var containerKinds: ["Canvas","VerticalBox","HorizontalBox","Grid","WrapBox"] function add(kind, x, y) { - const next = copy(document); - let id = kind.toLowerCase(), n = 1; - while (next.widgets.some(w => w.element.id === id + n)) ++n; - next.widgets.push({element: {id: id+n, kind: kind, content: ["Label","Button","RichText","Toggle"].indexOf(kind) >= 0 ? kind : "", anchor: "TopLeft", offset: [Math.round(x),Math.round(y)], size: [180, kind === "Panel" || kind === "ListView" ? 180 : 40], parent: "", modal: false, range: [0,100], value: {Number: 0}}, style: {}, bindings: [], items: []}); + const id = uniqueId(kind.toLowerCase()); + const parent = widget && !widget.world_actor && containerKinds.indexOf(widget.element.kind) >= 0 && x === 20 && y === 20 ? widget : null; + const free = !parent || parent.element.kind === "Canvas"; // Values use the core's tagged representation; default values can be omitted. - delete next.widgets[next.widgets.length-1].element.value; - screenId = ""; - selectedId = id+n; save(next); + const created = {element: {id: id, kind: kind, content: ["Label","Button","RichText","Toggle"].indexOf(kind) >= 0 ? kind : "", anchor: "TopLeft", offset: free ? [Math.round(x),Math.round(y)] : [0,0], size: [180, kind === "Panel" || kind === "ListView" ? 180 : 40], parent: parent ? parent.element.id : "", modal: false, range: [0,100]}, style: {}, bindings: [], items: []}; + if (structuralEdit({kind: "Create", widget: created}, id)) screenId = ""; + } + function duplicateSelected() { + if (!widget || widget.world_actor) return; + const id = widget.element.id, base = id + "-copy"; + let name = base, n = 1; + while (document.widgets.some(w => w.element.id === name)) name = base + (++n); + const edit = {kind: "Duplicate", id: id, new_id: name}; + if (editable(widget)) { const o = widget.element.offset || [0,0]; edit.offset = [o[0]+16, o[1]+16]; } + structuralEdit(edit, name); } function removeSelected() { if (!widget) return; - const next = copy(document); let gone = [widget.element.id]; - for (let i=0;i { if(w.element.parent === gone[i] && gone.indexOf(w.element.id)<0) gone.push(w.element.id); }); - next.widgets = next.widgets.filter(w => gone.indexOf(w.element.id)<0); selectedId = ""; save(next); + structuralEdit({kind: "Delete", id: widget.element.id}, ""); } RowLayout { anchors.fill: parent; spacing: 0 @@ -461,7 +487,10 @@ Item { Button { objectName: "interfaceLater"; text: "Later"; enabled: root.designing && !root.gesture && root.siblingIndex >= 0 && root.siblingIndex+1 < root.siblings.length; onClicked: root.reorderSibling(1) } } Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: "Sibling order controls flow layout and draw order." } - Button { text: "Delete widget"; enabled: !!root.widget; onClicked: root.removeSelected() } + RowLayout { + Button { objectName: "interfaceDuplicate"; text: "Duplicate"; enabled: root.designing && !root.gesture && !!root.widget && !root.widget.world_actor; onClicked: root.duplicateSelected() } + Button { objectName: "interfaceDelete"; text: "Delete"; enabled: root.designing && !root.gesture && !!root.widget; onClicked: root.removeSelected() } + } } ColumnLayout { Layout.fillWidth: true; Layout.fillHeight: true diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 5c3de6a5..564a6d76 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -31,6 +31,14 @@ TestCase { } else if (command === "update_interface_edit") { const next = JSON.parse(JSON.stringify(test.draft)); const w = next.widgets.find(w => w.element.id === args.edit.id); + if (args.edit.kind === "Create") next.widgets.push(args.edit.widget); + if (args.edit.kind === "Delete") next.widgets = next.widgets.filter(item => item.element.id !== args.edit.id); + if (args.edit.kind === "Duplicate") { + const copy = JSON.parse(JSON.stringify(w)); + copy.element.id = args.edit.new_id; + if (args.edit.offset) copy.element.offset = args.edit.offset; + next.widgets.push(copy); + } if (args.edit.kind === "Move" || args.edit.kind === "Resize") w.element.offset = args.edit.offset; if (args.edit.kind === "Resize") w.element.size = args.edit.size; if (args.edit.kind === "SetProperty") { @@ -316,6 +324,24 @@ TestCase { keyRelease(Qt.Key_Right); compare(calls.filter(c=>c.command === "commit_interface_edit").length,1); } + function test_create_duplicate_and_delete_use_typed_transactions_and_select_the_result() { + panel.add("Button", 20, 20); + let edit = calls.find(c=>c.command === "update_interface_edit").args.edit; + compare(edit.kind, "Create"); + compare(edit.widget.element.id, "button1"); + compare(panel.selectedId, "button1"); + compare(panel.document.widgets.length, 3); + panel.selectedId = "back"; + panel.duplicateSelected(); + edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit, {kind:"Duplicate", id:"back", new_id:"back-copy", offset:[216,216]}); + compare(panel.selectedId, "back-copy"); + panel.removeSelected(); + compare(panel.selectedId, ""); + compare(panel.document.widgets.map(w=>w.element.id), ["back","front","button1"]); + compare(calls.filter(c=>c.command === "commit_interface_edit").length, 3); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_flow_sibling_reorder_keeps_other_tree_slots() { const d = {widgets:[{element:{id:"root",kind:"VerticalBox"}}, {element:{id:"a",parent:"root"}}, {element:{id:"other"}}, {element:{id:"b",parent:"root"}}]}; panel.document = d; backend.appState.project.world.interface = d; panel.selectedId = "a"; diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 10c8392f..7acabab4 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -1,6 +1,6 @@ # Interface editor overhaul -Status: implementation started. Phase 0 has a runtime design session, frame-matched selection and screen isolation. Interface supports typed move/resize, property, reparent and reorder transactions, snapping, eight resize handles and keyboard nudging; the preview gate is still open. +Status: implementation started (eight increments). Phase 0 has a runtime design session, frame-matched selection and screen isolation. Interface supports typed move/resize, property, reparent and reorder transactions, snapping, eight resize handles and keyboard nudging; the preview gate is still open. ## Goal @@ -214,7 +214,23 @@ This is a major subsystem project. Do not claim engine-level parity after a cosm - Added Earlier/Later hierarchy controls for both free and flow widgets. Boundary controls disable, selection follows the ID and the UI explains that sibling order controls flow layout and drawing. The hierarchy still needs the full tree/search overhaul in phase 2. - Runtime spawning now walks parents before children, preserving sibling order even when a root reorder leaves a child earlier in the serialized array than its parent. Real Bevy checks cover flow placement, paint order and root reordering across child slots. -The next increment is typed creation, subtree deletion and duplication with reference remapping. Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. +### Creation, deletion and duplication (eighth increment) + +- Added typed `Create {widget}`, `Delete {id}` and `Duplicate {id,new_id,offset?}` edits. Create appends and normalizes placement from the parent (Canvas means free, anything else flow) and rejects projected widgets, unknown parents and duplicate or empty IDs. Delete removes the whole subtree and clears `scroll_target`s that pointed into it. Duplicate copies the subtree directly after the original under `new_id` and `new_id.`, remapping `parent` and `scroll_target` inside the copy only; an optional `offset` moves a free-placed root and a flow child rejects it. +- The Interface palette, Duplicate and Delete buttons now use the transaction commands (no `set_interface`), select the created or copied widget and clear selection on delete. A palette click adds inside the selected container; a drag still adds to the root. +- Block and script references to the original IDs are deliberately not rewritten; duplicated widgets are new names. + +### Review against recent changes (2026-10-08) + +Master since the seventh increment (Phase 6 2D work, script guests, bundled rustc, clippy/let-chain cleanups) left the interface pipeline intact: the only UI-file changes were formatting-level edits and the wall-clock pause fix in `overlay.rs`. Adjustments to the plan: + +- Protocol version is 35 (the screen isolation note's 23 was stale). Any phase 1 message change bumps from there. +- 2D now has screen feedback (`screenfx.rs`: cover z 52, flash z 54, floaters) and lighting (`unlit_above`) drawn around the interface. The design session must stay free of both, and phase 2's "Interact" preview should show cover/flash z-order rather than ignore it. +- Interface strings now come from the project's string table (`text for key`, `set language`). Phase 4 text editing and phase 6 localization samples should preview through the table instead of adding a second mechanism. +- Scripts gained more guest languages and ABI 47 slots (save slots, locale). Phase 5's binding and event wiring should use the script ABI as it stands and add verbs only through `abi.rs`. +- The remaining order is unchanged: finish phase 0/1 (schema versioning, property metadata, no world reload on commit), then the phase 2 hierarchy overhaul, then marquee/multiselection and alignment tools. + +The next increment is a real hierarchy tree with search and editor hide/lock (phase 2), then schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. Open phase 0 checks: embedded/process rendering and teardown on actual platforms; native presentation timing and process screenshot metadata on actual platforms; viewport resize/DPI/safe-area matrices; real image/font asset loading; screenshot baselines and gameplay input isolation with held inputs. Rotation, text editing/IME, nine-slice and animation capabilities remain unproven. The fixture image node currently has no asset. From 5453b5513ad598c3e5b89a3cb2c9d74e7f4e8700 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 22:11:16 +0000 Subject: [PATCH 02/26] Interface editor: hierarchy tree with search, folding and editor lock Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 +- blockloom-qt/qml/UiDesigner.qml | 76 +++++++++++++++++-- .../tests/qml/tst_InterfaceViewport.qml | 23 ++++++ docs/interface-editor-plan.md | 10 ++- 4 files changed, 100 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11df059e..6651a7a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3080,7 +3080,7 @@ appends a widget (a Canvas parent forces it free, any other parent flow), copies the subtree after the original as `new_id` and `new_id.`, remapping parents and `scroll_target`s inside the copy only. Blocks and scripts that name the original ids are not rewritten. `set_interface` stays -the whole-document import path. A new edit shape needs the `update-interface-edit` +the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. A new edit shape needs the `update-interface-edit` summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. ### Android devices and the emulator diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 7cf3f45e..88b4e9b4 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -24,6 +24,41 @@ Item { } return false; } + // Editor-only state: never saved and never sent to the runtime. + property var lockedIds: ({}) + property var collapsedIds: ({}) + property string search: "" + function isLocked(id) { return !!lockedIds[id]; } + function toggleLock(id) { const next = Object.assign({}, lockedIds); if (next[id]) delete next[id]; else next[id] = true; lockedIds = next; if (gesture && gesture.id === id) cancelEdit(); } + function toggleCollapsed(id) { const next = Object.assign({}, collapsedIds); if (next[id]) delete next[id]; else next[id] = true; collapsedIds = next; } + readonly property var treeRows: { + const widgets = screenWidgets, children = {}, byId = {}; + widgets.forEach(w => { byId[w.element.id] = w; }); + widgets.forEach(w => { + const parent = byId[w.element.parent] ? w.element.parent : ""; + (children[parent] = children[parent] || []).push(w); + }); + const needle = search.trim().toLowerCase(); + const matches = w => w.element.id.toLowerCase().indexOf(needle) >= 0 || (w.element.content || "").toLowerCase().indexOf(needle) >= 0; + const keep = {}; + if (needle) { + widgets.filter(matches).forEach(w => { + let current = w; + while (current && !keep[current.element.id]) { keep[current.element.id] = true; current = byId[current.element.parent]; } + }); + } + const rows = []; + const visit = (parent, depth) => (children[parent] || []).forEach(w => { + const id = w.element.id; + if (needle && !keep[id]) return; + const kids = (children[id] || []).length; + rows.push({id: id, depth: depth, kids: kids, kind: w.element.kind, collapsed: !needle && !!collapsedIds[id]}); + if (needle || !collapsedIds[id]) visit(id, depth + 1); + }); + visit("", 0); + return rows; + } + readonly property var pickable: bounds.filter(b => !isLocked(b.id)) onScreenIdChanged: { cancelEdit(); ++revision; @@ -74,7 +109,7 @@ Item { }).filter(line => line !== null); } function editable(w) { - if (!w || w.world_actor) return false; + if (!w || w.world_actor || isLocked(w.element.id)) return false; const parent = document.widgets.find(p => p.element.id === w.element.parent); return !w.element.parent || (w.layout && w.layout.absolute) || (parent && parent.element.kind === "Canvas"); } @@ -470,16 +505,41 @@ Item { } } Label { text: "Hierarchy"; font.bold: true; padding: 8 } + TextField { + objectName: "interfaceSearch" + Layout.fillWidth: true; Layout.leftMargin: 6; Layout.rightMargin: 6 + placeholderText: "Search widgets" + text: root.search + onTextEdited: root.search = text + } ListView { + objectName: "interfaceTree" Layout.fillWidth: true; Layout.fillHeight: true; clip: true - model: root.screenWidgets + model: root.treeRows delegate: ItemDelegate { required property var modelData - required property int index width: ListView.view.width; height: 30 - text: (modelData.element.parent ? " " : "")+modelData.element.id - highlighted: root.selectedId === modelData.element.id - onClicked: { root.selectedId=modelData.element.id; root.forceActiveFocus(); } + highlighted: root.selectedId === modelData.id + onClicked: { root.selectedId = modelData.id; root.forceActiveFocus(); } + contentItem: RowLayout { + spacing: 2 + Item { Layout.preferredWidth: 10 + modelData.depth * 14 } + ToolButton { + objectName: "interfaceFold" + Layout.preferredWidth: 22; Layout.preferredHeight: 24 + text: modelData.kids ? (modelData.collapsed ? "\u25B8" : "\u25BE") : "" + enabled: modelData.kids > 0 + onClicked: root.toggleCollapsed(modelData.id) + } + Label { Layout.fillWidth: true; elide: Text.ElideRight; text: modelData.id; opacity: root.isLocked(modelData.id) ? 0.6 : 1 } + ToolButton { + objectName: "interfaceLock" + Layout.preferredWidth: 26; Layout.preferredHeight: 24 + text: root.isLocked(modelData.id) ? "\uD83D\uDD12" : "\u00B7" + ToolTip.visible: hovered; ToolTip.text: root.isLocked(modelData.id) ? "Unlock (editor only)" : "Lock in the viewport (editor only)" + onClicked: root.toggleLock(modelData.id) + } + } } } RowLayout { @@ -575,12 +635,12 @@ Item { if (!root.gesture && Math.hypot(mouse.x-pressX, mouse.y-pressY) > 3/Math.max(0.05,root.zoom)) root.startEdit("Move", pressX, pressY); root.dragEdit(mouse.x, mouse.y, mouse.modifiers); } - else root.hoveredId = geometry.pick(root.bounds, mouse.x, mouse.y); + else root.hoveredId = geometry.pick(root.pickable, mouse.x, mouse.y); } onExited: root.hoveredId = "" onPressed: mouse => { root.forceActiveFocus(); - root.selectedId = geometry.pick(root.bounds, mouse.x, mouse.y); + root.selectedId = geometry.pick(root.pickable, mouse.x, mouse.y); pressX = mouse.x; pressY = mouse.y; } onReleased: root.finishEdit() diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 564a6d76..396433ef 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -342,6 +342,29 @@ TestCase { compare(calls.filter(c=>c.command === "commit_interface_edit").length, 3); verify(!calls.some(c=>c.command === "set_interface")); } + function test_hierarchy_is_a_searchable_collapsible_tree() { + const d = {widgets:[{element:{id:"menu"}}, {element:{id:"title",parent:"menu",content:"Hello"}}, {element:{id:"play",parent:"menu"}}, {element:{id:"hud"}}]}; + panel.document = d; + compare(panel.treeRows.map(r=>r.id+":"+r.depth), ["menu:0","title:1","play:1","hud:0"]); + panel.toggleCollapsed("menu"); + compare(panel.treeRows.map(r=>r.id), ["menu","hud"]); + panel.search = "hello"; + compare(panel.treeRows.map(r=>r.id), ["menu","title"]); + panel.search = ""; + panel.toggleCollapsed("menu"); + compare(panel.treeRows.length, 4); + } + function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { + panel.selectedId = "back"; + verify(panel.editable(panel.widget)); + panel.toggleLock("back"); + verify(!panel.editable(panel.widget)); + panel.receiveLayout(JSON.stringify(geometry(panel.revision, panel.generation))); + compare(panel.pickable.map(b=>b.id), ["front"]); + panel.toggleLock("back"); + verify(panel.editable(panel.widget)); + verify(!calls.some(c=>c.command === "set_interface" || c.command === "begin_interface_edit")); + } function test_flow_sibling_reorder_keeps_other_tree_slots() { const d = {widgets:[{element:{id:"root",kind:"VerticalBox"}}, {element:{id:"a",parent:"root"}}, {element:{id:"other"}}, {element:{id:"b",parent:"root"}}]}; panel.document = d; backend.appState.project.world.interface = d; panel.selectedId = "a"; diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 7acabab4..104aac51 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -1,6 +1,6 @@ # Interface editor overhaul -Status: implementation started (eight increments). Phase 0 has a runtime design session, frame-matched selection and screen isolation. Interface supports typed move/resize, property, reparent and reorder transactions, snapping, eight resize handles and keyboard nudging; the preview gate is still open. +Status: implementation started (nine increments). Phase 0 has a runtime design session, frame-matched selection and screen isolation. Interface supports typed move/resize, property, reparent and reorder transactions, snapping, eight resize handles and keyboard nudging; the preview gate is still open. ## Goal @@ -230,7 +230,13 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - Scripts gained more guest languages and ABI 47 slots (save slots, locale). Phase 5's binding and event wiring should use the script ABI as it stands and add verbs only through `abi.rs`. - The remaining order is unchanged: finish phase 0/1 (schema versioning, property metadata, no world reload on commit), then the phase 2 hierarchy overhaul, then marquee/multiselection and alignment tools. -The next increment is a real hierarchy tree with search and editor hide/lock (phase 2), then schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. +### Hierarchy tree, search and lock (ninth increment) + +- The hierarchy is a real tree in sibling order: indentation by depth, fold arrows, and a search box matching ID or content (matches keep their ancestors and ignore folds). It follows screen isolation. +- Per-widget Lock is editor-only state (never saved, never sent to the runtime): locked widgets are skipped by viewport picking and lose move/resize/nudge handles, but stay selectable from the tree and editable in the inspector. +- Editor-side hide is not done: hiding must reach the runtime preview (like screen isolation does) so it needs a protocol field; it stays open for the next phase 2 increment, together with marquee selection. + +The next increment is editor hide (protocol), then schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. Open phase 0 checks: embedded/process rendering and teardown on actual platforms; native presentation timing and process screenshot metadata on actual platforms; viewport resize/DPI/safe-area matrices; real image/font asset loading; screenshot baselines and gameplay input isolation with held inputs. Rotation, text editing/IME, nine-slice and animation capabilities remain unproven. The fixture image node currently has no asset. From f75467e16b8a3f5b4f69741d92c6aa648ccfac64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:31:29 +0000 Subject: [PATCH 03/26] Interface editor: editor hide in the preview and UI schema versioning Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 +- blockloom-app/src/commands.rs | 3 +- blockloom-app/src/shell.rs | 2 +- blockloom-core/src/project.rs | 1 + blockloom-core/src/ui_framework.rs | 48 +++++++++++++++++++ blockloom-protocol/src/lib.rs | 19 +++++++- blockloom-qt/qml/UiDesigner.qml | 18 ++++++- .../tests/qml/tst_InterfaceViewport.qml | 11 +++++ blockloom-runtime/src/embed.rs | 1 + blockloom-runtime/src/quality.rs | 1 + blockloom-runtime/src/ui_design.rs | 22 +++++++++ docs/interface-editor-plan.md | 10 +++- 12 files changed, 130 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6651a7a1..b96f05a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3080,7 +3080,7 @@ appends a widget (a Canvas parent forces it free, any other parent flow), copies the subtree after the original as `new_id` and `new_id.`, remapping parents and `scroll_target`s inside the copy only. Blocks and scripts that name the original ids are not rewritten. `set_interface` stays -the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. A new edit shape needs the `update-interface-edit` +the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. A new edit shape needs the `update-interface-edit` summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. ### Android devices and the emulator diff --git a/blockloom-app/src/commands.rs b/blockloom-app/src/commands.rs index f9e893cd..16ea23b5 100644 --- a/blockloom-app/src/commands.rs +++ b/blockloom-app/src/commands.rs @@ -5861,8 +5861,9 @@ pub(crate) fn cancel_interface_edit(state: &SharedState, token: String) -> Resul pub(crate) fn set_interface( state: &SharedState, app: &AppHandle, - document: blockloom_core::ui::UiDocument, + mut document: blockloom_core::ui::UiDocument, ) -> Result<(), String> { + document.migrate(); document.validate()?; let mut s = lock(state)?; if s.project().is_none() { diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index c0dce1fd..e007dac6 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -933,7 +933,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "preview-interface", cmd: "preview_interface", aliases: &["preview_interface"], - summary: "Preview a temporary {revision, generation, document, viewport?, screen?} without saving or starting gameplay. Screen names a top-level widget tree. Omit design to close it.", + summary: "Preview a temporary {revision, generation, document, viewport?, screen?, hidden?} without saving or starting gameplay. Screen names a top-level widget tree; hidden lists widget ids to hide (with their subtrees) in the preview only. Omit design to close it.", args: &[ArgSpec { name: "design", ty: "object", diff --git a/blockloom-core/src/project.rs b/blockloom-core/src/project.rs index dde8aff6..8fafbb0a 100644 --- a/blockloom-core/src/project.rs +++ b/blockloom-core/src/project.rs @@ -553,6 +553,7 @@ impl Scene { } fn normalize_scene(&mut self) { + self.world.interface.migrate(); self.migrate_camera_follow(); self.prune_parents(); let known: std::collections::HashSet = diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 651ca3f0..bd432be7 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -232,6 +232,9 @@ pub enum UiScale { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UiDocument { + /// Schema version; 0 is a document saved before versions existed. + #[serde(default = "legacy_version")] + pub version: u32, pub widgets: Vec, pub theme: UiTheme, pub styles: BTreeMap, @@ -241,9 +244,17 @@ pub struct UiDocument { pub safe_area: [f32; 4], pub prefabs: BTreeMap>, } +/// The schema version this build writes. +pub const UI_SCHEMA_VERSION: u32 = 1; + +fn legacy_version() -> u32 { + 0 +} + impl Default for UiDocument { fn default() -> Self { Self { + version: UI_SCHEMA_VERSION, widgets: vec![], theme: UiTheme::default(), styles: BTreeMap::new(), @@ -522,6 +533,16 @@ impl UiDocument { } impl UiDocument { + /// Brings an older document up to the current schema. Each step is + /// deterministic and keeps widget IDs, order and geometry. Version 1 only + /// stamps the version: version 0 documents already have its shape. A + /// newer document is left alone and refused by `validate`. + pub fn migrate(&mut self) { + if self.version < UI_SCHEMA_VERSION { + self.version = UI_SCHEMA_VERSION; + } + } + fn find(&self, id: &str) -> Option { self.widgets.iter().position(|w| w.element.id == id) } @@ -647,6 +668,12 @@ impl UiDocument { } pub fn validate(&self) -> Result<(), String> { + if self.version > UI_SCHEMA_VERSION { + return Err(format!( + "This interface uses schema version {} but this Blockloom understands up to {UI_SCHEMA_VERSION}; update Blockloom to edit it", + self.version + )); + } if self .reference_size .iter() @@ -902,6 +929,27 @@ mod tests { ); } + #[test] + fn schema_versions_migrate_forward_and_refuse_newer_documents() { + let mut legacy: UiDocument = + serde_json::from_value(serde_json::json!({"widgets": [{"element": {"id": "a"}}]})) + .unwrap(); + assert_eq!(legacy.version, 0); + let before = legacy.widgets.clone(); + legacy.migrate(); + assert_eq!(legacy.version, UI_SCHEMA_VERSION); + assert_eq!(legacy.widgets, before); + let again = legacy.clone(); + legacy.migrate(); + assert_eq!(legacy, again); + assert_eq!(UiDocument::default().version, UI_SCHEMA_VERSION); + let newer: UiDocument = serde_json::from_value(serde_json::json!({"version": 99})).unwrap(); + assert!(newer.validate().unwrap_err().contains("schema version 99")); + let mut kept = newer.clone(); + kept.migrate(); + assert_eq!(kept.version, 99); + } + #[test] fn create_delete_and_duplicate_keep_ids_and_references_consistent() { let mut doc: UiDocument = serde_json::from_value(serde_json::json!({"widgets": [ diff --git a/blockloom-protocol/src/lib.rs b/blockloom-protocol/src/lib.rs index 037a25ff..6e111e8b 100644 --- a/blockloom-protocol/src/lib.rs +++ b/blockloom-protocol/src/lib.rs @@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize}; /// Bumped when a message changes shape. The runtime reports the version it /// was built with in [`RuntimeMessage::Ready`]; a mismatch means a stale /// binary next to a fresh editor. -pub const PROTOCOL_VERSION: u32 = 35; +pub const PROTOCOL_VERSION: u32 = 36; /// The size a game's window opens at, in pixels - and so the size the /// editor's Game view draws it at, scaled to fit, so it shows exactly what a @@ -93,6 +93,9 @@ pub struct InterfaceDesign { /// Preview only this top-level widget tree without editing the document. #[serde(default, skip_serializing_if = "Option::is_none")] pub screen: Option, + /// Widgets hidden in the preview only (editor hide); their subtrees go with them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hidden: Vec, pub revision: u64, pub generation: u64, pub document: blockloom_core::ui::UiDocument, @@ -116,6 +119,15 @@ impl InterfaceDesign { { return Err("Interface screen must name a top-level widget".into()); } + if let Some(missing) = self.hidden.iter().find(|id| { + !self + .document + .widgets + .iter() + .any(|widget| widget.element.id == **id) + }) { + return Err(format!("Interface hides an unknown widget: {missing}")); + } Ok(()) } } @@ -770,6 +782,7 @@ mod tests { let message = EditorMessage::InterfaceDesign { design: Some(InterfaceDesign { screen: Some("screen".into()), + hidden: vec![], viewport: Some([960, 720]), revision: 4, generation: 2, @@ -783,6 +796,10 @@ mod tests { let legacy: InterfaceDesign = serde_json::from_str(r#"{"revision":1,"generation":1,"document":{}}"#).unwrap(); assert_eq!(legacy.screen, None); + assert!(legacy.hidden.is_empty()); + let mut hidden = legacy.clone(); + hidden.hidden = vec!["missing".into()]; + assert!(hidden.validate().is_err()); assert!(legacy.validate().is_ok()); let message = RuntimeMessage::InterfaceLayout(InterfaceLayout { viewport: [960, 720], diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 88b4e9b4..a2c7336a 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -26,7 +26,14 @@ Item { } // Editor-only state: never saved and never sent to the runtime. property var lockedIds: ({}) + property var hiddenIds: ({}) property var collapsedIds: ({}) + function toggleHidden(id) { + const next = Object.assign({}, hiddenIds); if (next[id]) delete next[id]; else next[id] = true; + hiddenIds = next; cancelEdit(); ++revision; frameLayout = null; if (designing) previewDelay.restart(); + } + // Only IDs that still exist: the runtime refuses a request naming a missing widget. + readonly property var hiddenList: document.widgets.map(w => w.element.id).filter(id => !!hiddenIds[id]) property string search: "" function isLocked(id) { return !!lockedIds[id]; } function toggleLock(id) { const next = Object.assign({}, lockedIds); if (next[id]) delete next[id]; else next[id] = true; lockedIds = next; if (gesture && gesture.id === id) cancelEdit(); } @@ -349,7 +356,7 @@ Item { ++revision; frameLayout = null; app.invoke("preview_interface", {design: {revision: revision, generation: generation, - viewport: [previewWidth, previewHeight], screen: screenId || null, document: copy(document)}}, + viewport: [previewWidth, previewHeight], screen: screenId || null, hidden: hiddenList, document: copy(document)}}, function() { root.error = ""; }, function(e) { root.error = String(e); }); } function updateSession() { @@ -531,7 +538,14 @@ Item { enabled: modelData.kids > 0 onClicked: root.toggleCollapsed(modelData.id) } - Label { Layout.fillWidth: true; elide: Text.ElideRight; text: modelData.id; opacity: root.isLocked(modelData.id) ? 0.6 : 1 } + Label { Layout.fillWidth: true; elide: Text.ElideRight; text: modelData.id; opacity: root.hiddenIds[modelData.id] ? 0.4 : (root.isLocked(modelData.id) ? 0.6 : 1) } + ToolButton { + objectName: "interfaceHide" + Layout.preferredWidth: 26; Layout.preferredHeight: 24 + text: root.hiddenIds[modelData.id] ? "\u25CB" : "\u25CF" + ToolTip.visible: hovered; ToolTip.text: root.hiddenIds[modelData.id] ? "Show in preview (editor only)" : "Hide in preview (editor only)" + onClicked: root.toggleHidden(modelData.id) + } ToolButton { objectName: "interfaceLock" Layout.preferredWidth: 26; Layout.preferredHeight: 24 diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 396433ef..56bb5599 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -354,6 +354,17 @@ TestCase { panel.toggleCollapsed("menu"); compare(panel.treeRows.length, 4); } + function test_editor_hide_is_sent_with_the_preview_and_never_saved() { + calls = []; + panel.toggleHidden("front"); + tryVerify(() => calls.some(c => c.command === "preview_interface" && !!c.args.design)); + const design = calls.filter(c => c.command === "preview_interface").pop().args.design; + compare(design.hidden, ["front"]); + compare(design.document.widgets.map(w => w.element.id), ["back", "front"]); + panel.toggleHidden("front"); + compare(panel.hiddenList, []); + verify(!calls.some(c => c.command === "set_interface")); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/blockloom-runtime/src/embed.rs b/blockloom-runtime/src/embed.rs index 3793673e..9d875d38 100644 --- a/blockloom-runtime/src/embed.rs +++ b/blockloom-runtime/src/embed.rs @@ -1869,6 +1869,7 @@ mod tests { vec![EditorMessage::InterfaceDesign { design: Some(blockloom_protocol::InterfaceDesign { screen: None, + hidden: vec![], revision: 1, generation: 1, viewport: None, diff --git a/blockloom-runtime/src/quality.rs b/blockloom-runtime/src/quality.rs index 80873638..50890c86 100644 --- a/blockloom-runtime/src/quality.rs +++ b/blockloom-runtime/src/quality.rs @@ -945,6 +945,7 @@ mod tests { .apply( Some(blockloom_protocol::InterfaceDesign { screen: None, + hidden: vec![], viewport: None, revision: 1, generation: 1, diff --git a/blockloom-runtime/src/ui_design.rs b/blockloom-runtime/src/ui_design.rs index f79c2996..b544cb35 100644 --- a/blockloom-runtime/src/ui_design.rs +++ b/blockloom-runtime/src/ui_design.rs @@ -59,6 +59,13 @@ impl DesignSession { } } } + for id in &request.hidden { + manager.set( + id, + blockloom_core::ui::UiProp::Visible, + &blockloom_core::value::Evaluated::Bool(false), + ); + } self.request = Some(request); self.last = None; Ok(()) @@ -172,6 +179,7 @@ mod tests { fn fixture(revision: u64) -> InterfaceDesign { InterfaceDesign { screen: None, + hidden: vec![], viewport: Some([960, 720]), revision, generation: 1, @@ -258,6 +266,20 @@ mod tests { assert_eq!(manager.document, document); assert!(manager.shown(manager.get("image").unwrap())); assert!(manager.shown(manager.get("other").unwrap())); + let mut hide = session.request.clone().unwrap(); + hide.revision = 3; + hide.hidden = vec!["missing".into()]; + assert!( + session + .apply(Some(hide.clone()), &engine, &mut manager) + .is_err() + ); + assert!(manager.shown(manager.get("image").unwrap())); + hide.hidden = vec!["image".into()]; + session.apply(Some(hide), &engine, &mut manager).unwrap(); + assert!(!manager.shown(manager.get("image").unwrap())); + assert!(manager.shown(manager.get("other").unwrap())); + assert_eq!(manager.document, document); } #[test] diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 104aac51..f6ab337e 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -234,9 +234,15 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - The hierarchy is a real tree in sibling order: indentation by depth, fold arrows, and a search box matching ID or content (matches keep their ancestors and ignore folds). It follows screen isolation. - Per-widget Lock is editor-only state (never saved, never sent to the runtime): locked widgets are skipped by viewport picking and lose move/resize/nudge handles, but stay selectable from the tree and editable in the inspector. -- Editor-side hide is not done: hiding must reach the runtime preview (like screen isolation does) so it needs a protocol field; it stays open for the next phase 2 increment, together with marquee selection. +- Editor hide is a per-row toggle. `InterfaceDesign` gained `hidden` (protocol 36): the design session hides those widgets and their subtrees in its temporary manager, like screen isolation, so the document, game visibility and saves are untouched. Unknown IDs reject the request atomically. Hidden widgets stay in the reported geometry with effective visibility false, so they cannot be picked. -The next increment is editor hide (protocol), then schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. +### Schema versioning (tenth increment) + +- `UiDocument` has a `version` (`UI_SCHEMA_VERSION` = 1). A document with no version loads as 0 and `migrate()` stamps it as 1 without touching IDs, order or geometry; migration is idempotent. It runs in `Project::normalize` (per scene) and `set_interface`. +- A document newer than this build is not rewritten: `validate()` refuses it with an upgrade message, so edits, previews and `set_interface` fail instead of silently dropping fields. Opening still works. Run-time loading is unaffected. +- Later steps (style inheritance, component instances) add a version and a `migrate` arm each; the stored shape stays serde-defaulted so older builds keep ignoring nothing they cannot read. + +The next increment is property metadata (phase 1), marquee/multiselection (phase 3), then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. Open phase 0 checks: embedded/process rendering and teardown on actual platforms; native presentation timing and process screenshot metadata on actual platforms; viewport resize/DPI/safe-area matrices; real image/font asset loading; screenshot baselines and gameplay input isolation with held inputs. Rotation, text editing/IME, nine-slice and animation capabilities remain unproven. The fixture image node currently has no asset. From 9f3355769265d3ed17d293091316119c4ebcd773 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:46:47 +0000 Subject: [PATCH 04/26] Interface editor: batch edits, property metadata, multiselection and marquee Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 +- blockloom-app/src/commands.rs | 4 + blockloom-app/src/dispatch.rs | 1 + blockloom-app/src/shell.rs | 9 +- blockloom-app/tests/sync.rs | 4 + blockloom-core/src/ui_framework.rs | 180 +++++++++++++++++- blockloom-qt/qml/UiDesigner.qml | 89 +++++++-- .../tests/qml/tst_InterfaceViewport.qml | 48 ++++- docs/interface-editor-plan.md | 8 +- 9 files changed, 316 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b96f05a8..27afff5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3080,7 +3080,7 @@ appends a widget (a Canvas parent forces it free, any other parent flow), copies the subtree after the original as `new_id` and `new_id.`, remapping parents and `scroll_target`s inside the copy only. Blocks and scripts that name the original ids are not rewritten. `set_interface` stays -the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. A new edit shape needs the `update-interface-edit` +the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. `Batch` applies several edits atomically; `property_metadata()` (`interface-properties`) is the table of `SetProperty` paths and is kept in step with `UiPropertyEdit` by a test. Selection is `selectedId` plus `extraIds` in QML (Shift-click, marquee) and Delete/Duplicate send one `Batch`. A new edit shape needs the `update-interface-edit` summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. ### Android devices and the emulator diff --git a/blockloom-app/src/commands.rs b/blockloom-app/src/commands.rs index 16ea23b5..31525697 100644 --- a/blockloom-app/src/commands.rs +++ b/blockloom-app/src/commands.rs @@ -5761,6 +5761,10 @@ pub(crate) fn preview_interface( Ok(()) } +pub(crate) fn interface_properties() -> Vec { + blockloom_core::ui::property_metadata() +} + pub(crate) fn interface_layout( state: &SharedState, ) -> Result, String> { diff --git a/blockloom-app/src/dispatch.rs b/blockloom-app/src/dispatch.rs index 604a226f..ffce59bd 100644 --- a/blockloom-app/src/dispatch.rs +++ b/blockloom-app/src/dispatch.rs @@ -322,6 +322,7 @@ impl Backend { arg(&args, "token")?, )?), "interface_layout" => to_json(commands::interface_layout(state)?), + "interface_properties" => to_json(commands::interface_properties()), "set_interface" => to_json(commands::set_interface( state, app, diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index e007dac6..648cc778 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -947,6 +947,13 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ summary: "Read the latest design geometry in physical viewport pixels, tagged by revision and generation.", args: &[], }, + CommandSpec { + name: "interface-properties", + cmd: "interface_properties", + aliases: &["interface_properties"], + summary: "List every property path SetProperty accepts with its label, group, type, choices and the widget kinds it applies to.", + args: &[], + }, CommandSpec { name: "begin-interface-edit", cmd: "begin_interface_edit", @@ -962,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. Property paths: element.kind/content/anchor/modal, layout (object or null). Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. Property paths: element.kind/content/anchor/modal, layout (object or null). Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-app/tests/sync.rs b/blockloom-app/tests/sync.rs index b347773b..fa43aca6 100644 --- a/blockloom-app/tests/sync.rs +++ b/blockloom-app/tests/sync.rs @@ -687,6 +687,10 @@ fn interface_property_and_parent_transactions_round_trip_and_undo() { json!({"kind": "Create", "widget": {"element": {"id": "fresh", "parent": "canvas"}}}), json!({"kind": "Duplicate", "id": "canvas", "new_id": "canvas2"}), json!({"kind": "Delete", "id": "canvas"}), + json!({"kind": "Batch", "edits": [ + {"kind": "SetProperty", "id": "child", "property": {"path": "element.content", "value": "b"}}, + {"kind": "Create", "widget": {"element": {"id": "x"}}} + ]}), ] { let before = backend.dispatch("get_state", json!({})).unwrap()["project"]["world"]["interface"] diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index bd432be7..39363476 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -282,6 +282,125 @@ pub enum UiPropertyEdit { Layout(#[serde(deserialize_with = "deserialize_edit_layout")] Option), } +/// How the inspector should draw a property path. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct UiPropertyInfo { + pub path: &'static str, + pub label: &'static str, + pub group: &'static str, + /// `choice`, `text`, `bool` or `layout`. + pub ty: &'static str, + /// Choices for a `choice`; empty otherwise. + pub choices: Vec, + /// Kinds the property applies to; empty means every kind. + pub kinds: Vec<&'static str>, +} + +impl UiPropertyEdit { + pub fn path(&self) -> &'static str { + match self { + UiPropertyEdit::Kind(_) => "element.kind", + UiPropertyEdit::Content(_) => "element.content", + UiPropertyEdit::Anchor(_) => "element.anchor", + UiPropertyEdit::Modal(_) => "element.modal", + UiPropertyEdit::Layout(_) => "layout", + } + } +} + +/// One entry per `UiPropertyEdit` path; a test keeps them in step. +pub fn property_metadata() -> Vec { + let names = |values: serde_json::Value| -> Vec { + values + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + }; + let kinds = names( + serde_json::to_value([ + UiKind::Panel, + UiKind::Label, + UiKind::Button, + UiKind::Image, + UiKind::Input, + UiKind::Slider, + UiKind::Toggle, + UiKind::List, + UiKind::VerticalBox, + UiKind::HorizontalBox, + UiKind::Grid, + UiKind::Canvas, + UiKind::WrapBox, + UiKind::SizeBox, + UiKind::Spacer, + UiKind::Progress, + UiKind::RadialProgress, + UiKind::ListView, + UiKind::Tabs, + UiKind::Select, + UiKind::Scrollbar, + UiKind::RichText, + UiKind::Tooltip, + ]) + .unwrap_or_default(), + ); + let anchors = names( + serde_json::to_value([ + UiAnchor::TopLeft, + UiAnchor::Top, + UiAnchor::TopRight, + UiAnchor::Left, + UiAnchor::Center, + UiAnchor::Right, + UiAnchor::BottomLeft, + UiAnchor::Bottom, + UiAnchor::BottomRight, + ]) + .unwrap_or_default(), + ); + let info = + |path, label, group, ty, choices: Vec, kinds: Vec<&'static str>| UiPropertyInfo { + path, + label, + group, + ty, + choices, + kinds, + }; + vec![ + info("element.kind", "Type", "Content", "choice", kinds, vec![]), + info( + "element.content", + "Content", + "Content", + "text", + vec![], + vec![], + ), + info( + "element.anchor", + "Anchor", + "Layout", + "choice", + anchors, + vec![], + ), + info( + "element.modal", + "Modal", + "Interaction", + "bool", + vec![], + vec!["Panel"], + ), + info("layout", "Layout", "Layout", "layout", vec![], vec![]), + ] +} + fn deserialize_edit_layout<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { @@ -340,6 +459,10 @@ pub enum UiEdit { parent: String, placement: UiPlacement, }, + /// Applies several edits in order as one; any failure rejects them all. + Batch { + edits: Vec, + }, /// Appends a new widget; a Canvas parent makes it free, any other parent flow. Create { widget: Box, @@ -378,6 +501,15 @@ impl UiDocument { fn apply_edit_inner(&mut self, edit: &UiEdit) -> Result<(), String> { match edit { + UiEdit::Batch { edits } => { + if edits.len() > 1000 || edits.iter().any(|e| matches!(e, UiEdit::Batch { .. })) { + return Err("A batch holds at most 1000 edits and cannot nest".into()); + } + for edit in edits { + self.apply_edit_inner(edit)?; + } + return Ok(()); + } UiEdit::Create { widget } => return self.create_widget(widget), UiEdit::Delete { id } => return self.delete_subtree(id), UiEdit::Duplicate { id, new_id, offset } => { @@ -391,9 +523,10 @@ impl UiDocument { | UiEdit::SetProperty { id, .. } | UiEdit::Reparent { id, .. } | UiEdit::Reorder { id, .. } => id, - UiEdit::Create { .. } | UiEdit::Delete { .. } | UiEdit::Duplicate { .. } => { - unreachable!() - } + UiEdit::Batch { .. } + | UiEdit::Create { .. } + | UiEdit::Delete { .. } + | UiEdit::Duplicate { .. } => unreachable!(), }; let index = self .widgets @@ -929,6 +1062,47 @@ mod tests { ); } + #[test] + fn property_metadata_covers_every_editable_path_and_batches_are_atomic() { + let samples = [ + UiPropertyEdit::Kind(UiKind::Label), + UiPropertyEdit::Content(String::new()), + UiPropertyEdit::Anchor(UiAnchor::Top), + UiPropertyEdit::Modal(true), + UiPropertyEdit::Layout(None), + ]; + let metadata = property_metadata(); + assert_eq!(metadata.len(), samples.len()); + for sample in &samples { + assert!(metadata.iter().any(|m| m.path == sample.path())); + } + assert!(metadata[0].choices.contains(&"Canvas".to_string())); + assert_eq!(metadata[2].choices.len(), 9); + let mut doc: UiDocument = serde_json::from_value(serde_json::json!({"widgets": [ + {"element": {"id": "a", "offset": [1, 1]}, "layout": {"absolute": true}}, + {"element": {"id": "b", "offset": [2, 2]}, "layout": {"absolute": true}} + ]})) + .unwrap(); + let parse = |value| serde_json::from_value::(value).unwrap(); + doc.apply_edit(&parse(serde_json::json!({"kind": "Batch", "edits": [ + {"kind": "Move", "id": "a", "offset": [10, 10]}, + {"kind": "Move", "id": "b", "offset": [20, 20]} + ]}))) + .unwrap(); + assert_eq!(doc.widgets[0].element.offset, [10., 10.]); + assert_eq!(doc.widgets[1].element.offset, [20., 20.]); + let before = doc.clone(); + for value in [ + serde_json::json!({"kind": "Batch", "edits": [ + {"kind": "Move", "id": "a", "offset": [99, 99]}, + {"kind": "Move", "id": "missing", "offset": [1, 1]}]}), + serde_json::json!({"kind": "Batch", "edits": [{"kind": "Batch", "edits": []}]}), + ] { + assert!(doc.apply_edit(&parse(value)).is_err()); + assert_eq!(doc, before); + } + } + #[test] fn schema_versions_migrate_forward_and_refuse_newer_documents() { let mut legacy: UiDocument = diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index a2c7336a..8bd96b20 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -26,6 +26,35 @@ Item { } // Editor-only state: never saved and never sent to the runtime. property var lockedIds: ({}) + // Further selected widgets beside selectedId (Shift-click, Ctrl-click in the tree, marquee). + property var extraIds: [] + readonly property var selectionIds: { + if (!selectedId) return []; + const have = {}; + document.widgets.forEach(w => { have[w.element.id] = true; }); + return [selectedId].concat(extraIds.filter(id => have[id] && id !== selectedId && inScreen(id))); + } + // A selection never lists a widget together with its ancestor: the ancestor's edit covers it. + function topLevelSelection() { + return selectionIds.filter(id => !selectionIds.some(other => other !== id && descendant(id, other))); + } + function toggleSelected(id) { + if (!id) return; + if (!selectedId) { selectedId = id; return; } + if (id === selectedId) { selectedId = extraIds.length ? extraIds[0] : ""; extraIds = extraIds.slice(1); return; } + extraIds = extraIds.indexOf(id) >= 0 ? extraIds.filter(x => x !== id) : extraIds.concat([id]); + } + function selectOnly(id) { extraIds = []; selectedId = id; } + property var marquee: null + function finishMarquee(m) { + const x0 = Math.min(m.x0, m.x1), x1 = Math.max(m.x0, m.x1), y0 = Math.min(m.y0, m.y1), y1 = Math.max(m.y0, m.y1); + const ids = pickable.filter(b => b.visible && editableId(b.id)).filter(b => { + const c = geometry.point(b.transform, 0, 0); + return c.x >= x0 && c.x <= x1 && c.y >= y0 && c.y <= y1; + }).sort((a, b) => a.paint_order - b.paint_order).map(b => b.id); + extraIds = ids.slice(1); selectedId = ids.length ? ids[0] : ""; + } + function editableId(id) { return document.widgets.some(w => w.element.id === id && !w.world_actor); } property var hiddenIds: ({}) property var collapsedIds: ({}) function toggleHidden(id) { @@ -173,7 +202,7 @@ Item { g.busy = false; if (g.canceled || root.gesture !== g) return; root.document = next; - if (g.select !== undefined) root.selectedId = g.select; + if (g.select !== undefined) { root.extraIds = g.more || []; root.selectedId = g.select; } root.flushEdit(g); }, function(e) { root.failEdit(g, e); }); } else if (g.released) { @@ -325,6 +354,7 @@ Item { Keys.priority: Keys.AfterItem Keys.onPressed: event => { if (event.key === Qt.Key_Escape && gesture) { cancelEdit(); event.accepted = true; } + else if ((event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) && activeFocus && designing && !gesture && widget) { removeSelected(); event.accepted = true; } else event.accepted = nudge(event); } Keys.onReleased: event => { @@ -389,6 +419,8 @@ Item { onPreviewHeightChanged: { cancelEdit(); ++generation; frameLayout = null; if (designing) previewDelay.restart(); } onSelectedIdChanged: { if (gesture && gesture.keyboard) cancelEdit(); overlay.requestPaint(); } onHoveredIdChanged: overlay.requestPaint() + onMarqueeChanged: overlay.requestPaint() + onExtraIdsChanged: overlay.requestPaint() onSnapLinesChanged: overlay.requestPaint() onFrameLayoutChanged: overlay.requestPaint() onLayoutReadyChanged: overlay.requestPaint() @@ -444,10 +476,10 @@ Item { style[styleState.currentText][field] = value; extra("style", style); } - function structuralEdit(edit, select) { + function structuralEdit(edit, select, more) { if (!designing || gesture) return false; const g = {kind: edit.kind, id: "", original: copy(document), token: null, bound: null, committing: false, - edit: edit, sent: null, busy: false, released: true, canceled: false, select: select}; + edit: edit, sent: null, busy: false, released: true, canceled: false, select: select, more: more || []}; gesture = g; forceActiveFocus(); const backend = app; @@ -474,16 +506,27 @@ Item { } function duplicateSelected() { if (!widget || widget.world_actor) return; - const id = widget.element.id, base = id + "-copy"; - let name = base, n = 1; - while (document.widgets.some(w => w.element.id === name)) name = base + (++n); - const edit = {kind: "Duplicate", id: id, new_id: name}; - if (editable(widget)) { const o = widget.element.offset || [0,0]; edit.offset = [o[0]+16, o[1]+16]; } - structuralEdit(edit, name); + const taken = {}; + document.widgets.forEach(w => { taken[w.element.id] = true; }); + const edits = []; + topLevelSelection().forEach(id => { + const source = document.widgets.find(w => w.element.id === id); + if (!source || source.world_actor) return; + const base = id + "-copy"; + let name = base, n = 1; + while (taken[name]) name = base + (++n); + taken[name] = true; + const edit = {kind: "Duplicate", id: id, new_id: name}; + if (editable(source)) { const o = source.element.offset || [0,0]; edit.offset = [o[0]+16, o[1]+16]; } + edits.push(edit); + }); + if (!edits.length) return; + structuralEdit(edits.length === 1 ? edits[0] : {kind: "Batch", edits: edits}, edits[0].new_id, edits.slice(1).map(e => e.new_id)); } function removeSelected() { if (!widget) return; - structuralEdit({kind: "Delete", id: widget.element.id}, ""); + const edits = topLevelSelection().map(id => ({kind: "Delete", id: id})); + structuralEdit(edits.length === 1 ? edits[0] : {kind: "Batch", edits: edits}, ""); } RowLayout { anchors.fill: parent; spacing: 0 @@ -526,8 +569,8 @@ Item { delegate: ItemDelegate { required property var modelData width: ListView.view.width; height: 30 - highlighted: root.selectedId === modelData.id - onClicked: { root.selectedId = modelData.id; root.forceActiveFocus(); } + highlighted: root.selectionIds.indexOf(modelData.id) >= 0 + onClicked: mouse => { if (mouse && (mouse.modifiers & (Qt.ControlModifier | Qt.ShiftModifier))) root.toggleSelected(modelData.id); else root.selectOnly(modelData.id); root.forceActiveFocus(); } contentItem: RowLayout { spacing: 2 Item { Layout.preferredWidth: 10 + modelData.depth * 14 } @@ -631,7 +674,14 @@ Item { ctx.lineWidth = 2/Math.max(0.05, root.zoom); ctx.stroke(); } if (root.hoveredId !== root.selectedId) outline(root.hoveredId, "#b9dfff"); + root.selectionIds.slice(1).forEach(id => outline(id, "#8fd0a0")); outline(root.selectedId, "#70baff"); + const m = root.marquee; + if (m) { + ctx.fillStyle = "rgba(112,186,255,0.15)"; ctx.strokeStyle = "#70baff"; ctx.lineWidth = 1/Math.max(0.05,root.zoom); + ctx.fillRect(Math.min(m.x0,m.x1), Math.min(m.y0,m.y1), Math.abs(m.x1-m.x0), Math.abs(m.y1-m.y0)); + ctx.strokeRect(Math.min(m.x0,m.x1), Math.min(m.y0,m.y1), Math.abs(m.x1-m.x0), Math.abs(m.y1-m.y0)); + } ctx.strokeStyle = "#ffcc70"; ctx.lineWidth = 1/Math.max(0.05,root.zoom); root.snapLines.forEach(line => { ctx.beginPath(); ctx.moveTo(line.a.x,line.a.y); ctx.lineTo(line.b.x,line.b.y); ctx.stroke(); }); @@ -645,6 +695,7 @@ Item { property real pressY: 0 hoverEnabled: true onPositionChanged: mouse => { + if (pressed && marqueeStart) { root.marquee = {x0: marqueeStart.x, y0: marqueeStart.y, x1: mouse.x, y1: mouse.y}; return; } if (pressed) { if (!root.gesture && Math.hypot(mouse.x-pressX, mouse.y-pressY) > 3/Math.max(0.05,root.zoom)) root.startEdit("Move", pressX, pressY); root.dragEdit(mouse.x, mouse.y, mouse.modifiers); @@ -654,11 +705,19 @@ Item { onExited: root.hoveredId = "" onPressed: mouse => { root.forceActiveFocus(); - root.selectedId = geometry.pick(root.pickable, mouse.x, mouse.y); + const hit = geometry.pick(root.pickable, mouse.x, mouse.y); pressX = mouse.x; pressY = mouse.y; + if (mouse.modifiers & Qt.ShiftModifier) { root.toggleSelected(hit); marqueeStart = null; } + else if (!hit) { root.selectOnly(""); marqueeStart = {x: mouse.x, y: mouse.y}; } + else { if (hit !== root.selectedId) root.selectOnly(hit); marqueeStart = null; } + } + property var marqueeStart: null + onReleased: { + if (marqueeStart && root.marquee) root.finishMarquee(root.marquee); + marqueeStart = null; root.marquee = null; + root.finishEdit(); } - onReleased: root.finishEdit() - onCanceled: root.cancelEdit() + onCanceled: { marqueeStart = null; root.marquee = null; root.cancelEdit(); } } Repeater { model: root.resizeHandles diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 56bb5599..7005fdd2 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -31,14 +31,18 @@ TestCase { } else if (command === "update_interface_edit") { const next = JSON.parse(JSON.stringify(test.draft)); const w = next.widgets.find(w => w.element.id === args.edit.id); - if (args.edit.kind === "Create") next.widgets.push(args.edit.widget); - if (args.edit.kind === "Delete") next.widgets = next.widgets.filter(item => item.element.id !== args.edit.id); - if (args.edit.kind === "Duplicate") { - const copy = JSON.parse(JSON.stringify(w)); - copy.element.id = args.edit.new_id; - if (args.edit.offset) copy.element.offset = args.edit.offset; - next.widgets.push(copy); - } + const structural = edit => { + const target = next.widgets.find(item => item.element.id === edit.id); + if (edit.kind === "Create") next.widgets.push(edit.widget); + if (edit.kind === "Delete") next.widgets = next.widgets.filter(item => item.element.id !== edit.id); + if (edit.kind === "Duplicate") { + const copy = JSON.parse(JSON.stringify(target)); + copy.element.id = edit.new_id; + if (edit.offset) copy.element.offset = edit.offset; + next.widgets.push(copy); + } + }; + if (args.edit.kind === "Batch") args.edit.edits.forEach(structural); else structural(args.edit); if (args.edit.kind === "Move" || args.edit.kind === "Resize") w.element.offset = args.edit.offset; if (args.edit.kind === "Resize") w.element.size = args.edit.size; if (args.edit.kind === "SetProperty") { @@ -365,6 +369,34 @@ TestCase { compare(panel.hiddenList, []); verify(!calls.some(c => c.command === "set_interface")); } + function test_multiselection_deletes_and_duplicates_as_one_batch() { + panel.selectOnly("back"); + panel.toggleSelected("front"); + compare(panel.selectionIds, ["back", "front"]); + panel.duplicateSelected(); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "Batch"); + compare(edit.edits.map(e=>e.id+">"+e.new_id), ["back>back-copy", "front>front-copy"]); + compare(calls.filter(c=>c.command === "commit_interface_edit").length, 1); + panel.removeSelected(); + edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "Batch"); + verify(edit.edits.every(e=>e.kind === "Delete")); + panel.toggleSelected("front"); + panel.toggleSelected("front"); + compare(panel.selectionIds, panel.selectedId ? [panel.selectedId] : []); + } + function test_marquee_selects_unlocked_widgets_whose_centers_fall_inside() { + panel.receiveLayout(JSON.stringify(geometry(panel.revision, panel.generation))); + panel.toggleLock("front"); + panel.finishMarquee({x0: 0, y0: 0, x1: 960, y1: 720}); + compare(panel.selectionIds, ["back"]); + panel.toggleLock("front"); + panel.finishMarquee({x0: 0, y0: 0, x1: 960, y1: 720}); + compare(panel.selectionIds.length, 2); + panel.finishMarquee({x0: 0, y0: 0, x1: 10, y1: 10}); + compare(panel.selectionIds, []); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index f6ab337e..3200c9c6 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -242,7 +242,13 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - A document newer than this build is not rewritten: `validate()` refuses it with an upgrade message, so edits, previews and `set_interface` fail instead of silently dropping fields. Opening still works. Run-time loading is unaffected. - Later steps (style inheritance, component instances) add a version and a `migrate` arm each; the stored shape stays serde-defaulted so older builds keep ignoring nothing they cannot read. -The next increment is property metadata (phase 1), marquee/multiselection (phase 3), then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. +### Property metadata and multiselection (eleventh increment) + +- `ui::property_metadata()` lists every `SetProperty` path with its label, group, type, choices and the widget kinds it applies to; a test keeps it in step with `UiPropertyEdit` (`path()` is an exhaustive match). It is exposed as `interface-properties` in dispatch, shell and MCP so inspectors and agents read one source. The inspector still draws its own controls from QML; moving it onto this table is the next step, together with the remaining styling paths. +- Added `Batch {edits}`: edits applied in order as one, all or nothing, one undo step. It cannot nest and holds at most 1000 edits. +- The viewport supports Shift-click and a marquee (drag on empty canvas; widgets whose centres fall inside, skipping locked and projected ones). The tree supports Ctrl/Shift-click. Delete (button or key) and Duplicate act on the whole selection as one Batch, skipping widgets whose ancestor is also selected. Dragging, handles and nudging still act on the primary widget only; group move and alignment/distribution are next. + +The next increment is group move and alignment/distribution tools, moving the inspector onto the metadata, then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. Open phase 0 checks: embedded/process rendering and teardown on actual platforms; native presentation timing and process screenshot metadata on actual platforms; viewport resize/DPI/safe-area matrices; real image/font asset loading; screenshot baselines and gameplay input isolation with held inputs. Rotation, text editing/IME, nine-slice and animation capabilities remain unproven. The fixture image node currently has no asset. From 4238a2d4333b63adbc97462f059ad55f03d05d2a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:55:50 +0000 Subject: [PATCH 05/26] Interface editor: group move, align and distribute Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-qt/qml/UiDesigner.qml | 64 +++++++++++++++++++ .../tests/qml/tst_InterfaceViewport.qml | 20 ++++++ docs/interface-editor-plan.md | 7 +- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 8bd96b20..cbc49ddd 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -177,6 +177,9 @@ Item { grid: snapGrid, align: snapAlign, step: Math.max(1,snapStep), tolerance: [6/(Math.max(0.05,zoom)*Math.hypot(frame[0],frame[1])),6/(Math.max(0.05,zoom)*Math.hypot(frame[2],frame[3]))], scale: designScale, committing: false, edit: null, sent: null, busy: false, released: false, canceled: false}; + const others = selectionIds.slice(1).map(id => document.widgets.find(w => w.element.id === id)); + g.group = kind === "Move" && others.length && others.every(w => w && editable(w) && (w.element.parent || "") === (widget.element.parent || "")) + ? others.map(w => ({id: w.element.id, offset: (w.element.offset || [0,0]).slice()})) : null; gesture = g; forceActiveFocus(); const backend = app; @@ -235,6 +238,10 @@ Item { showSnapLines(g,hits.map(hit=>hit.target)); } g.edit = {kind: "Move", id: g.id, offset: offset}; + if (g.group) { + const shift = [offset[0]-g.offset[0], offset[1]-g.offset[1]]; + g.edit = {kind: "Batch", edits: [g.edit].concat(g.group.map(o => ({kind: "Move", id: o.id, offset: [o.offset[0]+shift[0], o.offset[1]+shift[1]]})))}; + } } else { const a = geometry.point(g.inverse, g.start.x, g.start.y), b = geometry.point(g.inverse, x, y); const direction = g.direction; @@ -523,6 +530,50 @@ Item { if (!edits.length) return; structuralEdit(edits.length === 1 ? edits[0] : {kind: "Batch", edits: edits}, edits[0].new_id, edits.slice(1).map(e => e.new_id)); } + // Align or distribute the selection inside its shared parent, as one Batch of Moves. + function arrange(op) { + const ids = topLevelSelection(); + const widgets = ids.map(id => document.widgets.find(w => w.element.id === id)); + const need = op === "hdist" || op === "vdist" ? 3 : 2; + if (!layoutReady || widgets.length < need) return false; + const parentId = widgets[0].element.parent || ""; + if (!widgets.every(w => editable(w) && (w.element.parent || "") === parentId)) { error = "Select free-placed widgets that share a parent."; return false; } + const parent = bounds.find(b => b.id === parentId); + const frame = parent ? parent.transform.slice() : [designScale,0,0,designScale,safe[0],safe[1]]; + const inverse = geometry.inverse(frame); + const boxes = {}; + for (const id of ids) { + const b = bounds.find(x => x.id === id); + if (!b || !b.visible || !inverse) { error = "Wait for matching geometry before arranging."; return false; } + const origin = geometry.point(inverse, b.transform[4], b.transform[5]); + const ax = geometry.point(inverse, b.transform[4]+b.transform[0], b.transform[5]+b.transform[1]); + const ay = geometry.point(inverse, b.transform[4]+b.transform[2], b.transform[5]+b.transform[3]); + if (Math.abs(ax.y-origin.y) > 0.001 || Math.abs(ay.x-origin.x) > 0.001 || ax.x <= origin.x || ay.y <= origin.y) { error = "Rotated widgets cannot be aligned."; return false; } + boxes[id] = boxInFrame(b, inverse); + } + const axis = op === "left" || op === "hcenter" || op === "right" || op === "hdist" ? 0 : 1; + const lo = id => boxes[id][axis], hi = id => boxes[id][axis+2], mid = id => (lo(id)+hi(id))/2; + const delta = {}; + if (op === "hdist" || op === "vdist") { + const sorted = ids.slice().sort((a, b) => mid(a) - mid(b)); + const span = hi(sorted[sorted.length-1]) - lo(sorted[0]); + const total = sorted.reduce((sum, id) => sum + hi(id) - lo(id), 0); + const gap = (span - total) / (sorted.length - 1); + let at = lo(sorted[0]); + sorted.forEach(id => { delta[id] = at - lo(id); at += hi(id) - lo(id) + gap; }); + } else { + const first = op === "left" || op === "top", last = op === "right" || op === "bottom"; + const target = first ? Math.min(...ids.map(lo)) : last ? Math.max(...ids.map(hi)) : (Math.min(...ids.map(lo)) + Math.max(...ids.map(hi))) / 2; + ids.forEach(id => { delta[id] = target - (first ? lo(id) : last ? hi(id) : mid(id)); }); + } + const edits = widgets.filter(w => Math.abs(delta[w.element.id]) > 0.005).map(w => { + const offset = (w.element.offset || [0,0]).slice(); + offset[axis] = Math.round((offset[axis] + delta[w.element.id]) * 100) / 100; + return {kind: "Move", id: w.element.id, offset: offset}; + }); + if (!edits.length) return true; + return structuralEdit(edits.length === 1 ? edits[0] : {kind: "Batch", edits: edits}, selectedId, extraIds.slice()); + } function removeSelected() { if (!widget) return; const edits = topLevelSelection().map(id => ({kind: "Delete", id: id})); @@ -604,6 +655,19 @@ Item { Button { objectName: "interfaceLater"; text: "Later"; enabled: root.designing && !root.gesture && root.siblingIndex >= 0 && root.siblingIndex+1 < root.siblings.length; onClicked: root.reorderSibling(1) } } Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: "Sibling order controls flow layout and draw order." } + GridLayout { + columns: 4; Layout.leftMargin: 6; Layout.rightMargin: 6 + enabled: root.designing && !root.gesture && root.selectionIds.length > 1 + Repeater { + model: [["Left","left"],["Mid","hcenter"],["Right","right"],["Space H","hdist"],["Top","top"],["Mid","vcenter"],["Bottom","bottom"],["Space V","vdist"]] + delegate: Button { + required property var modelData + objectName: "interfaceArrange_" + modelData[1] + Layout.fillWidth: true; Layout.preferredWidth: 1; text: modelData[0] + onClicked: root.arrange(modelData[1]) + } + } + } RowLayout { Button { objectName: "interfaceDuplicate"; text: "Duplicate"; enabled: root.designing && !root.gesture && !!root.widget && !root.widget.world_actor; onClicked: root.duplicateSelected() } Button { objectName: "interfaceDelete"; text: "Delete"; enabled: root.designing && !root.gesture && !!root.widget; onClicked: root.removeSelected() } diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 7005fdd2..78925c2c 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -397,6 +397,26 @@ TestCase { panel.finishMarquee({x0: 0, y0: 0, x1: 10, y1: 10}); compare(panel.selectionIds, []); } + function test_align_and_distribute_send_one_batch_of_moves() { + const d = {widgets: [ + {element: {id: "a", kind: "Panel", offset: [0,0], size: [100,50], anchor: "TopLeft"}}, + {element: {id: "b", kind: "Panel", offset: [200,30], size: [100,50], anchor: "TopLeft"}}, + {element: {id: "c", kind: "Panel", offset: [300,60], size: [100,50], anchor: "TopLeft"}}]}; + panel.document = d; backend.appState.project.world.interface = d; + const box = (id, x, y) => ({id: id, size: [100,50], transform: [1,0,0,1,x+50,y+25], visible: true, paint_order: 1, clips: []}); + panel.receiveLayout(JSON.stringify({revision: panel.revision, generation: panel.generation, viewport: [960,720], widgets: [box("a",0,0), box("b",200,30), box("c",300,60)]})); + panel.selectOnly("a"); panel.toggleSelected("b"); panel.toggleSelected("c"); + verify(panel.arrange("top")); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "Batch"); + compare(edit.edits.map(e=>e.id+":"+e.offset[1]), ["b:0", "c:0"]); + compare(calls.filter(c=>c.command === "commit_interface_edit").length, 1); + panel.receiveLayout(JSON.stringify({revision: panel.revision, generation: panel.generation, viewport: [960,720], widgets: [box("a",0,0), box("b",200,0), box("c",300,0)]})); + verify(panel.arrange("hdist")); + edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "Move"); + compare(edit.id, "b"); compare(edit.offset[0], 150); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 3200c9c6..a5239cf4 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -248,7 +248,12 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - Added `Batch {edits}`: edits applied in order as one, all or nothing, one undo step. It cannot nest and holds at most 1000 edits. - The viewport supports Shift-click and a marquee (drag on empty canvas; widgets whose centres fall inside, skipping locked and projected ones). The tree supports Ctrl/Shift-click. Delete (button or key) and Duplicate act on the whole selection as one Batch, skipping widgets whose ancestor is also selected. Dragging, handles and nudging still act on the primary widget only; group move and alignment/distribution are next. -The next increment is group move and alignment/distribution tools, moving the inspector onto the metadata, then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. +### Group move, align and distribute (twelfth increment) + +- Dragging a widget that belongs to a multiselection moves the whole selection when every member is free-placed under the same parent: the draft is one `Batch` of `Move`s sharing the primary's snapped delta. Mixed selections drag only the primary widget. Handles and keyboard nudging remain primary-only. +- Align (left, middle, right, top, middle, bottom) and Space H/V (equal gaps between the outer widgets) are QML computations over the runtime geometry, in the shared parent's frame, committed as one `Batch` of `Move`s. They require free-placed, unrotated widgets with one parent and report why otherwise. They write exact authored offsets, so no new backend command is needed. + +The next increment is moving the inspector onto the metadata, then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. Open phase 0 checks: embedded/process rendering and teardown on actual platforms; native presentation timing and process screenshot metadata on actual platforms; viewport resize/DPI/safe-area matrices; real image/font asset loading; screenshot baselines and gameplay input isolation with held inputs. Rotation, text editing/IME, nine-slice and animation capabilities remain unproven. The fixture image node currently has no asset. From db2cf005ca4ef09b2ef533f329621da291bc334f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:55:58 +0000 Subject: [PATCH 06/26] AGENTS: note group move and align Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 27afff5c..fd5b6a17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3080,7 +3080,7 @@ appends a widget (a Canvas parent forces it free, any other parent flow), copies the subtree after the original as `new_id` and `new_id.`, remapping parents and `scroll_target`s inside the copy only. Blocks and scripts that name the original ids are not rewritten. `set_interface` stays -the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. `Batch` applies several edits atomically; `property_metadata()` (`interface-properties`) is the table of `SetProperty` paths and is kept in step with `UiPropertyEdit` by a test. Selection is `selectedId` plus `extraIds` in QML (Shift-click, marquee) and Delete/Duplicate send one `Batch`. A new edit shape needs the `update-interface-edit` +the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. `Batch` applies several edits atomically; `property_metadata()` (`interface-properties`) is the table of `SetProperty` paths and is kept in step with `UiPropertyEdit` by a test. Selection is `selectedId` plus `extraIds` in QML (Shift-click, marquee) and Delete/Duplicate send one `Batch`. Group drag, Align and Space H/V (`arrange`) compute in QML from runtime geometry and send a `Batch` of `Move`s. A new edit shape needs the `update-interface-edit` summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. ### Android devices and the emulator From 01fab13ebd9582f8fb801bcca22415861dc6f008 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:33:04 +0000 Subject: [PATCH 07/26] Interface editor: reload-free commit, typed content/style/document edits Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 +- blockloom-app/src/commands.rs | 6 +- blockloom-app/src/shell.rs | 2 +- blockloom-app/tests/sync.rs | 14 + blockloom-core/src/ui_framework.rs | 246 ++++++++++++++++++ blockloom-qt/qml/UiDesigner.qml | 6 +- .../tests/qml/tst_InterfaceViewport.qml | 12 + docs/interface-editor-plan.md | 7 + 8 files changed, 290 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd5b6a17..f056d1f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3080,7 +3080,7 @@ appends a widget (a Canvas parent forces it free, any other parent flow), copies the subtree after the original as `new_id` and `new_id.`, remapping parents and `scroll_target`s inside the copy only. Blocks and scripts that name the original ids are not rewritten. `set_interface` stays -the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. `Batch` applies several edits atomically; `property_metadata()` (`interface-properties`) is the table of `SetProperty` paths and is kept in step with `UiPropertyEdit` by a test. Selection is `selectedId` plus `extraIds` in QML (Shift-click, marquee) and Delete/Duplicate send one `Batch`. Group drag, Align and Space H/V (`arrange`) compute in QML from runtime geometry and send a `Batch` of `Move`s. A new edit shape needs the `update-interface-edit` +the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. `Batch` applies several edits atomically; `property_metadata()` (`interface-properties`) is the table of `SetProperty` paths and is kept in step with `UiPropertyEdit` by a test. Selection is `selectedId` plus `extraIds` in QML (Shift-click, marquee) and Delete/Duplicate send one `Batch`. Group drag, Align and Space H/V (`arrange`) compute in QML from runtime geometry and send a `Batch` of `Move`s. Content and style fields (`style`, `class`, `bindings`, `items`, `tooltip`, `scroll_target`, `tab_index`, `transition`, `element.range`) plus `SetDocument` and `SetClass` are typed too, so `set_interface` is only the import path. Committing with a design session open sends no `Load`. A new edit shape needs the `update-interface-edit` summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. ### Android devices and the emulator diff --git a/blockloom-app/src/commands.rs b/blockloom-app/src/commands.rs index 31525697..48a0cdb5 100644 --- a/blockloom-app/src/commands.rs +++ b/blockloom-app/src/commands.rs @@ -5842,7 +5842,11 @@ pub(crate) fn commit_interface_edit( push_undo(&mut s); s.project_mut().unwrap().world.interface = draft.document; auto_save(&s); - sync_runtime(&mut s); + // The design session already shows this document, and Play loads the whole + // project, so an open session needs no world reload. + if s.interface_design.is_none() || s.runtime.is_none() { + sync_runtime(&mut s); + } emit(app, &s); Ok(()) } diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 648cc778..925b2ad1 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. Property paths: element.kind/content/anchor/modal, layout (object or null). Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-app/tests/sync.rs b/blockloom-app/tests/sync.rs index fa43aca6..d8b86f90 100644 --- a/blockloom-app/tests/sync.rs +++ b/blockloom-app/tests/sync.rs @@ -492,6 +492,20 @@ fn interface_preview_never_saves_a_draft_and_cancel_uses_the_same_runtime() { ); assert_eq!(std::fs::read(dir.join("project.blockloom")).unwrap(), saved); assert_eq!(blockloom_core::sync::read_revision(&dir), revision); + // Committing while a session is open saves once without reloading the world. + let token = backend + .dispatch("begin_interface_edit", json!({"revision": revision})) + .unwrap(); + backend + .dispatch( + "update_interface_edit", + json!({"token": token, "edit": {"kind": "Create", "widget": {"element": {"id": "fresh"}}}}), + ) + .unwrap(); + backend + .dispatch("commit_interface_edit", json!({"token": token})) + .unwrap(); + assert_eq!(blockloom_core::sync::read_revision(&dir), revision + 1); backend.dispatch("preview_interface", json!({})).unwrap(); let receiver = host.0.lock().unwrap().take().unwrap(); let messages: Vec<_> = receiver.try_iter().collect(); diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 39363476..3c2d7c8a 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -280,6 +280,24 @@ pub enum UiPropertyEdit { Modal(bool), #[serde(rename = "layout")] Layout(#[serde(deserialize_with = "deserialize_edit_layout")] Option), + #[serde(rename = "element.range")] + Range([f32; 2]), + #[serde(rename = "style")] + Style(Box), + #[serde(rename = "class")] + Class(String), + #[serde(rename = "bindings")] + Bindings(Vec), + #[serde(rename = "items")] + Items(Vec), + #[serde(rename = "tooltip")] + Tooltip(String), + #[serde(rename = "scroll_target")] + ScrollTarget(String), + #[serde(rename = "tab_index")] + TabIndex(Option), + #[serde(rename = "transition")] + Transition(f32), } /// How the inspector should draw a property path. @@ -304,6 +322,15 @@ impl UiPropertyEdit { UiPropertyEdit::Anchor(_) => "element.anchor", UiPropertyEdit::Modal(_) => "element.modal", UiPropertyEdit::Layout(_) => "layout", + UiPropertyEdit::Range(_) => "element.range", + UiPropertyEdit::Style(_) => "style", + UiPropertyEdit::Class(_) => "class", + UiPropertyEdit::Bindings(_) => "bindings", + UiPropertyEdit::Items(_) => "items", + UiPropertyEdit::Tooltip(_) => "tooltip", + UiPropertyEdit::ScrollTarget(_) => "scroll_target", + UiPropertyEdit::TabIndex(_) => "tab_index", + UiPropertyEdit::Transition(_) => "transition", } } } @@ -398,6 +425,57 @@ pub fn property_metadata() -> Vec { vec!["Panel"], ), info("layout", "Layout", "Layout", "layout", vec![], vec![]), + info( + "element.range", + "Range", + "Content", + "range", + vec![], + vec!["Slider", "Progress", "RadialProgress", "Scrollbar"], + ), + info("style", "Style", "Appearance", "style", vec![], vec![]), + info("class", "Style class", "Appearance", "text", vec![], vec![]), + info( + "bindings", + "Bindings", + "Bindings", + "bindings", + vec![], + vec![], + ), + info( + "items", + "Items", + "Content", + "lines", + vec![], + vec!["List", "ListView", "Select", "Tabs"], + ), + info("tooltip", "Tooltip", "Interaction", "text", vec![], vec![]), + info( + "scroll_target", + "Scroll target", + "Interaction", + "widget", + vec![], + vec!["Scrollbar"], + ), + info( + "tab_index", + "Tab page", + "Interaction", + "int", + vec![], + vec![], + ), + info( + "transition", + "Transition seconds", + "Appearance", + "number", + vec![], + vec![], + ), ] } @@ -463,6 +541,18 @@ pub enum UiEdit { Batch { edits: Vec, }, + /// Sets document-wide options; omitted fields stay as they are. + SetDocument { + #[serde(default)] + theme: Option, + #[serde(default)] + scale: Option, + }, + /// Defines or (with null) removes a shared named style. + SetClass { + name: String, + styles: Option>, + }, /// Appends a new widget; a Canvas parent makes it free, any other parent flow. Create { widget: Box, @@ -510,6 +600,29 @@ impl UiDocument { } return Ok(()); } + UiEdit::SetDocument { theme, scale } => { + if let Some(theme) = theme { + self.theme = *theme; + } + if let Some(scale) = scale { + self.scale = *scale; + } + return Ok(()); + } + UiEdit::SetClass { name, styles } => { + if name.trim().is_empty() { + return Err("A style class needs a name".into()); + } + match styles { + Some(styles) => { + self.styles.insert(name.clone(), (**styles).clone()); + } + None => { + self.styles.remove(name); + } + } + return Ok(()); + } UiEdit::Create { widget } => return self.create_widget(widget), UiEdit::Delete { id } => return self.delete_subtree(id), UiEdit::Duplicate { id, new_id, offset } => { @@ -524,6 +637,8 @@ impl UiDocument { | UiEdit::Reparent { id, .. } | UiEdit::Reorder { id, .. } => id, UiEdit::Batch { .. } + | UiEdit::SetDocument { .. } + | UiEdit::SetClass { .. } | UiEdit::Create { .. } | UiEdit::Delete { .. } | UiEdit::Duplicate { .. } => unreachable!(), @@ -558,6 +673,7 @@ impl UiDocument { return Ok(()); } if let UiEdit::SetProperty { property, .. } = edit { + let ids: HashSet = self.widgets.iter().map(|w| w.element.id.clone()).collect(); let w = &mut self.widgets[index]; match property { UiPropertyEdit::Kind(value) => w.element.kind = *value, @@ -570,6 +686,49 @@ impl UiDocument { } w.layout = value.clone(); } + UiPropertyEdit::Range(value) => { + if value.iter().any(|n| !n.is_finite()) || value[0] >= value[1] { + return Err("Range must be finite with min below max".into()); + } + w.element.range = *value; + } + UiPropertyEdit::Style(value) => { + let bad = [ + &value.normal, + &value.hover, + &value.pressed, + &value.disabled, + &value.focused, + ] + .into_iter() + .any(|p| { + [p.text_size, p.border_width, p.radius] + .into_iter() + .flatten() + .any(|n| !n.is_finite() || n < 0.) + }); + if bad { + return Err("Style sizes must be finite and not negative".into()); + } + w.style = (**value).clone(); + } + UiPropertyEdit::Class(value) => w.class = value.clone(), + UiPropertyEdit::Bindings(value) => w.bindings = value.clone(), + UiPropertyEdit::Items(value) => w.items = value.clone(), + UiPropertyEdit::Tooltip(value) => w.tooltip = value.clone(), + UiPropertyEdit::ScrollTarget(value) => { + if !value.is_empty() && !ids.contains(value.as_str()) { + return Err(format!("Unknown scroll target: {value}")); + } + w.scroll_target = value.clone(); + } + UiPropertyEdit::TabIndex(value) => w.tab_index = *value, + UiPropertyEdit::Transition(value) => { + if !value.is_finite() || *value < 0. { + return Err("Transition must be finite and not negative".into()); + } + w.transition = *value; + } } return Ok(()); } @@ -1070,6 +1229,15 @@ mod tests { UiPropertyEdit::Anchor(UiAnchor::Top), UiPropertyEdit::Modal(true), UiPropertyEdit::Layout(None), + UiPropertyEdit::Range([0., 1.]), + UiPropertyEdit::Style(Box::default()), + UiPropertyEdit::Class(String::new()), + UiPropertyEdit::Bindings(vec![]), + UiPropertyEdit::Items(vec![]), + UiPropertyEdit::Tooltip(String::new()), + UiPropertyEdit::ScrollTarget(String::new()), + UiPropertyEdit::TabIndex(None), + UiPropertyEdit::Transition(0.), ]; let metadata = property_metadata(); assert_eq!(metadata.len(), samples.len()); @@ -1103,6 +1271,84 @@ mod tests { } } + #[test] + fn content_style_and_document_edits_validate_and_apply() { + let mut doc: UiDocument = serde_json::from_value(serde_json::json!({"widgets": [ + {"element": {"id": "bar", "kind": "Scrollbar"}}, + {"element": {"id": "list", "kind": "List"}} + ]})) + .unwrap(); + let parse = |value| serde_json::from_value::(value).unwrap(); + let prop = |id: &str, path: &str, value: serde_json::Value| { + parse( + serde_json::json!({"kind": "SetProperty", "id": id, "property": {"path": path, "value": value}}), + ) + }; + doc.apply_edit(&prop("bar", "scroll_target", "list".into())) + .unwrap(); + doc.apply_edit(&prop("list", "items", serde_json::json!(["a", "b"]))) + .unwrap(); + doc.apply_edit(&prop( + "list", + "style", + serde_json::json!({"normal": {"background": "#102030", "radius": 4.0}}), + )) + .unwrap(); + doc.apply_edit(&prop( + "list", + "bindings", + serde_json::json!([{"property": "Visible", "source": {"Variable": {"name": "shown"}}}]), + )) + .unwrap(); + doc.apply_edit(&prop("list", "tab_index", serde_json::json!(2))) + .unwrap(); + doc.apply_edit(&prop("list", "element.range", serde_json::json!([0, 10]))) + .unwrap(); + assert_eq!(doc.widgets[0].scroll_target, "list"); + assert_eq!(doc.widgets[1].items, ["a", "b"]); + assert_eq!(doc.widgets[1].style.normal.radius, Some(4.)); + assert_eq!(doc.widgets[1].bindings.len(), 1); + assert_eq!(doc.widgets[1].tab_index, Some(2)); + let before = doc.clone(); + for edit in [ + prop("bar", "scroll_target", "missing".into()), + prop("list", "element.range", serde_json::json!([5, 5])), + prop("list", "transition", serde_json::json!(-1)), + prop( + "list", + "style", + serde_json::json!({"normal": {"radius": -2.0}}), + ), + prop( + "list", + "bindings", + serde_json::json!([{"property": "Value", "source": {"Variable": {"name": "x"}}}, {"property": "Tooltip", "source": {"Variable": {"name": "x"}}}]), + ), + ] { + assert!(doc.apply_edit(&edit).is_err()); + assert_eq!(doc, before); + } + doc.apply_edit(&parse( + serde_json::json!({"kind": "SetDocument", "theme": "Light", "scale": "ScaleWithSize"}), + )) + .unwrap(); + assert_eq!(doc.theme, UiTheme::Light); + assert_eq!(doc.scale, UiScale::ScaleWithSize); + doc.apply_edit(&parse(serde_json::json!({"kind": "SetClass", "name": "title", "styles": {"normal": {"text_size": 24.0}}}))).unwrap(); + assert_eq!(doc.styles["title"].normal.text_size, Some(24.)); + doc.apply_edit(&parse( + serde_json::json!({"kind": "SetClass", "name": "title", "styles": null}), + )) + .unwrap(); + assert!(doc.styles.is_empty()); + assert!( + doc.apply_edit(&parse( + serde_json::json!({"kind": "SetClass", "name": " ", "styles": null}) + )) + .is_err() + ); + } + #[test] fn schema_versions_migrate_forward_and_refuse_newer_documents() { let mut legacy: UiDocument = diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index cbc49ddd..8d657ade 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -472,8 +472,10 @@ Item { if (!widget) return; propertyEdit("element." + field, value); } + readonly property var typedFields: ["layout","style","class","bindings","items","tooltip","scroll_target","tab_index","transition"] function extra(field, value) { if (!widget) return; + if (typedFields.indexOf(field) >= 0) { propertyEdit(field, value); return; } const next = copy(document); next.widgets[selected][field] = value; save(next); } function paint(field, value) { @@ -684,8 +686,8 @@ Item { onActivated: root.screenId = currentIndex > 0 ? root.screens[currentIndex - 1] : "" } ComboBox { model: ["960 × 720","1280 × 720","1920 × 1080","720 × 1280"]; onActivated: { const sizes=[[960,720],[1280,720],[1920,1080],[720,1280]]; root.previewWidth=sizes[currentIndex][0]; root.previewHeight=sizes[currentIndex][1]; } } - ComboBox { model: ["Dark","Light","HighContrast"]; currentIndex: model.indexOf(root.document.theme || "Dark"); onActivated: { const d=root.copy(root.document); d.theme=currentText; root.save(d); } } - ComboBox { model: ["ConstantPixel","ScaleWithSize"]; currentIndex: model.indexOf(root.document.scale || "ConstantPixel"); onActivated: { const d=root.copy(root.document); d.scale=currentText; root.save(d); } } + ComboBox { model: ["Dark","Light","HighContrast"]; currentIndex: model.indexOf(root.document.theme || "Dark"); onActivated: root.structuralEdit({kind: "SetDocument", theme: currentText}, root.selectedId, root.extraIds.slice()) } + ComboBox { model: ["ConstantPixel","ScaleWithSize"]; currentIndex: model.indexOf(root.document.scale || "ConstantPixel"); onActivated: root.structuralEdit({kind: "SetDocument", scale: currentText}, root.selectedId, root.extraIds.slice()) } Item { Layout.fillWidth: true } } RowLayout { diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 78925c2c..e1b68f31 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -417,6 +417,18 @@ TestCase { compare(edit.kind, "Move"); compare(edit.id, "b"); compare(edit.offset[0], 150); } + function test_content_style_and_document_options_use_typed_transactions() { + panel.selectedId = "front"; + panel.extra("tooltip", "Hi"); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit, {kind:"SetProperty", id:"front", property:{path:"tooltip", value:"Hi"}}); + panel.extra("bindings", []); + panel.structuralEdit({kind:"SetDocument", theme:"Light"}, panel.selectedId, []); + edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "SetDocument"); + compare(panel.selectedId, "front"); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index a5239cf4..d192f511 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -253,6 +253,13 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - Dragging a widget that belongs to a multiselection moves the whole selection when every member is free-placed under the same parent: the draft is one `Batch` of `Move`s sharing the primary's snapped delta. Mixed selections drag only the primary widget. Handles and keyboard nudging remain primary-only. - Align (left, middle, right, top, middle, bottom) and Space H/V (equal gaps between the outer widgets) are QML computations over the runtime geometry, in the shared parent's frame, committed as one `Batch` of `Move`s. They require free-placed, unrotated widgets with one parent and report why otherwise. They write exact authored offsets, so no new backend command is needed. +### Reload-free commit and typed content/style edits (thirteenth increment) + +- Committing an edit while a design session is open no longer reloads the world: the session already shows the committed document and Play sends the whole project. Without a session (or a runtime) the old sync still runs. A backend test asserts one `Load` across a preview and a commit. +- `SetProperty` now also covers `element.range`, `style` (all five states, sizes validated), `class`, `bindings` (validated binding properties), `items`, `tooltip`, `scroll_target` (must name a widget), `tab_index` and `transition`, all listed by `interface-properties`. New `SetDocument {theme?, scale?}` and `SetClass {name, styles|null}` cover document options and shared named styles. The inspector's tooltip, scroll target, tab page, items, transition, bindings, style and advanced JSON fields, and the theme/scale pickers, send these edits instead of `set_interface`. `world_actor`, prefab save/load and whole-document import still use it. + +Remaining plan work, in order: draw the inspector from `interface-properties` (visual style editor with tokens/classes UI, font and image pickers, nine-slice and image modes), text-input caret/selection/IME, rulers/guides/safe-area overlay and zoom/pan polish (phase 2), reusable components with instances and overrides (phase 5), visual binding/event editors, animation timeline and responsive variants (phase 6), and the platform/DPI checks listed under phase 0. These are each a large increment of their own. + The next increment is moving the inspector onto the metadata, then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. Open phase 0 checks: embedded/process rendering and teardown on actual platforms; native presentation timing and process screenshot metadata on actual platforms; viewport resize/DPI/safe-area matrices; real image/font asset loading; screenshot baselines and gameplay input isolation with held inputs. Rotation, text editing/IME, nine-slice and animation capabilities remain unproven. The fixture image node currently has no asset. From e2e40731a5531a49113e0348c50ac318c0cf8afc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:34:12 +0000 Subject: [PATCH 08/26] Interface editor: colour swatches, font picker and style classes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-qt/qml/UiDesigner.qml | 62 +++++++++++++++++-- .../tests/qml/tst_InterfaceViewport.qml | 15 +++++ docs/interface-editor-plan.md | 6 +- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 8d657ade..49708207 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -485,6 +485,24 @@ Item { style[styleState.currentText][field] = value; extra("style", style); } + // The class's value for this state when the widget sets none of its own. + function inheritedPaint(field) { + if (!widget || !widget.class || !document.styles) return null; + const sheet = document.styles[widget.class]; + const paint = sheet ? sheet[styleState.currentText] : null; + return paint && paint[field] !== undefined && paint[field] !== null ? paint[field] : null; + } + function saveClass(name) { + if (!widget) return; + const edits = [{kind: "SetClass", name: name, styles: copy(widget.style || {})}, + {kind: "SetProperty", id: widget.element.id, property: {path: "class", value: name}}]; + structuralEdit({kind: "Batch", edits: edits}, selectedId, extraIds.slice()); + } + function deleteClass(name) { + const edits = [{kind: "SetClass", name: name, styles: null}].concat( + document.widgets.filter(w => w.class === name).map(w => ({kind: "SetProperty", id: w.element.id, property: {path: "class", value: ""}}))); + structuralEdit({kind: "Batch", edits: edits}, selectedId, extraIds.slice()); + } function structuralEdit(edit, select, more) { if (!designing || gesture) return false; const g = {kind: edit.kind, id: "", original: copy(document), token: null, bound: null, committing: false, @@ -853,14 +871,48 @@ Item { TextArea { Layout.fillWidth: true; Layout.preferredHeight: 70; text: root.widget ? (root.widget.items || []).join("\n") : ""; onActiveFocusChanged: if(!activeFocus && root.widget) root.extra("items",text ? text.split("\n") : []) } Label { text: "Style"; font.bold: true } ComboBox { id: styleState; Layout.fillWidth: true; model: ["normal","hover","pressed","disabled","focused"] } + RowLayout { + Label { text: "Class"; Layout.preferredWidth: 100 } + ComboBox { + objectName: "interfaceClass" + Layout.fillWidth: true + model: [""].concat(Object.keys(root.document.styles || {})) + currentIndex: root.widget ? Math.max(0, model.indexOf(root.widget.class || "")) : 0 + enabled: !!root.widget && !root.gesture + onActivated: root.extra("class", currentText) + } + } + RowLayout { + TextField { id: className; objectName: "interfaceClassName"; Layout.fillWidth: true; placeholderText: "New class name" } + Button { objectName: "interfaceSaveClass"; text: "Save style as class"; enabled: !!root.widget && !!className.text.trim() && !root.gesture; onClicked: root.saveClass(className.text.trim()) } + } + Button { objectName: "interfaceDeleteClass"; text: "Delete class"; enabled: !!root.widget && !!root.widget.class && !root.gesture; onClicked: root.deleteClass(root.widget.class) } Repeater { model: ["background","text_color","border_color","shadow"] - delegate: TextField { + delegate: RowLayout { required property string modelData - Layout.fillWidth: true - placeholderText: modelData.replace(/_/g," ") + " (#RRGGBB)" - text: root.widget ? ((root.widget.style || {})[styleState.currentText] || {})[modelData] || "" : "" - onEditingFinished: root.paint(modelData,text || null) + readonly property string own: root.widget ? ((root.widget.style || {})[styleState.currentText] || {})[modelData] || "" : "" + readonly property string inherited: root.inheritedPaint(modelData) || "" + ColorField { + value: /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(own || inherited) ? (own || inherited) : "#FFFFFF" + opacity: own ? 1 : 0.5 + onPicked: c => root.paint(modelData, c) + } + TextField { + Layout.fillWidth: true + placeholderText: modelData.replace(/_/g," ") + (inherited ? " (class: " + inherited + ")" : " (#RRGGBB)") + text: own + onEditingFinished: root.paint(modelData,text || null) + } + } + } + Label { text: "Fonts (first available wins)" } + RowLayout { + AssetField { + Layout.fillWidth: true; app: root.app; accept: ["font"] + value: root.widget ? (((root.widget.style || {})[styleState.currentText] || {}).fonts || [])[0] || "" : "" + placeholderText: "Drag a font here" + onCommitted: p => root.paint("fonts", p ? [p] : []) } } Repeater { diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index e1b68f31..761c6792 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -429,6 +429,21 @@ TestCase { compare(panel.selectedId, "front"); verify(!calls.some(c=>c.command === "set_interface")); } + function test_style_classes_are_saved_and_deleted_as_one_batch() { + const d = {styles: {}, widgets: [{element: {id: "a"}, style: {normal: {background: "#102030"}}}, {element: {id: "b"}, class: "title"}]}; + panel.document = d; backend.appState.project.world.interface = d; + panel.selectedId = "a"; + panel.saveClass("title"); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "Batch"); + compare(edit.edits[0], {kind:"SetClass", name:"title", styles:{normal:{background:"#102030"}}}); + compare(edit.edits[1].property, {path:"class", value:"title"}); + panel.deleteClass("title"); + edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.edits.map(e=>e.kind), ["SetClass", "SetProperty"]); + compare(edit.edits[1].id, "b"); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index d192f511..e60182c5 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -258,7 +258,11 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - Committing an edit while a design session is open no longer reloads the world: the session already shows the committed document and Play sends the whole project. Without a session (or a runtime) the old sync still runs. A backend test asserts one `Load` across a preview and a commit. - `SetProperty` now also covers `element.range`, `style` (all five states, sizes validated), `class`, `bindings` (validated binding properties), `items`, `tooltip`, `scroll_target` (must name a widget), `tab_index` and `transition`, all listed by `interface-properties`. New `SetDocument {theme?, scale?}` and `SetClass {name, styles|null}` cover document options and shared named styles. The inspector's tooltip, scroll target, tab page, items, transition, bindings, style and advanced JSON fields, and the theme/scale pickers, send these edits instead of `set_interface`. `world_actor`, prefab save/load and whole-document import still use it. -Remaining plan work, in order: draw the inspector from `interface-properties` (visual style editor with tokens/classes UI, font and image pickers, nine-slice and image modes), text-input caret/selection/IME, rulers/guides/safe-area overlay and zoom/pan polish (phase 2), reusable components with instances and overrides (phase 5), visual binding/event editors, animation timeline and responsive variants (phase 6), and the platform/DPI checks listed under phase 0. These are each a large increment of their own. +### Visual style editing (fourteenth increment) + +- The Style section has colour swatches (`ColorField`) beside the hex fields, a font picker (`AssetField` for fonts, written to `fonts`), a Class dropdown over the document's named styles, Save style as class and Delete class. A class that a state does not override shows as a dimmed swatch and a `(class: value)` hint, so inherited values are visible. Saving and deleting a class are one `Batch` (`SetClass` plus the `class` property on affected widgets), so they undo together. + +Remaining plan work, in order: draw the inspector from `interface-properties`, image pickers and nine-slice/fit modes, text-input caret/selection/IME, rulers/guides/safe-area overlay and zoom/pan polish (phase 2), reusable components with instances and overrides (phase 5), visual binding/event editors, animation timeline and responsive variants (phase 6), and the platform/DPI checks listed under phase 0. These are each a large increment of their own. The next increment is moving the inspector onto the metadata, then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. From 04bc216b0f0a9172a1b7de1408ff029350de7786 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 00:35:09 +0000 Subject: [PATCH 09/26] Interface editor: safe-area and reference-size overlays, more preview sizes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-qt/qml/UiDesigner.qml | 21 ++++++++++++++++++- .../tests/qml/tst_InterfaceViewport.qml | 7 +++++++ docs/interface-editor-plan.md | 4 ++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 49708207..94cd1f3a 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -27,6 +27,11 @@ Item { // Editor-only state: never saved and never sent to the runtime. property var lockedIds: ({}) // Further selected widgets beside selectedId (Shift-click, Ctrl-click in the tree, marquee). + property bool showSafeArea: false + property bool showReference: false + onShowSafeAreaChanged: overlay.requestPaint() + onShowReferenceChanged: overlay.requestPaint() + onSafeChanged: overlay.requestPaint() property var extraIds: [] readonly property var selectionIds: { if (!selectedId) return []; @@ -703,7 +708,7 @@ Item { currentIndex: root.screenId ? root.screens.indexOf(root.screenId) + 1 : 0 onActivated: root.screenId = currentIndex > 0 ? root.screens[currentIndex - 1] : "" } - ComboBox { model: ["960 × 720","1280 × 720","1920 × 1080","720 × 1280"]; onActivated: { const sizes=[[960,720],[1280,720],[1920,1080],[720,1280]]; root.previewWidth=sizes[currentIndex][0]; root.previewHeight=sizes[currentIndex][1]; } } + ComboBox { model: ["960 × 720","1280 × 720","1920 × 1080","720 × 1280","1170 × 2532","2560 × 1440"]; onActivated: { const sizes=[[960,720],[1280,720],[1920,1080],[720,1280],[1170,2532],[2560,1440]]; root.previewWidth=sizes[currentIndex][0]; root.previewHeight=sizes[currentIndex][1]; } } ComboBox { model: ["Dark","Light","HighContrast"]; currentIndex: model.indexOf(root.document.theme || "Dark"); onActivated: root.structuralEdit({kind: "SetDocument", theme: currentText}, root.selectedId, root.extraIds.slice()) } ComboBox { model: ["ConstantPixel","ScaleWithSize"]; currentIndex: model.indexOf(root.document.scale || "ConstantPixel"); onActivated: root.structuralEdit({kind: "SetDocument", scale: currentText}, root.selectedId, root.extraIds.slice()) } Item { Layout.fillWidth: true } @@ -712,6 +717,8 @@ Item { CheckBox { text: "Grid"; checked: root.snapGrid; onToggled: root.snapGrid = checked } SpinBox { from: 1; to: 256; value: root.snapStep; editable: true; onValueModified: root.snapStep = value } Label { text: "px" } + CheckBox { objectName: "interfaceSafeArea"; text: "Safe area"; checked: root.showSafeArea; onToggled: root.showSafeArea = checked } + CheckBox { objectName: "interfaceReference"; text: "Reference size"; checked: root.showReference; onToggled: root.showReference = checked } CheckBox { text: "Align edges/centers"; checked: root.snapAlign; onToggled: root.snapAlign = checked } Label { text: "Hold Shift to bypass snapping" } } @@ -758,6 +765,18 @@ Item { ctx.lineWidth = 2/Math.max(0.05, root.zoom); ctx.stroke(); } if (root.hoveredId !== root.selectedId) outline(root.hoveredId, "#b9dfff"); + const inset = {x: root.safe[0], y: root.safe[1], w: root.previewWidth-root.safe[0]-root.safe[2], h: root.previewHeight-root.safe[1]-root.safe[3]}; + if (root.showSafeArea) { + ctx.save(); ctx.setLineDash([6/Math.max(0.05,root.zoom), 4/Math.max(0.05,root.zoom)]); + ctx.strokeStyle = "#ffb454"; ctx.lineWidth = 1/Math.max(0.05,root.zoom); + ctx.strokeRect(inset.x, inset.y, inset.w, inset.h); ctx.restore(); + } + if (root.showReference) { + const ref = root.document.reference_size || [960,720]; + ctx.save(); ctx.setLineDash([2/Math.max(0.05,root.zoom), 3/Math.max(0.05,root.zoom)]); + ctx.strokeStyle = "#c792ea"; ctx.lineWidth = 1/Math.max(0.05,root.zoom); + ctx.strokeRect(inset.x, inset.y, ref[0]*root.designScale, ref[1]*root.designScale); ctx.restore(); + } root.selectionIds.slice(1).forEach(id => outline(id, "#8fd0a0")); outline(root.selectedId, "#70baff"); const m = root.marquee; diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 761c6792..f2a3da4e 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -444,6 +444,13 @@ TestCase { compare(edit.edits[1].id, "b"); verify(!calls.some(c=>c.command === "set_interface")); } + function test_safe_area_and_reference_overlays_are_editor_only_toggles() { + verify(!panel.showSafeArea && !panel.showReference); + findChild(panel, "interfaceSafeArea").toggle(); + verify(panel.showSafeArea); + panel.showReference = true; + verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index e60182c5..dd0d6e7d 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -262,6 +262,10 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust - The Style section has colour swatches (`ColorField`) beside the hex fields, a font picker (`AssetField` for fonts, written to `fonts`), a Class dropdown over the document's named styles, Save style as class and Delete class. A class that a state does not override shows as a dimmed swatch and a `(class: value)` hint, so inherited values are visible. Saving and deleting a class are one `Batch` (`SetClass` plus the `class` property on affected widgets), so they undo together. +### Viewport overlays (fifteenth increment) + +- Safe area and Reference size toggles draw dashed frames over the viewport from the document's `safe_area` and `reference_size` (scaled like the runtime does), as editor-only overlays. Two phone/desktop presets (1170 x 2532, 2560 x 1440) join the preview sizes. Rulers, draggable guides and zoom/pan polish remain open. + Remaining plan work, in order: draw the inspector from `interface-properties`, image pickers and nine-slice/fit modes, text-input caret/selection/IME, rulers/guides/safe-area overlay and zoom/pan polish (phase 2), reusable components with instances and overrides (phase 5), visual binding/event editors, animation timeline and responsive variants (phase 6), and the platform/DPI checks listed under phase 0. These are each a large increment of their own. The next increment is moving the inspector onto the metadata, then the world-reload-free commit path. Schema versioning and property metadata (phase 1). Schema versioning, complete property metadata and the broader phase 1 contracts remain open. Committed edits still use the existing world synchronization path; avoiding a world reload on commit remains open. The current preview still shows the idle scene behind the interface. From fbb2ba664c44b113bd5c5d93e518bbd05e86b1e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:53:36 +0000 Subject: [PATCH 10/26] Interface editor: inspector rows follow property metadata Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 ++ blockloom-qt/qml/UiDesigner.qml | 23 +++++++++++++++---- .../tests/qml/tst_InterfaceViewport.qml | 19 ++++++++++++++- docs/interface-editor-plan.md | 4 ++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f056d1f1..5daa6cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3083,6 +3083,8 @@ scripts that name the original ids are not rewritten. `set_interface` stays the whole-document import path. The hierarchy is a tree built in QML from sibling order (`treeRows`) with search and fold state; Lock (`lockedIds`) is editor-only and never saved. `UiDocument.version` (`UI_SCHEMA_VERSION`) is stamped by `migrate()` on load and in `set_interface`; a newer document is refused by `validate`, not rewritten. Editor hide travels as `InterfaceDesign.hidden` and only affects the design preview. `Batch` applies several edits atomically; `property_metadata()` (`interface-properties`) is the table of `SetProperty` paths and is kept in step with `UiPropertyEdit` by a test. Selection is `selectedId` plus `extraIds` in QML (Shift-click, marquee) and Delete/Duplicate send one `Batch`. Group drag, Align and Space H/V (`arrange`) compute in QML from runtime geometry and send a `Batch` of `Move`s. Content and style fields (`style`, `class`, `bindings`, `items`, `tooltip`, `scroll_target`, `tab_index`, `transition`, `element.range`) plus `SetDocument` and `SetClass` are typed too, so `set_interface` is only the import path. Committing with a design session open sends no `Load`. A new edit shape needs the `update-interface-edit` summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. +The designer loads `interface_properties` on start and `applies(path)` hides inspector rows that do not suit the selected widget kind (Modal, scroll target, Items). + ### Android devices and the emulator The Devices tab (`DevicesPanel.qml`, `DevicePanel.qml`) lists what adb sees, diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 94cd1f3a..67614c3e 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -440,6 +440,19 @@ Item { readonly property double savedRevision: app.appState.sync ? app.appState.sync.revision : 0 onSavedRevisionChanged: { if (gesture && !gesture.committing) cancelEdit(); frameLayout = null; if (designing) previewDelay.restart(); } readonly property var widget: selected >= 0 && selected < document.widgets.length ? document.widgets[selected] : null + // Kinds each property applies to, from the backend's metadata; empty until loaded. + property var propertyKinds: ({}) + function applies(path) { + const ks = propertyKinds[path]; + return !ks || ks.length === 0 || !widget || ks.indexOf(widget.element.kind) >= 0; + } + function loadPropertyKinds() { + app.invoke("interface_properties", {}, function(list) { + const m = {}; + for (const p of (list || [])) m[p.path] = p.kinds || []; + root.propertyKinds = m; + }); + } readonly property var kinds: ["Panel","Label","Button","Image","Input","Slider","Toggle","List","VerticalBox","HorizontalBox","Grid","Canvas","WrapBox","SizeBox","Spacer","Progress","RadialProgress","ListView","Tabs","Select","Scrollbar","RichText","Tooltip"] property int previewWidth: 960 property int previewHeight: 720 @@ -462,7 +475,7 @@ Item { if (g.token && !g.committing) app.invoke("cancel_interface_edit", {token: g.token}); } } - Component.onCompleted: { refresh(); if (designing) updateSession(); } + Component.onCompleted: { loadPropertyKinds(); refresh(); if (designing) updateSession(); } Connections { target: root.app function onAppStateChanged() { root.refresh(); } @@ -881,13 +894,13 @@ Item { layoutValue: root.widget ? root.widget.layout || null : null onEdited: value => root.propertyEdit("layout", value) } - CheckBox { text: "Modal"; checked: root.widget ? root.widget.element.modal === true : false; onToggled: root.change("modal",checked) } + CheckBox { visible: root.applies("element.modal"); text: "Modal"; checked: root.widget ? root.widget.element.modal === true : false; onToggled: root.change("modal",checked) } TextField { Layout.fillWidth: true; placeholderText: "Tooltip"; text: root.widget ? root.widget.tooltip || "" : ""; onEditingFinished: root.extra("tooltip",text) } TextField { Layout.fillWidth: true; placeholderText: "World actor id"; text: root.widget ? root.widget.world_actor || "" : ""; onEditingFinished: root.extra("world_actor",text) } - TextField { Layout.fillWidth: true; placeholderText: "Scrollbar target widget id"; text: root.widget ? root.widget.scroll_target || "" : ""; onEditingFinished: root.extra("scroll_target",text) } + TextField { visible: root.applies("scroll_target"); Layout.fillWidth: true; placeholderText: "Scrollbar target widget id"; text: root.widget ? root.widget.scroll_target || "" : ""; onEditingFinished: root.extra("scroll_target",text) } TextField { Layout.fillWidth: true; placeholderText: "Tab page number (1-based)"; validator: IntValidator { bottom: 1 } text: root.widget ? root.widget.tab_index ?? "" : ""; onEditingFinished: root.extra("tab_index",text ? Number(text) : null) } - Label { text: "Items (one per line)" } - TextArea { Layout.fillWidth: true; Layout.preferredHeight: 70; text: root.widget ? (root.widget.items || []).join("\n") : ""; onActiveFocusChanged: if(!activeFocus && root.widget) root.extra("items",text ? text.split("\n") : []) } + Label { visible: root.applies("items"); text: "Items (one per line)" } + TextArea { visible: root.applies("items"); Layout.fillWidth: true; Layout.preferredHeight: 70; text: root.widget ? (root.widget.items || []).join("\n") : ""; onActiveFocusChanged: if(!activeFocus && root.widget) root.extra("items",text ? text.split("\n") : []) } Label { text: "Style"; font.bold: true } ComboBox { id: styleState; Layout.fillWidth: true; model: ["normal","hover","pressed","disabled","focused"] } RowLayout { diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index f2a3da4e..7d271abf 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -24,7 +24,9 @@ TestCase { property string previewLayout: "" function invoke(command, args, done, failed) { test.calls.push({command: command, args: args}); - if (command === "begin_interface_edit") { + if (command === "interface_properties") { + if (done) done([{path: "element.modal", kinds: ["Panel"]}, {path: "items", kinds: ["List", "Select"]}]); + } else if (command === "begin_interface_edit") { test.draft = JSON.parse(JSON.stringify(appState.project.world.interface)); if (test.deferBegin) test.pendingBegin = done; else if (done) done("draft-token"); @@ -703,6 +705,21 @@ TestCase { compare(calls.filter(c => c.command === "commit_interface_edit").length,1); } + function test_inspector_rows_follow_property_metadata() { + panel.loadPropertyKinds(); + const next = panel.copy(panel.document); + next.widgets[0].element.kind = "Label"; + panel.document = next; + panel.selectedId = "back"; + verify(!panel.applies("element.modal")); + verify(!panel.applies("items")); + verify(panel.applies("tooltip")); + const again = panel.copy(panel.document); + again.widgets[0].element.kind = "Panel"; + panel.document = again; + verify(panel.applies("element.modal")); + } + function test_moves_use_canvas_scale_and_parent_transform() { const next = panel.copy(panel.document); next.scale = "ScaleWithSize"; next.reference_size = [480,360]; diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index dd0d6e7d..e988d223 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -293,3 +293,7 @@ Verification for snapping and resize handles: the final full workspace build pas For sibling order, use `update-interface-edit token="" edit={"kind":"Reorder","id":"settings-row","index":0}` to move that widget to the first position among its siblings. The index is the final position, including the moved widget. Reordering does not reparent; use `Reparent` for that. Same-position commits do not create a save or undo entry. Verification for keyboard nudging and sibling order: the final full workspace build passed; backend/protocol suites passed (61 tests), including reorder draft isolation, save/reload and undo/redo; core interface tests passed (19 tests); runtime interface tests passed (23 tests) and all four design-session tests passed, including flow/paint order after reordering a root across its child slots. Qt Quick suite passed (81 tests), including keyboard batching, delayed replies, Escape/focus cancellation, inspector focus isolation and typed flow/root order controls. MCP suite passed (7 tests). QML lint completed with existing QQuickItem/GameView import warnings; formatting and diff checks passed. Backend/MCP integration tests used local IPC outside the sandbox. Actual platform presentation, full input/DPI matrices and commit synchronization without a world reload remain open. + +### Increment sixteen: metadata-driven inspector rows + +`UiDesigner.qml` loads `interface_properties` once and hides the Modal, scroll target and Items rows for kinds the metadata does not list (`applies(path)`). The rest of the inspector is still hand-built; drawing every row from the metadata remains open. From 86ef82750df627d0369f318799c1f2d333257636 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:08:47 +0000 Subject: [PATCH 11/26] Interface editor: reusable components and image fit modes Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 + blockloom-app/src/shell.rs | 2 +- blockloom-app/tests/sync.rs | 4 + blockloom-core/src/ui_framework.rs | 274 +++++++++++++++++- blockloom-qt/qml/UiDesigner.qml | 69 +++++ .../tests/qml/tst_InterfaceViewport.qml | 19 ++ blockloom-runtime/src/overlay.rs | 41 ++- docs/interface-editor-plan.md | 7 + 8 files changed, 411 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5daa6cce..28962c29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3085,6 +3085,8 @@ summary in `shell.rs` and a case in `tst_InterfaceViewport.qml`'s mock backend. The designer loads `interface_properties` on start and `applies(path)` hides inspector rows that do not suit the selected widget kind (Modal, scroll target, Items). +Components: `UiEdit::SavePrefab/InstantiatePrefab/DeletePrefab/DetachInstance` over `UiDocument.prefabs`; an instance root has `instance_of`, updating rebuilds instances but keeps widgets changed from the old source, and a missing prefab fails validation. Image widgets take `UiPaint.image_fit` (`Stretch`, `Sliced{border}`, `Tiled`), applied in `overlay.rs::image_node`. + ### Android devices and the emulator The Devices tab (`DevicesPanel.qml`, `DevicePanel.qml`) lists what adb sees, diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 925b2ad1..12b398cc 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, items, tooltip, scroll_target, tab_index, transition. Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-app/tests/sync.rs b/blockloom-app/tests/sync.rs index d8b86f90..0dfbd974 100644 --- a/blockloom-app/tests/sync.rs +++ b/blockloom-app/tests/sync.rs @@ -700,6 +700,10 @@ fn interface_property_and_parent_transactions_round_trip_and_undo() { json!({"kind": "Reorder", "id": "canvas", "index": 1}), json!({"kind": "Create", "widget": {"element": {"id": "fresh", "parent": "canvas"}}}), json!({"kind": "Duplicate", "id": "canvas", "new_id": "canvas2"}), + json!({"kind": "SavePrefab", "name": "pad", "root": "canvas2", "update_instances": true}), + json!({"kind": "InstantiatePrefab", "name": "pad", "prefix": "p.", "parent": ""}), + json!({"kind": "DetachInstance", "id": "p.canvas2"}), + json!({"kind": "DeletePrefab", "name": "pad"}), json!({"kind": "Delete", "id": "canvas"}), json!({"kind": "Batch", "edits": [ {"kind": "SetProperty", "id": "child", "property": {"path": "element.content", "value": "b"}}, diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 3c2d7c8a..ecda4758 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -99,6 +99,28 @@ pub struct UiPaint { pub radius: Option, pub shadow: Option, pub fonts: Vec, + /// How an Image widget fills its box; unset keeps the image's own size. + pub image_fit: Option, +} + +/// How an image fills its widget. `Sliced` borders are `[left, right, top, bottom]` +/// in image pixels. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub enum UiImageFit { + Stretch, + Sliced { border: [f32; 4] }, + Tiled, +} + +impl UiImageFit { + pub fn validate(&self) -> Result<(), String> { + if let UiImageFit::Sliced { border } = self + && border.iter().any(|v| !v.is_finite() || *v < 0.) + { + return Err("Nine-slice borders must be zero or more".into()); + } + Ok(()) + } } impl UiPaint { pub fn over(&self, base: &Self) -> Self { @@ -115,6 +137,7 @@ impl UiPaint { } else { self.fonts.clone() }, + image_fit: self.image_fit.or(base.image_fit), } } } @@ -221,6 +244,9 @@ pub struct UiWidget { pub scroll_target: String, pub tab_index: Option, pub transition: f32, + /// On the root of a prefab instance: the prefab it came from. + #[serde(skip_serializing_if = "String::is_empty")] + pub instance_of: String, } #[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] pub enum UiScale { @@ -578,6 +604,30 @@ pub enum UiEdit { size: [f32; 2], offset: [f32; 2], }, + /// Stores the subtree at `root` as the prefab `name` (replacing one of that + /// name). With `update_instances`, every instance is rebuilt from the new + /// version; widgets an instance changed from the old version are kept. + SavePrefab { + name: String, + root: String, + #[serde(default)] + update_instances: bool, + }, + /// Forgets a prefab; its instances stay as plain widgets. + DeletePrefab { + name: String, + }, + /// Adds a prefab's widgets under `parent`, every id prefixed by `prefix`. + InstantiatePrefab { + name: String, + prefix: String, + #[serde(default)] + parent: String, + }, + /// Turns an instance back into plain widgets. + DetachInstance { + id: String, + }, } impl UiDocument { @@ -628,6 +678,39 @@ impl UiDocument { UiEdit::Duplicate { id, new_id, offset } => { return self.duplicate_subtree(id, new_id, *offset); } + UiEdit::SavePrefab { + name, + root, + update_instances, + } => return self.save_prefab(name, root, *update_instances), + UiEdit::DeletePrefab { name } => { + if self.prefabs.remove(name).is_none() { + return Err(format!("No UI prefab named {name}")); + } + for w in &mut self.widgets { + if &w.instance_of == name { + w.instance_of.clear(); + } + } + return Ok(()); + } + UiEdit::InstantiatePrefab { + name, + prefix, + parent, + } => { + for widget in self.instantiate(name, prefix, parent)? { + self.create_widget(&widget)?; + } + return Ok(()); + } + UiEdit::DetachInstance { id } => { + let i = self + .find(id) + .ok_or_else(|| format!("Unknown widget: {id}"))?; + self.widgets[i].instance_of.clear(); + return Ok(()); + } _ => {} } let id = match edit { @@ -641,7 +724,11 @@ impl UiDocument { | UiEdit::SetClass { .. } | UiEdit::Create { .. } | UiEdit::Delete { .. } - | UiEdit::Duplicate { .. } => unreachable!(), + | UiEdit::Duplicate { .. } + | UiEdit::SavePrefab { .. } + | UiEdit::DeletePrefab { .. } + | UiEdit::InstantiatePrefab { .. } + | UiEdit::DetachInstance { .. } => unreachable!(), }; let index = self .widgets @@ -710,6 +797,17 @@ impl UiDocument { if bad { return Err("Style sizes must be finite and not negative".into()); } + for p in [ + &value.normal, + &value.hover, + &value.pressed, + &value.disabled, + &value.focused, + ] { + if let Some(fit) = &p.image_fit { + fit.validate()?; + } + } w.style = (**value).clone(); } UiPropertyEdit::Class(value) => w.class = value.clone(), @@ -959,6 +1057,85 @@ impl UiDocument { Ok(()) } + fn save_prefab(&mut self, name: &str, root: &str, update: bool) -> Result<(), String> { + if name.trim().is_empty() { + return Err("A prefab needs a name".into()); + } + let root_index = self + .find(root) + .ok_or_else(|| format!("Unknown widget: {root}"))?; + let members = self.subtree(root); + if members + .iter() + .any(|i| !self.widgets[*i].world_actor.is_empty()) + { + return Err("Projected widgets cannot be saved as a prefab".into()); + } + let mut copies: Vec = members.iter().map(|i| self.widgets[*i].clone()).collect(); + copies[0].element.parent.clear(); + for w in &mut copies { + w.instance_of.clear(); + } + // The source stays as it is; only instances elsewhere are rebuilt. + let old = self.prefabs.insert(name.to_string(), copies); + let _ = root_index; + if !update { + return Ok(()); + } + let Some(old) = old else { return Ok(()) }; + let old_root = old + .iter() + .find(|w| w.element.parent.is_empty()) + .map(|w| w.element.id.clone()) + .ok_or("The old prefab has no root")?; + let roots: Vec = self + .widgets + .iter() + .filter(|w| w.instance_of == name) + .map(|w| w.element.id.clone()) + .collect(); + let mut old_doc = self.clone(); + old_doc.prefabs.insert(name.to_string(), old); + for instance in roots { + let Some(at) = self.find(&instance) else { + continue; + }; + let prefix = instance + .strip_suffix(&old_root) + .ok_or_else(|| format!("Instance {instance} does not follow its prefab's naming"))? + .to_string(); + let parent = self.widgets[at].element.parent.clone(); + let members = self.subtree(&instance); + let expected = old_doc.instantiate(name, &prefix, &parent)?; + let fresh = self.instantiate(name, &prefix, &parent)?; + let current: Vec = members.iter().map(|i| self.widgets[*i].clone()).collect(); + let mut rebuilt: Vec = Vec::new(); + for w in fresh { + let kept = current.iter().find(|c| c.element.id == w.element.id); + let was = expected.iter().find(|e| e.element.id == w.element.id); + match (kept, was) { + (Some(c), Some(e)) if c != e => rebuilt.push(c.clone()), + _ => rebuilt.push(w), + } + } + for c in ¤t { + let known = expected.iter().find(|e| e.element.id == c.element.id); + let placed = rebuilt.iter().any(|r| r.element.id == c.element.id); + if !placed && known.is_none_or(|e| e != c) { + rebuilt.push(c.clone()); + } + } + let first = members[0]; + let mut slot = 0; + self.widgets.retain(|_| { + slot += 1; + !members.contains(&(slot - 1)) + }); + self.widgets.splice(first..first, rebuilt); + } + Ok(()) + } + pub fn validate(&self) -> Result<(), String> { if self.version > UI_SCHEMA_VERSION { return Err(format!( @@ -989,6 +1166,9 @@ impl UiDocument { { return Err(format!("Duplicate or empty widget id: {id}")); } + if !widget.instance_of.is_empty() && !self.prefabs.contains_key(&widget.instance_of) { + return Err(format!("{id} is an instance of a missing prefab")); + } if widget.bindings.iter().any(|b| { !matches!( b.property, @@ -1065,6 +1245,7 @@ impl UiDocument { } w.element.id = format!("{prefix}{}", w.element.id); w.element.parent = if w.element.parent.is_empty() { + w.instance_of = name.into(); parent.into() } else { format!("{prefix}{}", w.element.parent) @@ -1541,6 +1722,97 @@ mod tests { d.widgets[0].element.parent.clear(); assert!(d.validate().is_ok()); } + fn row(id: &str, parent: &str, content: &str) -> UiWidget { + let mut w = UiWidget::default(); + w.element.id = id.into(); + w.element.parent = parent.into(); + w.element.content = content.into(); + w + } + + #[test] + fn image_fit_round_trips_inherits_and_rejects_bad_borders() { + let mut doc = UiDocument::default(); + doc.widgets = vec![row("pic", "", "a.png")]; + let mut styles = UiStyles::default(); + styles.normal.image_fit = Some(UiImageFit::Sliced { + border: [4., 4., 6., 6.], + }); + doc.apply_edit(&UiEdit::SetProperty { + id: "pic".into(), + property: UiPropertyEdit::Style(Box::new(styles.clone())), + }) + .unwrap(); + let json = serde_json::to_string(&doc).unwrap(); + let back: UiDocument = serde_json::from_str(&json).unwrap(); + assert_eq!(back.widgets[0].style, styles); + let over = UiPaint::default().over(&styles.normal); + assert_eq!(over.image_fit, styles.normal.image_fit); + styles.normal.image_fit = Some(UiImageFit::Sliced { + border: [-1., 0., 0., 0.], + }); + assert!( + doc.apply_edit(&UiEdit::SetProperty { + id: "pic".into(), + property: UiPropertyEdit::Style(Box::new(styles)), + }) + .is_err() + ); + } + + #[test] + fn prefab_instances_follow_the_source_and_keep_overrides() { + let mut doc = UiDocument::default(); + doc.widgets = vec![ + row("row", "", "Row"), + row("label", "row", "Volume"), + row("hint", "row", "Hint"), + ]; + doc.apply_edit(&UiEdit::SavePrefab { + name: "setting".into(), + root: "row".into(), + update_instances: false, + }) + .unwrap(); + for prefix in ["a.", "b."] { + doc.apply_edit(&UiEdit::InstantiatePrefab { + name: "setting".into(), + prefix: prefix.into(), + parent: String::new(), + }) + .unwrap(); + } + let a = doc.find("a.row").unwrap(); + assert_eq!(doc.widgets[a].instance_of, "setting"); + // Override one widget in instance a. + let label = doc.find("a.label").unwrap(); + doc.widgets[label].element.content = "Music".into(); + // Change the source and push the update. + let src = doc.find("label").unwrap(); + doc.widgets[src].element.content = "Sound".into(); + let hint = doc.find("hint").unwrap(); + doc.widgets.remove(hint); + doc.apply_edit(&UiEdit::SavePrefab { + name: "setting".into(), + root: "row".into(), + update_instances: true, + }) + .unwrap(); + let content = + |d: &UiDocument, id: &str| d.widgets[d.find(id).unwrap()].element.content.clone(); + assert_eq!(content(&doc, "a.label"), "Music"); + assert_eq!(content(&doc, "b.label"), "Sound"); + assert!(doc.find("a.hint").is_none() && doc.find("b.hint").is_none()); + // Deleting the prefab detaches instances; a dangling tag is refused. + doc.apply_edit(&UiEdit::DeletePrefab { + name: "setting".into(), + }) + .unwrap(); + assert!(doc.widgets.iter().all(|w| w.instance_of.is_empty())); + doc.widgets[0].instance_of = "gone".into(); + assert!(doc.validate().is_err()); + } + #[test] fn prefabs_repoint_internal_scroll_targets_and_roundtrip() { let mut document = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 67614c3e..63064ea8 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -503,6 +503,20 @@ Item { style[styleState.currentText][field] = value; extra("style", style); } + // Image fit as the inspector sees it: auto, stretch, sliced or tiled. + function imageFit() { + const fit = widget ? ((widget.style || {})[styleState.currentText] || {}).image_fit : null; + if (!fit) return {mode: "auto", border: [0,0,0,0]}; + if (fit === "Stretch") return {mode: "stretch", border: [0,0,0,0]}; + if (fit === "Tiled") return {mode: "tiled", border: [0,0,0,0]}; + return {mode: "sliced", border: (fit.Sliced && fit.Sliced.border) || [0,0,0,0]}; + } + function setImageFit(mode, border) { + if (mode === "auto") paint("image_fit", null); + else if (mode === "stretch") paint("image_fit", "Stretch"); + else if (mode === "tiled") paint("image_fit", "Tiled"); + else paint("image_fit", {Sliced: {border: border || [0,0,0,0]}}); + } // The class's value for this state when the widget sets none of its own. function inheritedPaint(field) { if (!widget || !widget.class || !document.styles) return null; @@ -568,6 +582,26 @@ Item { if (!edits.length) return; structuralEdit(edits.length === 1 ? edits[0] : {kind: "Batch", edits: edits}, edits[0].new_id, edits.slice(1).map(e => e.new_id)); } + // Reusable components: prefabs saved from a subtree, instanced under a unique prefix. + function saveComponent(name, update) { + if (!widget || widget.world_actor || !name) return; + structuralEdit({kind: "SavePrefab", name: name, root: widget.element.id, update_instances: update}, widget.element.id, []); + } + function insertComponent(name) { + const prefabs = document.prefabs || {}; + const root = (prefabs[name] || []).find(w => !w.element.parent); + if (!root) return; + const taken = {}; + document.widgets.forEach(w => { taken[w.element.id] = true; }); + let n = 1; + while (taken["i" + n + "." + root.element.id]) n++; + const prefix = "i" + n + "."; + const parent = widget && !widget.world_actor ? widget.element.id : ""; + structuralEdit({kind: "InstantiatePrefab", name: name, prefix: prefix, parent: parent}, prefix + root.element.id, []); + } + function detachInstance() { + if (widget && widget.instance_of) structuralEdit({kind: "DetachInstance", id: widget.element.id}, widget.element.id, []); + } // Align or distribute the selection inside its shared parent, as one Batch of Moves. function arrange(op) { const ids = topLevelSelection(); @@ -938,6 +972,29 @@ Item { } } } + Label { visible: imageFitMode.visible; text: "Image fit" } + ComboBox { + id: imageFitMode; objectName: "interfaceImageFit"; Layout.fillWidth: true + visible: !!root.widget && root.widget.element.kind === "Image" + model: ["auto","stretch","sliced","tiled"] + currentIndex: model.indexOf(root.imageFit().mode) + onActivated: root.setImageFit(currentText, root.imageFit().border) + } + RowLayout { + visible: imageFitMode.visible && imageFitMode.currentText === "sliced" + Repeater { + model: ["left","right","top","bottom"] + delegate: TextField { + required property string modelData + required property int index + objectName: "interfaceSlice" + modelData + Layout.fillWidth: true; Layout.preferredWidth: 40; placeholderText: modelData[0].toUpperCase() + validator: DoubleValidator { bottom: 0 } + text: root.imageFit().border[index] + onEditingFinished: { const b = root.imageFit().border.slice(); b[index] = Number(text) || 0; root.setImageFit("sliced", b); } + } + } + } Label { text: "Fonts (first available wins)" } RowLayout { AssetField { @@ -965,6 +1022,18 @@ Item { text: root.widget ? root.widget.transition || 0 : 0 onEditingFinished: root.extra("transition",Number(text)) } } + Label { text: "Component"; font.bold: true } + Label { visible: !!(root.widget && root.widget.instance_of); text: root.widget ? "Instance of " + root.widget.instance_of : ""; color: Theme.textDim } + RowLayout { + TextField { id: componentName; objectName: "interfaceComponentName"; Layout.fillWidth: true; placeholderText: "Component name" } + Button { objectName: "interfaceSaveComponent"; text: "Save"; enabled: !!root.widget && !root.widget.world_actor && !!componentName.text.trim() && !root.gesture; onClicked: root.saveComponent(componentName.text.trim(), true) } + } + ComboBox { id: componentPick; objectName: "interfaceComponentPick"; Layout.fillWidth: true; model: Object.keys(root.document.prefabs || {}) } + RowLayout { + Button { objectName: "interfaceInsertComponent"; text: "Insert"; enabled: componentPick.currentIndex >= 0 && !root.gesture; onClicked: root.insertComponent(componentPick.currentText) } + Button { objectName: "interfaceDetachInstance"; text: "Detach"; enabled: !!(root.widget && root.widget.instance_of) && !root.gesture; onClicked: root.detachInstance() } + Button { objectName: "interfaceDeleteComponent"; text: "Delete"; enabled: componentPick.currentIndex >= 0 && !root.gesture; onClicked: root.structuralEdit({kind: "DeletePrefab", name: componentPick.currentText}, root.selectedId, []) } + } Label { text: "Variable binding"; font.bold: true } ComboBox { id: bindingProperty; Layout.fillWidth: true; model: ["Text","Value","Visible","SelectedIndex"] } TextField { id: bindingActor; Layout.fillWidth: true; placeholderText: "Actor id (blank for global)" } diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 7d271abf..f2115e3a 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -720,6 +720,25 @@ TestCase { verify(panel.applies("element.modal")); } + function test_components_send_prefab_edits() { + panel.selectedId = "back"; + const before = test.calls.length; + panel.saveComponent("pad", true); + const sent = test.calls.slice(before).filter(c => c.command === "update_interface_edit").map(c => c.args.edit.kind); + verify(sent.indexOf("SavePrefab") >= 0); + } + + function test_image_fit_encodes_serde_shapes() { + panel.selectedId = "back"; + const before = test.calls.length; + panel.setImageFit("sliced", [1,2,3,4]); + panel.setImageFit("tiled"); + const kinds = test.calls.slice(before).filter(c => c.command === "update_interface_edit"); + verify(kinds.length >= 1); + const fit = kinds[0].args.edit.property.value.normal.image_fit; + compare(JSON.stringify(fit), JSON.stringify({Sliced: {border: [1,2,3,4]}})); + } + function test_moves_use_canvas_scale_and_parent_transform() { const next = panel.copy(panel.document); next.scale = "ScaleWithSize"; next.reference_size = [480,360]; diff --git a/blockloom-runtime/src/overlay.rs b/blockloom-runtime/src/overlay.rs index 17b423bf..79af5409 100644 --- a/blockloom-runtime/src/overlay.rs +++ b/blockloom-runtime/src/overlay.rs @@ -10,6 +10,8 @@ use crate::engine::{ActorId, Dimension, Engine, PendingEffects}; use crate::ui::{UiElementText, UiManager, UiRoot, UiSliderFill, UiToggleLamp}; use crate::world::{self, WorldCamera}; use bevy::prelude::*; +use bevy::sprite::{BorderRect, TextureSlicer}; +use bevy::ui::widget::NodeImageMode; use blockloom_core::ui::{UiKind, UiPaint, UiTheme}; use blockloom_core::vm::Effect; @@ -505,12 +507,13 @@ pub fn draw_ui( commands.entity(element.entity).insert(transform); } if element.kind == UiKind::Image && !element.spec.content.trim().is_empty() { - commands - .entity(element.entity) - .insert(ImageNode::new(assets.load(world::asset_path( + commands.entity(element.entity).insert(image_node( + assets.load(world::asset_path( dir.as_deref(), element.spec.content.trim(), - )))); + )), + &paint, + )); } let wanted = crate::ui::text_of(element); for (text_entity, owner, mut text, mut font, mut color) in &mut labels { @@ -688,7 +691,10 @@ fn spawn_element( UiKind::Image => { let path = node.spec.content.trim(); if !path.is_empty() { - entity.insert(ImageNode::new(assets.load(world::asset_path(dir, path)))); + entity.insert(image_node( + assets.load(world::asset_path(dir, path)), + &node.styles.normal, + )); } } UiKind::Slider | UiKind::Progress | UiKind::Scrollbar => { @@ -798,3 +804,28 @@ fn spawn_element( } entity.id() } + +/// An image widget's node with the fit its style asks for. +fn image_node(image: Handle, paint: &UiPaint) -> ImageNode { + use blockloom_core::ui::UiImageFit; + let mut node = ImageNode::new(image); + node.image_mode = match paint.image_fit { + None => NodeImageMode::Auto, + Some(UiImageFit::Stretch) => NodeImageMode::Stretch, + Some(UiImageFit::Tiled) => NodeImageMode::Tiled { + tile_x: true, + tile_y: true, + stretch_value: 1.0, + }, + Some(UiImageFit::Sliced { + border: [l, r, t, b], + }) => NodeImageMode::Sliced(TextureSlicer { + border: BorderRect { + min_inset: Vec2::new(l, t), + max_inset: Vec2::new(r, b), + }, + ..default() + }), + }; + node +} diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index e988d223..163ee0a1 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -297,3 +297,10 @@ Verification for keyboard nudging and sibling order: the final full workspace bu ### Increment sixteen: metadata-driven inspector rows `UiDesigner.qml` loads `interface_properties` once and hides the Modal, scroll target and Items rows for kinds the metadata does not list (`applies(path)`). The rest of the inspector is still hand-built; drawing every row from the metadata remains open. + +### Increment seventeen: components and image fit + +- **Components (phase 5, first slice).** `UiEdit::{SavePrefab, InstantiatePrefab, DeletePrefab, DetachInstance}` build on the existing `prefabs` map. An instance root carries `instance_of`; `SavePrefab { update_instances: true }` rebuilds every instance from the new source and keeps any widget the instance changed from the old version (override at widget granularity). A dangling `instance_of` fails validation; deleting a component detaches its instances. The inspector has a Component section (save, insert with a unique `iN.` prefix, detach, delete). +- **Image fit (phase 4).** `UiPaint.image_fit` is `Stretch`, `Sliced { border: [l, r, t, b] }` or `Tiled`; unset keeps the image's own size. The runtime maps it to Bevy's `NodeImageMode`; the inspector has an Image fit dropdown with four border fields. + +Still open: per-field (not per-widget) instance overrides, exposed properties and named slots, visual binding/event editors, text-input caret/selection/IME, rulers and guides, animation timeline, responsive variants, platform checks. From 7945f806fcf5e2f30099e74400d013b89d367e69 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 03:31:20 +0000 Subject: [PATCH 12/26] Retry the adb boot-state stub test when a sibling fork races its exec Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-core/src/android.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/blockloom-core/src/android.rs b/blockloom-core/src/android.rs index ea8f5ef9..5f93d8de 100644 --- a/blockloom-core/src/android.rs +++ b/blockloom-core/src/android.rs @@ -4307,16 +4307,31 @@ mod tests { let root = temp_root("boot"); let bin = root.join("bin"); std::fs::create_dir_all(&bin).unwrap(); + // A sibling test forking while a stub is being written can make its + // first exec fail with ETXTBSY, so give the expected answer a few tries. + let settles = |adb: &Path, want: bool| { + (0..50).any(|_| { + let got = booted_with_adb(adb, "emulator-5554"); + if got != want { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + got == want + }) + }; let booted = stub_tool(&bin, "adb-on", "#!/bin/sh\necho '1'\n"); - assert!(booted_with_adb(&booted, "emulator-5554")); + assert!(settles(&booted, true)); let booting = stub_tool(&bin, "adb-off", "#!/bin/sh\necho '0'\n"); - assert!(!booted_with_adb(&booting, "emulator-5554")); + assert!(settles(&booting, false)); // `emu avd name` answers the name above the trailing OK. let named = stub_tool(&bin, "adb-name", "#!/bin/sh\necho 'blockloom'\necho 'OK'\n"); - assert_eq!( - avd_name_with_adb(&named, "emulator-5554").as_deref(), - Some("blockloom") - ); + let name = (0..50).find_map(|_| { + let name = avd_name_with_adb(&named, "emulator-5554"); + if name.is_none() { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + name + }); + assert_eq!(name.as_deref(), Some("blockloom")); let _ = std::fs::remove_dir_all(&root); } From 365c690e0e076b36235a9980a2f325d8ff8c233a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:49:33 +0000 Subject: [PATCH 13/26] Interface editor: per-field instance overrides and instance reset Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-app/src/shell.rs | 2 +- blockloom-core/src/ui_framework.rs | 209 ++++++++++++++++++++++++++++- blockloom-qt/qml/UiDesigner.qml | 4 + docs/interface-editor-plan.md | 2 +- 4 files changed, 213 insertions(+), 4 deletions(-) diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 12b398cc..0ea1c327 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, items, tooltip, scroll_target, tab_index, transition. Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index ecda4758..5e5a4565 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -628,6 +628,10 @@ pub enum UiEdit { DetachInstance { id: String, }, + /// Throws away every change an instance made and rebuilds it from its prefab. + ResetInstance { + id: String, + }, } impl UiDocument { @@ -711,6 +715,7 @@ impl UiDocument { self.widgets[i].instance_of.clear(); return Ok(()); } + UiEdit::ResetInstance { id } => return self.reset_instance(id), _ => {} } let id = match edit { @@ -728,7 +733,8 @@ impl UiDocument { | UiEdit::SavePrefab { .. } | UiEdit::DeletePrefab { .. } | UiEdit::InstantiatePrefab { .. } - | UiEdit::DetachInstance { .. } => unreachable!(), + | UiEdit::DetachInstance { .. } + | UiEdit::ResetInstance { .. } => unreachable!(), }; let index = self .widgets @@ -1114,7 +1120,7 @@ impl UiDocument { let kept = current.iter().find(|c| c.element.id == w.element.id); let was = expected.iter().find(|e| e.element.id == w.element.id); match (kept, was) { - (Some(c), Some(e)) if c != e => rebuilt.push(c.clone()), + (Some(c), Some(e)) if c != e => rebuilt.push(merge_widget(e, &w, c)?), _ => rebuilt.push(w), } } @@ -1136,6 +1142,76 @@ impl UiDocument { Ok(()) } + /// Rebuilds the instance rooted at `id` exactly as its prefab says. + fn reset_instance(&mut self, id: &str) -> Result<(), String> { + let at = self + .find(id) + .ok_or_else(|| format!("Unknown widget: {id}"))?; + let name = self.widgets[at].instance_of.clone(); + if name.is_empty() { + return Err(format!("{id} is not the root of a component instance")); + } + let old_root = self + .prefabs + .get(&name) + .and_then(|p| p.iter().find(|w| w.element.parent.is_empty())) + .map(|w| w.element.id.clone()) + .ok_or_else(|| format!("No UI prefab named {name}"))?; + let prefix = id + .strip_suffix(&old_root) + .ok_or_else(|| format!("Instance {id} does not follow its prefab's naming"))? + .to_string(); + let parent = self.widgets[at].element.parent.clone(); + let members = self.subtree(id); + let fresh = self.instantiate(&name, &prefix, &parent)?; + let first = members[0]; + let mut slot = 0; + self.widgets.retain(|_| { + slot += 1; + !members.contains(&(slot - 1)) + }); + self.widgets.splice(first..first, fresh); + Ok(()) + } + + /// The fields an instance changed from its prefab, as `widget id: path`. + pub fn instance_overrides(&self, id: &str) -> Result, String> { + let at = self + .find(id) + .ok_or_else(|| format!("Unknown widget: {id}"))?; + let name = &self.widgets[at].instance_of; + let old_root = self + .prefabs + .get(name) + .and_then(|p| p.iter().find(|w| w.element.parent.is_empty())) + .map(|w| w.element.id.clone()) + .ok_or_else(|| format!("{id} is not the root of a component instance"))?; + let prefix = id + .strip_suffix(&old_root) + .ok_or_else(|| format!("Instance {id} does not follow its prefab's naming"))?; + let parent = &self.widgets[at].element.parent; + let fresh = self.instantiate(name, prefix, parent)?; + let mut out = Vec::new(); + for member in self.subtree(id) { + let current = &self.widgets[member]; + match fresh.iter().find(|w| w.element.id == current.element.id) { + Some(base) => { + let (a, b) = (to_json(base)?, to_json(current)?); + changed_paths(&a, &b, "", &mut |path| { + out.push(format!("{}: {path}", current.element.id)) + }); + } + None => out.push(format!("{}: added", current.element.id)), + } + } + for w in &fresh { + if self.find(&w.element.id).is_none() { + out.push(format!("{}: removed", w.element.id)); + } + } + Ok(out) + } + pub fn validate(&self) -> Result<(), String> { if self.version > UI_SCHEMA_VERSION { return Err(format!( @@ -1255,6 +1331,80 @@ impl UiDocument { } } +fn to_json(widget: &UiWidget) -> Result { + serde_json::to_value(widget).map_err(|e| e.to_string()) +} + +/// Field-level three-way merge: a field the instance changed from `old` keeps +/// its value, every other field takes the prefab's `new` one. +fn merge_json( + old: &serde_json::Value, + new: &serde_json::Value, + cur: &serde_json::Value, +) -> serde_json::Value { + use serde_json::Value; + if cur == old { + return new.clone(); + } + match (old, new, cur) { + (Value::Object(o), Value::Object(n), Value::Object(c)) => { + let mut out = serde_json::Map::new(); + for key in n.keys().chain(c.keys()) { + if out.contains_key(key) { + continue; + } + let blank = Value::Null; + let merged = match (o.get(key), n.get(key), c.get(key)) { + (o, n, Some(c)) => merge_json(o.unwrap_or(&blank), n.unwrap_or(&blank), c), + (_, Some(n), None) => n.clone(), + _ => continue, + }; + out.insert(key.clone(), merged); + } + Value::Object(out) + } + _ => cur.clone(), + } +} + +fn merge_widget(old: &UiWidget, new: &UiWidget, cur: &UiWidget) -> Result { + let merged = merge_json(&to_json(old)?, &to_json(new)?, &to_json(cur)?); + let mut widget: UiWidget = serde_json::from_value(merged).map_err(|e| e.to_string())?; + // Identity and place in the tree always follow the rebuilt instance. + widget.element.id = new.element.id.clone(); + widget.element.parent = new.element.parent.clone(); + widget.instance_of = new.instance_of.clone(); + Ok(widget) +} + +fn changed_paths(a: &serde_json::Value, b: &serde_json::Value, at: &str, f: &mut dyn FnMut(&str)) { + use serde_json::Value; + if a == b { + return; + } + match (a, b) { + (Value::Object(x), Value::Object(y)) => { + let mut keys: Vec<&String> = x.keys().chain(y.keys()).collect(); + keys.sort(); + keys.dedup(); + for key in keys { + let path = if at.is_empty() { + key.clone() + } else { + format!("{at}.{key}") + }; + changed_paths( + x.get(key).unwrap_or(&Value::Null), + y.get(key).unwrap_or(&Value::Null), + &path, + f, + ); + } + } + _ => f(at), + } +} + /// A bounded pool of visible rows, including one overscan row at each end. pub fn visible_rows( count: usize, @@ -1813,6 +1963,61 @@ mod tests { assert!(doc.validate().is_err()); } + #[test] + fn overrides_merge_per_field_and_can_be_listed_and_reset() { + let mut doc = UiDocument::default(); + doc.widgets = vec![row("row", "", "Row"), row("label", "row", "Volume")]; + doc.widgets[1].tooltip = "Old tip".into(); + doc.apply_edit(&UiEdit::SavePrefab { + name: "setting".into(), + root: "row".into(), + update_instances: false, + }) + .unwrap(); + doc.apply_edit(&UiEdit::InstantiatePrefab { + name: "setting".into(), + prefix: "a.".into(), + parent: String::new(), + }) + .unwrap(); + assert!(doc.instance_overrides("a.row").unwrap().is_empty()); + // The instance changes the caption only. + let label = doc.find("a.label").unwrap(); + doc.widgets[label].element.content = "Music".into(); + assert_eq!( + doc.instance_overrides("a.row").unwrap(), + vec!["a.label: element.content".to_string()] + ); + // The prefab changes the tooltip and the caption; the tooltip flows in, + // the instance's caption stays. + let src = doc.find("label").unwrap(); + doc.widgets[src].tooltip = "New tip".into(); + doc.widgets[src].element.content = "Sound".into(); + doc.apply_edit(&UiEdit::SavePrefab { + name: "setting".into(), + root: "row".into(), + update_instances: true, + }) + .unwrap(); + let label = &doc.widgets[doc.find("a.label").unwrap()]; + assert_eq!(label.element.content, "Music"); + assert_eq!(label.tooltip, "New tip"); + // Resetting brings the prefab's caption back. + doc.apply_edit(&UiEdit::ResetInstance { id: "a.row".into() }) + .unwrap(); + assert_eq!( + doc.widgets[doc.find("a.label").unwrap()].element.content, + "Sound" + ); + assert!(doc.instance_overrides("a.row").unwrap().is_empty()); + assert!( + doc.apply_edit(&UiEdit::ResetInstance { + id: "a.label".into() + }) + .is_err() + ); + } + #[test] fn prefabs_repoint_internal_scroll_targets_and_roundtrip() { let mut document = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 63064ea8..8154ac59 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -599,6 +599,9 @@ Item { const parent = widget && !widget.world_actor ? widget.element.id : ""; structuralEdit({kind: "InstantiatePrefab", name: name, prefix: prefix, parent: parent}, prefix + root.element.id, []); } + function resetInstance() { + if (widget && widget.instance_of) structuralEdit({kind: "ResetInstance", id: widget.element.id}, widget.element.id, []); + } function detachInstance() { if (widget && widget.instance_of) structuralEdit({kind: "DetachInstance", id: widget.element.id}, widget.element.id, []); } @@ -1031,6 +1034,7 @@ Item { ComboBox { id: componentPick; objectName: "interfaceComponentPick"; Layout.fillWidth: true; model: Object.keys(root.document.prefabs || {}) } RowLayout { Button { objectName: "interfaceInsertComponent"; text: "Insert"; enabled: componentPick.currentIndex >= 0 && !root.gesture; onClicked: root.insertComponent(componentPick.currentText) } + Button { objectName: "interfaceResetInstance"; text: "Reset"; enabled: !!(root.widget && root.widget.instance_of) && !root.gesture; onClicked: root.resetInstance() } Button { objectName: "interfaceDetachInstance"; text: "Detach"; enabled: !!(root.widget && root.widget.instance_of) && !root.gesture; onClicked: root.detachInstance() } Button { objectName: "interfaceDeleteComponent"; text: "Delete"; enabled: componentPick.currentIndex >= 0 && !root.gesture; onClicked: root.structuralEdit({kind: "DeletePrefab", name: componentPick.currentText}, root.selectedId, []) } } diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 163ee0a1..8caffa5f 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -303,4 +303,4 @@ Verification for keyboard nudging and sibling order: the final full workspace bu - **Components (phase 5, first slice).** `UiEdit::{SavePrefab, InstantiatePrefab, DeletePrefab, DetachInstance}` build on the existing `prefabs` map. An instance root carries `instance_of`; `SavePrefab { update_instances: true }` rebuilds every instance from the new source and keeps any widget the instance changed from the old version (override at widget granularity). A dangling `instance_of` fails validation; deleting a component detaches its instances. The inspector has a Component section (save, insert with a unique `iN.` prefix, detach, delete). - **Image fit (phase 4).** `UiPaint.image_fit` is `Stretch`, `Sliced { border: [l, r, t, b] }` or `Tiled`; unset keeps the image's own size. The runtime maps it to Bevy's `NodeImageMode`; the inspector has an Image fit dropdown with four border fields. -Still open: per-field (not per-widget) instance overrides, exposed properties and named slots, visual binding/event editors, text-input caret/selection/IME, rulers and guides, animation timeline, responsive variants, platform checks. +Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Still open: exposed properties and named slots, visual binding/event editors, text-input caret/selection/IME, rulers and guides, animation timeline, responsive variants, platform checks. From ed4a8253b2fa55dc677ca3b6416cf7472812f804 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:53:52 +0000 Subject: [PATCH 14/26] Interface editor: rulers and snapping guides Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-qt/qml/UiDesigner.qml | 79 +++++++++++++++++++ .../tests/qml/tst_InterfaceViewport.qml | 19 +++++ docs/interface-editor-plan.md | 4 +- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 8154ac59..1bcc16d1 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -29,6 +29,21 @@ Item { // Further selected widgets beside selectedId (Shift-click, Ctrl-click in the tree, marquee). property bool showSafeArea: false property bool showReference: false + // Editor-only guides in preview pixels: {axis: 0 vertical line at x, 1 horizontal line at y, pos}. + property bool showRulers: false + property var guides: [] + onGuidesChanged: overlay.requestPaint() + function addGuide(axis, pos) { guides = guides.concat([{axis: axis, pos: pos}]); return guides.length - 1; } + function moveGuide(index, pos) { guides = guides.map((g, i) => i === index ? {axis: g.axis, pos: pos} : g); } + function removeGuide(index) { guides = guides.filter((g, i) => i !== index); } + // Axis 0 is x, 1 is y; stage pixels are the viewport, canvas pixels the preview. + function stageToCanvas(axis, s) { return (axis === 0 ? previewWidth : previewHeight) / 2 + (s - (axis === 0 ? stage.width : stage.height) / 2) / Math.max(0.05, zoom); } + function canvasToStage(axis, c) { return (axis === 0 ? stage.width : stage.height) / 2 + (c - (axis === 0 ? previewWidth : previewHeight) / 2) * Math.max(0.05, zoom); } + function guideTargets(frame, axis) { + // Guides only snap in axis-aligned frames, in the frame's own units. + if (Math.abs(frame[1]) > 1e-6 || Math.abs(frame[2]) > 1e-6) return []; + return guides.filter(g => g.axis === axis).map(g => (g.pos - frame[axis === 0 ? 4 : 5]) / frame[axis === 0 ? 0 : 3]); + } onShowSafeAreaChanged: overlay.requestPaint() onShowReferenceChanged: overlay.requestPaint() onSafeChanged: overlay.requestPaint() @@ -170,6 +185,7 @@ Item { const box = boxInFrame(b, frameInverse); for (let axis=0;axis<2;++axis) targets[axis].push(box[axis],(box[axis]+box[axis+2])/2,box[axis+2]); }); + for (let axis=0;axis<2;++axis) guideTargets(frame, axis).forEach(t => targets[axis].push(t)); const g = {kind: kind, id: selectedId, original: copy(document), token: null, offset: (widget.element.offset || [0,0]).slice(), start: {x: x, y: y}, inverse: bound ? geometry.inverse(bound.transform) : null, @@ -769,11 +785,67 @@ Item { Label { text: "px" } CheckBox { objectName: "interfaceSafeArea"; text: "Safe area"; checked: root.showSafeArea; onToggled: root.showSafeArea = checked } CheckBox { objectName: "interfaceReference"; text: "Reference size"; checked: root.showReference; onToggled: root.showReference = checked } + CheckBox { objectName: "interfaceRulers"; text: "Rulers"; checked: root.showRulers; onToggled: root.showRulers = checked } + Button { objectName: "interfaceClearGuides"; text: "Clear guides"; enabled: root.guides.length > 0; onClicked: root.guides = [] } CheckBox { text: "Align edges/centers"; checked: root.snapAlign; onToggled: root.snapAlign = checked } Label { text: "Hold Shift to bypass snapping" } } Rectangle { id: stage; Layout.fillWidth: true; Layout.fillHeight: true; color: "#171a21"; clip: true + // Rulers: drag out of one for a guide, and drag a guide back onto it to remove it. + Repeater { + model: root.showRulers ? [0, 1] : [] + delegate: Item { + id: ruler + required property int modelData + // The top ruler measures x and pulls out horizontal guides; the left one the reverse. + readonly property int measures: modelData + readonly property int makes: 1 - modelData + objectName: measures === 0 ? "interfaceRulerTop" : "interfaceRulerLeft" + z: 5 + width: measures === 0 ? stage.width : 18 + height: measures === 0 ? 18 : stage.height + property int dragging: -1 + Rectangle { anchors.fill: parent; color: "#1d222c"; opacity: 0.92 } + Repeater { + // A tick every 50 preview pixels, labelled every 100. + model: Math.ceil((ruler.measures === 0 ? root.previewWidth : root.previewHeight) / 50) + 1 + delegate: Item { + required property int index + readonly property real at: root.canvasToStage(ruler.measures, index * 50) + x: ruler.measures === 0 ? at : 0 + y: ruler.measures === 0 ? 0 : at + Rectangle { + width: ruler.measures === 0 ? 1 : (index % 2 ? 6 : 12) + height: ruler.measures === 0 ? (index % 2 ? 6 : 12) : 1 + color: "#8892a6" + } + Label { + visible: index % 2 === 0 + x: ruler.measures === 0 ? 3 : 1; y: ruler.measures === 0 ? 0 : 3 + text: index * 50; font.pixelSize: 8; color: "#8892a6" + } + } + } + MouseArea { + anchors.fill: parent + function canvasPoint(mouse) { + const p = mapToItem(stage, mouse.x, mouse.y); + return Math.round(root.stageToCanvas(ruler.makes, ruler.makes === 0 ? p.x : p.y)); + } + onPressed: mouse => { ruler.dragging = root.addGuide(ruler.makes, canvasPoint(mouse)); } + onPositionChanged: mouse => { if (ruler.dragging >= 0) root.moveGuide(ruler.dragging, canvasPoint(mouse)); } + onReleased: mouse => { + if (ruler.dragging < 0) return; + const p = mapToItem(stage, mouse.x, mouse.y); + // Released on a ruler (or off the stage) takes the guide away again. + if ((ruler.makes === 0 ? p.x : p.y) <= 18) root.removeGuide(ruler.dragging); + ruler.dragging = -1; + } + onCanceled: { if (ruler.dragging >= 0) root.removeGuide(ruler.dragging); ruler.dragging = -1; } + } + } + } Rectangle { id: canvas; objectName: "interfaceCanvas"; anchors.centerIn: parent width: root.previewWidth; height: root.previewHeight @@ -827,6 +899,13 @@ Item { ctx.strokeStyle = "#c792ea"; ctx.lineWidth = 1/Math.max(0.05,root.zoom); ctx.strokeRect(inset.x, inset.y, ref[0]*root.designScale, ref[1]*root.designScale); ctx.restore(); } + ctx.save(); ctx.strokeStyle = "#4dd0e1"; ctx.lineWidth = 1/Math.max(0.05,root.zoom); + root.guides.forEach(g => { + ctx.beginPath(); + if (g.axis === 0) { ctx.moveTo(g.pos, 0); ctx.lineTo(g.pos, height); } else { ctx.moveTo(0, g.pos); ctx.lineTo(width, g.pos); } + ctx.stroke(); + }); + ctx.restore(); root.selectionIds.slice(1).forEach(id => outline(id, "#8fd0a0")); outline(root.selectedId, "#70baff"); const m = root.marquee; diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index f2115e3a..12269d90 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -453,6 +453,25 @@ TestCase { panel.showReference = true; verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); } + function test_guides_are_editor_only_and_snap_targets_follow_the_frame() { + compare(panel.guides.length, 0); + const a = panel.addGuide(0, 100); + const b = panel.addGuide(1, 40); + compare(panel.guides.length, 2); + panel.moveGuide(a, 120); + compare(panel.guides[a].pos, 120); + // Frame: scale 2 at an origin of (10, 20); guides land in frame units. + compare(panel.guideTargets([2, 0, 0, 2, 10, 20], 0), [55]); + compare(panel.guideTargets([2, 0, 0, 2, 10, 20], 1), [10]); + // A rotated frame gets none. + compare(panel.guideTargets([0, 1, -1, 0, 0, 0], 0), []); + compare(panel.stageToCanvas(0, panel.canvasToStage(0, 300)), 300); + panel.removeGuide(b); + compare(panel.guides.length, 1); + findChild(panel, "interfaceRulers").toggle(); + verify(panel.showRulers); + verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); + } function test_locked_widgets_are_not_picked_or_edited_but_nothing_is_saved() { panel.selectedId = "back"; verify(panel.editable(panel.widget)); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 8caffa5f..1cf76cf1 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -264,7 +264,7 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust ### Viewport overlays (fifteenth increment) -- Safe area and Reference size toggles draw dashed frames over the viewport from the document's `safe_area` and `reference_size` (scaled like the runtime does), as editor-only overlays. Two phone/desktop presets (1170 x 2532, 2560 x 1440) join the preview sizes. Rulers, draggable guides and zoom/pan polish remain open. +- Safe area and Reference size toggles draw dashed frames over the viewport from the document's `safe_area` and `reference_size` (scaled like the runtime does), as editor-only overlays. Two phone/desktop presets (1170 x 2532, 2560 x 1440) join the preview sizes. Rulers and guides: the Rulers toggle shows ticks along the top and left of the viewport; dragging out of one pulls a guide (editor-only, never saved), drawing it over the preview and adding it as a snap target for moves and resizes in axis-aligned frames; dropping a guide back on a ruler removes it, and Clear guides drops all. Zoom/pan polish remains open. Remaining plan work, in order: draw the inspector from `interface-properties`, image pickers and nine-slice/fit modes, text-input caret/selection/IME, rulers/guides/safe-area overlay and zoom/pan polish (phase 2), reusable components with instances and overrides (phase 5), visual binding/event editors, animation timeline and responsive variants (phase 6), and the platform/DPI checks listed under phase 0. These are each a large increment of their own. @@ -303,4 +303,4 @@ Verification for keyboard nudging and sibling order: the final full workspace bu - **Components (phase 5, first slice).** `UiEdit::{SavePrefab, InstantiatePrefab, DeletePrefab, DetachInstance}` build on the existing `prefabs` map. An instance root carries `instance_of`; `SavePrefab { update_instances: true }` rebuilds every instance from the new source and keeps any widget the instance changed from the old version (override at widget granularity). A dangling `instance_of` fails validation; deleting a component detaches its instances. The inspector has a Component section (save, insert with a unique `iN.` prefix, detach, delete). - **Image fit (phase 4).** `UiPaint.image_fit` is `Stretch`, `Sliced { border: [l, r, t, b] }` or `Tiled`; unset keeps the image's own size. The runtime maps it to Bevy's `NodeImageMode`; the inspector has an Image fit dropdown with four border fields. -Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Still open: exposed properties and named slots, visual binding/event editors, text-input caret/selection/IME, rulers and guides, animation timeline, responsive variants, platform checks. +Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Still open: exposed properties and named slots, visual binding/event editors, text-input caret/selection/IME, animation timeline, responsive variants, platform checks. From dcd9008a9a12149e5c9904cf2d22c8462dcbd446 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:38:50 +0000 Subject: [PATCH 15/26] Interface editor: widget events, binding list, text input caret and IME Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- AGENTS.md | 2 +- blockloom-app/src/shell.rs | 2 +- blockloom-core/src/ui.rs | 230 ++++++++++++++++++ blockloom-core/src/ui_framework.rs | 92 +++++++ blockloom-qt/qml/UiDesigner.qml | 62 ++++- .../tests/qml/tst_InterfaceViewport.qml | 10 + blockloom-runtime/src/engine.rs | 83 +++++++ blockloom-runtime/src/overlay.rs | 2 +- blockloom-runtime/src/ui.rs | 24 +- blockloom-runtime/src/world.rs | 168 ++++++++++--- docs/interface-editor-plan.md | 2 +- 11 files changed, 642 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 28962c29..b089e33c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3184,7 +3184,7 @@ lands. key`), edited through `set-locale` and friends; scripts drive both through ABI 47 (`switch_save_slot`, `save_slot`/`save_slots`, `set_language`, `language`, `text_for`). -- A text input is basic: no selection, no cursor, no IME. Backspace rubs out, +- A text input has a caret, shift-selection and IME composition (no pointer placement or drawn selection yet). Backspace rubs out, Escape and Enter let go, and every other character key appends - subject to the input's own `allow` and `max length`, which is all the validation there is. Backspace ignores both, so a rule written after the typing doesn't trap diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 0ea1c327..9b1f4358 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, items, tooltip, scroll_target, tab_index, transition. Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}}}]; $value is the new value on Change), items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-core/src/ui.rs b/blockloom-core/src/ui.rs index 70179387..a989ab06 100644 --- a/blockloom-core/src/ui.rs +++ b/blockloom-core/src/ui.rs @@ -420,6 +420,184 @@ pub fn typed(text: &str, ch: char, allow: UiAllow, max_length: usize) -> Option< Some(next) } +/// Where typing lands in a text input, and what is selected. Positions count +/// characters, not bytes, and are kept inside the text by every operation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct TextCaret { + pub at: usize, + /// The other end of a selection, when one is held. + pub anchor: Option, +} + +impl TextCaret { + /// A caret at the end of `text`, as a freshly focused input has. + pub fn end_of(text: &str) -> Self { + Self { + at: text.chars().count(), + anchor: None, + } + } + + /// The selected characters as `(start, end)`, if any are. + pub fn selection(&self) -> Option<(usize, usize)> { + self.anchor + .filter(|a| *a != self.at) + .map(|a| (a.min(self.at), a.max(self.at))) + } + + pub fn clamp(&mut self, text: &str) { + let len = text.chars().count(); + self.at = self.at.min(len); + self.anchor = self.anchor.map(|a| a.min(len)); + } + + fn byte(text: &str, chars: usize) -> usize { + text.char_indices() + .nth(chars) + .map_or(text.len(), |(i, _)| i) + } + + /// Removes the selection, leaving the caret where it was. True if it did. + fn cut(&mut self, text: &mut String) -> bool { + let Some((start, end)) = self.selection() else { + self.anchor = None; + return false; + }; + text.replace_range(Self::byte(text, start)..Self::byte(text, end), ""); + self.at = start; + self.anchor = None; + true + } + + /// Types one character over the selection, if any. The input's own rules + /// decide; a refused character leaves the text and selection as they were. + pub fn insert( + &mut self, + text: &mut String, + ch: char, + allow: UiAllow, + max_length: usize, + ) -> bool { + self.clamp(text); + let mut next = text.clone(); + let mut caret = *self; + caret.cut(&mut next); + if max_length > 0 && next.chars().count() >= max_length { + return false; + } + // A minus only leads, so it is judged against what precedes it. + let seen = if ch == '-' { + &next[..Self::byte(&next, caret.at)] + } else { + &next[..] + }; + if !allow.admits(seen, ch) { + return false; + } + next.insert(Self::byte(&next, caret.at), ch); + caret.at += 1; + *text = next; + *self = caret; + true + } + + /// Rubs out the selection, or the character before the caret. + pub fn backspace(&mut self, text: &mut String) -> bool { + self.clamp(text); + if self.cut(text) { + return true; + } + if self.at == 0 { + return false; + } + text.replace_range(Self::byte(text, self.at - 1)..Self::byte(text, self.at), ""); + self.at -= 1; + true + } + + /// Rubs out the selection, or the character after the caret. + pub fn delete(&mut self, text: &mut String) -> bool { + self.clamp(text); + if self.cut(text) { + return true; + } + if self.at >= text.chars().count() { + return false; + } + text.replace_range(Self::byte(text, self.at)..Self::byte(text, self.at + 1), ""); + true + } + + /// Moves by `delta` characters. `extend` (Shift) grows the selection; + /// without it a held selection collapses to the edge it moves towards. + pub fn step(&mut self, text: &str, delta: isize, extend: bool) { + self.clamp(text); + if !extend && let Some((start, end)) = self.selection() { + self.at = if delta < 0 { start } else { end }; + self.anchor = None; + return; + } + let len = text.chars().count() as isize; + let moved = (self.at as isize + delta).clamp(0, len) as usize; + self.go(moved, extend); + } + + /// Jumps to `at` (clamped), selecting from where it was when `extend`. + pub fn go(&mut self, at: usize, extend: bool) { + if extend { + self.anchor.get_or_insert(self.at); + } else { + self.anchor = None; + } + self.at = at; + } + + pub fn home(&mut self, extend: bool) { + self.go(0, extend); + } + + pub fn end(&mut self, text: &str, extend: bool) { + self.go(text.chars().count(), extend); + } + + pub fn select_all(&mut self, text: &str) { + self.anchor = Some(0); + self.at = text.chars().count(); + } + + /// The text with the caret drawn in and any selection and uncommitted IME + /// composition marked, for an input that holds the keyboard. + pub fn shown(&self, text: &str, preedit: &str) -> String { + let mut caret = *self; + caret.clamp(text); + let chars: Vec = text.chars().collect(); + let mut out = String::new(); + let selection = caret.selection(); + for (i, ch) in chars.iter().enumerate() { + if i == caret.at { + out.push_str(preedit); + if selection.is_none() { + out.push('|'); + } + } + if selection.is_some_and(|(s, _)| s == i) { + out.push('['); + } + out.push(*ch); + if selection.is_some_and(|(_, e)| e == i + 1) { + out.push(']'); + } + } + if caret.at >= chars.len() { + out.push_str(preedit); + if selection.is_none() { + out.push('|'); + } + } + out + } +} + /// Where a slider's number really lands: inside its own ends, and on a /// multiple of its step measured from the low end - so a 0-to-10 slider /// stepping 3 stops at 0, 3, 6 and 9, never at 7.4 and never at 10. A step @@ -642,6 +820,58 @@ mod tests { assert!(!UiKind::Label.is_input()); assert!(!UiKind::Button.is_input()); } + #[test] + fn a_caret_types_deletes_and_selects_inside_the_text() { + let mut text = String::from("held"); + let mut caret = TextCaret::end_of(&text); + caret.step(&text, -2, false); + assert!(caret.insert(&mut text, 'x', UiAllow::Any, 0)); + assert_eq!((text.as_str(), caret.at), ("hexld", 3)); + assert!(caret.backspace(&mut text)); + assert_eq!((text.as_str(), caret.at), ("held", 2)); + assert!(caret.delete(&mut text)); + assert_eq!(text, "hed"); + // Shift-extend, then typing replaces the selection. + caret.home(true); + assert_eq!(caret.selection(), Some((0, 2))); + assert!(caret.insert(&mut text, 'J', UiAllow::Any, 0)); + assert_eq!((text.as_str(), caret.at, caret.anchor), ("Jd", 1, None)); + caret.select_all(&text); + assert!(caret.backspace(&mut text)); + assert_eq!((text.as_str(), caret.at), ("", 0)); + assert!(!caret.backspace(&mut text) && !caret.delete(&mut text)); + } + + #[test] + fn a_caret_keeps_the_inputs_rules_and_counts_characters() { + let mut text = String::from("5"); + let mut caret = TextCaret::end_of(&text); + // A minus only leads; a full field takes no more; a number has one point. + assert!(!caret.insert(&mut text, '-', UiAllow::Numbers, 0)); + caret.home(false); + assert!(caret.insert(&mut text, '-', UiAllow::Numbers, 0)); + caret.end(&text, false); + assert!(caret.insert(&mut text, '.', UiAllow::Numbers, 0)); + assert!(!caret.insert(&mut text, '.', UiAllow::Numbers, 0)); + assert!(!caret.insert(&mut text, 'a', UiAllow::Digits, 0)); + let mut word = String::from("ab"); + let mut at = TextCaret::end_of(&word); + assert!(!at.insert(&mut word, 'c', UiAllow::Any, 2)); + // Multi-byte characters move one at a time. + let mut text = String::from("héé"); + let mut caret = TextCaret::end_of(&text); + caret.step(&text, -1, false); + assert!(caret.backspace(&mut text)); + assert_eq!(text, "hé"); + // A held selection collapses towards the arrow, and the display marks it. + let mut caret = TextCaret::end_of("abc"); + assert_eq!(caret.shown("abc", ""), "abc|"); + caret.select_all("abc"); + assert_eq!(caret.shown("abc", ""), "[abc]"); + caret.step("abc", -1, false); + assert_eq!((caret.at, caret.selection()), (0, None)); + assert_eq!(caret.shown("abc", "ni"), "ni|abc"); + } } #[path = "ui_framework.rs"] diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 5e5a4565..37547d9d 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -230,6 +230,34 @@ pub struct UiBinding { pub two_way: bool, } +/// What a widget reports to the game. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum UiEventKind { + Click, + Change, +} + +/// What a widget event does. In `SetVariable`, a value of `$value` stands for +/// the widget's new value on a Change event. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum UiDo { + Broadcast { + message: String, + }, + SetVariable { + #[serde(default)] + actor: String, + name: String, + value: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiAction { + pub on: UiEventKind, + pub run: UiDo, +} + #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub struct UiWidget { @@ -238,6 +266,9 @@ pub struct UiWidget { pub style: UiStyles, pub class: String, pub bindings: Vec, + /// Event handlers authored on the widget, run by the player without blocks. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub actions: Vec, pub items: Vec, pub tooltip: String, pub world_actor: String, @@ -314,6 +345,8 @@ pub enum UiPropertyEdit { Class(String), #[serde(rename = "bindings")] Bindings(Vec), + #[serde(rename = "actions")] + Actions(Vec), #[serde(rename = "items")] Items(Vec), #[serde(rename = "tooltip")] @@ -352,6 +385,7 @@ impl UiPropertyEdit { UiPropertyEdit::Style(_) => "style", UiPropertyEdit::Class(_) => "class", UiPropertyEdit::Bindings(_) => "bindings", + UiPropertyEdit::Actions(_) => "actions", UiPropertyEdit::Items(_) => "items", UiPropertyEdit::Tooltip(_) => "tooltip", UiPropertyEdit::ScrollTarget(_) => "scroll_target", @@ -469,6 +503,7 @@ pub fn property_metadata() -> Vec { vec![], vec![], ), + info("actions", "Events", "Bindings", "actions", vec![], vec![]), info( "items", "Items", @@ -818,6 +853,10 @@ impl UiDocument { } UiPropertyEdit::Class(value) => w.class = value.clone(), UiPropertyEdit::Bindings(value) => w.bindings = value.clone(), + UiPropertyEdit::Actions(value) => { + check_actions(value)?; + w.actions = value.clone(); + } UiPropertyEdit::Items(value) => w.items = value.clone(), UiPropertyEdit::Tooltip(value) => w.tooltip = value.clone(), UiPropertyEdit::ScrollTarget(value) => { @@ -1253,6 +1292,7 @@ impl UiDocument { }) { return Err(format!("Unsupported binding property on {id}")); } + check_actions(&widget.actions).map_err(|e| format!("{id}: {e}"))?; if let Some(layout) = &widget.layout && (layout.columns == 0 || !layout.row_height.is_finite() @@ -1331,6 +1371,21 @@ impl UiDocument { } } +fn check_actions(actions: &[UiAction]) -> Result<(), String> { + for action in actions { + match &action.run { + UiDo::Broadcast { message } if message.trim().is_empty() => { + return Err("A broadcast needs a message".into()); + } + UiDo::SetVariable { name, .. } if name.trim().is_empty() => { + return Err("Set variable needs a variable name".into()); + } + _ => {} + } + } + Ok(()) +} + fn to_json(widget: &UiWidget) -> Result { serde_json::to_value(widget).map_err(|e| e.to_string()) } @@ -1564,6 +1619,7 @@ mod tests { UiPropertyEdit::Style(Box::default()), UiPropertyEdit::Class(String::new()), UiPropertyEdit::Bindings(vec![]), + UiPropertyEdit::Actions(vec![]), UiPropertyEdit::Items(vec![]), UiPropertyEdit::Tooltip(String::new()), UiPropertyEdit::ScrollTarget(String::new()), @@ -1963,6 +2019,42 @@ mod tests { assert!(doc.validate().is_err()); } + #[test] + fn widget_actions_edit_validate_and_round_trip() { + let mut doc = UiDocument::default(); + doc.widgets = vec![row("play", "", "Play")]; + let good = serde_json::json!([ + {"on": "Click", "run": {"Broadcast": {"message": "start"}}}, + {"on": "Change", "run": {"SetVariable": {"name": "volume", "value": "$value"}}} + ]); + let edit = |value: serde_json::Value| UiEdit::SetProperty { + id: "play".into(), + property: serde_json::from_value( + serde_json::json!({"path": "actions", "value": value}), + ) + .unwrap(), + }; + doc.apply_edit(&edit(good)).unwrap(); + assert_eq!(doc.widgets[0].actions.len(), 2); + let back: UiDocument = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + assert_eq!(back.widgets[0].actions, doc.widgets[0].actions); + let before = doc.clone(); + assert!( + doc.apply_edit(&edit( + serde_json::json!([{"on": "Click", "run": {"Broadcast": {"message": " "}}}]) + )) + .is_err() + ); + assert_eq!(doc, before); + assert!(UiDocument::default().widgets.is_empty()); + doc.widgets[0].actions[0].run = UiDo::SetVariable { + actor: String::new(), + name: String::new(), + value: "1".into(), + }; + assert!(doc.validate().is_err()); + } + #[test] fn overrides_merge_per_field_and_can_be_listed_and_reset() { let mut doc = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 1bcc16d1..b4aa2be8 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -506,7 +506,7 @@ Item { if (!widget) return; propertyEdit("element." + field, value); } - readonly property var typedFields: ["layout","style","class","bindings","items","tooltip","scroll_target","tab_index","transition"] + readonly property var typedFields: ["layout","style","class","bindings","actions","items","tooltip","scroll_target","tab_index","transition"] function extra(field, value) { if (!widget) return; if (typedFields.indexOf(field) >= 0) { propertyEdit(field, value); return; } @@ -615,6 +615,28 @@ Item { const parent = widget && !widget.world_actor ? widget.element.id : ""; structuralEdit({kind: "InstantiatePrefab", name: name, prefix: prefix, parent: parent}, prefix + root.element.id, []); } + function describeAction(a) { + const run = a.run || {}; + if (run.Broadcast) return a.on + ": broadcast " + run.Broadcast.message; + const v = run.SetVariable || {}; + return a.on + ": set " + (v.actor ? v.actor + "." : "") + v.name + " to " + v.value; + } + function describeBinding(b) { + const v = b.source && b.source.Variable; + return b.property + (b.two_way ? " <-> " : " <- ") + (v ? (v.actor ? v.actor + "." : "") + v.name : "component field"); + } + function addAction(action) { + if (!widget) return; + propertyEdit("actions", (widget.actions || []).concat([action])); + } + function removeAction(index) { + if (!widget) return; + propertyEdit("actions", (widget.actions || []).filter((a, i) => i !== index)); + } + function removeBinding(index) { + if (!widget) return; + propertyEdit("bindings", (widget.bindings || []).filter((b, i) => i !== index)); + } function resetInstance() { if (widget && widget.instance_of) structuralEdit({kind: "ResetInstance", id: widget.element.id}, widget.element.id, []); } @@ -1127,6 +1149,44 @@ Item { bindings.push({property:bindingProperty.currentText,source:{Variable:{actor:bindingActor.text,name:bindingName.text}},two_way:bindingWrite.checked}); root.extra("bindings",bindings); } } + Repeater { + model: root.widget ? root.widget.bindings || [] : [] + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true + Label { Layout.fillWidth: true; elide: Text.ElideRight; text: root.describeBinding(modelData); color: Theme.textDim } + Button { objectName: "interfaceRemoveBinding" + index; text: "Remove"; enabled: !root.gesture; onClicked: root.removeBinding(index) } + } + } + Label { text: "Events"; font.bold: true } + Repeater { + model: root.widget ? root.widget.actions || [] : [] + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true + Label { Layout.fillWidth: true; elide: Text.ElideRight; text: root.describeAction(modelData); color: Theme.textDim } + Button { objectName: "interfaceRemoveAction" + index; text: "Remove"; enabled: !root.gesture; onClicked: root.removeAction(index) } + } + } + RowLayout { + ComboBox { id: actionOn; objectName: "interfaceActionOn"; model: ["Click","Change"] } + ComboBox { id: actionKind; objectName: "interfaceActionKind"; model: ["Broadcast","Set variable"] } + } + TextField { id: actionMessage; objectName: "interfaceActionMessage"; Layout.fillWidth: true; visible: actionKind.currentIndex === 0; placeholderText: "Message to broadcast" } + TextField { id: actionActor; Layout.fillWidth: true; visible: actionKind.currentIndex === 1; placeholderText: "Actor id (blank for global)" } + TextField { id: actionVariable; objectName: "interfaceActionVariable"; Layout.fillWidth: true; visible: actionKind.currentIndex === 1; placeholderText: "Variable name" } + TextField { id: actionValue; objectName: "interfaceActionValue"; Layout.fillWidth: true; visible: actionKind.currentIndex === 1; placeholderText: "Value ($value is the new value on Change)" } + Button { + objectName: "interfaceAddAction"; text: "Add event" + enabled: !!root.widget && !root.gesture && (actionKind.currentIndex === 0 ? !!actionMessage.text.trim() : !!actionVariable.text.trim()) + onClicked: { + root.addAction({on: actionOn.currentText, run: actionKind.currentIndex === 0 + ? {Broadcast: {message: actionMessage.text.trim()}} + : {SetVariable: {actor: actionActor.text.trim(), name: actionVariable.text.trim(), value: actionValue.text}}}); + } + } Label { text: "Advanced properties"; font.bold: true } Repeater { model: ["layout","style","bindings"] diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 12269d90..da7432c8 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -453,6 +453,16 @@ TestCase { panel.showReference = true; verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); } + function test_events_and_bindings_edit_through_typed_properties() { + panel.selectedId = "front"; + panel.addAction({on: "Click", run: {Broadcast: {message: "start"}}}); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit, {kind:"SetProperty", id:"front", property:{path:"actions", value:[{on:"Click", run:{Broadcast:{message:"start"}}}]}}); + compare(panel.describeAction({on:"Change", run:{SetVariable:{actor:"", name:"vol", value:"$value"}}}), "Change: set vol to $value"); + compare(panel.describeBinding({property:"Text", source:{Variable:{actor:"", name:"score"}}, two_way:false}), "Text <- score"); + panel.removeBinding(0); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_guides_are_editor_only_and_snap_targets_follow_the_frame() { compare(panel.guides.length, 0); const a = panel.addGuide(0, 100); diff --git a/blockloom-runtime/src/engine.rs b/blockloom-runtime/src/engine.rs index dcbdbeab..efc9aa6f 100644 --- a/blockloom-runtime/src/engine.rs +++ b/blockloom-runtime/src/engine.rs @@ -826,7 +826,53 @@ impl Engine { format!("~h{}", self.made) } + /// Runs the actions a widget was authored with for `event`, if it is a + /// click or a change. + fn run_widget_actions(&mut self, event: &blockloom_core::vm::Event) { + use blockloom_core::ui::{UiDo, UiEventKind}; + use blockloom_core::vm::Event; + let (id, kind, changed) = match event { + Event::UiClicked { id } => (id, UiEventKind::Click, None), + Event::UiChanged { id, value } => (id, UiEventKind::Change, Some(value)), + _ => return, + }; + let Some(widget) = self + .project + .world + .interface + .widgets + .iter() + .find(|w| &w.element.id == id) + else { + return; + }; + let actions: Vec = widget + .actions + .iter() + .filter(|a| a.on == kind) + .map(|a| a.run.clone()) + .collect(); + for run in actions { + match run { + UiDo::Broadcast { message } => { + self.fire(Event::Message(message.trim().to_string())); + } + UiDo::SetVariable { actor, name, value } => { + let value = match (value.trim(), changed) { + ("$value", Some(v)) => v.clone(), + (text, _) => match text.parse::() { + Ok(n) => Evaluated::Number(n), + Err(_) => Evaluated::Text(value.clone()), + }, + }; + self.variables.write(&actor, &name, value); + } + } + } + } + pub fn fire(&mut self, event: blockloom_core::vm::Event) { + self.run_widget_actions(&event); // Queued even while paused: the fixed `tick` skips paused steps, but // the per-frame `ui` entry point drains these so menus answer while // the world is frozen, the way UI strands do. @@ -853,6 +899,43 @@ impl Engine { mod tests { use super::*; + #[test] + fn authored_widget_actions_broadcast_and_write_variables() { + use blockloom_core::ui::{UiAction, UiDo, UiEventKind, UiWidget}; + use blockloom_core::vm::Event; + let (_sender, incoming) = std::sync::mpsc::channel(); + let mut engine = Engine::new(incoming, Mode::TwoD); + let mut slider = UiWidget::default(); + slider.element.id = "volume".into(); + slider.actions = vec![ + UiAction { + on: UiEventKind::Change, + run: UiDo::SetVariable { + actor: String::new(), + name: "level".into(), + value: "$value".into(), + }, + }, + UiAction { + on: UiEventKind::Click, + run: UiDo::Broadcast { + message: "open".into(), + }, + }, + ]; + engine.project.world.interface.widgets.push(slider); + engine.fire(Event::UiChanged { + id: "volume".into(), + value: Evaluated::Number(7.0), + }); + assert_eq!(engine.variables.read("", "level"), Evaluated::Number(7.0)); + // A click runs only the click handler; nothing else is written. + engine.fire(Event::UiClicked { + id: "volume".into(), + }); + assert_eq!(engine.variables.read("", "level"), Evaluated::Number(7.0)); + } + #[test] fn say_replaces_and_empty_say_clears_an_actors_bubble() { let (_sender, incoming) = std::sync::mpsc::channel(); diff --git a/blockloom-runtime/src/overlay.rs b/blockloom-runtime/src/overlay.rs index 79af5409..103bed50 100644 --- a/blockloom-runtime/src/overlay.rs +++ b/blockloom-runtime/src/overlay.rs @@ -515,7 +515,7 @@ pub fn draw_ui( &paint, )); } - let wanted = crate::ui::text_of(element); + let wanted = crate::ui::display_text(element, id == focused); for (text_entity, owner, mut text, mut font, mut color) in &mut labels { if owner.0 != id { continue; diff --git a/blockloom-runtime/src/ui.rs b/blockloom-runtime/src/ui.rs index 162b17b9..a7f05539 100644 --- a/blockloom-runtime/src/ui.rs +++ b/blockloom-runtime/src/ui.rs @@ -14,8 +14,8 @@ use bevy::prelude::*; use blockloom_core::sense::UiSense; use blockloom_core::ui::{ - UiAllow, UiAnchor, UiBinding, UiDocument, UiElement, UiKind, UiLayout, UiPaint, UiProp, - UiStyles, UiTheme, UiWidget, + TextCaret, UiAllow, UiAnchor, UiBinding, UiDocument, UiElement, UiKind, UiLayout, UiPaint, + UiProp, UiStyles, UiTheme, UiWidget, }; use blockloom_core::value::Evaluated; use std::collections::HashMap; @@ -118,6 +118,10 @@ pub struct UiNode { pub collection_range: Option<(usize, usize)>, pub collection_dirty: bool, pub expanded: bool, + /// Where typing lands while this input holds the keyboard. + pub caret: TextCaret, + /// An IME composition not yet committed, drawn at the caret. + pub preedit: String, } impl UiNode { @@ -366,6 +370,8 @@ impl UiManager { collection_range: None, collection_dirty: true, expanded: false, + caret: TextCaret::default(), + preedit: String::new(), }, ); self.pending.push(id); @@ -703,6 +709,11 @@ impl UiManager { for id in [self.focus.clone(), next.clone()].into_iter().flatten() { self.mark_dirty(&id); } + // A fresh focus starts typing at the end, with no composition held. + if let Some(node) = next.as_ref().and_then(|id| self.nodes.get_mut(id)) { + node.caret = TextCaret::end_of(&node.value.as_text()); + node.preedit.clear(); + } self.focus = next; } @@ -1033,6 +1044,15 @@ pub fn text_of(node: &UiNode) -> String { } } +/// What an element draws as its text: an input that holds the keyboard shows +/// its caret, selection and composition, everything else its `text_of`. +pub fn display_text(node: &UiNode, focused: bool) -> String { + if focused && node.kind == UiKind::Input { + return node.caret.shown(&node.value.as_text(), &node.preedit); + } + text_of(node) +} + pub fn layout_node(element: &UiNode, canvas: bool) -> Node { use blockloom_core::ui::{UiAlign, UiLength}; let mut node = node_for(&element.spec, !element.parent.is_empty() && !canvas); diff --git a/blockloom-runtime/src/world.rs b/blockloom-runtime/src/world.rs index 8f8b82f8..aa689718 100644 --- a/blockloom-runtime/src/world.rs +++ b/blockloom-runtime/src/world.rs @@ -18,6 +18,7 @@ use crate::engine::{ PendingEffects, PhysicsPose, PrevPose, TweeningColor, TweeningRotation, TweeningScale, }; use crate::{bridge, dim2, dim3}; +use bevy::ecs::message::MessageCursor; use bevy::input::ButtonState; use bevy::input::gamepad::{ Gamepad, GamepadAxis, GamepadButton, GamepadRumbleIntensity, GamepadRumbleRequest, @@ -28,9 +29,7 @@ use bevy::input::touch::Touches; use bevy::prelude::*; #[cfg(not(target_arch = "wasm32"))] use bevy::window::CursorGrabMode; -#[cfg(target_os = "android")] -use bevy::window::Ime; -use bevy::window::{CursorOptions, PrimaryWindow, WindowFocused}; +use bevy::window::{CursorOptions, Ime, PrimaryWindow, WindowFocused}; use blockloom_core::input::{ActionSense, LiveInput, normalize_pad_axis, normalize_pad_button}; use blockloom_core::nav; use blockloom_core::project::Actor; @@ -2269,8 +2268,10 @@ pub fn back_button( } /// Types into whichever text input holds the keyboard. Escape lets go of -/// it, Backspace rubs a character out, and every other key that means a -/// character adds one. `changed` fires per keystroke, as the spec has it. +/// it, Backspace and Delete rub characters out around the caret, the arrows, +/// Home and End move it (Shift selects, Ctrl or Cmd with A selects all), and +/// every other key that means a character adds one at the caret. `changed` +/// fires per keystroke, as the spec has it. /// /// While an input holds the keyboard, `publish_sensors` hands the game no /// keys at all, so nothing else sees this typing. @@ -2278,14 +2279,28 @@ pub fn type_into_focused_input( mut engine: NonSendMut, mut manager: ResMut, mut typed: MessageReader, - #[cfg(target_os = "android")] mut imes: MessageReader, + // Optional so a world without a window (or a test) needs neither. + ime_messages: Option>>, + mut ime_cursor: Local>, + held: Option>>, + mut windows: Query<&mut Window, With>, ) { - let Some(focused) = manager.focus().map(str::to_string) else { + let mut imes: Vec = ime_messages + .map(|m| ime_cursor.read(&m).cloned().collect()) + .unwrap_or_default(); + let focused = manager.focus().map(str::to_string); + // The window asks the platform for composition only while an input holds + // the keyboard, so a game's own keys never open an input method. + for mut window in &mut windows { + let wanted = focused.is_some(); + if window.ime_enabled != wanted { + window.ime_enabled = wanted; + } + } + let Some(focused) = focused else { // Nothing has the keyboard, but the queue still has to be drained: // otherwise a burst arrives the moment an input is clicked. typed.clear(); - #[cfg(target_os = "android")] - imes.clear(); return; }; if !engine.running { @@ -2293,23 +2308,23 @@ pub fn type_into_focused_input( } let Some(node) = manager.get(&focused) else { typed.clear(); - #[cfg(target_os = "android")] - imes.clear(); return; }; let (allow, ceiling) = (node.allow(), node.max_length()); let mut text = node.value.as_text(); let before = text.clone(); + let mut caret = node.caret; + caret.clamp(&text); + let (caret_before, mut preedit) = (caret, node.preedit.clone()); let mut release = false; + let down = |keys: [KeyCode; 2]| held.as_ref().is_some_and(|h| h.any_pressed(keys)); + let shift = down([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + let command = down([KeyCode::ControlLeft, KeyCode::ControlRight]) + || down([KeyCode::SuperLeft, KeyCode::SuperRight]); // Every character goes through the input's own rules, one at a time: // a full field takes no more, and a numeric one takes no letters. What // is refused is simply not there - a rubbed-out keystroke rather than // an error, since a person holding a key down means no harm by it. - let write = |text: &mut String, ch: char| { - if let Some(next) = blockloom_core::ui::typed(text, ch, allow, ceiling) { - *text = next; - } - }; for key in typed.read() { if key.state != ButtonState::Pressed { continue; @@ -2320,31 +2335,68 @@ pub fn type_into_focused_input( // hands the keyboard back rather than typing anything. Key::BrowserBack => release = true, Key::Backspace => { - text.pop(); + caret.backspace(&mut text); + } + Key::Delete => { + caret.delete(&mut text); + } + Key::ArrowLeft => caret.step(&text, -1, shift), + Key::ArrowRight => caret.step(&text, 1, shift), + Key::Home => caret.home(shift), + Key::End => caret.end(&text, shift), + Key::Space => { + caret.insert(&mut text, ' ', allow, ceiling); } - Key::Space => write(&mut text, ' '), Key::Enter => release = true, + Key::Character(written) if command => { + if written.eq_ignore_ascii_case("a") { + caret.select_all(&text); + } + } Key::Character(written) => { for ch in written.chars() { - write(&mut text, ch); + caret.insert(&mut text, ch, allow, ceiling); } } _ => {} } } - // The soft keyboard commits whole runs instead of keys: same rules, one - // at a time. Only on Android, where the keyboard raises it. - #[cfg(target_os = "android")] - for ime in imes.read() { - if let Ime::Commit { value, .. } = ime { - for ch in value.chars() { - write(&mut text, ch); + // An input method commits whole runs instead of keys: same rules, one + // character at a time. Until it does, its composition is only shown. + for ime in imes.drain(..) { + match ime { + Ime::Preedit { value, cursor, .. } => { + preedit = if cursor.is_some() { + value + } else { + String::new() + }; } + Ime::Commit { value, .. } => { + preedit.clear(); + for ch in value.chars() { + caret.insert(&mut text, ch, allow, ceiling); + } + } + Ime::Disabled { .. } => preedit.clear(), + Ime::Enabled { .. } => {} } } - if text != before - && let Some(value) = manager.changed(&focused, Evaluated::Text(text)) + let changed = text != before; + let moved = caret != caret_before; + let shown = manager.get(&focused).map(|n| n.preedit.clone()); + if (moved || changed || shown.as_ref() != Some(&preedit)) + && let Some(node) = manager.get_mut(&focused) { + node.caret = caret; + node.preedit = preedit; + node.dirty = true; + } + if changed && let Some(value) = manager.changed(&focused, Evaluated::Text(text)) { + if let Some(node) = manager.get_mut(&focused) { + let settled = node.value.as_text(); + node.caret.clamp(&settled); + } engine.fire(Event::UiChanged { id: focused.clone(), value, @@ -7857,6 +7909,66 @@ mod tests { assert_eq!(typed_text(&app), "Ada 7!"); } + fn press(app: &mut App, logical_key: Key) { + app.world_mut() + .resource_mut::>() + .write(KeyboardInput { + key_code: KeyCode::KeyA, + logical_key, + state: ButtonState::Pressed, + text: None, + repeat: false, + window: Entity::PLACEHOLDER, + }); + app.update(); + } + + #[test] + fn arrows_home_delete_and_composition_edit_around_the_caret() { + let mut app = typing_harness(|_| {}); + type_word(&mut app, "held"); + press(&mut app, Key::ArrowLeft); + press(&mut app, Key::ArrowLeft); + type_word(&mut app, "x"); + assert_eq!(typed_text(&app), "hexld"); + press(&mut app, Key::Delete); + assert_eq!(typed_text(&app), "hexd"); + press(&mut app, Key::Home); + press(&mut app, Key::Delete); + assert_eq!(typed_text(&app), "exd"); + press(&mut app, Key::End); + press(&mut app, Key::Backspace); + assert_eq!(typed_text(&app), "ex"); + // The caret is drawn only while the input holds the keyboard. + let shown = |app: &App, focused| { + let manager = app.world().resource::(); + crate::ui::display_text(manager.get("name").unwrap(), focused) + }; + assert_eq!(shown(&app, true), "ex|"); + assert_eq!(shown(&app, false), "ex"); + // An input method's composition is shown at the caret, then committed. + app.init_resource::>(); + app.world_mut() + .resource_mut::>() + .write(Ime::Preedit { + window: Entity::PLACEHOLDER, + value: "ni".into(), + cursor: Some((2, 2)), + }); + app.update(); + assert_eq!(shown(&app, true), "exni|"); + assert_eq!(typed_text(&app), "ex"); + app.world_mut() + .resource_mut::>() + .write(Ime::Commit { + window: Entity::PLACEHOLDER, + value: "ä½ ".into(), + }); + app.update(); + assert_eq!(typed_text(&app), "exä½ "); + assert_eq!(shown(&app, true), "exä½ |"); + } + #[test] fn a_numeric_input_turns_away_what_is_not_a_number() { let mut app = typing_harness(|manager| { diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 1cf76cf1..4cd6461d 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -303,4 +303,4 @@ Verification for keyboard nudging and sibling order: the final full workspace bu - **Components (phase 5, first slice).** `UiEdit::{SavePrefab, InstantiatePrefab, DeletePrefab, DetachInstance}` build on the existing `prefabs` map. An instance root carries `instance_of`; `SavePrefab { update_instances: true }` rebuilds every instance from the new source and keeps any widget the instance changed from the old version (override at widget granularity). A dangling `instance_of` fails validation; deleting a component detaches its instances. The inspector has a Component section (save, insert with a unique `iN.` prefix, detach, delete). - **Image fit (phase 4).** `UiPaint.image_fit` is `Stretch`, `Sliced { border: [l, r, t, b] }` or `Tiled`; unset keeps the image's own size. The runtime maps it to Bevy's `NodeImageMode`; the inspector has an Image fit dropdown with four border fields. -Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Still open: exposed properties and named slots, visual binding/event editors, text-input caret/selection/IME, animation timeline, responsive variants, platform checks. +Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Events and caret: a widget's `actions` (property path `actions`) run on Click or Change without blocks: Broadcast a message, or SetVariable (`$value` is the new value on Change), applied in `Engine::fire` so VM and compiled logic see the same events; the inspector lists and removes bindings and events and adds new events. A focused text input now has a caret (`TextCaret` in core): arrows, Home, End, Delete, Shift-select, Ctrl/Cmd+A, typing over a selection, and IME composition shown at the caret and committed through the input's allow/max-length rules; the window asks for IME only while an input holds the keyboard. The selection is drawn as `[...]` and the caret as `|` in the text; clicking to place the caret and a drawn selection remain open. Still open: exposed properties and named slots, animation timeline, responsive variants, platform checks. From 4e9a4e7cc65eaf78706990b3104b847853eb0602 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:39:01 +0000 Subject: [PATCH 16/26] Update the text input note Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- CLAUDE.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4d25be61..2a4f7150 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2439,10 +2439,11 @@ lands. key`), edited through `set-locale` and friends; scripts drive both through ABI 47 (`switch_save_slot`, `save_slot`/`save_slots`, `set_language`, `language`, `text_for`). -- A text input is basic: no selection, no cursor, no IME. Backspace rubs out, - Escape and Enter let go, and every other character key appends - subject to - the input's own `allow` and `max length`, which is all the validation there - is. Backspace ignores both, so a rule written after the typing doesn't trap +- A text input has a caret (`ui::TextCaret`): arrows, Home, End, Delete, + Shift-select, Ctrl/Cmd+A and IME composition, all through the input's own + `allow` and `max length`. The caret shows as `|` and a selection as `[..]` + in the text; there is no pointer placement or drawn selection yet. Backspace + and Delete ignore the rules, so a rule written after the typing doesn't trap what is already in the field. - A clone copies the template as the editor authored it, standing where the template stands now. What `attach`/`detach` did to the template since Play From b6dd9fab1aa02290ef5b467f3b0620aa5661eb93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:43:28 +0000 Subject: [PATCH 17/26] Interface editor: exposed component properties and slots Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-app/src/shell.rs | 2 +- blockloom-core/src/ui_framework.rs | 305 +++++++++++++++++- blockloom-qt/qml/UiDesigner.qml | 65 ++++ .../tests/qml/tst_InterfaceViewport.qml | 11 + docs/interface-editor-plan.md | 2 +- 5 files changed, 382 insertions(+), 3 deletions(-) diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 9b1f4358..45b21c87 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}}}]; $value is the new value on Change), items, tooltip, scroll_target, tab_index, transition. Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}, ExposeProperty {prefab, name, widget, path}, ExposeSlot {prefab, name, widget}, Unexpose {prefab, name}, SetExposed {instance, name, value}, FillSlot {instance, slot, widget}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}}}]; $value is the new value on Change), items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 37547d9d..70e95e2e 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -300,6 +300,23 @@ pub struct UiDocument { pub reference_size: [f32; 2], pub safe_area: [f32; 4], pub prefabs: BTreeMap>, + /// What each prefab lets an instance set by name: properties and slots. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub exports: BTreeMap>, +} + +/// A named handle on a prefab: one property of one widget inside it, or a +/// container widget other widgets can be placed into. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiExport { + pub name: String, + /// The widget's id inside the prefab. + pub widget: String, + /// A property path (see `property_metadata`); empty for a slot. + #[serde(default)] + pub path: String, + #[serde(default)] + pub slot: bool, } /// The schema version this build writes. pub const UI_SCHEMA_VERSION: u32 = 1; @@ -320,6 +337,7 @@ impl Default for UiDocument { reference_size: [960., 720.], safe_area: [0.; 4], prefabs: BTreeMap::new(), + exports: BTreeMap::new(), } } } @@ -667,6 +685,35 @@ pub enum UiEdit { ResetInstance { id: String, }, + /// Lets instances of `prefab` set `path` of its widget `widget` as `name`. + ExposeProperty { + prefab: String, + name: String, + widget: String, + path: String, + }, + /// Lets instances of `prefab` take widgets inside its container `widget`. + ExposeSlot { + prefab: String, + name: String, + widget: String, + }, + Unexpose { + prefab: String, + name: String, + }, + /// Sets an exposed property on the instance rooted at `instance`. + SetExposed { + instance: String, + name: String, + value: serde_json::Value, + }, + /// Moves `widget` into the instance's slot `slot`. + FillSlot { + instance: String, + slot: String, + widget: String, + }, } impl UiDocument { @@ -726,6 +773,7 @@ impl UiDocument { if self.prefabs.remove(name).is_none() { return Err(format!("No UI prefab named {name}")); } + self.exports.remove(name); for w in &mut self.widgets { if &w.instance_of == name { w.instance_of.clear(); @@ -751,6 +799,80 @@ impl UiDocument { return Ok(()); } UiEdit::ResetInstance { id } => return self.reset_instance(id), + UiEdit::ExposeProperty { + prefab, + name, + widget, + path, + } => { + if !property_metadata().iter().any(|m| &m.path == path) { + return Err(format!("Unknown property path: {path}")); + } + return self.expose(prefab, name, widget, path, false); + } + UiEdit::ExposeSlot { + prefab, + name, + widget, + } => return self.expose(prefab, name, widget, "", true), + UiEdit::Unexpose { prefab, name } => { + let list = self.exports.get_mut(prefab); + let before = list.as_ref().map_or(0, |l| l.len()); + if let Some(list) = list { + list.retain(|e| &e.name != name); + if list.is_empty() { + self.exports.remove(prefab); + } + } + let after = self.exports.get(prefab).map_or(0, |l| l.len()); + return if before == after { + Err(format!("{prefab} exposes nothing called {name}")) + } else { + Ok(()) + }; + } + UiEdit::SetExposed { + instance, + name, + value, + } => { + let (export, prefix) = self.exported(instance, name, false)?; + let property = serde_json::from_value( + serde_json::json!({"path": export.path, "value": value}), + ) + .map_err(|e| format!("{name}: {e}"))?; + return self.apply_edit(&UiEdit::SetProperty { + id: format!("{prefix}{}", export.widget), + property, + }); + } + UiEdit::FillSlot { + instance, + slot, + widget, + } => { + let (export, prefix) = self.exported(instance, slot, true)?; + let parent = format!("{prefix}{}", export.widget); + let canvas = self + .find(&parent) + .is_some_and(|i| self.widgets[i].element.kind == UiKind::Canvas); + let placement = if canvas { + let size = self + .find(widget) + .map_or([100., 40.], |i| self.widgets[i].element.size); + UiPlacement::Free { + offset: [0.; 2], + size: [size[0].max(1.), size[1].max(1.)], + } + } else { + UiPlacement::Flow + }; + return self.apply_edit(&UiEdit::Reparent { + id: widget.clone(), + parent, + placement, + }); + } _ => {} } let id = match edit { @@ -769,7 +891,12 @@ impl UiDocument { | UiEdit::DeletePrefab { .. } | UiEdit::InstantiatePrefab { .. } | UiEdit::DetachInstance { .. } - | UiEdit::ResetInstance { .. } => unreachable!(), + | UiEdit::ResetInstance { .. } + | UiEdit::ExposeProperty { .. } + | UiEdit::ExposeSlot { .. } + | UiEdit::Unexpose { .. } + | UiEdit::SetExposed { .. } + | UiEdit::FillSlot { .. } => unreachable!(), }; let index = self .widgets @@ -1181,6 +1308,102 @@ impl UiDocument { Ok(()) } + fn expose( + &mut self, + prefab: &str, + name: &str, + widget: &str, + path: &str, + slot: bool, + ) -> Result<(), String> { + if name.trim().is_empty() { + return Err("An exposed name cannot be empty".into()); + } + let members = self + .prefabs + .get(prefab) + .ok_or_else(|| format!("No UI prefab named {prefab}"))?; + if !members.iter().any(|w| w.element.id == widget) { + return Err(format!("{prefab} has no widget {widget}")); + } + let list = self.exports.entry(prefab.to_string()).or_default(); + if list.iter().any(|e| e.name == name) { + return Err(format!("{prefab} already exposes {name}")); + } + list.push(UiExport { + name: name.to_string(), + widget: widget.to_string(), + path: path.to_string(), + slot, + }); + Ok(()) + } + + /// The prefab export `name` of the instance at `instance`, and the id + /// prefix its widgets carry. + fn exported( + &self, + instance: &str, + name: &str, + slot: bool, + ) -> Result<(UiExport, String), String> { + let at = self + .find(instance) + .ok_or_else(|| format!("Unknown widget: {instance}"))?; + let prefab = self.widgets[at].instance_of.clone(); + let root = self + .prefabs + .get(&prefab) + .and_then(|p| p.iter().find(|w| w.element.parent.is_empty())) + .map(|w| w.element.id.clone()) + .ok_or_else(|| format!("{instance} is not the root of a component instance"))?; + let prefix = instance + .strip_suffix(&root) + .ok_or_else(|| format!("Instance {instance} does not follow its prefab's naming"))? + .to_string(); + let export = self + .exports + .get(&prefab) + .and_then(|l| l.iter().find(|e| e.name == name && e.slot == slot)) + .ok_or_else(|| { + format!( + "{prefab} exposes no {} called {name}", + if slot { "slot" } else { "property" } + ) + })?; + Ok((export.clone(), prefix)) + } + + /// What an instance's exposed properties currently hold, by name. + pub fn exposed_values( + &self, + instance: &str, + ) -> Result, String> { + let at = self + .find(instance) + .ok_or_else(|| format!("Unknown widget: {instance}"))?; + let prefab = &self.widgets[at].instance_of; + let mut out = Vec::new(); + for export in self + .exports + .get(prefab) + .into_iter() + .flatten() + .filter(|e| !e.slot) + { + let (_, prefix) = self.exported(instance, &export.name, false)?; + let Some(i) = self.find(&format!("{prefix}{}", export.widget)) else { + continue; + }; + let mut value = to_json(&self.widgets[i])?; + for step in export.path.split('.') { + value = value.get(step).cloned().unwrap_or(serde_json::Value::Null); + } + out.push((export.name.clone(), value)); + } + Ok(out) + } + /// Rebuilds the instance rooted at `id` exactly as its prefab says. fn reset_instance(&mut self, id: &str) -> Result<(), String> { let at = self @@ -1271,6 +1494,17 @@ impl UiDocument { return Err("An interface supports at most 10000 widgets".into()); } let mut ids = HashSet::new(); + for (prefab, list) in &self.exports { + let members = self + .prefabs + .get(prefab) + .ok_or_else(|| format!("Exports for a missing prefab: {prefab}"))?; + if list.iter().any(|e| { + e.name.trim().is_empty() || !members.iter().any(|w| w.element.id == e.widget) + }) { + return Err(format!("{prefab} exposes something that is not in it")); + } + } for widget in &self.widgets { let id = &widget.element.id; if id.is_empty() @@ -2019,6 +2253,75 @@ mod tests { assert!(doc.validate().is_err()); } + #[test] + fn instances_set_exposed_properties_and_fill_slots() { + let mut doc = UiDocument::default(); + let mut body = row("body", "card", ""); + body.element.kind = UiKind::VerticalBox; + doc.widgets = vec![row("card", "", "Card"), row("title", "card", "Title"), body]; + doc.apply_edit(&UiEdit::SavePrefab { + name: "card".into(), + root: "card".into(), + update_instances: false, + }) + .unwrap(); + let expose = |doc: &mut UiDocument, edit: serde_json::Value| { + doc.apply_edit(&serde_json::from_value(edit).unwrap()) + }; + expose(&mut doc, serde_json::json!({"kind": "ExposeProperty", "prefab": "card", "name": "title", "widget": "title", "path": "element.content"})).unwrap(); + expose(&mut doc, serde_json::json!({"kind": "ExposeSlot", "prefab": "card", "name": "body", "widget": "body"})).unwrap(); + // Bad exposures are refused. + assert!(expose(&mut doc, serde_json::json!({"kind": "ExposeProperty", "prefab": "card", "name": "x", "widget": "nope", "path": "tooltip"})).is_err()); + assert!(expose(&mut doc, serde_json::json!({"kind": "ExposeProperty", "prefab": "card", "name": "x", "widget": "title", "path": "bogus"})).is_err()); + assert!(expose(&mut doc, serde_json::json!({"kind": "ExposeProperty", "prefab": "card", "name": "title", "widget": "title", "path": "tooltip"})).is_err()); + doc.apply_edit(&UiEdit::InstantiatePrefab { + name: "card".into(), + prefix: "a.".into(), + parent: String::new(), + }) + .unwrap(); + expose(&mut doc, serde_json::json!({"kind": "SetExposed", "instance": "a.card", "name": "title", "value": "Hello"})).unwrap(); + assert_eq!( + doc.widgets[doc.find("a.title").unwrap()].element.content, + "Hello" + ); + assert_eq!( + doc.exposed_values("a.card").unwrap(), + vec![("title".to_string(), serde_json::json!("Hello"))] + ); + // The wrong type, an unknown name and a slot used as a property fail. + assert!(expose(&mut doc, serde_json::json!({"kind": "SetExposed", "instance": "a.card", "name": "title", "value": 5})).is_err()); + assert!(expose(&mut doc, serde_json::json!({"kind": "SetExposed", "instance": "a.card", "name": "body", "value": "x"})).is_err()); + // A loose widget moves into the slot. + doc.widgets.push(row("extra", "", "Extra")); + expose(&mut doc, serde_json::json!({"kind": "FillSlot", "instance": "a.card", "slot": "body", "widget": "extra"})).unwrap(); + assert_eq!( + doc.widgets[doc.find("extra").unwrap()].element.parent, + "a.body" + ); + assert!(doc.validate().is_ok()); + // Unexposing and deleting the prefab clean up. + expose( + &mut doc, + serde_json::json!({"kind": "Unexpose", "prefab": "card", "name": "title"}), + ) + .unwrap(); + assert!( + expose( + &mut doc, + serde_json::json!({"kind": "Unexpose", "prefab": "card", "name": "title"}) + ) + .is_err() + ); + expose( + &mut doc, + serde_json::json!({"kind": "DeletePrefab", "name": "card"}), + ) + .unwrap(); + assert!(doc.exports.is_empty()); + assert!(doc.validate().is_ok()); + } + #[test] fn widget_actions_edit_validate_and_round_trip() { let mut doc = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index b4aa2be8..bac91c60 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -637,6 +637,40 @@ Item { if (!widget) return; propertyEdit("bindings", (widget.bindings || []).filter((b, i) => i !== index)); } + // Exposed properties of the selected instance: [{name, value}] read from its widgets. + function exposedRows() { + if (!widget || !widget.instance_of) return []; + const prefab = (document.prefabs || {})[widget.instance_of] || []; + const rootId = (prefab.find(w => !w.element.parent) || {element: {id: ""}}).element.id; + const id = widget.element.id; + if (!rootId || id.slice(id.length - rootId.length) !== rootId) return []; + const prefix = id.slice(0, id.length - rootId.length); + return ((document.exports || {})[widget.instance_of] || []).map(e => { + const target = document.widgets.find(w => w.element.id === prefix + e.widget); + let value = target; + (e.path ? e.path.split(".") : []).forEach(step => { value = value === undefined || value === null ? undefined : value[step]; }); + return {name: e.name, slot: !!e.slot, value: e.slot ? "" : (value === undefined || value === null ? "" : value)}; + }); + } + function setExposed(name, text) { + if (!widget) return; + let value = text; + const current = exposedRows().find(r => r.name === name); + if (current && typeof current.value !== "string") { try { value = JSON.parse(text); } catch (e) { error = String(e); return; } } + structuralEdit({kind: "SetExposed", instance: widget.element.id, name: name, value: value}, widget.element.id, []); + } + function fillSlot(slot, widgetId) { + if (!widget || !widgetId) return; + structuralEdit({kind: "FillSlot", instance: widget.element.id, slot: slot, widget: widgetId}, widgetId, []); + } + function exposeProperty(prefab, name, path) { + if (!widget) return; + structuralEdit({kind: "ExposeProperty", prefab: prefab, name: name, widget: widget.element.id, path: path}, widget.element.id, []); + } + function exposeSlot(prefab, name) { + if (!widget) return; + structuralEdit({kind: "ExposeSlot", prefab: prefab, name: name, widget: widget.element.id}, widget.element.id, []); + } function resetInstance() { if (widget && widget.instance_of) structuralEdit({kind: "ResetInstance", id: widget.element.id}, widget.element.id, []); } @@ -1139,6 +1173,37 @@ Item { Button { objectName: "interfaceDetachInstance"; text: "Detach"; enabled: !!(root.widget && root.widget.instance_of) && !root.gesture; onClicked: root.detachInstance() } Button { objectName: "interfaceDeleteComponent"; text: "Delete"; enabled: componentPick.currentIndex >= 0 && !root.gesture; onClicked: root.structuralEdit({kind: "DeletePrefab", name: componentPick.currentText}, root.selectedId, []) } } + Repeater { + model: root.exposedRows() + delegate: RowLayout { + required property var modelData + Layout.fillWidth: true + Label { text: modelData.name; color: Theme.textDim } + TextField { + objectName: "interfaceExposed_" + modelData.name + Layout.fillWidth: true; visible: !modelData.slot + text: typeof modelData.value === "string" ? modelData.value : JSON.stringify(modelData.value) + onEditingFinished: if (text !== (typeof modelData.value === "string" ? modelData.value : JSON.stringify(modelData.value))) root.setExposed(modelData.name, text) + } + Button { + objectName: "interfaceFillSlot_" + modelData.name + visible: modelData.slot; text: "Place selection here" + enabled: !root.gesture && root.extraIds.length > 0 + onClicked: root.extraIds.forEach(id => root.fillSlot(modelData.name, id)) + } + } + } + Label { text: "Expose from the selected widget"; font.bold: true; visible: componentPick.currentIndex >= 0 } + RowLayout { + visible: componentPick.currentIndex >= 0 + TextField { id: exposeName; objectName: "interfaceExposeName"; Layout.fillWidth: true; placeholderText: "Name" } + ComboBox { id: exposePath; objectName: "interfaceExposePath"; model: Object.keys(root.propertyKinds).filter(p => root.applies(p)) } + } + RowLayout { + visible: componentPick.currentIndex >= 0 + Button { objectName: "interfaceExposeProperty"; text: "Expose property"; enabled: !!root.widget && !!exposeName.text.trim() && !root.gesture; onClicked: root.exposeProperty(componentPick.currentText, exposeName.text.trim(), exposePath.currentText) } + Button { objectName: "interfaceExposeSlot"; text: "Expose as slot"; enabled: !!root.widget && !!exposeName.text.trim() && !root.gesture; onClicked: root.exposeSlot(componentPick.currentText, exposeName.text.trim()) } + } Label { text: "Variable binding"; font.bold: true } ComboBox { id: bindingProperty; Layout.fillWidth: true; model: ["Text","Value","Visible","SelectedIndex"] } TextField { id: bindingActor; Layout.fillWidth: true; placeholderText: "Actor id (blank for global)" } diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index da7432c8..c881345b 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -463,6 +463,17 @@ TestCase { panel.removeBinding(0); verify(!calls.some(c=>c.command === "set_interface")); } + function test_exposed_properties_and_slots_read_and_write_through_typed_edits() { + const d = {widgets:[{element:{id:"a.card",content:"Card"}, instance_of:"card"}, {element:{id:"a.title",parent:"a.card",content:"Hello"}}], + prefabs:{card:[{element:{id:"card"}}, {element:{id:"title",parent:"card"}}]}, + exports:{card:[{name:"title",widget:"title",path:"element.content",slot:false},{name:"body",widget:"card",path:"",slot:true}]}}; + panel.document = d; backend.appState.project.world.interface = d; panel.selectedId = "a.card"; + compare(panel.exposedRows().map(r => r.name + ":" + r.slot + ":" + r.value), ["title:false:Hello", "body:true:"]); + panel.setExposed("title", "Bye"); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit, {kind:"SetExposed", instance:"a.card", name:"title", value:"Bye"}); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_guides_are_editor_only_and_snap_targets_follow_the_frame() { compare(panel.guides.length, 0); const a = panel.addGuide(0, 100); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 4cd6461d..2ee5127d 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -303,4 +303,4 @@ Verification for keyboard nudging and sibling order: the final full workspace bu - **Components (phase 5, first slice).** `UiEdit::{SavePrefab, InstantiatePrefab, DeletePrefab, DetachInstance}` build on the existing `prefabs` map. An instance root carries `instance_of`; `SavePrefab { update_instances: true }` rebuilds every instance from the new source and keeps any widget the instance changed from the old version (override at widget granularity). A dangling `instance_of` fails validation; deleting a component detaches its instances. The inspector has a Component section (save, insert with a unique `iN.` prefix, detach, delete). - **Image fit (phase 4).** `UiPaint.image_fit` is `Stretch`, `Sliced { border: [l, r, t, b] }` or `Tiled`; unset keeps the image's own size. The runtime maps it to Bevy's `NodeImageMode`; the inspector has an Image fit dropdown with four border fields. -Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Events and caret: a widget's `actions` (property path `actions`) run on Click or Change without blocks: Broadcast a message, or SetVariable (`$value` is the new value on Change), applied in `Engine::fire` so VM and compiled logic see the same events; the inspector lists and removes bindings and events and adds new events. A focused text input now has a caret (`TextCaret` in core): arrows, Home, End, Delete, Shift-select, Ctrl/Cmd+A, typing over a selection, and IME composition shown at the caret and committed through the input's allow/max-length rules; the window asks for IME only while an input holds the keyboard. The selection is drawn as `[...]` and the caret as `|` in the text; clicking to place the caret and a drawn selection remain open. Still open: exposed properties and named slots, animation timeline, responsive variants, platform checks. +Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Events and caret: a widget's `actions` (property path `actions`) run on Click or Change without blocks: Broadcast a message, or SetVariable (`$value` is the new value on Change), applied in `Engine::fire` so VM and compiled logic see the same events; the inspector lists and removes bindings and events and adds new events. A focused text input now has a caret (`TextCaret` in core): arrows, Home, End, Delete, Shift-select, Ctrl/Cmd+A, typing over a selection, and IME composition shown at the caret and committed through the input's allow/max-length rules; the window asks for IME only while an input holds the keyboard. The selection is drawn as `[...]` and the caret as `|` in the text; clicking to place the caret and a drawn selection remain open. Components expose named properties and slots (`UiDocument::exports`): `ExposeProperty`/`ExposeSlot` name a prefab widget's property path or container, `SetExposed` writes the property on an instance and `FillSlot` moves a widget into the instance's slot, both through the ordinary typed edits; the inspector lists the exposed rows of a selected instance. Still open: animation timeline, responsive variants, platform checks. From fd055ee8ccd843f1170dd9e8dfdd92f78cb7c461 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:45:13 +0000 Subject: [PATCH 18/26] Fix a clippy lint in the export check Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-core/src/ui_framework.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 70e95e2e..58afaeee 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -805,7 +805,7 @@ impl UiDocument { widget, path, } => { - if !property_metadata().iter().any(|m| &m.path == path) { + if !property_metadata().iter().any(|m| m.path == path.as_str()) { return Err(format!("Unknown property path: {path}")); } return self.expose(prefab, name, widget, path, false); From 99e695481fb1bd55ff763b41b029361b6f427951 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:45:50 +0000 Subject: [PATCH 19/26] Interface editor: zoom and pan in the viewport Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-qt/qml/UiDesigner.qml | 46 +++++++++++++++++-- .../tests/qml/tst_InterfaceViewport.qml | 14 ++++++ docs/interface-editor-plan.md | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index bac91c60..71aec14a 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -37,8 +37,8 @@ Item { function moveGuide(index, pos) { guides = guides.map((g, i) => i === index ? {axis: g.axis, pos: pos} : g); } function removeGuide(index) { guides = guides.filter((g, i) => i !== index); } // Axis 0 is x, 1 is y; stage pixels are the viewport, canvas pixels the preview. - function stageToCanvas(axis, s) { return (axis === 0 ? previewWidth : previewHeight) / 2 + (s - (axis === 0 ? stage.width : stage.height) / 2) / Math.max(0.05, zoom); } - function canvasToStage(axis, c) { return (axis === 0 ? stage.width : stage.height) / 2 + (c - (axis === 0 ? previewWidth : previewHeight) / 2) * Math.max(0.05, zoom); } + function stageToCanvas(axis, s) { return (axis === 0 ? previewWidth : previewHeight) / 2 + (s - (axis === 0 ? stage.width : stage.height) / 2 - (axis === 0 ? panX : panY)) / Math.max(0.05, zoom); } + function canvasToStage(axis, c) { return (axis === 0 ? stage.width : stage.height) / 2 + (axis === 0 ? panX : panY) + (c - (axis === 0 ? previewWidth : previewHeight) / 2) * Math.max(0.05, zoom); } function guideTargets(frame, axis) { // Guides only snap in axis-aligned frames, in the frame's own units. if (Math.abs(frame[1]) > 1e-6 || Math.abs(frame[2]) > 1e-6) return []; @@ -474,7 +474,23 @@ Item { property int previewHeight: 720 readonly property var safe: document.safe_area || [0,0,0,0] readonly property real designScale: document.scale === "ScaleWithSize" ? Math.max(0.01, Math.min((previewWidth-safe[0]-safe[2])/(document.reference_size || [960,720])[0], (previewHeight-safe[1]-safe[3])/(document.reference_size || [960,720])[1])) : 1 - property real zoom: Math.min((stage.width - 32) / previewWidth, (stage.height - 32) / previewHeight) + readonly property real fitZoom: Math.min((stage.width - 32) / previewWidth, (stage.height - 32) / previewHeight) + // Zoom follows the fit until the user sets one; panning is the canvas offset from centre. + property real zoom: fitZoom + property real panX: 0 + property real panY: 0 + function fitView() { zoom = Qt.binding(() => fitZoom); panX = 0; panY = 0; } + function actualSize() { zoom = 1; panX = 0; panY = 0; } + // Zooms by `factor` keeping the preview point under the stage position (sx, sy) where it is. + function zoomAt(sx, sy, factor) { + const old = Math.max(0.05, zoom); + const next = Math.max(0.1, Math.min(8, old * factor)); + const cx = previewWidth / 2 + (sx - stage.width / 2 - panX) / old; + const cy = previewHeight / 2 + (sy - stage.height / 2 - panY) / old; + zoom = next; + panX = sx - stage.width / 2 - (cx - previewWidth / 2) * next; + panY = sy - stage.height / 2 - (cy - previewHeight / 2) * next; + } function copy(v) { return JSON.parse(JSON.stringify(v)); } function refresh() { if (gesture) return; @@ -841,6 +857,9 @@ Item { Label { text: "px" } CheckBox { objectName: "interfaceSafeArea"; text: "Safe area"; checked: root.showSafeArea; onToggled: root.showSafeArea = checked } CheckBox { objectName: "interfaceReference"; text: "Reference size"; checked: root.showReference; onToggled: root.showReference = checked } + Button { objectName: "interfaceFit"; text: "Fit"; onClicked: root.fitView() } + Button { objectName: "interfaceActualSize"; text: "100%"; onClicked: root.actualSize() } + Label { objectName: "interfaceZoomLabel"; text: Math.round(root.zoom * 100) + "%" } CheckBox { objectName: "interfaceRulers"; text: "Rulers"; checked: root.showRulers; onToggled: root.showRulers = checked } Button { objectName: "interfaceClearGuides"; text: "Clear guides"; enabled: root.guides.length > 0; onClicked: root.guides = [] } CheckBox { text: "Align edges/centers"; checked: root.snapAlign; onToggled: root.snapAlign = checked } @@ -848,6 +867,26 @@ Item { } Rectangle { id: stage; Layout.fillWidth: true; Layout.fillHeight: true; color: "#171a21"; clip: true + // Ctrl+wheel zooms at the pointer; the wheel pans (Shift for sideways); middle-drag pans. + WheelHandler { + objectName: "interfaceWheel" + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + const dy = event.angleDelta.y, dx = event.angleDelta.x; + if (event.modifiers & Qt.ControlModifier) root.zoomAt(point.position.x, point.position.y, Math.pow(1.0015, dy)); + else if (event.modifiers & Qt.ShiftModifier) root.panX += (dy || dx) / 2; + else { root.panX += dx / 2; root.panY += dy / 2; } + } + } + DragHandler { + objectName: "interfacePan" + acceptedButtons: Qt.MiddleButton + target: null + property real lastX: 0 + property real lastY: 0 + onActiveChanged: { lastX = 0; lastY = 0; } + onTranslationChanged: { root.panX += translation.x - lastX; root.panY += translation.y - lastY; lastX = translation.x; lastY = translation.y; } + } // Rulers: drag out of one for a guide, and drag a guide back onto it to remove it. Repeater { model: root.showRulers ? [0, 1] : [] @@ -904,6 +943,7 @@ Item { } Rectangle { id: canvas; objectName: "interfaceCanvas"; anchors.centerIn: parent + anchors.horizontalCenterOffset: root.panX; anchors.verticalCenterOffset: root.panY width: root.previewWidth; height: root.previewHeight scale: Math.max(0.05,root.zoom); color: root.document.theme === "Light" ? "#d9dfe8" : "#252d3a" Loader { diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index c881345b..b8092bf6 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -474,6 +474,20 @@ TestCase { compare(edit, {kind:"SetExposed", instance:"a.card", name:"title", value:"Bye"}); verify(!calls.some(c=>c.command === "set_interface")); } + function test_zoom_keeps_the_point_under_the_pointer_and_fit_restores_the_view() { + panel.fitView(); + const fit = panel.zoom; + const before = panel.stageToCanvas(0, 200); + panel.zoomAt(200, 150, 2); + fuzzyCompare(panel.zoom, Math.min(8, fit * 2), 0.0001); + fuzzyCompare(panel.stageToCanvas(0, 200), before, 0.001); + panel.actualSize(); + compare(panel.zoom, 1); + compare(panel.panX, 0); + panel.fitView(); + fuzzyCompare(panel.zoom, fit, 0.0001); + verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); + } function test_guides_are_editor_only_and_snap_targets_follow_the_frame() { compare(panel.guides.length, 0); const a = panel.addGuide(0, 100); diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 2ee5127d..9c1823c8 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -264,7 +264,7 @@ Master since the seventh increment (Phase 6 2D work, script guests, bundled rust ### Viewport overlays (fifteenth increment) -- Safe area and Reference size toggles draw dashed frames over the viewport from the document's `safe_area` and `reference_size` (scaled like the runtime does), as editor-only overlays. Two phone/desktop presets (1170 x 2532, 2560 x 1440) join the preview sizes. Rulers and guides: the Rulers toggle shows ticks along the top and left of the viewport; dragging out of one pulls a guide (editor-only, never saved), drawing it over the preview and adding it as a snap target for moves and resizes in axis-aligned frames; dropping a guide back on a ruler removes it, and Clear guides drops all. Zoom/pan polish remains open. +- Safe area and Reference size toggles draw dashed frames over the viewport from the document's `safe_area` and `reference_size` (scaled like the runtime does), as editor-only overlays. Two phone/desktop presets (1170 x 2532, 2560 x 1440) join the preview sizes. Rulers and guides: the Rulers toggle shows ticks along the top and left of the viewport; dragging out of one pulls a guide (editor-only, never saved), drawing it over the preview and adding it as a snap target for moves and resizes in axis-aligned frames; dropping a guide back on a ruler removes it, and Clear guides drops all. Zoom and pan: Ctrl+wheel zooms at the pointer (10% to 800%), the wheel pans (Shift for sideways), middle-drag pans, and Fit and 100% reset the view; rulers follow. The view is editor-only and resets with Fit. Remaining plan work, in order: draw the inspector from `interface-properties`, image pickers and nine-slice/fit modes, text-input caret/selection/IME, rulers/guides/safe-area overlay and zoom/pan polish (phase 2), reusable components with instances and overrides (phase 5), visual binding/event editors, animation timeline and responsive variants (phase 6), and the platform/DPI checks listed under phase 0. These are each a large increment of their own. From d5b0b58445c2db0518a647b75cb1a39ac55ad8db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 23:52:14 +0000 Subject: [PATCH 20/26] Interface editor: responsive variants Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-app/src/shell.rs | 2 +- blockloom-core/src/ui_framework.rs | 125 ++++++++++++++++++ blockloom-qt/qml/UiDesigner.qml | 57 +++++++- .../tests/qml/tst_InterfaceViewport.qml | 10 ++ blockloom-runtime/src/ui.rs | 69 ++++++++++ blockloom-runtime/src/ui_systems.rs | 1 + docs/interface-editor-plan.md | 2 +- 7 files changed, 263 insertions(+), 3 deletions(-) diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 45b21c87..7e608f6a 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}, ExposeProperty {prefab, name, widget, path}, ExposeSlot {prefab, name, widget}, Unexpose {prefab, name}, SetExposed {instance, name, value}, FillSlot {instance, slot, widget}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}}}]; $value is the new value on Change), items, tooltip, scroll_target, tab_index, transition. Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}, ExposeProperty {prefab, name, widget, path}, ExposeSlot {prefab, name, widget}, Unexpose {prefab, name}, SetExposed {instance, name, value}, FillSlot {instance, slot, widget}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}}}]; $value is the new value on Change), variants ([{when: {min_width?, max_width?, min_height?, max_height?}, layout?, visible?}]; the first match for the viewport wins), items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 58afaeee..865f8ba7 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -230,6 +230,63 @@ pub struct UiBinding { pub two_way: bool, } +/// A viewport size range; every bound is optional and inclusive, in canvas +/// pixels (after the safe area and scale are taken off). +#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct UiBreakpoint { + pub min_width: Option, + pub max_width: Option, + pub min_height: Option, + pub max_height: Option, +} + +impl UiBreakpoint { + pub fn matches(&self, width: f32, height: f32) -> bool { + self.min_width.is_none_or(|v| width >= v) + && self.max_width.is_none_or(|v| width <= v) + && self.min_height.is_none_or(|v| height >= v) + && self.max_height.is_none_or(|v| height <= v) + } + + pub fn validate(&self) -> Result<(), String> { + let bounds = [ + self.min_width, + self.max_width, + self.min_height, + self.max_height, + ]; + if bounds.iter().flatten().any(|v| !v.is_finite() || *v < 0.) { + return Err("Breakpoint sizes must be finite and not negative".into()); + } + let ordered = |lo: Option, hi: Option| lo.zip(hi).is_none_or(|(lo, hi)| lo <= hi); + if !ordered(self.min_width, self.max_width) || !ordered(self.min_height, self.max_height) { + return Err("A breakpoint's minimum cannot exceed its maximum".into()); + } + Ok(()) + } +} + +/// How a widget differs while the viewport is inside `when`. The first +/// matching variant wins; what it leaves unset follows the base widget. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiVariant { + pub when: UiBreakpoint, + #[serde(default)] + pub layout: Option, + #[serde(default)] + pub visible: Option, +} + +impl UiWidget { + /// Index of the first variant that fits a viewport of this size. + pub fn variant_for(&self, width: f32, height: f32) -> Option { + self.variants + .iter() + .position(|v| v.when.matches(width, height)) + } +} + /// What a widget reports to the game. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum UiEventKind { @@ -269,6 +326,9 @@ pub struct UiWidget { /// Event handlers authored on the widget, run by the player without blocks. #[serde(skip_serializing_if = "Vec::is_empty")] pub actions: Vec, + /// Layout and visibility changes for other viewport sizes. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub variants: Vec, pub items: Vec, pub tooltip: String, pub world_actor: String, @@ -365,6 +425,8 @@ pub enum UiPropertyEdit { Bindings(Vec), #[serde(rename = "actions")] Actions(Vec), + #[serde(rename = "variants")] + Variants(Vec), #[serde(rename = "items")] Items(Vec), #[serde(rename = "tooltip")] @@ -404,6 +466,7 @@ impl UiPropertyEdit { UiPropertyEdit::Class(_) => "class", UiPropertyEdit::Bindings(_) => "bindings", UiPropertyEdit::Actions(_) => "actions", + UiPropertyEdit::Variants(_) => "variants", UiPropertyEdit::Items(_) => "items", UiPropertyEdit::Tooltip(_) => "tooltip", UiPropertyEdit::ScrollTarget(_) => "scroll_target", @@ -522,6 +585,14 @@ pub fn property_metadata() -> Vec { vec![], ), info("actions", "Events", "Bindings", "actions", vec![], vec![]), + info( + "variants", + "Responsive variants", + "Layout", + "variants", + vec![], + vec![], + ), info( "items", "Items", @@ -980,6 +1051,10 @@ impl UiDocument { } UiPropertyEdit::Class(value) => w.class = value.clone(), UiPropertyEdit::Bindings(value) => w.bindings = value.clone(), + UiPropertyEdit::Variants(value) => { + check_variants(value)?; + w.variants = value.clone(); + } UiPropertyEdit::Actions(value) => { check_actions(value)?; w.actions = value.clone(); @@ -1527,6 +1602,7 @@ impl UiDocument { return Err(format!("Unsupported binding property on {id}")); } check_actions(&widget.actions).map_err(|e| format!("{id}: {e}"))?; + check_variants(&widget.variants).map_err(|e| format!("{id}: {e}"))?; if let Some(layout) = &widget.layout && (layout.columns == 0 || !layout.row_height.is_finite() @@ -1605,6 +1681,18 @@ impl UiDocument { } } +fn check_variants(variants: &[UiVariant]) -> Result<(), String> { + for variant in variants { + variant.when.validate()?; + if let Some(layout) = &variant.layout + && (layout.columns == 0 || !layout.row_height.is_finite() || layout.row_height <= 0.) + { + return Err("A variant's layout is invalid".into()); + } + } + Ok(()) +} + fn check_actions(actions: &[UiAction]) -> Result<(), String> { for action in actions { match &action.run { @@ -1854,6 +1942,7 @@ mod tests { UiPropertyEdit::Class(String::new()), UiPropertyEdit::Bindings(vec![]), UiPropertyEdit::Actions(vec![]), + UiPropertyEdit::Variants(vec![]), UiPropertyEdit::Items(vec![]), UiPropertyEdit::Tooltip(String::new()), UiPropertyEdit::ScrollTarget(String::new()), @@ -2322,6 +2411,42 @@ mod tests { assert!(doc.validate().is_ok()); } + #[test] + fn variants_pick_the_first_matching_breakpoint_and_validate() { + let mut doc = UiDocument::default(); + doc.widgets = vec![row("menu", "", "Menu")]; + let value = serde_json::json!([ + {"when": {"max_width": 600.0}, "layout": {"width": {"Percent": 100.0}}, "visible": true}, + {"when": {"min_width": 601.0, "max_width": 1200.0}, "visible": false} + ]); + let edit = |value: serde_json::Value| UiEdit::SetProperty { + id: "menu".into(), + property: serde_json::from_value( + serde_json::json!({"path": "variants", "value": value}), + ) + .unwrap(), + }; + doc.apply_edit(&edit(value)).unwrap(); + let w = &doc.widgets[0]; + assert_eq!(w.variant_for(400., 800.), Some(0)); + assert_eq!(w.variant_for(900., 800.), Some(1)); + assert_eq!(w.variant_for(1920., 1080.), None); + let back: UiDocument = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + assert_eq!(back.widgets[0].variants, w.variants); + let before = doc.clone(); + assert!( + doc.apply_edit(&edit( + serde_json::json!([{"when": {"min_width": 900.0, "max_width": 300.0}}]) + )) + .is_err() + ); + assert!( + doc.apply_edit(&edit(serde_json::json!([{"when": {"min_height": -1.0}}]))) + .is_err() + ); + assert_eq!(doc, before); + } + #[test] fn widget_actions_edit_validate_and_round_trip() { let mut doc = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 71aec14a..1ad6552c 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -522,7 +522,7 @@ Item { if (!widget) return; propertyEdit("element." + field, value); } - readonly property var typedFields: ["layout","style","class","bindings","actions","items","tooltip","scroll_target","tab_index","transition"] + readonly property var typedFields: ["layout","style","class","bindings","actions","variants","items","tooltip","scroll_target","tab_index","transition"] function extra(field, value) { if (!widget) return; if (typedFields.indexOf(field) >= 0) { propertyEdit(field, value); return; } @@ -649,6 +649,30 @@ Item { if (!widget) return; propertyEdit("actions", (widget.actions || []).filter((a, i) => i !== index)); } + function describeVariant(v) { + const w = v.when || {}; + const parts = []; + if (w.min_width != null) parts.push("w >= " + w.min_width); + if (w.max_width != null) parts.push("w <= " + w.max_width); + if (w.min_height != null) parts.push("h >= " + w.min_height); + if (w.max_height != null) parts.push("h <= " + w.max_height); + const does = []; + if (v.layout) does.push("own layout"); + if (v.visible === true) does.push("shown"); + if (v.visible === false) does.push("hidden"); + return (parts.join(", ") || "any size") + ": " + (does.join(", ") || "no change"); + } + function addVariant(when, useLayout, visible) { + if (!widget) return; + const v = {when: when}; + if (useLayout && widget.layout) v.layout = copy(widget.layout); + if (visible === true || visible === false) v.visible = visible; + propertyEdit("variants", (widget.variants || []).concat([v])); + } + function removeVariant(index) { + if (!widget) return; + propertyEdit("variants", (widget.variants || []).filter((v, i) => i !== index)); + } function removeBinding(index) { if (!widget) return; propertyEdit("bindings", (widget.bindings || []).filter((b, i) => i !== index)); @@ -1292,6 +1316,37 @@ Item { : {SetVariable: {actor: actionActor.text.trim(), name: actionVariable.text.trim(), value: actionValue.text}}}); } } + Label { text: "Responsive variants"; font.bold: true } + Repeater { + model: root.widget ? root.widget.variants || [] : [] + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true + Label { Layout.fillWidth: true; elide: Text.ElideRight; text: root.describeVariant(modelData); color: Theme.textDim } + Button { objectName: "interfaceRemoveVariant" + index; text: "Remove"; enabled: !root.gesture; onClicked: root.removeVariant(index) } + } + } + GridLayout { + columns: 4 + TextField { id: varMinW; objectName: "interfaceVariantMinWidth"; Layout.fillWidth: true; placeholderText: "min w"; validator: DoubleValidator { bottom: 0 } } + TextField { id: varMaxW; objectName: "interfaceVariantMaxWidth"; Layout.fillWidth: true; placeholderText: "max w"; validator: DoubleValidator { bottom: 0 } } + TextField { id: varMinH; objectName: "interfaceVariantMinHeight"; Layout.fillWidth: true; placeholderText: "min h"; validator: DoubleValidator { bottom: 0 } } + TextField { id: varMaxH; objectName: "interfaceVariantMaxHeight"; Layout.fillWidth: true; placeholderText: "max h"; validator: DoubleValidator { bottom: 0 } } + } + RowLayout { + ComboBox { id: varVisible; objectName: "interfaceVariantVisible"; model: ["Visibility unchanged","Shown","Hidden"] } + CheckBox { id: varLayout; objectName: "interfaceVariantLayout"; text: "Use current layout"; enabled: !!(root.widget && root.widget.layout) } + } + Button { + objectName: "interfaceAddVariant"; text: "Add variant" + enabled: !!root.widget && !root.gesture + onClicked: { + const num = f => f.text.trim() === "" ? null : Number(f.text); + const when = {min_width: num(varMinW), max_width: num(varMaxW), min_height: num(varMinH), max_height: num(varMaxH)}; + root.addVariant(when, varLayout.checked, [undefined, true, false][varVisible.currentIndex]); + } + } Label { text: "Advanced properties"; font.bold: true } Repeater { model: ["layout","style","bindings"] diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index b8092bf6..838b6e5a 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -488,6 +488,16 @@ TestCase { fuzzyCompare(panel.zoom, fit, 0.0001); verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); } + function test_variants_edit_through_typed_properties() { + panel.selectedId = "front"; + panel.addVariant({min_width: null, max_width: 600, min_height: null, max_height: null}, false, false); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.property.path, "variants"); + compare(edit.property.value[0].visible, false); + compare(panel.describeVariant({when:{max_width:600}, visible:false}), "w <= 600: hidden"); + compare(panel.describeVariant({when:{}, layout:{}}), "any size: own layout"); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_guides_are_editor_only_and_snap_targets_follow_the_frame() { compare(panel.guides.length, 0); const a = panel.addGuide(0, 100); diff --git a/blockloom-runtime/src/ui.rs b/blockloom-runtime/src/ui.rs index a7f05539..ae4bf14e 100644 --- a/blockloom-runtime/src/ui.rs +++ b/blockloom-runtime/src/ui.rs @@ -122,6 +122,8 @@ pub struct UiNode { pub caret: TextCaret, /// An IME composition not yet committed, drawn at the caret. pub preedit: String, + /// The responsive variant of its widget in force, by index. + pub variant: Option, } impl UiNode { @@ -180,6 +182,37 @@ impl UiManager { self.show_widget(widget.clone()); } } + /// Puts each authored widget's responsive variant for a viewport this + /// size in force, redrawing only those whose variant changed. Visibility + /// is written only by a variant that sets it, and given back when one + /// that did stops applying. + pub fn apply_variants(&mut self, width: f32, height: f32) { + for widget in &self.document.widgets { + if widget.variants.is_empty() { + continue; + } + let Some(node) = self.nodes.get_mut(&widget.element.id) else { + continue; + }; + let pick = widget.variant_for(width, height); + if node.variant == pick { + continue; + } + let (old, new) = ( + node.variant.and_then(|i| widget.variants[i].visible), + pick.and_then(|i| widget.variants[i].visible), + ); + node.layout = pick + .and_then(|i| widget.variants[i].layout.clone()) + .or_else(|| widget.layout.clone()); + if old.is_some() || new.is_some() { + node.visible = new.unwrap_or(true); + } + node.variant = pick; + node.dirty = true; + } + } + pub fn show_widget(&mut self, mut widget: UiWidget) { widget.element.value = UiElement::initial_value(widget.element.kind, &widget.element.value); let id = widget.element.id.clone(); @@ -372,6 +405,7 @@ impl UiManager { expanded: false, caret: TextCaret::default(), preedit: String::new(), + variant: None, }, ); self.pending.push(id); @@ -1521,4 +1555,39 @@ mod tests { manager.changed("name", Evaluated::Text("Ada".to_string())); assert_eq!(text_of(manager.get("name").unwrap()), "Ada"); } + #[test] + fn a_viewport_picks_a_widgets_variant_and_gives_it_back() { + let document: UiDocument = serde_json::from_value(serde_json::json!({"widgets": [{ + "element": {"id": "menu", "kind": "Panel"}, + "layout": {"width": {"Px": 300.0}}, + "variants": [ + {"when": {"max_width": 600.0}, "layout": {"width": {"Percent": 100.0}}}, + {"when": {"min_width": 1200.0}, "visible": false} + ] + }]})) + .unwrap(); + let mut manager = UiManager::default(); + manager.load(&document); + let width = |m: &UiManager| m.get("menu").unwrap().layout.as_ref().map(|l| l.width); + manager.apply_variants(400., 800.); + assert_eq!( + width(&manager), + Some(blockloom_core::ui::UiLength::Percent(100.)) + ); + manager.apply_variants(900., 800.); + assert_eq!( + width(&manager), + Some(blockloom_core::ui::UiLength::Px(300.)) + ); + assert!(manager.get("menu").unwrap().visible); + manager.apply_variants(1600., 800.); + assert!(!manager.get("menu").unwrap().visible); + // Leaving the variant that hid it brings it back. + manager.apply_variants(900., 800.); + assert!(manager.get("menu").unwrap().visible); + // Nothing changes while the same variant stays in force. + manager.get_mut("menu").unwrap().dirty = false; + manager.apply_variants(950., 800.); + assert!(!manager.get("menu").unwrap().dirty); + } } diff --git a/blockloom-runtime/src/ui_systems.rs b/blockloom-runtime/src/ui_systems.rs index 5a1df3ca..2c8dcc64 100644 --- a/blockloom-runtime/src/ui_systems.rs +++ b/blockloom-runtime/src/ui_systems.rs @@ -529,6 +529,7 @@ pub fn canvas( manager.canvas_scale = scale * ui_scale.0.max(0.01); manager.canvas_origin = Vec2::new(left, top) * ui_scale.0.max(0.01); let logical = available / scale; + manager.apply_variants(logical.x, logical.y); for (mut node, mut transform) in &mut roots { let wanted = Node { position_type: PositionType::Absolute, diff --git a/docs/interface-editor-plan.md b/docs/interface-editor-plan.md index 9c1823c8..87409b85 100644 --- a/docs/interface-editor-plan.md +++ b/docs/interface-editor-plan.md @@ -303,4 +303,4 @@ Verification for keyboard nudging and sibling order: the final full workspace bu - **Components (phase 5, first slice).** `UiEdit::{SavePrefab, InstantiatePrefab, DeletePrefab, DetachInstance}` build on the existing `prefabs` map. An instance root carries `instance_of`; `SavePrefab { update_instances: true }` rebuilds every instance from the new source and keeps any widget the instance changed from the old version (override at widget granularity). A dangling `instance_of` fails validation; deleting a component detaches its instances. The inspector has a Component section (save, insert with a unique `iN.` prefix, detach, delete). - **Image fit (phase 4).** `UiPaint.image_fit` is `Stretch`, `Sliced { border: [l, r, t, b] }` or `Tiled`; unset keeps the image's own size. The runtime maps it to Bevy's `NodeImageMode`; the inspector has an Image fit dropdown with four border fields. -Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Events and caret: a widget's `actions` (property path `actions`) run on Click or Change without blocks: Broadcast a message, or SetVariable (`$value` is the new value on Change), applied in `Engine::fire` so VM and compiled logic see the same events; the inspector lists and removes bindings and events and adds new events. A focused text input now has a caret (`TextCaret` in core): arrows, Home, End, Delete, Shift-select, Ctrl/Cmd+A, typing over a selection, and IME composition shown at the caret and committed through the input's allow/max-length rules; the window asks for IME only while an input holds the keyboard. The selection is drawn as `[...]` and the caret as `|` in the text; clicking to place the caret and a drawn selection remain open. Components expose named properties and slots (`UiDocument::exports`): `ExposeProperty`/`ExposeSlot` name a prefab widget's property path or container, `SetExposed` writes the property on an instance and `FillSlot` moves a widget into the instance's slot, both through the ordinary typed edits; the inspector lists the exposed rows of a selected instance. Still open: animation timeline, responsive variants, platform checks. +Instance overrides now merge per field (a prefab update keeps only the fields an instance changed; `UiDocument::instance_overrides` lists them) and `ResetInstance` rebuilds an instance from its prefab. Events and caret: a widget's `actions` (property path `actions`) run on Click or Change without blocks: Broadcast a message, or SetVariable (`$value` is the new value on Change), applied in `Engine::fire` so VM and compiled logic see the same events; the inspector lists and removes bindings and events and adds new events. A focused text input now has a caret (`TextCaret` in core): arrows, Home, End, Delete, Shift-select, Ctrl/Cmd+A, typing over a selection, and IME composition shown at the caret and committed through the input's allow/max-length rules; the window asks for IME only while an input holds the keyboard. The selection is drawn as `[...]` and the caret as `|` in the text; clicking to place the caret and a drawn selection remain open. Components expose named properties and slots (`UiDocument::exports`): `ExposeProperty`/`ExposeSlot` name a prefab widget's property path or container, `SetExposed` writes the property on an instance and `FillSlot` moves a widget into the instance's slot, both through the ordinary typed edits; the inspector lists the exposed rows of a selected instance. Responsive variants: a widget's `variants` (property path `variants`) each give a viewport size range (`when`, in canvas pixels after safe area and scale) with an optional own layout and visibility; the first match wins, `UiManager::apply_variants` puts it in force when the canvas size changes, and leaving a variant that set visibility shows the widget again. The inspector lists, adds and removes them. Still open: animation timeline, responsive variants, platform checks. From 7db417c0dfb66ecbcd8d7192fc08c56fe7e29500 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 00:00:25 +0000 Subject: [PATCH 21/26] Interface editor: animations with a timeline editor Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013K2j7WKJ3rVme2h7LSfvc6 --- blockloom-app/src/shell.rs | 2 +- blockloom-core/src/ui_framework.rs | 276 ++++++++++++++++++ blockloom-qt/qml/UiDesigner.qml | 129 +++++++- .../tests/qml/tst_InterfaceViewport.qml | 21 ++ blockloom-runtime/src/engine.rs | 5 + blockloom-runtime/src/lib.rs | 1 + blockloom-runtime/src/ui.rs | 86 ++++++ blockloom-runtime/src/ui_systems.rs | 19 ++ docs/interface-editor-plan.md | 2 +- 9 files changed, 533 insertions(+), 8 deletions(-) diff --git a/blockloom-app/src/shell.rs b/blockloom-app/src/shell.rs index 7e608f6a..488ce5fc 100644 --- a/blockloom-app/src/shell.rs +++ b/blockloom-app/src/shell.rs @@ -969,7 +969,7 @@ pub(crate) const COMMANDS: &[CommandSpec] = &[ name: "update-interface-edit", cmd: "update_interface_edit", aliases: &["update_interface_edit"], - summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}, ExposeProperty {prefab, name, widget, path}, ExposeSlot {prefab, name, widget}, Unexpose {prefab, name}, SetExposed {instance, name, value}, FillSlot {instance, slot, widget}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}}}]; $value is the new value on Change), variants ([{when: {min_width?, max_width?, min_height?, max_height?}, layout?, visible?}]; the first match for the viewport wins), items, tooltip, scroll_target, tab_index, transition. Does not save.", + summary: "Replace the draft with Move, Resize, SetProperty {id, property: {path, value}}, Reorder {id, index: zero-based sibling position}, Batch {edits: [..]} (applied in order as one, all or nothing), Create {widget: full widget; a Canvas parent makes it free, other parents flow}, Delete {id} (removes descendants), Duplicate {id, new_id, offset?} (copies the subtree as new_id and new_id.) or Reparent {id, parent, placement: {mode: Free, offset, size} or {mode: Flow}}. SetDocument {theme?, scale?}, SetClass {name, styles|null}, SavePrefab {name, root, update_instances?} (store the subtree as a component; true rebuilds its instances and keeps widgets they changed), InstantiatePrefab {name, prefix, parent?}, DeletePrefab {name}, DetachInstance {id}, ResetInstance {id: instance root; drops its changes}, SetAnimation {name, animation: {duration, looping?, tracks: [{widget, property: Width|Height|TextSize|CornerRadius|Padding|Value|Background|TextColor, keys: [{time, value: number or #rrggbb[aa], ease?: Linear|EaseIn|EaseOut|EaseInOut}]}]} or null to delete}, ExposeProperty {prefab, name, widget, path}, ExposeSlot {prefab, name, widget}, Unexpose {prefab, name}, SetExposed {instance, name, value}, FillSlot {instance, slot, widget}. Property paths (see interface-properties): element.kind/content/anchor/modal/range, layout (object or null), style, class, bindings, actions ([{on: Click|Change, run: {Broadcast: {message}} or {SetVariable: {actor?, name, value}} or {PlayAnimation|StopAnimation: {name}}}]; $value is the new value on Change), variants ([{when: {min_width?, max_width?, min_height?, max_height?}, layout?, visible?}]; the first match for the viewport wins), items, tooltip, scroll_target, tab_index, transition. Does not save.", args: &[ ArgSpec { name: "token", diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index 865f8ba7..a8e50201 100644 --- a/blockloom-core/src/ui_framework.rs +++ b/blockloom-core/src/ui_framework.rs @@ -287,6 +287,164 @@ impl UiWidget { } } +/// How a key eases into the next one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum UiEase { + #[default] + Linear, + EaseIn, + EaseOut, + EaseInOut, +} + +impl UiEase { + pub fn apply(self, t: f32) -> f32 { + let t = t.clamp(0., 1.); + match self { + UiEase::Linear => t, + UiEase::EaseIn => t * t, + UiEase::EaseOut => 1. - (1. - t) * (1. - t), + UiEase::EaseInOut => t * t * (3. - 2. * t), + } + } +} + +/// A keyed value: a number, or a `#rrggbb[aa]` color for the color tracks. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UiKeyValue { + Number(f64), + Color(String), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiKey { + pub time: f32, + pub value: UiKeyValue, + /// How it eases from this key to the next. + #[serde(default)] + pub ease: UiEase, +} + +/// One property of one widget over time. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiTrack { + pub widget: String, + pub property: UiProp, + pub keys: Vec, +} + +/// The properties a track can drive: the sizes and colors an element is +/// restyled by, and a value. +pub const ANIMATABLE_NUMBERS: [UiProp; 6] = [ + UiProp::Width, + UiProp::Height, + UiProp::TextSize, + UiProp::CornerRadius, + UiProp::Padding, + UiProp::Value, +]; +pub const ANIMATABLE_COLORS: [UiProp; 2] = [UiProp::Background, UiProp::TextColor]; + +fn parse_hex(text: &str) -> Option<[f32; 4]> { + let hex = text.trim().strip_prefix('#')?; + if !(hex.len() == 6 || hex.len() == 8) || !hex.is_ascii() { + return None; + } + let byte = |i: usize| { + u8::from_str_radix(&hex[i..i + 2], 16) + .ok() + .map(|b| b as f32) + }; + Some([ + byte(0)?, + byte(2)?, + byte(4)?, + if hex.len() == 8 { byte(6)? } else { 255. }, + ]) +} + +impl UiTrack { + /// The value at `time`, holding the first key before it and the last after. + pub fn sample(&self, time: f32) -> Option { + let first = self.keys.first()?; + let last = self.keys.last()?; + if time <= first.time { + return Some(first.value.clone()); + } + if time >= last.time { + return Some(last.value.clone()); + } + let at = self.keys.iter().rposition(|k| k.time <= time)?; + let (a, b) = (&self.keys[at], &self.keys[at + 1]); + let span = (b.time - a.time).max(f32::EPSILON); + let f = a.ease.apply((time - a.time) / span); + match (&a.value, &b.value) { + (UiKeyValue::Number(x), UiKeyValue::Number(y)) => { + Some(UiKeyValue::Number(x + (y - x) * f as f64)) + } + (UiKeyValue::Color(x), UiKeyValue::Color(y)) => { + let (x, y) = (parse_hex(x)?, parse_hex(y)?); + let c: Vec = (0..4) + .map(|i| (x[i] + (y[i] - x[i]) * f).round().clamp(0., 255.) as u8) + .collect(); + Some(UiKeyValue::Color(format!( + "#{:02x}{:02x}{:02x}{:02x}", + c[0], c[1], c[2], c[3] + ))) + } + _ => Some(a.value.clone()), + } + } + + fn validate(&self, duration: f32) -> Result<(), String> { + let color = ANIMATABLE_COLORS.contains(&self.property); + if !color && !ANIMATABLE_NUMBERS.contains(&self.property) { + return Err(format!("{:?} cannot be animated", self.property)); + } + if self.keys.is_empty() { + return Err(format!("A track on {} needs a key", self.widget)); + } + let mut last = -1.; + for key in &self.keys { + if !key.time.is_finite() || key.time < 0. || key.time > duration || key.time <= last { + return Err("Keys must be in strictly increasing time inside the animation".into()); + } + last = key.time; + match &key.value { + UiKeyValue::Number(n) if !color && n.is_finite() => {} + UiKeyValue::Color(c) if color && parse_hex(c).is_some() => {} + _ => { + return Err(format!( + "A key on {} holds the wrong kind of value", + self.widget + )); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UiAnimation { + pub duration: f32, + #[serde(default)] + pub looping: bool, + pub tracks: Vec, +} + +impl UiAnimation { + pub fn validate(&self) -> Result<(), String> { + if !self.duration.is_finite() || self.duration <= 0. || self.duration > 3600. { + return Err("An animation's duration must be above zero".into()); + } + self.tracks + .iter() + .try_for_each(|t| t.validate(self.duration)) + } +} + /// What a widget reports to the game. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum UiEventKind { @@ -301,6 +459,13 @@ pub enum UiDo { Broadcast { message: String, }, + /// Starts (or restarts) a document animation. + PlayAnimation { + name: String, + }, + StopAnimation { + name: String, + }, SetVariable { #[serde(default)] actor: String, @@ -363,6 +528,9 @@ pub struct UiDocument { /// What each prefab lets an instance set by name: properties and slots. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub exports: BTreeMap>, + /// Named property animations the document's actions (or blocks) can play. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub animations: BTreeMap, } /// A named handle on a prefab: one property of one widget inside it, or a @@ -398,6 +566,7 @@ impl Default for UiDocument { safe_area: [0.; 4], prefabs: BTreeMap::new(), exports: BTreeMap::new(), + animations: BTreeMap::new(), } } } @@ -756,6 +925,11 @@ pub enum UiEdit { ResetInstance { id: String, }, + /// Stores (or with `None`, removes) the animation `name`. + SetAnimation { + name: String, + animation: Option, + }, /// Lets instances of `prefab` set `path` of its widget `widget` as `name`. ExposeProperty { prefab: String, @@ -870,6 +1044,30 @@ impl UiDocument { return Ok(()); } UiEdit::ResetInstance { id } => return self.reset_instance(id), + UiEdit::SetAnimation { name, animation } => { + if name.trim().is_empty() { + return Err("An animation needs a name".into()); + } + match animation { + Some(animation) => { + animation.validate()?; + if let Some(missing) = animation + .tracks + .iter() + .find(|t| self.find(&t.widget).is_none()) + { + return Err(format!("Unknown widget: {}", missing.widget)); + } + self.animations.insert(name.clone(), animation.clone()); + } + None => { + if self.animations.remove(name).is_none() { + return Err(format!("No UI animation named {name}")); + } + } + } + return Ok(()); + } UiEdit::ExposeProperty { prefab, name, @@ -963,6 +1161,7 @@ impl UiDocument { | UiEdit::InstantiatePrefab { .. } | UiEdit::DetachInstance { .. } | UiEdit::ResetInstance { .. } + | UiEdit::SetAnimation { .. } | UiEdit::ExposeProperty { .. } | UiEdit::ExposeSlot { .. } | UiEdit::Unexpose { .. } @@ -1569,6 +1768,21 @@ impl UiDocument { return Err("An interface supports at most 10000 widgets".into()); } let mut ids = HashSet::new(); + for (name, animation) in &self.animations { + animation + .validate() + .map_err(|e| format!("Animation {name}: {e}"))?; + if let Some(t) = animation + .tracks + .iter() + .find(|t| !self.widgets.iter().any(|w| w.element.id == t.widget)) + { + return Err(format!( + "Animation {name} animates a missing widget: {}", + t.widget + )); + } + } for (prefab, list) in &self.exports { let members = self .prefabs @@ -1699,6 +1913,11 @@ fn check_actions(actions: &[UiAction]) -> Result<(), String> { UiDo::Broadcast { message } if message.trim().is_empty() => { return Err("A broadcast needs a message".into()); } + UiDo::PlayAnimation { name } | UiDo::StopAnimation { name } + if name.trim().is_empty() => + { + return Err("An animation action needs a name".into()); + } UiDo::SetVariable { name, .. } if name.trim().is_empty() => { return Err("Set variable needs a variable name".into()); } @@ -2411,6 +2630,63 @@ mod tests { assert!(doc.validate().is_ok()); } + #[test] + fn animations_sample_ease_validate_and_edit() { + let mut doc = UiDocument::default(); + doc.widgets = vec![row("bar", "", "")]; + let animation: UiAnimation = serde_json::from_value(serde_json::json!({ + "duration": 2.0, "looping": true, + "tracks": [ + {"widget": "bar", "property": "Width", "keys": [ + {"time": 0.0, "value": 100.0}, {"time": 1.0, "value": 200.0, "ease": "EaseIn"}, {"time": 2.0, "value": 100.0}]}, + {"widget": "bar", "property": "Background", "keys": [ + {"time": 0.0, "value": "#000000"}, {"time": 2.0, "value": "#ffffff80"}]} + ] + })) + .unwrap(); + let sample = |t: f32, i: usize| animation.tracks[i].sample(t).unwrap(); + assert_eq!(sample(0.5, 0), UiKeyValue::Number(150.)); + assert_eq!(sample(-1., 0), UiKeyValue::Number(100.)); + assert_eq!(sample(9., 0), UiKeyValue::Number(100.)); + assert_eq!(sample(1.0, 0), UiKeyValue::Number(200.)); + assert_eq!(sample(1.0, 1), UiKeyValue::Color("#808080c0".into())); + let named = |a: Option| UiEdit::SetAnimation { + name: "pulse".into(), + animation: a, + }; + doc.apply_edit(&named(Some(animation.clone()))).unwrap(); + let back: UiDocument = serde_json::from_str(&serde_json::to_string(&doc).unwrap()).unwrap(); + assert_eq!(back.animations["pulse"], animation); + let mut bad = animation.clone(); + bad.tracks[0].keys[1].time = 0.0; + assert!(doc.apply_edit(&named(Some(bad))).is_err()); + let mut bad = animation.clone(); + bad.tracks[0].property = UiProp::Text; + assert!(doc.apply_edit(&named(Some(bad))).is_err()); + let mut bad = animation.clone(); + bad.tracks[1].keys[0].value = UiKeyValue::Number(1.); + assert!(doc.apply_edit(&named(Some(bad))).is_err()); + let mut bad = animation.clone(); + bad.tracks[0].widget = "ghost".into(); + assert!(doc.apply_edit(&named(Some(bad))).is_err()); + assert_eq!(doc.animations.len(), 1); + doc.apply_edit(&named(None)).unwrap(); + assert!(doc.apply_edit(&named(None)).is_err()); + // Actions can start and stop one by name. + let play = [UiAction { + on: UiEventKind::Click, + run: UiDo::PlayAnimation { + name: "pulse".into(), + }, + }]; + assert!(check_actions(&play).is_ok()); + let empty = [UiAction { + on: UiEventKind::Click, + run: UiDo::StopAnimation { name: " ".into() }, + }]; + assert!(check_actions(&empty).is_err()); + } + #[test] fn variants_pick_the_first_matching_breakpoint_and_validate() { let mut doc = UiDocument::default(); diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 1ad6552c..623dbb6d 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -634,6 +634,8 @@ Item { function describeAction(a) { const run = a.run || {}; if (run.Broadcast) return a.on + ": broadcast " + run.Broadcast.message; + if (run.PlayAnimation) return a.on + ": play " + run.PlayAnimation.name; + if (run.StopAnimation) return a.on + ": stop " + run.StopAnimation.name; const v = run.SetVariable || {}; return a.on + ": set " + (v.actor ? v.actor + "." : "") + v.name + " to " + v.value; } @@ -649,6 +651,49 @@ Item { if (!widget) return; propertyEdit("actions", (widget.actions || []).filter((a, i) => i !== index)); } + // Animations: edited whole through SetAnimation so every change is one undo step. + readonly property var animatableProps: ["Width","Height","TextSize","CornerRadius","Padding","Value","Background","TextColor"] + function isColorProp(p) { return p === "Background" || p === "TextColor"; } + function animationNames() { return Object.keys(document.animations || {}); } + function saveAnimation(name, animation) { + structuralEdit({kind: "SetAnimation", name: name, animation: animation}, selectedId, extraIds.slice()); + } + function newAnimation(name) { + if (!name || (document.animations || {})[name]) return; + saveAnimation(name, {duration: 1, looping: false, tracks: []}); + } + function deleteAnimation(name) { + if ((document.animations || {})[name]) saveAnimation(name, null); + } + function withAnimation(name, change) { + const current = (document.animations || {})[name]; + if (!current) return; + const next = copy(current); + change(next); + saveAnimation(name, next); + } + function addKey(name, widgetId, property, time, rawValue, ease) { + const value = isColorProp(property) ? String(rawValue) : Number(rawValue); + withAnimation(name, a => { + let track = a.tracks.find(t => t.widget === widgetId && t.property === property); + if (!track) { track = {widget: widgetId, property: property, keys: []}; a.tracks.push(track); } + track.keys = track.keys.filter(k => k.time !== Number(time)); + track.keys.push({time: Number(time), value: value, ease: ease}); + track.keys.sort((x, y) => x.time - y.time); + if (Number(time) > a.duration) a.duration = Number(time); + }); + } + function removeTrack(name, index) { + withAnimation(name, a => { a.tracks.splice(index, 1); }); + } + function removeKey(name, trackIndex, keyIndex) { + withAnimation(name, a => { + const track = a.tracks[trackIndex]; + track.keys.splice(keyIndex, 1); + if (!track.keys.length) a.tracks.splice(trackIndex, 1); + }); + } + function describeTrack(t) { return t.widget + " " + t.property + " (" + t.keys.length + " keys)"; } function describeVariant(v) { const w = v.when || {}; const parts = []; @@ -1301,21 +1346,93 @@ Item { } RowLayout { ComboBox { id: actionOn; objectName: "interfaceActionOn"; model: ["Click","Change"] } - ComboBox { id: actionKind; objectName: "interfaceActionKind"; model: ["Broadcast","Set variable"] } + ComboBox { id: actionKind; objectName: "interfaceActionKind"; model: ["Broadcast","Set variable","Play animation","Stop animation"] } } - TextField { id: actionMessage; objectName: "interfaceActionMessage"; Layout.fillWidth: true; visible: actionKind.currentIndex === 0; placeholderText: "Message to broadcast" } + TextField { id: actionMessage; objectName: "interfaceActionMessage"; Layout.fillWidth: true; visible: actionKind.currentIndex === 0 || actionKind.currentIndex >= 2; placeholderText: actionKind.currentIndex === 0 ? "Message to broadcast" : "Animation name" } TextField { id: actionActor; Layout.fillWidth: true; visible: actionKind.currentIndex === 1; placeholderText: "Actor id (blank for global)" } TextField { id: actionVariable; objectName: "interfaceActionVariable"; Layout.fillWidth: true; visible: actionKind.currentIndex === 1; placeholderText: "Variable name" } TextField { id: actionValue; objectName: "interfaceActionValue"; Layout.fillWidth: true; visible: actionKind.currentIndex === 1; placeholderText: "Value ($value is the new value on Change)" } Button { objectName: "interfaceAddAction"; text: "Add event" - enabled: !!root.widget && !root.gesture && (actionKind.currentIndex === 0 ? !!actionMessage.text.trim() : !!actionVariable.text.trim()) + enabled: !!root.widget && !root.gesture && (actionKind.currentIndex === 1 ? !!actionVariable.text.trim() : !!actionMessage.text.trim()) onClicked: { - root.addAction({on: actionOn.currentText, run: actionKind.currentIndex === 0 - ? {Broadcast: {message: actionMessage.text.trim()}} - : {SetVariable: {actor: actionActor.text.trim(), name: actionVariable.text.trim(), value: actionValue.text}}}); + const text = actionMessage.text.trim(); + root.addAction({on: actionOn.currentText, run: [ + {Broadcast: {message: text}}, + {SetVariable: {actor: actionActor.text.trim(), name: actionVariable.text.trim(), value: actionValue.text}}, + {PlayAnimation: {name: text}}, + {StopAnimation: {name: text}}][actionKind.currentIndex]}); } } + Label { text: "Animations"; font.bold: true } + RowLayout { + ComboBox { id: animationPick; objectName: "interfaceAnimationPick"; Layout.fillWidth: true; model: root.animationNames() } + Button { objectName: "interfaceDeleteAnimation"; text: "Delete"; enabled: animationPick.currentIndex >= 0 && !root.gesture; onClicked: root.deleteAnimation(animationPick.currentText) } + } + RowLayout { + TextField { id: animationName; objectName: "interfaceAnimationName"; Layout.fillWidth: true; placeholderText: "New animation name" } + Button { objectName: "interfaceNewAnimation"; text: "New"; enabled: !!animationName.text.trim() && !root.gesture; onClicked: { root.newAnimation(animationName.text.trim()); animationName.text = ""; } } + } + RowLayout { + visible: animationPick.currentIndex >= 0 + Label { text: "Seconds" } + TextField { + id: animationLength; objectName: "interfaceAnimationLength"; Layout.preferredWidth: 60 + text: animationPick.currentIndex >= 0 ? String(((root.document.animations || {})[animationPick.currentText] || {}).duration || 1) : "" + validator: DoubleValidator { bottom: 0.05 } + onEditingFinished: { const n = Number(text); if (n > 0) root.withAnimation(animationPick.currentText, a => { a.duration = n; a.tracks.forEach(t => { t.keys = t.keys.filter(k => k.time <= n); }); a.tracks = a.tracks.filter(t => t.keys.length); }); } + } + CheckBox { + objectName: "interfaceAnimationLoop"; text: "Loop" + checked: animationPick.currentIndex >= 0 && !!((root.document.animations || {})[animationPick.currentText] || {}).looping + onToggled: root.withAnimation(animationPick.currentText, a => { a.looping = checked; }) + } + } + Repeater { + model: animationPick.currentIndex >= 0 ? (((root.document.animations || {})[animationPick.currentText] || {}).tracks || []) : [] + delegate: ColumnLayout { + required property var modelData + required property int index + Layout.fillWidth: true + RowLayout { + Layout.fillWidth: true + Label { Layout.fillWidth: true; elide: Text.ElideRight; text: root.describeTrack(modelData); color: Theme.textDim } + Button { text: "Remove track"; enabled: !root.gesture; onClicked: root.removeTrack(animationPick.currentText, index) } + } + // The timeline: each key sits at its time along the animation. + Rectangle { + id: strip + objectName: "interfaceTimeline" + index + Layout.fillWidth: true; Layout.preferredHeight: 18; color: "#1d222c"; radius: 3 + readonly property real total: Math.max(0.05, ((root.document.animations || {})[animationPick.currentText] || {}).duration || 1) + Repeater { + model: modelData.keys + delegate: Rectangle { + required property var modelData + required property int index + x: Math.min(strip.width - width, modelData.time / strip.total * (strip.width - width)) + y: 4; width: 10; height: 10; rotation: 45; color: "#70baff" + MouseArea { anchors.fill: parent; onClicked: root.removeKey(animationPick.currentText, trackIndexOf(strip), index) } + function trackIndexOf(item) { return parseInt(item.objectName.replace("interfaceTimeline", "")); } + } + } + } + } + } + RowLayout { + visible: animationPick.currentIndex >= 0 + ComboBox { id: keyProperty; objectName: "interfaceKeyProperty"; model: root.animatableProps } + TextField { id: keyTime; objectName: "interfaceKeyTime"; Layout.preferredWidth: 50; placeholderText: "time"; validator: DoubleValidator { bottom: 0 } } + TextField { id: keyValue; objectName: "interfaceKeyValue"; Layout.fillWidth: true; placeholderText: root.isColorProp(keyProperty.currentText) ? "#rrggbbaa" : "value" } + ComboBox { id: keyEase; objectName: "interfaceKeyEase"; model: ["Linear","EaseIn","EaseOut","EaseInOut"] } + } + Button { + objectName: "interfaceAddKey"; text: "Key selected widget" + visible: animationPick.currentIndex >= 0 + enabled: !!root.widget && !root.gesture && keyTime.text !== "" && keyValue.text !== "" + onClicked: root.addKey(animationPick.currentText, root.widget.element.id, keyProperty.currentText, keyTime.text, keyValue.text, keyEase.currentText) + } + Label { text: "Run it from Events: add an event whose action plays the animation."; color: Theme.textDim; wrapMode: Text.Wrap; Layout.fillWidth: true; visible: animationPick.currentIndex >= 0 } Label { text: "Responsive variants"; font.bold: true } Repeater { model: root.widget ? root.widget.variants || [] : [] diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 838b6e5a..b3f696d0 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -498,6 +498,27 @@ TestCase { compare(panel.describeVariant({when:{}, layout:{}}), "any size: own layout"); verify(!calls.some(c=>c.command === "set_interface")); } + function test_animations_edit_whole_through_set_animation() { + panel.selectedId = "front"; + const d = panel.copy(panel.document); + d.animations = {pulse: {duration: 1, looping: false, tracks: []}}; + panel.document = d; backend.appState.project.world.interface = d; + compare(panel.animationNames(), ["pulse"]); + panel.addKey("pulse", "front", "Width", "0.5", "120", "EaseIn"); + let edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.kind, "SetAnimation"); + compare(edit.name, "pulse"); + compare(edit.animation.tracks, [{widget:"front", property:"Width", keys:[{time:0.5, value:120, ease:"EaseIn"}]}]); + // A color track keeps its text, and a key past the end stretches the animation. + panel.cancelEdit(); + panel.addKey("pulse", "front", "Background", "2", "#ff000080", "Linear"); + edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.animation.duration, 2); + compare(edit.animation.tracks[0].keys[0].value, "#ff000080"); + compare(panel.describeTrack({widget:"front", property:"Width", keys:[{}, {}]}), "front Width (2 keys)"); + compare(panel.describeAction({on:"Click", run:{PlayAnimation:{name:"pulse"}}}), "Click: play pulse"); + verify(!calls.some(c=>c.command === "set_interface")); + } function test_guides_are_editor_only_and_snap_targets_follow_the_frame() { compare(panel.guides.length, 0); const a = panel.addGuide(0, 100); diff --git a/blockloom-runtime/src/engine.rs b/blockloom-runtime/src/engine.rs index efc9aa6f..eb0889f0 100644 --- a/blockloom-runtime/src/engine.rs +++ b/blockloom-runtime/src/engine.rs @@ -454,6 +454,8 @@ pub struct Engine { pub scripts_started: HashSet<(String, String)>, /// Events fired since scripts last ran, for their `event` entry points. pub script_events: Vec, + /// Interface animations asked for since the last frame: (name, play or stop). + pub ui_animations: Vec<(String, bool)>, /// Which components each actor is carrying right now. Seeded from the /// project on every rebuild and moved by `attach`/`detach`, so it - not /// the document - is what a mid-run question about a component answers. @@ -625,6 +627,7 @@ impl Engine { scripts: HashMap::new(), scripts_started: HashSet::new(), script_events: Vec::new(), + ui_animations: Vec::new(), // Smoothed milliseconds per script path, for the profiler's // `script/*` rows. Timed around each entry's callbacks so one // heavy file stands out from a busy project. @@ -857,6 +860,8 @@ impl Engine { UiDo::Broadcast { message } => { self.fire(Event::Message(message.trim().to_string())); } + UiDo::PlayAnimation { name } => self.ui_animations.push((name, true)), + UiDo::StopAnimation { name } => self.ui_animations.push((name, false)), UiDo::SetVariable { actor, name, value } => { let value = match (value.trim(), changed) { ("$value", Some(v)) => v.clone(), diff --git a/blockloom-runtime/src/lib.rs b/blockloom-runtime/src/lib.rs index dd8d7fc8..79f87878 100644 --- a/blockloom-runtime/src/lib.rs +++ b/blockloom-runtime/src/lib.rs @@ -623,6 +623,7 @@ pub(crate) fn add_world(app: &mut App, mode: Mode, mut engine: engine::Engine) { ui_systems::navigation, ui_systems::touch, ui_systems::project_widgets.run_if(ui_design::inactive), + ui_systems::play_animations, overlay::draw_ui, ui_systems::animate, ui_systems::atlas, diff --git a/blockloom-runtime/src/ui.rs b/blockloom-runtime/src/ui.rs index ae4bf14e..9401d126 100644 --- a/blockloom-runtime/src/ui.rs +++ b/blockloom-runtime/src/ui.rs @@ -161,6 +161,8 @@ pub struct UiManager { dropped: Vec, theme: UiTheme, pub navigation: Option, + /// Document animations running, with how long each has played. + playing: Vec<(String, f32)>, pub binding_writes: Vec<(UiBinding, Evaluated)>, pub document: UiDocument, pub canvas_scale: f32, @@ -213,6 +215,61 @@ impl UiManager { } } + /// Starts a document animation over, or ignores a name nothing has. + pub fn play_animation(&mut self, name: &str) { + if !self.document.animations.contains_key(name) { + return; + } + self.playing.retain(|(n, _)| n != name); + self.playing.push((name.to_string(), 0.)); + } + + /// Stops one where it stands; what it set stays set. + pub fn stop_animation(&mut self, name: &str) { + self.playing.retain(|(n, _)| n != name); + } + + pub fn animating(&self) -> bool { + !self.playing.is_empty() + } + + /// Advances every running animation by `dt` seconds and writes what its + /// tracks sample to, through the same `set` the blocks use. + pub fn step_animations(&mut self, dt: f32) { + use blockloom_core::ui::UiKeyValue; + let mut writes: Vec<(String, UiProp, Evaluated)> = Vec::new(); + let animations = &self.document.animations; + self.playing.retain_mut(|(name, time)| { + let Some(animation) = animations.get(name) else { + return false; + }; + *time += dt.max(0.); + let mut at = *time; + let mut running = true; + if animation.looping { + at %= animation.duration; + } else if at >= animation.duration { + at = animation.duration; + running = false; + } + for track in &animation.tracks { + match track.sample(at) { + Some(UiKeyValue::Number(n)) => { + writes.push((track.widget.clone(), track.property, Evaluated::Number(n))); + } + Some(UiKeyValue::Color(c)) => { + writes.push((track.widget.clone(), track.property, Evaluated::Text(c))); + } + None => {} + } + } + running + }); + for (id, prop, value) in writes { + self.set(&id, prop, &value); + } + } + pub fn show_widget(&mut self, mut widget: UiWidget) { widget.element.value = UiElement::initial_value(widget.element.kind, &widget.element.value); let id = widget.element.id.clone(); @@ -1590,4 +1647,33 @@ mod tests { manager.apply_variants(950., 800.); assert!(!manager.get("menu").unwrap().dirty); } + #[test] + fn a_played_animation_drives_properties_then_ends_or_loops() { + let document: UiDocument = serde_json::from_value(serde_json::json!({ + "widgets": [{"element": {"id": "bar", "kind": "Panel"}}], + "animations": { + "grow": {"duration": 2.0, "tracks": [{"widget": "bar", "property": "Width", + "keys": [{"time": 0.0, "value": 100.0}, {"time": 2.0, "value": 200.0}]}]}, + "blink": {"duration": 1.0, "looping": true, "tracks": [{"widget": "bar", "property": "Height", + "keys": [{"time": 0.0, "value": 0.0}, {"time": 1.0, "value": 10.0}]}]} + } + })) + .unwrap(); + let mut manager = UiManager::default(); + manager.load(&document); + manager.play_animation("missing"); + assert!(!manager.animating()); + manager.play_animation("grow"); + manager.step_animations(1.0); + assert_eq!(manager.get("bar").unwrap().style.width, Some(150.)); + manager.step_animations(5.0); + assert_eq!(manager.get("bar").unwrap().style.width, Some(200.)); + assert!(!manager.animating()); + manager.play_animation("blink"); + manager.step_animations(1.25); + assert_eq!(manager.get("bar").unwrap().style.height, Some(2.5)); + assert!(manager.animating()); + manager.stop_animation("blink"); + assert!(!manager.animating()); + } } diff --git a/blockloom-runtime/src/ui_systems.rs b/blockloom-runtime/src/ui_systems.rs index 2c8dcc64..7c4c3393 100644 --- a/blockloom-runtime/src/ui_systems.rs +++ b/blockloom-runtime/src/ui_systems.rs @@ -559,6 +559,25 @@ pub struct UiTween { pub hide: bool, pub show: bool, } +/// Plays and steps the document's interface animations on the wall clock, so +/// a paused world still animates its menus. +pub fn play_animations( + mut engine: NonSendMut, + mut manager: ResMut, + time: Res>, +) { + for (name, play) in engine.ui_animations.drain(..) { + if play { + manager.play_animation(&name); + } else { + manager.stop_animation(&name); + } + } + if manager.animating() { + manager.step_animations(time.delta_secs()); + } +} + pub fn animate( mut commands: Commands, time: Res