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
This commit is contained in:
382
docs/22-ui.md
Normal file
382
docs/22-ui.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user