Files
libakgl/tests/ui.c

1017 lines
38 KiB
C
Raw Normal View History

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
2026-08-02 10:53:11 -04:00
/**
* @file ui.c
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
2026-08-02 11:06:27 -04:00
* @brief Unit tests for the UI subsystem: lifecycle, fonts, measurement and rendering.
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
2026-08-02 10:53:11 -04:00
*
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
2026-08-02 11:06:27 -04:00
* 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.
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
2026-08-02 10:53:11 -04:00
*/
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
2026-08-02 11:06:27 -04:00
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
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
2026-08-02 10:53:11 -04:00
#include <akerror.h>
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
2026-08-02 11:06:27 -04:00
#include <akgl/draw.h>
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
2026-08-02 10:53:11 -04:00
#include <akgl/error.h>
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
2026-08-02 11:06:27 -04:00
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
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
2026-08-02 10:53:11 -04:00
#include <akgl/ui.h>
#include "testutil.h"
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
2026-08-02 11:06:27 -04:00
/** @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));
}
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
2026-08-02 10:53:11 -04:00
/**
* @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");
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
2026-08-02 11:06:27 -04:00
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(),
"akgl_ui_frame_begin worked on an uninitialized subsystem");
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
2026-08-02 10:53:11 -04:00
} 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);
}
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
2026-08-02 11:06:27 -04:00
/**
* @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);
}
Feed the mouse to the UI and tell the host which events it consumed akgl_ui_handle_event is the mouse half of the input story -- keyboard and gamepad stay with the control maps, because keyboard focus is something an application declares rather than something a pointer position implies. It handles motion, left-button presses and releases, the wheel and window resizes; everything else, and everything while the subsystem is inert, reports consumed=false and succeeds, the same pass-everything contract akgl_controller_handle_event already has. The documented call order is UI first, control maps second, skipping on consumed. A press, release or wheel is consumed when the pointer is over any UI element. The hit test asks clay against the layout the previous frame retained -- this frame's does not exist while events are polled, and one frame of staleness is the standard model's accepted cost -- with clay's internal full-screen Clay__RootContainer excluded, because being inside the window is not being over the interface (unexcluded, every click anywhere was consumed; the test that pins this caught it). Pointer state is fed to clay per event, as clay documents; the press edge is latched once per frame_begin for the widgets to come, rather than trusting Clay_PointerData's per-SetPointerState edge, which advances per mouse event. frame_begin also drives Clay_UpdateScrollContainers from the accumulated wheel (32 pixels per notch) and a wall-clock dt. Tests pin the whole contract: pass-through before init, no consumption before any layout exists, press/release inside vs beside a panel, right button ignored, motion never consumed, wheel consumed only over the UI, resize reaching the layout engine, and key events left alone. 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 11:10:35 -04:00
/** @brief Build a left-button mouse event at a position, down or up. */
static void make_button_event(SDL_Event *event, uint32_t type, float x, float y)
{
SDL_memset(event, 0x00, sizeof(SDL_Event));
event->type = type;
event->button.button = SDL_BUTTON_LEFT;
event->button.x = x;
event->button.y = y;
}
/**
* @brief The consumed contract: clicks on the UI are the UI's, everything else passes.
*
* The hit test runs against the layout the previous frame retained, so the
* test lays one panel out, then throws events at it: a click inside is
* consumed, a click outside is not, motion never is, and -- the case most
* likely to surprise -- a click before any layout exists is not consumed,
* because there is nothing to be over.
*/
akerr_ErrorContext *test_ui_handle_event(void)
{
PREPARE_ERROR(e);
SDL_Event event;
bool consumed = true;
int token = 1;
ATTEMPT {
// Uninitialized: everything passes through untouched.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"an event before init was refused");
TEST_ASSERT(e, consumed == false, "an event before init was consumed");
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the event test failed");
// Initialized but nothing laid out yet: a click lands on no tree.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a click before any layout was refused");
TEST_ASSERT(e, consumed == false, "a click was consumed before any layout existed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release before any layout was refused");
// Lay out one 20x20 panel at (8,8) for the hit tests that follow.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the hit-test 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 hit-test frame failed");
// A press on the panel is the UI's; its release is too.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the press on the panel was refused");
TEST_ASSERT(e, consumed == true, "a press on a UI panel was not consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release on the panel was refused");
TEST_ASSERT(e, consumed == true, "a release on a UI panel was not consumed");
// A press beside the panel belongs to the game.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 50.0f, 50.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the press beside the panel was refused");
TEST_ASSERT(e, consumed == false, "a press beside the panel was consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 50.0f, 50.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the release beside the panel was refused");
// A right-button press is not the UI's business at all.
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
event.button.button = SDL_BUTTON_RIGHT;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a right-button press was refused");
TEST_ASSERT(e, consumed == false, "a right-button press was consumed");
// Motion is never consumed, wherever it lands.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = 10.0f;
event.motion.y = 10.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"motion over the panel was refused");
TEST_ASSERT(e, consumed == false, "motion over a panel was consumed");
// The wheel goes to the UI only while the pointer is over it. The
// motion event above left the pointer on the panel.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_WHEEL;
event.wheel.y = 1.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the wheel over the panel was refused");
TEST_ASSERT(e, consumed == true, "the wheel over a panel was not consumed");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = 50.0f;
event.motion.y = 50.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"motion off the panel was refused");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_WHEEL;
event.wheel.y = 1.0f;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the wheel beside the panel was refused");
TEST_ASSERT(e, consumed == false, "the wheel beside the panel was consumed");
// A resize reaches the layout engine and still passes through.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_WINDOW_RESIZED;
event.window.data1 = TEST_TARGET_SIZE;
event.window.data2 = TEST_TARGET_SIZE;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a resize event was refused");
TEST_ASSERT(e, consumed == false, "a resize event was consumed");
// A key event is not the UI's; keyboard focus is the application's.
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_RETURN;
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"a key event was refused");
TEST_ASSERT(e, consumed == false, "a key event was consumed");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f);
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(NULL, &event, &consumed),
"a NULL appstate was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, NULL, &consumed),
"a NULL event was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, &event, NULL),
"a NULL consumed destination was accepted");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
Add the widget helpers: dialog, HUD label, and menu The other half of the hybrid decision: an application that wants a dialog box, a score counter or a main menu gets each in one call, without touching a CLAY() macro -- and an application that outgrows them still has clay's whole DSL, because the widgets are nothing but functions that declare the same elements between the same frame bracket. akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL style *is* the textbox palette -- near-black fill, parchment edge and ink, 8px padding -- deliberately, so the widget and the 125-line hand-rolled panel it stands beside in the comparison chapter produce the same picture. Showing and hiding is the frame's business: call it while somebody is talking, don't while nobody is; a declarative frame is the visible flag. akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by the style's padding. akgl_ui_menu declares a centred vertical menu from a caller-owned struct (no heap layer: it owns no texture, no font, no registry entry), with the selected row drawn inverted, hover moving the selection and the frame-latched press edge activating it. akgl_ui_menu_handle_event drives the same struct from Up/Down/Return and D-pad/South with wrapping, slotting into the event chain after akgl_ui_handle_event; routing events to a menu is the application's focus model, stated rather than invented. Tests cover the dialog's geometry and palette by pixel, label anchoring by quadrant, menu validation, the inverted highlight, keyboard and gamepad wrap/activate/pass-through, and the full two-frame mouse protocol -- click aimed at the second row's real box via Clay_GetElementData, seen by the next frame's declaration as selection plus activation. 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 11:17:32 -04:00
/** @brief The default widget palette's fill, for pixel assertions. Matches src/ui.c. */
static const SDL_Color widgetfill = { 24, 20, 37, 235 };
/** @brief The default widget palette's edge and ink. */
static const SDL_Color widgetink = { 240, 236, 214, 255 };
/**
* @brief The dialog widget draws the textbox: fill inside, border on the edge, text on top.
*
* On a 64x64 layout with the default 8px padding the panel spans (8,0) to
* (56,56) -- the width follows the layout, the height is
* AKGL_UI_DIALOG_HEIGHT. The palette asserted here is the point: the default
* style *is* the JRPG textbox's, so the comparison chapter can put the two
* side by side.
*/
akerr_ErrorContext *test_ui_dialog_widget(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
uint16_t fontid = 0;
ATTEMPT {
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL),
"a dialog was declared with no subsystem");
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the dialog test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the dialog test font failed");
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL),
"a dialog was declared outside the frame bracket");
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the dialog frame failed");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog(NULL, "words", NULL),
"a NULL dialog id was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog("dialog", NULL, NULL),
"NULL dialog text was accepted");
TEST_EXPECT_OK(e, akgl_ui_dialog("dialog", "Hi", NULL), "declaring the dialog failed");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the dialog 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, 32, 40, widgetfill),
"the dialog panel's fill is missing from its middle");
TEST_ASSERT(e, pixel_is(shot, 8, 28, widgetink),
"the dialog panel's border is missing from its left edge");
TEST_ASSERT(e, pixel_is(shot, 4, 28, testblack),
"the dialog painted outside its margin");
TEST_ASSERT(e, pixel_is(shot, 32, 60, testblack),
"the dialog painted over the margin below it");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Labels pin to their corners and stay out of the others.
*/
akerr_ErrorContext *test_ui_label_widget(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
uint16_t fontid = 0;
bool topright = false;
bool bottomleft = false;
int x = 0;
int y = 0;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the label test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the label test font failed");
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the label frame failed");
TEST_EXPECT_OK(e, akgl_ui_label("score", "9", AKGL_UI_ANCHOR_TOP_RIGHT, NULL),
"declaring the top-right label failed");
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
akgl_ui_label("bogus", "9", (akgl_UiAnchor)99, NULL),
"a nonsense anchor was accepted");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the label frame failed");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
// The label is fit-sized around its glyph, so where exactly it ends
// is the font's business; the assertions are per-quadrant instead.
for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) {
for ( x = (TEST_TARGET_SIZE / 2); x < TEST_TARGET_SIZE; x++ ) {
if ( pixel_is(shot, x, y, widgetfill) ) {
topright = true;
}
}
}
// The far corner, not the whole quadrant: a padded label on a 64px
// target is fat enough to graze the quadrant boundary, and where its
// edge falls is the font's business, not this test's.
for ( y = (TEST_TARGET_SIZE - 16); y < TEST_TARGET_SIZE; y++ ) {
for ( x = 0; x < 16; x++ ) {
if ( pixel_is(shot, x, y, widgetfill) ) {
bottomleft = true;
}
}
}
TEST_ASSERT(e, topright, "the top-right label left no fill in its quadrant");
TEST_ASSERT(e, bottomleft == false, "the top-right label reached the opposite corner");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief The menu widget: validation, the inverted highlight, and mouse selection.
*
* The mouse half runs the real two-frame protocol: frame one lays the menu
* out, the click lands between frames against the retained tree, and frame
* two's declaration sees the hover and the latched press edge. Row geometry
* comes from Clay_GetElementData rather than being derived here, so the test
* cannot drift from however the menu lays its rows out.
*/
akerr_ErrorContext *test_ui_menu_widget(void)
{
PREPARE_ERROR(e);
SDL_Surface *shot = NULL;
SDL_Event event;
Clay_ElementData rowdata;
static akgl_UiMenu menu = {
.id = "testmenu",
.items = { "New Game", "Options", "Quit" },
.count = 3,
};
akgl_UiMenu broken;
uint16_t fontid = 0;
bool consumed = false;
bool inverted = false;
int token = 1;
int x = 0;
int y = 0;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"init for the menu test failed");
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
"registering the menu test font failed");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(NULL),
"a NULL menu was accepted");
broken = menu;
broken.count = 0;
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken),
"an empty menu was accepted");
broken = menu;
broken.selected = 7;
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken),
"a selection outside the menu was accepted");
broken = menu;
broken.items[1] = NULL;
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(&broken),
"a NULL entry was accepted");
// Frame one: the menu as declared, selection on the first row.
CATCH(e, clear_target());
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the first menu frame failed");
TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "declaring the menu failed");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the first menu 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; y++ ) {
for ( x = 0; x < TEST_TARGET_SIZE; x++ ) {
if ( pixel_is(shot, x, y, widgetink) ) {
inverted = true;
}
}
}
TEST_ASSERT(e, inverted, "no row carries the inverted highlight");
// The click: aimed at the second row's real box, landed between
// frames, seen by the declaration in frame two.
rowdata = Clay_GetElementData(Clay_GetElementIdWithIndex(CLAY_STRING("testmenu"), 2));
TEST_ASSERT(e, rowdata.found, "the second row's element id cannot be found");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_MOUSE_MOTION;
event.motion.x = rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f);
event.motion.y = rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f);
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"moving onto the second row was refused");
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN,
rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f),
rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f));
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
"the click on the second row was refused");
TEST_ASSERT(e, consumed == true, "the click on the menu was not consumed");
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the second menu frame failed");
TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu failed");
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the second menu frame failed");
TEST_ASSERT(e, menu.selected == 1,
"clicking the second row moved the selection to %d, not 1", menu.selected);
TEST_ASSERT(e, menu.activated == true, "clicking a row did not activate the menu");
menu.activated = false;
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief Keyboard and gamepad drive a menu: wrap both ways, confirm, ignore the rest.
*/
akerr_ErrorContext *test_ui_menu_events(void)
{
PREPARE_ERROR(e);
SDL_Event event;
static akgl_UiMenu menu = {
.id = "eventmenu",
.items = { "One", "Two", "Three" },
.count = 3,
};
bool consumed = false;
ATTEMPT {
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the menu event test failed");
menu.selected = 0;
menu.activated = false;
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = SDLK_DOWN;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused");
TEST_ASSERT(e, consumed && (menu.selected == 1), "Down did not move the selection to 1");
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused");
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused");
TEST_ASSERT(e, menu.selected == 0, "Down from the last row did not wrap to the first");
event.key.key = SDLK_UP;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Up was refused");
TEST_ASSERT(e, menu.selected == 2, "Up from the first row did not wrap to the last");
event.key.key = SDLK_RETURN;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Return was refused");
TEST_ASSERT(e, consumed && menu.activated, "Return did not activate the menu");
menu.activated = false;
event.key.key = SDLK_ESCAPE;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Escape was refused");
TEST_ASSERT(e, consumed == false, "a key the menu does not use was consumed");
SDL_memset(&event, 0x00, sizeof(SDL_Event));
event.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
event.gbutton.button = SDL_GAMEPAD_BUTTON_DPAD_DOWN;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "D-pad down was refused");
TEST_ASSERT(e, consumed && (menu.selected == 0), "D-pad down did not wrap to the first row");
event.gbutton.button = SDL_GAMEPAD_BUTTON_SOUTH;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "South was refused");
TEST_ASSERT(e, consumed && menu.activated, "South did not activate the menu");
menu.activated = false;
event.gbutton.button = SDL_GAMEPAD_BUTTON_EAST;
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "East was refused");
TEST_ASSERT(e, consumed == false, "a button the menu does not use was consumed");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
akgl_ui_menu_handle_event(NULL, &event, &consumed),
"a NULL menu was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
akgl_ui_menu_handle_event(&menu, NULL, &consumed),
"a NULL event was accepted");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
akgl_ui_menu_handle_event(&menu, &event, NULL),
"a NULL consumed destination was accepted");
} CLEANUP {
IGNORE(akgl_ui_shutdown());
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
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
2026-08-02 11:06:27 -04:00
/**
* @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);
}
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
2026-08-02 10:53:11 -04:00
int main(void)
{
PREPARE_ERROR(errctx);
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
2026-08-02 11:06:27 -04:00
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
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
2026-08-02 10:53:11 -04:00
ATTEMPT {
CATCH(errctx, akgl_error_init());
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
2026-08-02 11:06:27 -04:00
// The headless tests run first, before SDL is up, because running
// without a renderer is part of what they assert.
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
2026-08-02 10:53:11 -04:00
CATCH(errctx, test_ui_validation());
CATCH(errctx, test_ui_lifecycle());
CATCH(errctx, test_ui_arena_exhaustion());
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
2026-08-02 11:06:27 -04:00
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());
Feed the mouse to the UI and tell the host which events it consumed akgl_ui_handle_event is the mouse half of the input story -- keyboard and gamepad stay with the control maps, because keyboard focus is something an application declares rather than something a pointer position implies. It handles motion, left-button presses and releases, the wheel and window resizes; everything else, and everything while the subsystem is inert, reports consumed=false and succeeds, the same pass-everything contract akgl_controller_handle_event already has. The documented call order is UI first, control maps second, skipping on consumed. A press, release or wheel is consumed when the pointer is over any UI element. The hit test asks clay against the layout the previous frame retained -- this frame's does not exist while events are polled, and one frame of staleness is the standard model's accepted cost -- with clay's internal full-screen Clay__RootContainer excluded, because being inside the window is not being over the interface (unexcluded, every click anywhere was consumed; the test that pins this caught it). Pointer state is fed to clay per event, as clay documents; the press edge is latched once per frame_begin for the widgets to come, rather than trusting Clay_PointerData's per-SetPointerState edge, which advances per mouse event. frame_begin also drives Clay_UpdateScrollContainers from the accumulated wheel (32 pixels per notch) and a wall-clock dt. Tests pin the whole contract: pass-through before init, no consumption before any layout exists, press/release inside vs beside a panel, right button ignored, motion never consumed, wheel consumed only over the UI, resize reaching the layout engine, and key events left alone. 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 11:10:35 -04:00
CATCH(errctx, test_ui_handle_event());
Add the widget helpers: dialog, HUD label, and menu The other half of the hybrid decision: an application that wants a dialog box, a score counter or a main menu gets each in one call, without touching a CLAY() macro -- and an application that outgrows them still has clay's whole DSL, because the widgets are nothing but functions that declare the same elements between the same frame bracket. akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL style *is* the textbox palette -- near-black fill, parchment edge and ink, 8px padding -- deliberately, so the widget and the 125-line hand-rolled panel it stands beside in the comparison chapter produce the same picture. Showing and hiding is the frame's business: call it while somebody is talking, don't while nobody is; a declarative frame is the visible flag. akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by the style's padding. akgl_ui_menu declares a centred vertical menu from a caller-owned struct (no heap layer: it owns no texture, no font, no registry entry), with the selected row drawn inverted, hover moving the selection and the frame-latched press edge activating it. akgl_ui_menu_handle_event drives the same struct from Up/Down/Return and D-pad/South with wrapping, slotting into the event chain after akgl_ui_handle_event; routing events to a menu is the application's focus model, stated rather than invented. Tests cover the dialog's geometry and palette by pixel, label anchoring by quadrant, menu validation, the inverted highlight, keyboard and gamepad wrap/activate/pass-through, and the full two-frame mouse protocol -- click aimed at the second row's real box via Clay_GetElementData, seen by the next frame's declaration as selection plus activation. 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 11:17:32 -04:00
CATCH(errctx, test_ui_dialog_widget());
CATCH(errctx, test_ui_label_widget());
CATCH(errctx, test_ui_menu_widget());
CATCH(errctx, test_ui_menu_events());
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
2026-08-02 11:06:27 -04:00
CATCH(errctx, test_ui_layout_error_surfaces());
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
2026-08-02 10:53:11 -04:00
} CLEANUP {
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
2026-08-02 11:06:27 -04:00
IGNORE(akgl_text_unloadallfonts());
TTF_Quit();
SDL_Quit();
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
2026-08-02 10:53:11 -04:00
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}