diff --git a/.gitmodules b/.gitmodules index b8ebc03..1530379 100644 --- a/.gitmodules +++ b/.gitmodules @@ -28,3 +28,7 @@ [submodule "deps/tg"] path = deps/tg url = https://github.com/tidwall/tg.git +[submodule "clay"] + path = deps/clay + url = https://github.com/nicbarker/clay.git + branch = v0.14 diff --git a/CMakeLists.txt b/CMakeLists.txt index a1ecffa..27bc9dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -217,6 +217,7 @@ set(AKGL_PUBLIC_HEADERS text tilemap types + ui util ) @@ -317,6 +318,8 @@ add_library(akgl SHARED src/sprite.c src/staticstring.c src/tilemap.c + src/ui.c + src/ui_clay.c src/util.c src/version.c ) @@ -368,6 +371,26 @@ set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h" COMPILE_DEFINITIONS "CCD_STATIC_DEFINE") +# clay supplies the UI layout engine. Its sources are listed rather than +# add_subdirectory()'d, on the semver/libccd precedent, because +# deps/clay/CMakeLists.txt is unusable as a subproject and none of it is ours +# to fix in a submodule: +# +# - it declares no library target at all -- clay is a single header, and the +# add_library(INTERFACE) lines at the bottom are commented out upstream. +# - option(CLAY_INCLUDE_ALL_EXAMPLES ... ON) add_subdirectory()s every +# example by default, dragging raylib, cairo and sokol into our configure. +# - cmake_minimum_required(VERSION 3.27) against this project's 3.10. +# +# src/ui_clay.c is the one translation unit that defines CLAY_IMPLEMENTATION; +# it is 99% vendored code, so it gets -w on the same terms as semver and +# libccd. Deliberately NOT -fvisibility=hidden, unlike libccd: the CLAY() +# macros in a consuming game expand to Clay__OpenElement() and friends, which +# must resolve against libakgl.so, so clay's symbols are part of the ABI on +# purpose. The matching obligation -- a consumer must not link a second clay -- +# is documented in akgl/ui.h. +set_source_files_properties(src/ui_clay.c PROPERTIES COMPILE_OPTIONS "-w") + # PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers` # suite compiles each public header as the first include of a translation unit # with only include/ available, so a public header that pulled in @@ -406,6 +429,7 @@ set(AKGL_TEST_SUITES staticstring text tilemap + ui util version ) @@ -516,6 +540,11 @@ set_tests_properties( target_include_directories(akgl PUBLIC include/ deps/semver/ + # PUBLIC, unlike libccd's PRIVATE include above, because akgl/ui.h includes + # in its public interface -- consumers write CLAY() blocks, so + # hiding the header would hide the point. clay.h is installed beside + # semver.h for the same reason. + deps/clay/ # akgl/version.h is generated by configure_file, so it lives in the build tree # rather than beside the headers it is included from. ${CMAKE_CURRENT_BINARY_DIR}/include/ @@ -882,11 +911,14 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/") install(TARGETS akgl DESTINATION "lib/") install(FILES "deps/semver/semver.h" DESTINATION "include/") -# libccd is compiled into libakgl.so, and its BSD-3 licence requires the notice -# to travel with the binary form. Nothing else in deps/ is linked in statically -# -- SDL, jansson, libakerror and libakstdlib are all separate shared objects -# that ship their own -- so this is the only third-party notice we owe. +install(FILES "deps/clay/clay.h" DESTINATION "include/") +# libccd and clay are compiled into libakgl.so, and their licences (BSD-3 and +# zlib respectively) ask that the notice travel with the binary form. Nothing +# else in deps/ is linked in statically -- SDL, jansson, libakerror and +# libakstdlib are all separate shared objects that ship their own -- so these +# are the only third-party notices we owe. install(FILES "deps/libccd/BSD-LICENSE" DESTINATION "share/doc/akgl/" RENAME "BSD-LICENSE.libccd") +install(FILES "deps/clay/LICENSE.md" DESTINATION "share/doc/akgl/" RENAME "LICENSE.clay") foreach(header IN LISTS AKGL_PUBLIC_HEADERS) install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/") endforeach() diff --git a/deps/clay b/deps/clay new file mode 160000 index 0000000..b25a31c --- /dev/null +++ b/deps/clay @@ -0,0 +1 @@ +Subproject commit b25a31c1a152915cd7dd6796e6592273e5a10aac diff --git a/include/akgl/error.h b/include/akgl/error.h index 997afc5..7fa0bbf 100644 --- a/include/akgl/error.h +++ b/include/akgl/error.h @@ -76,11 +76,12 @@ #define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */ #define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */ #define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */ +#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */ // One past the last libakgl status. The reservation is all-or-nothing -- a // subset or superset of an existing one is refused -- so this must stay one // past the highest code above. -#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 6) +#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 7) #define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE) /** diff --git a/include/akgl/ui.h b/include/akgl/ui.h new file mode 100644 index 0000000..5e9fa20 --- /dev/null +++ b/include/akgl/ui.h @@ -0,0 +1,161 @@ +/** + * @file ui.h + * @brief Screen-space user interface layouts, built on the clay layout engine. + * + * This subsystem exists because every game eventually wants a dialog box, a + * HUD counter, or a menu, and hand-rolling them out of rectangles and + * akgl_text_rendertextat() is tedious the first time and unmaintainable by the + * fourth (`examples/jrpg/textbox.c` is the honest record of the first time). + * clay computes the layout; libakgl owns everything that has to touch SDL or + * the rest of the library -- the arena clay allocates from, the text + * measurement it calls back into, the render commands it emits, and the + * pointer state it is fed. + * + * There are two ways in, and they compose: + * + * - Write clay's declarative `CLAY({...}) { ... }` blocks yourself between + * akgl_ui_frame_begin() and akgl_ui_frame_end(). The whole of clay's API + * is available -- this header includes `` on purpose, the same way + * SDL types appear undisguised elsewhere in this library. + * - Call the widget helpers, which emit those blocks for you. + * + * The subsystem is optional the way collision is optional: always compiled in, + * inert until akgl_ui_init() runs, and costing nothing but static storage + * before that. + * + * @warning libakgl compiles clay into `libakgl.so` (the `CLAY_IMPLEMENTATION` + * translation unit is `src/ui_clay.c`) and exports its symbols, + * because the `CLAY()` macros in *your* translation units expand to + * calls into them. Do not define `CLAY_IMPLEMENTATION` yourself and + * do not link another copy of clay: two definitions of the same + * symbols and the loader picks one silently, which is the exact + * defect AGENTS.md records against an exported `renderer`. + */ + +#ifndef _AKGL_UI_H_ +#define _AKGL_UI_H_ + +#include +#include + +#include +#include + +/** + * @brief Most elements one layout may declare. + * + * Passed to `Clay_SetMaxElementCount` before the arena is sized, so it is a + * compile-time ceiling in the same sense as the `AKGL_MAX_HEAP_*` family: + * define it before including this header to raise it, and see + * #AKGL_UI_ARENA_BYTES, which must grow with it. + */ +#ifndef AKGL_UI_MAX_ELEMENTS +#define AKGL_UI_MAX_ELEMENTS 1024 +#endif + +/** + * @brief Most whitespace-separated words clay's text measurement cache holds. + * + * Clay measures text one word at a time and caches the result; a cache miss + * costs one call back into SDL_ttf. This bounds the cache, not the text -- a + * layout with more distinct words than this still renders, it just measures + * some of them every frame and clay reports the overflow through the error + * handler. + */ +#ifndef AKGL_UI_MAX_MEASURE_WORDS +#define AKGL_UI_MAX_MEASURE_WORDS 4096 +#endif + +/** + * @brief Bytes of static storage clay lays its internal structures out in. + * + * The arena is a fixed array in `src/ui.c` -- libakgl does not call `malloc` + * at runtime, and clay never allocates behind the arena's back. What clay + * needs is a function of #AKGL_UI_MAX_ELEMENTS and #AKGL_UI_MAX_MEASURE_WORDS + * and is only computable at runtime, so akgl_ui_init() checks + * `Clay_MinMemorySize()` against this and refuses with both numbers in the + * message when it does not fit. A too-small arena is a loud failure at + * startup, never a corruption at frame forty thousand. + */ +#ifndef AKGL_UI_ARENA_BYTES +#define AKGL_UI_ARENA_BYTES (1024 * 1024) +#endif + +/** + * @brief Bring the UI subsystem up. + * + * Bounds clay to the `AKGL_UI_*` ceilings, checks that the arena is big + * enough for them, and hands it to `Clay_Initialize` with an error handler + * that routes clay's layout-time complaints into the libakgl error protocol + * (they surface from akgl_ui_frame_end(), which is the first call after they + * can happen). + * + * The layout dimensions are taken as parameters rather than read from the + * camera or the window, so a headless program -- a test, a layout tool -- can + * bring the UI up without a renderer existing at all. A windowed game passes + * its screen size and lets akgl_ui_handle_event() track resizes from there. + * + * @param width Layout width in pixels. Must be positive. + * @param height Layout height in pixels. Must be positive. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_OUTOFBOUNDS If @p width or @p height is not positive. + * @throws AKGL_ERR_UI If the subsystem is already initialized -- call + * akgl_ui_shutdown() first; if the arena cannot hold clay's + * structures at the current ceilings -- the message carries the bytes + * required and the bytes available, so the operator knows what to + * raise #AKGL_UI_ARENA_BYTES to; or if `Clay_Initialize` refuses, + * which the preceding checks should make unreachable. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_init(int width, int height); + +/** + * @brief Return the UI subsystem to the inert state. + * + * clay has no teardown of its own -- everything it built lives in the arena, + * and the next akgl_ui_init() lays a new context out over it. This forgets + * the context and clears the stashed error state, and is idempotent, because + * shutdown paths run after partial startups. + * + * @warning Anything that captured clay state -- and any `CLAY()` block that + * runs after this -- is left talking to a context this subsystem has + * disowned. Stop declaring layouts before shutting down, the same + * way fonts stop being drawn before akgl_text_unloadallfonts(). + * + * @return `NULL`. Shutting down an uninitialized subsystem is success. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_shutdown(void); + +/** + * @brief Tell the layout engine the render target changed size. + * + * akgl_ui_handle_event() calls this for `SDL_EVENT_WINDOW_RESIZED`, so a game + * that routes its events through there never calls it directly. It is public + * for the host that owns its own event loop. + * + * @param width New layout width in pixels. Must be positive. + * @param height New layout height in pixels. Must be positive. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the subsystem is not initialized. + * @throws AKERR_OUTOFBOUNDS If @p width or @p height is not positive. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_resize(int width, int height); + +/* + * 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 Narrow the arena, so a test can drive akgl_ui_init() into refusal. + * + * The interesting behaviour is the refusal message naming both numbers, and + * the only other way to reach it is rebuilding the library with a smaller + * #AKGL_UI_ARENA_BYTES, which CI cannot do and a reader cannot repeat. Same + * contract as akgl_ccd_arena_set_limit(). + * + * @param limit Usable bytes. 0, or anything above #AKGL_UI_ARENA_BYTES, + * restores the full arena. + */ +void akgl_ui_arena_limit(size_t limit); + +#endif // _AKGL_UI_H_ diff --git a/src/error.c b/src/error.c index 1df3869..644f448 100644 --- a/src/error.c +++ b/src/error.c @@ -21,5 +21,6 @@ akerr_ErrorContext *akgl_error_init(void) PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error")); PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt")); PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_COLLISION, "Collision Error")); + PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_UI, "UI Error")); SUCCEED_RETURN(errctx); } diff --git a/src/ui.c b/src/ui.c new file mode 100644 index 0000000..4981a65 --- /dev/null +++ b/src/ui.c @@ -0,0 +1,153 @@ +/** + * @file ui.c + * @brief Implements the UI subsystem: clay's arena, lifecycle and error stash. + */ + +#include +#include + +#include +#include +#include +#include + +#include +#include + +/** + * @brief Bytes of the first clay error's text kept for reporting. + * + * Sized to hold every message clay 0.14 actually emits -- they are one + * sentence each -- with room for a longer one to truncate into rather than + * overflow. + */ +#define UI_ERROR_TEXT_LENGTH 256 + +/** @brief The arena clay lays its structures out in. Static storage, so this file allocates nothing. */ +static uint8_t ui_arena_bytes[AKGL_UI_ARENA_BYTES]; +/** @brief Usable bytes. #AKGL_UI_ARENA_BYTES unless a test has narrowed it. */ +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 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. + */ +static char ui_clay_error_text[UI_ERROR_TEXT_LENGTH]; +/** @brief How many errors clay has reported since the stash was cleared. */ +static uint32_t ui_clay_error_count; + +/** + * @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. + */ +static void ui_clay_error(Clay_ErrorData error) +{ + size_t length = 0; + + 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)); +} + +akerr_ErrorContext *akgl_ui_init(int width, int height) +{ + uint32_t needed = 0; + Clay_Arena arena; + Clay_ErrorHandler handler = { &ui_clay_error, NULL }; + Clay_Dimensions dimensions; + + PREPARE_ERROR(errctx); + FAIL_NONZERO_RETURN(errctx, (width <= 0), AKERR_OUTOFBOUNDS, "Layout width %d is not positive", width); + FAIL_NONZERO_RETURN(errctx, (height <= 0), AKERR_OUTOFBOUNDS, "Layout height %d is not positive", height); + FAIL_NONZERO_RETURN( + errctx, + (ui_context != NULL), + AKGL_ERR_UI, + "The UI subsystem is already initialized; akgl_ui_shutdown first"); + + // Ceilings before measurement: Clay_MinMemorySize reports what the + // *current* ceilings need, so they must be set first. Element count + // before word count, and not the other way around -- with no context + // yet, Clay_SetMaxElementCount also overwrites the word-cache default + // with elements * 2, and would silently undo a word count set before it. + Clay_SetMaxElementCount(AKGL_UI_MAX_ELEMENTS); + Clay_SetMaxMeasureTextCacheWordCount(AKGL_UI_MAX_MEASURE_WORDS); + needed = Clay_MinMemorySize(); + FAIL_NONZERO_RETURN( + errctx, + ((size_t)needed > ui_arena_usable), + AKGL_ERR_UI, + "clay needs %u bytes for %d elements and %d measured words; the arena holds %u. Raise AKGL_UI_ARENA_BYTES.", + needed, + AKGL_UI_MAX_ELEMENTS, + AKGL_UI_MAX_MEASURE_WORDS, + (uint32_t)ui_arena_usable); + + ui_clay_error_text[0] = '\0'; + ui_clay_error_count = 0; + arena = Clay_CreateArenaWithCapacityAndMemory(ui_arena_usable, ui_arena_bytes); + dimensions.width = (float)width; + dimensions.height = (float)height; + ui_context = Clay_Initialize(arena, dimensions, handler); + // The capacity check above is the one that should fail; this one exists + // because Clay_Initialize reports refusal by returning NULL and a NULL + // 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); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_shutdown(void) +{ + PREPARE_ERROR(errctx); + // clay holds no resource but the arena, and the arena is ours: there is + // nothing to hand back, so shutdown is forgetting. The next init lays a + // fresh context over the same bytes. + ui_context = NULL; + ui_clay_error_text[0] = '\0'; + ui_clay_error_count = 0; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_resize(int width, int height) +{ + Clay_Dimensions dimensions; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_NONZERO_RETURN(errctx, (width <= 0), AKERR_OUTOFBOUNDS, "Layout width %d is not positive", width); + FAIL_NONZERO_RETURN(errctx, (height <= 0), AKERR_OUTOFBOUNDS, "Layout height %d is not positive", height); + dimensions.width = (float)width; + dimensions.height = (float)height; + Clay_SetLayoutDimensions(dimensions); + SUCCEED_RETURN(errctx); +} + +void akgl_ui_arena_limit(size_t limit) +{ + if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) { + ui_arena_usable = AKGL_UI_ARENA_BYTES; + } else { + ui_arena_usable = limit; + } +} diff --git a/src/ui_clay.c b/src/ui_clay.c new file mode 100644 index 0000000..1adf5cf --- /dev/null +++ b/src/ui_clay.c @@ -0,0 +1,21 @@ +/** + * @file ui_clay.c + * @brief The one translation unit that compiles clay's implementation. + * + * clay is a single-header library: every other file that includes + * gets declarations only, and exactly one file in the whole program defines + * CLAY_IMPLEMENTATION before the include to get the definitions. This is that + * file, and it must stay the only one -- a second definition anywhere, + * including in a consuming game, is two copies of clay's globals and the + * loader picking between them silently. The warning in akgl/ui.h says so to + * consumers; this comment says so to us. + * + * Nothing else belongs here. libakgl's own UI code is src/ui.c, which + * includes like any other consumer. CMakeLists.txt compiles this + * file with -w on the same terms as deps/semver and deps/libccd: 99% of what + * this TU contains is vendored code, and a future clay bump must not be able + * to fail the build on a warning we do not own. + */ + +#define CLAY_IMPLEMENTATION +#include diff --git a/tests/docs_preludes/docs_prelude.h b/tests/docs_preludes/docs_prelude.h index 477461c..ee68daa 100644 --- a/tests/docs_preludes/docs_prelude.h +++ b/tests/docs_preludes/docs_prelude.h @@ -50,6 +50,7 @@ #include #include #include +#include #include /** diff --git a/tests/error.c b/tests/error.c index 046d863..b739b19 100644 --- a/tests/error.c +++ b/tests/error.c @@ -33,7 +33,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void) { AKGL_ERR_HEAP, "Heap Error" }, { AKGL_ERR_BEHAVIOR, "Behavior Error" }, { AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" }, - { AKGL_ERR_COLLISION, "Collision Error" } + { AKGL_ERR_COLLISION, "Collision Error" }, + { AKGL_ERR_UI, "UI Error" } }; bool named = true; int i = 0; diff --git a/tests/ui.c b/tests/ui.c new file mode 100644 index 0000000..60ac515 --- /dev/null +++ b/tests/ui.c @@ -0,0 +1,112 @@ +/** + * @file ui.c + * @brief Unit tests for the UI subsystem: lifecycle, arena sizing and validation. + * + * Everything here runs without a window, a renderer, or SDL_Init at all -- + * akgl_ui_init takes its layout dimensions as parameters precisely so that a + * test can bring the subsystem up headless. Only akgl_error_init() is needed + * first, so that AKGL_ERR_UI carries its name into any trace these tests + * produce. + */ + +#include + +#include +#include + +#include "testutil.h" + +/** + * @brief Init must refuse bad dimensions and an uninitialized resize. + * + * Runs before anything initializes the subsystem, because the "not + * initialized" refusal is half of what it asserts. + */ +akerr_ErrorContext *test_ui_validation(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(0, 240), + "akgl_ui_init accepted a zero width"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(320, -1), + "akgl_ui_init accepted a negative height"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_resize(320, 240), + "akgl_ui_resize worked on an uninitialized subsystem"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Init, double-init refusal, idempotent shutdown, re-init. + * + * The double init has to be refused rather than absorbed: a second + * Clay_Initialize over a live context would silently discard every element id + * and scroll position the first one held, which is the kind of reset a game + * only notices as "the menu forgot where it was". + */ +akerr_ErrorContext *test_ui_lifecycle(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "first akgl_ui_init failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240), + "a second akgl_ui_init was not refused"); + TEST_EXPECT_OK(e, akgl_ui_resize(640, 480), "resize on a live subsystem failed"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "akgl_ui_shutdown failed"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "a second akgl_ui_shutdown was not a no-op"); + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "re-init after shutdown failed"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after re-init failed"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief A too-small arena must refuse at init, loudly, and recover. + * + * The narrowed limit stands in for a consumer who raised AKGL_UI_MAX_ELEMENTS + * without raising AKGL_UI_ARENA_BYTES: the refusal at startup, with both + * numbers in the message, is the whole safety story -- past init, clay trusts + * the arena completely. Same shape as the collision arena's exhaustion test, + * for the same reason: rebuilding with a smaller ceiling is not something CI + * can do. + */ +akerr_ErrorContext *test_ui_arena_exhaustion(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + akgl_ui_arena_limit(64); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240), + "akgl_ui_init accepted an arena clay cannot fit in"); + akgl_ui_arena_limit(0); + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), + "akgl_ui_init failed after the arena limit was restored"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after the exhaustion test failed"); + } CLEANUP { + akgl_ui_arena_limit(0); + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + ATTEMPT { + CATCH(errctx, akgl_error_init()); + CATCH(errctx, test_ui_validation()); + CATCH(errctx, test_ui_lifecycle()); + CATCH(errctx, test_ui_arena_exhaustion()); + } CLEANUP { + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +}