Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Recoil:RmlUI Starter Guide: Difference between revisions

From Fightorder
No edit summary
Tag: Reverted
Undo revision 2705 by Qrow (talk)
Tag: Undo
 
(One intermediate revision by the same user not shown)
Line 1: Line 1:
{{DISPLAYTITLE:RmlUI Starter Guide}}
__TOC__


= RmlUI Starter Guide =


RmlUi is a UI framework that is defined using a HTML/CSS style workflow (using Lua instead of JS) intended to simplify UI development especially for those already familiar with web development. It is designed for interactive applications, and so is reactive by default. You can learn more about it on the [RmlUI website] and [differences in the Recoil version here](#differences-between-upstream-rmlui-and-rmlui-in-recoil).
'''''For RecoilEngine game developers — build reactive, HTML/CSS-style game UIs in Lua.'''''


# How does RmlUI Work?
{| class="wikitable"
! Attribute !! Value
|-
| Engine || RecoilEngine (Spring)
|-
| Framework || RmlUi ([https://github.com/mikke89/RmlUi mikke89/RmlUi])
|-
| Language || Lua (via sol2 bindings)
|-
| Availability || LuaUI (LuaIntro / LuaMenu planned)
|}


To get started, it's important to learn a few key concepts.
----
- Context: This is a bundle of documents and data models.
- Document: This is a document tree/DOM (document object model) holding the actual UI.
- RML: This is the markup language used to define the document, it is very similar to XHTML (HTML but must be well-formed XML).
- RCSS: This is the styling language used to style the document, it is very similar to CSS2 but does differ in some places.
- Data Model: This holds information (a Lua table) that is used in the UI code in data bindings.


On the Lua side, you are creating 1 or more contexts and adding data models and documents to them.
== 1. Introduction ==


On the Rml side, you are creating documents to be loaded by the Lua which will likely include data bindings to show information from the data model, and references to lua functions for event handlers.
RmlUI is a UI framework that lets you build reactive game interfaces using an HTML/CSS-style workflow — with Lua instead of JavaScript. If you have ever built a web page, the learning curve is gentle. If you haven't, the concepts are still approachable because the mental model (mark-up + style + logic) is widely documented.


As each widget/component you create will likely comprise of several files (.lua, .rml & .rcss) you may find having a folder for each widget/component a useful way to organise your files.
RecoilEngine ships with a full RmlUI integration including:


# Getting Started
* An OpenGL 3 renderer tightly coupled to the engine's rendering pipeline
* A virtual-filesystem-aware file loader so your <code>.rml</code> and <code>.rcss</code> files live inside game archives
RmlUi is available in LuaUI (the game UI) with future availability in LuaIntro & LuaMenu planned, so it is already there for you to use.
* Complete Lua bindings via sol2 so every RmlUI object is accessible from Lua
However, to get going with it there is some setup code that should be considered for loading fonts, cursors and configuring ui scaling, an example script is provided below.
* Custom elements: <code><nowiki><texture></nowiki></code> for engine textures and <code><nowiki><svg></nowiki></code> for vector art
If you are working on a widget for an existing game, it is likely that the game already has some form of this setup code in, so you may be able to skip this section.
* A built-in DOM debugger for interactive inspection


# Setup script
This guide walks you through everything you need to get a working, reactive widget running in LuaUI. The examples are game-engine-agnostic and safe to copy into any Recoil-based game.


Here is the "[handler](https://github.com/beyond-all-reason/Beyond-All-Reason/blob/master/luaui/rml_setup.lua)", written by lov and ChrisFloofyKitsune, 2 of the people responsible for the RmlUi implementation.
{{Note|RmlUI is currently available in '''LuaUI''' (the in-game UI). Support for LuaIntro and LuaMenu is planned for a future release.}}


```lua
----
-- luaui/rml_setup.lua
-- author:  lov + ChrisFloofyKitsune
--
--  Copyright (C) 2024.
--  Licensed under the terms of the GNU GPL, v2 or later.


if (RmlGuard or not RmlUi) then
== 2. Key Concepts ==
return
end
-- don't allow this initialization code to be run multiple times
RmlGuard = true


--[[
Before writing any code it helps to understand the five core objects:
Recoil uses a custom set of Lua bindings (check out rts/Rml/SolLua/bind folder in the C++ engine code)
Aside from the Lua API, the rest of the RmlUi documentation is still relevant
https://mikke89.github.io/RmlUiDoc/index.html
]]


--[[ create a common Context to be used for widgets
{| class="wikitable"
pros:
! Concept !! Description
* Documents in the same Context can make use of the same DataModels, allowing for less duplicate data
|-
* Documents can be arranged in front/behind of each other dynamically
| '''Context''' || A named container that holds documents and data models. Multiple contexts can exist simultaneously, each rendered independently.
cons:
|-
* Documents in the same Context can make use of the same data models, leading to side effects
| '''Document''' || A parsed RML file representing a DOM tree. Documents live inside a Context and can be shown, hidden, or closed.
* DataModels must have unique names within the same Context
|-
| '''RML''' || The markup language for documents. Very similar to XHTML (well-formed XML). The root tag is <code><nowiki><rml></nowiki></code> instead of <code><nowiki><html></nowiki></code>.
|-
| '''RCSS''' || The styling language. Similar to CSS2 with some differences (see section 11). File extension: <code>.rcss</code>.
|-
| '''Data Model''' || A Lua table exposed to the RML document via reactive data bindings. Changes to the model automatically update the rendered UI.
|}


If you have lots of DataModel use you may want to create your own Context
On the '''Lua side''' you create contexts, attach data models to them, and load documents. On the '''RML side''' you declare your UI structure and reference data-model values through data bindings.
otherwise you should be able to just use the shared Context


Contexts created with the Lua API are automatically disposed of when the LuaUi environment is unloaded
Each widget will typically consist of at least three files: a <code>.lua</code>, a <code>.rml</code>, and optionally a <code>.rcss</code> file — grouping them in a folder per widget keeps things tidy.
]]


local oldCreateContext = RmlUi.CreateContext
----


local function NewCreateContext(name)
== 3. Setup ==
local context = oldCreateContext(name)


-- set up dp_ratio considering the user's UI scale preference and the screen resolution
RecoilEngine ships a minimal setup script at <code>cont/LuaUI/rml_setup.lua</code>. It is included automatically by the base content handler but you should understand what it does so you can extend it for your game.
local viewSizeX, viewSizeY = Spring.GetViewGeometry()


local userScale = Spring.GetConfigFloat("ui_scale", 1)
=== 3.1 The rml_setup.lua script ===


local baseWidth = 1920
The script performs three tasks: guards against double-initialisation, loads font faces, and registers mouse-cursor aliases. Below is a more complete version that also creates a shared context and configures dp scaling:
local baseHeight = 1080
local resFactor = math.min(viewSizeX / baseWidth, viewSizeY / baseHeight)


context.dp_ratio = resFactor * userScale
<syntaxhighlight lang="lua">
--  luaui/rml_setup.lua
--  author:  lov + ChrisFloofyKitsune
--  License: GNU GPL, v2 or later


context.dp_ratio = math.floor(context.dp_ratio * 100) / 100
if (RmlGuard or not RmlUi) then
return context
    return
end
end
-- Prevent this from running more than once
RmlGuard = true


-- Patch CreateContext to set dp_ratio automatically
local oldCreateContext = RmlUi.CreateContext
local function NewCreateContext(name)
    local context = oldCreateContext(name)
    local viewSizeX, viewSizeY = Spring.GetViewGeometry()
    local userScale = Spring.GetConfigFloat("ui_scale", 1)
    local baseWidth, baseHeight = 1920, 1080
    local resFactor = math.min(viewSizeX / baseWidth, viewSizeY / baseHeight)
    -- Floor to 2 decimal places to avoid floating point drift
    context.dp_ratio = math.floor(resFactor * userScale * 100) / 100
    return context
end
RmlUi.CreateContext = NewCreateContext
RmlUi.CreateContext = NewCreateContext


-- Load fonts
-- Load fonts (list your .ttf files here)
local font_files = {
local font_files = {
    -- "Fonts/MyFont-Regular.ttf",
}
}
for _, file in ipairs(font_files) do
for _, file in ipairs(font_files) do
Spring.Echo("loading font", file)
    RmlUi.LoadFontFace(file, true)
RmlUi.LoadFontFace(file, true)
end
end


 
-- Map CSS cursor names to engine cursor names
-- Mouse Cursor Aliases
-- CSS cursor list: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
--[[
These let standard CSS cursor names be used when doing styling.
If a cursor set via RCSS does not have an alias, it is unchanged.
CSS cursor list: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
RmlUi documentation: https://mikke89.github.io/RmlUiDoc/pages/rcss/user_interface.html#cursor
]]
 
-- when "cursor: normal" is set via RCSS, "cursornormal" will be sent to the engine... and so on for the rest
RmlUi.SetMouseCursorAlias("default", 'cursornormal')
RmlUi.SetMouseCursorAlias("default", 'cursornormal')
RmlUi.SetMouseCursorAlias("pointer", 'Move') -- command cursors use the command name. TODO: replace with actual pointer cursor?
RmlUi.SetMouseCursorAlias("move",   'uimove')
RmlUi.SetMouseCursorAlias("move", 'uimove')
RmlUi.SetMouseCursorAlias("pointer", 'Move')
RmlUi.SetMouseCursorAlias("nesw-resize", 'uiresized2')
RmlUi.SetMouseCursorAlias("nwse-resize", 'uiresized1')
RmlUi.SetMouseCursorAlias("ns-resize", 'uiresizev')
RmlUi.SetMouseCursorAlias("ew-resize", 'uiresizeh')


-- Create the shared context used by all widgets
RmlUi.CreateContext("shared")
RmlUi.CreateContext("shared")
```
</syntaxhighlight>


What this does is create a unified context 'shared' for all your documents and data models, which is currently the recommended way to architect documents. If you have any custom font files, list them in `font_files`, otherwise leave it empty.
=== 3.2 Including the setup in main.lua ===


The setup script above can then be included from your `luaui/main.lua` (main.lua is the entry point for the LuaUI environment).
Add a single line to <code>luaui/main.lua</code> to run the setup before any widgets load:


```lua
<syntaxhighlight lang="lua">
VFS.Include(LUAUI_DIRNAME .. "rml_setup.lua", nil, VFS.ZIP) -- Runs the script
-- luaui/main.lua
```
VFS.Include(LUAUI_DIRNAME .. "rml_setup.lua", nil, VFS.ZIP)
> [!NOTE] If you are working on a widget for an existing game, check whether the game already has a setup script — it may not include all of the pieces shown above (font loading, cursor aliases, context creation, dp_ratio).
</syntaxhighlight>


{{Note|The base-content <code>rml_setup.lua</code> only loads a font and sets cursor aliases; it does '''not''' create a context. If you are building a new game you should add context creation here or let each widget create its own context.}}


### Keeping dp_ratio Updated
----


The setup script sets `dp_ratio` once at context creation time, but the user may resize the window or change their `ui_scale` config during a session. To keep `dp_ratio` correct you need to recalculate and reapply it whenever the viewport changes.
== 4. Writing Your First Document (.rml) ==


The `ViewResize` callin fires whenever the window is resized. Add it to your widget and update each context you own:
Create a file at <code>luaui/widgets/my_widget/my_widget.rml</code>. RML is well-formed XML, so every tag must be closed and attribute values must be quoted.


```lua
=== 4.1 Root structure ===
function widget:ViewResize(newSizeX, newSizeY)
    local userScale = Spring.GetConfigFloat("ui_scale", 1)
    local baseWidth = 1920
    local baseHeight = 1080
    local resFactor = math.min(newSizeX / baseWidth, newSizeY / baseHeight)
    local dp = math.floor(resFactor * userScale * 100) / 100
    widget.rmlContext.dp_ratio = dp
end
```


If you have multiple contexts, update each one. [Mupersega's `rml_context_manager.lua`](https://github.com/beyond-all-reason/Beyond-All-Reason/blob/master/luaui/rml_context_manager.lua) in Beyond All Reason is a good reference implementation that handles context lifecycle and `dp_ratio` tracking together.
<syntaxhighlight lang="xml">
<rml>
<head>
    <title>My Widget</title>
    <!-- External stylesheet (optional) -->
    <link type="text/rcss" href="my_widget.rcss"/>
    <!-- Inline styles -->
    <style>
        body { margin: 0; padding: 0; }
    </style>
</head>
<body>
    <!-- Your content here -->
</body>
</rml>
</syntaxhighlight>


### Writing Your First Document
=== 4.2 Styles (inline and .rcss) ===


You will be creating files with .rml and .rcss extensions, as these closely resemble HTML and CSS it is worth configuring your editor to treat these file extensions as HTML and CSS respectively, see the [IDE Setup](#ide-setup) section for more information.
RmlUI starts with '''no default styles''' at all — not even block/inline defaults. The RmlUI docs provide an HTML4 base stylesheet you can copy in, but you will need to add form element styles yourself.


Now, create an RML file somewhere under `luaui/widgets/`, like `luaui/widgets/getting_started.rml`. This is the UI document.
Use <code>dp</code> units instead of <code>px</code> so your UI scales correctly with the player's display and <code>ui_scale</code> setting.


Writing it is much like HTML by design. There are some differences, the most immediate being the root tag is called rml instead of html, most other RML/HTML differences relate to attributes for databindings and events, but for the time being, we don't need to worry about them.
<syntaxhighlight lang="css">
/* Typical widget container */
#my-widget {
    position: absolute;
    width: 400dp;
    right: 10dp;
    top: 50%;
    transform: translateY(-50%);
    pointer-events: auto;  /* required to receive mouse input */
}


By default RmlUi has *NO* styles, this includes setting default element behaviour like block/inline and styles web developers would expect like input elements default appearances, as a starting point you can use [RmlUi documentation](https://mikke89.github.io/RmlUiDoc/pages/rml/html4_style_sheet.html) though these do not include styles for form elements.
#my-widget .panel {
    padding: 10dp;
    border-radius: 8dp;
    border: 1dp #6c7086;  /* note: no 'solid' keyword – see section 11 */
    background-color: rgba(30, 30, 46, 200);
}
</syntaxhighlight>


Here's a basic widget written by Mupersega.
=== 4.3 Data bindings in RML ===


```html
Attach a data model to a root element with <code>data-model="model_name"</code>. All data binding attributes inside that element can reference the model.
<rml>
<head>
    <title>Rml Starter</title>


    <style>
<syntaxhighlight lang="xml">
        #rml-starter-widget {
            pointer-events: auto;
            width: 400dp;
            right: 0;
            top: 50%;
            transform: translateY(-90%);
            position: absolute;
            margin-right: 10dp;
        }
        #main-content {
            padding: 10dp;
            border-radius: 8dp;
            z-index: 1;
        }
        #expanding-content {
            transform: translateY(0%);
            transition: top 0.1s linear-in-out;
            z-index: 0;
            height: 100%;
            width: 100%;
            position: absolute;
            top: 0dp;
            left: 0dp;
            border-radius: 8dp;
            display: flex;
            flex-direction: column;
            justify-content: flex-end;
            align-items: center;
            padding-bottom: 20dp;
        }
        /* This is just a checkbox sitting above the entirety of the expanding content */
        /* It is bound directly with data-checked attr to the expanded value */
        #expanding-content>input {
            height: 100%;
            width: 100%;
            z-index: 1;
            position: absolute;
            top: 0dp;
            left: 0dp;
        }
        #expanding-content.expanded {
            top: 90%;
        }
        #expanding-content:hover {
            background-color: rgba(255, 0, 0, 125);
        }
    </style>
</head>
<body>
<body>
    <div id="rml-starter-widget" class="relative" data-model="starter_model">
  <!-- data-model scopes all bindings inside this div -->
        <div id="main-content">
  <div id="my-widget" data-model="my_model">
            <h1 class="text-primary">Welcome to an Rml Starter Widget</h1>
 
            <p>This is a simple example of an RMLUI widget.</p>
    <!-- Text interpolation -->
            <div>
    <p>Status: {{status_message}}</p>
                <button onclick="widget:Reload()">reload widget</button>
 
            </div>
    <!-- Conditional visibility -->
        </div>
    <div data-if="show_details">
        <div id="expanding-content" data-class-expanded="expanded">
        <p>Extra details go here.</p>
            <input type="checkbox" value="expanded" data-checked="expanded"/>
    </div>
            <p>{{message}}</p>
 
            <div>
    <!-- Dynamic class based on model value -->
                <span data-for="test, i: testArray">name:{{test.name}} index:{{i}}</span>
    <div class="panel" data-class-active="is_active">
            </div>
         Active panel
         </div>
     </div>
     </div>
    <!-- Loop over an array -->
    <ul>
        <li data-for="item, i: items">{{i}}: {{item.label}}</li>
    </ul>
    <!-- Two-way checkbox binding -->
    <input type="checkbox" data-checked="is_enabled"/>
    <!-- Data event: calls model function on click -->
    <button data-click="on_button_click()">Click me</button>
  </div>
</body>
</body>
</rml>
</syntaxhighlight>
```


Let's take a look at different areas that are important to look at.
'''Data binding reference:'''


`<div id="rml-starter-widget" class="relative" data-model="starter_model">`
{| class="wikitable"
1. Here, we bind to the data model using `data-model`. This is what we will need to name the data model in our Lua script later. Everything inside the model will be in scope and beneath the div.
! Binding !! Effect
2. Typically, it is recommended to bind your data model inside of a div beneath `body` rather than `body` itself.
|-
| <code><nowiki>{{value}}</nowiki></code> || Interpolate model value as text
|-
| <code>data-model="name"</code> || Attach named data model to this element and its children
|-
| <code>data-if="flag"</code> || Show element only when flag is truthy
|-
| <code>data-class-X="flag"</code> || Add class X when flag is truthy
|-
| <code>data-for="v, i: arr"</code> || Repeat element for each item in array arr
|-
| <code>data-checked="val"</code> || Two-way bind checkbox to boolean model value
|-
| <code>data-click="fn()"</code> || Call model function on click (data event)
|-
| <code>data-attr-src="val"</code> || Dynamically set the src attribute from model value
|}


```html
----
<div id="expanding-content" data-class-expanded="expanded">
    <input type="checkbox" value="expanded" data-checked="expanded"/>
```
any attribute starting with `data-` is a "data event". We will go through a couple below, but you can find out more here [on the RmlUi docs site](https://mikke89.github.io/RmlUiDoc/pages/data_bindings/views_and_controllers.html).
1. Double curly braces are used to show values from the data model within the document text. e.g. `{{message}}` shows the value of `message` in the data model.
2. `data-class-expanded="expanded"` applies the `expanded` class to the div if the value `expanded` in the data model is `true`.
3. `<input type="checkbox" value="expanded" data-checked="expanded"/>` This is a two-way binding: the checkbox reflects the current value of `expanded` in the data model, and toggling it writes back `true` or `false` to that field.
4. When the data model is changed, the document is rerendered automatically. So, the expanding div will have the `expanded` class applied to it or removed whenever the check box is toggled.


There are data bindings to allow you to loop through arrays (data-for) have conditional sections of the document (data-if) and many others.  
== 5. The Lua Widget ==


### The Lua
Each widget is a Lua file that the handler loads. The <code>widget</code> global is injected by the widget handler and provides the lifecycle hooks.


> [!NOTE] For information on setting up your editor to provide intellisense behaviour see the [Lua Language Server guide]({{% ref "lua-language-server" %}}).
=== 5.1 Widget skeleton ===


To load your document into the shared context we created earlier and to define and add the data model you will need to have a lua script something like the one below.
<syntaxhighlight lang="lua">
 
-- luaui/widgets/my_widget/my_widget.lua
```lua
if not RmlUi then return end   -- guard: RmlUi not available
-- luaui/widgets/getting_started.lua
if not RmlUi then
    return
end


local widget = widget ---@type Widget
local widget = widget ---@type Widget
Line 262: Line 249:
function widget:GetInfo()
function widget:GetInfo()
     return {
     return {
         name = "Rml Starter",
         name   = "My Widget",
         desc = "This widget is a starter example for RmlUi widgets.",
         desc   = "Demonstrates RmlUI basics.",
         author = "Mupersega",
         author = "YourName",
         date = "2025",
         date   = "2025",
         license = "GNU GPL, v2 or later",
         license = "GNU GPL, v2 or later",
         layer = -1000000,
         layer   = 0,
         enabled = true
         enabled = true,
     }
     }
end
end
</syntaxhighlight>


local document
=== 5.2 Loading the document ===
local dm_handle
 
<syntaxhighlight lang="lua">
local RML_FILE  = "luaui/widgets/my_widget/my_widget.rml"
local MODEL_NAME = "my_model"
 
local rml_ctx    -- the RmlUi Context
local dm_handle -- the DataModel handle (MUST be kept – see section 6)
local document  -- the loaded Document
 
-- Initial data model state
local init_model = {
local init_model = {
     expanded = false,
     status_message = "Ready",
     message = "Hello, find my text in the data model!",
    is_enabled    = false,
     testArray = {
     show_details  = false,
         { name = "Item 1", value = 1 },
     items = {
         { name = "Item 2", value = 2 },
         { label = "Alpha", value = 1 },
        { name = "Item 3", value = 3 },
         { label = "Beta", value = 2 },
     },
     },
    -- Functions can live in the model too (callable from data-events)
    on_button_click = function()
        Spring.Echo("Button was clicked!")
    end,
}
}
local main_model_name = "starter_model"


function widget:Initialize()
function widget:Initialize()
     widget.rmlContext = RmlUi.GetContext("shared") -- Get the context from the setup lua
     rml_ctx = RmlUi.GetContext("shared")


     -- Open the model, using init_model as the template. All values inside are copied.
     -- IMPORTANT: assign dm_handle or the engine WILL crash on RML render
    -- Returns a handle, which we will touch on later.
     dm_handle = rml_ctx:OpenDataModel(MODEL_NAME, init_model)
     dm_handle = widget.rmlContext:OpenDataModel(main_model_name, init_model)
     if not dm_handle then
     if not dm_handle then
         Spring.Echo("RmlUi: Failed to open data model ", main_model_name)
         Spring.Echo("[my_widget] Failed to open data model")
         return
         return
     end
     end
    -- Load the document we wrote earlier.
 
     document = widget.rmlContext:LoadDocument("luaui/widgets/getting_started.rml", widget)
     document = rml_ctx:LoadDocument(RML_FILE, widget)
     if not document then
     if not document then
         Spring.Echo("Failed to load document")
         Spring.Echo("[my_widget] Failed to load document")
         return
         return
     end
     end
    -- uncomment the line below to enable debugger
    -- RmlUi.SetDebugContext('shared')


     document:ReloadStyleSheet()
     document:ReloadStyleSheet()
     document:Show()
     document:Show()
end
end
</syntaxhighlight>


=== 5.3 Shutting down cleanly ===
<syntaxhighlight lang="lua">
function widget:Shutdown()
function widget:Shutdown()
    widget.rmlContext:RemoveDataModel(main_model_name)
     if document then
     if document then
         document:Close()
         document:Close()
        document = nil
     end
     end
    -- Remove the model from the context; frees memory
    rml_ctx:RemoveDataModel(MODEL_NAME)
    dm_handle = nil
end
end
</syntaxhighlight>


-- This function is only for dev experience, ideally it would be a hot reload, and not required at all in a completed widget.
----
function widget:Reload(event)
 
    Spring.Echo("Reloading")
== 6. Data Models in Depth ==
     Spring.Echo(event)
 
    widget:Shutdown()
=== 6.1 Opening a data model ===
     widget:Initialize()
 
<code>context:OpenDataModel(name, table)</code> copies the structure of the table into a reactive proxy. The name must match the <code>data-model="name"</code> attribute in your RML. It must be unique within the context.
 
{{Warning|You '''must''' store the return value of <code>OpenDataModel</code> in a variable. Letting it be garbage-collected while the document is open will crash the engine the next time RmlUI tries to render a data binding.}}
 
=== 6.2 Reading and writing values ===
 
For simple string/number/boolean values at the top level of the model you can use dot-notation directly on the handle:
 
<syntaxhighlight lang="lua">
-- Read a value
local msg = dm_handle.status_message  -- works
 
-- Write a value (auto-marks the field dirty; UI updates next frame)
dm_handle.status_message = "Unit selected"
dm_handle.is_enabled     = true
</syntaxhighlight>
 
=== 6.3 Iterating arrays ===
 
Because the handle is a sol2 proxy, you cannot iterate it directly with <code>pairs</code> or <code>ipairs</code>. Call <code>dm_handle:__GetTable()</code> to get the underlying Lua table:
 
<syntaxhighlight lang="lua">
local tbl = dm_handle:__GetTable()
 
for i, item in ipairs(tbl.items) do
     Spring.Echo(i, item.label, item.value)
    item.value = item.value + 1  -- modify in place
end
end
```
</syntaxhighlight>
 
=== 6.4 Marking dirty ===
 
When you modify nested values (e.g. elements inside an array) through <code>__GetTable()</code>, you must tell the model which top-level key changed so that RmlUI re-renders the bound elements:
 
<syntaxhighlight lang="lua">
-- After modifying tbl.items …
dm_handle:__SetDirty("items")
 
-- For a simple top-level assignment via dm_handle.key = value,
-- dirty-marking is done automatically.
</syntaxhighlight>
 
----
 
== 7. Events ==
 
Recoil's RmlUI supports two distinct event families. They look similar but have different scopes — mixing them up is a common source of confusion.
 
=== 7.1 Normal events (on*) ===
 
Written as <code>onclick="myFunc()"</code>. The function is resolved from the '''second argument''' passed to <code>LoadDocument</code> — the widget table. These events do '''not''' have access to the data model.
 
<syntaxhighlight lang="lua">
-- Lua
local doc_scope = {
    greet = function(name)
        Spring.Echo("Hello", name)
    end,
}
document = rml_ctx:LoadDocument(RML_FILE, doc_scope)
</syntaxhighlight>
 
<syntaxhighlight lang="xml">
<!-- RML -->
<button onclick="greet('World')">Say hello</button>
</syntaxhighlight>
 
=== 7.2 Data events (data-*) ===
 
Written as <code>data-click="fn()"</code>. The function is resolved from the '''data model'''. These events have full access to all model values.
 
<syntaxhighlight lang="lua">
-- Lua model
local init_model = {
    counter = 0,
    increment = function()
        -- dm_handle is captured in the closure
        dm_handle.counter = dm_handle.counter + 1
    end,
}
</syntaxhighlight>
 
<syntaxhighlight lang="xml">
<!-- RML -->
<p>Count: {{counter}}</p>
<button data-click="increment()">+1</button>
</syntaxhighlight>
 
'''Event type comparison:'''
 
{| class="wikitable"
! Type !! Syntax example !! Scope
|-
| Normal || <code>onclick="fn()"</code> || <code>LoadDocument</code> second argument (widget table)
|-
| Data || <code>data-click="fn()"</code> || Data model table
|-
| Normal || <code>onmouseover="fn()"</code> || <code>LoadDocument</code> second argument
|-
| Data || <code>data-mouseover="fn()"</code> || Data model table
|}
 
----


### The Data Model Handle
== 8. Custom Recoil Elements ==


In the script, we are given a data model handle. This is a proxy for the Lua table used as the data model; as the Recoil RmlUi integration uses Sol2 as a wrapper data cannot be accessed directly.
Beyond standard HTML elements, Recoil adds two custom RML elements.


In most cases, you can simply do `dm_handle.expanded = true`. Assigning to any field on the handle (or any nested table within it, at any depth) automatically marks the relevant parts of the model as dirty and triggers a UI update — you do not need to call `__SetDirty` manually.
=== 8.1 The &lt;texture&gt; element ===


What if you have an array, like `testArray` above, and want to loop through it on the Lua side? You will need to get the underlying table:
Renders any engine texture — unit icons, map thumbnails, GL4 render targets, etc. Behaves identically to <code><nowiki><img></nowiki></code> except the <code>src</code> attribute takes a '''Recoil texture reference string''' (see engine docs for the full format).


```lua
<syntaxhighlight lang="xml">
local model_handle = dm_handle:__GetTable()
<!-- Load a file from VFS -->
```
<texture src="unitpics/armcom.png" width="64dp" height="64dp"/>


As of writing, this function is not documented in the Lua API, due to some problems with language server generics that haven't been sorted out yet. It is there, however, and will be added back in in the future. It returns the table with the shape of your initial model.
<!-- Reference a named GL texture -->
You can then iterate through it and change things as you please:
<texture src="%luaui:myTextureName" width="128dp" height="128dp"/>
</syntaxhighlight>


```lua
=== 8.2 The &lt;svg&gt; element ===
for i, v in pairs(model_handle.testArray) do
    Spring.Echo(i, v.name)
    v.value = v.value + 1
end
```


> [!NOTE] Use `pairs` rather than `ipairs` when iterating over data model arrays on the Lua side. The model handle does not currently implement `__len`, so `ipairs` will not traverse the array correctly. This will be resolved once `__len` is backported.
Renders an SVG image. Unlike upstream RmlUI, the Recoil implementation accepts either a file path '''or raw inline SVG data''' in the <code>src</code> attribute:


### Debugging
<syntaxhighlight lang="xml">
<!-- External file -->
<svg src="images/icon.svg" width="32dp" height="32dp"/>


RmlUi comes with a debugger that lets you see and interact with the DOM. It's very handy! To use it, use this:
<!-- Inline SVG data -->
<svg src="<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><circle cx='5' cy='5' r='4' fill='red'/></svg>"
    width="32dp" height="32dp"/>
</syntaxhighlight>


```lua
----
RmlUi.SetDebugContext(DATA_MODEL_NAME)
```


And it will appear in-game. A useful idiom I like is to put this in `rml_setup.lua`:
== 9. Debugging ==


```lua
RmlUI ships with a DOM debugger that you can open in-game to inspect elements, check computed styles, and view event logs.
local DEBUG_RMLUI = true


--...
<syntaxhighlight lang="lua">
-- Enable the debugger for the shared context.
-- Call this AFTER the context has been created.
RmlUi.SetDebugContext('shared')


-- A handy pattern: toggle via a build flag in rml_setup.lua
local DEBUG_RMLUI = true  -- set false before shipping
if DEBUG_RMLUI then
if DEBUG_RMLUI then
     RmlUi.SetDebugContext('shared')
     RmlUi.SetDebugContext('shared')
end
end
```
</syntaxhighlight>
 
Once enabled, a small panel appears in the game window. Click '''Debug''' to open the inspector or '''Log''' for the event log.


If you use the shared context, you can see everything that happens in it! Neat!
{{Note|The debugger attaches to one context at a time. Using the shared context means you see all widgets in one place — very useful for tracking cross-widget interactions.}}


## Things to Know and Best Practices
----


### Things to know
== 10. Best Practices ==
Some of the rough edges you are likely to run into have already been discussed, like the data model thing, but here are some more:


---
; Use dp units everywhere
: The <code>dp</code> unit scales with <code>context.dp_ratio</code>, so your UI automatically adapts to different resolutions and the player's <code>ui_scale</code> setting.


Unlike a web browser a default set of styles is not included, as a starting point you can look at the [RmlUi documentation](https://mikke89.github.io/RmlUiDoc/pages/rml/html4_style_sheet.html) though this doesn't provide styles for form elements.
; One shared context per game
: Beyond All Reason and most community projects use a single <code>'shared'</code> context. Documents in the same context can reference the same data models and are Z-ordered relative to each other.


Input elements of type submit & button behave differently to HTML and more like Button elements in that their text is not set by the value attribute. (This is likely to be corrected in a future version)
; Keep data models flat when possible
: Top-level writes via <code>dm_handle.key = value</code> are automatically dirty-marked. Deeply nested structures require manual <code>__SetDirty</code> calls — simpler models mean fewer bugs.


The alpha/transparency value of an RGBA colour is different to CSS (0-1) and instead uses 0-255. The css opacity does still use 0-1.
; Separate per-document and shared styles
: Put styles unique to one document in a <code><nowiki><style></nowiki></code> block inside the <code>.rml</code> file. Put styles shared across multiple documents in a <code>.rcss</code> file and link it.


List styling is unavailable (list-style-type etc.), you can still use UL/OL/LI elements but there is no special meaning to them, whilst you could use background images to replicate bullets there isn't a practical way to achieve numbered list items with RML/RCSS.
; Guard against missing RmlUi
: Always start widgets with <code>if not RmlUi then return end</code> to prevent errors in environments where RmlUI is unavailable.


Only solid borders are supported, so the border-style property is unavailable and the shorthand border property doesn't include a style part (```border: 1dp solid black;``` won't work, instead use ```border: 1dp black;```).
; Check return values
: Both <code>OpenDataModel</code> and <code>LoadDocument</code> can fail silently if paths are wrong. Always check for nil and log an error so you know immediately what happened.


background-color behaves as expected, all other background styles are different and use decorators instead see the [RmlUi documentation for more information on decorators.](https://mikke89.github.io/RmlUiDoc/pages/rcss/decorators.html)
; Organise by widget folder
: Group <code>luaui/widgets/my_widget/my_widget.lua</code>, <code>.rml</code>, and <code>.rcss</code> together. This makes the project navigable and each widget self-contained.


There are two kinds of events: data events, like `data-mouseover`, and normal events, like `onmouseover`. These have different data in their scopes.
----
- Data events have the data model in their scope.
- Normal events don't have the data model, but they *do* have whatever is passed into `widget` on `widget.rmlContext:LoadDocument`. `widget` doesn't have to be a widget, just any table with data in it.


For example, take this:
== 11. RCSS Gotchas ==


```lua
RCSS closely follows CSS2 but has several important differences that will trip up web developers:
local model = {
 
    add = function(a, b)
; No default stylesheet
        Spring.Echo(a + b)
: RmlUI ships with zero default styles. Block/inline, heading sizes, list bullets — none of it exists unless you add it. Copy the HTML4 base sheet from the RmlUI docs as a starting point.
    end
 
}
; RGBA alpha is 0–255
local document_table = {
: <code>rgba(255, 0, 0, 128)</code> means 50% transparent red. CSS uses 0.0–1.0 for alpha. The <code>opacity</code> property still uses 0.0–1.0.
    print = function(msg)
 
        Spring.Echo(msg)
; Border shorthand has no style keyword
    end,
: <code>border: 1dp #6c7086;</code> works. <code>border: 1dp solid #6c7086;</code> does '''not''' work. Only solid borders are supported; <code>border-style</code> is unavailable.
}
 
; background-* properties use decorators
: <code>background-color</code> works normally. <code>background-image</code>, <code>background-size</code>, etc. are replaced by the decorator system. See the RmlUI docs for syntax.
 
; No list styling
: <code>list-style-type</code> and friends are not supported. <code>ul</code>/<code>ol</code>/<code>li</code> have no special rendering — treat them as plain div-like elements.
 
; Input text is set by content, not value
: For <code><nowiki><input type="button"></nowiki></code> and <code><nowiki><input type="submit"></nowiki></code> the displayed text comes from the element's text content, not the <code>value</code> attribute (unlike HTML).
 
; pointer-events: auto required
: By default elements do not receive mouse events. Add <code>pointer-events: auto</code> to any element that needs to be interactive.
 
----
 
== 12. Differences from Upstream RmlUI ==
 
The Recoil implementation is based on the official RmlUI library but adds engine-specific features and changes some defaults:
 
{| class="wikitable"
! Feature !! Description
|-
| '''<nowiki><texture></nowiki> element''' || A custom element that renders engine-managed textures via Recoil texture reference strings. Not present in upstream.
|-
| '''<nowiki><svg></nowiki> inline data''' || The <code>src</code> attribute accepts raw SVG markup in addition to file paths. Upstream only supports file paths.
|-
| '''VFS file loader''' || All file paths are resolved through Recoil's Virtual File System, so <code>.rml</code> and <code>.rcss</code> files work inside <code>.sdz</code> game archives transparently.
|-
| '''Lua bindings via sol2''' || The Lua API is provided by RmlSolLua (derived from [https://github.com/LoneBoco/RmlSolLua LoneBoco/RmlSolLua]) rather than the upstream Lua plugin, offering tighter engine integration.
|-
| '''dp_ratio via context''' || <code>context.dp_ratio</code> is set from code rather than the system DPI, allowing games to honour the player's <code>ui_scale</code> preference.
|}
 
{{Note|For everything not listed above, the upstream RmlUI documentation at [https://mikke89.github.io/RmlUiDoc/ mikke89.github.io/RmlUiDoc] applies directly.}}
 
----


dm_handle = widget.rmlContext:OpenDataModel("test", model)
== 13. IDE Setup ==
document = widget.rmlContext:LoadDocument("document.rml", document_table)
```


```html
Configuring your editor for <code>.rml</code> and <code>.rcss</code> file extensions dramatically improves the authoring experience.
<h1>Normal Events</h1>
<input type="button" onclick="add(1, 2)">Won't work!</input>
<input type="button" onclick="print('test')">Will work!</input>


<h1>Data Events</h1>
=== VS Code ===
<input type="button" data-click="add(1, 2)">Will work!</input>
<input type="button" data-click="print('test')">Won't work!</input>
```


### Best Practices
# Open any <code>.rml</code> file in VS Code.
# Click the language mode indicator in the status bar (usually shows ''Plain Text'').
# Choose '''Configure File Association for '.rml''''
# Select '''HTML''' from the list.
# Repeat for <code>.rcss</code> files, selecting '''CSS'''.


- To create a scalable interface the use of the dp unit over px is recommended as the scale can be set per context with SetDensityIndependentPixelRatio.
=== Lua Language Server (lua-ls) ===
- For styles unique to a document, put them in a `style` tag. For shared styles, put them in an `rcss` file.
- Rely on Recoil's RmlUi Lua bindings doc for what you can and can't do. The Recoil implementation has some extra stuff the RmlUi docs don't.
- The Beyond All Reason devs prefer to use one shared context for all rmlui widgets.


Add the Recoil type annotations to your workspace for autocomplete on <code>RmlUi</code>, <code>Spring</code>, and all widget APIs. See the Recoil docs guide on the Lua Language Server for details.


### Differences between upstream RmlUI and RmlUI in Recoil
----


- The SVG element allows either a filepath or raw SVG data in the src attribute, allowing for inline svg to be used (this may change to svg being supported between the opening and closing tag when implemented upstream)
== 14. Quick Reference ==
- An additional element ```<texture>``` is available which allows for textures loaded in Recoil to be used, this behaves the same as an ```<img>``` element except the src attribute takes a [texture reference]({{% ref "articles/texture-reference-strings" %}})


=== Lua API ===


### IDE Setup
{| class="wikitable"
! Call !! Description
|-
| <code>RmlUi.CreateContext(name)</code> || Create a new rendering context
|-
| <code>RmlUi.GetContext(name)</code> || Retrieve an existing context by name
|-
| <code>RmlUi.LoadFontFace(path, fallback)</code> || Register a <code>.ttf</code> font (<code>fallback=true</code> recommended)
|-
| <code>RmlUi.SetMouseCursorAlias(css, engine)</code> || Map a CSS cursor name to an engine cursor name
|-
| <code>RmlUi.SetDebugContext(name)</code> || Attach the DOM debugger to a named context
|-
| <code>ctx:OpenDataModel(name, tbl)</code> || Create reactive data model from Lua table; '''store the return value!'''
|-
| <code>ctx:RemoveDataModel(name)</code> || Destroy a data model and free its memory
|-
| <code>ctx:LoadDocument(path, scope)</code> || Parse an RML file; scope is used for normal (<code>on*</code>) events
|-
| <code>doc:Show()</code> || Make the document visible
|-
| <code>doc:Hide()</code> || Hide the document (keeps it in memory)
|-
| <code>doc:Close()</code> || Destroy the document
|-
| <code>doc:ReloadStyleSheet()</code> || Reload linked <code>.rcss</code> files (useful during development)
|-
| <code>dm:__GetTable()</code> || Get the underlying Lua table for iteration
|-
| <code>dm:__SetDirty(key)</code> || Mark a top-level model key as changed; triggers re-render
|-
| <code>dm.key = value</code> || Write a top-level value; auto-marks dirty
|}


To get the best experience with RmlUi, you should set up your editor to use HTML syntax highlighting for .rml file extensions and CSS syntax highlighting for .rcss file extensions.
=== Useful Links ===


In VS Code, this can be done by opening a file with the extension you want to setup, then clicking on the language mode  in the bottom right corner of the window (probably shows as Plain Text). From there, you can select "Configure File Association for '.rml'" from the top menu that appears and choose "HTML" from the list. Do the same for .rcss files, but select "CSS".
* [https://mikke89.github.io/RmlUiDoc/ RmlUI official documentation]
* [https://mikke89.github.io/RmlUiDoc/pages/data_bindings/views_and_controllers.html RmlUI data bindings reference]
* [https://mikke89.github.io/RmlUiDoc/pages/rcss.html RmlUI RCSS reference]
* [https://mikke89.github.io/RmlUiDoc/pages/rml/html4_style_sheet.html RmlUI HTML4 base stylesheet]
* [https://mikke89.github.io/RmlUiDoc/pages/rcss/decorators.html RmlUI decorators (backgrounds)]
* [https://github.com/mikke89/RmlUi RmlUI GitHub]
* [https://github.com/beyond-all-reason/RecoilEngine/tree/master/rts/Rml RecoilEngine source (rts/Rml/)]
* Recoil texture reference strings — see engine docs: <code>articles/texture-reference-strings</code>


[RmlUI website]: https://mikke89.github.io/RmlUiDoc/
[[Category:Guides]]
[[Category:LuaUI]]
[[Category:RmlUI]]

Latest revision as of 23:18, 5 March 2026

RmlUI Starter Guide

For RecoilEngine game developers — build reactive, HTML/CSS-style game UIs in Lua.

Attribute Value
Engine RecoilEngine (Spring)
Framework RmlUi (mikke89/RmlUi)
Language Lua (via sol2 bindings)
Availability LuaUI (LuaIntro / LuaMenu planned)

1. Introduction

RmlUI is a UI framework that lets you build reactive game interfaces using an HTML/CSS-style workflow — with Lua instead of JavaScript. If you have ever built a web page, the learning curve is gentle. If you haven't, the concepts are still approachable because the mental model (mark-up + style + logic) is widely documented.

RecoilEngine ships with a full RmlUI integration including:

  • An OpenGL 3 renderer tightly coupled to the engine's rendering pipeline
  • A virtual-filesystem-aware file loader so your .rml and .rcss files live inside game archives
  • Complete Lua bindings via sol2 so every RmlUI object is accessible from Lua
  • Custom elements: <texture> for engine textures and <svg> for vector art
  • A built-in DOM debugger for interactive inspection

This guide walks you through everything you need to get a working, reactive widget running in LuaUI. The examples are game-engine-agnostic and safe to copy into any Recoil-based game.

RmlUI is currently available in LuaUI (the in-game UI). Support for LuaIntro and LuaMenu is planned for a future release.

2. Key Concepts

Before writing any code it helps to understand the five core objects:

Concept Description
Context A named container that holds documents and data models. Multiple contexts can exist simultaneously, each rendered independently.
Document A parsed RML file representing a DOM tree. Documents live inside a Context and can be shown, hidden, or closed.
RML The markup language for documents. Very similar to XHTML (well-formed XML). The root tag is <rml> instead of <html>.
RCSS The styling language. Similar to CSS2 with some differences (see section 11). File extension: .rcss.
Data Model A Lua table exposed to the RML document via reactive data bindings. Changes to the model automatically update the rendered UI.

On the Lua side you create contexts, attach data models to them, and load documents. On the RML side you declare your UI structure and reference data-model values through data bindings.

Each widget will typically consist of at least three files: a .lua, a .rml, and optionally a .rcss file — grouping them in a folder per widget keeps things tidy.


3. Setup

RecoilEngine ships a minimal setup script at cont/LuaUI/rml_setup.lua. It is included automatically by the base content handler but you should understand what it does so you can extend it for your game.

3.1 The rml_setup.lua script

The script performs three tasks: guards against double-initialisation, loads font faces, and registers mouse-cursor aliases. Below is a more complete version that also creates a shared context and configures dp scaling:

<syntaxhighlight lang="lua"> -- luaui/rml_setup.lua -- author: lov + ChrisFloofyKitsune -- License: GNU GPL, v2 or later

if (RmlGuard or not RmlUi) then

   return

end -- Prevent this from running more than once RmlGuard = true

-- Patch CreateContext to set dp_ratio automatically local oldCreateContext = RmlUi.CreateContext local function NewCreateContext(name)

   local context = oldCreateContext(name)
   local viewSizeX, viewSizeY = Spring.GetViewGeometry()
   local userScale = Spring.GetConfigFloat("ui_scale", 1)
   local baseWidth, baseHeight = 1920, 1080
   local resFactor = math.min(viewSizeX / baseWidth, viewSizeY / baseHeight)
   -- Floor to 2 decimal places to avoid floating point drift
   context.dp_ratio = math.floor(resFactor * userScale * 100) / 100
   return context

end RmlUi.CreateContext = NewCreateContext

-- Load fonts (list your .ttf files here) local font_files = {

   -- "Fonts/MyFont-Regular.ttf",

} for _, file in ipairs(font_files) do

   RmlUi.LoadFontFace(file, true)

end

-- Map CSS cursor names to engine cursor names -- CSS cursor list: https://developer.mozilla.org/en-US/docs/Web/CSS/cursor RmlUi.SetMouseCursorAlias("default", 'cursornormal') RmlUi.SetMouseCursorAlias("move", 'uimove') RmlUi.SetMouseCursorAlias("pointer", 'Move')

-- Create the shared context used by all widgets RmlUi.CreateContext("shared") </syntaxhighlight>

3.2 Including the setup in main.lua

Add a single line to luaui/main.lua to run the setup before any widgets load:

<syntaxhighlight lang="lua"> -- luaui/main.lua VFS.Include(LUAUI_DIRNAME .. "rml_setup.lua", nil, VFS.ZIP) </syntaxhighlight>

The base-content rml_setup.lua only loads a font and sets cursor aliases; it does not create a context. If you are building a new game you should add context creation here or let each widget create its own context.

4. Writing Your First Document (.rml)

Create a file at luaui/widgets/my_widget/my_widget.rml. RML is well-formed XML, so every tag must be closed and attribute values must be quoted.

4.1 Root structure

<syntaxhighlight lang="xml"> <rml> <head>

   <title>My Widget</title>
   <link type="text/rcss" href="my_widget.rcss"/>
   <style>
       body { margin: 0; padding: 0; }
   </style>

</head> <body> </body> </rml> </syntaxhighlight>

4.2 Styles (inline and .rcss)

RmlUI starts with no default styles at all — not even block/inline defaults. The RmlUI docs provide an HTML4 base stylesheet you can copy in, but you will need to add form element styles yourself.

Use dp units instead of px so your UI scales correctly with the player's display and ui_scale setting.

<syntaxhighlight lang="css"> /* Typical widget container */

  1. my-widget {
   position: absolute;
   width: 400dp;
   right: 10dp;
   top: 50%;
   transform: translateY(-50%);
   pointer-events: auto;   /* required to receive mouse input */

}

  1. my-widget .panel {
   padding: 10dp;
   border-radius: 8dp;
   border: 1dp #6c7086;   /* note: no 'solid' keyword – see section 11 */
   background-color: rgba(30, 30, 46, 200);

} </syntaxhighlight>

4.3 Data bindings in RML

Attach a data model to a root element with data-model="model_name". All data binding attributes inside that element can reference the model.

<syntaxhighlight lang="xml"> <body>

Status: Template:Status message

Extra details go here.

       Active panel
   <input type="checkbox" data-checked="is_enabled"/>
   <button data-click="on_button_click()">Click me</button>

</body> </syntaxhighlight>

Data binding reference:

Binding Effect
{{value}} Interpolate model value as text
data-model="name" Attach named data model to this element and its children
data-if="flag" Show element only when flag is truthy
data-class-X="flag" Add class X when flag is truthy
data-for="v, i: arr" Repeat element for each item in array arr
data-checked="val" Two-way bind checkbox to boolean model value
data-click="fn()" Call model function on click (data event)
data-attr-src="val" Dynamically set the src attribute from model value

5. The Lua Widget

Each widget is a Lua file that the handler loads. The widget global is injected by the widget handler and provides the lifecycle hooks.

5.1 Widget skeleton

<syntaxhighlight lang="lua"> -- luaui/widgets/my_widget/my_widget.lua if not RmlUi then return end -- guard: RmlUi not available

local widget = widget ---@type Widget

function widget:GetInfo()

   return {
       name    = "My Widget",
       desc    = "Demonstrates RmlUI basics.",
       author  = "YourName",
       date    = "2025",
       license = "GNU GPL, v2 or later",
       layer   = 0,
       enabled = true,
   }

end </syntaxhighlight>

5.2 Loading the document

<syntaxhighlight lang="lua"> local RML_FILE = "luaui/widgets/my_widget/my_widget.rml" local MODEL_NAME = "my_model"

local rml_ctx -- the RmlUi Context local dm_handle -- the DataModel handle (MUST be kept – see section 6) local document -- the loaded Document

-- Initial data model state local init_model = {

   status_message = "Ready",
   is_enabled     = false,
   show_details   = false,
   items = {
       { label = "Alpha", value = 1 },
       { label = "Beta",  value = 2 },
   },
   -- Functions can live in the model too (callable from data-events)
   on_button_click = function()
       Spring.Echo("Button was clicked!")
   end,

}

function widget:Initialize()

   rml_ctx = RmlUi.GetContext("shared")
   -- IMPORTANT: assign dm_handle or the engine WILL crash on RML render
   dm_handle = rml_ctx:OpenDataModel(MODEL_NAME, init_model)
   if not dm_handle then
       Spring.Echo("[my_widget] Failed to open data model")
       return
   end
   document = rml_ctx:LoadDocument(RML_FILE, widget)
   if not document then
       Spring.Echo("[my_widget] Failed to load document")
       return
   end
   document:ReloadStyleSheet()
   document:Show()

end </syntaxhighlight>

5.3 Shutting down cleanly

<syntaxhighlight lang="lua"> function widget:Shutdown()

   if document then
       document:Close()
       document = nil
   end
   -- Remove the model from the context; frees memory
   rml_ctx:RemoveDataModel(MODEL_NAME)
   dm_handle = nil

end </syntaxhighlight>


6. Data Models in Depth

6.1 Opening a data model

context:OpenDataModel(name, table) copies the structure of the table into a reactive proxy. The name must match the data-model="name" attribute in your RML. It must be unique within the context.

<translate> Warning</translate> <translate> Warning:</translate> You must store the return value of OpenDataModel in a variable. Letting it be garbage-collected while the document is open will crash the engine the next time RmlUI tries to render a data binding.

6.2 Reading and writing values

For simple string/number/boolean values at the top level of the model you can use dot-notation directly on the handle:

<syntaxhighlight lang="lua"> -- Read a value local msg = dm_handle.status_message -- works

-- Write a value (auto-marks the field dirty; UI updates next frame) dm_handle.status_message = "Unit selected" dm_handle.is_enabled = true </syntaxhighlight>

6.3 Iterating arrays

Because the handle is a sol2 proxy, you cannot iterate it directly with pairs or ipairs. Call dm_handle:__GetTable() to get the underlying Lua table:

<syntaxhighlight lang="lua"> local tbl = dm_handle:__GetTable()

for i, item in ipairs(tbl.items) do

   Spring.Echo(i, item.label, item.value)
   item.value = item.value + 1   -- modify in place

end </syntaxhighlight>

6.4 Marking dirty

When you modify nested values (e.g. elements inside an array) through __GetTable(), you must tell the model which top-level key changed so that RmlUI re-renders the bound elements:

<syntaxhighlight lang="lua"> -- After modifying tbl.items … dm_handle:__SetDirty("items")

-- For a simple top-level assignment via dm_handle.key = value, -- dirty-marking is done automatically. </syntaxhighlight>


7. Events

Recoil's RmlUI supports two distinct event families. They look similar but have different scopes — mixing them up is a common source of confusion.

7.1 Normal events (on*)

Written as onclick="myFunc()". The function is resolved from the second argument passed to LoadDocument — the widget table. These events do not have access to the data model.

<syntaxhighlight lang="lua"> -- Lua local doc_scope = {

   greet = function(name)
       Spring.Echo("Hello", name)
   end,

} document = rml_ctx:LoadDocument(RML_FILE, doc_scope) </syntaxhighlight>

<syntaxhighlight lang="xml"> <button onclick="greet('World')">Say hello</button> </syntaxhighlight>

7.2 Data events (data-*)

Written as data-click="fn()". The function is resolved from the data model. These events have full access to all model values.

<syntaxhighlight lang="lua"> -- Lua model local init_model = {

   counter = 0,
   increment = function()
       -- dm_handle is captured in the closure
       dm_handle.counter = dm_handle.counter + 1
   end,

} </syntaxhighlight>

<syntaxhighlight lang="xml">

Count: 108

<button data-click="increment()">+1</button> </syntaxhighlight>

Event type comparison:

Type Syntax example Scope
Normal onclick="fn()" LoadDocument second argument (widget table)
Data data-click="fn()" Data model table
Normal onmouseover="fn()" LoadDocument second argument
Data data-mouseover="fn()" Data model table

8. Custom Recoil Elements

Beyond standard HTML elements, Recoil adds two custom RML elements.

8.1 The <texture> element

Renders any engine texture — unit icons, map thumbnails, GL4 render targets, etc. Behaves identically to <img> except the src attribute takes a Recoil texture reference string (see engine docs for the full format).

<syntaxhighlight lang="xml"> <texture src="unitpics/armcom.png" width="64dp" height="64dp"/>

<texture src="%luaui:myTextureName" width="128dp" height="128dp"/> </syntaxhighlight>

8.2 The <svg> element

Renders an SVG image. Unlike upstream RmlUI, the Recoil implementation accepts either a file path or raw inline SVG data in the src attribute:

<syntaxhighlight lang="xml"> <svg src="images/icon.svg" width="32dp" height="32dp"/>

<svg src="<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><circle cx='5' cy='5' r='4' fill='red'/></svg>"

    width="32dp" height="32dp"/>

</syntaxhighlight>


9. Debugging

RmlUI ships with a DOM debugger that you can open in-game to inspect elements, check computed styles, and view event logs.

<syntaxhighlight lang="lua"> -- Enable the debugger for the shared context. -- Call this AFTER the context has been created. RmlUi.SetDebugContext('shared')

-- A handy pattern: toggle via a build flag in rml_setup.lua local DEBUG_RMLUI = true -- set false before shipping if DEBUG_RMLUI then

   RmlUi.SetDebugContext('shared')

end </syntaxhighlight>

Once enabled, a small panel appears in the game window. Click Debug to open the inspector or Log for the event log.

The debugger attaches to one context at a time. Using the shared context means you see all widgets in one place — very useful for tracking cross-widget interactions.

10. Best Practices

Use dp units everywhere
The dp unit scales with context.dp_ratio, so your UI automatically adapts to different resolutions and the player's ui_scale setting.
One shared context per game
Beyond All Reason and most community projects use a single 'shared' context. Documents in the same context can reference the same data models and are Z-ordered relative to each other.
Keep data models flat when possible
Top-level writes via dm_handle.key = value are automatically dirty-marked. Deeply nested structures require manual __SetDirty calls — simpler models mean fewer bugs.
Separate per-document and shared styles
Put styles unique to one document in a <style> block inside the .rml file. Put styles shared across multiple documents in a .rcss file and link it.
Guard against missing RmlUi
Always start widgets with if not RmlUi then return end to prevent errors in environments where RmlUI is unavailable.
Check return values
Both OpenDataModel and LoadDocument can fail silently if paths are wrong. Always check for nil and log an error so you know immediately what happened.
Organise by widget folder
Group luaui/widgets/my_widget/my_widget.lua, .rml, and .rcss together. This makes the project navigable and each widget self-contained.

11. RCSS Gotchas

RCSS closely follows CSS2 but has several important differences that will trip up web developers:

No default stylesheet
RmlUI ships with zero default styles. Block/inline, heading sizes, list bullets — none of it exists unless you add it. Copy the HTML4 base sheet from the RmlUI docs as a starting point.
RGBA alpha is 0–255
rgba(255, 0, 0, 128) means 50% transparent red. CSS uses 0.0–1.0 for alpha. The opacity property still uses 0.0–1.0.
Border shorthand has no style keyword
border: 1dp #6c7086; works. border: 1dp solid #6c7086; does not work. Only solid borders are supported; border-style is unavailable.
background-* properties use decorators
background-color works normally. background-image, background-size, etc. are replaced by the decorator system. See the RmlUI docs for syntax.
No list styling
list-style-type and friends are not supported. ul/ol/li have no special rendering — treat them as plain div-like elements.
Input text is set by content, not value
For <input type="button"> and <input type="submit"> the displayed text comes from the element's text content, not the value attribute (unlike HTML).
pointer-events
auto required
By default elements do not receive mouse events. Add pointer-events: auto to any element that needs to be interactive.

12. Differences from Upstream RmlUI

The Recoil implementation is based on the official RmlUI library but adds engine-specific features and changes some defaults:

Feature Description
<texture> element A custom element that renders engine-managed textures via Recoil texture reference strings. Not present in upstream.
<svg> inline data The src attribute accepts raw SVG markup in addition to file paths. Upstream only supports file paths.
VFS file loader All file paths are resolved through Recoil's Virtual File System, so .rml and .rcss files work inside .sdz game archives transparently.
Lua bindings via sol2 The Lua API is provided by RmlSolLua (derived from LoneBoco/RmlSolLua) rather than the upstream Lua plugin, offering tighter engine integration.
dp_ratio via context context.dp_ratio is set from code rather than the system DPI, allowing games to honour the player's ui_scale preference.
For everything not listed above, the upstream RmlUI documentation at mikke89.github.io/RmlUiDoc applies directly.

13. IDE Setup

Configuring your editor for .rml and .rcss file extensions dramatically improves the authoring experience.

VS Code

  1. Open any .rml file in VS Code.
  2. Click the language mode indicator in the status bar (usually shows Plain Text).
  3. Choose Configure File Association for '.rml'
  4. Select HTML from the list.
  5. Repeat for .rcss files, selecting CSS.

Lua Language Server (lua-ls)

Add the Recoil type annotations to your workspace for autocomplete on RmlUi, Spring, and all widget APIs. See the Recoil docs guide on the Lua Language Server for details.


14. Quick Reference

Lua API

Call Description
RmlUi.CreateContext(name) Create a new rendering context
RmlUi.GetContext(name) Retrieve an existing context by name
RmlUi.LoadFontFace(path, fallback) Register a .ttf font (fallback=true recommended)
RmlUi.SetMouseCursorAlias(css, engine) Map a CSS cursor name to an engine cursor name
RmlUi.SetDebugContext(name) Attach the DOM debugger to a named context
ctx:OpenDataModel(name, tbl) Create reactive data model from Lua table; store the return value!
ctx:RemoveDataModel(name) Destroy a data model and free its memory
ctx:LoadDocument(path, scope) Parse an RML file; scope is used for normal (on*) events
doc:Show() Make the document visible
doc:Hide() Hide the document (keeps it in memory)
doc:Close() Destroy the document
doc:ReloadStyleSheet() Reload linked .rcss files (useful during development)
dm:__GetTable() Get the underlying Lua table for iteration
dm:__SetDirty(key) Mark a top-level model key as changed; triggers re-render
dm.key = value Write a top-level value; auto-marks dirty