docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl owns, bring-up and the arena refusal contract, the frame bracket and where it sits in a real frame, the consumed-event rules (with the one-frame-stale hit test stated plainly), the three widgets, menus across keyboard, gamepad and mouse, writing raw CLAY() with an application-owned press edge, images and styles, and how layout errors surface from frame_end with a captured trace. Every listing is a checked block: one linked-and-run example with pinned output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c, and the chapter figure is a frame out of uidemo via docs_game_figures. The chapter owes and pays the comparison the tutorials earn: the dialog widget's default style reproduces the JRPG textbox palette, the two listings sit side by side, and the trade is stated -- 125 lines you own completely against one call that costs you the subsystem. Neither is deprecated; chapter 21 keeps teaching the hand-rolled panel on purpose. The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits table, and the new draw primitives' rows. Its status-band figures were stale at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at 7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the name list. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
383 lines
16 KiB
Markdown
383 lines
16 KiB
Markdown
# 22. User interfaces
|
|
|
|

|
|
|
|
Every game eventually wants a dialog box, a score counter, or a menu — and hand-rolling
|
|
them out of rectangles and `akgl_text_rendertextat` is tolerable exactly once.
|
|
[Chapter 21](21-tutorial-jrpg.md) does it once, on purpose, and its 125-line
|
|
`textbox.c` is still the right call for one panel with no ambitions. This chapter is for
|
|
everything past that point: HUDs, menus, options screens, and the layout arithmetic that
|
|
makes them miserable to maintain by hand.
|
|
|
|
The layout engine is [clay](https://github.com/nicbarker/clay), vendored under
|
|
`deps/clay` and compiled into `libakgl.so`. Per this manual's rule, clay's own API is
|
|
documented by clay — its README is thorough — and this chapter covers what libakgl adds
|
|
or constrains. The split:
|
|
|
|
| clay owns | libakgl owns |
|
|
|---|---|
|
|
| The layout algorithm and the `CLAY()` declaration DSL | The arena clay allocates from — static storage, no `malloc`, sized by `AKGL_UI_ARENA_BYTES` |
|
|
| Sizing, padding, floating elements, scroll containers | Text measurement, through SDL_ttf and the font registry |
|
|
| The render command list each frame produces | Drawing those commands, through the render backend |
|
|
| Hover and pointer-over queries | Feeding it the mouse, and telling *you* which events the UI consumed |
|
|
|
|
**There are two ways in, and they compose.** The widget helpers — `akgl_ui_dialog`,
|
|
`akgl_ui_label`, `akgl_ui_menu` — cover the common cases in one call each, and a simple
|
|
game never touches a `CLAY()` macro. When a screen outgrows them, you write clay's
|
|
declarative blocks yourself between the same two frame calls, with the whole DSL
|
|
available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does
|
|
both: its title menu and HUD are widgets, its options screen is raw `CLAY()`.
|
|
|
|
Like collision, the subsystem is optional at runtime rather than at build time: it is
|
|
always compiled in, costs static storage until `akgl_ui_init` runs, and a game that
|
|
never calls that pays nothing else.
|
|
|
|
**One warning before any code.** libakgl ships clay inside `libakgl.so` and exports its
|
|
symbols, because the `CLAY()` macros in *your* translation units expand to calls into
|
|
them. Do not define `CLAY_IMPLEMENTATION` anywhere and do not link a second copy of clay
|
|
— two definitions of the same symbols, and the loader picks one silently.
|
|
|
|
## Bring it up, lay something out
|
|
|
|
`akgl_ui_init(width, height)` takes the layout size as parameters — deliberately not
|
|
read from the camera or the window, so a headless program can bring the UI up with no
|
|
renderer at all. It bounds clay to the `AKGL_UI_*` ceilings, checks the arena fits them,
|
|
and refuses **with both byte counts in the message** when it does not: a raised ceiling
|
|
without a raised arena is a loud startup failure, never a corruption at frame forty
|
|
thousand.
|
|
|
|
After that, a frame of UI is a bracket with declarations inside:
|
|
|
|
```c run=akglapp
|
|
PASS(errctx, akgl_ui_init(320, 240));
|
|
|
|
PASS(errctx, akgl_ui_frame_begin());
|
|
CLAY({
|
|
.id = CLAY_ID("panel"),
|
|
.layout = {
|
|
.sizing = {
|
|
.width = CLAY_SIZING_FIXED(120),
|
|
.height = CLAY_SIZING_FIXED(40)
|
|
}
|
|
},
|
|
.backgroundColor = { 24, 20, 37, 255 }
|
|
}) {}
|
|
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
|
|
|
|
PASS(errctx, akgl_ui_shutdown());
|
|
printf("one panel, laid out and drawn\n");
|
|
```
|
|
|
|
```output
|
|
one panel, laid out and drawn
|
|
```
|
|
|
|
`frame_begin` starts the clay layout and feeds it the pointer state the event handler
|
|
has been accumulating; `frame_end` computes the layout and draws every render command it
|
|
produces through the backend — fills and rounded fills, borders, clip rectangles, text,
|
|
and sprites. Everything lands in screen coordinates on top of whatever is already on the
|
|
target.
|
|
|
|
Text needs one more step at startup: fonts. clay names a font by a `uint16_t` fontId;
|
|
libakgl names one by a registry key ([Chapter 17](17-text-and-fonts.md)). The bridge is
|
|
`akgl_ui_font_register`, and the demo's whole font story is three lines:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE));
|
|
PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT));
|
|
PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid));
|
|
```
|
|
|
|
The first font registered gets id 0, which is what the widgets' default style uses — so
|
|
a one-font game never mentions a fontId again. Two things worth knowing before they
|
|
surprise you:
|
|
|
|
- **`Clay_TextElementConfig.fontSize` is ignored.** A libakgl font bakes its size in at
|
|
load ([Chapter 17](17-text-and-fonts.md) explains why); the size text renders at is
|
|
the size the font behind its id was loaded at. One face at two sizes is two loads, two
|
|
registrations, two ids.
|
|
- The table stores the *name* and resolves it per use, so `akgl_text_unloadfont` on a
|
|
registered font makes the next frame fail loudly with the name in the message — not
|
|
dangle.
|
|
|
|
## The frame contract
|
|
|
|
Where the bracket goes in a real frame, from the demo — compare the JRPG's `frame()`,
|
|
which calls `akgl_game_update` where this program paints a checkerboard:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
|
if ( state == UIDEMO_STATE_PLAY ) {
|
|
PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT));
|
|
score += 1;
|
|
}
|
|
|
|
PASS(errctx, akgl_ui_frame_begin());
|
|
switch ( state ) {
|
|
case UIDEMO_STATE_TITLE:
|
|
PASS(errctx, declare_title());
|
|
break;
|
|
case UIDEMO_STATE_OPTIONS:
|
|
PASS(errctx, declare_options());
|
|
break;
|
|
case UIDEMO_STATE_PLAY:
|
|
PASS(errctx, declare_play());
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
|
|
```
|
|
|
|
The UI draws after the world because it is declared after the world — the overlay slot
|
|
between `akgl_game_update` and the backend's `frame_end` is exactly where the JRPG drew
|
|
its hand-rolled text box, and nothing about that slot changed.
|
|
|
|
Events go through the UI *first*. `akgl_ui_handle_event` takes every event
|
|
unconditionally — the same pass-everything contract as `akgl_controller_handle_event` —
|
|
and reports back whether the UI claimed it, so a click on a menu never leaks through and
|
|
also fires a game control:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
|
|
if ( consumed ) {
|
|
SUCCEED_RETURN(errctx);
|
|
}
|
|
|
|
switch ( state ) {
|
|
case UIDEMO_STATE_TITLE:
|
|
PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed));
|
|
break;
|
|
```
|
|
|
|
Three rules govern what gets consumed, and each is a decision worth stating:
|
|
|
|
- **Presses, releases and the wheel are consumed when the pointer is over any UI
|
|
element.** Motion and resizes never are — the game may care where the mouse is, and
|
|
certainly cares about its window.
|
|
- **The hit test runs against the layout the previous frame declared**, because this
|
|
frame's does not exist while events are being polled. clay retains the last tree for
|
|
exactly this purpose; one frame of staleness is the standard model's accepted cost,
|
|
and before any frame has been laid out, nothing is over anything.
|
|
- **Keyboard events are never consumed by the UI itself.** Which menu hears the arrow
|
|
keys is something the application declares — the `switch` above *is* the focus model —
|
|
not something a pointer position implies.
|
|
|
|
## A dialog in one call — and what it replaces
|
|
|
|
The play screen's declarations, whole:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score));
|
|
PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives));
|
|
PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
|
|
PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
|
|
if ( dialog_open ) {
|
|
PASS(errctx, akgl_ui_dialog("dialog",
|
|
"This panel is one call. Space dismisses it; "
|
|
"compare examples/jrpg/textbox.c.",
|
|
NULL));
|
|
}
|
|
```
|
|
|
|
Note what is absent: no `visible` flag, no draw call, no geometry. The dialog is open
|
|
because this frame declares it — a declarative frame *is* the flag. The text buffers are
|
|
`static` because clay borrows the pointer until `frame_end` rather than copying; format
|
|
your score into storage that outlives the bracket.
|
|
|
|
Now the same panel the way [Chapter 21](21-tutorial-jrpg.md) builds it, which is the
|
|
comparison this chapter owes you. The hand-rolled version keeps a flag and a copy of the
|
|
string, and its draw function does the geometry itself:
|
|
|
|
```c excerpt=examples/jrpg/textbox.c
|
|
panel.x = TEXTBOX_MARGIN;
|
|
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
|
|
panel.h = TEXTBOX_HEIGHT;
|
|
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
|
|
|
|
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
|
|
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
|
|
PASS(errctx,
|
|
akgl_text_rendertextat(
|
|
font,
|
|
textbox_text,
|
|
TEXTBOX_INK,
|
|
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
|
|
(int)(panel.x + TEXTBOX_PADDING),
|
|
(int)(panel.y + TEXTBOX_PADDING)
|
|
));
|
|
```
|
|
|
|
The widget's default style is **deliberately that panel's palette** — near-black fill,
|
|
parchment edge and ink, 8 pixels of padding — so the two produce the same picture, and
|
|
the trade is visible with nothing hidden in a theme:
|
|
|
|
- `textbox.c` is 125 lines you own completely. It costs no new concepts, no arena, no
|
|
frame bracket; its geometry is four assignments you can read. For one panel, that is a
|
|
perfectly good deal — which is why chapter 21 still teaches it and its game still
|
|
ships it.
|
|
- `akgl_ui_dialog` is one line that costs you the subsystem: `akgl_ui_init`, a
|
|
registered font, and the bracket in your frame. The payment buys every *next* piece of
|
|
interface — the second panel is also one line, the score label is one line, the menu
|
|
is a struct — and the layout arithmetic, wrapping, and stacking are clay's problem
|
|
from then on.
|
|
|
|
Neither is deprecated. The library's own position: build one panel by hand; build an
|
|
interface on the subsystem.
|
|
|
|
## Menus: one selection, three devices
|
|
|
|
A menu is caller-owned state — a struct you keep, the way `textbox.c` keeps its statics.
|
|
There is no heap pool behind it because it owns no texture, no font, and no registry
|
|
entry:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
static akgl_UiMenu title_menu = {
|
|
.id = "title",
|
|
.items = { "Start", "Options", "Quit" },
|
|
.count = 3,
|
|
};
|
|
```
|
|
|
|
Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and
|
|
they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the
|
|
arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad
|
|
South; the mouse selects by *moving onto* a row and activates by clicking one, during
|
|
the declaration itself. A stationary pointer claims nothing — parking the mouse over the
|
|
menu must not pin the selection against the keyboard, which would otherwise fight it
|
|
sixty times a second and lose.
|
|
|
|
`activated` latches until you clear it, so the check lives wherever reading it is
|
|
convenient — the demo reads it after the frame closes, which catches both input routes:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
if ( title_menu.activated ) {
|
|
title_menu.activated = false;
|
|
switch ( title_menu.selected ) {
|
|
case 0:
|
|
state = UIDEMO_STATE_PLAY;
|
|
score = 0;
|
|
dialog_open = false;
|
|
break;
|
|
case 1:
|
|
state = UIDEMO_STATE_OPTIONS;
|
|
break;
|
|
case 2:
|
|
default:
|
|
running = false;
|
|
break;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Writing layout directly with CLAY()
|
|
|
|
The options screen uses no widgets, and exists to show that the widgets are a
|
|
convenience rather than a boundary. One row of it carries every idea — hover styling
|
|
computed *inside* the declaration, and a click paired with a press edge the application
|
|
tracks itself:
|
|
|
|
```c excerpt=examples/uidemo/uidemo.c
|
|
CLAY({
|
|
.id = CLAY_ID("options-music"),
|
|
.layout = { .padding = { 8, 8, 4, 4 } },
|
|
.backgroundColor = Clay_Hovered()
|
|
? (Clay_Color){ 64, 58, 88, 255 }
|
|
: (Clay_Color){ 0, 0, 0, 0 }
|
|
}) {
|
|
if ( Clay_Hovered() && clicked ) {
|
|
opt_music = !opt_music;
|
|
}
|
|
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }),
|
|
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
|
|
}
|
|
```
|
|
|
|
`clicked` is one application-owned `bool`: set when a left press arrives in the event
|
|
loop — *before* `akgl_ui_handle_event` can consume it, because a click on a UI row is
|
|
always consumed — and cleared at the end of the frame. The widgets keep an equivalent
|
|
edge internally; a raw screen keeps its own. Everything else — the sizing model, scroll
|
|
containers, floating attach points, aspect ratios — is clay's DSL, and clay's README
|
|
documents it far better than a restatement here would.
|
|
|
|
## Images and styles
|
|
|
|
An image on a UI element is a sprite: set `.image.imageData` in a `CLAY()` declaration
|
|
to an `akgl_Sprite *` ([Chapter 10](10-spritesheets-and-sprites.md)) and its first frame
|
|
is drawn stretched to the element's box. Two current limits, both deliberate scope
|
|
rather than accident: clay's image tint colour and `CUSTOM` render commands are skipped
|
|
by the executor, and per-corner radii collapse to the top-left value — the widgets only
|
|
produce uniform corners. The mouse cursor stays the operating system's; a game that
|
|
wants a themed cursor draws a floating image element at the pointer, which makes a good
|
|
exercise.
|
|
|
|
A style is one struct shared by all three widgets — colours, padding, corner radius,
|
|
fontId — and `NULL` means the textbox palette described above. There is no per-field
|
|
defaulting: a transparent fill and a zero radius are things a style legitimately says,
|
|
so copy the default and change what you mean to change:
|
|
|
|
```c wrap=akglbody
|
|
akgl_UiStyle alert = {
|
|
.fill = { 120, 24, 24, 235 },
|
|
.edge = { 240, 236, 214, 255 },
|
|
.ink = { 240, 236, 214, 255 },
|
|
.padding = 8.0f,
|
|
.corner_radius = 6.0f,
|
|
.fontid = 0
|
|
};
|
|
|
|
PASS(errctx, akgl_ui_dialog("alert", "The reactor is on fire.", &alert));
|
|
```
|
|
|
|
## When things go wrong
|
|
|
|
Everything here reports through the ordinary error protocol
|
|
([Chapter 4](04-errors.md)) under one status, `AKGL_ERR_UI`, and the message says which
|
|
refusal it was. Lifecycle misuse fails at the call that misused it:
|
|
|
|
```text
|
|
/home/andrew/source/libakgl/src/ui.c:ui_widget_ready:850: 262 (UI Error) : Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
|
|
/home/andrew/source/libakgl/src/ui.c:akgl_ui_dialog:866
|
|
/home/andrew/source/libakgl/main.c:main:13
|
|
/home/andrew/source/libakgl/main.c:main:16: Unhandled Error 262 (UI Error): Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
|
|
```
|
|
|
|
Errors *inside the layout* cannot fail at the call that caused them, because clay
|
|
reports through a void callback with libakgl nowhere on the stack. So they are logged as
|
|
they happen, stashed, and raised from `akgl_ui_frame_end` — the earliest point the
|
|
protocol can carry them — with the first message and a count of the rest. A frame that
|
|
fails is one bad frame: the next `akgl_ui_frame_begin` is legal, and the executor clears
|
|
any clip rectangle on its way out so a failed UI cannot leave the next frame's *world*
|
|
clipped.
|
|
|
|
The ceilings — elements, measured words, arena bytes, fonts, text-run length, menu
|
|
entries — are all in [Chapter 23](23-appendix-limits.md), every one of them overridable,
|
|
and every refusal names the number to raise.
|
|
|
|
## Build it and run it
|
|
|
|
The demo builds with the library:
|
|
|
|
```sh norun
|
|
cmake -S . -B build
|
|
cmake --build build -j$(nproc)
|
|
./build/examples/uidemo/uidemo
|
|
```
|
|
|
|
Arrow keys, Return, the D-pad and the mouse all drive the title menu; Space opens the
|
|
dialog on the play screen; Escape backs out. `ctest -R example_uidemo` runs the same
|
|
program headless through a scripted tour of every screen.
|
|
|
|
## Where to look next
|
|
|
|
- [clay's README](https://github.com/nicbarker/clay) — the layout DSL itself: sizing,
|
|
floating elements, scroll containers, and the debug tools.
|
|
- [Chapter 16](16-input.md) — the control maps that own the keyboard and gamepad once
|
|
the UI has declined an event.
|
|
- [Chapter 17](17-text-and-fonts.md) — fonts, the registry, and what text costs per
|
|
frame; a UI panel of text pays the same immediate-mode price.
|
|
- [Chapter 21](21-tutorial-jrpg.md) — the hand-rolled text box this chapter keeps
|
|
comparing against, in the game that earns it.
|
|
- [Chapter 23](23-appendix-limits.md) — every `AKGL_UI_*` limit and the `ui.h` status
|
|
cross-reference.
|