diff --git a/AGENTS.md b/AGENTS.md index ad5a807..7c364a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.`, +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, @@ -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 @@ -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. + diff --git a/CLAUDE.md b/CLAUDE.md index 4d25be6..2a4f715 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 diff --git a/Cargo.lock b/Cargo.lock index 24c88c1..9bd3727 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2718,6 +2718,7 @@ dependencies = [ "libc", "libloading 0.9.0", "naga", + "parley", "polyanya", "serde", "serde_json", diff --git a/blockloom-app/src/commands.rs b/blockloom-app/src/commands.rs index f9e893c..48a0cdb 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> { @@ -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(()) } @@ -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() { diff --git a/blockloom-app/src/dispatch.rs b/blockloom-app/src/dispatch.rs index 604a226..ffce59b 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 4395a39..f276efe 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?, 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", @@ -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} 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}, 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-app/tests/sync.rs b/blockloom-app/tests/sync.rs index ef2ec74..0dfbd97 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(); @@ -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"] diff --git a/blockloom-core/src/android.rs b/blockloom-core/src/android.rs index ea8f5ef..5f93d8d 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); } diff --git a/blockloom-core/src/project.rs b/blockloom-core/src/project.rs index dde8aff..8fafbb0 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.rs b/blockloom-core/src/ui.rs index 7017938..a7aeac8 100644 --- a/blockloom-core/src/ui.rs +++ b/blockloom-core/src/ui.rs @@ -270,6 +270,13 @@ pub enum UiProp { Tooltip, WorldActor, Transition, + /// Whole-element fade, 0 to 1, inherited by its children. Visual only. + Opacity, + /// Visual shift in pixels, added to wherever layout and anchor put it. + OffsetX, + OffsetY, + /// Visual turn about the element's centre, in degrees. + Rotation, } impl UiProp { @@ -302,6 +309,10 @@ impl UiProp { UiProp::Tooltip, UiProp::WorldActor, UiProp::Transition, + UiProp::Opacity, + UiProp::OffsetX, + UiProp::OffsetY, + UiProp::Rotation, ]; /// The wire name, which is also what the ABI carries and what the @@ -335,6 +346,10 @@ impl UiProp { UiProp::Tooltip => "Tooltip", UiProp::WorldActor => "WorldActor", UiProp::Transition => "Transition", + UiProp::Opacity => "Opacity", + UiProp::OffsetX => "OffsetX", + UiProp::OffsetY => "OffsetY", + UiProp::Rotation => "Rotation", } } @@ -420,6 +435,191 @@ 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 an input draws, with any uncommitted IME composition spliced + /// in at the caret. Marks are byte offsets into that text; the caret sits + /// after the composition. + pub fn compose(&self, text: &str, preedit: &str) -> ShownText { + let mut caret = *self; + caret.clamp(text); + let at = Self::byte(text, caret.at); + let mut out = String::with_capacity(text.len() + preedit.len()); + out.push_str(&text[..at]); + out.push_str(preedit); + out.push_str(&text[at..]); + // Bytes after the splice move right by the composition's length. + let moved = |chars: usize| { + let b = Self::byte(text, chars); + if b >= at { b + preedit.len() } else { b } + }; + ShownText { + text: out, + caret: at + preedit.len(), + selection: caret.selection().map(|(s, e)| (moved(s), moved(e))), + preedit: (!preedit.is_empty()).then_some((at, at + preedit.len())), + } + } +} + +/// What an input draws: its words, and where the caret, selection and +/// composition sit in them as byte offsets. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShownText { + pub text: String, + pub caret: usize, + pub selection: Option<(usize, usize)>, + pub preedit: Option<(usize, usize)>, +} + +/// The character index a byte offset of `text` falls on, rounding down. +pub fn char_at_byte(text: &str, byte: usize) -> usize { + text.char_indices().take_while(|(i, _)| *i < byte).count() +} + /// 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 +842,75 @@ 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"); + let shown = caret.compose("abc", ""); + assert_eq!( + (shown.text.as_str(), shown.caret, shown.selection), + ("abc", 3, None) + ); + caret.select_all("abc"); + assert_eq!(caret.compose("abc", "").selection, Some((0, 3))); + caret.step("abc", -1, false); + assert_eq!((caret.at, caret.selection()), (0, None)); + let shown = caret.compose("abc", "ni"); + assert_eq!( + (shown.text.as_str(), shown.caret, shown.preedit), + ("niabc", 2, Some((0, 2))) + ); + // Marks are bytes, and a composition pushes what follows it right. + let caret = TextCaret { + at: 1, + anchor: Some(3), + }; + let shown = caret.compose("héé", "x"); + assert_eq!(shown.text, "hxéé"); + assert_eq!((shown.caret, shown.selection), (2, Some((2, 6)))); + assert_eq!(char_at_byte("héé", 3), 2); + } } #[path = "ui_framework.rs"] diff --git a/blockloom-core/src/ui_framework.rs b/blockloom-core/src/ui_framework.rs index f525a5f..9c610b4 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), } } } @@ -207,6 +230,299 @@ 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)) + } +} + +/// 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; 10] = [ + UiProp::Width, + UiProp::Height, + UiProp::TextSize, + UiProp::CornerRadius, + UiProp::Padding, + UiProp::Value, + UiProp::Opacity, + UiProp::OffsetX, + UiProp::OffsetY, + UiProp::Rotation, +]; +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 { + 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, + }, + /// Starts (or restarts) a document animation. + PlayAnimation { + name: String, + }, + StopAnimation { + name: 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, +} + +/// Visual state laid over an element after layout: it never changes what +/// layout measures. Offset adds to wherever the anchor and layout put the +/// element and rotation turns it about its centre, so neither fights the +/// anchor's own shift. Opacity multiplies down the tree. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UiFx { + pub opacity: f32, + pub offset: [f32; 2], + /// Degrees, clockwise. + pub rotation: f32, +} + +impl Default for UiFx { + fn default() -> Self { + Self { + opacity: 1., + offset: [0., 0.], + rotation: 0., + } + } +} + +impl UiFx { + pub fn is_default(&self) -> bool { + *self == Self::default() + } + + pub fn validate(&self) -> Result<(), String> { + if !self.opacity.is_finite() || !(0. ..=1.).contains(&self.opacity) { + return Err("Opacity must be between 0 and 1".into()); + } + if !self.offset.iter().all(|v| v.is_finite()) || !self.rotation.is_finite() { + return Err("Offset and rotation must be finite".into()); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] #[serde(default)] pub struct UiWidget { @@ -215,12 +531,24 @@ 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, + /// 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, pub scroll_target: String, pub tab_index: Option, pub transition: f32, + /// Opacity, offset and rotation the widget starts with. + #[serde(skip_serializing_if = "UiFx::is_default")] + pub fx: UiFx, + /// 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 { @@ -232,6 +560,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, @@ -240,10 +571,38 @@ 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>, + /// 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 +/// 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; + +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(), @@ -252,6 +611,8 @@ impl Default for UiDocument { reference_size: [960., 720.], safe_area: [0.; 4], prefabs: BTreeMap::new(), + exports: BTreeMap::new(), + animations: BTreeMap::new(), } } } @@ -269,6 +630,229 @@ 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 = "actions")] + Actions(Vec), + #[serde(rename = "variants")] + Variants(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), + #[serde(rename = "fx")] + Fx(UiFx), +} + +/// 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", + UiPropertyEdit::Range(_) => "element.range", + UiPropertyEdit::Style(_) => "style", + UiPropertyEdit::Class(_) => "class", + UiPropertyEdit::Bindings(_) => "bindings", + UiPropertyEdit::Actions(_) => "actions", + UiPropertyEdit::Variants(_) => "variants", + UiPropertyEdit::Items(_) => "items", + UiPropertyEdit::Tooltip(_) => "tooltip", + UiPropertyEdit::ScrollTarget(_) => "scroll_target", + UiPropertyEdit::TabIndex(_) => "tab_index", + UiPropertyEdit::Transition(_) => "transition", + UiPropertyEdit::Fx(_) => "fx", + } + } +} + +/// 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![]), + 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("actions", "Events", "Bindings", "actions", vec![], vec![]), + info( + "variants", + "Responsive variants", + "Layout", + "variants", + 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![], + ), + info( + "fx", + "Opacity, offset and rotation", + "Appearance", + "fx", + vec![], + vec![], + ), + ] } fn deserialize_edit_layout<'de, D: serde::Deserializer<'de>>( @@ -329,6 +913,38 @@ pub enum UiEdit { parent: String, placement: UiPlacement, }, + /// Applies several edits in order as one; any failure rejects them all. + 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, + }, + /// 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], @@ -338,6 +954,68 @@ 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, + }, + /// Throws away every change an instance made and rebuilds it from its prefab. + 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, + 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 { @@ -350,12 +1028,202 @@ 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::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 } => { + 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}")); + } + self.exports.remove(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(()); + } + 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, + widget, + 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); + } + 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 { UiEdit::Move { id, .. } | UiEdit::Resize { id, .. } | UiEdit::SetProperty { id, .. } | UiEdit::Reparent { id, .. } | UiEdit::Reorder { id, .. } => id, + UiEdit::Batch { .. } + | UiEdit::SetDocument { .. } + | UiEdit::SetClass { .. } + | UiEdit::Create { .. } + | UiEdit::Delete { .. } + | UiEdit::Duplicate { .. } + | UiEdit::SavePrefab { .. } + | UiEdit::DeletePrefab { .. } + | UiEdit::InstantiatePrefab { .. } + | UiEdit::DetachInstance { .. } + | UiEdit::ResetInstance { .. } + | UiEdit::SetAnimation { .. } + | UiEdit::ExposeProperty { .. } + | UiEdit::ExposeSlot { .. } + | UiEdit::Unexpose { .. } + | UiEdit::SetExposed { .. } + | UiEdit::FillSlot { .. } => unreachable!(), }; let index = self .widgets @@ -387,6 +1255,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, @@ -399,6 +1268,72 @@ 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()); + } + 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(), + 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(); + } + 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; + } + UiPropertyEdit::Fx(value) => { + value.validate()?; + w.fx = *value; + } } return Ok(()); } @@ -495,38 +1430,454 @@ impl UiDocument { } impl UiDocument { - pub fn validate(&self) -> Result<(), String> { - if self - .reference_size - .iter() - .any(|v| !v.is_finite() || *v <= 0.) - || self.safe_area.iter().any(|v| !v.is_finite() || *v < 0.) - { - return Err("Invalid interface canvas dimensions".into()); + /// 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; } + } - if self.widgets.len() > 10000 { - return Err("An interface supports at most 10000 widgets".into()); + 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; } - let mut ids = HashSet::new(); - for widget in &self.widgets { - let id = &widget.element.id; - if id.is_empty() - || id.contains("::row:") - || id.ends_with("::extent") - || id == "__tooltip" - || !ids.insert(id) - { - return Err(format!("Duplicate or empty widget id: {id}")); + (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()); } - if widget.bindings.iter().any(|b| { - !matches!( - b.property, - UiProp::Text | UiProp::Value | UiProp::Visible | UiProp::SelectedIndex - ) - }) { + 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(()) + } + + 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(merge_widget(e, &w, c)?), + _ => 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(()) + } + + 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 + .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!( + "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() + .any(|v| !v.is_finite() || *v <= 0.) + || self.safe_area.iter().any(|v| !v.is_finite() || *v < 0.) + { + return Err("Invalid interface canvas dimensions".into()); + } + + if self.widgets.len() > 10000 { + 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 + .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() + || id.contains("::row:") + || id.ends_with("::extent") + || id == "__tooltip" + || !ids.insert(id) + { + 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, + UiProp::Text | UiProp::Value | UiProp::Visible | UiProp::SelectedIndex + ) + }) { 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() @@ -595,6 +1946,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) @@ -604,6 +1956,112 @@ 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 { + 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()); + } + _ => {} + } + } + Ok(()) +} + +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, @@ -751,6 +2209,278 @@ mod tests { ); } + #[test] + fn fx_is_a_typed_edit_and_an_animation_track_may_drive_it() { + let mut doc: UiDocument = serde_json::from_value(serde_json::json!({ + "widgets": [{"element": {"id": "a"}}] + })) + .unwrap(); + let edit = |value| serde_json::from_value::(value).unwrap(); + doc.apply_edit(&edit(serde_json::json!({"kind": "SetProperty", "id": "a", + "property": {"path": "fx", "value": {"opacity": 0.0, "rotation": 15}}}))) + .unwrap(); + assert_eq!(doc.widgets[0].fx.opacity, 0.); + assert_eq!(doc.widgets[0].fx.rotation, 15.); + let bad = edit(serde_json::json!({"kind": "SetProperty", "id": "a", + "property": {"path": "fx", "value": {"opacity": 2.0}}})); + assert!(doc.apply_edit(&bad).is_err()); + // A default fx is not written out. + doc.widgets[0].fx = UiFx::default(); + assert!( + serde_json::to_value(&doc.widgets[0]) + .unwrap() + .get("fx") + .is_none() + ); + let track = |property| UiTrack { + widget: "a".into(), + property, + keys: vec![UiKey { + time: 0., + value: UiKeyValue::Number(1.), + ease: UiEase::Linear, + }], + }; + for property in [ + UiProp::Opacity, + UiProp::OffsetX, + UiProp::OffsetY, + UiProp::Rotation, + ] { + assert!(track(property).validate(1.).is_ok(), "{property:?}"); + } + } + + #[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), + UiPropertyEdit::Range([0., 1.]), + UiPropertyEdit::Style(Box::default()), + UiPropertyEdit::Class(String::new()), + UiPropertyEdit::Bindings(vec![]), + UiPropertyEdit::Actions(vec![]), + UiPropertyEdit::Variants(vec![]), + UiPropertyEdit::Items(vec![]), + UiPropertyEdit::Tooltip(String::new()), + UiPropertyEdit::ScrollTarget(String::new()), + UiPropertyEdit::TabIndex(None), + UiPropertyEdit::Transition(0.), + UiPropertyEdit::Fx(UiFx::default()), + ]; + 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 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 = + 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": [ + {"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(); @@ -844,6 +2574,350 @@ 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 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 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(); + 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(); + 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(); + 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-protocol/src/lib.rs b/blockloom-protocol/src/lib.rs index 037a25f..ad86312 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 = 37; /// 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,11 +93,24 @@ 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, + /// Show a document animation frozen at one time, without playing it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub animation: Option, pub revision: u64, pub generation: u64, pub document: blockloom_core::ui::UiDocument, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InterfaceAnimationPreview { + pub name: String, + /// Seconds into the animation; held at the end past its duration. + pub time: f32, +} + impl InterfaceDesign { pub fn validate(&self) -> Result<(), String> { self.document.validate()?; @@ -107,6 +120,14 @@ impl InterfaceDesign { { return Err("Interface viewport dimensions must be between 16 and 8192 pixels".into()); } + if let Some(preview) = &self.animation { + if !preview.time.is_finite() || preview.time < 0. { + return Err("Animation preview time must be finite and not negative".into()); + } + if !self.document.animations.contains_key(&preview.name) { + return Err(format!("Unknown animation: {}", preview.name)); + } + } if let Some(screen) = &self.screen && !self .document @@ -116,6 +137,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 +800,8 @@ mod tests { let message = EditorMessage::InterfaceDesign { design: Some(InterfaceDesign { screen: Some("screen".into()), + hidden: vec![], + animation: None, viewport: Some([960, 720]), revision: 4, generation: 2, @@ -783,6 +815,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/Blocks.qml b/blockloom-qt/qml/Blocks.qml index 98b10da..5e7277a 100644 --- a/blockloom-qt/qml/Blocks.qml +++ b/blockloom-qt/qml/Blocks.qml @@ -48,7 +48,7 @@ QtObject { readonly property var visibleOptions: [{value:"true",label:"show"},{value:"false",label:"hide"}] readonly property var mouseLockOptions: [{value:"true",label:"lock"},{value:"false",label:"unlock"}] readonly property var uiAnchorOptions: [{value:"TopLeft",label:"top left"},{value:"Top",label:"top"},{value:"TopRight",label:"top right"},{value:"Left",label:"left"},{value:"Center",label:"centre"},{value:"Right",label:"right"},{value:"BottomLeft",label:"bottom left"},{value:"Bottom",label:"bottom"},{value:"BottomRight",label:"bottom right"}] - readonly property var uiPropOptions: [{value:"Text",label:"text"},{value:"TextColor",label:"text color"},{value:"TextSize",label:"text size"},{value:"Background",label:"background"},{value:"Width",label:"width"},{value:"Height",label:"height"},{value:"Visible",label:"visible"},{value:"CornerRadius",label:"corner radius"},{value:"Padding",label:"padding"},{value:"Modal",label:"modal"},{value:"Min",label:"min"},{value:"Max",label:"max"},{value:"Value",label:"value"},{value:"Step",label:"step"},{value:"Allow",label:"allow"},{value:"MaxLength",label:"max length"},{value:"Layout",label:"Layout"},{value:"Style",label:"Style"},{value:"Bind",label:"Bind"},{value:"Items",label:"Items"},{value:"Scroll",label:"Scroll"},{value:"SelectedIndex",label:"SelectedIndex"},{value:"Theme",label:"Theme"},{value:"Enabled",label:"Enabled"},{value:"Tooltip",label:"Tooltip"},{value:"WorldActor",label:"WorldActor"},{value:"Transition",label:"Transition"}] + readonly property var uiPropOptions: [{value:"Text",label:"text"},{value:"TextColor",label:"text color"},{value:"TextSize",label:"text size"},{value:"Background",label:"background"},{value:"Width",label:"width"},{value:"Height",label:"height"},{value:"Visible",label:"visible"},{value:"CornerRadius",label:"corner radius"},{value:"Padding",label:"padding"},{value:"Modal",label:"modal"},{value:"Min",label:"min"},{value:"Max",label:"max"},{value:"Value",label:"value"},{value:"Step",label:"step"},{value:"Allow",label:"allow"},{value:"MaxLength",label:"max length"},{value:"Layout",label:"Layout"},{value:"Style",label:"Style"},{value:"Bind",label:"Bind"},{value:"Items",label:"Items"},{value:"Scroll",label:"Scroll"},{value:"SelectedIndex",label:"SelectedIndex"},{value:"Theme",label:"Theme"},{value:"Enabled",label:"Enabled"},{value:"Tooltip",label:"Tooltip"},{value:"WorldActor",label:"WorldActor"},{value:"Transition",label:"Transition"},{value:"Opacity",label:"opacity"},{value:"OffsetX",label:"offset x"},{value:"OffsetY",label:"offset y"},{value:"Rotation",label:"rotation"}] readonly property var modalOptions: [{value:"false",label:"floating"},{value:"true",label:"modal"}] readonly property var onOffOptions: [{value:"true",label:"on"},{value:"false",label:"off"}] readonly property var enableOptions: [{value:"true",label:"enable"},{value:"false",label:"disable"}] diff --git a/blockloom-qt/qml/UiDesigner.qml b/blockloom-qt/qml/UiDesigner.qml index 66446bf..a42ca91 100644 --- a/blockloom-qt/qml/UiDesigner.qml +++ b/blockloom-qt/qml/UiDesigner.qml @@ -24,6 +24,97 @@ Item { } return false; } + // 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 + // 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 - (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 []; + 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() + 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) { + 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(); } + 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 +165,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) || animPreviewing) 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"); } @@ -94,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, @@ -106,6 +198,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; @@ -131,6 +226,7 @@ Item { g.busy = false; if (g.canceled || root.gesture !== g) return; root.document = next; + 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) { @@ -163,6 +259,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; @@ -282,6 +382,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 => { @@ -313,11 +414,12 @@ 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, + animation: animPreviewing ? {name: animPreviewName, time: animPreviewTime} : null, document: copy(document)}}, function() { root.error = ""; }, function(e) { root.error = String(e); }); } function updateSession() { - if (!designing) cancelEdit(); + if (!designing) { cancelEdit(); animPreviewName = ""; } frameLayout = null; if (designing) { if (!embedded && !app.appState.preview_enabled) { @@ -346,6 +448,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() @@ -353,12 +457,65 @@ 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: ({}) + // The backend's property table, in its order: every editable path with label, group and type. + property var propertyInfo: [] + function propertyValue(path) { + let value = widget; + for (const step of path.split(".")) value = value === undefined || value === null ? undefined : value[step]; + return value; + } + // Text shown for a property in the generated form, and its inverse. + function formatProperty(ty, value) { + if (value === undefined || value === null) return ty === "layout" ? "null" : ""; + if (ty === "range") return value[0] + ", " + value[1]; + if (ty === "lines") return value.join("\n"); + if (ty === "layout" || ty === "style" || ty === "bindings" || ty === "actions" || ty === "variants" || ty === "fx") return JSON.stringify(value, null, 2); + return String(value); + } + function parseProperty(ty, text) { + if (ty === "range") { const p = text.split(",").map(t => Number(t.trim())); if (p.length !== 2 || p.some(isNaN)) throw "Range is two numbers, like 0, 100"; return p; } + if (ty === "lines") return text === "" ? [] : text.split("\n"); + if (ty === "int") return text.trim() === "" ? null : Math.round(Number(text)); + if (ty === "number") { const n = Number(text); if (isNaN(n)) throw "Not a number"; return n; } + if (ty === "layout" || ty === "style" || ty === "bindings" || ty === "actions" || ty === "variants" || ty === "fx") return JSON.parse(text); + return text; + } + 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; + root.propertyInfo = list || []; + }); + } 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 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; @@ -375,7 +532,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(); } @@ -390,8 +547,10 @@ Item { if (!widget) return; propertyEdit("element." + field, value); } + 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; } const next = copy(document); next.widgets[selected][field] = value; save(next); } function paint(field, value) { @@ -401,21 +560,292 @@ 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; + 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, + edit: edit, sent: null, busy: false, released: true, canceled: false, select: select, more: more || []}; + 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 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)); + } + // 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 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; + } + 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)); + } + // Animations: edited whole through SetAnimation so every change is one undo step. + readonly property var animatableProps: ["Width","Height","TextSize","CornerRadius","Padding","Value","Opacity","OffsetX","OffsetY","Rotation","Background","TextColor"] + // Scrubbing shows an animation frozen at a time in the runtime preview; nothing is saved. + property string animPreviewName: "" + property real animPreviewTime: 0 + readonly property bool animPreviewing: animPreviewName !== "" && !!(document.animations || {})[animPreviewName] + function previewAnimation(name, time) { + cancelEdit(); + animPreviewName = name; animPreviewTime = Math.max(0, time); + ++revision; frameLayout = null; + if (designing) previewDelay.restart(); + } + function stopAnimationPreview() { if (animPreviewName !== "") previewAnimation("", 0); } + 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 = []; + 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)); + } + // 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, []); + } + 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(); + 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 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); + 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 @@ -444,16 +874,48 @@ 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.selectionIds.indexOf(modelData.id) >= 0 + onClicked: { if (Qt.application.keyboardModifiers & (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 } + 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.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 + 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 { @@ -461,7 +923,23 @@ 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() } + 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() } + } } ColumnLayout { Layout.fillWidth: true; Layout.fillHeight: true @@ -473,22 +951,104 @@ 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: ["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: ["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 } } RowLayout { 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 } + 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 } Label { text: "Hold Shift to bypass snapping" } } 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] : [] + 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 + 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 { @@ -528,7 +1088,33 @@ 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(); + } + 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; + 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(); }); @@ -542,20 +1128,29 @@ 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); } - 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); + 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 @@ -616,23 +1211,80 @@ 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 { + 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 { 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 { + 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 { @@ -653,6 +1305,50 @@ 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: "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, []) } + } + 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)" } @@ -663,15 +1359,205 @@ Item { bindings.push({property:bindingProperty.currentText,source:{Variable:{actor:bindingActor.text,name:bindingName.text}},two_way:bindingWrite.checked}); root.extra("bindings",bindings); } } - Label { text: "Advanced properties"; font.bold: true } Repeater { - model: ["layout","style","bindings"] + 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","Play animation","Stop animation"] } + } + 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 === 1 ? !!actionVariable.text.trim() : !!actionMessage.text.trim()) + onClicked: { + 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(); onCurrentIndexChanged: root.stopAnimationPreview() } + 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 string modelData + required property var modelData + required property int index Layout.fillWidth: true - Label { text: modelData + " (JSON)" } - TextArea { Layout.fillWidth: true; Layout.preferredHeight: 100; wrapMode: TextEdit.Wrap; text: root.widget ? JSON.stringify(root.widget[modelData] || (modelData === "bindings" ? [] : {}),null,2) : ""; - onActiveFocusChanged: if(!activeFocus && root.widget) { try { root.extra(modelData,JSON.parse(text)); } catch(e) { root.error=String(e); } } } + 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) + } + RowLayout { + visible: animationPick.currentIndex >= 0 + Button { + objectName: "interfacePreviewAnimation" + text: root.animPreviewing ? "Stop preview" : "Preview" + enabled: !root.gesture && root.designing + onClicked: root.animPreviewing ? root.stopAnimationPreview() : root.previewAnimation(animationPick.currentText, 0) + } + Slider { + objectName: "interfaceAnimationScrub" + Layout.fillWidth: true + enabled: root.animPreviewing + from: 0 + to: animationPick.currentIndex >= 0 ? Math.max(0.05, ((root.document.animations || {})[animationPick.currentText] || {}).duration || 1) : 1 + value: root.animPreviewTime + onMoved: root.previewAnimation(root.animPreviewName, value) + } + Label { text: root.animPreviewTime.toFixed(2) + "s"; color: Theme.textDim } + } + Label { visible: root.animPreviewing; text: "Previewing: editing is paused and nothing is saved."; color: Theme.textDim; wrapMode: Text.Wrap; Layout.fillWidth: true } + 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 || [] : [] + 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: "All properties"; font.bold: true } + Label { text: "Every property of the selected widget, straight from the backend's table."; color: Theme.textDim; wrapMode: Text.Wrap; Layout.fillWidth: true } + Repeater { + model: root.propertyInfo + delegate: ColumnLayout { + id: field + required property var modelData + readonly property string ty: modelData.ty + // Re-read whenever the widget changes. + readonly property var current: root.widget ? root.propertyValue(modelData.path) : undefined + visible: !!root.widget && root.applies(modelData.path) + Layout.fillWidth: true + Label { text: modelData.group + ": " + modelData.label; color: Theme.textDim } + ComboBox { + objectName: "interfaceField_" + modelData.path + visible: field.ty === "choice"; Layout.fillWidth: true + model: modelData.choices || [] + currentIndex: model.indexOf(field.current) + onActivated: root.propertyEdit(modelData.path, currentText) + } + CheckBox { + objectName: "interfaceField_" + modelData.path + visible: field.ty === "bool" + checked: !!field.current + onToggled: root.propertyEdit(modelData.path, checked) + } + TextArea { + objectName: "interfaceField_" + modelData.path + visible: field.ty !== "choice" && field.ty !== "bool" + Layout.fillWidth: true + Layout.preferredHeight: ["layout","style","bindings","actions","variants","lines","fx"].indexOf(field.ty) >= 0 ? 90 : implicitHeight + wrapMode: TextEdit.Wrap + text: root.formatProperty(field.ty, field.current) + onActiveFocusChanged: if (!activeFocus && root.widget && text !== root.formatProperty(field.ty, field.current)) { + try { root.propertyEdit(modelData.path, root.parseProperty(field.ty, text)); } + catch (e) { root.error = String(e); } + } + } } } Label { text: "Interface assets"; font.bold: true } diff --git a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml index 5c3de6a..1eb127c 100644 --- a/blockloom-qt/tests/qml/tst_InterfaceViewport.qml +++ b/blockloom-qt/tests/qml/tst_InterfaceViewport.qml @@ -24,13 +24,27 @@ 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"); } 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); + 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") { @@ -316,6 +330,270 @@ 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_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_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_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_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_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_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_safe_area_and_reference_overlays_are_editor_only_toggles() { + verify(!panel.showSafeArea && !panel.showReference); + mouseClick(findChild(panel, "interfaceSafeArea")); + verify(panel.showSafeArea); + 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_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_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_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_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_animation_preview_scrubs_in_the_runtime_and_pauses_editing() { + panel.selectedId = "front"; + const d = panel.copy(panel.document); + d.animations = {fade: {duration: 2, looping: false, tracks: []}}; + panel.document = d; backend.appState.project.world.interface = d; + verify(panel.editable(panel.widget)); + compare(panel.animatableProps.indexOf("Opacity") >= 0, true); + compare(panel.animatableProps.indexOf("Rotation") >= 0, true); + panel.previewAnimation("fade", 0.5); + verify(panel.animPreviewing); + verify(!panel.editable(panel.widget)); + panel.requestPreview(); + let design = calls.filter(c => c.command === "preview_interface").pop().args.design; + compare(design.animation, {name: "fade", time: 0.5}); + panel.previewAnimation("fade", 1.5); + panel.requestPreview(); + design = calls.filter(c => c.command === "preview_interface").pop().args.design; + compare(design.animation.time, 1.5); + panel.stopAnimationPreview(); + panel.requestPreview(); + design = calls.filter(c => c.command === "preview_interface").pop().args.design; + compare(design.animation, null); + verify(panel.editable(panel.widget)); + verify(!calls.some(c => c.command === "set_interface" || c.command === "begin_interface_edit")); + } + function test_generated_property_form_formats_and_parses_by_type() { + compare(panel.formatProperty("range", [0, 100]), "0, 100"); + compare(panel.parseProperty("range", "5, 10"), [5, 10]); + compare(panel.formatProperty("lines", ["a", "b"]), "a\nb"); + compare(panel.parseProperty("lines", "a\nb"), ["a", "b"]); + compare(panel.parseProperty("lines", ""), []); + compare(panel.parseProperty("int", " 3 "), 3); + compare(panel.parseProperty("int", ""), null); + compare(panel.formatProperty("layout", null), "null"); + compare(panel.parseProperty("layout", "null"), null); + compare(panel.parseProperty("actions", '[{"on":"Click"}]'), [{on: "Click"}]); + let failed = false; + try { panel.parseProperty("range", "1"); } catch (e) { failed = true; } + verify(failed); + panel.selectedId = "front"; + compare(panel.propertyValue("element.id"), "front"); + panel.propertyEdit("element.content", "Hi"); + const edit = calls.filter(c=>c.command === "update_interface_edit").pop().args.edit; + compare(edit.property, {path: "element.content", value: "Hi"}); + } + 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); + mouseClick(findChild(panel, "interfaceRulers")); + 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)); + 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"; @@ -557,6 +835,40 @@ 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_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/Cargo.toml b/blockloom-runtime/Cargo.toml index f60c76c..7fe1f78 100644 --- a/blockloom-runtime/Cargo.toml +++ b/blockloom-runtime/Cargo.toml @@ -23,6 +23,8 @@ blockloom-plugin-gpu = { path = "../blockloom-plugin-gpu", optional = true, feat tracing-log = { workspace = true } polyanya.workspace = true half = "2" +# The text layout Bevy shapes into: caret and selection geometry. +parley = { version = "0.11", default-features = false, features = ["std"] } # The key set Bevy hands a schedule build pass (see `hdr::ToneInputs`). indexmap = "2" bytemuck = { version = "1.25", features = ["derive"] } diff --git a/blockloom-runtime/src/embed.rs b/blockloom-runtime/src/embed.rs index 3793673..f5f3c72 100644 --- a/blockloom-runtime/src/embed.rs +++ b/blockloom-runtime/src/embed.rs @@ -1869,6 +1869,8 @@ mod tests { vec![EditorMessage::InterfaceDesign { design: Some(blockloom_protocol::InterfaceDesign { screen: None, + hidden: vec![], + animation: None, revision: 1, generation: 1, viewport: None, @@ -1897,6 +1899,95 @@ mod tests { } } + #[test] + #[ignore = "needs Vulkan and dma-buf export"] + fn interface_design_geometry_follows_viewport_dpi_and_safe_area() { + // (scale mode, safe area, viewport in physical pixels, DPI scale) + let cases: [(&str, [f32; 4], [u32; 2], f32); 6] = [ + ("ConstantPixel", [0.; 4], [960, 720], 1.0), + ("ConstantPixel", [0.; 4], [1280, 800], 2.0), + ("ConstantPixel", [40., 20., 0., 60.], [960, 720], 1.0), + ("ScaleWithSize", [0.; 4], [1920, 1440], 1.0), + ("ScaleWithSize", [48., 0., 48., 0.], [1600, 720], 1.0), + ("ScaleWithSize", [0., 30., 0., 30.], [1440, 1080], 1.5), + ]; + for (mode, safe, viewport, dpi) in cases { + let document: blockloom_core::ui::UiDocument = + serde_json::from_value(serde_json::json!({ + "scale": mode, "safe_area": safe, "reference_size": [960, 720], + "widgets": [{ + "element": {"id": "panel", "kind": "Panel", "anchor": "Center", "size": [200, 160]}, + "style": {"normal": {"background": "#ff0000"}} + }] + })) + .unwrap(); + let project = blockloom_core::project::Project::starter( + "Interface", + blockloom_core::scene::Mode::TwoD, + ); + let (set, _, reports) = run_world_reporting( + project, + |exchange| exchange.resize(viewport[0], viewport[1], dpi), + SceneView { + enabled: false, + ..Default::default() + }, + 40, + |_| false, + vec![EditorMessage::InterfaceDesign { + design: Some(blockloom_protocol::InterfaceDesign { + screen: None, + hidden: vec![], + animation: None, + revision: 1, + generation: 1, + viewport: None, + document, + }), + }], + ); + let set = set.expect("interface frame"); + let layout = reports + .iter() + .rev() + .find_map(|r| match r { + RuntimeMessage::InterfaceLayout(l) => Some(l.clone()), + _ => None, + }) + .expect("a layout report"); + let panel = &layout.widgets[0]; + // Logical room inside the safe area, then the canvas scale. + let logical = [set.width as f32 / dpi, set.height as f32 / dpi]; + let room = [ + logical[0] - safe[0] - safe[2], + logical[1] - safe[1] - safe[3], + ]; + let scale = if mode == "ScaleWithSize" { + (room[0] / 960.).min(room[1] / 720.) + } else { + 1.0 + }; + let width = panel.size[0] * panel.transform[0].abs(); + let height = panel.size[1] * panel.transform[3].abs(); + let centre = [panel.transform[4], panel.transform[5]]; + let want_centre = [ + (safe[0] + room[0] / 2.) * dpi, + (safe[1] + room[1] / 2.) * dpi, + ]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5; + assert!( + near(width, 200. * scale * dpi) + && near(height, 160. * scale * dpi) + && near(centre[0], want_centre[0]) + && near(centre[1], want_centre[1]), + "{mode} {safe:?} {viewport:?} x{dpi}: got size {width}x{height} at {centre:?}, \ + want {}x{} at {want_centre:?}", + 200. * scale * dpi, + 160. * scale * dpi + ); + } + } + #[test] fn a_world_waits_for_the_display_but_not_forever() { let exchange = FrameExchange::new(|| {}); diff --git a/blockloom-runtime/src/engine.rs b/blockloom-runtime/src/engine.rs index dcbdbea..eb0889f 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. @@ -826,7 +829,55 @@ 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::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(), + (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 +904,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/input_caret.rs b/blockloom-runtime/src/input_caret.rs new file mode 100644 index 0000000..ca0e720 --- /dev/null +++ b/blockloom-runtime/src/input_caret.rs @@ -0,0 +1,160 @@ +//! The caret, selection and composition underline of a focused text input, +//! drawn over its text from the text layout's own glyph geometry. + +use crate::ui::{UiElementText, UiManager}; +use bevy::prelude::*; +use bevy::text::{ComputedTextBlock, TextColor}; +use bevy::ui::{ComputedNode, UiGlobalTransform}; +use parley::{Affinity, Cursor, Selection}; + +/// One drawn mark; they are children of the text's parent element. +#[derive(Component)] +pub struct InputMark; + +/// Seconds a blink phase lasts. +const BLINK: f64 = 0.53; + +/// The byte offset into the text node's shown text under `point`, in physical +/// screen pixels. None when the node's transform can't be inverted. +pub fn byte_at( + block: &ComputedTextBlock, + node: &ComputedNode, + transform: &UiGlobalTransform, + point: Vec2, +) -> Option { + let local = transform.try_inverse()?.transform_point2(point) + node.size() / 2.0; + Some(Cursor::from_point(block.buffer(), local.x, local.y).index()) +} + +/// A mark in a node's own pixels: `[x0, y0, x1, y1]`. +type Mark = ([f32; 4], Color); + +fn marks_of( + block: &ComputedTextBlock, + shown: &blockloom_core::ui::ShownText, + width: f32, + ink: Color, + selection: Color, + blink_on: bool, +) -> Vec { + let layout = block.buffer(); + let bounds = |b: parley::BoundingBox| [b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32]; + let at = |byte| Cursor::from_byte_index(layout, byte, Affinity::Downstream); + let mut marks = Vec::new(); + if let Some((start, end)) = shown.selection { + for (rect, _) in Selection::new(at(start), at(end)).geometry(layout) { + marks.push((bounds(rect), selection)); + } + } + if let Some((start, end)) = shown.preedit { + for (rect, _) in Selection::new(at(start), at(end)).geometry(layout) { + let b = bounds(rect); + marks.push(([b[0], b[3] - width, b[2], b[3]], ink)); + } + } + if shown.selection.is_none() && blink_on { + marks.push((bounds(at(shown.caret).geometry(layout, width)), ink)); + } + marks +} + +/// Keeps the marks of the focused input in step with its layout. Runs on last +/// frame's layout, so a caret that just moved may trail it by one frame. +pub fn draw_input_caret( + mut commands: Commands, + manager: Res, + time: Res>, + texts: Query<( + &UiElementText, + &ComputedTextBlock, + &ComputedNode, + &UiGlobalTransform, + &TextColor, + &ChildOf, + )>, + parents: Query<(&ComputedNode, &UiGlobalTransform)>, + mut marks: Query<(Entity, &ChildOf, &mut Node, &mut BackgroundColor), With>, + mut last: Local<(usize, f64)>, +) { + let mut wanted: Vec<(Entity, [f32; 4], Color)> = Vec::new(); + let focused = manager + .focus() + .and_then(|id| manager.get(id).map(|node| (id, node))); + if let Some((id, node)) = focused + && let Some(shown) = crate::ui::shown_input(node, true) + && let Some((_, block, text_node, text_at, ink, child_of)) = + texts.iter().find(|(owner, ..)| owner.0 == id) + && let Ok((parent_node, parent_at)) = parents.get(child_of.parent()) + && let Some(into_parent) = parent_at.try_inverse() + { + let now = time.elapsed_secs_f64(); + if last.0 != shown.caret { + *last = (shown.caret, now); + } + let blink_on = (((now - last.1) / BLINK) as u64).is_multiple_of(2); + let scale = 1.0 / text_node.inverse_scale_factor().max(f32::EPSILON); + let accent = crate::ui::accent(manager.theme()).with_alpha(0.45); + let (inset, shrink) = (parent_node.border(), parent_node.inverse_scale_factor()); + for (rect, color) in marks_of( + block, + &shown, + (1.5 * scale).max(1.0), + ink.0, + accent, + blink_on, + ) { + // Text-local pixels to the parent's padding box, in logical units. + let to_parent = |x: f32, y: f32| { + let screen = text_at.transform_point2(Vec2::new(x, y) - text_node.size() / 2.0); + let local = into_parent.transform_point2(screen) + parent_node.size() / 2.0; + (local - inset.min_inset) * shrink + }; + let (min, max) = (to_parent(rect[0], rect[1]), to_parent(rect[2], rect[3])); + wanted.push(( + child_of.parent(), + [ + min.x, + min.y, + (max.x - min.x).max(0.0), + (max.y - min.y).max(0.0), + ], + color, + )); + } + } + let have: Vec<_> = marks.iter().map(|(e, c, ..)| (e, c.parent())).collect(); + let same = + have.len() == wanted.len() && have.iter().zip(&wanted).all(|((_, p), (q, ..))| p == q); + if !same { + for (entity, ..) in &have { + commands.entity(*entity).despawn(); + } + for (parent, [x, y, w, h], color) in wanted { + commands.entity(parent).with_child(( + InputMark, + Node { + position_type: PositionType::Absolute, + left: Val::Px(x), + top: Val::Px(y), + width: Val::Px(w), + height: Val::Px(h), + ..default() + }, + BackgroundColor(color), + )); + } + return; + } + for ((entity, ..), (_, [x, y, w, h], color)) in have.iter().zip(wanted) { + let Ok((_, _, mut node, mut fill)) = marks.get_mut(*entity) else { + continue; + }; + let (left, top, width, height) = (Val::Px(x), Val::Px(y), Val::Px(w), Val::Px(h)); + if node.left != left || node.top != top || node.width != width || node.height != height { + (node.left, node.top, node.width, node.height) = (left, top, width, height); + } + if fill.0 != color { + fill.0 = color; + } + } +} diff --git a/blockloom-runtime/src/lib.rs b/blockloom-runtime/src/lib.rs index dd8d7fc..a8f8baa 100644 --- a/blockloom-runtime/src/lib.rs +++ b/blockloom-runtime/src/lib.rs @@ -56,6 +56,7 @@ mod fx; mod gpu; mod hdr; mod indirect; +mod input_caret; mod lan; mod light2d; mod light_probes; @@ -623,7 +624,10 @@ 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, + overlay::apply_fx_rotation, + input_caret::draw_input_caret, ui_systems::animate, ui_systems::atlas, ) @@ -631,6 +635,7 @@ pub(crate) fn add_world(app: &mut App, mode: Mode, mut engine: engine::Engine) { world::type_into_focused_input, world::scroll_ui_lists, world::detect_clicks, + world::drag_input_selection, world::publish_sensors, world::step_scripts_frame_ui, sound::maintain_voices, diff --git a/blockloom-runtime/src/overlay.rs b/blockloom-runtime/src/overlay.rs index 17b423b..b00f498 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; @@ -448,11 +450,17 @@ pub fn draw_ui( } let paint = manager.paint(element); let local_theme = element.theme.unwrap_or(theme); - let target = paint - .background - .as_ref() - .map(|hex| world::parse_color(hex)) - .unwrap_or_else(|| crate::ui::background_for(local_theme, element.kind, id == focused)); + let fade = manager.opacity_of(&id); + let target = faded( + paint + .background + .as_ref() + .map(|hex| world::parse_color(hex)) + .unwrap_or_else(|| { + crate::ui::background_for(local_theme, element.kind, id == focused) + }), + fade, + ); if element.transition > 0. { commands .entity(element.entity) @@ -473,20 +481,23 @@ pub fn draw_ui( paint.radius.unwrap_or_else(crate::ui::corner_radius), )); node.border = UiRect::all(Val::Px(paint.border_width.unwrap_or(0.).max(0.))); - commands.entity(element.entity).insert(BorderColor::all( - paint - .border_color - .as_ref() - .map(|hex| world::parse_color(hex)) - .unwrap_or(Color::NONE), - )); + commands + .entity(element.entity) + .insert(BorderColor::all(faded( + paint + .border_color + .as_ref() + .map(|hex| world::parse_color(hex)) + .unwrap_or(Color::NONE), + fade, + ))); commands.entity(element.entity).insert(BoxShadow( paint .shadow .as_ref() .map(|hex| { vec![ShadowStyle { - color: world::parse_color(hex), + color: faded(world::parse_color(hex), fade), x_offset: Val::Px(2.), y_offset: Val::Px(3.), blur_radius: Val::Px(8.), @@ -496,23 +507,27 @@ pub fn draw_ui( .unwrap_or_default(), )); if element.parent.is_empty() || canvas { - let transform = if element.layout.as_ref().is_some_and(|l| l.absolute) { + let mut transform = if element.layout.as_ref().is_some_and(|l| l.absolute) { UiTransform::default() } else { let at = crate::ui::anchoring(element.spec.anchor, element.spec.offset); UiTransform::from_translation(at.self_shift) }; + transform.rotation = Rot2::degrees(element.fx.rotation); 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( + let mut image = image_node( + assets.load(world::asset_path( dir.as_deref(), element.spec.content.trim(), - )))); + )), + &paint, + ); + image.color = faded(image.color, fade); + commands.entity(element.entity).insert(image); } - 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; @@ -558,7 +573,7 @@ pub fn draw_ui( }, ..font.clone() }, - TextColor( + TextColor(faded( run.color .as_ref() .map(|c| world::parse_color(c)) @@ -569,17 +584,21 @@ pub fn draw_ui( .map(|c| world::parse_color(c)) .unwrap_or_else(|| crate::ui::text_color(local_theme)) }), - ), + fade, + )), )); } }); } else if text.0 != wanted { text.0.clone_from(&wanted); } - *color = TextColor(match &paint.text_color { - Some(hex) => world::parse_color(hex), - None => crate::ui::text_color(local_theme), - }); + *color = TextColor(faded( + match &paint.text_color { + Some(hex) => world::parse_color(hex), + None => crate::ui::text_color(local_theme), + }, + fade, + )); } // A slider's fill and a toggle's lamp are the only two whose look is // their value rather than their style. @@ -601,7 +620,7 @@ pub fn draw_ui( let on = element.value.as_bool(); for (owner, mut lamp) in &mut lamps { if owner.0 == id { - *lamp = BackgroundColor(toggle_lamp_color(local_theme, &paint, on)); + *lamp = BackgroundColor(faded(toggle_lamp_color(local_theme, &paint, on), fade)); } } } @@ -610,6 +629,29 @@ pub fn draw_ui( } } +/// `color` with its alpha scaled by an element's effective opacity. +fn faded(color: Color, opacity: f32) -> Color { + if opacity >= 1.0 { + return color; + } + color.with_alpha(color.alpha() * opacity) +} + +/// Turns elements whose rotation was written, about their centres. The +/// anchor's shift lives in the same transform's translation and stays. +pub fn apply_fx_rotation(mut manager: ResMut, mut transforms: Query<&mut UiTransform>) { + for (id, entity, degrees) in manager.fx_pending() { + // A spawn is deferred, so the transform may not exist for a frame. + if let Ok(mut transform) = transforms.get_mut(entity) { + let rotation = Rot2::degrees(degrees); + if transform.rotation != rotation { + transform.rotation = rotation; + } + manager.fx_done(&id); + } + } +} + /// Where along its own ends a slider currently stands, as a fraction. fn slider_fraction(node: &crate::ui::UiNode) -> f32 { let [low, high] = node.range; @@ -688,7 +730,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 +843,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/blockloom-runtime/src/quality.rs b/blockloom-runtime/src/quality.rs index 8087363..27d27fd 100644 --- a/blockloom-runtime/src/quality.rs +++ b/blockloom-runtime/src/quality.rs @@ -945,6 +945,8 @@ mod tests { .apply( Some(blockloom_protocol::InterfaceDesign { screen: None, + hidden: vec![], + animation: None, viewport: None, revision: 1, generation: 1, diff --git a/blockloom-runtime/src/ui.rs b/blockloom-runtime/src/ui.rs index 162b17b..8ba5f70 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, UiFx, UiKind, UiLayout, + UiPaint, UiProp, UiStyles, UiTheme, UiWidget, }; use blockloom_core::value::Evaluated; use std::collections::HashMap; @@ -118,6 +118,17 @@ 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, + /// The responsive variant of its widget in force, by index. + pub variant: Option, + /// Opacity, offset and rotation as authored, then as animations and + /// blocks have moved them. Visual only. + pub fx: UiFx, + /// The rotation still has to reach the entity's transform. + pub fx_dirty: bool, } impl UiNode { @@ -155,6 +166,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, @@ -176,6 +189,116 @@ 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; + } + } + + /// 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() + } + + /// Writes what an animation's tracks hold at `time` (wrapped when it + /// loops, held at the end otherwise), through the same `set` blocks use. + pub fn sample_animation(&mut self, name: &str, time: f32) { + let Some(animation) = self.document.animations.get(name) else { + return; + }; + let at = Self::position_in(animation, time).0; + let writes = Self::sample(animation, at); + for (id, prop, value) in writes { + self.set(&id, prop, &value); + } + } + + /// Where `time` falls in an animation, and whether it is still running. + fn position_in(animation: &blockloom_core::ui::UiAnimation, time: f32) -> (f32, bool) { + if animation.looping { + (time % animation.duration, true) + } else if time >= animation.duration { + (animation.duration, false) + } else { + (time, true) + } + } + + fn sample( + animation: &blockloom_core::ui::UiAnimation, + at: f32, + ) -> Vec<(String, UiProp, Evaluated)> { + use blockloom_core::ui::UiKeyValue; + animation + .tracks + .iter() + .filter_map(|track| { + let value = match track.sample(at)? { + UiKeyValue::Number(n) => Evaluated::Number(n), + UiKeyValue::Color(c) => Evaluated::Text(c), + }; + Some((track.widget.clone(), track.property, value)) + }) + .collect() + } + + /// 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) { + 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 (at, running) = Self::position_in(animation, *time); + writes.extend(Self::sample(animation, at)); + 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(); @@ -202,6 +325,8 @@ impl UiManager { n.scroll_target = widget.scroll_target; n.tab_index = widget.tab_index; n.transition = widget.transition.max(0.); + n.fx = widget.fx; + n.fx_dirty = true; } } pub fn bubble(&self, id: &str) -> Vec { @@ -366,6 +491,11 @@ impl UiManager { collection_range: None, collection_dirty: true, expanded: false, + caret: TextCaret::default(), + preedit: String::new(), + variant: None, + fx: UiFx::default(), + fx_dirty: true, }, ); self.pending.push(id); @@ -645,10 +775,52 @@ impl UiManager { UiProp::Height => node.style.height = Some(number(value)), UiProp::CornerRadius => node.style.corner_radius = Some(number(value).max(0.0)), UiProp::Padding => node.style.padding = Some(number(value).max(0.0)), + UiProp::Opacity => node.fx.opacity = number(value).clamp(0., 1.), + UiProp::OffsetX => node.fx.offset[0] = number(value), + UiProp::OffsetY => node.fx.offset[1] = number(value), + UiProp::Rotation => { + node.fx.rotation = number(value); + node.fx_dirty = true; + } + } + // A fade is inherited, so the children repaint with it. + if prop == UiProp::Opacity { + for child in self.subtree(id).into_iter().skip(1) { + if let Some(node) = self.nodes.get_mut(&child) { + node.dirty = true; + } + } } true } + /// An element's own opacity times each ancestor's. + pub fn opacity_of(&self, id: &str) -> f32 { + let mut opacity = 1.0; + let mut current = self.nodes.get(id); + for _ in 0..self.order.len() { + let Some(node) = current else { break }; + opacity *= node.fx.opacity; + current = self.nodes.get(&node.parent); + } + opacity + } + + /// Drawn elements whose rotation still has to reach their transform. + pub fn fx_pending(&self) -> Vec<(String, Entity, f32)> { + self.nodes + .iter() + .filter(|(_, n)| n.fx_dirty && n.entity != Entity::PLACEHOLDER) + .map(|(id, n)| (id.clone(), n.entity, n.fx.rotation)) + .collect() + } + + pub fn fx_done(&mut self, id: &str) { + if let Some(node) = self.nodes.get_mut(id) { + node.fx_dirty = false; + } + } + /// Records what a person just did to an input, and answers what it is /// now - which is what the `changed` event carries. pub fn changed(&mut self, id: &str, value: Evaluated) -> Option { @@ -683,6 +855,24 @@ impl UiManager { /// Gives the keyboard to a text input, or takes it back. Anything that /// isn't one drops focus instead, so clicking away releases the keys. + /// Puts the caret of input `id` at a byte offset of the text it shows, + /// extending the selection from where it stood when `extend`. A held + /// composition keeps the caret where it is. + pub fn place_caret(&mut self, id: &str, byte: usize, extend: bool) { + let Some(node) = self.nodes.get_mut(id) else { + return; + }; + if node.kind != UiKind::Input || !node.preedit.is_empty() { + return; + } + let text = node.value.as_text(); + let at = blockloom_core::ui::char_at_byte(&text, byte).min(text.chars().count()); + if node.caret.at != at || (!extend && node.caret.anchor.is_some()) { + node.caret.go(at, extend); + node.dirty = true; + } + } + pub fn focus_on(&mut self, id: Option<&str>) { let next = match id { Some(id) @@ -703,6 +893,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 +1228,18 @@ pub fn text_of(node: &UiNode) -> String { } } +/// What an element draws as its text. An input that holds the keyboard shows +/// its composition spliced in; the caret and selection are drawn over it. +pub fn display_text(node: &UiNode, focused: bool) -> String { + shown_input(node, focused).map_or_else(|| text_of(node), |shown| shown.text) +} + +/// The caret, selection and composition marks of a focused input. +pub fn shown_input(node: &UiNode, focused: bool) -> Option { + (focused && node.kind == UiKind::Input) + .then(|| node.caret.compose(&node.value.as_text(), &node.preedit)) +} + 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); @@ -1093,6 +1300,23 @@ pub fn layout_node(element: &UiNode, canvas: bool) -> Node { if let Some(padding) = element.style.padding { node.padding = UiRect::all(Val::Px(padding)); } + // The visual shift rides on the same insets layout already sets, so + // the anchor's own transform is left alone. + let [dx, dy] = element.fx.offset; + if dx != 0. || dy != 0. { + if node.position_type == PositionType::Absolute { + if let (Val::Px(x), Val::Px(y)) = (node.left, node.top) { + node.left = Val::Px(x + dx); + node.top = Val::Px(y + dy); + } else if let (Val::Px(x), Val::Px(y)) = (node.margin.left, node.margin.top) { + node.margin.left = Val::Px(x + dx); + node.margin.top = Val::Px(y + dy); + } + } else { + node.left = Val::Px(dx); + node.top = Val::Px(dy); + } + } if !element.visible || !element.projected || !element.tab_active { node.display = Display::None; } @@ -1501,4 +1725,142 @@ 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); + } + #[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()); + } + #[test] + fn fx_shifts_without_touching_the_anchor_and_fades_down_the_tree() { + let document: UiDocument = serde_json::from_value(serde_json::json!({ + "widgets": [ + {"element": {"id": "hud", "kind": "Panel", "anchor": "TopRight", "offset": [-20, 15]}, + "fx": {"opacity": 0.5, "offset": [4, 6], "rotation": 30}}, + {"element": {"id": "row", "kind": "Label", "parent": "hud"}}, + {"element": {"id": "free", "kind": "Panel", "offset": [10, 10]}, + "layout": {"absolute": true}, "fx": {"offset": [1, 2]}} + ], + "animations": { + "fade": {"duration": 1.0, "tracks": [{"widget": "hud", "property": "Opacity", + "keys": [{"time": 0.0, "value": 1.0}, {"time": 1.0, "value": 0.0}]}, + {"widget": "hud", "property": "Rotation", + "keys": [{"time": 0.0, "value": 0.0}, {"time": 1.0, "value": 90.0}]}]} + } + })) + .unwrap(); + let mut manager = UiManager::default(); + manager.load(&document); + // An anchored element moves through its margin, so the self shift stays. + let hud = layout_node(manager.get("hud").unwrap(), false); + assert_eq!(hud.left, Val::Percent(100.)); + assert_eq!(hud.margin.left, Val::Px(-16.)); + assert_eq!(hud.margin.top, Val::Px(21.)); + // An absolute one through its insets; a flow child gets relative ones. + let free = layout_node(manager.get("free").unwrap(), false); + assert_eq!((free.left, free.top), (Val::Px(11.), Val::Px(12.))); + let row = layout_node(manager.get("row").unwrap(), false); + assert_eq!((row.left, row.top), (Val::Auto, Val::Auto)); + // Opacity multiplies down the tree. + assert_eq!(manager.opacity_of("row"), 0.5); + manager.play_animation("fade"); + manager.step_animations(0.75); + assert_eq!(manager.opacity_of("row"), 0.25); + assert_eq!(manager.get("hud").unwrap().fx.rotation, 67.5); + // A fade repaints the children, and rotation waits for a drawn entity. + manager.get_mut("row").unwrap().dirty = false; + manager.set("hud", UiProp::Opacity, &Evaluated::Number(0.25)); + assert!(manager.get("row").unwrap().dirty); + assert!(manager.fx_pending().is_empty()); + drawn(&mut manager); + assert!( + manager + .fx_pending() + .iter() + .any(|(id, _, deg)| id == "hud" && *deg == 67.5) + ); + manager.fx_done("hud"); + assert!(manager.fx_pending().iter().all(|(id, ..)| id != "hud")); + } + + #[test] + fn a_click_puts_the_caret_on_a_character_and_a_composition_holds_it() { + let mut manager = UiManager::default(); + manager.show(UiElement { + id: "name".into(), + kind: UiKind::Input, + value: Evaluated::Text("héllo".into()), + ..Default::default() + }); + drawn(&mut manager); + manager.focus_on(Some("name")); + // Byte 3 is the start of the first l; a drag then extends from there. + manager.place_caret("name", 3, false); + assert_eq!(manager.get("name").unwrap().caret.at, 2); + manager.place_caret("name", 6, true); + assert_eq!(manager.get("name").unwrap().caret.selection(), Some((2, 5))); + manager.place_caret("name", 0, false); + assert_eq!(manager.get("name").unwrap().caret.selection(), None); + manager.get_mut("name").unwrap().preedit = "ni".into(); + manager.place_caret("name", 6, false); + assert_eq!(manager.get("name").unwrap().caret.at, 0); + } } diff --git a/blockloom-runtime/src/ui_design.rs b/blockloom-runtime/src/ui_design.rs index f79c299..e5e779c 100644 --- a/blockloom-runtime/src/ui_design.rs +++ b/blockloom-runtime/src/ui_design.rs @@ -59,6 +59,16 @@ impl DesignSession { } } } + for id in &request.hidden { + manager.set( + id, + blockloom_core::ui::UiProp::Visible, + &blockloom_core::value::Evaluated::Bool(false), + ); + } + if let Some(preview) = &request.animation { + manager.sample_animation(&preview.name, preview.time); + } self.request = Some(request); self.last = None; Ok(()) @@ -172,6 +182,8 @@ mod tests { fn fixture(revision: u64) -> InterfaceDesign { InterfaceDesign { screen: None, + hidden: vec![], + animation: None, viewport: Some([960, 720]), revision, generation: 1, @@ -258,6 +270,76 @@ 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] + fn design_previews_an_animation_frozen_at_a_time() { + use blockloom_core::ui::{UiAnimation, UiEase, UiKey, UiKeyValue, UiProp, UiTrack}; + let (_sender, receiver) = std::sync::mpsc::channel(); + let engine = Engine::new(receiver, Mode::TwoD); + let mut manager = UiManager::default(); + let mut session = DesignSession::default(); + let mut design = fixture(1); + let key = |time, value| UiKey { + time, + value: UiKeyValue::Number(value), + ease: UiEase::Linear, + }; + design.document.animations.insert( + "fade".into(), + UiAnimation { + duration: 2., + looping: false, + tracks: vec![UiTrack { + widget: "image".into(), + property: UiProp::Opacity, + keys: vec![key(0., 1.), key(2., 0.)], + }], + }, + ); + design.animation = Some(blockloom_protocol::InterfaceAnimationPreview { + name: "fade".into(), + time: 0.5, + }); + session + .apply(Some(design.clone()), &engine, &mut manager) + .unwrap(); + assert_eq!(manager.get("image").unwrap().fx.opacity, 0.75); + let saved = design.document.clone(); + assert_eq!(manager.document, saved); + // Past the end it holds the last key; an unknown name or a bad time refuses. + design.revision = 2; + design.animation.as_mut().unwrap().time = 9.; + session + .apply(Some(design.clone()), &engine, &mut manager) + .unwrap(); + assert_eq!(manager.get("image").unwrap().fx.opacity, 0.); + for bad in [("missing", 1.), ("fade", -1.), ("fade", f32::NAN)] { + design.revision += 1; + design.animation = Some(blockloom_protocol::InterfaceAnimationPreview { + name: bad.0.into(), + time: bad.1, + }); + assert!( + session + .apply(Some(design.clone()), &engine, &mut manager) + .is_err() + ); + } } #[test] diff --git a/blockloom-runtime/src/ui_systems.rs b/blockloom-runtime/src/ui_systems.rs index 5a1df3c..7c4c339 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, @@ -558,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