The AGENTS.md "Testing a Tutorial" pass: chapter 22 was handed to a reader on a weaker model with the library tree stripped of examples/ and docs/, and they had to build a menu/HUD/dialog program from the chapter alone. They built one that compiled and ran -- and reported success their own screenshots disproved, which is why the protocol says to verify: their synthesized key events set only the scancode, akgl_ui_menu_handle_event matches keycodes, and their program never actually left the title screen. With that one line fixed, everything they had built from the chapter worked. Three findings survived verification against eight reported: - The chapter never stated its prerequisites, so the reader burned most of the session on library bring-up it does not cover and invented goto error handling for main(). A new "What this chapter assumes" paragraph points at chapters 3, 7 and 17, and at the demo's startup() and main() as the complete reference. - Nothing named the menu's exact keys, or that they are keycodes rather than scancodes -- invisible with a real keyboard, fatal for synthesized events, which the demo's own --demo script teaches. The menus section now names SDLK_UP/DOWN/RETURN, the gamepad buttons, and the key.key trap. - Verifying their dialog screenshot showed a message wrapping past the fixed AKGL_UI_DIALOG_HEIGHT runs visibly out of the panel. Deliberate -- same as the hand-rolled textbox, and visible overflow beats silent truncation -- but undocumented; akgl_ui_dialog's header note now says so. Rejected for the record, per protocol: akgl_game_init already initializes the property registry (src/game.c:219), and the font they "found" in a different directory was the build tree's file(COPY) of tests/assets. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
397 lines
17 KiB
Markdown
397 lines
17 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()`.
|
|
|
|
**What this chapter assumes.** A program that already brings libakgl up — the
|
|
`akgl_game_init` startup order from [Chapter 7](07-the-game-and-the-frame.md) (or
|
|
[Chapter 3](03-getting-started.md)'s smallest window), plus one loaded font from
|
|
[Chapter 17](17-text-and-fonts.md). None of that is restated here, and the UI adds only
|
|
three calls to it, shown below. When something in *your* bring-up fights you, the demo's
|
|
`startup()` in [`examples/uidemo/uidemo.c`](../examples/uidemo/uidemo.c) is the complete
|
|
working sequence to diff against — including the `main()` shape, which the runnable
|
|
snippets in this manual deliberately hide.
|
|
|
|
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 — exactly `SDLK_UP`, `SDLK_DOWN` and `SDLK_RETURN` against `SDL_Event.key.key`,
|
|
and `SDL_GAMEPAD_BUTTON_DPAD_UP`, `_DPAD_DOWN` and `_SOUTH` against
|
|
`SDL_Event.gbutton.button`. Keycodes, not scancodes — which only matters when you
|
|
*synthesize* events, as the demo's `--demo` script does: a hand-built key event must
|
|
fill in `key.key`, or it matches nothing and the menu silently ignores it. 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.
|