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:
@@ -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
514
src/ui.c
@@ -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) ) {
|
||||
|
||||
475
tests/ui.c
475
tests/ui.c
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user