Files
regolith-ui/docs/dd.md
T
2026-11-02 12:40:38 -06:00

8.9 KiB

Regolith

Regolith is a retained mode GUI framework written in C99. It is built for native desktop applications and for game user interfaces, and it compiles to Linux, Windows, macOS, and WebAssembly. It uses no webview and no web backend.

Goals

  • A clean C API that other languages such as Odin and Zig can bind directly.
  • A retained document tree that loads from disk.
  • Lua as the document language and as a full runtime API, so an application can be written entirely in Lua after initialization.
  • Easy initialization, with a small number of calls to get a window and a document on screen.
  • Independence from any single platform or renderer.

Inspiration

RmlUi is the main inspiration. Its document model, styling separation, data binding, animations, and event bubbling are the parts I want. Regolith aims for those ideas with a C API and a simpler event model. HTML and CSS parity is not a goal.

Architecture

Regolith is split into layers. Each layer depends only on the ones below it.

  • core: node tree, style resolution, events, data bindings, animation, and text. Pure C99. No Lua types and no Clay types appear in its public API.
  • layout adapter: the only code that touches Clay. It walks the retained tree each frame, emits Clay calls, and reads back computed layout.
  • lua module: the document loader and the Lua API. It is a thin layer over the C API.
  • backend interface: a struct of function pointers defined in the main header. It creates and updates textures, draws batches of colored and textured quads, and sets the scissor rect.
  • default backend: SDL3 and OpenGL.
  • null backend: records the quad list instead of drawing, used for headless tests.

The C API is the source of truth. Every capability exists in C first, and the Lua binding exposes it without adding behavior of its own.

Dependencies

All dependencies are vendored in third_party/ and built by Regolith's own CMake, so native and WASM builds use identical versions.

  • Lua 5.4 for documents and scripting
  • Clay for layout and render commands
  • stb_truetype and stb_rect_pack for fonts and atlas packing

SDL3 is the only external dependency, and only the default backend uses it.

Layout

Clay provides layout. Regolith exposes a Clay-flavored property vocabulary (sizing, padding, gap, align, direction). Every node is a box with a direction. Node types use Godot-style names such as Panel, Label, Button, HBox, VBox, and ScrollContainer, where HBox and VBox are shorthand for a box with a fixed direction.

Text

The core owns all text handling. It loads fonts, caches glyph metrics, packs glyphs into a single-channel atlas, and measures strings for Clay. The backend receives the atlas as a texture and draws glyphs as textured quads. Rasterization happens at logical size multiplied by display scale, so the font API carries a scale factor from the start. The text module sits behind a small interface so a shaper can replace it later. Shaping, bidirectional text, and color emoji are out of scope for the first versions.

Rendering

Rectangles, borders, and glyphs all reduce to quads. A backend only needs to manage textures, draw quad batches, and apply a scissor rect. Rendering backends are replaceable, and the SDL3 and OpenGL backend is the default.

Documents

Documents are Lua files. Node constructors such as VBox, Label, and Button return plain descriptor tables containing a type, properties, and children. A separate step realizes the descriptor tree into live nodes through the C API. Descriptors are pure data, which keeps them easy to print and debug and allows hot reload to reconcile a new tree against the live one by id or key.

Each document runs in its own _ENV that provides the node constructors and helpers. A document is free to contain arbitrary Lua logic, including frontend-only behavior and calls to the host application. The same _ENV mechanism provides sandboxing, which is an option at initialization. A sandboxed state omits io, os, debug, and arbitrary load, and installs an instruction-count hook to stop runaway scripts.

Both authoring surfaces exist. The call style (VBox { Label { text = "Hi" } }) is the primary form. The builder style (create a node, set properties, append it to a parent) is the same C API exposed to Lua and is used for dynamic content.

Styling

Styles are Lua tables with a small selector engine. Supported selectors are type, class, id, the state pseudo-classes :hover, :focus, :active, and :disabled, and a child combinator. The cascade orders by specificity and then declaration order. There is no !important. Components are Lua functions that return descriptor tables, and shared appearance comes from classes. Style resolution runs only when classes, state, or tree structure change.

Data binding

A binding is a callback with userdata attached to a node property. The data model is a keyed store with version counters, and a binding re-evaluates only when a key it depends on has a newer version than its last evaluation. In Lua the model is a proxy table whose __newindex bumps the version. Dependencies are declared as explicit keys. Automatic dependency tracking is a later improvement.

Animation

Property changes can transition over time, similar to CSS transitions. The retained tree stores the previous value, and the interpolated value is fed into layout each frame. Keyframes and coroutine-driven sequences come later.

Events

Events bubble and have no capture phase. The fixed set is click, hover enter, hover leave, focus, blur, change, key, and custom named events. A handler is either a Lua function or a string name. A string name pushes an event into a polled queue, so C, Zig, and Odin users consume events without callbacks across the FFI boundary.

API conventions

  • One public header, regolith.h, with the implementation in ordinary .c files compiled into a static or shared library.
  • regolith_lua.h and regolith_sdl3.h are separate optional headers, so lua_State and SDL types never appear in the main header.
  • Fixed-width integer types, plain structs, no bitfields, no variadics, and no static inline in the public header.
  • Every public function is marked RG_API and prefixed with rg_.
  • Enums are an int typedef with named constants.
  • Nodes are referenced by opaque handles made of an index and a generation, never by raw pointers.
  • The user can supply an allocator, and internal allocation uses arenas.

Build and environment

The build system is CMake with Ninja. Development happens on NixOS using direnv and flakes, and the devShell provides CMake, Ninja, pkg-config, clang, clang-tools, SDL3, Emscripten, and lua-language-server. Emscripten's cache directory is set to .emscripten-cache inside the project. WASM builds go through emcmake into build-wasm/. Sources live in a flat src/ with no generated files, so Zig and Odin users can compile them with their own toolchain.

Milestone 1

A main.lua builds a VBox of Label and Button nodes. A stylesheet with classes and a :hover state styles them. Layout runs through Clay and draws through the SDL3 and OpenGL backend with stb_truetype text. A button click fires both a Lua handler and a named event in the polled queue. One transition eases a button background on hover. A data-bound label shows a counter that the button increments.

Text inputs and scrolling follow in milestone 2, along with focus handling.

Context and ownership

All state lives in an rg_context, which is created with rg_create and passed explicitly to every call. There are no hidden globals. A context owns its node tree, style data, data model, event queue, text atlas, layout state, and, when the Lua module is attached, its own lua_State. This allows headless tests, multiple windows, and multiple Lua states in one process. The user-supplied allocator is set in the config struct at creation and is used for all internal allocation, including Lua and Clay. A context is not thread safe. It is used from one thread at a time, and separate contexts are fully independent.

Main loop

The host owns the main loop. Regolith never blocks and never creates a window loop of its own. Each frame the host pushes input events, calls rg_update with a delta time, then calls rg_render to get a draw list that it hands to a backend. This is the same shape on native and on WASM, where the browser calls the host back once per frame.

Input

The core accepts platform-neutral input events and never polls a platform API. The event kinds are pointer move, pointer button, scroll, key, and text. Key codes are Regolith's own enum and the SDL3 backend translates into them. Gamepad navigation will be added as a semantic layer (up, down, left, right, accept, cancel) that drives focus movement, so a game can map any device to it.

Open questions

  • Whether texture uploads travel inside the draw list.
  • Viewport and display scale handling.
  • Error reporting and logging.