Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
894df23
Interface editor: typed create, delete and duplicate edits
claude Oct 8, 2026
5453b55
Interface editor: hierarchy tree with search, folding and editor lock
claude Oct 8, 2026
f75467e
Interface editor: editor hide in the preview and UI schema versioning
claude Oct 8, 2026
9f33557
Interface editor: batch edits, property metadata, multiselection and …
claude Oct 8, 2026
4238a2d
Interface editor: group move, align and distribute
claude Oct 8, 2026
db2cf00
AGENTS: note group move and align
claude Oct 8, 2026
01fab13
Interface editor: reload-free commit, typed content/style/document edits
claude Oct 9, 2026
e2e4073
Interface editor: colour swatches, font picker and style classes
claude Oct 9, 2026
04bc216
Interface editor: safe-area and reference-size overlays, more preview…
claude Oct 9, 2026
fbb2ba6
Interface editor: inspector rows follow property metadata
claude Oct 9, 2026
86ef827
Interface editor: reusable components and image fit modes
claude Oct 9, 2026
7945f80
Retry the adb boot-state stub test when a sibling fork races its exec
claude Oct 9, 2026
365c690
Interface editor: per-field instance overrides and instance reset
claude Oct 10, 2026
ed4a825
Interface editor: rulers and snapping guides
claude Oct 10, 2026
dcd9008
Interface editor: widget events, binding list, text input caret and IME
claude Oct 10, 2026
4e9a4e7
Update the text input note
claude Oct 10, 2026
b6dd9fa
Interface editor: exposed component properties and slots
claude Oct 10, 2026
fd055ee
Fix a clippy lint in the export check
claude Oct 10, 2026
99e6954
Interface editor: zoom and pan in the viewport
claude Oct 10, 2026
d5b0b58
Interface editor: responsive variants
claude Oct 10, 2026
7db417c
Interface editor: animations with a timeline editor
claude Oct 11, 2026
4b5dceb
Interface editor: generated all-properties form from the backend table
claude Oct 11, 2026
46fa738
Interface editor: drawn text caret and selection with pointer placement
EthanRStokes Oct 11, 2026
412b25a
Interface editor: opacity, offset and rotation tracks, animation preview
EthanRStokes Oct 11, 2026
9555334
Interface editor: animation scrub preview, QML test fixes
EthanRStokes Oct 11, 2026
52ca166
Docs: interface effects, caret and verification notes
EthanRStokes Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3060,6 +3060,33 @@ 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.<old 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. 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).

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,
Expand Down Expand Up @@ -3157,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, IME composition, pointer placement and drag selection. The caret, selection and composition underline are drawn by `input_caret.rs` from the text layout's glyph geometry (parley, via `ComputedTextBlock::buffer`); the shown text only splices the composition in. 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
Expand Down Expand Up @@ -3193,3 +3220,20 @@ lands.
path attachments draw nothing. A rig's or stack's outline is one
silhouette per piece, so a translucent outline color darkens where pieces'
silhouettes overlap.

### Interface effects and animation preview

`UiFx` (opacity, offset, rotation) is visual state laid over an element after
layout: `UiWidget.fx` holds the authored start (typed edit path `fx`), and the
`Opacity`/`OffsetX`/`OffsetY`/`Rotation` `UiProp`s write it at run time and are
animatable tracks. It composes with the anchor without touching its
`UiTransform` translation: offset adds to the margin (anchored), insets
(absolute) or relative insets (flow child) in `layout_node`, rotation sets the
same transform's rotation (`overlay::apply_fx_rotation`), and opacity
multiplies down the tree (`UiManager::opacity_of`) into background, border,
shadow, image and text colours. Not faded yet: slider tracks and fills.
`InterfaceDesign.animation` (`{name, time}`, `PROTOCOL_VERSION` 37) freezes a
document animation at a time in the design session through
`UiManager::sample_animation`; the editor's timeline Preview/scrub uses it and
pauses editing while it is on. Nothing is saved.

9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions blockloom-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5761,6 +5761,10 @@ pub(crate) fn preview_interface(
Ok(())
}

pub(crate) fn interface_properties() -> Vec<blockloom_core::ui::UiPropertyInfo> {
blockloom_core::ui::property_metadata()
}

pub(crate) fn interface_layout(
state: &SharedState,
) -> Result<Option<blockloom_protocol::InterfaceLayout>, String> {
Expand Down Expand Up @@ -5838,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(())
}
Expand All @@ -5861,8 +5869,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() {
Expand Down
1 change: 1 addition & 0 deletions blockloom-app/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions blockloom-app/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?, animation?} 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; animation is {name, time} and shows that document animation frozen at the time. Omit design to close it.",
args: &[ArgSpec {
name: "design",
ty: "object",
Expand All @@ -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",
Expand All @@ -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} 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.<old 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",
Expand Down
25 changes: 25 additions & 0 deletions blockloom-app/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -684,6 +698,17 @@ 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": "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"}},
{"kind": "Create", "widget": {"element": {"id": "x"}}}
]}),
] {
let before =
backend.dispatch("get_state", json!({})).unwrap()["project"]["world"]["interface"]
Expand Down
27 changes: 21 additions & 6 deletions blockloom-core/src/android.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions blockloom-core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> =
Expand Down
Loading
Loading