diff --git a/include/akgl/ui.h b/include/akgl/ui.h index 5e9fa20..b03a0b8 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -41,6 +41,8 @@ #include #include +#include + /** * @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. * diff --git a/src/ui.c b/src/ui.c index 4981a65..f7df8df 100644 --- a/src/ui.c +++ b/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 #include #include @@ -10,8 +11,14 @@ #include #include #include +#include +#include #include +#include +#include +#include +#include #include /** @@ -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) ) { diff --git a/tests/ui.c b/tests/ui.c index 60ac515..b547b19 100644 --- a/tests/ui.c +++ b/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 +#include #include +#include #include +#include +#include +#include +#include #include #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); }