Files
libakgl/docs/22-ui.md
Andrew Kesterson f2efbdf1b7
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 22s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 18s
Fill the gaps the chapter 22 weaker-model reader found
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
2026-08-02 13:06:22 -04:00

17 KiB

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

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 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, 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, 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 (or Chapter 3's smallest window), plus one loaded font from Chapter 17. 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 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:

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");
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). The bridge is akgl_ui_font_register, and the demo's whole font story is three lines:

    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 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:

    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:

    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:

    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 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:

    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:

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:

    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:

	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) 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:

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) under one status, AKGL_ERR_UI, and the message says which refusal it was. Lifecycle misuse fails at the call that misused it:

/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, every one of them overridable, and every refusal names the number to raise.

Build it and run it

The demo builds with the library:

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 — the layout DSL itself: sizing, floating elements, scroll containers, and the debug tools.
  • Chapter 16 — the control maps that own the keyboard and gamepad once the UI has declined an event.
  • Chapter 17 — fonts, the registry, and what text costs per frame; a UI panel of text pays the same immediate-mode price.
  • Chapter 21 — the hand-rolled text box this chapter keeps comparing against, in the game that earns it.
  • Chapter 23 — every AKGL_UI_* limit and the ui.h status cross-reference.