Orkige Help DownloadOrkige

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, the

    modal stack, toggle groups and toasts. In Lua this is the gui object.

  • GuiFactory builds widgets (createButton, createCheckBox, …) and loads

    .oui layouts. In Lua this is the factory object.

    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:

  1. 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.

  2. 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.

  3. Asset default — an .oui [Layout] input = on|off key is an explicit

    choice at load time (it beats auto); absent, the auto path decides. input off on 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)

  1. Author a .oui text layout (grammar below) — or write_project_file it

    over MCP.

  2. Load it: factory:loadLayout("screens/pause.oui") (or gui:loadLayout).
  3. Find each widget by id: local btn = gui:findWidget("resume") (nil if

    absent).

  4. Wire behavior each frame: if btn:wasClicked() then ... end.
  5. Verify: read back get_ui_layout over MCP (per-widget pixel rects + the

    visible/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).

FormMeaning
[Layout]optional global section (first)
atlas = gui_defaultdefault atlas for widgets that omit one
design = 1280 720 0.5design resolution W H + match factor (drives scale)
root = fullwindow | safearealayout root rect
input = on | offscreen-level input enablement (explicit; beats auto)
[Type id]a widget; Type ∈ the widget-type list below
z = 2draw layer (painter order; higher = front)
sprite = button | noneatlas sprite; none on a decor = solid fill
font = 9glyph/font index
text = Play | @keycaption; leading @ = StringTable lookup
position = 40 40 / size = 200 48fixed pixel placement
textAlignment = toplefttext anchor within the rect
enabled = falseinput-inert + dimmed (default true)
parent = panelIdrect-anchor layout parent
anchor = centeranchor preset (or anchorMin/anchorMax pair)
pivot = 0.5 0.5pivot within the rect
offsets = l t r bedge offsets from the anchored rect
anchoredPos = 0 0 / sizeDelta = 460 460anchored position / size
useSafeArea = trueinset the rect by the device safe area
group = vertical | horizontal | gridmake this a layout container
padding = l t r b / spacing = 10container spacing
childAlign / childExpand / fitcontainer child rules
cellSize = w h / gridConstraint = ngrid-group geometry
nineSlice = true | tiled = true | color = r g b adraw modes (decor, button, checkbox, text entry; color decor-only)
checkbox = truecheckbox: the BOX skin (a check symbol beside the caption) instead of the two-state PLATE
items = A | B | Cdropdown / listview / selectmenu / slider options (pipe-separated)
wrap = truelabel / textbox: break the text to the widget width
markup = trueread the caption as inline rich text ([c=..] / [f=..] spans, [sprite=..] icons)
style = NAMEapply a [Style NAME] bundle first, then this widget's own keys
textColor = r g b a / textScale = 2caption ink / glyph size multiplier
pressFeedback = truebutton: the face snaps smaller on press and springs back
multiline = truetext entry: a text area (soft wrap, Return inserts a line break)
virtualized = true / itemHeight = 24list view: materialise only the visible rows, at that uniform row height
transition = fade 0.2BOTH directions from one spec (the exit reverses it; see Animation)
enter = fade 0.25 quadOut | slide 0 -40the SHOW transition only: |-composed clauses, each with its own duration + easing curve
exit = fade 0.15the HIDE transition only
modal = confirmIdcreate on that modal's content layer + tear down with it
[Modal id] + scrim = r g b a / lightDismiss = boola modal scrim
[ToggleGroup id] + members = a b c / selected / allowNonea radio group
[TabBar id] + tabs = a b / panels = pa pb / selecteda tab bar (panel visibility follows the tab)
[ListView id] + items = A | Ba scrolling vertical list (seed rows)

Widget Types: label, textbox, button, checkbox, selectmenu, slider, progressbar, textentry, decorwidget / panel, scrollview, listview, dropdown.

Widget set

