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

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

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

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

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

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 11:06:27 -04:00
parent 064e6569e8
commit 3b3bfcbe10
3 changed files with 1122 additions and 30 deletions

View File

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

514
src/ui.c
View File

@@ -1,8 +1,9 @@
/**
* @file ui.c
* @brief Implements the UI subsystem: clay's arena, lifecycle and error stash.
* @brief Implements the UI subsystem: clay's arena, fonts, rendering and lifecycle.
*/
#include <stdarg.h>
#include <stddef.h>
#include <stdint.h>
@@ -10,8 +11,14 @@
#include <akstdlib.h>
#include <clay.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/sprite.h>
#include <akgl/text.h>
#include <akgl/ui.h>
/**
@@ -29,44 +36,105 @@ static uint8_t ui_arena_bytes[AKGL_UI_ARENA_BYTES];
static size_t ui_arena_usable = AKGL_UI_ARENA_BYTES;
/** @brief The context Clay_Initialize handed back, or NULL before init / after shutdown. */
static Clay_Context *ui_context;
/** @brief Whether a frame is open -- between frame_begin and frame_end. */
static bool ui_in_frame;
/**
* @brief The fontId table: slot N holds the registry name behind clay fontId N.
*
* Names, not `TTF_Font *` handles -- fonts are not reference counted, and a
* name resolved per use through #AKGL_REGISTRY_FONT reports "gone" honestly
* where a cached handle would dangle.
*/
static char ui_fonts[AKGL_UI_MAX_FONTS][AKGL_UI_FONT_NAME_LENGTH];
/** @brief Slots of #ui_fonts in use. Ids 0 to this minus one are live. */
static int ui_font_count;
/** @brief Scratch one TEXT render command's line is copied through, so SDL_ttf gets a C string. */
static char ui_text_scratch[AKGL_UI_MAX_TEXT_BYTES];
/**
* @brief First error clay reported since the stash was last cleared.
*
* clay's error handler is a void callback with no way to refuse or return, so
* a layout error cannot surface at the call that caused it -- it fires in the
* middle of Clay_EndLayout with libakgl nowhere on the stack. The handler
* stashes the first message here and akgl_ui_frame_end raises it, which is
* the earliest point the error protocol can carry it. Later errors in the
* same frame are counted but not kept: the first one is almost always the
* cause and the rest its consequences.
* clay's error handler is a void callback with no way to refuse or return,
* and the measure callback's signature is no better -- a layout error cannot
* surface at the call that caused it, because libakgl is nowhere on the stack
* when it happens. Both stash the first message here and akgl_ui_frame_end
* raises it, which is the earliest point the error protocol can carry it.
* Later errors in the same frame are counted but not kept: the first one is
* almost always the cause and the rest its consequences.
*/
static char ui_clay_error_text[UI_ERROR_TEXT_LENGTH];
/** @brief How many errors clay has reported since the stash was cleared. */
/** @brief How many errors have been stashed since the stash was cleared. */
static uint32_t ui_clay_error_count;
/**
* @brief Log a layout-time failure now and stash the first one for frame_end.
*
* The shared back half of the clay error handler and the measure callback --
* the two places an error can happen with no way to return it.
*
* @param format printf format for the message, followed by its arguments.
*/
static void ui_stash_error(const char *format, ...)
{
va_list args;
int count = 0;
ui_clay_error_count += 1;
if ( ui_clay_error_count > 1 ) {
// Still worth logging: the operator watching the log sees the whole
// cascade, even though frame_end will raise only the first.
va_start(args, format);
SDL_LogMessageV(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_ERROR, format, args);
va_end(args);
return;
}
va_start(args, format);
IGNORE(aksl_vsnprintf(&count, ui_clay_error_text, sizeof(ui_clay_error_text), format, args));
va_end(args);
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", ui_clay_error_text);
}
/**
* @brief The error handler given to Clay_Initialize.
*
* Logs every report as it happens -- an operator watching the log should not
* have to wait for frame_end -- and stashes the first for the error protocol.
* Clay_String is a length and a pointer, not a C string, so the copy is
* bounded by hand before aksl_strncpy sees it.
* Clay_String is a length and a pointer, not a C string, so the text goes
* through the stash with an explicit precision.
*/
static void ui_clay_error(Clay_ErrorData error)
{
size_t length = 0;
ui_stash_error("clay: %.*s", (int)error.errorText.length, error.errorText.chars);
}
SDL_Log("clay: %.*s", (int)error.errorText.length, error.errorText.chars);
ui_clay_error_count += 1;
if ( ui_clay_error_count > 1 ) {
return;
}
length = (size_t)error.errorText.length;
if ( length > (sizeof(ui_clay_error_text) - 1) ) {
length = sizeof(ui_clay_error_text) - 1;
}
IGNORE(aksl_strncpy(ui_clay_error_text, sizeof(ui_clay_error_text), error.errorText.chars, length));
/**
* @brief Resolve a clay fontId to the live font handle behind it.
*
* @param fontid The id a text element carried.
* @param dest Receives the font. Assumed non-`NULL`; internal callers only.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_UI If the id was never registered, or the name behind it
* has left the registry since.
*/
static akerr_ErrorContext *ui_font_for_id(uint16_t fontid, TTF_Font **dest)
{
PREPARE_ERROR(errctx);
FAIL_NONZERO_RETURN(
errctx,
((int)fontid >= ui_font_count),
AKGL_ERR_UI,
"fontId %u was never registered; akgl_ui_font_register has issued %d",
fontid,
ui_font_count);
*dest = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, ui_fonts[fontid], NULL);
FAIL_ZERO_RETURN(
errctx,
*dest,
AKGL_ERR_UI,
"The font named %s behind fontId %u is no longer in the registry",
ui_fonts[fontid],
fontid);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_ui_init(int width, int height)
@@ -114,6 +182,9 @@ akerr_ErrorContext *akgl_ui_init(int width, int height)
// context would otherwise surface as a crash inside the first CLAY()
// block, far from the cause.
FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "Clay_Initialize refused: %s", ui_clay_error_text);
// Needs the context Clay_Initialize just made current, so it cannot move
// earlier. Without a measure function every text element is an error.
Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL);
SUCCEED_RETURN(errctx);
}
@@ -124,6 +195,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void)
// nothing to hand back, so shutdown is forgetting. The next init lays a
// fresh context over the same bytes.
ui_context = NULL;
ui_in_frame = false;
ui_font_count = 0;
ui_clay_error_text[0] = '\0';
ui_clay_error_count = 0;
SUCCEED_RETURN(errctx);
@@ -143,6 +216,399 @@ akerr_ErrorContext *akgl_ui_resize(int width, int height)
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_ui_font_register(char *name, uint16_t *fontid)
{
int i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name");
FAIL_ZERO_RETURN(errctx, fontid, AKERR_NULLPOINTER, "Null fontid destination");
FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
FAIL_ZERO_RETURN(
errctx,
SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL),
AKGL_ERR_UI,
"No font named %s in the registry; akgl_text_loadfont first",
name);
for ( i = 0; i < ui_font_count; i++ ) {
if ( SDL_strcmp(ui_fonts[i], name) == 0 ) {
*fontid = (uint16_t)i;
SUCCEED_RETURN(errctx);
}
}
FAIL_NONZERO_RETURN(
errctx,
(ui_font_count >= AKGL_UI_MAX_FONTS),
AKGL_ERR_UI,
"All %d fontId slots are taken. Raise AKGL_UI_MAX_FONTS.",
AKGL_UI_MAX_FONTS);
PASS(errctx, aksl_strcpy(ui_fonts[ui_font_count], AKGL_UI_FONT_NAME_LENGTH, name));
*fontid = (uint16_t)ui_font_count;
ui_font_count += 1;
SUCCEED_RETURN(errctx);
}
Clay_Dimensions akgl_ui_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData)
{
Clay_Dimensions dimensions = { 0.0f, 0.0f };
TTF_Font *font = NULL;
int w = 0;
int h = 0;
(void)userData;
// No error protocol in here: clay's callback signature returns dimensions
// by value, so failure is zero-by-zero plus a stashed message for
// frame_end. The checks mirror ui_font_for_id without borrowing it,
// because a context prepared here would have nowhere to go.
if ( config == NULL ) {
ui_stash_error("Text measurement was asked for with no element configuration");
return dimensions;
}
if ( (int)config->fontId >= ui_font_count ) {
ui_stash_error(
"fontId %u was never registered; akgl_ui_font_register has issued %d",
config->fontId,
ui_font_count);
return dimensions;
}
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, ui_fonts[config->fontId], NULL);
if ( font == NULL ) {
ui_stash_error(
"The font named %s behind fontId %u is no longer in the registry",
ui_fonts[config->fontId],
config->fontId);
return dimensions;
}
if ( text.length == 0 ) {
return dimensions;
}
// The slice is not NUL-terminated and does not need to be: SDL_ttf takes
// an explicit byte length, so the measurement happens in place with no
// copy -- this callback runs for every word clay has not cached.
if ( TTF_GetStringSize(font, text.chars, (size_t)text.length, &w, &h) == false ) {
ui_stash_error("Measuring %d bytes of text failed: %s", text.length, SDL_GetError());
return dimensions;
}
dimensions.width = (float)w;
dimensions.height = (float)h;
return dimensions;
}
/** @brief Clamp one clay colour channel (a float, conventionally 0-255) to a byte. */
static uint8_t ui_color_channel(float value)
{
if ( value <= 0.0f ) {
return 0;
}
if ( value >= 255.0f ) {
return 255;
}
return (uint8_t)(value + 0.5f);
}
/** @brief Convert a clay colour to the SDL_Color every draw primitive takes. */
static SDL_Color ui_color_from_clay(Clay_Color color)
{
SDL_Color out;
out.r = ui_color_channel(color.r);
out.g = ui_color_channel(color.g);
out.b = ui_color_channel(color.b);
out.a = ui_color_channel(color.a);
return out;
}
/**
* @brief Draw one BORDER command: four edge fills and up to four corner arcs.
*
* The edges are shortened by the corner radius and the corners are stroked as
* quarter arcs of the adjacent widths' larger value -- the approximation is
* only visible when two adjacent sides have different widths *and* a radius,
* which nothing in this library produces.
*
* @param self The backend. Assumed checked by the caller.
* @param command The BORDER render command.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_* Whatever the fills and arcs underneath raise.
*/
static akerr_ErrorContext *ui_execute_border(akgl_RenderBackend *self, Clay_RenderCommand *command)
{
Clay_BorderRenderData *border = &command->renderData.border;
Clay_BoundingBox box = command->boundingBox;
SDL_Color color;
SDL_FRect edge;
float32_t radius = border->cornerRadius.topLeft;
float32_t thickness = 0.0f;
PREPARE_ERROR(errctx);
color = ui_color_from_clay(border->color);
if ( border->width.left > 0 ) {
edge.x = box.x;
edge.y = box.y + radius;
edge.w = (float32_t)border->width.left;
edge.h = box.height - (radius * 2.0f);
PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
}
if ( border->width.right > 0 ) {
edge.x = box.x + box.width - (float32_t)border->width.right;
edge.y = box.y + radius;
edge.w = (float32_t)border->width.right;
edge.h = box.height - (radius * 2.0f);
PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
}
if ( border->width.top > 0 ) {
edge.x = box.x + radius;
edge.y = box.y;
edge.w = box.width - (radius * 2.0f);
edge.h = (float32_t)border->width.top;
PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
}
if ( border->width.bottom > 0 ) {
edge.x = box.x + radius;
edge.y = box.y + box.height - (float32_t)border->width.bottom;
edge.w = box.width - (radius * 2.0f);
edge.h = (float32_t)border->width.bottom;
PASS(errctx, akgl_draw_filled_rect(self, &edge, color));
}
if ( radius > 0.0f ) {
thickness = (float32_t)((border->width.top > border->width.left) ? border->width.top : border->width.left);
if ( thickness > 0.0f ) {
PASS(errctx, akgl_draw_arc(self, box.x + radius, box.y + radius, radius, 180.0f, 270.0f, thickness, color));
}
thickness = (float32_t)((border->width.top > border->width.right) ? border->width.top : border->width.right);
if ( thickness > 0.0f ) {
PASS(errctx, akgl_draw_arc(self, box.x + box.width - radius, box.y + radius, radius, 270.0f, 360.0f, thickness, color));
}
thickness = (float32_t)((border->width.bottom > border->width.right) ? border->width.bottom : border->width.right);
if ( thickness > 0.0f ) {
PASS(errctx, akgl_draw_arc(self, box.x + box.width - radius, box.y + box.height - radius, radius, 0.0f, 90.0f, thickness, color));
}
thickness = (float32_t)((border->width.bottom > border->width.left) ? border->width.bottom : border->width.left);
if ( thickness > 0.0f ) {
PASS(errctx, akgl_draw_arc(self, box.x + radius, box.y + box.height - radius, radius, 90.0f, 180.0f, thickness, color));
}
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Draw one TEXT command through akgl_text_rendertextat().
*
* clay wraps text itself -- each command is one line -- so the line is drawn
* unwrapped at the command's position. The slice is copied through the
* bounded scratch because akgl_text_rendertextat takes a C string.
*
* @param self The backend. Assumed checked by the caller.
* @param command The TEXT render command.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_UI If the line exceeds #AKGL_UI_MAX_TEXT_BYTES or the
* fontId does not resolve.
* @throws AKERR_* Whatever the copy or the draw underneath raises.
*/
static akerr_ErrorContext *ui_execute_text(akgl_RenderBackend *self, Clay_RenderCommand *command)
{
Clay_TextRenderData *text = &command->renderData.text;
TTF_Font *font = NULL;
PREPARE_ERROR(errctx);
if ( text->stringContents.length == 0 ) {
SUCCEED_RETURN(errctx);
}
FAIL_NONZERO_RETURN(
errctx,
(text->stringContents.length >= (int32_t)sizeof(ui_text_scratch)),
AKGL_ERR_UI,
"A text run of %d bytes exceeds AKGL_UI_MAX_TEXT_BYTES (%d)",
text->stringContents.length,
(int)sizeof(ui_text_scratch));
PASS(errctx, ui_font_for_id(text->fontId, &font));
PASS(errctx, aksl_strncpy(
ui_text_scratch,
sizeof(ui_text_scratch),
text->stringContents.chars,
(size_t)text->stringContents.length));
PASS(errctx, akgl_text_rendertextat(
font,
ui_text_scratch,
ui_color_from_clay(text->textColor),
0,
(int)command->boundingBox.x,
(int)command->boundingBox.y));
SUCCEED_RETURN(errctx);
}
/**
* @brief Draw one IMAGE command: a sprite's first frame stretched to the box.
*
* @param self The backend. Assumed checked by the caller, `draw_texture`
* included.
* @param command The IMAGE render command, whose `imageData` is an
* `akgl_Sprite *` by the contract in akgl/ui.h.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKGL_ERR_UI If the command carries no sprite, or the sprite has no
* sheet behind it.
* @throws AKERR_* Whatever the frame lookup or the draw underneath raises.
*/
static akerr_ErrorContext *ui_execute_image(akgl_RenderBackend *self, Clay_RenderCommand *command)
{
akgl_Sprite *sprite = (akgl_Sprite *)command->renderData.image.imageData;
SDL_FRect src;
SDL_FRect dest;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(
errctx,
sprite,
AKGL_ERR_UI,
"An IMAGE element carries no sprite; .image.imageData must be an akgl_Sprite pointer");
FAIL_ZERO_RETURN(
errctx,
sprite->sheet,
AKGL_ERR_UI,
"The sprite named %s behind an IMAGE element has no sheet",
sprite->name);
PASS(errctx, akgl_spritesheet_coords_for_frame(sprite, &src, sprite->frameids[0]));
dest.x = command->boundingBox.x;
dest.y = command->boundingBox.y;
dest.w = command->boundingBox.width;
dest.h = command->boundingBox.height;
PASS(errctx, self->draw_texture(self, sprite->sheet->texture, &src, &dest, 0, NULL, SDL_FLIP_NONE));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_RenderCommandArray *commands)
{
Clay_RenderCommand *command = NULL;
SDL_FRect fill;
SDL_Rect clip;
bool clipped = false;
static bool custom_skip_logged = false;
int32_t i = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
FAIL_ZERO_RETURN(errctx, self->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture");
FAIL_ZERO_RETURN(errctx, commands, AKERR_NULLPOINTER, "NULL command array");
ATTEMPT {
// The loop is the whole ATTEMPT body on purpose: CATCH reports
// failure by break, which must leave the block, and here leaving the
// loop is leaving the block.
for ( i = 0; i < commands->length; i++ ) {
command = Clay_RenderCommandArray_Get(commands, i);
switch ( command->commandType ) {
case CLAY_RENDER_COMMAND_TYPE_RECTANGLE:
fill.x = command->boundingBox.x;
fill.y = command->boundingBox.y;
fill.w = command->boundingBox.width;
fill.h = command->boundingBox.height;
CATCH(errctx, akgl_draw_filled_rounded_rect(
self,
&fill,
command->renderData.rectangle.cornerRadius.topLeft,
ui_color_from_clay(command->renderData.rectangle.backgroundColor)));
break;
case CLAY_RENDER_COMMAND_TYPE_BORDER:
CATCH(errctx, ui_execute_border(self, command));
break;
case CLAY_RENDER_COMMAND_TYPE_TEXT:
CATCH(errctx, ui_execute_text(self, command));
break;
case CLAY_RENDER_COMMAND_TYPE_IMAGE:
CATCH(errctx, ui_execute_image(self, command));
break;
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START:
clip.x = (int)command->boundingBox.x;
clip.y = (int)command->boundingBox.y;
clip.w = (int)command->boundingBox.width;
clip.h = (int)command->boundingBox.height;
CATCH(errctx, akgl_draw_set_clip(self, &clip));
clipped = true;
break;
case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END:
CATCH(errctx, akgl_draw_set_clip(self, NULL));
clipped = false;
break;
case CLAY_RENDER_COMMAND_TYPE_CUSTOM:
// Skipped in this version, once in the log rather than once
// per frame -- sixty repeats a second is how a log gets
// ignored.
if ( custom_skip_logged == false ) {
SDL_Log("akgl_ui: CUSTOM render commands are not handled and were skipped");
custom_skip_logged = true;
}
break;
default:
break;
}
// A CATCH inside a case reports failure with `break`, and that
// break binds to the *switch* -- so a failed command falls out
// here with errctx carrying the failure, and this is what leaves
// the loop. On success errctx is still NULL; check the pointer,
// not a status through it.
if ( errctx != NULL ) {
break;
}
}
} CLEANUP {
// A failed frame must not leave the next frame's world clipped.
if ( clipped == true ) {
IGNORE(akgl_draw_set_clip(self, NULL));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_ui_frame_begin(void)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
FAIL_NONZERO_RETURN(
errctx,
ui_in_frame,
AKGL_ERR_UI,
"A UI frame is already open; a frame_begin/frame_begin sequence means a frame_end went missing");
ui_clay_error_text[0] = '\0';
ui_clay_error_count = 0;
Clay_BeginLayout();
ui_in_frame = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self)
{
Clay_RenderCommandArray commands;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
FAIL_ZERO_RETURN(errctx, self->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture");
FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
FAIL_NONZERO_RETURN(
errctx,
(ui_in_frame == false),
AKGL_ERR_UI,
"No UI frame is open; akgl_ui_frame_begin starts one");
// Closed before anything can fail, so one bad frame is one bad frame and
// the next frame_begin is legal rather than "already open".
ui_in_frame = false;
commands = Clay_EndLayout();
// Layout errors take precedence over drawing: a layout that failed is not
// a layout, and drawing its partial commands would only bury the cause
// under whatever the draw then reported.
FAIL_NONZERO_RETURN(
errctx,
(ui_clay_error_count > 0),
AKGL_ERR_UI,
"clay reported %u layout error(s); the first: %s",
ui_clay_error_count,
ui_clay_error_text);
PASS(errctx, akgl_ui_execute_commands(self, &commands));
SUCCEED_RETURN(errctx);
}
void akgl_ui_arena_limit(size_t limit)
{
if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) {

View File

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