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

@@ -1,21 +1,82 @@
/**
* @file ui.c
* @brief Unit tests for the UI subsystem: lifecycle, arena sizing and validation.
* @brief Unit tests for the UI subsystem: lifecycle, fonts, measurement and rendering.
*
* Everything here runs without a window, a renderer, or SDL_Init at all --
* akgl_ui_init takes its layout dimensions as parameters precisely so that a
* test can bring the subsystem up headless. Only akgl_error_init() is needed
* first, so that AKGL_ERR_UI carries its name into any trace these tests
* produce.
* The lifecycle and arena tests run without a renderer -- akgl_ui_init takes
* its layout dimensions as parameters precisely so that a headless program
* can bring the subsystem up. The layout tests draw real clay layouts into a
* small software renderer under the dummy video driver and read the target
* back, the same arrangement tests/draw.c uses, so their assertions are about
* pixels rather than about clay having been called.
*/
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include "testutil.h"
/** @brief Width and height of the offscreen target the layout tests draw into. */
#define TEST_TARGET_SIZE 64
/** @brief The font every text test measures and draws with. */
#define TEST_FONT_PATH "assets/akgl_test_mono.ttf"
/** @brief Registry name the test font is loaded under. */
#define TEST_FONT_NAME "uifont"
/** @brief Point size the test font is loaded at. */
#define TEST_FONT_SIZE 16
/** @brief Opaque black, what each layout test clears the target to. */
static const SDL_Color testblack = { 0x00, 0x00, 0x00, 0xff };
/** @brief The color most layout tests fill with. */
static const SDL_Color testred = { 0xff, 0x00, 0x00, 0xff };
/** @brief A second color, for borders against fills. */
static const SDL_Color testgreen = { 0x00, 0xff, 0x00, 0xff };
/** @brief Clear the whole target to opaque black. */
static akerr_ErrorContext *clear_target(void)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(
errctx,
SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x00, 0x00, 0x00, 0xff),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
FAIL_ZERO_RETURN(
errctx,
SDL_RenderClear(akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}
/** @brief Report whether one pixel of @p shot carries @p color. Alpha is not compared. */
static bool pixel_is(SDL_Surface *shot, int x, int y, SDL_Color color)
{
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 0;
if ( shot == NULL ) {
return false;
}
if ( !SDL_ReadSurfacePixel(shot, x, y, &r, &g, &b, &a) ) {
return false;
}
return ((r == color.r) && (g == color.g) && (b == color.b));
}
/**
* @brief Init must refuse bad dimensions and an uninitialized resize.
*
@@ -33,6 +94,8 @@ akerr_ErrorContext *test_ui_validation(void)
"akgl_ui_init accepted a negative height");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_resize(320, 240),
"akgl_ui_resize worked on an uninitialized subsystem");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(),
"akgl_ui_frame_begin worked on an uninitialized subsystem");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
@@ -97,16 +160,416 @@ akerr_ErrorContext *test_ui_arena_exhaustion(void)
SUCCEED_RETURN(e);
}
/**
* @brief The fontId table: issue, dedupe, refuse the unknown and the overfull.
*
* The table stores names and resolves them per use, so the interesting cases
* are all at registration: an id for a loaded font, the same id again for the
* same name, a refusal for a name nothing loaded, and a refusal -- naming the
* ceiling -- when the table is full.
*/
akerr_ErrorContext *test_ui_font_register(void)
{
PREPARE_ERROR(e);
uint16_t fontid = 99;
uint16_t again = 99;
uint16_t extra = 99;
char slotname[8];
bool loaded = true;
bool registered = true;
int count = 0;
int i = 0;
ATTEMPT {
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"font registration worked on an uninitialized subsystem");
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the font table test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering a loaded font failed");
TEST_ASSERT(e, fontid == 0, "the first fontId issued was %u, not 0", fontid);
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &again),
"re-registering the same name failed");
TEST_ASSERT(e, again == fontid,
"re-registering the same name issued a second id (%u after %u)", again, fontid);
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register("nothing-loaded-this", &extra),
"a name absent from the font registry was registered");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_font_register(NULL, &extra),
"a NULL name was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_font_register(TEST_FONT_NAME, NULL),
"a NULL id destination was accepted");
// Fill the table: the same face under fresh names costs a slot each,
// because the size-baked handle is what a slot maps to.
for ( i = 1; i < AKGL_UI_MAX_FONTS; i++ ) {
IGNORE(aksl_snprintf(&count, slotname, sizeof(slotname), "f%d", i));
TEST_ASSERT_FLAG(loaded, akgl_text_loadfont(slotname, TEST_FONT_PATH, TEST_FONT_SIZE) == NULL);
TEST_ASSERT_FLAG(registered, akgl_ui_font_register(slotname, &extra) == NULL);
}
TEST_ASSERT(e, loaded, "loading the fill fonts failed");
TEST_ASSERT(e, registered, "registering the fill fonts failed");
TEST_EXPECT_OK(e, akgl_text_loadfont("overflow", TEST_FONT_PATH, TEST_FONT_SIZE),
"loading the overflow font failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register("overflow", &extra),
"a full fontId table issued another id");
} CLEANUP {
IGNORE(akgl_text_unloadfont("overflow"));
for ( i = 1; i < AKGL_UI_MAX_FONTS; i++ ) {
IGNORE(aksl_snprintf(&count, slotname, sizeof(slotname), "f%d", i));
IGNORE(akgl_text_unloadfont(slotname));
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The measure bridge must agree with akgl_text_measure, slice or not.
*
* clay hands the callback slices carved out of larger strings, so the case
* that matters is a slice whose stated length stops short of the bytes behind
* it -- five bytes of "HelloWorld" must measure exactly as "Hello" does. A
* fontId nothing issued reports zero-by-zero, the only failure channel the
* callback signature allows.
*/
akerr_ErrorContext *test_ui_measure_text(void)
{
PREPARE_ERROR(e);
Clay_TextElementConfig config;
Clay_StringSlice slice;
Clay_Dimensions sliced;
Clay_Dimensions whole;
TTF_Font *font = NULL;
uint16_t fontid = 0;
int w = 0;
int h = 0;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the measure test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the measure test font failed");
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, TEST_FONT_NAME, NULL);
FAIL_ZERO_BREAK(e, font, AKGL_ERR_BEHAVIOR, "the test font left the registry");
SDL_memset(&config, 0x00, sizeof(config));
config.fontId = fontid;
slice.chars = "HelloWorld";
slice.baseChars = slice.chars;
slice.length = 5;
sliced = akgl_ui_measure_text(slice, &config, NULL);
TEST_EXPECT_OK(e, akgl_text_measure(font, "Hello", &w, &h),
"measuring the whole string through the text subsystem failed");
TEST_ASSERT_FEQ(e, sliced.width, (float)w,
"a 5-byte slice of HelloWorld measured %f wide; Hello measures %d",
(double)sliced.width, w);
TEST_ASSERT_FEQ(e, sliced.height, (float)h,
"a 5-byte slice of HelloWorld measured %f high; Hello measures %d",
(double)sliced.height, h);
slice.length = 10;
whole = akgl_ui_measure_text(slice, &config, NULL);
TEST_ASSERT(e, whole.width > sliced.width,
"HelloWorld did not measure wider than its Hello prefix");
config.fontId = (uint16_t)(AKGL_UI_MAX_FONTS + 1);
sliced = akgl_ui_measure_text(slice, &config, NULL);
TEST_ASSERT_FEQ(e, sliced.width, 0.0f,
"an unregistered fontId measured %f wide instead of 0",
(double)sliced.width);
TEST_ASSERT_FEQ(e, sliced.height, 0.0f,
"an unregistered fontId measured %f high instead of 0",
(double)sliced.height);
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The frame bracket refuses to nest, to close unopened, and recovers from failure.
*/
akerr_ErrorContext *test_ui_frame_bracket(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the frame bracket test failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer),
"frame_end closed a frame nothing opened");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening a frame failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(),
"a second frame_begin was not refused");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer),
"closing an empty frame failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer),
"frame_end closed the same frame twice");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_frame_end(NULL),
"frame_end accepted a NULL backend");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief One real layout, executed, read back: fill, border and clip land where clay put them.
*
* An outer container with 8 pixels of padding and a fixed 20x20 child pins
* the child's box to (8,8)-(28,28) without depending on any layout behaviour
* subtler than "padding is padding".
*/
akerr_ErrorContext *test_ui_layout_pixels(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the layout test failed");
// A filled rectangle.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the fill frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(20),
.height = CLAY_SIZING_FIXED(20)
}
},
.backgroundColor = { 255, 0, 0, 255 }
}) {}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the fill frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(e, pixel_is(shot, 10, 10, testred),
"the fill did not land inside the child's box");
TEST_ASSERT(e, pixel_is(shot, 27, 27, testred),
"the fill stopped short of the child's far corner");
TEST_ASSERT(e, pixel_is(shot, 7, 7, testblack),
"the fill leaked outside the child's box");
TEST_ASSERT(e, pixel_is(shot, 40, 40, testblack),
"the fill reached pixels no element occupies");
SDL_DestroySurface(shot);
shot = NULL;
// A border: green edges two pixels wide, nothing in the middle.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the border frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(20),
.height = CLAY_SIZING_FIXED(20)
}
},
.border = {
.color = { 0, 255, 0, 255 },
.width = { 2, 2, 2, 2, 0 }
}
}) {}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the border frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(e, pixel_is(shot, 18, 9, testgreen), "the top border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 18, 26, testgreen), "the bottom border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 9, 18, testgreen), "the left border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 26, 18, testgreen), "the right border edge is missing");
TEST_ASSERT(e, pixel_is(shot, 18, 18, testblack), "the border filled its middle");
SDL_DestroySurface(shot);
shot = NULL;
// A clipped child: a 30x30 fill inside a 10x10 clipping container
// must stop at the container's edge.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the clip frame failed");
CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(10),
.height = CLAY_SIZING_FIXED(10)
}
},
.clip = { .horizontal = true, .vertical = true }
}) {
CLAY({
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(30),
.height = CLAY_SIZING_FIXED(30)
}
},
.backgroundColor = { 255, 0, 0, 255 }
}) {}
}
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the clip frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(e, pixel_is(shot, 10, 10, testred),
"the clipped child did not draw inside the clip");
TEST_ASSERT(e, pixel_is(shot, 25, 25, testblack),
"the child escaped its clipping container");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Text laid out by clay lands on the target, and in its colour.
*
* Which pixels a rasterized glyph occupies is SDL_ttf's business, so the
* assertion is that *some* pixel in the text's box carries the text colour --
* enough to prove the measure bridge, the scratch copy and the draw all met.
*/
akerr_ErrorContext *test_ui_layout_text(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
uint16_t fontid = 0;
bool found = false;
int x = 0;
int y = 0;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the text layout test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the layout test font failed");
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the text frame failed");
CLAY({ .layout = { .padding = { 4, 0, 4, 0 } } }) {
CLAY_TEXT(CLAY_STRING("Hi"), CLAY_TEXT_CONFIG({
.fontId = fontid,
.textColor = { 255, 0, 0, 255 }
}));
}
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the text frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) {
for ( x = 0; x < (TEST_TARGET_SIZE / 2); x++ ) {
if ( pixel_is(shot, x, y, testred) ) {
found = true;
}
}
}
TEST_ASSERT(e, found, "no pixel of the laid-out text carries the text colour");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief A layout error inside clay must surface from frame_end, not vanish.
*
* The measure callback and clay's own error handler both report into a stash
* with no other way out. Text declared against a fontId nothing issued is the
* easiest such error to provoke, and the frame after the failed one must be
* clean -- one bad frame, not a wedged subsystem.
*/
akerr_ErrorContext *test_ui_layout_error_surfaces(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the layout error test failed");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the doomed frame failed");
CLAY_TEXT(CLAY_STRING("doomed"), CLAY_TEXT_CONFIG({
.fontId = 7,
.textColor = { 255, 255, 255, 255 }
}));
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer),
"a layout with an unresolvable font closed cleanly");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "the frame after a failed one was refused");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer),
"closing the recovery frame failed");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
ATTEMPT {
CATCH(errctx, akgl_error_init());
// The headless tests run first, before SDL is up, because running
// without a renderer is part of what they assert.
CATCH(errctx, test_ui_validation());
CATCH(errctx, test_ui_lifecycle());
CATCH(errctx, test_ui_arena_exhaustion());
akgl_renderer = &akgl_default_renderer;
FAIL_ZERO_BREAK(
errctx,
SDL_Init(SDL_INIT_VIDEO),
AKGL_ERR_SDL,
"Couldn't initialize SDL: %s",
SDL_GetError());
FAIL_ZERO_BREAK(
errctx,
TTF_Init(),
AKGL_ERR_SDL,
"Couldn't initialize the font engine: %s",
SDL_GetError());
CATCH(errctx, akgl_registry_init_font());
CATCH(errctx, akgl_text_loadfont(TEST_FONT_NAME, TEST_FONT_PATH, TEST_FONT_SIZE));
FAIL_ZERO_BREAK(
errctx,
SDL_CreateWindowAndRenderer(
"net/aklabs/libakgl/test_ui",
TEST_TARGET_SIZE,
TEST_TARGET_SIZE,
0,
&akgl_window,
&akgl_renderer->sdl_renderer),
AKGL_ERR_SDL,
"Couldn't create window/renderer: %s",
SDL_GetError());
CATCH(errctx, akgl_render_2d_bind(akgl_renderer));
CATCH(errctx, test_ui_font_register());
CATCH(errctx, test_ui_measure_text());
CATCH(errctx, test_ui_frame_bracket());
CATCH(errctx, test_ui_layout_pixels());
CATCH(errctx, test_ui_layout_text());
CATCH(errctx, test_ui_layout_error_surfaces());
} CLEANUP {
IGNORE(akgl_text_unloadallfonts());
TTF_Quit();
SDL_Quit();
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}