WidgetPurposeKey API (poll / set)
GuiLabelone line of text, or wrap-to-widthsetText / setWrap
GuiTextboxmulti-line rich text (UiMarkupText), wrap-to-widthsetText / setWrap
GuiButtonpressable buttonwasClicked / ButtonHitEvent, setPressFeedback
GuiCheckBoxon/off toggle (may join a toggle group)isChecked / setChecked
GuiSelectMenuan option cycler (‹ value ›)getSelectedIndex / setItemsString
GuiDropDownan option list dropped on a modalgetSelectedIndex / setItemsString
GuiSlidera dragged value over discrete itemsgetSelectedItemIndex / setCaption
GuiProgressBara fill bar with a captionsetProgress / setCaption
GuiTextEntrytext field: single-line, or a multi-line text areagetText / wasSubmitted / setText / setMultiline
GuiDecorWidgetsprite panel OR (empty sprite) solid fill; nine-slice / tiledsetColour / setAlpha
GuiScrollViewa clipping viewport whose content scrollssetScroll / getScroll / getMaxScroll
GuiListViewa vertical list: a scroll viewport with a built-in content container; optionally virtualizedaddItem / removeItem / clear / getItemCount / setVirtualized
GuiToasta passive, timed, self-dismissing notification(built via gui:showToast)
GuiModalScrimthe 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

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-testing

    entirely — 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 cursor

    event, 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).

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 /

    showAlert default 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 EaseLibrary name. Every call returns a handle:

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 — move drives anchoredPosition, size drives sizeDelta — so the resolver and the animation cooperate. scale and rotate are 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]:

ClauseChannel 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 an enter animates itself in as the

    screen 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 ScrollMomentum state machine, unit-tested headlessly.

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 DrawLayer2D batch. 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.

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:

FormMeaning
[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). A

    line 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.

  • textScale multiplies every run AND every inline sprite, so a scaled

    rich-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:

InputVerdict
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 openit ends with the text, one warning
an unknown font or sprite NAMEthe 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 .oui hot-reload, once, before any widget is

    built — so a style edit lands in a running Play session like any other property change.

  • Styles do NOT nest: a style key 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 the

    UI 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 offsets form shifts all four offsets (move) or the

    dragged edge (resize); in anchoredPos/sizeDelta form it shifts anchoredPos (move) or grows sizeDelta about 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 the scripts/ watcher).

  • reload_ui(file) over MCP (file is the name the game passed to

    loadLayout, e.g. hud.oui).

  • Both funnel to the ONE MSG_RELOAD_UI player message, applied at the frame

    boundary (never mid-frame).

Contract:

  • A broken (unparseable) .oui keeps the OLD screen up and reports the parse

    error 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 emits ui.reloaded {file} on the script event bus after a successful rebuild; subscribe in init and re-acquire handles with gui: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 .oui screen on the running game (destroy +

    rebuild from the fresh file); a parse failure keeps the old screen and surfaces a [remote] error, a rebuild emits ui.reloaded {file}. See "Hot-reload during Play" above.

  • get_ui_layout — the running game's widgets: parallel ids / rects /

    styles lists. Each rect is a flat left top width height visible enabled modal string (pixels; the three flags are 1/0); each style a flat hasText font textScale colourSet r g b a markup string — the RESOLVED text style (a named style plus the widget's own keys), markup = the text is read as inline rich text. Read modal to assert a dialog is up, enabled to assert a row is disabled.

  • gui_press — synthesize a press on a widget by id, routed through the REAL

    input 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 .oui screen 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 via contexts. The agent's half of the collaborative loop below.

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 recipe ParticleComponent uses for 3D particles), with glyph UVs in place of a particle atlas frame. The pure layout is engine_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)

propertytypemeaning
textStringthe literal string (\n opens a new line; center-justified)
sizeFloatworld units per line height
colourColortint, multiplied over the glyph coverage
billboardBoolface the camera (default) or lie in the object's local XY plane
visibleBoolshow / 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 world

    root and re-faces it to the window camera each frame at the object's world position (the 3D-particle attachment). billboard = false attaches 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/size

    change; 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 and

    depth-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.