Files
libakgl/docs/22-ui.md

383 lines
16 KiB
Markdown
Raw Normal View History

Document the UI subsystem: chapter 22, with the appendix moving to 23 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
2026-08-02 11:32:20 -04:00
# 22. User interfaces
![The UI demo's play screen: a LIVES label top-left, a SCORE label top-right, and a dialog panel across the bottom, over a checkerboard standing in for a game world](images/uidemo.png)
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.