Vendor clay and bring the UI subsystem up: arena, lifecycle, status band

clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new
akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the
semver/libccd precedent: clay's CMakeLists declares no library target, builds
its examples by default, and requires CMake 3.27 against this project's 3.10.
src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on
the vendored-code terms but with clay's symbols deliberately exported --
consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them
never to link a second clay.

The subsystem is runtime-optional the way collision is: always compiled in,
inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_*
ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc),
and refuses with both numbers in the message when it does not fit. Measured:
812544 bytes at the default 1024 elements / 4096 measured words, against a
1 MiB arena. clay's void-callback layout errors are logged as they happen and
the first is stashed for the error protocol to raise later.

AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in
akgl_error_init. New `ui` test suite covers validation, the init/shutdown
lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same
contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h,
its licence beside libccd's, and the headers suite proves akgl/ui.h
self-contained -- clay.h compiles clean under -Wall.

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 10:53:11 -04:00
parent 75da766724
commit b7ff54b09f
11 changed files with 494 additions and 6 deletions

View File

@@ -50,6 +50,7 @@
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include <akgl/types.h>
#include <akgl/ui.h>
#include <akgl/util.h>
/**

View File

@@ -33,7 +33,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void)
{ AKGL_ERR_HEAP, "Heap Error" },
{ AKGL_ERR_BEHAVIOR, "Behavior Error" },
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" },
{ AKGL_ERR_COLLISION, "Collision Error" }
{ AKGL_ERR_COLLISION, "Collision Error" },
{ AKGL_ERR_UI, "UI Error" }
};
bool named = true;
int i = 0;

112
tests/ui.c Normal file
View File

@@ -0,0 +1,112 @@
/**
* @file ui.c
* @brief Unit tests for the UI subsystem: lifecycle, arena sizing and validation.
*
* 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.
*/
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/ui.h>
#include "testutil.h"
/**
* @brief Init must refuse bad dimensions and an uninitialized resize.
*
* Runs before anything initializes the subsystem, because the "not
* initialized" refusal is half of what it asserts.
*/
akerr_ErrorContext *test_ui_validation(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(0, 240),
"akgl_ui_init accepted a zero width");
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(320, -1),
"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");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Init, double-init refusal, idempotent shutdown, re-init.
*
* The double init has to be refused rather than absorbed: a second
* Clay_Initialize over a live context would silently discard every element id
* and scroll position the first one held, which is the kind of reset a game
* only notices as "the menu forgot where it was".
*/
akerr_ErrorContext *test_ui_lifecycle(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "first akgl_ui_init failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240),
"a second akgl_ui_init was not refused");
TEST_EXPECT_OK(e, akgl_ui_resize(640, 480), "resize on a live subsystem failed");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "akgl_ui_shutdown failed");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "a second akgl_ui_shutdown was not a no-op");
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "re-init after shutdown failed");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after re-init failed");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief A too-small arena must refuse at init, loudly, and recover.
*
* The narrowed limit stands in for a consumer who raised AKGL_UI_MAX_ELEMENTS
* without raising AKGL_UI_ARENA_BYTES: the refusal at startup, with both
* numbers in the message, is the whole safety story -- past init, clay trusts
* the arena completely. Same shape as the collision arena's exhaustion test,
* for the same reason: rebuilding with a smaller ceiling is not something CI
* can do.
*/
akerr_ErrorContext *test_ui_arena_exhaustion(void)
{
PREPARE_ERROR(e);
ATTEMPT {
akgl_ui_arena_limit(64);
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240),
"akgl_ui_init accepted an arena clay cannot fit in");
akgl_ui_arena_limit(0);
TEST_EXPECT_OK(e, akgl_ui_init(320, 240),
"akgl_ui_init failed after the arena limit was restored");
TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after the exhaustion test failed");
} CLEANUP {
akgl_ui_arena_limit(0);
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
int main(void)
{
PREPARE_ERROR(errctx);
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, test_ui_validation());
CATCH(errctx, test_ui_lifecycle());
CATCH(errctx, test_ui_arena_exhaustion());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}