Render clay layouts: fonts, text measurement, executor and the frame bracket

The UI subsystem now draws. akgl_ui_frame_begin/frame_end bracket one frame's
CLAY() declarations: begin clears the error stash and starts the clay layout,
end computes it and walks the render commands through a backend -- RECTANGLE
as (rounded) fills, BORDER as radius-shortened edge fills plus corner arcs,
TEXT through akgl_text_rendertextat one wrapped line per command, IMAGE as an
akgl_Sprite's first frame stretched to the bounding box, and the SCISSOR pair
through akgl_draw_set_clip, cleared again on any exit so a failed frame
cannot leave the world clipped.

akgl_ui_font_register maps registry font names onto clay's uint16_t fontIds.
The table keeps the *name* and resolves it per use -- fonts are not reference
counted, and a cached TTF_Font* would dangle where a name reports "gone"
honestly. Clay_TextElementConfig.fontSize is deliberately ignored: libakgl
bakes the size into the handle at load, so one face at two sizes is two ids.

The measure bridge passes clay's non-NUL-terminated slices straight to
SDL_ttf's explicit-length TTF_GetStringSize -- no copy, no scratch. Its
signature has no error channel, so failures report zero-by-zero and stash a
message that frame_end raises, the same route clay's own void error handler
uses; layout errors take precedence over drawing and one bad frame leaves the
next one clean.

Tests drive real CLAY() layouts through the software renderer and read pixels
back: fill placement, border edges with an empty middle, a child clipped by
its container, text landing in its colour, slice-vs-whole measurement
agreement, fontId table dedupe/refusal/exhaustion, and the bracket's
begin/begin, end-without-begin and failure-recovery contracts.

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:
2026-08-02 11:06:27 -04:00
parent 064e6569e8
commit 3b3bfcbe10
3 changed files with 1122 additions and 30 deletions

View File

