GG-UI — Interaction
← GG-UI Design | UI Core | Rendering | Home
Input routing, interaction state, focus, value widgets, popups, text editing, localisation, input actions, drag and drop, the console, and the inspector.
The Path of an Event
Sentinel (1000 Hz) UI update (before the frame is drawn) Encoder ────────────────── ───────────────────────────────────── ─────── PlatformEvent │ ▼ UI router, per window: latest published snapshot ─ HitTest decide: the UI takes it, or the game │ │ │ └──► InputBuffer (sim) ▼ event queue of that document ────────► drain: update hover / pressed / focus, (lock-free) run handlers, move scroll offsets re-run, bindings, layout, paint, publish ──► draw
- Sentinel decides who gets an event; it never runs a handler. The decision uses the last published snapshot, which is what the user is looking at. It costs a hit test and never waits.
- An event takes effect in the next frame drawn. The UI update drains the queue before it re-runs, lays out, and paints.
- What the UI takes, the game never sees. The decision is made before anything reaches
InputBuffer, so a click on a button can't also fire a weapon, and the sim's input stream stays a pure record of game input.
Who Gets It
| Event | The UI takes it when |
|---|---|
| Pointer press | The topmost node under it that blocks the pointer belongs to a document |
| Pointer move, release | The UI holds the capture from a press it took, or the pointer is over a blocking node |
| Wheel | The pointer is over a blocking node |
| Key, text | The document has keyboard focus; or the key is Tab and something is focusable; or it is a gamepad button |
A node blocks the pointer if it has a reaction, is a value widget, scrolls, has a style that fills it, or says so (ui.BlocksPointer()). A plain layout container doesn't, so the empty part of a full-window HUD row lets clicks through to the scene. Text doesn't block: a click on a label in a row is a click on the row.
A press on the scene while the UI has focus still goes to the game, and is also passed to the document so focus returns to the game.
Pointer Mode
| Mode | Cursor | Pointer goes to |
|---|---|---|
UI | Shown | Documents first. The game gets the presses they don't take, and motion only while one of those is held |
Game | Hidden, relative | The game only |
The game sets the mode at any time from any thread (TrinyxEngine::). The engine never switches it. A click doesn't capture the mouse and Escape doesn't quit a windowed build.
Developer keys (each can be turned on or off in the project INI):
| Key | Does |
|---|---|
| Shift+F1 | Releases the pointer over whatever mode the game set, and gives it back (DevPointerKey, off by default) |
| Shift+F2 | Opens the inspector (DevInspectorKey, --inspect) |
| Shift+F3 | Opens the console (DevConsoleKey, --console) |
Windows other than the game's are tools: their documents have the pointer whatever mode the game asked for.
Dispatch
| UI event | Sent to |
|---|---|
PointerEnter, PointerLeave | Each node the pointer starts or stops being inside |
Press, Release | The node the press landed on; it has the capture until release |
Click | The pressed node, if the release is over it or a descendant. Also from accept on the focused node |
Drag | The node with the capture, on each move |
Wheel | The innermost scrolling node that can still scroll that way; the remainder chains outward |
Key, Text | The focused node |
- An event goes to its target, then up through its ancestors until one handles it. A handler that wants it to continue calls
event.Pass(). There is no capture phase; a scope that must see keys first is a focus scope. - Every event is re-targeted against the tree as it is now. If a handler removed the node, the release that follows goes nowhere.
- Handlers run on the thread updating the document. A handler may change UI state, bump a
GGRevision, write an atomic, or queue a command. It may not touch the World. - Moves arriving between two updates collapse into one, and give way in the queue to presses and releases when Encoder is behind.
Handlers on Elements
Declared inside the element, like its other properties. An element stops reacting when its content stops declaring the handler.
if (auto row = ui.Row({ .Gap = 8, .Class = "scenario" })) { ui.OnClick([scenario] { Demo::Select(scenario); }); ui.OnHover([scenario](bool bInside) { Demo::Preview(scenario, bInside); }); ui.Tooltip([scenario](UI& ui) { ui.Label(DescriptionOf(scenario), { .bWrap = true }); }); ui.Disabled(!Demo::IsConnected()); ui.Label(NameOf(scenario)); }
OnClick, OnPress, OnRelease, OnDrag, OnHover, OnMove, OnWheel, OnKey, OnFocus, BlocksPointer, Cursor, Focusable, Disabled. A handler takes the event, or nothing. OnMove hears the pointer move over the element while no button is held (a gizmo lighting the handle under it); once a button is down, what it pressed hears OnDrag instead. An OnRelease handler calls event.Used() when the press did something else, such as flying a camera with the keys while the right button was held: no click and no context menu follow it. A right press released more than a few units from where it went down opens no context menu either. OnWheel hears the wheel turn over the element (WheelX, WheelY in notches, Y away from the user positive), before any box it is in scrolls: a view that zooms takes it, and calls event.Pass() to let the boxes around it scroll instead.
Rejected: handlers in the props struct (every props struct grows a dozen fields and multi-line lambdas format badly in designated initialisers), and a wrapper container per handler (a node and a nesting level each).
In the editor every key goes to the window's document, focused or not (UIHost::): there is no game behind it to give them to.
Not yet: Disabled driven by a binding; an element with OnClick becoming focusable without saying so.
Interaction State
| Bit | Set while |
|---|---|
Hovered | The pointer is inside it |
Pressed | A press that landed on it is held |
Focused | It has keyboard focus |
FocusVisible | It has focus and focus was moved by keys or gamepad. A mouse click shows no ring |
Disabled | It or an ancestor is disabled. Takes no events and no focus, but still blocks the pointer |
Checked | A toggle that is on; a selected row |
Drop | A drop target under a compatible drag |
State is paint-only: a change repaints that node and lays nothing out. A node is repainted only if the state change gives it a different style. A pointer that moves without crossing an edge publishes nothing.
The cursor is what the innermost hovered node that sets one asks for. Anything clickable shows the hand.
Focus
- One focused node per document. Buttons and value widgets are focusable; any element is with
ui.Focusable(). Nothing disabled or hidden is. - Tab order is document order, wrapping at the ends.
- Arrows go to the nearest focusable element in that direction. One in line with the current element always beats one off to the side, so a row or column is walked straight. Nothing that way: focus stays.
- Order of a key: the focused element's
OnKeyand its ancestors'; the widget's own use of it; a command bound to the chord; then navigation and activation. - Whose keys they are is decided on Sentinel from the snapshot. Tab brings the keyboard to a document; from then every key is the UI's until Escape or a press elsewhere. A control that was clicked has focus but not the keyboard: the game's Space stays the game's, and Tab then continues from the clicked control.
- Escape goes out one step: from a control to its owner if that is focusable (a cell editor to its row), and from there out of the UI.
- Scopes:
ui.FocusScope(FocusScopeTrap | FocusScopeRestore)keeps navigation inside a container and returns focus to where it was when the focused node goes away. A popup is a scope. - Focus moved by keys scrolls every enclosing scrolling box by the minimum that shows the element.
- Gamepad: the host maps buttons to the same keys (d-pad to arrows, bottom face button to accept, right to cancel, shoulders to Tab and Shift+Tab). The core has no gamepad type. A gamepad's first button brings focus.
- A tool can ask where keys go without moving focus:
FocusOrder(node, out),FocusToward(node, key).
Not yet: gamepad sticks and held-button repeat; the game isn't given gamepad input, so in Game pointer mode a gamepad does nothing; keys for documents in windows other than the main one.
To Be Made Configurable
| What | Fixed today as |
|---|---|
| Where an arrow takes focus | Nearest element that way. A game needs to name a neighbour for one edge, or supply the rule for a container |
| Whether focus wraps | Tab wraps; arrows stop |
| What brings the keyboard to the UI | Tab, or a gamepad's first button, whatever UI.Next is bound to. The choice is made before a document sees the key |
| What widgets do with keys | Left / right step by one, page keys by ten |
| How far a wheel notch scrolls | 48 units |
These belong in one input-settings block a game fills in and changes at run time. The core knows only UIKey, so the mapping is the host's to make configurable.
Value Widgets
A value widget shows a value and lets the user change it. What it shows follows data without a function re-running; a change by the user is an event. A widget never writes what it was bound to: it reports the change and the owner decides.
The widget shows its own value while the user holds it, and after release until the bound value next moves. A slider doesn't fight a value that arrives a frame later, or over a network a hundred milliseconds later.
Every value widget is Widget(props, shows, does):
// Shows what is really being injected (a binding); the change is a call. ui.Toggle("Slow Brain", GGBind(Engine, load > 0.0f), [](bool bOn) { Demo::SlowBrain(bOn); }); ui.Slider({ .Min = 0, .Max = 8, .Step = 0.25f }, GGBind(Engine, load), [](float ms) { Demo::SetBrainDelay(ms); }); // UI-only state: the widget writes it, no handler. auto bAdvanced = ui.State<bool>(false); ui.Toggle("Advanced", bAdvanced);
shows (GGShows<T>) | does (GGDoes<T>) |
|---|---|
| A plain value | A lambda: called on every change |
A GGBinding | OnCommit(lambda): called once, on release |
A ui.State<T> (writes itself; does is optional) | GGEdits<T>{ .Begin, .Change, .Commit, .Cancel }: all four phases |
An Editable<T> (GGEdit(reads, writes)) |
// The editor: an undo transaction around a drag. ui.Slider({ .Min = 0, .Max = 100 }, health, GGEdits<float>{ .Begin = [=] { BeginTransaction("Health"); }, .Change = [=](float value) { PreviewHealth(entity, value); }, .Commit = [=](float value) { CommitHealth(entity, value); }, .Cancel = [=] { CancelTransaction(); } });
- Edits have phases. A drag is one edit made of many changes. Escape during a drag cancels and restores the starting value; so does the pointer being taken away. A change is clamped and stepped, and reported only when the value differs.
- Tests and tools act as a user through the same calls:
BeginEdit,ChangeEdit,CommitEdit,CancelEdit,StepEdit. - Left / right step a number or slider, page keys by ten, home / end go to the limits.
Rejected: a widget that returns the change (if (ui.Toggle(...))). It needs the function to run every frame, which gives up idle-costs-nothing. Also one lambda told the phase: the usual bug is forgetting to check it and sending a command per mouse move.
The widgets themselves are listed in Core.
Scrolling
- A wheel turn goes to the innermost scrolling box under the pointer, which takes what it can still move; the rest chains outward. A list at its end scrolls the page. One notch is 48 units. With shift, a vertical-only wheel scrolls horizontally.
- A scrolling box blocks the pointer, so the wheel over it is the UI's.
- Scroll bars are painted from the snapshot's list of scrolling boxes, after every node, into the overlay. They cost no nodes. They drag, and a press on the track jumps the thumb there. A widget that reports its own position (a virtualised list) is told where its bar was dragged.
- Scrolling lays nothing out. A turn that can move nothing publishes nothing.
Not yet: eased wheel scrolling; arrow and page keys scrolling a box that has focus inside it; a scroll chain stopping at a popup; bars are drawn over everything, so a layer above a scrolling box is under its bar.
Popups
A popup is its own document, owned by the document it belongs to. Its content is built when it opens and destroyed when it closes.
ui.Tooltip("Rebuilds the pyramid"); // inside an element, or straight after a leaf widget ui.Menu([=](UI& ui) { ui.MenuItem("Copy", [] { ... }); ui.Separator(); ui.SubMenu("More", ...); }); ui.ContextMenu(content); ui.Popup(open, { .Side = Below }, content); // open while the GGState<bool> is true
- Input: a menu or
Popuphas the pointer and the keys. A press outside closes it and does nothing else; Escape closes it; focus returns to what opened it. Opened by keys, its first item has focus. A tooltip takes nothing and doesn't block the pointer. - Menus:
MenuItem(shortcut text, checked, disabled, keep-open),Separator,SubMenuto any depth. Choosing an item closes every menu. Submenus open on click, right arrow, or a quarter-second hover. - Placement: on the side asked for, aligned to the anchor's start, centre, or end; flipped when there is no room; always kept on the surface. A menu taller than its window scrolls.
- Tooltips show after the pointer rests for half a second (
SetTooltipDelay), or at once on keyboard focus. A long one wraps at 320 units. - Layer or window. Everything is drawn as a layer in its owner's window unless it asks otherwise. Menus, dropdowns, context menus, and tooltips always are, so they behave the same windowed, fullscreen, and on a console. Only
ui.Popupcan ask for its own OS window (PopupProps::bOwnWindow), and the host may refuse. - In its own window it is the same document, still built and published by its owner. The host (
UIHost::) opens a platform popup window beside the anchor, routes that window's pointer to it, and closes it with the popup. It is placed within the screen area the host reports (PopupWindowSink UIContext::SetPopupRoom). - When the application loses focus, every popup closes. Dialogs don't (Core).
Not yet: popup window placement assumes the main window's pixel density and only measures the main window's screen; type-ahead in a menu; the inspector doesn't show a document's popups in its tree.
Text
Buffer
TextBuffer: UTF-8 as a piece table with a line index.
- Anchors are positions kept across every edit, with the side they lean towards. Carets, selections, and composition ranges are anchors, so they survive edits made by anyone.
- Edits are transactions: a list of replacements whose positions all refer to the text before any of them, applied and undone together. Overlapping replacements are refused whole.
- Undo per buffer, with a limit. Typing merges with the typing before it up to the end of a word and never across a line. A run of backspaces is one step.
LastChangesays which lines an edit touched;Changedis aGGRevision.- Tested with four thousand random transactions against a plain string: text, every anchor, and every line start agree throughout, and undoing everything returns the first text byte for byte.
A piece table rather than a rope: simpler, append-mostly edits are its best case, and anchors need only piece identity.
Not yet: pieces and line starts are flat arrays, so an edit costs in proportion to what follows it. Fine for fields and consoles; it becomes a tree before the code editor opens large files. Undo doesn't restore the selection. A buffer that belongs to a project asset doesn't join the editor's undo history.
Widgets
ui.TextField(props, shows, does); // one line ui.TextArea(props, shows, does); // several ui.Label(text, { .bSelectable = true }); // read, select, copy
Shows is text (the field keeps a copy and takes new text when what it is given changes) or a TextBuffer the author owns. Does is a lambda taking std::string_view, OnCommit(...), or GGEdits<std::string_view>.
TextEditStateis the caret and selection in a buffer, usable without a widget. Movement by character as a reader sees one (a letter with its accents, a joined emoji, a flag, CRLF), by word, by line; rules for single line, maximum length, digits, a number, read only.- Drawing: the widget breaks its own lines and hands the text node the result. The caret, selection, and composition underline are published as marks and drawn over the text; only the focused widget has any. A field scrolls its text to keep the caret in view; an area scrolls like any box.
- Keys: arrows (ctrl: by word), home / end (ctrl: the document), shift to select, ctrl+A / C / X / V / Z / Y, backspace and delete (ctrl: a word), Enter (commit in a field, a new line in an area), Escape, Tab. A field focused by Tab selects all its text.
- Pointer: click places the caret, shift+click and drag select, double click selects a word, triple a line.
- Text input reaches a document as
Text,Compose, andPaste. The snapshot says when the focused node takes typing; the host starts and stops platform text input from that and gives it the caret rect for the IME window. Composed text is underlined and isn't the user's until committed: undo never sees it. - Password fields draw a dot per character and never copy or cut.
Not yet: styled spans in an editable buffer (syntax colour, links); caret placement inside right-to-left text is wrong (it measures the text before the caret); an area shapes all its text on every change; "a character as a reader sees one" is a short rule set, not the full Unicode tables; dragging after a double click doesn't select by word; no right-click context menu. Nobody has typed into a field with a real input method yet.
Localisation
UIStrings.h. Text passed to a widget is a lookup key: either the source-language text itself, or a string ID.
ui.Label("Slow Brain"); // looked up; shown as written if untranslated ui.Label("UI.KeyBinding.Prompt"); // an ID: resolved through the default table ui.Label(GGText("{count} entities", { { "count", n } })); // named placeholders, so translations can reorder ui.Label(GGText("{gold} gold", { { "gold", GGBind(player, gold) } })); // one placeholder may be bound ui.Label(GGText::Verbatim(playerName)); // data: never looked up
- Lookup order: the active table (
UIContext::SetStrings,UIHost::), thenSetStrings GGStringTable::Defaults(), then the key as written. - IDs survive rewording. The library's own strings are all IDs (
UI.Accept.Title,UI.Property.Mixed,UI.List.Count) with English text inDefaults(). A game adds its own at startup. Plain text is quicker to write and fine for a game that never rewords, but changing the English breaks its translations silently. - Key names are
Key.<HID usage>, andKey.Ctrl,Key.Shift,Key.Alt, with an English fallback for every key of a full keyboard (F1–F24, the keypad, lock keys). - Switching language re-resolves and re-lays out interface text. No function runs. A popup follows its owner's language, even while open.
- Looked up: labels, button / toggle / menu labels, text tooltips, dropdown and choice options, placeholders, action titles and tooltips, panel titles. Never: data source cells, numbers, typed text, anything verbatim.
- A text node stores its key only when that differs from what it shows.
Developer-facing text stays English and out of the tables: authoring warnings, sheet errors, the inspector, and the key bindings file.
String files (TnxStringTable.h): the engine fills the table every document shows from a file per language, <language>.strings, with lines of ID = text:
# content/strings/de.strings Language.Name = Deutsch Editor.Menu.File = Datei
- The engine's folder (
Trinyx/assets/strings) is read first and the project's (content/strings) after it, so a project can reword anything. Its English is compiled in, so text shows without the files. - English is under every other language: a translation needs only the lines it translates.
UIHost::switches from the next update. A file is read again when it is saved. The table is rebuilt into a spare and then shown, so a document updating late keeps the one it started with.UseLanguage("de") Language.Nameis how a language names itself in a language menu.
Not yet: editing translations in the editor; plural forms and per-language number formatting; a name given with ui.Accessible doesn't follow a language change until rebuilt.
Input Actions
A shortcut is never text typed into a menu, and a title is never text that can't be translated. What a key does is declared apart from which key does it, in one system for editor shortcuts and game bindings.
inline constexpr GGInputAction OpenScene{ "Editor.Scene.Open", "Editor.Scene.Open.Title", "Editor.Scene.Open.Tip", GGChord::Ctrl(GGKeys::O) }; ui.Command(OpenScene, [] { Open(); }); // what it does while focus is inside this element ui.MenuItem(OpenScene); // title and chord from the action; choosing it runs it ui.Command(GGChord::Alt(GGKeys::F4), [] { Quit(); }); // a fixed chord
GGKey | A key by its USB HID usage, which every platform already has |
GGChord | A key plus modifiers. Its display text is generated, never written |
GGInputAction | A name, a title and tooltip (string keys), a default chord, and optionally a default alternate and a gamepad button (GGPad). A constant |
GGActionMap | The actions and each one's current chords. The host owns one for every document (UIHost::) |
- Two chords per action: a primary and an alternate (
GGSlot). Either runs it; menus and tooltips show the primary, or the alternate if there is no primary. A person can add their own chord without losing the default. One chord is in one slot: given to the other, it moves. - An action's name is a dotted tag, widest scope first. Everything before the last dot is its category. Two actions conflict on a chord only if one's category contains the other's, so
Editor.ViewportandEditor.Outlinercan reuse a chord. The same shape carries over to a game's contexts. - A command runs the nearest declaration for the chord, from the focused element outward. A popup continues from its anchor.
- A menu item or tool button given an action is disabled when no command is in reach.
- While text is being typed, a key without ctrl or alt is a character and runs nothing.
- The UI's own keys are actions (
GGNav):UI.Accept,UI.Cancel,UI.Next,UI.Previous, the four directions,UI.Table.Sort. Rebound, the new chord works and the old key no longer does. A direction key with extra modifiers is still the direction. ui.KeyBinding(action)shows an action's two chords and rebinds either: click one, then press the new chord. While waiting it gets every key first, so the chord being assigned doesn't also run. It has no keys of its own, so any key can be assigned: Cancel, Clear, and Reset are buttons, for each chord. A chord another action already has is reported, and taken on a second press.- Saved as text (
Keybinds.ini,UIHost::): one line per action whose chords aren't the defaults,SetKeyBindingsFile Editor.Scene.Open = Ctrl+Shift+O, orEditor.Edit.Redo = Ctrl+Y, Ctrl+Shift+Zwith an alternate, and a gamepad button after a bar:Game.Buy = E | Pad.North. A line without an alternate or a button leaves it at its default. Key and button names are English, so the file means the same in every language. - A gamepad button is named by where it is (
GGPad::Southis the bottom face button, whatever it is labelled). Prompts show it while a gamepad is in use (Rich Text); the document knows which is in use from what was last pressed (UIContext::InputDevice), and the pad's family from the platform. Hand-editable; an unreadable line is reported and skipped; a line for an action nothing has registered yet is kept.
Not yet: a chord that works when the UI doesn't have the keyboard (an editor shortcut while the pointer is in the viewport); mouse buttons in a chord; a command run by an action's gamepad button; rebinding a button in ui.KeyBinding.
Planned for the engine: TnxInputAction wraps the UI's and adds what a game needs: mouse and gamepad, axes, triggers (pressed, held, released, double), stacked contexts, and a map per player. Names and categories are TnxNames, one per level: Editor.Viewport.Cycle is Editor > Viewport > Cycle, so whether one category contains another is a compare of leading hashes, not of text. Bindings are resolved where input is read (Sentinel), so the simulation and the input log see actions and never keys: a player who rebinds changes nothing a replay or rollback depends on. Today the input buffer carries scancodes.
Drag and Drop
ui.DragSource(asset, { .Preview = [=](UI& ui) { ... } }); // any small plain value ui.DropTarget<AssetID>([=](const AssetID& dropped) { Assign(dropped); }); ui.DropTarget<AssetID>(onDrop, { .OnOver = [=](const AssetID& a, const DropEvent& at) { Preview(a, at); }, .OnLeave = [=] { EndPreview(); } }); ui.Tree(source, { .OnMove = [=](std::span<const ItemID> items, ItemID onto, GGDropPlace place) { ... } });
- The payload is a value of up to 32 bytes: an ID or a handle. A target is for one type and is only a target while that type is dragged.
- A press on a source that moves four units starts the drag. The target under the pointer is
:drop. Released over a target, the target gets the value and the position on it; anywhere else, or on Escape, nobody does. - A drag isn't a click.
- OnOver hears every move of a matching drag over the target, with where it is on it; OnLeave, the drag leaving the target or being cancelled over it. A drop goes to the drop handler, with no OnLeave. (The editor's viewport shows a prefab where it would land.)
- Rows of a list, tree, or table move with
OnMove: before (top half), after (bottom half), or into (middle, trees). The payload is the row, or the whole selection if the row is in it. The view moves nothing; the owner does, and the view follows its source. - The preview follows the pointer in class
drag-preview. The cursor showsMoveover a compatible target andNotAllowedelsewhere. - A press on a control inside a source drags the control, not the source.
- Dock tabs drag between windows through the host (Core).
Not yet: a row dropped from one view onto another; auto-scroll at a list's edge during a drag; a target that accepts some values of its type and refuses others.
Console
ui.LogView(props, lines)shows aGGLineSource. Lines are numbered from when the source began, so a line keeps its number when older ones are dropped. Only visible rows are built: a million lines are a dozen rows. It follows new lines while the newest is in view. A line containing line feeds is as many rows tall as it has lines of text.- Filter: case-insensitive, scanned twenty thousand lines per update so a filter over a million lines never stalls a frame. Matches are highlighted.
- Selection is by line; ctrl+C copies.
ui.CommandLine(props, commands)is a text field over aGGCommandSource. Candidates are listed above the line with their argument hints; Tab completes or cycles; Enter runs the line; up / down recall history.TnxConsole(engine) is the registry: commands and variables (a name and an atomic the engine already reads). It has no UI and builds headless.Submitqueues a line from any thread; Sentinel runs it.UIConsoleis the console's document in its own window. Its log is the logger's ring of recent lines.
Completions are a list above the line, not a popup: a popup that takes the keys would take them from the field being typed into.
Not yet: line wrapping or horizontal scrolling in the log; filtering by channel or level; a command that must run on Brain at a frame boundary. The engine's log source is the last 1024 lines of at most 255 characters.
Inspector
A document that shows another document, built from the same widgets as everything else.
- Tree of the inspected document's nodes; click to select. Pick: point at the real UI.
- Node: source line, rect, size rules (editable live via
UIContext::DebugSetLayout), state, focus order and arrow targets, reactions, bindings and their last values, accessible name. - Style and Tokens: see Styling.
- Counters: work in the last second. All zeros for an idle document.
- Overlays (
UIContext::SetDebugOverlays): Paint tints what was repainted, Layout outlines what moved, Focus outlines what keys can reach and where each arrow goes, Blocks tints what stops the pointer, Moving outlines transitions. - It reads and changes the document it shows, so the two never update in parallel. It stays above the window it inspects, except while picking.
Not yet: opening the source line in an IDE; a document's popups in the tree; inspecting a document other than the main window's; focus order drawn on the document (an overlay has no text); the paint and layout overlays last one publish.
Determinism
Sim-bound documents will receive the same UI events from the sim's input stream on Brain. Dispatch is kept compatible with that:
- It takes a list of events and a tree and has no other input: no clock (repeat and double-click timing arrive in the events), no thread assumption, no platform call. Requests to the platform (cursor, text input, clipboard) are emitted as data for the host to carry out.
- Hover, press, and focus are state in rows, changed only by dispatch.
- Directional focus uses the document's numeric type.
Testing
- Headless: build a document, publish it, feed events through the same router Sentinel uses, and assert on handlers, state, focus, and scroll offsets.
- By meaning, not by pixel: find nodes by role and name in the snapshot, so tests survive layout changes.
- On the real engine: events are handed to
UIHost::below the OS. A windowed test drives a panel without moving the real cursor or needing the window in front.RouteInput ResilienceDemo --selftestdoes this for the demo's panel, by pointer, by keys alone, and by gamepad alone.
Open Questions
- A value widget keeps showing what the user chose until the bound value moves. An owner that refuses the change never moves it. Either the commit handler returns whether it took the value, or a refused widget is put back by an explicit call.
- Touch. The press / drag / release model fits a single touch; multi-touch gestures need their own pass.
- Double-click time and drag threshold: from the OS where it offers them, fixed in sim-bound documents.
- How a custom-drawn widget (node graph canvas, curve editor) receives pointer events in its own coordinates.
- Nothing yet carries a command from the UI update to the simulation; a handler that changes the world leaves a request for Sentinel.