The runtime GUI (engine_gui)
The game UI system. It renders through the DrawLayer2D facade, so it draws the SAME image on both render flavors (classic OGRE and Ogre-Next) and on desktop and mobile. Widgets are authored from Lua (GuiFactory) or declaratively from a .oui text file (GuiFactory::loadLayout), and an AI agent drives and inspects the live UI over MCP.
Two objects do the work:
-
GuiManager(a singleton) owns the views/atlases, the input routing, themodal stack, toggle groups and toasts. In Lua this is the
guiobject. -
GuiFactorybuilds widgets (createButton,createCheckBox, …) and loads.ouilayouts. In Lua this is thefactoryobject.factory = GuiFactory() gui = GuiManager(factory, "gui_default", PROJECT_RESOURCE_GROUP) local ok = factory:createButton("play", "button", 9, "Play", Vector2(40, 40), 0, Vector2(200, 48), "", 2, false, 0) -- input events subscribe themselves the moment the button is created (see -- "Input enablement" below); an explicit gui:enableInputEvents() is optional
Input enablement (explicit > auto > asset default)
GuiManager subscribes to key/mouse/touch events only when it has interactive content to route them to. Three signals decide it, highest priority first:
-
Explicit — a Lua/C++
gui:enableInputEvents()/gui:disableInputEvents()always wins and latches: a later interactive widget never re-toggles the state on its own, and a deliberate
disableInputEvents()keeps sticking even as more interactive widgets are added. -
Auto — with no explicit choice made, the FIRST interactive widget a screen
gains (a button, checkbox, select-menu/slider, scroll view, text entry, or the drag button) enables input events by itself. A label/decor/textbox/progressbar is display-only, so a pure HUD never enters the input path and pays nothing.
-
Asset default — an
.oui[Layout]input = on|offkey is an explicitchoice at load time (it beats auto); absent, the auto path decides.
input offon a screen that has buttons deliberately keeps input off.
enableInputEvents() is idempotent (a double enable never re-registers), so an explicit call in a script is a harmless no-op. The editor GUI Preview stage never runs game input, so none of this applies there — a preview manager stays fully input-inert.
Atlases (<name>.ogui + <name>.png, or a runtime-baked TTF/SVG .ogui) are generated by Util/make_gui_atlas.py. Visibility rides the shared per-z UiLayer: widget:getLayer():hide()/show()/isVisible() toggles a whole layer at once.
Build a screen (the canonical recipe)
-
Author a
.ouitext layout (grammar below) — orwrite_project_fileitover MCP.
-
Load it:
factory:loadLayout("screens/pause.oui")(orgui:loadLayout). -
Find each widget by id:
local btn = gui:findWidget("resume")(nil ifabsent).
-
Wire behavior each frame:
if btn:wasClicked() then ... end. -
Verify: read back
get_ui_layoutover MCP (per-widget pixel rects + thevisible/enabled/modal flags) and cross-check against
get_safe_area.
Poll idiom is single-consumer (latch-and-clear). wasClicked/wasSubmitted/ pollChanged/getDialogResult return a pending event ONCE and clear it. With several script components on one object, the FIRST to poll a widget consumes the event; a later script sees nothing. Convention: exactly one script owns a given widget's events — split UI ownership by widget, never have two scripts race for the same button (see the script-component note in lua-api.md).
Screen flow (the screens router)
A game is a handful of full-screen pages — a title, a level-select, a settings page, the in-game HUD — and moving between them. The screens table is that router: one .oui per screen, then screens.push. It turns navigation from hand-wiring show/hide into a declaration.
function init(self)
screens.define("title", "screens/title.oui") -- a .oui-backed screen
screens.define("settings", "screens/settings.oui")
screens.defineBuilder("hud", function() -- or a code-built screen
factory:loadLayout("screens/hud.oui")
-- ...any imperative widget wiring for this screen...
end)
screens.push("title") -- the first screen
end
Then navigate from anywhere — a button poll, a game event:
if playBtn:wasClicked() then screens.push("hud") end -- cover with a new screen
if settingsBtn:wasClicked() then screens.push("settings") end
if backBtn:wasClicked() then screens.pop() end -- back to the one beneath
screens.replace("gameover") -- swap the top, same depth
local where = screens.current() -- "" when none is up
Lifecycle — destroy-on-navigation, the .oui is the source of truth. Only the top screen is materialized. A push plays the current screen's exit transition, tears its widgets down, then builds the new screen and plays its enter transition; a pop reverses it, rebuilding the revealed screen from its .oui/builder. Transitions come from each widget's own transition spec (fade, slide-up, pop, …), so screens fade/slide in and out for free. The router is sequential (the outgoing screen finishes leaving before the incoming one builds), so screen widget ids need not be unique across screens, and transient widget state is not preserved across navigation — a screen is cheap to rebuild, so rebuild it. Modals (gui:showConfirm/showModal) ride above the stack and are unaffected by screen navigation.
Back / Escape. With a screen stack in play, the Android back button and desktop Escape pop the stack by default (but never past the root screen — there a back falls through, so Android backgrounds the app). A screen owns the back gesture by installing a handler; while one is installed the default pop is suppressed and the handler decides (pop, show a confirm, or ignore):
screens.setBackHandler(function()
gui:showConfirm("Quit?", "Leave the level?", "Quit", "Stay")
-- resolve the confirm elsewhere; call screens.pop() when the user confirms
end)
The handler resets on every navigation, so each screen opts in fresh.
Readback (agents). get_ui_layout carries the router state alongside the widget rects: screen (the current top) and screenStack (the space-joined bottom-to-top path). Drive it over MCP with screens.push/pop from a ScriptComponent and assert the path.
.oui grammar cheat-sheet
An ordered list of [Type id] sections, each a block of key = value lines (# comments, LF endings, no scripting — parses in ORKIGE_SCRIPTING=OFF).
| Form | Meaning |
|---|---|
[Layout] | optional global section (first) |
atlas = gui_default | default atlas for widgets that omit one |
design = 1280 720 0.5 | design resolution W H + match factor (drives scale) |
root = fullwindow | safearea | layout root rect |
input = on | off | screen-level input enablement (explicit; beats auto) |
[Type id] | a widget; Type ∈ the widget-type list below |
z = 2 | draw layer (painter order; higher = front) |
sprite = button | none | atlas sprite; none on a decor = solid fill |
font = 9 | glyph/font index |
text = Play | @key | caption; leading @ = StringTable lookup |
position = 40 40 / size = 200 48 | fixed pixel placement |
textAlignment = topleft | text anchor within the rect |
enabled = false | input-inert + dimmed (default true) |
parent = panelId | rect-anchor layout parent |
anchor = center | anchor preset (or anchorMin/anchorMax pair) |
pivot = 0.5 0.5 | pivot within the rect |
offsets = l t r b | edge offsets from the anchored rect |
anchoredPos = 0 0 / sizeDelta = 460 460 | anchored position / size |
useSafeArea = true | inset the rect by the device safe area |
group = vertical | horizontal | grid | make this a layout container |
padding = l t r b / spacing = 10 | container spacing |
childAlign / childExpand / fit | container child rules |
cellSize = w h / gridConstraint = n | grid-group geometry |
nineSlice = true | tiled = true | color = r g b a | draw modes (decor, button, checkbox, text entry; color decor-only) |
checkbox = true | checkbox: the BOX skin (a check symbol beside the caption) instead of the two-state PLATE |
items = A | B | C | dropdown / listview / selectmenu / slider options (pipe-separated) |
wrap = true | label / textbox: break the text to the widget width |
markup = true | read the caption as inline rich text ([c=..] / [f=..] spans, [sprite=..] icons) |
style = NAME | apply a [Style NAME] bundle first, then this widget's own keys |
textColor = r g b a / textScale = 2 | caption ink / glyph size multiplier |
pressFeedback = true | button: the face snaps smaller on press and springs back |
multiline = true | text entry: a text area (soft wrap, Return inserts a line break) |
virtualized = true / itemHeight = 24 | list view: materialise only the visible rows, at that uniform row height |
transition = fade 0.2 | BOTH directions from one spec (the exit reverses it; see Animation) |
enter = fade 0.25 quadOut | slide 0 -40 | the SHOW transition only: |-composed clauses, each with its own duration + easing curve |
exit = fade 0.15 | the HIDE transition only |
modal = confirmId | create on that modal's content layer + tear down with it |
[Modal id] + scrim = r g b a / lightDismiss = bool | a modal scrim |
[ToggleGroup id] + members = a b c / selected / allowNone | a radio group |
[TabBar id] + tabs = a b / panels = pa pb / selected | a tab bar (panel visibility follows the tab) |
[ListView id] + items = A | B | a scrolling vertical list (seed rows) |
Widget Types: label, textbox, button, checkbox, selectmenu, slider, progressbar, textentry, decorwidget / panel, scrollview, listview, dropdown.
Widget set
| Widget | Purpose | Key API (poll / set) |
|---|---|---|
GuiLabel | one line of text, or wrap-to-width | setText / setWrap |
GuiTextbox | multi-line rich text (UiMarkupText), wrap-to-width | setText / setWrap |
GuiButton | pressable button | wasClicked / ButtonHitEvent, setPressFeedback |
GuiCheckBox | on/off toggle (may join a toggle group) | isChecked / setChecked |
GuiSelectMenu | an option cycler (‹ value ›) | getSelectedIndex / setItemsString |
GuiDropDown | an option list dropped on a modal | getSelectedIndex / setItemsString |
GuiSlider | a dragged value over discrete items | getSelectedItemIndex / setCaption |
GuiProgressBar | a fill bar with a caption | setProgress / setCaption |
GuiTextEntry | text field: single-line, or a multi-line text area | getText / wasSubmitted / setText / setMultiline |
GuiDecorWidget | sprite panel OR (empty sprite) solid fill; nine-slice / tiled | setColour / setAlpha |
GuiScrollView | a clipping viewport whose content scrolls | setScroll / getScroll / getMaxScroll |
GuiListView | a vertical list: a scroll viewport with a built-in content container; optionally virtualized | addItem / removeItem / clear / getItemCount / setVirtualized |
GuiToast | a passive, timed, self-dismissing notification | (built via gui:showToast) |
GuiModalScrim | the consuming backdrop of a modal dialog | (built via gui:showModal) |
The tab bar (GuiTabBar) is not a widget but a manager-owned composition: a single-selection group of tab checkboxes, each paired with a content panel whose visibility follows the selection. Build it with gui:createTabBar(id) then bar:addTab(checkboxId, panelId), poll bar:getSelected()/bar:pollChanged().
An image is a GuiDecorWidget (a sprite panel, with nine-slice / tiled draw modes) — there is no separate image widget. A toggle switch (the mobile on/off pill) is a GuiCheckBox skinned with a two-state pill sprite: the checkbox already swaps <sprite>_off/<sprite>_on sprites, so a switch is a look, not a class. A radio group is a toggle group (GuiToggleGroup, single selection over checkboxes).
Common to every widget (GuiWidget): setEnabled/isEnabled, the rect-anchor layout setters (setParent/setAnchorPreset/setPivot/setOffsets/…), setGroupAlpha/getEffectiveAlpha, setTransition, and getLayer() for per-layer visibility.
Wrap-to-width text
A label or textbox with setWrap(true) (.oui key wrap true, Lua setWrap) wraps to its resolved width instead of drawing one clipped line. The break rules are the standard ones: a Latin run breaks at spaces; a CJK codepoint may break between any two glyphs; a single word wider than the column hard-breaks at the glyph that no longer fits (never overflowing); an explicit \n still forces a break; kerning applies within a line and never across a break. A rich-text element's runs and inline sprites flow across the breaks — a run split by a wrap keeps its colour/font, and a sprite that does not fit moves whole to the next line. The pure greedy line-breaker is engine_gui/TextWrap; the pixel layout is UiCaption/UiMarkupText.
Pair wrap with a width (anchors, setSize, or a stretch anchor) and a preferred vertical content-size-fit (fit none preferred) to let the widget grow taller as its column narrows: the layout resolver measures a wrapped node's height from the width it settles on (height-for-width), so a wrapped label in a content-fit panel re-flows and re-heights on a resize or a loc() text swap with no script maths.
[Label body]
anchor = stretchtop # stretch across the parent, pinned to the top
offsets = 16 8 -16 0
wrap = true
fit = none preferred # grow the height to fit the wrapped lines
The ORDERING contract of height-for-width: a wrapped node's height depends on its width, so the width settles first. The resolver's two passes do exactly that — pass 1 measures every node's width-independent preferred size, pass 2 assigns rects top-down and, the moment a node's WIDTH is final (its anchors, its preferred horizontal fit, or the column a layout group hands it), consults the node's height-for-width measurer for the vertical preferred axis. A wrapped label inside a horizontal group therefore heights against the column that group gave it, not against its natural single-line width. wrap without a bounded width still measures one line — give the node a stretch anchor or a childExpand parent so the column is real.
Multi-line text entry
setMultiline(true) (.oui key multiline true) turns a GuiTextEntry into a small text area. The text soft-wraps to the field width through the SAME wrap core a wrapped label uses, so both break identically; up/down walk the logical lines keeping the code-point column; home/end work within a line; and the view scrolls vertically to keep the caret visible, rendering only the window of wrapped lines that fits the field (a whole-line clip — it costs no extra draw batch and never claims its layer's scissor rect, so a field may share a layer).
Semantics: Return inserts a line break. A multi-line field has no submit — wasSubmitted stays a single-line concept, so a game that wants a "send" adds a button beside the field. Blur still ends the platform text-input session exactly as it does for a single-line field, so the mobile keyboard flow is unchanged. The pure edit model (line bounds, columns, up/down, the newline insert and the joins backspace/delete make across a break) is engine_gui/GuiTextEdit.h.
[TextEntry notes]
sprite = select_menu_field
anchor = stretchtop
offsets = 0 0 0 96 # a tall box: several lines fit
multiline = true
maxLength = 400
Virtualized lists
A GuiListView flows every row as a widget by default — right for the option / level / inventory lists a screenful covers. setVirtualized(true) (.oui virtualized true) trades that for a flat cost on big lists: rows share ONE uniform height (setItemHeight, .oui itemHeight) and only the rows the viewport shows — plus one row of overscan above and below — exist as widgets. The window is decided by the pure core_util virtualWindow and the rows anchor at their virtual offsets through the same layout resolver, so the scroll extent covers the whole model and a visible row hit-tests exactly as an unvirtualized one does. A 1000-row list then costs a dozen widgets.
Item ids stay stable per item in both modes (addItem returns one, getItemId keeps returning it), so the item API is unchanged. The one honest difference: in virtualized mode an id only resolves to a live widget while its row is inside the materialised window — wire per-row behaviour to the item INDEX, never to a cached widget handle. A variable-height list is not virtualizable (the window maths needs the uniform height); leave it flowed.
[ListView bigList]
anchor = stretchall
offsets = 8 8 -8 -8
itemHeight = 24
virtualized = true
The gallery example
projects/gallery is the worked example of the whole widget tier: one declarative .oui screen suite (a tab bar over four panels — text, controls, lists, overlays — plus a safe-area HUD file) showing every widget kind, with a small Lua driver that only wires behaviour on top. Open it in the editor, or render a screen headlessly with the MCP preview_ui verb. It is also a test: player_gallery_selfcheck boots it, clicks through every tab, and asserts the wrapped label is taller than the single-line one, that a typed Return inserts a line break in the multi-line field, that the 1000-row list keeps only its viewport window of rows alive and still hit-tests a scrolled one, and that a modal raises and dismisses.
Widget class hierarchy
graph TD
GuiButton["GuiButton"]
GuiButtonBlink["GuiButtonBlink"]
GuiDropDown["GuiDropDown<br/><i>a button that, when tapped, opens a scrollable li...</i>"]
GuiDecorWidget["GuiDecorWidget"]
GuiModalScrim["GuiModalScrim<br/><i>the input-consuming backdrop of a modal dialog: a...</i>"]
GuiScrollView["GuiScrollView<br/><i>a scroll viewport: a clipping container whose lay...</i>"]
GuiListView["GuiListView<br/><i>a vertical list: a scroll viewport whose content...</i>"]
GuiSelectMenu["GuiSelectMenu<br/><i>a stepped value control: one row carrying a TITLE...</i>"]
GuiSlider["GuiSlider"]
GuiWidget["GuiWidget"]
GuiCheckBox["GuiCheckBox<br/><i>a two-state toggle in two SKINS, chosen by the `u...</i>"]
GuiDragDropButton["GuiDragDropButton"]
GuiLabel["GuiLabel"]
GuiProgressBar["GuiProgressBar"]
GuiTextEntry["GuiTextEntry<br/><i>a text input field: SDL text-input driven glyph e...</i>"]
GuiTextbox["GuiTextbox"]
GuiToast["GuiToast<br/><i>a passive timed notification: a rounded backing p...</i>"]
IGuiObject["IGuiObject"]
GuiButton --> GuiButtonBlink
GuiButton --> GuiDropDown
GuiDecorWidget --> GuiModalScrim
GuiScrollView --> GuiListView
GuiSelectMenu --> GuiSlider
GuiWidget --> GuiButton
GuiWidget --> GuiCheckBox
GuiWidget --> GuiDecorWidget
GuiWidget --> GuiDragDropButton
GuiWidget --> GuiLabel
GuiWidget --> GuiProgressBar
GuiWidget --> GuiScrollView
GuiWidget --> GuiSelectMenu
GuiWidget --> GuiTextEntry
GuiWidget --> GuiTextbox
GuiWidget --> GuiToast
IGuiObject --> GuiWidget
Input routing (the model to understand)
GuiManager broadcasts every input event to all widgets in z order, highest layer first. Each widget hit-tests itself; there is no first-hit-wins consume — overlapping widgets all see the same press. Two things change that:
-
A disabled widget (
setEnabled(false)) is skipped by hit-testingentirely — the dispatch loop never calls its handlers, so it is input-inert — and rendered dimmed. Uniform for every widget type.
-
A modal scrim (
GuiModalScrim) sits on a high layer and, on any cursorevent, calls
GuiManager::cancelCurrentInputUpdate(). That stops the dispatch for every LOWER layer for the rest of that event. The dialog's own widgets sit one layer ABOVE the scrim, so they are dispatched first and still work, while everything beneath the scrim is eaten.
Disabled state
button:setEnabled(false) -- input-inert + dimmed (Button swaps to its
-- "_disabled" sprite; others dim to 50% alpha)
if button:isEnabled() then ... end
Disabling a container / panel disables its whole subtree: a widget is inert when it OR any layout ancestor is disabled (the manager gates input on isEffectivelyEnabled, which walks the layout-parent chain). In .oui, any widget takes enabled = false (default true).
Modal dialogs
A modal is a full-window consuming scrim on a fresh layer above everything, plus the dialog widgets one layer above the scrim. Two convenience builders assemble a whole dialog; both return the modal id and resolve via a poll.
local id = gui:showConfirm("Reset?", "Erase all settings?", "Yes", "No")
-- later, each frame:
local r = gui:getDialogResult(id) -- 0 pending, 1 Yes/OK, 2 No
if r == 1 then doReset() end
gui:showAlert("Out of coins", "Earn more to buy this.", "OK")
Lower-level control:
local m = gui:showModal("pause", false) -- (id, lightDismiss); bare scrim
local z = gui:getModalContentZ("pause") -- author dialog widgets on this layer
gui:registerModalWidget("pause", "pauseTitle") -- tear down with the modal
gui:dismissModal("pause") -- or gui:dismissTopModal()
if gui:isModalActive() then ... end
Established modal behavior, so instincts transfer:
-
The scrim blocks ALL input on the layers below it; the dialog's own widgets sit
one layer above and stay interactive.
-
Outside-tap dismiss is opt-in per dialog (
lightDismiss):showConfirm/showAlertdefault NOT light-dismissable (they must be answered by a button); menus and dropdowns ARE. A tap on the scrim of a light-dismissable modal closes it. -
Escape (desktop) and the Android back button dismiss the TOP modal, but
only when it is light-dismissable — a confirm/alert is left up (answer it with a button). Either way the key is consumed while a modal is up.
- Modals stack LIFO: the newest is raised above the rest and wins input.
-
Focus transfers: the text-entry input session focused below the stack is
blurred while a modal is up (so typing never leaks past the scrim) and restored when the last modal closes.
Text with a leading @ is looked up in the StringTable (localisation).
Toggle groups (radio / single selection)
Radio semantics: exactly one member is selected; checking one unchecks the others, and clicking the already-selected member does NOT deselect it (a group never lands in an empty state unless you opt in). The pure state machine is core_util/ToggleGroupState.
local group = gui:createToggleGroup("quality")
group:addMember(low) -- three GuiCheckBox widgets
group:addMember(medium)
group:addMember(high)
group:setSelected(0)
-- each frame:
if group:pollChanged() then applyQuality(group:getSelected()) end
group:setAllowNone(true) (default off) opts into switch-off: tapping the selected member then deselects it, allowing the empty state.
Toasts
A timed, self-dismissing notification. It is non-interactive and never blocks input. Toasts queue FIFO with one visible at a time (the common mobile convention); the default lifetime is ~2.5 s. The pure core_util/ToastQueue sequences and fades them.
gui:showToast("Saved", 2.5) -- text, seconds
Animation & juice
Widgets animate through the same TweenManager the game tweens ride (ticked in the player loop; dormant in the editor, so animation never runs in edit mode). The Lua surface is the guitween table — one call per property, keyed by widget id:
guitween.alpha(id, alpha, duration [, ease [, delay [, onComplete]]])
guitween.scale(id, scale, duration [, ...]) -- uniform scale about the centre
guitween.rotate(id, degrees, duration [, ...]) -- Z rotation about the centre
guitween.move(id, x, y, duration [, ...]) -- anchoredPosition (layout) / position
guitween.size(id, w, h, duration [, ...]) -- sizeDelta (layout) / size
guitween.color(id, r, g, b, a, duration [, ...]) -- decor tint (decor widgets)
guitween.show(id) / guitween.hide(id) -- play the widget's transition
guitween.stop(id) -- cancel every tween on the widget
The default ease is ease-out quadratic (quadOut); pass any name. Every call returns a handle:EaseLibrary
local h = guitween.scale("badge", 1.2, 0.3, "quadInOut")
h:setLoops(-1, true) -- loop forever, ping-pong (back and forth); count<0 = infinite
if not h:isActive() then ... end -- the completion poll
h:cancel()
Completion resolves BOTH ways, matching how confirm dialogs do: poll handle:isActive(), or pass an onComplete callback as the trailing argument.
Semantics (the conventions to rely on):
-
Replace-on-retarget (last-wins). Starting a tween on a property that is
already animating on that widget cancels the running one — the newest target wins. There is at most one tween per (widget, property).
-
Auto-kill on destroy. Destroying a widget stops its animations; a tween
never writes to a widget that no longer exists (the apply re-fetches by id).
-
Compose with layout, don't fight it. Animating a layout-driven widget
tweens its LAYOUT INPUTS —
movedrivesanchoredPosition,sizedrivessizeDelta— so the resolver and the animation cooperate.scaleandrotateare a render transform about the widget centre, layered on top of the resolved rect (the layout geometry never changes). -
The batch stays one draw. Scale/rotation transform the emitted vertices in
place; an animating widget resubmits its screen but rebuilds no geometry.
Cascading (group) alpha
widget:setGroupAlpha(a) sets a 0..1 opacity that multiplies down the layout-parent chain: fading a panel dims every widget parented under it, multiplicatively through nesting. widget:getEffectiveAlpha() reads the resolved product. Below a small threshold the faded-out subtree also stops hit-testing (input falls through to whatever is behind it) — the standard convention; opt out per widget with widget:setAlphaBlocksInput(false).
Show / hide transitions
Widgets carry enter/exit transitions, declared in .oui or set from Lua:
[Panel menu]
transition = fade 0.2 # BOTH directions from one spec
[Panel sheet]
enter = fade 0.25 quadOut | slide 0 -40 0.3 backOut
exit = fade 0.15
A spec is one or more CLAUSES separated by |, each family [numbers] [ease]:
| Clause | Channel it drives |
|---|---|
fade [duration] [ease] | opacity 0 ↔ 1 |
pop [duration] [ease] | scale 0 ↔ 1 (springy backOut by default) |
slide-up / -down / -left / -right [duration] [ease] | a positional offset the widget's own extent wide |
slide dx dy [duration] [ease] | a positional offset of an EXPLICIT pixel vector |
none (or nothing) | snap |
Clauses of different families COMPOSE, because each drives a different channel: fade 0.25 | slide 0 -40 fades AND drops in at once, and each clause carries its own duration and easing curve (any name EaseLibrary knows — quadOut, backOut, bounceOut, …; an unknown name warns once and takes the direction's default). Two clauses on the same channel are last-wins. The whole transition lasts as long as its longest channel. Case is free and _ reads like - (slide_up == slide-up). A malformed clause is one warning and a spec without it — never a screen that fails to build.
transition seeds BOTH directions (the exit plays the same clauses reversed: fade out, slide back out the way it came, pop down); enter and exit override one direction each. A hide ends the widget at effective-invisible so it stops drawing AND hit-testing.
Transitions play from four places, all through the one seam (GuiManager::playWidgetTransition):
-
guitween.show(id)/guitween.hide(id)from Lua, - the screen router on every push/pop,
-
a tab bar revealing a panel that declares one (it animates in instead of
snapping to full alpha; the first apply as a screen opens always snaps, so an unselected panel never fades itself out on the way in),
-
loadLayout: a widget that declares anenteranimates itself in as thescreen is built, once. Anything the build parked invisible (a widget inside an unselected tab panel) is skipped — it enters when its tab reveals it.
panel:setTransition("pop 0.25") -- both directions sheet:setEnterTransition("fade 0.25 quadOut | slide 0 -40") sheet:setExitTransition("fade 0.15") guitween.show("menu") -- plays the enter transition guitween.hide("menu") -- plays the exit THEN parks the widget hidden
A slide moves the widget away from its rest position and tweens back, so the rest position is REMEMBERED while the transition runs: replaying a transition mid-flight (a screen re-revealed while it is still animating) returns to the same rest instead of drifting one offset further each time.
Everything rides the existing tween system — TweenManager + EaseLibrary driving the widget's group alpha, render scale and layout position — so a transition composes with the layout resolver and with the cascading alpha rather than fighting them, and it is player-ticked: the editor never ticks tweens, so in edit mode (and in the GUI Preview) a transition SNAPS to its end state instead of animating. The pure parse/plan is core_util/UiTransition (GuiAnimationTests); projects/gallery authors both an enter on its title and a composed one on its Overlays panel, asserted mid-flight and at exact rest by player_gallery_selfcheck.
Button press feedback
button:setPressFeedback(true) opts a button into the tactile "juice": it scales down a touch on press and springs back with a slight overshoot on release (a short backOut scale tween through the same path).
Scroll momentum
GuiScrollView flicks: releasing a drag with velocity coasts with exponential deceleration; dragging past an edge rubber-bands (diminishing resistance) and springs back on release; the mouse wheel bypasses momentum (discrete, not a flick). The feel is the pure state machine, unit-tested headlessly.ScrollMomentum
The matrix selfcheck (demo_gui_matrix, both flavors) builds every widget type from .oui AND imperatively, drives each with synthetic input, and asserts its state/geometry plus this whole animation layer.
Performance contract
Mobile is the target, so the renderer keeps a hard promise, made enforceable by the demo_gui_matrix selfcheck (both flavors):
-
One draw per screen per atlas. Every visible layer of one atlas
concatenates into a single
DrawLayer2Dbatch. A modal (scrim + dialog) shares its atlas' batch (still 1); only a scissored scroll region adds one (→ 2); a second atlas adds one screen (→ +1). Probe:gui:getLastBatchCount(). -
Dirty-tracked. A fully static screen resubmits nothing; one content change
resubmits exactly once; an animation resubmits each active frame and stops the frame it completes. Probe:
gui:getRebuildCount()(batch resubmits, read deltas). An unfocused widget must never dirty per frame. -
Transform-only animation rebuilds no geometry. A scale/rotation/alpha
animation rides as a per-frame post-pass on the cached vertices, so it RESUBMITS the batch but re-tessellates nothing. Probe:
gui:getGeometryRebuildCount()— flat while a transform animates, +1 per real content change (a text/sprite edit). This distinction is the point of the post-pass transform design. -
Zero steady-state allocation. The retained scratch buffer keeps its
capacity across identical rebuilt frames (no per-frame reallocation). Probe:
gui:getScratchCapacity(), stable after warmup.
gui:profileTick(dt) runs the whole gui frame (layout resolve + tween tick + rebuild + submit) off the event bus so a selfcheck can time it; the matrix logs µs/frame for a settings-scale screen and a 200-widget stress.
Fully scriptable
Every gui capability — creation of all widget types, every setter/getter (anchors/pivots/offsets/groups/fit, enabled, nine-slice/tiled, group alpha, render scale/rotation, transitions), modals (show/dismiss/confirm/alert/dialog results), toggle groups, dropdown items (setItemsString) + selection, toast, scroll offsets, loadLayout, and the guitween surface — is reachable from a ScriptComponent. Widgets authored in a .oui are found from Lua by id with gui:findWidget(id) (nil when absent), the bridge that lets a script wire behavior onto a declaratively-authored screen. The guarantee is enforced by construction: the demo_gui_lua selfcheck authors, drives and asserts the whole matrix in Lua, so a missing/renamed binding fails the suite rather than review.
Dropdown vs. cycler
GuiSelectMenu is a compact cycler (‹ value ›) — best for short option sets. GuiDropDown drops a scrollable list on a light-dismiss modal — best for long lists. Both share the value API. The dropdown follows the established combobox behavior: it opens on press, the list overlays the content (it does not push it), the currently-selected option is highlighted, the list scrolls when it is long, and it closes on pick, on an outside tap, or on Escape.
local dd = factory:createDropDown("lang", "button", 9, "English",
Vector2(40, 300), 0, Vector2(220, 44), "", 2)
-- from Lua, set the options with a pipe-delimited string (setItemsString); the
-- vector-taking setItems is the C++ face. GuiSelectMenu/GuiSlider share it.
dd:setItemsString("English | Deutsch | Français | 日本語")
-- each frame:
local i = dd:getSelectedIndex()
.oui declarative layout
A .oui file is an ordered list of [Type id] sections, each a list of key = value entries. It is pure text — no renderer, no scripting — so it parses in the ORKIGE_SCRIPTING=OFF build and an agent authors it over the MCP write_project_file verb. Load it with factory:loadLayout("screen.oui") at runtime.
[Layout] # optional global section
atlas = gui_default # default atlas for widgets that omit one
design = 1280 720 0.5 # design resolution + match factor (drives the scale)
root = fullwindow # or "safearea"
input = on # optional: on|off input enablement (else auto, below)
[Button play]
z = 2
sprite = button
font = 9
text = Play # a leading @ looks the value up in the StringTable
position = 40 40
size = 200 48
enabled = true
anchor = center # rect-anchor layout (opt-in; see below)
Common keys (any widget)
atlas, z, sprite, font, text, position, size, textAlignment, enabled. Rect-anchor layout keys: parent, anchor (preset) or anchorMin/anchorMax, pivot, offsets, anchoredPos, sizeDelta, useSafeArea. Group keys (on a container): group (horizontal/vertical/ grid), padding, spacing, childAlign, childExpand, cellSize, gridConstraint, fit. Draw modes: nineSlice, tiled (decors, buttons, checkboxes and text entries — every widget whose skin stretches), color (decor-only). Text: wrap (label/textbox; wrap to the resolved width — see Wrap-to-width text), multiline (text entry; a text area — see Multi-line text entry). Text style (any text-bearing widget): font, textColor, textScale — see Text style. Styling: style — see Named styles. List: virtualized + itemHeight (list view — see Virtualized lists).
Widget types
label, textbox, button, checkbox, selectmenu, slider, progressbar, textentry, decorwidget / panel, scrollview, listview, dropdown.
A dropdown, listview, selectmenu and slider all take items = A | B | C (pipe-separated so labels may hold spaces, each entry @-resolvable through the StringTable); a listview seeds its rows from them, a selectmenu/slider its value steps. A listview also takes itemHeight + virtualized (see Virtualized lists), and a textentry takes multiline.
A checkbox comes in two skins. Default (PLATE) is a two-state button: the sprite's _off/_on pair fills the whole rect and the caption is CENTRED in it — what a tab in a [TabBar] looks like; pair it with nineSlice = true so a wide plate keeps its corners. With checkbox = true (BOX) the sprite is the row plate, the shared check symbol keeps its own size at the row's trailing edge and the caption reads BESIDE it — the settings-row checkbox. Either way the caption is never drawn on top of the box.
A widget's size is a DESIGN size: it scales with the display density (UiGlyph::scale), exactly like the glyph metrics, so authored boxes and the text in them stay in proportion on a 2x/3x screen. Anchors, offsets, padding, spacing and cellSize scale with the layout reference instead (design + match) — pick a design resolution close to the target device and the two agree.
A layout group arranges its children in DECLARATION order (the order the sections appear in the file / the widgets were created), independent of their z. Each scroll region needs its own z, though: the clip is a per-layer scissor, so two scroll views sharing a layer would fight over it.
Text style (font, colour, size)
Every text-bearing widget — label, textbox, button, checkbox, select menu, slider, progress-bar caption, text entry, dropdown, list-view rows — carries one text-style vocabulary. The three keys are ordinary .oui keys, live Lua setters and property rows in the UI Editor, and they read back over MCP get_ui_layout.
[Label title]
font = heading # an atlas font: its ROLE NAME, or a [Font.N] index
textColor = 1 0.82 0.35 1 # r g b a, 0..1 (three components = opaque)
textScale = 2 # a glyph size multiplier (1 = the font's baked size)
font picks which [Font.N] of the atlas the caption draws with. It takes either the section's decimal INDEX (font = 24) or the ROLE NAME the font declares with its own name key (font = heading). Prefer the name: an index shifts if the atlas ever gains a size, a role does not. The generated atlases carry body (small HUD/body text), heading, display and title — the same four sizes, now addressable by what they are for. A reference the atlas does not carry is one honest warning and the default (body, index 9); it never blanks the text.
Declare the role in the .ogui beside the metrics — for a bitmap font:
[Font.24]
name heading
lineheight 36
...
and for a runtime TrueType font exactly the same way:
[Font.24]
name heading
ttf Nunito-Regular.ttf
size 34
textColor is the caption ink as r g b a in 0..1 (a three-component value is opaque). A widget that never sets one keeps its inherited default look, so an unstyled screen is unchanged. The colour flows into the batched per-vertex colour the renderer already carries — no extra draw. On a textbox it is the colour a run STARTS in — a [c=..] span switches per run and closing it returns to this colour (see Inline rich text). A disabled widget still owns its own opacity (it dims to DISABLED_ALPHA), so styling a disabled control does not brighten it.
textScale multiplies the widget's glyph size, so ONE baked font serves several display sizes. Every metric scales together — advance, glyph box, line height, space, kerning, letter spacing — which means measurement, wrapping, content-size-fit and the drawn quads all agree; a 2 on a wrapped label breaks into more lines AND makes each line taller. Be honest about the trade: the glyphs are baked pixels sampled point-filtered, so a WHOLE multiple (2, 3) is exact and crisp while a fractional factor (1.3) resamples the texels and reads softer than a font baked at that size. For body copy at an unusual size, bake another [Font.N] and name it; use textScale for accents, emphasis and the "one font, two sizes" case. A factor of zero or below is refused with a warning.
Live from Lua, on any widget handle:
local title = gui:findWidget("title")
title:setFontIndex(24) -- an atlas [Font.N] index
title:setTextColour(1, 0.82, 0.35, 1)
title:setTextScale(2)
print(title:getFontIndex(), title:getTextScale(), title:hasTextStyle())
Inline rich text (styled runs)
A caption normally draws in ONE font and ONE colour. markup = true reads the text as inline rich text instead: colour spans, font spans and inline atlas icons inside the one widget, for dialogue emphasis and HUD strings like +50 <icon>.
[Label reward]
markup = true
text = [c=FFCC33]+50[/c] points [sprite=coin]
wrap = true
The grammar is small, and every part of it is escapable:
| Form | Meaning |
|---|---|
[c=RRGGBB] … [/c] | a colour span (8 digits = RRGGBBAA) |
[f=NAME] … [/f] | a font span: a font ROLE name (heading) or a [Font.N] index |
[sprite=NAME] | an inline atlas sprite (self-closing, one cell) |
[[ | a literal [ — the only escape the grammar needs |
Spans nest per attribute: an inner [c=..] restores the outer colour at its [/c], and colour and font are independent (closing one keeps the other). A span still open at the end of the text closes there. An inline sprite is tinted by the colour span around it, so [c=FF8800][sprite=coin][/c] works like text.
markup is a text-style key like font / textColor / textScale: it is stored on the widget and reaches whatever caption it owns, so a button face, a checkbox label or a dropdown caption can carry runs too. It is opt-in, so every existing screen is unchanged: with markup on, a string carrying no tags measures and draws exactly as it did with markup off. A textbox is the styled-run widget by definition and always reads its text as rich text — which is why a literal [ in a textbox needs the [[ escape. Text ENTRY stays plain: markup is a display feature, so a field's text is what the player typed, never a parsed tag stream.
Rich text composes with the rest of the text tier rather than sitting beside it:
-
Wrapping flows through the one line-breaker (
engine_gui/TextWrap). Aline may break inside a run, between runs, or before an icon that no longer fits, and each cell keeps its own colour and glyph across the break. An inline sprite is ATOMIC — it moves whole to the next line.
-
Measurement excludes the tags (they are not glyphs), so a markup label
measures like the same sentence without them, and content-size-fit / height-for-width behave as they do for plain text. Splitting a run costs the kerning pair at each boundary (the first glyph of a run takes the font's letter spacing instead) — a couple of pixels, not a glyph.
-
A taller run raises the line height for the whole block, so a
[f=heading]run inside body copy never overlaps the next line.
-
textScalemultiplies every run AND every inline sprite, so a scaledrich-text element stays internally consistent.
-
Rendering is the same batched per-vertex-colour quads over the same atlas
page fonts and sprites already share: no extra draw call, no second atlas.
Malformed markup warns and stays readable — never a crash, never eaten text:
| Input | Verdict |
|---|---|
an unknown tag ([b], [colour=red]) | drawn VERBATIM, one warning |
a bad colour ([c=tomato], [c=F80]) | drawn verbatim, one warning |
a [ with no ] | the rest of the string is plain text, one warning |
a stray [/c] / [/f] | dropped, one warning |
| a span left open | it ends with the text, one warning |
| an unknown font or sprite NAME | the run draws in the default font / the icon is skipped, one warning |
From Lua, and over MCP:
local reward = gui:findWidget("reward")
reward:setTextMarkup(true)
reward:setText("[c=FFCC33]+50[/c] points [sprite=coin]")
print(reward:getTextMarkup())
get_ui_layout's styles list carries the resolved markup flag beside the font/ink/scale fields, so an agent can assert that a rich-text screen really is reading its text as rich text. The pure parser is engine_gui/TextMarkup (TextMarkupTests covers the grammar and every verdict above; WrapLayoutTests covers the measurement), and projects/gallery's Styles tab is the worked example, asserted by player_gallery_selfcheck.
Named styles
A [Style NAME] section is a BUNDLE of ordinary widget keys — any subset: sprite, nineSlice, font, textColor, textScale, color, size, … A widget references it with style = NAME.
[Style hero]
font = heading
textColor = 1 0.82 0.35 1
nineSlice = true
[Button play]
style = hero # the bundle SEEDS every key it carries
sprite = button
textColor = 0.35 1 0.65 1 # ...and the widget's OWN key overrides it
text = Play
Application order is STYLE FIRST, then the widget's own explicit keys. The bundle seeds the widget and every key the widget spells out itself wins — the same declaration-order precedence the scene-side atmosphere preset uses. That rule is a pure function (engine_gui/GuiStyle), so it holds identically for every key a widget understands: a style can carry geometry, a sprite or a draw mode just as well as text style, with no per-key plumbing.
The rest of the contract:
-
Styles resolve at load AND at
.ouihot-reload, once, before any widget isbuilt — so a style edit lands in a running Play session like any other property change.
-
Styles do NOT nest: a
stylekey inside a[Style]section is ignored. -
An unknown style name is one warning and the widget's own keys alone — the
screen still builds.
-
A
[Style]section is not a widget: it has no rect, it never appears in theUI Editor's widget tree, and widgets point at it through their Style row.
-
The document model round-trips it unchanged (it is an ordinary section), so
the visual editor's save is byte-stable over a styled screen.
Over MCP nothing new is needed — the file is the interface. Author it with write_project_file, then read the RESOLVED result back: get_ui_layout returns a styles list parallel to ids/rects, each entry a flat hasText font textScale colourSet r g b a, which is what the widget actually draws with after the merge. preview_ui renders the styled screen to a PNG.
projects/gallery's Styles tab is the worked example: two named styles applied across a label, two buttons and a toggle, one of the buttons overriding just its ink, plus the second font and a 2x-scaled label — asserted end to end by the player_gallery_selfcheck ctest.
Modals in .oui
[Modal confirm]
scrim = 0 0 0 0.5 # backdrop tint r g b a (optional)
lightDismiss = false # tap-outside-to-close (optional)
[Button confirmYes]
modal = confirm # created on the modal's content layer + torn down with it
sprite = button
text = Yes
position = 300 320
size = 160 48
A widget with modal = <id> is created on that modal's content layer (above its scrim) and freed when the modal is dismissed.
Toggle groups in .oui
[ToggleGroup quality]
members = optLow optMed optHigh # space-separated checkbox ids
selected = 1 # optional
allowNone = false # optional
Tab bars in .oui
A tab bar pairs each tab checkbox with a content panel shown while that tab is selected (the tabs join a single-selection group; the selected tab's panel gets group-alpha 1, the rest 0 — cascaded and input-inert). Author the tab checkboxes and the panels as ordinary widgets, then wire them:
[TabBar mainTabs]
tabs = tabItems tabStats # space-separated tab checkbox ids
panels = panelItems panelStats # content widget ids, index-aligned with tabs
selected = 0 # initial tab (optional; default 0)
List views in .oui
A list view is a scroll viewport with a built-in vertical-group content container; items seeds its rows and it scrolls when they overflow. Add / remove rows at runtime with list:addItem(text) / removeItem(id) / clear().
[ListView inventory]
anchor = stretchall
offsets = 8 8 -8 -8
items = Sword | Shield | Potion # pipe-separated initial rows (optional)
Visual editor (Preview → Edit UI + the UI Editor panel)
The Preview panel edits the picked .oui visually. Pick a screen in the Overlay dropdown and tick Edit UI: the Preview panel becomes the CANVAS (the screen rendered through the SAME preview gui stack, with the edit adornments) and its slim toolbar keeps only the Edit UI toggle, a + Add button and a trash-can delete button; the TOOL SURFACE — widget tree, properties, anchor gizmo, align/distribute, add/delete and the undo/redo/save controls — lives in the dockable UI Editor panel (a tab beside the Inspector by default), which auto-opens on entering Edit UI and shows an honest empty state otherwise. The .oui text file stays the source of truth — the canvas, the UI Editor panel and hand/agent editing are views of the one GuiLayoutDoc.
Canvas. Click a widget to select it (a hit-test over the resolved widget rects the runtime reports). The selection draws an outline (the align key object in amber, the rest in blue) with eight resize handles; hovering another widget outlines it. Drag the body to move, a handle to resize. Hold Shift while dragging to snap to a coarse design-unit grid. Every adornment (outline, handles, anchor grips, pivot, guides, marquee) is CLIPPED to the canvas image rect, so an edge grip of a stretch widget never bleeds across the neighbouring panels; the surface->screen transform (mapSurfaceRectToScreen) maps a full-width widget to exactly the device screen, never wider.
Multi-select. Shift-click on the canvas or Shift/Ctrl(Cmd)-click in the tree adds or removes a widget from an ordered selection whose FIRST member is the align key object; a drag on empty canvas rubber-bands a marquee that selects every intersecting widget. Dragging any selected widget moves the whole set as one undo step. Arrow keys nudge the selection by one design unit (Shift = ten); a held-key burst folds into a single undo step.
Smart guides. While a widget moves, candidate lines from the siblings' edges/centres, the parent rect's edges/centre and the design-resolution centre light up when an edge or centre of the dragged rect comes within a few screen pixels, and the widget snaps to the line. The grid snap (Shift) takes over when held — guides are off then.
Anchors on the canvas. A layout widget shows four anchor triangles at its parent-relative anchor fractions and a pivot dot at its pivot. Drag a triangle to retarget an anchor corner (anchorMin/anchorMax) — the offsets recompute so the on-screen rect does not jump; drag the pivot dot to move the pivot while the rect stays visually fixed (anchoredPos re-derives from the unchanged offsets).
Anchor-preserving edits. A drag edits the widget's own geometry form and never rewrites its anchors:
-
A layout widget in
offsetsform shifts all four offsets (move) or thedragged edge (resize); in
anchoredPos/sizeDeltaform it shiftsanchoredPos(move) or growssizeDeltaabout the pivot (resize). -
A legacy absolute widget edits its
position/size.
Screen pixels convert to design units through the layout's [Layout] design reference scale, so a drag moves the widget by exactly the on-screen distance.
UI Editor panel. Its header carries Undo / Redo / Save and the save-state indicator (* unsaved / saved). Below sits a widget tree (the .oui section order, children indented by parent) with synced multi-selection — selecting in the tree selects on the canvas and vice versa; an align/distribute row when two or more widgets are selected (align left/centre/right/top/middle/ bottom to the key object's matching edge or centre, distribute horizontally/ vertically to equal gaps between the extremes); and a properties area for the key object laid out to Inspector parity — the same 30/70 label-left / value-right columns, the baked small value font, the shared dense grid style and OS-mannered tooltips — grouped under collapsing headers in the component-header visual language: Widget (text, z order, a sprite field — a manual entry plus a pick popup listing the sprite names in the current layout's loaded atlas, the same names the runtime renders through, with a (none) clear — a Style combo listing the [Style NAME] bundles this screen declares (with a (none) clear, and an honest inline note when the widget names a style the file does not carry), and for a text-bearing kind the text-style trio: a Font combo over the atlas' fonts by ROLE NAME (the index beside it; an authored name the live atlas lacks is flagged inline rather than rewritten), a Text Color RGBA picker and a Text Scale field), Anchors for a layout widget (the anchor-preset gizmo, the anchor combo, and the geometry fields) or Transform for a legacy absolute widget (position, size). Multi-value fields are per-axis drag-floats through the same helper the Inspector uses (offsets as four L/T/R/B fields, anchoredPos/position/pivot/sizeDelta/size as two), with trimmed display and full-precision editing; a drag folds into one undo step and the canvas catches up on release. The anchor-preset gizmo is a 4×3 point grid plus a stretch column and row, one click per the 16 LayoutAnchorPresets: a plain click re-anchors the widget while preserving its on-screen size (so a stretch↔point switch can never leave a degenerate box the caption would draw outside), Alt also moves the pivot to the preset point, Shift also keeps the whole on-screen rect (recomputing offsets); the anchor combo routes through the same size-preserving apply. An Add Widget button opens a searchable picker of the kinds (label, button, checkbox, slider, progressbar, selectmenu, dropdown, textentry, textbox, panel, scrollview) — the Inspector's Add-Component pattern (and shares its primary-button shade) — that adds one under the selection (or at the root) with sane defaults (a positive default box on every kind, so a freshly added label's centred caption stays inside its rect) and a unique id; the Preview panel's slim + Add opens the SAME picker canvas-adjacent. A trash-can control on the selected widget-tree row removes the whole selection in one undo step — the Inspector's per-component remove-control pattern (a hover disc behind the glyph); the Preview panel's slim toolbar carries the same trash-can for a canvas-adjacent delete. Undo / Redo step one gesture at a time (a drag, an align, a nudge burst are each one step); global Cmd/Ctrl+Z routes to the document while the UI Editor panel or the canvas holds focus, so it never edits the scene from the UI-editing context. A gesture the window system ends — the app losing focus mid-drag, the pointer leaving the window — is cancelled: the cursor is invalid at that moment, so applying its delta would resize the widget against a position that does not exist; the widget keeps the geometry it had when the gesture started and nothing enters the undo history.
Alignment write-back. Align and distribute operate on the resolved surface rects, so a selection spanning different parents aligns in screen space; the resulting per-widget translation is replayed through each widget's OWN geometry form (offsets / friendly anchoredPos / legacy position), so no widget changes representation and the whole command is one undo step.
Save & round-trip. Each gesture writes the .oui through GuiLayout's serializer and reloads the canvas, so the panel is WYSIWYG and — because the write goes to the real file — a running Play session hot-reloads it (below), the same as a hand save. The writer emits the canonical form: the first edit normalises spacing and drops comments (the document model does not preserve them); thereafter edits are minimal, clean diffs. An unedited screen is never rewritten, so opening it and leaving edit mode leaves the bytes untouched.
The editing core is UI-independent and headless-tested: EditorUiEdit (hit-testing, the drag→offset/resize math, the anchor-preset gizmo and anchor/ pivot drag math, align/distribute over rect lists, marquee intersection, smart- guide candidates + snapping, palette placement, add/remove, the snapshot undo document with nudge-burst coalescing) with EditorUiEditTests unit coverage plus the round-trip assertion on the shipped sample screens. That suite includes an alignment-switching matrix — every anchor preset (16) × modifier variant (plain / keep-rect / also-pivot) × representative widget kinds (an offsets-form stretch caption, a friendly nine-slice decor, a wrap textbox, a button) — that asserts, per application, the doc-model anchor fields, the resolved-rect expectation (keep-rect holds the whole rect; plain holds the size and anchoredPosition), content containment (a non-degenerate resolved box — the necessary-and-sufficient condition for a caption to stay inside, since the runtime clips to the box whenever its width is non-zero and only escapes a zero/negative box), save→reload→re-resolve equality, and byte-exact undo, plus interaction chains (preset→drag→preset, preset→resize→undo→redo). A sibling palette-add matrix asserts every palette kind's default box is positive and stays positive across an immediate re-anchor to every preset (the add→ anchor-switch chain), with a direct guard that the text-bearing kinds carry a positive default sizeDelta. The panel wiring — load, multi-select, align, marquee, keep-rect anchor preset, undo, a content-containment matrix and a palette-add-every-kind containment leg through the real load→apply→reload path — is the editor_uiedit selfcheck on both flavors. All alignment tooling resolves each widget's rect through the ONE UiLayout resolver: the live overlay rects on the Ogre-Next canvas, and a document-side resolve of the same math elsewhere, so the tools work whether or not the live canvas is available. The canvas RENDER is Ogre-Next only (the offscreen UI composition capability); on the classic flavor the document editing still works, the live canvas does not.
Agents do not need this panel: they author .oui text with write_project_file and verify the resolve with preview_ui / get_ui_layout — the visual editor is the human front-end onto the same file and the same resolver.
Hot-reload during Play (.oui iteration)
Editing an .oui while a Play session runs updates the running game's screen live — the same iteration loop .lua scripts have. On a file save the editor's .oui watcher sends the fresh screen to the player, which destroys that screen's widgets and rebuilds them from the new file (a CLEAN CUTOVER — no state is merged; GuiFactory tracks which widgets/modals/toggle-groups each loaded .oui produced and tears exactly those down). Trigger it three ways:
-
Save the file in an external editor while Play runs (the editor watches the
project tree for
*.oui, same cadence/lifecycle as thescripts/watcher). -
reload_ui(file)over MCP (fileis the name the game passed toloadLayout, e.g.hud.oui). -
Both funnel to the ONE
MSG_RELOAD_UIplayer message, applied at the frameboundary (never mid-frame).
Contract:
-
A broken (unparseable)
.ouikeeps the OLD screen up and reports the parseerror to the editor Console as a
[remote]line — a half-built screen never renders. -
Script handles go stale. A rebuild destroys the old widget objects, so any
woptr/handle a script cached is dead. The player emitsui.reloaded {file}on the script event bus after a successful rebuild; subscribe ininitand re-acquire handles withgui:findWidget(id):function init(self) factory:loadLayout("hud.oui") self.score = gui:findWidget("scoreLabel") events.subscribe("ui.reloaded", function(e) if e.file == "hud.oui" then self.score = gui:findWidget("scoreLabel") -- rebuilt: re-acquire end end) end
The editor never hot-reloads its own GUI Preview stage through this path (that panel has its own refresh).
MCP (agent control)
-
reload_ui— hot-reload one.ouiscreen on the running game (destroy +rebuild from the fresh file); a parse failure keeps the old screen and surfaces a
[remote]error, a rebuild emitsui.reloaded {file}. See "Hot-reload during Play" above. -
get_ui_layout— the running game's widgets: parallelids/rects/styleslists. Each rect is a flatleft top width height visible enabled modalstring (pixels; the three flags are1/0); each style a flathasText font textScale colourSet r g b a markupstring — the RESOLVED text style (a named style plus the widget's own keys),markup= the text is read as inline rich text. Readmodalto assert a dialog is up,enabledto assert a row is disabled. -
gui_press— synthesize a press on a widget by id, routed through the REALinput path, so modal/disabled semantics apply (a button under a scrim does NOT fire; a disabled widget stays inert).
-
dismiss_modal— close a modal by id, or the topmost one. -
preview_ui— render a.ouiscreen at a SIMULATED device context (resolution-
content scale + safe-area notch) into an offscreen target and return a
screenshot + the resolved widget rects, with no running player. Single context via
width/height/scale/insets, or a device-matrix sweep viacontexts. The agent's half of the collaborative loop below.
-
content scale + safe-area notch) into an offscreen target and return a
The GUI Preview tab (the collaborative design loop)
The editor's GUI Preview panel (View ▸ GUI Preview) renders a project screen through the SAME real gui stack the game uses — an isolated instance, never the running game's — into an offscreen target, at a simulated device you pick (resolution presets phone/tablet/desktop + custom, content scale 1×/2×/3×, a notch preset). It watches the previewed .oui's mtime and rebuilds on change, so an agent editing the file over MCP (write_project_file) is reflected live in the human's tab. The optional widget-rect overlay outlines every resolved widget.
That is the loop: an agent authors a screen (write_project_file), preview_ui screenshots it across device contexts and reads back the rects, a human watches the tab update, both iterate on the one shared .oui. The preview needs offscreen 2D composition (a DrawLayer2D compositing into a RenderTexture), which is an Ogre-Next capability; on the classic editor the tab shows a disabled note and preview_ui returns an honest error (see Docs/render-abstraction.md).
See Docs/mcp.md for the full endpoint.
Recipes
Tabs
Use a tab bar: a single-selection group of tab checkboxes, each paired with a content panel whose visibility follows the selection. The bar wires the radio semantics and swaps the panels for you (group-alpha, cascaded + input-inert).
local tabs = gui:createTabBar("mainTabs")
tabs:addTab("tabGeneral", "panelGeneral") -- (tab checkbox id, content panel id)
tabs:addTab("tabAudio", "panelAudio")
tabs:setSelected(0)
-- in update: if tabs:pollChanged() then ... end -- the panels already swapped
Or author the whole thing declaratively with the .oui [TabBar] section (see Tab bars in .oui).
Lists
Use a list view: a scroll viewport with a built-in vertical content group.
local list = gui:getFactory():createListView("inventory", pos, size, "gui_default", 6)
list:addItem("Sword")
list:addItem("Shield")
-- list:removeItem(id) / list:clear() / list:getItemCount()
Or author it with the .oui [ListView] section (seed rows with items).
Show / hide transitions
Declare the transition and play it — see Animation & juice.
panel:setTransition("slide-up 0.3") -- both directions
sheet:setEnterTransition("fade 0.25 | slide 0 -40") -- composed, show only
guitween.show("panel") -- slide in
guitween.hide("panel") -- slide back out, then park hidden
Or declaratively, played as the screen is built (and again whenever a tab bar reveals the panel):
[Panel sheet]
enter = fade 0.25 quadOut | slide 0 -40 0.3 backOut
Inline rich text
One caption, several styled runs plus an icon — see Inline rich text.
[Label reward]
markup = true
text = [c=FFCC33]+50[/c] points [sprite=coin]
reward:setTextMarkup(true)
reward:setText("[c=FFCC33]+50[/c] points [sprite=coin]")
Value-label binding
Slider / progress bar carry a caption; update it from the value each frame.
slider:setCaption(tostring(slider:getSelectedItemIndex()))
Widget click sounds
Poll the click and play a UI sound (no engine hook needed):
if button:wasClicked() then sound:play("ui_click") end
World-space text (WorldTextComponent)
Text placed IN the 3D scene — floating damage numbers, name tags, world-space labels — is engine_gocomponent/WorldTextComponent, not a gui widget. It is a composition of two existing systems, so it reuses this page's font machinery rather than inventing its own:
-
Glyphs come from the SAME baked font page the gui uses (
FontAtlas/UiFont— kerning, design-pixel metrics and lazy CJK/Cyrillic paging all included). A shared engine-default page (orkige_engine/media/fonts/ world_text.ogui, Nunito) is baked ONCE and reused by every world-text component — never a second bake per label. -
Quads are CPU-billboarded camera-facing textured quads through the facade
SpriteBatch(the same recipeParticleComponentuses for 3D particles), with glyph UVs in place of a particle atlas frame. The pure layout isengine_gui/WorldTextLayout(headless-tested).
It is AUTHORED scene content (like a sprite or a particle emitter), so it renders in edit mode, in Preview and in Play — there is no editor-only bit.
Reflected look (inspector / serialization / Lua / MCP — the ONE registry)
| property | type | meaning |
|---|---|---|
text | String | the literal string (\n opens a new line; center-justified) |
size | Float | world units per line height |
colour | Color | tint, multiplied over the glyph coverage |
billboard | Bool | face the camera (default) or lie in the object's local XY plane |
visible | Bool | show / hide |
-- a name tag over a character (a script sets the literal; loc() stays in Lua)
self.worldtext.text = loc("enemy_name")
self.worldtext:setColour(1.0, 0.3, 0.2, 1.0)
Behavior & caveats
-
Placement.
billboard = true(default) hangs the glyph batch off the worldroot and re-faces it to the window camera each frame at the object's world position (the 3D-particle attachment).
billboard = falseattaches the batch to the transform node, so the object's rotation orients the text in its local XY plane. The text is CENTER-anchored on the node (v1). -
Layout vs. refresh. The glyph layout rebuilds only on a
text/sizechange; a moving camera or object re-runs just the cheap per-frame quad refresh (allocation-free steady state).
-
Transparency. Like
SpriteComponent, the glyph quads are alpha-blended anddepth-tested but NOT depth-sorted against other transparent 3D content — two overlapping labels can resolve in submission order. Keep labels from overlapping where ordering matters.
-
Editor facing (v1). In edit mode the text renders and faces the camera as
sampled at build time (on any property change); the editor does not tick GameObjects, so it does not continuously re-face as the Scene camera orbits. Play / Preview re-face live.