@@ -41,6 +41,8 @@
#include <akerror.h>
#include <clay.h>
#include <akgl/renderer.h>
/**
* @brief Most elements one layout may declare.
*
@@ -81,6 +83,38 @@
#define AKGL_UI_ARENA_BYTES (1024 * 1024)
#endif
/**
* @brief Most fonts akgl_ui_font_register() will map to clay fontIds.
*
* clay identifies a font as a `uint16_t`; libakgl identifies one as a name in
* #AKGL_REGISTRY_FONT. The table joining them is this many fixed slots. A
* size is baked into each registered font handle, so a game using one face at
* three sizes is using three slots.
*/
#ifndef AKGL_UI_MAX_FONTS
#define AKGL_UI_MAX_FONTS 8
#endif
/**
* @brief Bytes each fontId table slot holds for its registry name, terminator included.
*/
#ifndef AKGL_UI_FONT_NAME_LENGTH
#define AKGL_UI_FONT_NAME_LENGTH 64
#endif
/**
* @brief Bytes of scratch one text render command may occupy, terminator included.
*
* clay hands text to a renderer as a length-and-pointer slice, one command
* per wrapped line; akgl_text_rendertextat() takes a C string, so each line
* is copied through a bounded buffer on the way. A line longer than this
* fails the frame loudly rather than drawing a truncation that reads as the
* author's text.
*/
#ifndef AKGL_UI_MAX_TEXT_BYTES
#define AKGL_UI_MAX_TEXT_BYTES 1024
#endif
/**
* @brief Bring the UI subsystem up.
*
@@ -140,11 +174,140 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_shutdown(void);
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_resize(int width, int height);
/**
* @brief Map a font-registry name to the clay fontId that text elements name it by.
*
* The table stores the *name* and resolves it through #AKGL_REGISTRY_FONT on
* every use, rather than caching the `TTF_Font *` -- fonts are not reference
* counted, so a cached handle would dangle the moment akgl_text_unloadfont()
* took the font away, while a name honestly reports "no longer registered".
* Registering a name that is already in the table returns its existing id
* rather than spending a second slot.
*
* clay's `Clay_TextElementConfig.fontSize` is **ignored** by this subsystem:
* a libakgl font bakes its size in at akgl_text_loadfont() time, so the size
* a text element renders at is the size the font behind its fontId was loaded
* at. One face at two sizes is two loads, two registrations, two ids.
*
* @param name Registry key the font was published under. Required, and it
* must already be in the registry -- registering first and
* loading later would leave a window where a fontId resolves to
* nothing.
* @param fontid Receives the id to put in `Clay_TextElementConfig.fontId`.
* Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p name or @p fontid is `NULL`.
* @throws AKGL_ERR_UI If the subsystem is not initialized; if no font is
* registered under @p name; or if all #AKGL_UI_MAX_FONTS slots are
* taken. Each message says which.
* @throws AKERR_OUTOFBOUNDS If @p name does not fit in a table slot --
* #AKGL_UI_FONT_NAME_LENGTH bytes including the terminator.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_font_register(char *name, uint16_t *fontid);
/**
* @brief Open the UI frame: clear the error stash and begin the clay layout.
*
* Call once per rendered frame, after akgl_game_update() and before any
* `CLAY()` block or widget helper. Everything declared between this and
* akgl_ui_frame_end() is this frame's interface; a dialog is "open" because
* the frame declares it, not because a mode was toggled somewhere.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_UI If the subsystem is not initialized, or if a frame is
* already open -- a begin/begin sequence means a frame_end went
* missing, and absorbing it would hide the layout of one frame inside
* another.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_begin(void);
/**
* @brief Close the UI frame: compute the layout and draw it.
*
* Runs `Clay_EndLayout` and walks the render commands it produces through
* @p self -- fills and rounded fills, borders, clip rectangles, text through
* akgl_text_rendertextat(), images through the backend's `draw_texture`.
* Draws in screen coordinates on whatever is already on the target, so call
* it between akgl_game_update() and the backend's `frame_end`: the UI lands
* on top of the world.
*
* The frame is considered closed even when this fails: the next
* akgl_ui_frame_begin() is legal, so one bad frame is one bad frame rather
* than a wedged subsystem.
*
* If clay reported errors during layout -- through its handler, or the
* measure callback failing -- this raises the first of them here and does not
* draw, because a layout that failed is not a layout, and the raise names how
* many more followed it.
*
* @param self The backend to draw through. Required, along with its
* `sdl_renderer` and `draw_texture`.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p self, its `sdl_renderer`, or its
* `draw_texture` is `NULL`.
* @throws AKGL_ERR_UI If the subsystem is not initialized; if no frame is
* open; if clay reported layout errors; if a text command's line
* exceeds #AKGL_UI_MAX_TEXT_BYTES; or if a fontId cannot be resolved.
* @throws AKERR_* Whatever the drawing primitives underneath raise.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_end(akgl_RenderBackend *self);
/*
* The following is part of the internal API. It is exposed so the test suite
* can reach it and is not meant to be called by a game.
*/
/**
* @brief The measure callback akgl_ui_init() hands to clay.
*
* clay's callback signature returns dimensions by value and has no error
* channel, so this cannot raise: on any failure -- an unregistered fontId, a
* font gone from the registry, SDL_ttf refusing the string -- it reports zero
* by zero and stashes the failure where akgl_ui_frame_end() will raise it.
* The slice is measured at its stated length through SDL_ttf directly, so it
* is never copied and need not be NUL-terminated.
*
* @param text The slice of text clay wants measured.
* @param config The text element's configuration; only `fontId` is
* consulted (see akgl_ui_font_register() on `fontSize`).
* @param userData Unused. clay passes back whatever init registered.
* @return The slice's size in pixels, or zero by zero on failure.
*/
Clay_Dimensions akgl_ui_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData);
/**
* @brief Draw one frame's render commands through a backend.
*
* The renderer half of akgl_ui_frame_end(), separated so a test can hand it
* a command array and read pixels back without a full frame bracket. Handles
* RECTANGLE, BORDER, TEXT, IMAGE and the SCISSOR pair; CUSTOM commands and
* image tint colours are skipped in this version, and per-corner radii are
* collapsed to the top-left value -- the widget helpers only produce uniform
* corners.
*
* An IMAGE command's `imageData` is an `akgl_Sprite *`; its first frame is
* drawn stretched to the command's bounding box.
*
* Any clip rectangle a command set is cleared before this returns, success or
* failure -- the UI must not leave the next frame's world clipped.
*
* @note TEXT commands draw through akgl_text_rendertextat(), whose contract
* is the *global* `akgl_renderer` -- so a host drawing its UI through
* some other backend must keep the two pointed at the same renderer,
* or its text lands somewhere else.
*
* @param self The backend to draw through. Required, along with its
* `sdl_renderer` and `draw_texture`.
* @param commands The commands `Clay_EndLayout` returned. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p self, its `sdl_renderer`, its
* `draw_texture`, or @p commands is `NULL`.
* @throws AKGL_ERR_UI If a text line exceeds #AKGL_UI_MAX_TEXT_BYTES, a
* fontId does not resolve, or an IMAGE command carries no sprite.
* @throws AKERR_* Whatever the drawing primitives underneath raise.
*/
akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_RenderCommandArray *commands);
/**
* @brief Narrow the arena, so a test can drive akgl_ui_init() into refusal.
*