From b7ff54b09f1d4969b64ae380c8a283e9635228a4 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 10:53:11 -0400 Subject: [PATCH 01/15] Vendor clay and bring the UI subsystem up: arena, lifecycle, status band clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the semver/libccd precedent: clay's CMakeLists declares no library target, builds its examples by default, and requires CMake 3.27 against this project's 3.10. src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on the vendored-code terms but with clay's symbols deliberately exported -- consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them never to link a second clay. The subsystem is runtime-optional the way collision is: always compiled in, inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_* ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc), and refuses with both numbers in the message when it does not fit. Measured: 812544 bytes at the default 1024 elements / 4096 measured words, against a 1 MiB arena. clay's void-callback layout errors are logged as they happen and the first is stashed for the error protocol to raise later. AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in akgl_error_init. New `ui` test suite covers validation, the init/shutdown lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h, its licence beside libccd's, and the headers suite proves akgl/ui.h self-contained -- clay.h compiles clean under -Wall. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- .gitmodules | 4 + CMakeLists.txt | 40 ++++++- deps/clay | 1 + include/akgl/error.h | 3 +- include/akgl/ui.h | 161 +++++++++++++++++++++++++++++ src/error.c | 1 + src/ui.c | 153 +++++++++++++++++++++++++++ src/ui_clay.c | 21 ++++ tests/docs_preludes/docs_prelude.h | 1 + tests/error.c | 3 +- tests/ui.c | 112 ++++++++++++++++++++ 11 files changed, 494 insertions(+), 6 deletions(-) create mode 160000 deps/clay create mode 100644 include/akgl/ui.h create mode 100644 src/ui.c create mode 100644 src/ui_clay.c create mode 100644 tests/ui.c 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); +} From 064e6569e8ee63e754dfbf6092be3ffbbae1dbac Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 10:57:23 -0400 Subject: [PATCH 02/15] Add rounded-rect fill, arc stroke and clip-rect drawing primitives The UI subsystem's render executor needs all three -- clay emits rectangles with corner radii, per-side borders, and scissor commands -- but they earn their place in draw.h on the same terms as everything else there: a rounded panel, a ring gauge and a clipped viewport are things a game wants with or without a layout engine. akgl_draw_filled_rounded_rect decomposes into three band fills plus four quarter-circle triangle fans through SDL_RenderGeometry, with the radius clamped to half the shorter side. akgl_draw_arc strokes between an outer and inner radius as one triangle strip, stepping in proportion to the swept angle under a fixed vertex cap -- no VLAs, unlike clay's own SDL3 reference renderer. akgl_draw_set_clip wraps SDL_SetRenderClipRect and is the one draw entry point where a NULL rectangle is legal, because that is how a clip is cleared. Pixel-readback tests cover the corner geometry (inside the arc filled, outside it untouched), the radius clamp, the zero-radius fall-through, ring and quarter-arc coverage at mid-stroke, sweep bounds, and clip set/clear round trips. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- include/akgl/draw.h | 101 ++++++++++++++++++++ src/draw.c | 226 ++++++++++++++++++++++++++++++++++++++++++++ tests/draw.c | 180 +++++++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+) diff --git a/include/akgl/draw.h b/include/akgl/draw.h index ced1e70..425461f 100644 --- a/include/akgl/draw.h +++ b/include/akgl/draw.h @@ -36,6 +36,25 @@ */ #define AKGL_DRAW_MAX_FLOOD_SPANS 4096 +/** + * @brief Triangles each corner of akgl_draw_filled_rounded_rect() is built from. + * + * A quarter circle approximated by this many segments is visually round at any + * radius a UI panel plausibly uses; the vertex arrays are fixed at this size, + * so it is a compile-time cost, not a per-call one. + */ +#define AKGL_DRAW_ROUNDED_RECT_SEGMENTS 16 + +/** + * @brief Most vertices akgl_draw_arc() will spend on one arc. + * + * An arc is a triangle strip between its inner and outer edge, two vertices + * per step, so half of these are positions along the curve. The step count + * scales with the swept angle and is clamped to fit this array -- a full + * circle gets all of them, a sliver gets a few -- rather than allocating. + */ +#define AKGL_DRAW_ARC_MAX_POINTS 64 + /** * @brief Paint an 8x8 grey checkerboard over a region, the way an image editor shows transparency. * @@ -242,4 +261,86 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_copy_region(akgl_RenderBackend *sel */ akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y); +/** + * @brief Fill a rectangle whose corners are rounded off with quarter circles. + * + * The body is three axis-aligned fills and the corners are triangle fans + * through `SDL_RenderGeometry` -- #AKGL_DRAW_ROUNDED_RECT_SEGMENTS triangles + * each -- so nothing here is anti-aliased, matching every other primitive in + * this header. + * + * @param self The backend to draw through. Required, along with its + * `sdl_renderer`. + * @param rect The rectangle, in render-target pixels. Required. A zero or + * negative width or height fills nothing and is not reported. + * @param radius Corner radius in pixels. Zero or negative falls through to + * akgl_draw_filled_rect() -- a square corner is not an error. + * Larger than half the shorter side is clamped to it, which at + * the limit turns a square into a circle rather than folding the + * corners over each other. + * @param color Colour to fill with, including alpha. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p rect is + * `NULL`. + * @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if a band + * or corner cannot be drawn. A failure partway leaves the shape + * partially drawn. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color); + +/** + * @brief Stroke a circular arc of a given thickness between two angles. + * + * Angles are in degrees, 0 pointing along +x (three o'clock) and increasing + * clockwise on screen -- the same convention as the rotation argument the + * render backend's `draw_texture` takes. The stroke runs from the arc's outer + * edge at @p radius inward by @p thickness, drawn as one triangle strip, not + * anti-aliased. + * + * @param self The backend to draw through. Required, along with its + * `sdl_renderer`. + * @param x Horizontal position of the arc's centre. + * @param y Vertical position of the arc's centre. + * @param radius Outer radius in pixels. Must be positive. + * @param start_deg Angle the arc starts at. + * @param end_deg Angle the arc ends at. At or below @p start_deg nothing is + * drawn and nothing is reported, matching the zero-area + * rectangle fills above. Neither angle is normalized, so an + * arc crossing three o'clock is 350 to 370, not 350 to 10; a + * sweep beyond 360 degrees is clamped to one full turn. + * @param thickness Stroke width in pixels, measured inward from @p radius. + * Must be positive; at or above @p radius the arc becomes a + * filled wedge to the centre. + * @param color Colour to stroke in, including alpha. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`. + * @throws AKERR_OUTOFBOUNDS If @p radius or @p thickness is not positive. The + * message reports the offending value. + * @throws AKGL_ERR_SDL If the strip cannot be drawn. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color); + +/** + * @brief Restrict every subsequent draw to a rectangle of the render target. + * + * Everything drawn through @p self after this -- primitives here, textures, + * text -- is clipped to @p rect until the restriction is cleared. This is the + * only entry point in this header where a `NULL` rectangle is legal rather + * than an error: it means "clear the restriction", matching what + * `SDL_SetRenderClipRect` does with it, and there is no other value that + * could. + * + * The clip is renderer state, not saved and restored around anything: a + * caller that sets it owns putting it back, the way a `CLEANUP` block puts + * back a draw colour. Leaving it set clips the next frame's world too. + * + * @param self The backend to clip. Required, along with its `sdl_renderer`. + * @param rect Rectangle to clip to, in render-target pixels -- integer, since + * a clip boundary is a pixel boundary. `NULL` clears the clip. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`. + * @throws AKGL_ERR_SDL If the clip cannot be set or cleared. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect); + #endif //_AKGL_DRAW_H_ diff --git a/src/draw.c b/src/draw.c index ae9c399..6d3eb58 100644 --- a/src/draw.c +++ b/src/draw.c @@ -619,3 +619,229 @@ akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface } FINISH(errctx, true); SUCCEED_RETURN(errctx); } + +/** + * @brief Fill one quarter-circle corner as a triangle fan. + * + * `SDL_RenderGeometry` colours from its vertices rather than the renderer's + * draw colour, so this neither pushes nor pops it. + * + * @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`; + * the caller has already checked both. + * @param cx Horizontal position of the corner's centre -- the point the + * fan radiates from, one radius inside the rectangle. + * @param cy Vertical position of the centre. + * @param radius Radius of the quarter circle. Assumed positive. + * @param start_deg Angle the quarter starts at; it sweeps 90 degrees clockwise + * from there. + * @param color Colour to fill with. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_SDL If the fan cannot be drawn. + */ +static akerr_ErrorContext *fill_corner_fan(akgl_RenderBackend *self, float32_t cx, float32_t cy, float32_t radius, float32_t start_deg, SDL_Color color) +{ + SDL_Vertex vertices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2]; + int indices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3]; + SDL_FColor fcolor; + float32_t angle = 0.0f; + int i = 0; + + PREPARE_ERROR(errctx); + fcolor.r = (float)color.r / 255.0f; + fcolor.g = (float)color.g / 255.0f; + fcolor.b = (float)color.b / 255.0f; + fcolor.a = (float)color.a / 255.0f; + + vertices[0].position.x = cx; + vertices[0].position.y = cy; + vertices[0].color = fcolor; + vertices[0].tex_coord.x = 0.0f; + vertices[0].tex_coord.y = 0.0f; + for ( i = 0; i <= AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) { + angle = (start_deg + ((90.0f / AKGL_DRAW_ROUNDED_RECT_SEGMENTS) * (float)i)) * (SDL_PI_F / 180.0f); + vertices[i + 1].position.x = cx + (radius * SDL_cosf(angle)); + vertices[i + 1].position.y = cy + (radius * SDL_sinf(angle)); + vertices[i + 1].color = fcolor; + vertices[i + 1].tex_coord.x = 0.0f; + vertices[i + 1].tex_coord.y = 0.0f; + } + for ( i = 0; i < AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) { + indices[(i * 3) + 0] = 0; + indices[(i * 3) + 1] = i + 1; + indices[(i * 3) + 2] = i + 2; + } + FAIL_ZERO_RETURN( + errctx, + SDL_RenderGeometry( + self->sdl_renderer, + NULL, + vertices, + AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2, + indices, + AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color) +{ + SDL_Color previous; + SDL_FRect band; + float32_t half = 0.0f; + bool pushed = false; + bool drawfailed = false; + + 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, rect, AKERR_NULLPOINTER, "NULL rectangle"); + if ( (rect->w <= 0.0f) || (rect->h <= 0.0f) ) { + SUCCEED_RETURN(errctx); + } + if ( radius <= 0.0f ) { + PASS(errctx, akgl_draw_filled_rect(self, rect, color)); + SUCCEED_RETURN(errctx); + } + half = (((rect->w < rect->h) ? rect->w : rect->h) / 2.0f); + if ( radius > half ) { + radius = half; + } + + ATTEMPT { + CATCH(errctx, push_draw_color(self, color, &previous)); + pushed = true; + + // The body is three bands: one the full width between the corner rows, + // and one between the corners along each of the top and bottom edges. + // A radius of exactly half the shorter side leaves one or more of them + // with no area, which SDL fills as nothing -- at the limit the whole + // shape is the four fans. + band.x = rect->x; + band.y = rect->y + radius; + band.w = rect->w; + band.h = rect->h - (radius * 2.0f); + if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) { + drawfailed = true; + } + band.x = rect->x + radius; + band.y = rect->y; + band.w = rect->w - (radius * 2.0f); + band.h = radius; + if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) { + drawfailed = true; + } + band.y = rect->y + rect->h - radius; + if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) { + drawfailed = true; + } + FAIL_NONZERO_BREAK(errctx, drawfailed, AKGL_ERR_SDL, "%s", SDL_GetError()); + + CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + radius, radius, 180.0f, color)); + CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + radius, radius, 270.0f, color)); + CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + rect->h - radius, radius, 0.0f, color)); + CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + rect->h - radius, radius, 90.0f, color)); + } CLEANUP { + if ( pushed == true ) { + IGNORE(pop_draw_color(self, &previous)); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color) +{ + SDL_Vertex vertices[AKGL_DRAW_ARC_MAX_POINTS]; + int indices[(AKGL_DRAW_ARC_MAX_POINTS - 2) * 3]; + SDL_FColor fcolor; + float32_t span = 0.0f; + float32_t inner = 0.0f; + float32_t angle = 0.0f; + int segments = 0; + int base = 0; + int 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_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_OUTOFBOUNDS, "Arc radius %f is not positive", (double)radius); + FAIL_NONZERO_RETURN(errctx, (thickness <= 0.0f), AKERR_OUTOFBOUNDS, "Arc thickness %f is not positive", (double)thickness); + span = end_deg - start_deg; + if ( span <= 0.0f ) { + SUCCEED_RETURN(errctx); + } + if ( span > 360.0f ) { + span = 360.0f; + } + inner = radius - thickness; + if ( inner < 0.0f ) { + inner = 0.0f; + } + + // Steps in proportion to the angle swept, so a sliver of arc does not + // spend the whole vertex budget and a full circle uses all of it: the + // quarter-circle density of AKGL_DRAW_ROUNDED_RECT_SEGMENTS, capped by + // what AKGL_DRAW_ARC_MAX_POINTS vertices can carry at two per step. + segments = (int)((span / 90.0f) * (float)AKGL_DRAW_ROUNDED_RECT_SEGMENTS) + 1; + if ( segments > ((AKGL_DRAW_ARC_MAX_POINTS / 2) - 1) ) { + segments = (AKGL_DRAW_ARC_MAX_POINTS / 2) - 1; + } + + fcolor.r = (float)color.r / 255.0f; + fcolor.g = (float)color.g / 255.0f; + fcolor.b = (float)color.b / 255.0f; + fcolor.a = (float)color.a / 255.0f; + for ( i = 0; i <= segments; i++ ) { + angle = (start_deg + ((span / (float)segments) * (float)i)) * (SDL_PI_F / 180.0f); + vertices[i * 2].position.x = x + (radius * SDL_cosf(angle)); + vertices[i * 2].position.y = y + (radius * SDL_sinf(angle)); + vertices[i * 2].color = fcolor; + vertices[i * 2].tex_coord.x = 0.0f; + vertices[i * 2].tex_coord.y = 0.0f; + vertices[(i * 2) + 1].position.x = x + (inner * SDL_cosf(angle)); + vertices[(i * 2) + 1].position.y = y + (inner * SDL_sinf(angle)); + vertices[(i * 2) + 1].color = fcolor; + vertices[(i * 2) + 1].tex_coord.x = 0.0f; + vertices[(i * 2) + 1].tex_coord.y = 0.0f; + } + for ( i = 0; i < segments; i++ ) { + base = i * 2; + indices[(i * 6) + 0] = base; + indices[(i * 6) + 1] = base + 2; + indices[(i * 6) + 2] = base + 1; + indices[(i * 6) + 3] = base + 1; + indices[(i * 6) + 4] = base + 2; + indices[(i * 6) + 5] = base + 3; + } + FAIL_ZERO_RETURN( + errctx, + SDL_RenderGeometry( + self->sdl_renderer, + NULL, + vertices, + (segments + 1) * 2, + indices, + segments * 6), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect) +{ + 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"); + // NULL rect deliberately passes straight through: it is how the clip is + // cleared, and the header says so. + FAIL_ZERO_RETURN( + errctx, + SDL_SetRenderClipRect(self->sdl_renderer, rect), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} diff --git a/tests/draw.c b/tests/draw.c index 8088b4e..401a99c 100644 --- a/tests/draw.c +++ b/tests/draw.c @@ -628,6 +628,183 @@ akerr_ErrorContext *test_draw_background(void) SUCCEED_RETURN(errctx); } +akerr_ErrorContext *test_draw_filled_rounded_rect(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + SDL_FRect rect = { 8.0f, 8.0f, 40.0f, 24.0f }; + SDL_FRect square = { 40.0f, 40.0f, 16.0f, 16.0f }; + + ATTEMPT { + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 8.0f, testred), + "filling a rounded rectangle"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 28, 20, testred), + "the middle of the rounded rectangle is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 28, 8, testred), + "the top edge between the corners is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 8, 20, testred), + "the left edge between the corners is unfilled"); + // 12,12 is inside the corner's quarter circle (5.7px from its centre + // at 16,16); 8,8 and 9,9 are outside it (11.3px and 9.9px). The fan's + // triangles are chords of the circle, so they cover nothing beyond it. + TEST_ASSERT(errctx, pixel_is(shot, 12, 12, testred), + "the inside of the rounded corner is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testblack), + "the square corner pixel was filled despite the rounding"); + TEST_ASSERT(errctx, pixel_is(shot, 9, 9, testblack), + "a pixel outside the corner arc was filled"); + SDL_DestroySurface(shot); + shot = NULL; + + // A radius beyond half the shorter side clamps to it, which on a + // square is a circle: centre filled, corners untouched. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &square, 100.0f, testgreen), + "filling a rounded rectangle with an oversized radius"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 48, 48, testgreen), + "the centre of the clamped-radius square is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 41, 41, testblack), + "clamping the radius still filled the square's corner"); + + // Radius zero is the plain fill, corners and all. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 0.0f, testred), + "a zero radius falling through to the plain fill"); + SDL_DestroySurface(shot); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred), + "a zero radius did not fill the square corner"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_filled_rounded_rect(NULL, &rect, 4.0f, testred), + "rounded fill through a NULL backend"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_filled_rounded_rect(akgl_renderer, NULL, 4.0f, testred), + "rounded fill of a NULL rectangle"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_draw_arc(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + + ATTEMPT { + // A full ring: outer radius 20, stroke 4, so mid-stroke is radius 18. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 360.0f, 4.0f, testred), + "stroking a full circle"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 50, 32, testred), + "the ring is missing at three o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 50, testred), + "the ring is missing at six o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 14, 32, testred), + "the ring is missing at nine o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testred), + "the ring is missing at twelve o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack), + "the centre of the ring was filled"); + TEST_ASSERT(errctx, pixel_is(shot, 42, 32, testblack), + "the stroke bled inside its inner radius"); + SDL_DestroySurface(shot); + shot = NULL; + + // A quarter arc sweeps clockwise from three o'clock to six o'clock; + // 44,44 is on its mid-stroke at 45 degrees, twelve o'clock is not in + // the sweep at all. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testgreen), + "stroking a quarter arc"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 44, 44, testgreen), + "the quarter arc is missing at its midpoint"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testblack), + "the quarter arc drew outside its sweep"); + + // An empty sweep draws nothing and reports nothing. + TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 90.0f, 90.0f, 4.0f, testred), + "an arc with no sweep"); + + TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, + akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 0.0f, 0.0f, 90.0f, 4.0f, testred), + "an arc with no radius"); + TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, + akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, -1.0f, testred), + "an arc with a negative thickness"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_arc(NULL, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testred), + "an arc through a NULL backend"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_draw_set_clip(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + SDL_Rect clip = { 0, 0, 16, 16 }; + SDL_FRect everything = { 0.0f, 0.0f, (float)TEST_TARGET_SIZE, (float)TEST_TARGET_SIZE }; + + ATTEMPT { + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, &clip), + "setting the clip rectangle"); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testred), + "filling the whole target under a clip"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred), + "the fill did not reach inside the clip rectangle"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack), + "the fill escaped the clip rectangle"); + SDL_DestroySurface(shot); + shot = NULL; + + TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, NULL), + "clearing the clip rectangle"); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testgreen), + "filling the whole target after clearing the clip"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testgreen), + "clearing the clip did not restore the full target"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_set_clip(NULL, &clip), + "clipping through a NULL backend"); + } CLEANUP { + // The clip is renderer state; leaving it set would clip every test + // after this one. + IGNORE(akgl_draw_set_clip(akgl_renderer, NULL)); + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + int main(void) { PREPARE_ERROR(errctx); @@ -668,6 +845,9 @@ int main(void) CATCH(errctx, test_draw_preserves_render_draw_color()); CATCH(errctx, test_draw_backend_without_a_renderer()); CATCH(errctx, test_draw_background()); + CATCH(errctx, test_draw_filled_rounded_rect()); + CATCH(errctx, test_draw_arc()); + CATCH(errctx, test_draw_set_clip()); } CLEANUP { SDL_Quit(); } PROCESS(errctx) { From 3b3bfcbe10a334b5376adea5a29ac38300b2a7c3 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:06:27 -0400 Subject: [PATCH 03/15] 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) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- include/akgl/ui.h | 163 +++++++++++++++ src/ui.c | 514 +++++++++++++++++++++++++++++++++++++++++++--- tests/ui.c | 475 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1122 insertions(+), 30 deletions(-) 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); } From 566b870a5c93ba4f1018444271376e0bc680e25b Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:10:35 -0400 Subject: [PATCH 04/15] Feed the mouse to the UI and tell the host which events it consumed akgl_ui_handle_event is the mouse half of the input story -- keyboard and gamepad stay with the control maps, because keyboard focus is something an application declares rather than something a pointer position implies. It handles motion, left-button presses and releases, the wheel and window resizes; everything else, and everything while the subsystem is inert, reports consumed=false and succeeds, the same pass-everything contract akgl_controller_handle_event already has. The documented call order is UI first, control maps second, skipping on consumed. A press, release or wheel is consumed when the pointer is over any UI element. The hit test asks clay against the layout the previous frame retained -- this frame's does not exist while events are polled, and one frame of staleness is the standard model's accepted cost -- with clay's internal full-screen Clay__RootContainer excluded, because being inside the window is not being over the interface (unexcluded, every click anywhere was consumed; the test that pins this caught it). Pointer state is fed to clay per event, as clay documents; the press edge is latched once per frame_begin for the widgets to come, rather than trusting Clay_PointerData's per-SetPointerState edge, which advances per mouse event. frame_begin also drives Clay_UpdateScrollContainers from the accumulated wheel (32 pixels per notch) and a wall-clock dt. Tests pin the whole contract: pass-through before init, no consumption before any layout exists, press/release inside vs beside a panel, right button ignored, motion never consumed, wheel consumed only over the UI, resize reaching the layout engine, and key events left alone. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- include/akgl/ui.h | 46 ++++++++++++++ src/ui.c | 159 ++++++++++++++++++++++++++++++++++++++++++++++ tests/ui.c | 149 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+) diff --git a/include/akgl/ui.h b/include/akgl/ui.h index b03a0b8..26a8217 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -38,6 +38,8 @@ #include #include +#include + #include #include @@ -205,6 +207,50 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_resize(int width, int height); */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_font_register(char *name, uint16_t *fontid); +/** + * @brief Feed one SDL event to the UI, and learn whether the UI claimed it. + * + * The mouse half of the input story -- keyboard and gamepad stay with the + * control maps and the widget helpers, because keyboard focus is something + * the application declares, not something a pointer position implies. This + * handles mouse motion, left-button presses and releases, the wheel, and + * window resizes; every other event, and every event while the subsystem is + * not initialized, reports @p consumed false and succeeds, so a host passes + * every event through unconditionally -- the same contract + * akgl_controller_handle_event() has. + * + * Call it *before* akgl_controller_handle_event() in the event loop, and skip + * the rest of the chain when it consumed: + * + * while ( SDL_PollEvent(&event) ) { + * PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed)); + * if ( consumed ) { continue; } + * PASS(errctx, akgl_controller_handle_event(&app, &event)); + * } + * + * A press or release is consumed when the pointer is over any UI element; + * motion and resizes never are (the game may care where the mouse is, and + * certainly cares about its window). The hit test runs against the layout + * the *previous* frame declared, because this frame's does not exist while + * events are being polled -- clay retains the last tree for exactly this + * purpose, and one frame of staleness is the accepted cost of the standard + * model. Before any frame has been laid out, nothing is over anything and + * nothing is consumed. + * + * @param appstate The application state pointer the event loop owns. + * Required, though this implementation does not read it -- + * the parameter exists so the signature matches + * akgl_controller_handle_event() and can grow the same way. + * @param event The event SDL handed the loop. Required. + * @param consumed Receives whether the UI claimed the event. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p appstate, @p event, or @p consumed is + * `NULL`. + * @throws AKERR_OUTOFBOUNDS If a resize event carries a non-positive size, + * from akgl_ui_resize(). + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed); + /** * @brief Open the UI frame: clear the error stash and begin the clay layout. * diff --git a/src/ui.c b/src/ui.c index f7df8df..8c5b833 100644 --- a/src/ui.c +++ b/src/ui.c @@ -15,6 +15,7 @@ #include #include +#include #include #include #include @@ -53,6 +54,39 @@ 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 Pixels one wheel notch scrolls a clay scroll container. + * + * SDL reports the wheel in notches; clay wants pixels. 32 is three lines of + * 16pt text per notch, which is what desktop toolkits converge on. + */ +#define UI_WHEEL_NOTCH_PIXELS 32.0f + +/** @brief Where the pointer last was, in layout pixels. Fed to clay per event. */ +static float ui_pointer_x; +/** @brief Vertical half of the pointer position. */ +static float ui_pointer_y; +/** @brief Whether the left button is currently held. */ +static bool ui_pointer_down; +/** + * @brief A left press arrived since the last frame_begin latched. + * + * Widgets read the *latched* copy (#ui_pointer_pressed) rather than clay's + * `Clay_PointerData.state`, because clay advances its press-edge per + * Clay_SetPointerState call and several of those can land between two + * layouts -- one per motion event -- which turns "pressed this frame" into + * "pressed since the last mouse event", an edge no widget can trust. + */ +static bool ui_pointer_pending_pressed; +/** @brief The frame-stable press edge: one left press happened before this frame. */ +static bool ui_pointer_pressed; +/** @brief Wheel notches accumulated since the last frame_begin, horizontal. */ +static float ui_wheel_x; +/** @brief Wheel notches accumulated since the last frame_begin, vertical. */ +static float ui_wheel_y; +/** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */ +static uint64_t ui_last_frame_ns; + /** * @brief First error clay reported since the stash was last cleared. * @@ -199,6 +233,14 @@ akerr_ErrorContext *akgl_ui_shutdown(void) ui_font_count = 0; ui_clay_error_text[0] = '\0'; ui_clay_error_count = 0; + ui_pointer_x = 0.0f; + ui_pointer_y = 0.0f; + ui_pointer_down = false; + ui_pointer_pending_pressed = false; + ui_pointer_pressed = false; + ui_wheel_x = 0.0f; + ui_wheel_y = 0.0f; + ui_last_frame_ns = 0; SUCCEED_RETURN(errctx); } @@ -561,8 +603,108 @@ akerr_ErrorContext *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_Rend SUCCEED_RETURN(errctx); } +/** + * @brief Report whether the pointer is over any element of the retained layout. + * + * The one question the consumed contract turns on. Asked of clay rather than + * answered from our own geometry, so floating elements, z-order and culling + * all mean whatever clay says they mean -- with one exception: clay wraps + * every layout in an internal `Clay__RootContainer` sized to the whole + * screen, so "over anything" naively answers yes for every pixel of the + * window. The root is excluded here; being inside the window is not being + * over the interface. + */ +static bool ui_pointer_over_anything(void) +{ + static uint32_t rootid = 0; + Clay_ElementIdArray over = Clay_GetPointerOverIds(); + int32_t i = 0; + + if ( rootid == 0 ) { + rootid = Clay_GetElementId(CLAY_STRING("Clay__RootContainer")).id; + } + for ( i = 0; i < over.length; i++ ) { + if ( over.internalArray[i].id != rootid ) { + return true; + } + } + return false; +} + +akerr_ErrorContext *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed) +{ + Clay_Vector2 position; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination"); + *consumed = false; + // Inert until initialized, like the rest of the subsystem: the host is + // told "not mine" rather than "not ready", so its event loop needs no + // knowledge of whether the UI is up. + if ( ui_context == NULL ) { + SUCCEED_RETURN(errctx); + } + + switch ( event->type ) { + case SDL_EVENT_MOUSE_MOTION: + ui_pointer_x = event->motion.x; + ui_pointer_y = event->motion.y; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + // Fed per event, not per frame, which is the usage clay documents: + // hover tracks the pointer through fast motion instead of sampling + // wherever it happened to be when the frame began. + Clay_SetPointerState(position, ui_pointer_down); + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if ( event->button.button != SDL_BUTTON_LEFT ) { + break; + } + ui_pointer_x = event->button.x; + ui_pointer_y = event->button.y; + ui_pointer_down = true; + ui_pointer_pending_pressed = true; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + Clay_SetPointerState(position, true); + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_MOUSE_BUTTON_UP: + if ( event->button.button != SDL_BUTTON_LEFT ) { + break; + } + ui_pointer_x = event->button.x; + ui_pointer_y = event->button.y; + ui_pointer_down = false; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + Clay_SetPointerState(position, false); + // The release is consumed on the same terms as the press: a click + // that began on a panel must not leak its other half to the game. + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_MOUSE_WHEEL: + ui_wheel_x += event->wheel.x; + ui_wheel_y += event->wheel.y; + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_WINDOW_RESIZED: + PASS(errctx, akgl_ui_resize(event->window.data1, event->window.data2)); + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + akerr_ErrorContext *akgl_ui_frame_begin(void) { + Clay_Vector2 scroll; + uint64_t now = 0; + float dt = 0.0f; + PREPARE_ERROR(errctx); FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); FAIL_NONZERO_RETURN( @@ -572,6 +714,23 @@ akerr_ErrorContext *akgl_ui_frame_begin(void) "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; + + // Latch the press edge for the widgets this frame declares, then let the + // event loop start accumulating the next one. + ui_pointer_pressed = ui_pointer_pending_pressed; + ui_pointer_pending_pressed = false; + + now = SDL_GetTicksNS(); + if ( ui_last_frame_ns != 0 ) { + dt = (float)(now - ui_last_frame_ns) / (float)AKGL_TIME_ONESEC_NS; + } + ui_last_frame_ns = now; + scroll.x = ui_wheel_x * UI_WHEEL_NOTCH_PIXELS; + scroll.y = ui_wheel_y * UI_WHEEL_NOTCH_PIXELS; + ui_wheel_x = 0.0f; + ui_wheel_y = 0.0f; + Clay_UpdateScrollContainers(false, scroll, dt); + Clay_BeginLayout(); ui_in_frame = true; SUCCEED_RETURN(errctx); diff --git a/tests/ui.c b/tests/ui.c index b547b19..f0a3c8f 100644 --- a/tests/ui.c +++ b/tests/ui.c @@ -482,6 +482,154 @@ akerr_ErrorContext *test_ui_layout_text(void) SUCCEED_RETURN(e); } +/** @brief Build a left-button mouse event at a position, down or up. */ +static void make_button_event(SDL_Event *event, uint32_t type, float x, float y) +{ + SDL_memset(event, 0x00, sizeof(SDL_Event)); + event->type = type; + event->button.button = SDL_BUTTON_LEFT; + event->button.x = x; + event->button.y = y; +} + +/** + * @brief The consumed contract: clicks on the UI are the UI's, everything else passes. + * + * The hit test runs against the layout the previous frame retained, so the + * test lays one panel out, then throws events at it: a click inside is + * consumed, a click outside is not, motion never is, and -- the case most + * likely to surprise -- a click before any layout exists is not consumed, + * because there is nothing to be over. + */ +akerr_ErrorContext *test_ui_handle_event(void) +{ + PREPARE_ERROR(e); + SDL_Event event; + bool consumed = true; + int token = 1; + + ATTEMPT { + // Uninitialized: everything passes through untouched. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "an event before init was refused"); + TEST_ASSERT(e, consumed == false, "an event before init was consumed"); + + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the event test failed"); + + // Initialized but nothing laid out yet: a click lands on no tree. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a click before any layout was refused"); + TEST_ASSERT(e, consumed == false, "a click was consumed before any layout existed"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the release before any layout was refused"); + + // Lay out one 20x20 panel at (8,8) for the hit tests that follow. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the hit-test frame failed"); + CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) { + CLAY({ + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(20), + .height = CLAY_SIZING_FIXED(20) + } + }, + .backgroundColor = { 255, 0, 0, 255 } + }) {} + } + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the hit-test frame failed"); + + // A press on the panel is the UI's; its release is too. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the press on the panel was refused"); + TEST_ASSERT(e, consumed == true, "a press on a UI panel was not consumed"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the release on the panel was refused"); + TEST_ASSERT(e, consumed == true, "a release on a UI panel was not consumed"); + + // A press beside the panel belongs to the game. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 50.0f, 50.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the press beside the panel was refused"); + TEST_ASSERT(e, consumed == false, "a press beside the panel was consumed"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 50.0f, 50.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the release beside the panel was refused"); + + // A right-button press is not the UI's business at all. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + event.button.button = SDL_BUTTON_RIGHT; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a right-button press was refused"); + TEST_ASSERT(e, consumed == false, "a right-button press was consumed"); + + // Motion is never consumed, wherever it lands. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = 10.0f; + event.motion.y = 10.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "motion over the panel was refused"); + TEST_ASSERT(e, consumed == false, "motion over a panel was consumed"); + + // The wheel goes to the UI only while the pointer is over it. The + // motion event above left the pointer on the panel. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_WHEEL; + event.wheel.y = 1.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the wheel over the panel was refused"); + TEST_ASSERT(e, consumed == true, "the wheel over a panel was not consumed"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = 50.0f; + event.motion.y = 50.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "motion off the panel was refused"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_WHEEL; + event.wheel.y = 1.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the wheel beside the panel was refused"); + TEST_ASSERT(e, consumed == false, "the wheel beside the panel was consumed"); + + // A resize reaches the layout engine and still passes through. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_WINDOW_RESIZED; + event.window.data1 = TEST_TARGET_SIZE; + event.window.data2 = TEST_TARGET_SIZE; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a resize event was refused"); + TEST_ASSERT(e, consumed == false, "a resize event was consumed"); + + // A key event is not the UI's; keyboard focus is the application's. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_RETURN; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a key event was refused"); + TEST_ASSERT(e, consumed == false, "a key event was consumed"); + + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(NULL, &event, &consumed), + "a NULL appstate was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, NULL, &consumed), + "a NULL event was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, &event, NULL), + "a NULL consumed destination was accepted"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + /** * @brief A layout error inside clay must surface from frame_end, not vanish. * @@ -565,6 +713,7 @@ int main(void) CATCH(errctx, test_ui_frame_bracket()); CATCH(errctx, test_ui_layout_pixels()); CATCH(errctx, test_ui_layout_text()); + CATCH(errctx, test_ui_handle_event()); CATCH(errctx, test_ui_layout_error_surfaces()); } CLEANUP { IGNORE(akgl_text_unloadallfonts()); From 0679d40ee407d2bfad18f98966206dadd7fa36d3 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:17:32 -0400 Subject: [PATCH 05/15] Add the widget helpers: dialog, HUD label, and menu The other half of the hybrid decision: an application that wants a dialog box, a score counter or a main menu gets each in one call, without touching a CLAY() macro -- and an application that outgrows them still has clay's whole DSL, because the widgets are nothing but functions that declare the same elements between the same frame bracket. akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL style *is* the textbox palette -- near-black fill, parchment edge and ink, 8px padding -- deliberately, so the widget and the 125-line hand-rolled panel it stands beside in the comparison chapter produce the same picture. Showing and hiding is the frame's business: call it while somebody is talking, don't while nobody is; a declarative frame is the visible flag. akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by the style's padding. akgl_ui_menu declares a centred vertical menu from a caller-owned struct (no heap layer: it owns no texture, no font, no registry entry), with the selected row drawn inverted, hover moving the selection and the frame-latched press edge activating it. akgl_ui_menu_handle_event drives the same struct from Up/Down/Return and D-pad/South with wrapping, slotting into the event chain after akgl_ui_handle_event; routing events to a menu is the application's focus model, stated rather than invented. Tests cover the dialog's geometry and palette by pixel, label anchoring by quadrant, menu validation, the inverted highlight, keyboard and gamepad wrap/activate/pass-through, and the full two-frame mouse protocol -- click aimed at the second row's real box via Clay_GetElementData, seen by the next frame's declaration as selection plus activation. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- include/akgl/ui.h | 172 ++++++++++++++++++++++++ src/ui.c | 323 ++++++++++++++++++++++++++++++++++++++++++++++ tests/ui.c | 292 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 787 insertions(+) diff --git a/include/akgl/ui.h b/include/akgl/ui.h index 26a8217..168042d 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -44,6 +44,7 @@ #include #include +#include /** * @brief Most elements one layout may declare. @@ -117,6 +118,82 @@ #define AKGL_UI_MAX_TEXT_BYTES 1024 #endif +/** + * @brief Most entries one akgl_UiMenu may carry. + * + * A menu deeper than this wants scrolling and sub-screens, which is `CLAY()` + * territory rather than a widget's. + */ +#ifndef AKGL_UI_MENU_MAX_ITEMS +#define AKGL_UI_MENU_MAX_ITEMS 16 +#endif + +/** + * @brief Height of the akgl_ui_dialog() panel, in pixels. + * + * Two lines of 12pt monospace plus padding -- the JRPG textbox's height, kept + * so the widget's output is comparable with the hand-rolled panel it + * replaces. + */ +#ifndef AKGL_UI_DIALOG_HEIGHT +#define AKGL_UI_DIALOG_HEIGHT 56 +#endif + +/** + * @brief One look, shared by every widget: colours, spacing, corners, font. + * + * Passing `NULL` wherever a style is taken means the library default, which + * is deliberately the JRPG textbox's palette -- near-black fill (24,20,37,235) + * with parchment edge and ink (240,236,214,255), 8 pixels of padding, square + * corners, fontId 0. A caller wanting one change copies those values and + * changes one; there is no per-field "zero means default" magic, because a + * transparent fill and a zero radius are things a style legitimately says. + */ +typedef struct { + SDL_Color fill; /**< Panel background. */ + SDL_Color edge; /**< Border colour. Drawn one pixel wide where a widget has a border. */ + SDL_Color ink; /**< Text colour. */ + float32_t padding; /**< Pixels between a panel's edge and its content, and between a widget and the screen edge it anchors to. */ + float32_t corner_radius; /**< Corner rounding in pixels. 0 is square. */ + uint16_t fontid; /**< The clay fontId text renders with, from akgl_ui_font_register(). */ +} akgl_UiStyle; + +/** + * @brief Where on the screen akgl_ui_label() pins itself. + */ +typedef enum { + AKGL_UI_ANCHOR_TOP_LEFT, /**< Inset by the style's padding from the top-left corner. */ + AKGL_UI_ANCHOR_TOP_RIGHT, /**< Inset from the top-right corner. */ + AKGL_UI_ANCHOR_BOTTOM_LEFT, /**< Inset from the bottom-left corner. */ + AKGL_UI_ANCHOR_BOTTOM_RIGHT, /**< Inset from the bottom-right corner. */ + AKGL_UI_ANCHOR_CENTER /**< Dead centre, no inset. */ +} akgl_UiAnchor; + +/** + * @brief One vertical menu: its entries, its selection, and whether it fired. + * + * Caller-owned, the way the JRPG's textbox state is a static in the game -- + * there is no heap layer behind this because it owns no texture, no font and + * no registry entry; it is a struct of borrowed pointers and two integers. + * Declare it static, fill in `id`, `items` and `count`, and leave the rest + * zeroed. + * + * `selected` is yours to read and the menu's to move: keyboard and gamepad + * move it through akgl_ui_menu_handle_event(), and the mouse moves it by + * hovering while akgl_ui_menu() declares the rows. `activated` latches true + * on Return, gamepad South, or a click on a row; the caller acts on it and + * sets it back to false -- the menu never clears it, so an unread activation + * is not lost between frames. + */ +typedef struct { + char *id; /**< Element id prefix, unique per menu. Required. Borrowed. */ + char *items[AKGL_UI_MENU_MAX_ITEMS]; /**< The entries, top to bottom. Borrowed; they must outlive the frame. */ + int32_t count; /**< How many of @c items are set. 1 to #AKGL_UI_MENU_MAX_ITEMS. */ + int32_t selected; /**< Index of the highlighted entry. In and out. */ + bool activated; /**< Out: the selected entry was confirmed. Caller clears. */ + akgl_UiStyle *style; /**< Look to draw with, or `NULL` for the library default. */ +} akgl_UiMenu; + /** * @brief Bring the UI subsystem up. * @@ -298,6 +375,101 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_begin(void); */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_end(akgl_RenderBackend *self); +/** + * @brief Declare a dialog panel: the JRPG textbox in one call. + * + * A panel across the bottom of the screen, inset by the style's padding, + * #AKGL_UI_DIALOG_HEIGHT pixels tall, with a one-pixel border and the text + * wrapped inside it. Legal only between akgl_ui_frame_begin() and + * akgl_ui_frame_end(). Showing and hiding is the frame's business: call this + * while somebody is talking and don't while nobody is -- there is no + * `visible` flag, because a declarative frame *is* the flag. + * + * @param id Element id for the panel, unique within the frame. Required. + * Borrowed until frame_end. + * @param text What the dialog says. Required, and borrowed until frame_end + * -- clay keeps the pointer, not a copy. May be empty, which + * draws an empty panel. + * @param style Look to draw with, or `NULL` for the textbox palette. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style); + +/** + * @brief Declare a HUD label: one line of text on a padded panel, pinned to a corner. + * + * Fit-sized around its text, anchored per @p anchor and inset by the style's + * padding. The score counter and the lives counter are two calls to this with + * two ids. Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). + * + * The text is borrowed until frame_end, which matters for the obvious use: + * format the score into a buffer that outlives the frame bracket, not into a + * scope that closes before the draw. + * + * @param id Element id for the label, unique within the frame. Required. + * @param text What the label says. Required; empty draws an empty chip. + * @param anchor Which corner (or the centre) to pin to. + * @param style Look to draw with, or `NULL` for the library default. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + * @throws AKERR_OUTOFBOUNDS If @p anchor is not one of the enum's values. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style); + +/** + * @brief Declare a vertical menu from caller-owned state, centred on screen. + * + * One row per entry, the selected row drawn inverted (ink on fill swaps to + * fill on ink). Hovering a row with the mouse moves `selected` to it; a left + * click on a row sets `activated`. Keyboard and gamepad go through + * akgl_ui_menu_handle_event(), which the application calls for whichever + * menu currently has its attention -- that routing *is* the focus model. + * Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). + * + * @param menu The menu state. Required, with a non-`NULL` `id`, a `count` in + * range, every used entry non-`NULL`, and `selected` pointing at + * an entry. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p menu, its `id`, or any of its first + * `count` items is `NULL`. The message names the index. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + * @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu(akgl_UiMenu *menu); + +/** + * @brief Drive a menu from a keyboard or gamepad event. + * + * Up and Down arrows and the D-pad move the selection, wrapping at both + * ends; Return and the gamepad South button set `activated`. Anything else + * -- and anything while the subsystem is inert -- reports @p consumed false + * and succeeds, so it slots into the same pass-everything event chain as + * akgl_ui_handle_event(), after it and before the control maps: + * + * PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed)); + * if ( consumed ) { continue; } + * if ( menu_active ) { + * PASS(errctx, akgl_ui_menu_handle_event(&menu, &event, &consumed)); + * if ( consumed ) { continue; } + * } + * PASS(errctx, akgl_controller_handle_event(&app, &event)); + * + * @param menu The menu the application is routing input to. Required, + * with `count` in range. + * @param event The event SDL handed the loop. Required. + * @param consumed Receives whether the menu claimed the event. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p menu, @p event, or @p consumed is `NULL`. + * @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed); + /* * 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. diff --git a/src/ui.c b/src/ui.c index 8c5b833..7ff8295 100644 --- a/src/ui.c +++ b/src/ui.c @@ -87,6 +87,28 @@ static float ui_wheel_y; /** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */ static uint64_t ui_last_frame_ns; +/** @brief The layout width akgl_ui_init or the last resize established. The dialog spans it. */ +static int ui_layout_width; +/** @brief The layout height, kept alongside #ui_layout_width. */ +static int ui_layout_height; + +/** + * @brief What `NULL` means wherever a widget takes a style. + * + * The JRPG textbox's palette, on purpose: the widget that replaces that panel + * should reproduce it, so the two are comparable pixel for pixel. + * + * Field Value Meaning + */ +static const akgl_UiStyle ui_default_style = { + { 24, 20, 37, 235 }, /* fill: near-black, slightly translucent */ + { 240, 236, 214, 255 }, /* edge: parchment */ + { 240, 236, 214, 255 }, /* ink: parchment */ + 8.0f, /* padding */ + 0.0f, /* corner_radius: square */ + 0 /* fontid: the first font registered */ +}; + /** * @brief First error clay reported since the stash was last cleared. * @@ -219,6 +241,8 @@ akerr_ErrorContext *akgl_ui_init(int width, int height) // 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); + ui_layout_width = width; + ui_layout_height = height; SUCCEED_RETURN(errctx); } @@ -241,6 +265,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void) ui_wheel_x = 0.0f; ui_wheel_y = 0.0f; ui_last_frame_ns = 0; + ui_layout_width = 0; + ui_layout_height = 0; SUCCEED_RETURN(errctx); } @@ -255,6 +281,8 @@ akerr_ErrorContext *akgl_ui_resize(int width, int height) dimensions.width = (float)width; dimensions.height = (float)height; Clay_SetLayoutDimensions(dimensions); + ui_layout_width = width; + ui_layout_height = height; SUCCEED_RETURN(errctx); } @@ -768,6 +796,301 @@ akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self) SUCCEED_RETURN(errctx); } +/** @brief Wrap a caller's C string as the length-and-pointer form clay takes. Borrowed, not copied. */ +static Clay_String ui_string_from(char *text) +{ + Clay_String out; + + out.isStaticallyAllocated = false; + out.length = (int32_t)SDL_strlen(text); + out.chars = text; + return out; +} + +/** @brief Convert an SDL_Color to clay's float 0-255 convention. */ +static Clay_Color ui_clay_color(SDL_Color color) +{ + Clay_Color out; + + out.r = (float)color.r; + out.g = (float)color.g; + out.b = (float)color.b; + out.a = (float)color.a; + return out; +} + +/** + * @brief The two checks every widget makes before declaring anything. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + */ +static akerr_ErrorContext *ui_widget_ready(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 == false), + AKGL_ERR_UI, + "Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end"); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style) +{ + const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style; + uint16_t pad = (uint16_t)look->padding; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null dialog id"); + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null dialog text"); + PASS(errctx, ui_widget_ready()); + + CLAY({ + .id = CLAY_SIDI(ui_string_from(id), 0), + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED((float)ui_layout_width - (look->padding * 2.0f)), + .height = CLAY_SIZING_FIXED(AKGL_UI_DIALOG_HEIGHT) + }, + .padding = { pad, pad, pad, pad } + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .border = { + .color = ui_clay_color(look->edge), + .width = { 1, 1, 1, 1, 0 } + }, + .floating = { + .offset = { 0.0f, -look->padding }, + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_BOTTOM, + .parent = CLAY_ATTACH_POINT_CENTER_BOTTOM + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({ + .textColor = ui_clay_color(look->ink), + .fontId = look->fontid + })); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style) +{ + const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style; + uint16_t pad = (uint16_t)look->padding; + Clay_FloatingAttachPointType attach = CLAY_ATTACH_POINT_LEFT_TOP; + Clay_Vector2 offset = { 0.0f, 0.0f }; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null label id"); + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null label text"); + PASS(errctx, ui_widget_ready()); + + /* + * Anchor Attach point Inset + */ + switch ( anchor ) { + case AKGL_UI_ANCHOR_TOP_LEFT: + attach = CLAY_ATTACH_POINT_LEFT_TOP; + offset.x = look->padding; + offset.y = look->padding; + break; + case AKGL_UI_ANCHOR_TOP_RIGHT: + attach = CLAY_ATTACH_POINT_RIGHT_TOP; + offset.x = -look->padding; + offset.y = look->padding; + break; + case AKGL_UI_ANCHOR_BOTTOM_LEFT: + attach = CLAY_ATTACH_POINT_LEFT_BOTTOM; + offset.x = look->padding; + offset.y = -look->padding; + break; + case AKGL_UI_ANCHOR_BOTTOM_RIGHT: + attach = CLAY_ATTACH_POINT_RIGHT_BOTTOM; + offset.x = -look->padding; + offset.y = -look->padding; + break; + case AKGL_UI_ANCHOR_CENTER: + attach = CLAY_ATTACH_POINT_CENTER_CENTER; + break; + default: + FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Anchor %d is not an akgl_UiAnchor", (int)anchor); + } + + CLAY({ + .id = CLAY_SIDI(ui_string_from(id), 0), + .layout = { + .padding = { pad, pad, pad, pad } + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .floating = { + .offset = offset, + .attachPoints = { .element = attach, .parent = attach }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({ + .textColor = ui_clay_color(look->ink), + .fontId = look->fontid + })); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu) +{ + const akgl_UiStyle *look = NULL; + uint16_t pad = 0; + Clay_String idstring; + Clay_Color rowink; + Clay_Color rowfill; + bool rowselected = false; + int32_t i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu"); + FAIL_ZERO_RETURN(errctx, menu->id, AKERR_NULLPOINTER, "Null menu id"); + FAIL_NONZERO_RETURN( + errctx, + ((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)), + AKERR_OUTOFBOUNDS, + "A menu carries 1 to %d entries; this one claims %d", + AKGL_UI_MENU_MAX_ITEMS, + menu->count); + FAIL_NONZERO_RETURN( + errctx, + ((menu->selected < 0) || (menu->selected >= menu->count)), + AKERR_OUTOFBOUNDS, + "Selection %d is outside this menu's %d entries", + menu->selected, + menu->count); + for ( i = 0; i < menu->count; i++ ) { + FAIL_ZERO_RETURN(errctx, menu->items[i], AKERR_NULLPOINTER, "Menu entry %d is NULL", i); + } + PASS(errctx, ui_widget_ready()); + + look = ( menu->style != NULL ) ? menu->style : &ui_default_style; + pad = (uint16_t)look->padding; + idstring = ui_string_from(menu->id); + + CLAY({ + .id = CLAY_SIDI(idstring, 0), + .layout = { + .padding = { pad, pad, pad, pad }, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .border = { + .color = ui_clay_color(look->edge), + .width = { 1, 1, 1, 1, 0 } + }, + .floating = { + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_CENTER, + .parent = CLAY_ATTACH_POINT_CENTER_CENTER + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + for ( i = 0; i < menu->count; i++ ) { + // The selected row draws inverted. An unselected row declares no + // background at all -- alpha 0 emits no render command -- so the + // panel's fill shows through rather than being repainted. + rowselected = (i == menu->selected); + rowink = rowselected ? ui_clay_color(look->fill) : ui_clay_color(look->ink); + rowfill = rowselected ? ui_clay_color(look->ink) : (Clay_Color){ 0, 0, 0, 0 }; + CLAY({ + .id = CLAY_SIDI(idstring, (uint32_t)(i + 1)), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(0) }, + .padding = { pad, pad, (uint16_t)(pad / 2), (uint16_t)(pad / 2) } + }, + .backgroundColor = rowfill + }) { + // Mouse selection: hovering a row makes it the selection, and + // the frame-latched press edge on a hovered row confirms it. + // The row highlight catches up next frame, which at any + // playable frame rate is invisible. + if ( Clay_Hovered() ) { + menu->selected = i; + if ( ui_pointer_pressed ) { + menu->activated = true; + } + } + CLAY_TEXT(ui_string_from(menu->items[i]), CLAY_TEXT_CONFIG({ + .textColor = rowink, + .fontId = look->fontid + })); + } + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination"); + FAIL_NONZERO_RETURN( + errctx, + ((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)), + AKERR_OUTOFBOUNDS, + "A menu carries 1 to %d entries; this one claims %d", + AKGL_UI_MENU_MAX_ITEMS, + menu->count); + FAIL_NONZERO_RETURN( + errctx, + ((menu->selected < 0) || (menu->selected >= menu->count)), + AKERR_OUTOFBOUNDS, + "Selection %d is outside this menu's %d entries", + menu->selected, + menu->count); + *consumed = false; + if ( ui_context == NULL ) { + SUCCEED_RETURN(errctx); + } + + switch ( event->type ) { + case SDL_EVENT_KEY_DOWN: + if ( event->key.key == SDLK_UP ) { + menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1); + *consumed = true; + } else if ( event->key.key == SDLK_DOWN ) { + menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1); + *consumed = true; + } else if ( event->key.key == SDLK_RETURN ) { + menu->activated = true; + *consumed = true; + } + break; + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ) { + menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1); + *consumed = true; + } else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ) { + menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1); + *consumed = true; + } else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ) { + menu->activated = true; + *consumed = true; + } + break; + default: + break; + } + 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 f0a3c8f..dfa1bff 100644 --- a/tests/ui.c +++ b/tests/ui.c @@ -630,6 +630,294 @@ akerr_ErrorContext *test_ui_handle_event(void) SUCCEED_RETURN(e); } +/** @brief The default widget palette's fill, for pixel assertions. Matches src/ui.c. */ +static const SDL_Color widgetfill = { 24, 20, 37, 235 }; +/** @brief The default widget palette's edge and ink. */ +static const SDL_Color widgetink = { 240, 236, 214, 255 }; + +/** + * @brief The dialog widget draws the textbox: fill inside, border on the edge, text on top. + * + * On a 64x64 layout with the default 8px padding the panel spans (8,0) to + * (56,56) -- the width follows the layout, the height is + * AKGL_UI_DIALOG_HEIGHT. The palette asserted here is the point: the default + * style *is* the JRPG textbox's, so the comparison chapter can put the two + * side by side. + */ +akerr_ErrorContext *test_ui_dialog_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL), + "a dialog was declared with no subsystem"); + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the dialog test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the dialog test font failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL), + "a dialog was declared outside the frame bracket"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the dialog frame failed"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog(NULL, "words", NULL), + "a NULL dialog id was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog("dialog", NULL, NULL), + "NULL dialog text was accepted"); + TEST_EXPECT_OK(e, akgl_ui_dialog("dialog", "Hi", NULL), "declaring the dialog failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the dialog frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(e, pixel_is(shot, 32, 40, widgetfill), + "the dialog panel's fill is missing from its middle"); + TEST_ASSERT(e, pixel_is(shot, 8, 28, widgetink), + "the dialog panel's border is missing from its left edge"); + TEST_ASSERT(e, pixel_is(shot, 4, 28, testblack), + "the dialog painted outside its margin"); + TEST_ASSERT(e, pixel_is(shot, 32, 60, testblack), + "the dialog painted over the margin below it"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Labels pin to their corners and stay out of the others. + */ +akerr_ErrorContext *test_ui_label_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + bool topright = false; + bool bottomleft = false; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the label test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the label test font failed"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the label frame failed"); + TEST_EXPECT_OK(e, akgl_ui_label("score", "9", AKGL_UI_ANCHOR_TOP_RIGHT, NULL), + "declaring the top-right label failed"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, + akgl_ui_label("bogus", "9", (akgl_UiAnchor)99, NULL), + "a nonsense anchor was accepted"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the label frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + // The label is fit-sized around its glyph, so where exactly it ends + // is the font's business; the assertions are per-quadrant instead. + for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) { + for ( x = (TEST_TARGET_SIZE / 2); x < TEST_TARGET_SIZE; x++ ) { + if ( pixel_is(shot, x, y, widgetfill) ) { + topright = true; + } + } + } + // The far corner, not the whole quadrant: a padded label on a 64px + // target is fat enough to graze the quadrant boundary, and where its + // edge falls is the font's business, not this test's. + for ( y = (TEST_TARGET_SIZE - 16); y < TEST_TARGET_SIZE; y++ ) { + for ( x = 0; x < 16; x++ ) { + if ( pixel_is(shot, x, y, widgetfill) ) { + bottomleft = true; + } + } + } + TEST_ASSERT(e, topright, "the top-right label left no fill in its quadrant"); + TEST_ASSERT(e, bottomleft == false, "the top-right label reached the opposite corner"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The menu widget: validation, the inverted highlight, and mouse selection. + * + * The mouse half runs the real two-frame protocol: frame one lays the menu + * out, the click lands between frames against the retained tree, and frame + * two's declaration sees the hover and the latched press edge. Row geometry + * comes from Clay_GetElementData rather than being derived here, so the test + * cannot drift from however the menu lays its rows out. + */ +akerr_ErrorContext *test_ui_menu_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + SDL_Event event; + Clay_ElementData rowdata; + static akgl_UiMenu menu = { + .id = "testmenu", + .items = { "New Game", "Options", "Quit" }, + .count = 3, + }; + akgl_UiMenu broken; + uint16_t fontid = 0; + bool consumed = false; + bool inverted = false; + int token = 1; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the menu test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the menu test font failed"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(NULL), + "a NULL menu was accepted"); + broken = menu; + broken.count = 0; + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken), + "an empty menu was accepted"); + broken = menu; + broken.selected = 7; + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken), + "a selection outside the menu was accepted"); + broken = menu; + broken.items[1] = NULL; + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(&broken), + "a NULL entry was accepted"); + + // Frame one: the menu as declared, selection on the first row. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the first menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "declaring the menu failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the first menu frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + for ( y = 0; y < TEST_TARGET_SIZE; y++ ) { + for ( x = 0; x < TEST_TARGET_SIZE; x++ ) { + if ( pixel_is(shot, x, y, widgetink) ) { + inverted = true; + } + } + } + TEST_ASSERT(e, inverted, "no row carries the inverted highlight"); + + // The click: aimed at the second row's real box, landed between + // frames, seen by the declaration in frame two. + rowdata = Clay_GetElementData(Clay_GetElementIdWithIndex(CLAY_STRING("testmenu"), 2)); + TEST_ASSERT(e, rowdata.found, "the second row's element id cannot be found"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f); + event.motion.y = rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "moving onto the second row was refused"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, + rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f), + rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f)); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the click on the second row was refused"); + TEST_ASSERT(e, consumed == true, "the click on the menu was not consumed"); + + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the second menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the second menu frame failed"); + + TEST_ASSERT(e, menu.selected == 1, + "clicking the second row moved the selection to %d, not 1", menu.selected); + TEST_ASSERT(e, menu.activated == true, "clicking a row did not activate the menu"); + menu.activated = false; + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Keyboard and gamepad drive a menu: wrap both ways, confirm, ignore the rest. + */ +akerr_ErrorContext *test_ui_menu_events(void) +{ + PREPARE_ERROR(e); + SDL_Event event; + static akgl_UiMenu menu = { + .id = "eventmenu", + .items = { "One", "Two", "Three" }, + .count = 3, + }; + bool consumed = false; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the menu event test failed"); + menu.selected = 0; + menu.activated = false; + + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_ASSERT(e, consumed && (menu.selected == 1), "Down did not move the selection to 1"); + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_ASSERT(e, menu.selected == 0, "Down from the last row did not wrap to the first"); + event.key.key = SDLK_UP; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Up was refused"); + TEST_ASSERT(e, menu.selected == 2, "Up from the first row did not wrap to the last"); + event.key.key = SDLK_RETURN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Return was refused"); + TEST_ASSERT(e, consumed && menu.activated, "Return did not activate the menu"); + menu.activated = false; + event.key.key = SDLK_ESCAPE; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Escape was refused"); + TEST_ASSERT(e, consumed == false, "a key the menu does not use was consumed"); + + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN; + event.gbutton.button = SDL_GAMEPAD_BUTTON_DPAD_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "D-pad down was refused"); + TEST_ASSERT(e, consumed && (menu.selected == 0), "D-pad down did not wrap to the first row"); + event.gbutton.button = SDL_GAMEPAD_BUTTON_SOUTH; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "South was refused"); + TEST_ASSERT(e, consumed && menu.activated, "South did not activate the menu"); + menu.activated = false; + event.gbutton.button = SDL_GAMEPAD_BUTTON_EAST; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "East was refused"); + TEST_ASSERT(e, consumed == false, "a button the menu does not use was consumed"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(NULL, &event, &consumed), + "a NULL menu was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(&menu, NULL, &consumed), + "a NULL event was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(&menu, &event, NULL), + "a NULL consumed destination was accepted"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + /** * @brief A layout error inside clay must surface from frame_end, not vanish. * @@ -714,6 +1002,10 @@ int main(void) CATCH(errctx, test_ui_layout_pixels()); CATCH(errctx, test_ui_layout_text()); CATCH(errctx, test_ui_handle_event()); + CATCH(errctx, test_ui_dialog_widget()); + CATCH(errctx, test_ui_label_widget()); + CATCH(errctx, test_ui_menu_widget()); + CATCH(errctx, test_ui_menu_events()); CATCH(errctx, test_ui_layout_error_surfaces()); } CLEANUP { IGNORE(akgl_text_unloadallfonts()); From badaf570ab6001aa95bf84ba691614f3807e325e Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:24:57 -0400 Subject: [PATCH 06/15] Stop a stationary pointer from pinning the menu selection A pointer parked over a menu row re-claimed the selection on every frame's declaration, so keyboard navigation fought the mouse sixty times a second and lost -- press Up, and the hover put the selection straight back. Found by the UI demo's scripted tour: its mouse click leaves the pointer resting on the menu, and the Up keystroke that followed did nothing. Hover now moves the selection only on frames the pointer actually moved (or clicked), through a frame-latched motion edge beside the existing press edge. Parking the mouse claims nothing; moving it onto a row still selects, clicking still selects and activates. Regression test drives the exact sequence: click a row, move the selection away by keyboard, redeclare, and assert the stationary pointer stole nothing back. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- include/akgl/ui.h | 7 +++++-- src/ui.c | 34 +++++++++++++++++++++++++++------- tests/ui.c | 17 +++++++++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/include/akgl/ui.h b/include/akgl/ui.h index 168042d..01f43da 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -425,8 +425,11 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_label(char *id, char *text, akgl_UiAn * @brief Declare a vertical menu from caller-owned state, centred on screen. * * One row per entry, the selected row drawn inverted (ink on fill swaps to - * fill on ink). Hovering a row with the mouse moves `selected` to it; a left - * click on a row sets `activated`. Keyboard and gamepad go through + * fill on ink). Moving the mouse onto a row selects it; a left click on a + * row selects and sets `activated`. A *stationary* pointer claims nothing -- + * parking the mouse over the menu must not pin the selection against the + * keyboard, which is fighting sixty times a second and losing. Keyboard and + * gamepad go through * akgl_ui_menu_handle_event(), which the application calls for whichever * menu currently has its attention -- that routing *is* the focus model. * Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). diff --git a/src/ui.c b/src/ui.c index 7ff8295..754bdc4 100644 --- a/src/ui.c +++ b/src/ui.c @@ -80,6 +80,18 @@ static bool ui_pointer_down; static bool ui_pointer_pending_pressed; /** @brief The frame-stable press edge: one left press happened before this frame. */ static bool ui_pointer_pressed; +/** @brief Motion arrived since the last frame_begin latched. Companion to the press edge. */ +static bool ui_pointer_pending_moved; +/** + * @brief The frame-stable motion edge: the pointer moved before this frame. + * + * What keeps a menu's hover-selection polite. A stationary pointer parked + * over a row must not re-claim the selection every frame -- the keyboard + * would fight it and lose, sixty times a second -- so hover only moves the + * selection on frames the mouse actually moved. Found the way these things + * are found: the demo script pressed Up and the selection did not move. + */ +static bool ui_pointer_moved; /** @brief Wheel notches accumulated since the last frame_begin, horizontal. */ static float ui_wheel_x; /** @brief Wheel notches accumulated since the last frame_begin, vertical. */ @@ -262,6 +274,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void) ui_pointer_down = false; ui_pointer_pending_pressed = false; ui_pointer_pressed = false; + ui_pointer_pending_moved = false; + ui_pointer_moved = false; ui_wheel_x = 0.0f; ui_wheel_y = 0.0f; ui_last_frame_ns = 0; @@ -679,6 +693,7 @@ akerr_ErrorContext *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool case SDL_EVENT_MOUSE_MOTION: ui_pointer_x = event->motion.x; ui_pointer_y = event->motion.y; + ui_pointer_pending_moved = true; position.x = ui_pointer_x; position.y = ui_pointer_y; // Fed per event, not per frame, which is the usage clay documents: @@ -743,10 +758,12 @@ akerr_ErrorContext *akgl_ui_frame_begin(void) ui_clay_error_text[0] = '\0'; ui_clay_error_count = 0; - // Latch the press edge for the widgets this frame declares, then let the - // event loop start accumulating the next one. + // Latch the press and motion edges for the widgets this frame declares, + // then let the event loop start accumulating the next ones. ui_pointer_pressed = ui_pointer_pending_pressed; ui_pointer_pending_pressed = false; + ui_pointer_moved = ui_pointer_pending_moved; + ui_pointer_pending_moved = false; now = SDL_GetTicksNS(); if ( ui_last_frame_ns != 0 ) { @@ -1015,11 +1032,14 @@ akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu) }, .backgroundColor = rowfill }) { - // Mouse selection: hovering a row makes it the selection, and - // the frame-latched press edge on a hovered row confirms it. - // The row highlight catches up next frame, which at any - // playable frame rate is invisible. - if ( Clay_Hovered() ) { + // Mouse selection: a row claims the selection when the + // pointer moves onto it or clicks it -- not merely by being + // under a stationary pointer, which would re-claim it every + // frame and the keyboard would fight it and lose. The + // frame-latched press edge on a hovered row confirms. The + // row highlight catches up next frame, which at any playable + // frame rate is invisible. + if ( Clay_Hovered() && (ui_pointer_moved || ui_pointer_pressed) ) { menu->selected = i; if ( ui_pointer_pressed ) { menu->activated = true; diff --git a/tests/ui.c b/tests/ui.c index dfa1bff..4717cc5 100644 --- a/tests/ui.c +++ b/tests/ui.c @@ -841,6 +841,23 @@ akerr_ErrorContext *test_ui_menu_widget(void) "clicking the second row moved the selection to %d, not 1", menu.selected); TEST_ASSERT(e, menu.activated == true, "clicking a row did not activate the menu"); menu.activated = false; + + // The pointer is still parked over the second row. Keyboard moves the + // selection away, and redeclaring the menu must not let the + // stationary pointer claim it back -- only motion or a click selects. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), + "Down after the click was refused"); + TEST_ASSERT(e, menu.selected == 2, "Down did not move the selection off the hovered row"); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the third menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu a third time failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the third menu frame failed"); + TEST_ASSERT(e, menu.selected == 2, + "a stationary pointer stole the selection back to %d", menu.selected); + TEST_ASSERT(e, menu.activated == false, + "a stationary pointer activated the menu"); } CLEANUP { if ( shot != NULL ) { SDL_DestroySurface(shot); From 4ddd8d1ad312bf42781005f89f19a5a17b744836 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:24:58 -0400 Subject: [PATCH 07/15] Add the UI demo: title menu, raw-CLAY options screen, HUD and dialog examples/uidemo is the program the UI chapter quotes. Three screens, three ways of building an interface: the title screen is one akgl_UiMenu plus a heading label; the options screen is written in raw CLAY() declarations -- hover highlighting inside the declaration, clicks paired with the application's own press edge -- because the widgets are a convenience, not a boundary; and the play screen is two HUD labels and the one-call dialog over a checkerboard standing in for a game world. No tilemap, no actors, no physics: everything left on screen is the subject. Its route_event() is the documented call-order contract in the flesh -- UI first, consumed events stop there, then the current screen's keys -- and the screen routing is the focus model, stated rather than invented. The headless smoke run (example_uidemo, --frames 240 --demo) tours every path through the real event chain: a mouse click on the centred menu (the consumed path, landing on the middle row by symmetry), keyboard into and out of the options screen, the dialog opened and dismissed, and Quit confirmed from the menu. The run prints its score and settings so a pass can be read, not just counted. docs_game_figures gains a uidemo frame -- the play screen with the dialog up -- for the chapter to come. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- CMakeLists.txt | 10 +- examples/CMakeLists.txt | 4 +- examples/uidemo/CMakeLists.txt | 59 ++++ examples/uidemo/uidemo.c | 609 +++++++++++++++++++++++++++++++++ examples/uidemo/uidemo.h | 58 ++++ 5 files changed, 737 insertions(+), 3 deletions(-) create mode 100644 examples/uidemo/CMakeLists.txt create mode 100644 examples/uidemo/uidemo.c create mode 100644 examples/uidemo/uidemo.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 27bc9dd..0d07f9b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -861,7 +861,15 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt") --demo --frames 240 --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png" --screenshot-frame 230 - DEPENDS sidescroller jrpg + # Frame 100 of the UI demo's scripted tour is the play screen with the + # dialog up and the score mid-count -- all three widgets in one frame. + COMMAND ${CMAKE_COMMAND} -E env + SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy + $ + --demo --frames 240 + --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/uidemo.png" + --screenshot-frame 100 + DEPENDS sidescroller jrpg uidemo COMMENT "Regenerating the tutorial figures in docs/images" VERBATIM ) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 4b444df..adf1cfb 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,4 +1,4 @@ -# The two tutorial games. +# The tutorial games and demos. # # Each one is a complete, running program that the matching chapter quotes with # `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot @@ -9,7 +9,7 @@ # passes and land at different times, and a configure that fails because one of # them is not there yet would block the other. There is nothing clever about the # guard; it exists so an incomplete tree still builds. -foreach(_example sidescroller jrpg) +foreach(_example sidescroller jrpg uidemo) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt") add_subdirectory(${_example}) endif() diff --git a/examples/uidemo/CMakeLists.txt b/examples/uidemo/CMakeLists.txt new file mode 100644 index 0000000..65d87d6 --- /dev/null +++ b/examples/uidemo/CMakeLists.txt @@ -0,0 +1,59 @@ +# The UI demo, quoted by the UI chapter's `c excerpt=` blocks. +# +# A real target built by `all`, on the same terms as the tutorial games: a +# chapter whose program does not compile is the failure the documentation +# harness exists to stop. + +add_executable(uidemo + uidemo.c +) + +target_link_libraries(uidemo + PRIVATE akstdlib::akstdlib akerror::akerror akgl + SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer + jansson::jansson -lm) +target_include_directories(uidemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_options(uidemo PRIVATE ${AKGL_WARNING_FLAGS}) + +# The demo needs no art -- its "world" is akgl_draw_background -- but it does +# need one font, and it uses the same test-asset face the JRPG does, compiled +# in as an absolute path for the same run-from-anywhere reason. +get_filename_component(UIDEMO_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +target_compile_definitions(uidemo PRIVATE + UIDEMO_FONT_FILE="${UIDEMO_REPO_ROOT}/tests/assets/akgl_test_mono.ttf" +) + +if(AKGL_VENDORED_DEPENDENCIES) + set_target_properties(uidemo PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") +endif() + +# The headless smoke run. `--demo` tours every screen through the real event +# chain -- a mouse click on the title menu (the consumed path), keyboard into +# and out of the raw-CLAY options screen, the play screen's dialog opened and +# dismissed, and Quit confirmed from the menu -- rather than just proving that +# main() returns. There is no physics here, so nothing needs a driven clock; +# `--frames` is a backstop above the script's last step, not the exit path. +add_test(NAME example_uidemo COMMAND uidemo --frames 240 --demo) +set_tests_properties(example_uidemo PROPERTIES + TIMEOUT 120 + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +) + +# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has +# to go in the same property rather than a second one. Only needed when the +# dependencies were vendored; an installed build resolves them normally. +if(AKGL_VENDORED_DEPENDENCIES) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") + set(UIDEMO_TEST_ENV_MOD "") + foreach(dir IN LISTS AKGL_TEST_LIBPATH) + list(APPEND UIDEMO_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") + endforeach() + set_tests_properties(example_uidemo + PROPERTIES ENVIRONMENT_MODIFICATION "${UIDEMO_TEST_ENV_MOD}") + else() + string(REPLACE ";" ":" UIDEMO_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") + set_tests_properties(example_uidemo PROPERTIES + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" + ) + endif() +endif() diff --git a/examples/uidemo/uidemo.c b/examples/uidemo/uidemo.c new file mode 100644 index 0000000..d8152cf --- /dev/null +++ b/examples/uidemo/uidemo.c @@ -0,0 +1,609 @@ +/** + * @file uidemo.c + * @brief The UI demo: a title menu, an options screen, and a HUD, three ways. + * + * The chapter on the UI subsystem quotes this program rather than restating + * it. Run it with no arguments for a window: + * + * ./examples/uidemo/uidemo + * + * The title menu answers to the arrow keys, Return, the D-pad, and the mouse. + * Start opens a stand-in play screen -- a checkerboard where a game would be + * -- with a score counter ticking in a HUD label; Space opens and closes a + * dialog panel there, and Escape backs out. Options is a screen written in + * raw CLAY() declarations, because the widgets are a convenience, not a + * boundary. `--frames N` bounds the run and `--demo` drives the whole tour + * from a script, which is what makes this runnable as a headless smoke test. + * + * What is deliberately absent: a tilemap, actors, physics. The world here is + * one draw call, so everything left is the subject -- what a UI costs and + * where it goes in a frame. + */ + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "uidemo.h" + +/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */ +#define UIDEMO_FRAME_BUDGET_MS 16 + +/* + * The scripted tour `--demo` drives. Every route through the demo, by every + * input device it supports: the mouse clicks the title menu open (dead centre + * lands on the middle row by symmetry -- the menu is centred and Options is + * its middle entry), the keyboard walks back out, into the play screen, + * through the dialog, and finally down to Quit. + * + * Frame Event Key / position What it proves + */ +static const uidemo_ScriptStep UIDEMO_SCRIPT[] = { + { 5, SDL_EVENT_MOUSE_MOTION, 0, 320.0f, 240.0f }, /* hover the menu */ + { 10, SDL_EVENT_MOUSE_BUTTON_DOWN, 0, 320.0f, 240.0f }, /* consumed click */ + { 11, SDL_EVENT_MOUSE_BUTTON_UP, 0, 320.0f, 240.0f }, /* -> Options screen */ + { 40, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */ + { 50, SDL_EVENT_KEY_DOWN, SDLK_UP, 0.0f, 0.0f }, /* select Start */ + { 60, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f }, /* -> play screen */ + { 80, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* open the dialog */ + { 120, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* close it again */ + { 140, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */ + { 150, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Options */ + { 160, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Quit */ + { 170, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f } /* and out */ +}; + +#define UIDEMO_SCRIPT_STEPS (sizeof(UIDEMO_SCRIPT) / sizeof(UIDEMO_SCRIPT[0])) + +static long frame_limit = 0; +/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */ +static char *shotpath = NULL; +static long shotframe = 0; +static bool demo = false; +static bool running = true; +static int exitstatus = 0; +static bool lowfps_warned = false; + +/** @brief Which screen the frame declares. */ +static uidemo_State state = UIDEMO_STATE_TITLE; + +/** + * @brief The title menu. Caller-owned state, zero machinery: this struct and + * the akgl_ui_menu call each frame are the whole main menu. + */ +static akgl_UiMenu title_menu = { + .id = "title", + .items = { "Start", "Options", "Quit" }, + .count = 3, +}; + +/** @brief The options screen's two settings. What the toggles flip. */ +static bool opt_music = true; +static bool opt_sound = true; + +/** @brief The play screen's HUD numbers. The score ticks so the label visibly updates. */ +static int score = 0; +static int lives = 3; +/** @brief Whether the play screen's dialog is up. Space flips it. */ +static bool dialog_open = false; + +/** + * @brief A left click arrived this frame, wherever it landed. + * + * The raw CLAY() options screen needs a press edge to pair with + * Clay_Hovered(), and the application is the right owner of it: it sees + * every event before the UI does. Set in route_event, cleared at the end of + * each frame. The widget helpers keep their own edge internally -- this one + * exists precisely because the options screen does not use them. + */ +static bool clicked = false; + +/** @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. */ +static void lowfps_quiet(void) +{ + if ( lowfps_warned == false ) { + lowfps_warned = true; + SDL_Log("Frame rate is under 30 and this demo does nothing about it"); + } +} + +/** + * @brief Read `--frames N`, `--demo` and the screenshot flags off the command line. + * + * @param argc Argument count, from `main`. + * @param argv Argument vector, from `main`. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p argv is `NULL`. + * @throws AKERR_VALUE If a flag needing an argument is last, or its argument + * is not a number. + */ +static akerr_ErrorContext *parse_args(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + int i = 0; + int number = 0; + + FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv"); + + for ( i = 1; i < argc; i++ ) { + if ( strcmp(argv[i], "--demo") == 0 ) { + demo = true; + } else if ( strcmp(argv[i], "--frames") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count"); + } + i += 1; + PASS(errctx, aksl_atoi(argv[i], &number)); + frame_limit = number; + } else if ( strcmp(argv[i], "--screenshot") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot needs a path"); + } + i += 1; + shotpath = argv[i]; + } else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot-frame needs a number"); + } + i += 1; + PASS(errctx, aksl_atoi(argv[i], &number)); + shotframe = number; + } else { + FAIL_RETURN(errctx, AKERR_VALUE, + "usage: uidemo [--frames N] [--demo]" + " [--screenshot PATH] [--screenshot-frame N]"); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Bring the library and the UI subsystem up. + * + * The UI half is three calls: load a font, akgl_ui_init at the screen size, + * and register the font for a clay fontId. The id comes back 0 because it is + * the first registered, which is what the widgets' default style uses -- so + * nothing here ever mentions a fontId again. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *startup(void) +{ + PREPARE_ERROR(errctx); + uint16_t fontid = 0; + + PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl UI demo", sizeof(akgl_game.name) - 1)); + PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1)); + PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.uidemo", sizeof(akgl_game.uri) - 1)); + + PASS(errctx, akgl_game_init()); + akgl_game.lowfpsfunc = &lowfps_quiet; + + PASS(errctx, akgl_set_property("game.screenwidth", UIDEMO_SCREEN_WIDTH)); + PASS(errctx, akgl_set_property("game.screenheight", UIDEMO_SCREEN_HEIGHT)); + PASS(errctx, akgl_render_2d_init(akgl_renderer)); + + PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE)); + PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT)); + PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid)); + + SUCCEED_RETURN(errctx); +} + +/** + * @brief Route one event: the UI first, then whatever the current screen wants. + * + * This is the call-order contract from akgl/ui.h in the flesh. The UI gets + * first refusal; a consumed event goes no further, which is what keeps a + * click on a menu from also being a click in the game. Only then does the + * current screen read the keyboard -- and *that* routing is the whole focus + * model: the title menu hears keys because this function sends them there + * while the title screen is up, not because anything owns "focus". + * + * @param event The event to route. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *route_event(SDL_Event *event) +{ + PREPARE_ERROR(errctx); + bool consumed = false; + + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + + if ( event->type == SDL_EVENT_QUIT ) { + running = false; + SUCCEED_RETURN(errctx); + } + // The options screen's press edge, recorded before the UI can consume + // the event -- a click on a toggle row is *always* consumed (the row is + // UI), so waiting until afterwards would record nothing. + if ( (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) + && (event->button.button == SDL_BUTTON_LEFT) ) { + clicked = true; + } + + PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed)); + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed)); + break; + case UIDEMO_STATE_OPTIONS: + if ( (event->type == SDL_EVENT_KEY_DOWN) && (event->key.key == SDLK_ESCAPE) ) { + state = UIDEMO_STATE_TITLE; + } + break; + case UIDEMO_STATE_PLAY: + if ( event->type == SDL_EVENT_KEY_DOWN ) { + if ( event->key.key == SDLK_ESCAPE ) { + state = UIDEMO_STATE_TITLE; + } else if ( event->key.key == SDLK_SPACE ) { + dialog_open = !dialog_open; + } + } + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Feed the scripted events due on this frame through the real routing. + * + * Synthesized SDL_Events, not direct state changes: the demo run proves the + * event chain -- consumed and not, menu and screen -- because it travels it. + * + * @param frameno The frame about to be drawn. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *demo_step(long frameno) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + size_t i = 0; + + for ( i = 0; i < UIDEMO_SCRIPT_STEPS; i++ ) { + if ( UIDEMO_SCRIPT[i].frame != frameno ) { + continue; + } + PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event))); + event.type = UIDEMO_SCRIPT[i].type; + switch ( UIDEMO_SCRIPT[i].type ) { + case SDL_EVENT_KEY_DOWN: + event.key.key = UIDEMO_SCRIPT[i].key; + break; + case SDL_EVENT_MOUSE_MOTION: + event.motion.x = UIDEMO_SCRIPT[i].x; + event.motion.y = UIDEMO_SCRIPT[i].y; + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + event.button.button = SDL_BUTTON_LEFT; + event.button.x = UIDEMO_SCRIPT[i].x; + event.button.y = UIDEMO_SCRIPT[i].y; + break; + default: + break; + } + PASS(errctx, route_event(&event)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the title screen: one widget call. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_title(void) +{ + PREPARE_ERROR(errctx); + PASS(errctx, akgl_ui_label("heading", "libakgl UI demo", AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + PASS(errctx, akgl_ui_menu(&title_menu)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the options screen in raw CLAY(), no widgets anywhere. + * + * This screen exists to show the other way in: a centred panel, a column of + * rows, hover highlighting via Clay_Hovered() *inside* the declaration, and + * clicks paired with the application's own press edge. Everything the widgets + * do is done with these same pieces; a screen the widgets cannot express is + * written like this rather than waiting for a widget to exist. + * + * The row labels live in statics because clay borrows the pointer until + * frame_end -- a local buffer here would be dangling by the time the text is + * drawn. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_options(void) +{ + PREPARE_ERROR(errctx); + static char musicrow[32]; + static char soundrow[32]; + int count = 0; + + PASS(errctx, aksl_snprintf(&count, musicrow, sizeof(musicrow), "Music: %s", opt_music ? "ON" : "OFF")); + PASS(errctx, aksl_snprintf(&count, soundrow, sizeof(soundrow), "Sound: %s", opt_sound ? "ON" : "OFF")); + + CLAY({ + .id = CLAY_ID("options"), + .layout = { + .padding = { 16, 16, 16, 16 }, + .childGap = 8, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = { 24, 20, 37, 235 }, + .border = { .color = { 240, 236, 214, 255 }, .width = { 1, 1, 1, 1, 0 } }, + .floating = { + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_CENTER, + .parent = CLAY_ATTACH_POINT_CENTER_CENTER + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(CLAY_STRING("OPTIONS"), CLAY_TEXT_CONFIG({ + .textColor = { 240, 236, 214, 255 }, + .fontId = 0 + })); + + CLAY({ + .id = CLAY_ID("options-music"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_music = !opt_music; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } + + CLAY({ + .id = CLAY_ID("options-sound"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_sound = !opt_sound; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(soundrow), .chars = soundrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } + + CLAY({ + .id = CLAY_ID("options-back"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + state = UIDEMO_STATE_TITLE; + } + CLAY_TEXT(CLAY_STRING("Back (Esc)"), CLAY_TEXT_CONFIG({ + .textColor = { 240, 236, 214, 255 }, + .fontId = 0 + })); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the play screen's HUD: two labels, and the dialog while it is up. + * + * The score buffer is static for the same borrowed-until-frame_end reason as + * the options rows. Note what is *not* here: no visible flag, no draw call, + * no geometry. The dialog is open because this frame declares it. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_play(void) +{ + PREPARE_ERROR(errctx); + static char scoretext[32]; + static char livestext[32]; + int count = 0; + + PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score)); + PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives)); + PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + if ( dialog_open ) { + PASS(errctx, akgl_ui_dialog("dialog", + "This panel is one call. Space dismisses it; " + "compare examples/jrpg/textbox.c.", + NULL)); + } + SUCCEED_RETURN(errctx); +} + +/** @brief Read the render target back and write it out as a PNG, for the chapter figure. */ +static akerr_ErrorContext *save_screenshot(char *path) +{ + SDL_Surface *shot = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError()); + + ATTEMPT { + FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL, + "IMG_SavePNG(%s): %s", path, SDL_GetError()); + } CLEANUP { + SDL_DestroySurface(shot); + } PROCESS(errctx) { + } FINISH(errctx, true); + SDL_Log("Wrote %s", path); + SUCCEED_RETURN(errctx); +} + +/** + * @brief One frame: events, the world stand-in, the UI bracket, present. + * + * The shape to compare with the JRPG's frame(): where that program calls + * akgl_game_update between frame_start and frame_end, this one paints a + * checkerboard -- and the UI bracket sits in the same overlay slot the + * hand-rolled textbox draw did. + * + * @param frameno The frame number, for the demo script. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *frame(long frameno) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + + while ( SDL_PollEvent(&event) ) { + PASS(errctx, route_event(&event)); + } + if ( demo ) { + PASS(errctx, demo_step(frameno)); + } + + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + if ( state == UIDEMO_STATE_PLAY ) { + // Where a game would call akgl_game_update. The score ticking is the + // stand-in for play, so the HUD label visibly earns its redraw. + PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT)); + score += 1; + } + + PASS(errctx, akgl_ui_frame_begin()); + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, declare_title()); + break; + case UIDEMO_STATE_OPTIONS: + PASS(errctx, declare_options()); + break; + case UIDEMO_STATE_PLAY: + PASS(errctx, declare_play()); + break; + default: + break; + } + PASS(errctx, akgl_ui_frame_end(akgl_renderer)); + + if ( (shotpath != NULL) && (frameno == shotframe) ) { + PASS(errctx, save_screenshot(shotpath)); + } + PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + + // The press edge lives exactly one frame: the declarations above have + // seen it, so the next frame starts clean. + clicked = false; + + // Menu activation is read after the frame, so it catches both routes in: + // Return through akgl_ui_menu_handle_event before the declarations, and a + // click through the declaration itself. + if ( title_menu.activated ) { + title_menu.activated = false; + switch ( title_menu.selected ) { + case 0: + state = UIDEMO_STATE_PLAY; + score = 0; + dialog_open = false; + break; + case 1: + state = UIDEMO_STATE_OPTIONS; + break; + case 2: + default: + running = false; + break; + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Give back what has to be given back, in the order that works. + * + * akgl_ui_shutdown first because it is the newest thing up, then the fonts + * **before** SDL_Quit -- the registry the fonts live in is an SDL property + * set, and SDL_Quit destroys it with them inside. The pools and the clay + * arena are static storage; there is nothing to free. + */ +static void teardown(void) +{ + IGNORE(akgl_ui_shutdown()); + IGNORE(akgl_text_unloadallfonts()); + if ( akgl_window != NULL ) { + SDL_DestroyWindow(akgl_window); + akgl_window = NULL; + } + SDL_Quit(); +} + +int main(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + long frameno = 0; + uint64_t started = 0; + uint64_t spent = 0; + + ATTEMPT { + CATCH(errctx, parse_args(argc, argv)); + CATCH(errctx, startup()); + + // The loop is the last thing in this ATTEMPT block on purpose: CATCH + // reports failure by `break`ing out of the loop, which falls straight + // into CLEANUP. See examples/jrpg/jrpg.c for the long form of this + // note. + while ( running ) { + started = SDL_GetTicks(); + CATCH(errctx, frame(frameno)); + frameno += 1; + if ( (frame_limit > 0) && (frameno >= frame_limit) ) { + running = false; + } + if ( demo == false ) { + spent = SDL_GetTicks() - started; + if ( spent < UIDEMO_FRAME_BUDGET_MS ) { + SDL_Delay((uint32_t)(UIDEMO_FRAME_BUDGET_MS - spent)); + } + } + } + } CLEANUP { + teardown(); + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "the UI demo could not finish"); + exitstatus = 1; + } FINISH_NORETURN(errctx); + + // Enough for the smoke run to be read rather than merely passed: the + // script quits from the title menu, so a run that finished its tour says + // so here. + printf("uidemo: %ld frames, score %d, music %s, sound %s\n", + frameno, score, opt_music ? "on" : "off", opt_sound ? "on" : "off"); + return exitstatus; +} diff --git a/examples/uidemo/uidemo.h b/examples/uidemo/uidemo.h new file mode 100644 index 0000000..ba6e1e3 --- /dev/null +++ b/examples/uidemo/uidemo.h @@ -0,0 +1,58 @@ +/** + * @file uidemo.h + * @brief Shared constants and types for the UI demo. + * + * There is deliberately little here: the demo is one translation unit, and + * this header exists so the chapter can quote the constants and the script + * type without quoting the code around them. + */ + +#ifndef _UIDEMO_H_ +#define _UIDEMO_H_ + +#include + +/** @brief Window and layout width, in pixels. */ +#define UIDEMO_SCREEN_WIDTH "640" +/** @brief Window and layout height, in pixels. */ +#define UIDEMO_SCREEN_HEIGHT "480" +/** @brief The same width as a number, for akgl_ui_init. */ +#define UIDEMO_WIDTH 640 +/** @brief The same height as a number. */ +#define UIDEMO_HEIGHT 480 + +/** @brief Registry name the demo's one font is loaded under. */ +#define UIDEMO_FONT_NAME "uidemo" +/** @brief Point size the font is loaded at. The size is baked into the handle. */ +#define UIDEMO_FONT_SIZE 16 + +/** + * @brief Which screen the demo is on. + * + * Three states, three ways of building an interface: the title screen is the + * menu *widget*, the options screen is raw `CLAY()` layout, and the play + * screen is the label and dialog widgets over a moving world stand-in. + */ +typedef enum { + UIDEMO_STATE_TITLE, /**< The akgl_UiMenu title menu. */ + UIDEMO_STATE_OPTIONS, /**< The hand-written CLAY() options panel. */ + UIDEMO_STATE_PLAY /**< HUD labels and a dialog over the "game". */ +} uidemo_State; + +/** + * @brief One scripted input in a `--demo` run. + * + * Key steps carry a keycode; mouse steps carry a position. Every step is + * synthesized as a real SDL_Event and pushed through the same routing the + * interactive loop uses, so the demo exercises the event chain rather than + * poking state behind its back. + */ +typedef struct { + long frame; /**< The frame this step fires on. */ + uint32_t type; /**< SDL_EVENT_KEY_DOWN, _MOUSE_MOTION, _MOUSE_BUTTON_DOWN or _MOUSE_BUTTON_UP. */ + SDL_Keycode key; /**< For key steps. */ + float x; /**< For mouse steps. */ + float y; /**< For mouse steps. */ +} uidemo_ScriptStep; + +#endif // _UIDEMO_H_ From 8f848d80beac32cb00e6e4129eb0056d6d0cf068 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:32:20 -0400 Subject: [PATCH 08/15] Document the UI subsystem: chapter 22, with the appendix moving to 23 docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl owns, bring-up and the arena refusal contract, the frame bracket and where it sits in a real frame, the consumed-event rules (with the one-frame-stale hit test stated plainly), the three widgets, menus across keyboard, gamepad and mouse, writing raw CLAY() with an application-owned press edge, images and styles, and how layout errors surface from frame_end with a captured trace. Every listing is a checked block: one linked-and-run example with pinned output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c, and the chapter figure is a frame out of uidemo via docs_game_figures. The chapter owes and pays the comparison the tutorials earn: the dialog widget's default style reproduces the JRPG textbox palette, the two listings sit side by side, and the trade is stated -- 125 lines you own completely against one call that costs you the subsystem. Neither is deprecated; chapter 21 keeps teaching the hand-rolled panel on purpose. The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits table, and the new draw primitives' rows. Its status-band figures were stale at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at 7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the name list. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- docs/04-errors.md | 17 +- docs/05-the-heap.md | 2 +- docs/06-the-registry.md | 2 +- docs/07-the-game-and-the-frame.md | 2 +- docs/13-tilemaps.md | 2 +- docs/22-ui.md | 382 ++++++++++++++++++ ...pendix-limits.md => 23-appendix-limits.md} | 55 ++- docs/README.md | 3 +- docs/images/uidemo.png | Bin 0 -> 11380 bytes 9 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 docs/22-ui.md rename docs/{22-appendix-limits.md => 23-appendix-limits.md} (87%) create mode 100644 docs/images/uidemo.png diff --git a/docs/04-errors.md b/docs/04-errors.md index 451a5f0..64d0277 100644 --- a/docs/04-errors.md +++ b/docs/04-errors.md @@ -25,7 +25,7 @@ The house rules for *writing* code against the protocol — never a `*_RETURN` i libakerror reserves statuses 0–255 for the host's `errno` values and its own `AKERR_*` codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**. -libakgl claims a band of six starting there, under the owner string `"libakgl"`: +libakgl claims a band of seven starting there, under the owner string `"libakgl"`: ```c excerpt=include/akgl/error.h #define AKGL_ERR_OWNER "libakgl" @@ -37,12 +37,13 @@ libakgl claims a band of six starting there, under the owner string `"libakgl"`: #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 */ ``` -**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 6`, one past the last +**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 7`, one past the last one, and exists only so `AKGL_ERR_COUNT` can be computed from it. Nothing in `src/` raises it and `akgl_error_init` does not name it. Do not write a `HANDLE(e, AKGL_ERR_LIMIT)` -arm — it would catch nothing, and if a seventh real code is ever added it would silently +arm — it would catch nothing, and if an eighth real code is ever added it would silently start catching that instead. | Code | Value | Means | Raised by | What the caller does | @@ -53,10 +54,12 @@ start catching that instead. | `AKGL_ERR_BEHAVIOR` | 259 | A component did not behave the way its contract requires | **Nothing in the library.** Only `tests/` raises it, through `testutil.h` | Available to you for the same purpose: asserting a contract in your own code | | `AKGL_ERR_LOGICINTERRUPT` | 260 | **Not a failure — a control signal.** "Skip the rest of this step for this actor" | your own `movementlogicfunc` | Raise it deliberately. See the note below | | `AKGL_ERR_COLLISION` | 261 | A collision query could not be answered | **One cause only**: `akgl_collision_test`, when the ccd arena is exhausted. The message carries the high-water mark | Raise `AKGL_CCD_ARENA_BYTES` to the reported figure. See [Chapter 15](15-collision.md) | +| `AKGL_ERR_UI` | 262 | The UI subsystem refused, or clay reported a layout error | `akgl_ui_*` — lifecycle misuse (init twice, a widget outside the frame bracket), an arena or table at its ceiling, and layout errors surfacing from `akgl_ui_frame_end` | The message says which; the ceilings are in [Chapter 23](23-appendix-limits.md). See [Chapter 22](22-ui.md) | `akgl_error_init` reserves the band all-or-nothing and registers a name for each of the -six: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`, -`"Logic Interrupt"`, `"Collision Error"`. Those names are what a stack trace prints. +seven: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`, +`"Logic Interrupt"`, `"Collision Error"`, `"UI Error"`. Those names are what a stack trace +prints. Two rows deserve more than a cell. @@ -99,7 +102,7 @@ Statuses raised by the libraries underneath also reach you unchanged. `aksl_fope `akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`, `AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`. -[Chapter 22](22-appendix-limits.md) has the per-function cross-reference. +[Chapter 23](23-appendix-limits.md) has the per-function cross-reference. ## Table 3 — the exit status trap @@ -266,6 +269,6 @@ Four things in there are libakgl-specific and worth naming: release. - [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and `AKERR_NULLPOINTER`. -- [Chapter 22](22-appendix-limits.md) — which function raises what. +- [Chapter 23](23-appendix-limits.md) — which function raises what. - `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion. - The generated Doxygen reference — every function's own `@throws` list. diff --git a/docs/05-the-heap.md b/docs/05-the-heap.md index 713b5e2..5db47c9 100644 --- a/docs/05-the-heap.md +++ b/docs/05-the-heap.md @@ -344,4 +344,4 @@ all visible in `src/heap.c`. - [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables. - [Chapter 6](06-the-registry.md) — the registries the releases clear entries from. -- [Chapter 22](22-appendix-limits.md) — every `AKGL_MAX_*` in one place. +- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place. diff --git a/docs/06-the-registry.md b/docs/06-the-registry.md index 41056d3..37758f8 100644 --- a/docs/06-the-registry.md +++ b/docs/06-the-registry.md @@ -224,5 +224,5 @@ registry's. - [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created, and where configuration has to be in place by. - [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do. -- [Chapter 22](22-appendix-limits.md) — the configuration table again, alongside every +- [Chapter 23](23-appendix-limits.md) — the configuration table again, alongside every `AKGL_MAX_*`. diff --git a/docs/07-the-game-and-the-frame.md b/docs/07-the-game-and-the-frame.md index 18268f0..3c6e13d 100644 --- a/docs/07-the-game-and-the-frame.md +++ b/docs/07-the-game-and-the-frame.md @@ -367,4 +367,4 @@ error-reporting path. See [Chapter 4](04-errors.md). what `draw_world` does with the layers. - [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not pace. -- [Chapter 22](22-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants. +- [Chapter 23](23-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants. diff --git a/docs/13-tilemaps.md b/docs/13-tilemaps.md index 0536676..33afb3d 100644 --- a/docs/13-tilemaps.md +++ b/docs/13-tilemaps.md @@ -37,7 +37,7 @@ why the library's own map, `akgl_default_gamemap`, is a file-scope object in The bounds are fixed at compile time, so the size is fixed too. Every field is present whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same 25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and -changes the ABI — see [Chapter 22](22-appendix-limits.md). +changes the ABI — see [Chapter 23](23-appendix-limits.md). ## Limits diff --git a/docs/22-ui.md b/docs/22-ui.md new file mode 100644 index 0000000..88ef180 --- /dev/null +++ b/docs/22-ui.md @@ -0,0 +1,382 @@ +# 22. User interfaces + +![The UI demo's play screen: a LIVES label top-left, a SCORE label top-right, and a dialog panel across the bottom, over a checkerboard standing in for a game world](images/uidemo.png) + +Every game eventually wants a dialog box, a score counter, or a menu — and hand-rolling +them out of rectangles and `akgl_text_rendertextat` is tolerable exactly once. +[Chapter 21](21-tutorial-jrpg.md) does it once, on purpose, and its 125-line +`textbox.c` is still the right call for one panel with no ambitions. This chapter is for +everything past that point: HUDs, menus, options screens, and the layout arithmetic that +makes them miserable to maintain by hand. + +The layout engine is [clay](https://github.com/nicbarker/clay), vendored under +`deps/clay` and compiled into `libakgl.so`. Per this manual's rule, clay's own API is +documented by clay — its README is thorough — and this chapter covers what libakgl adds +or constrains. The split: + +| clay owns | libakgl owns | +|---|---| +| The layout algorithm and the `CLAY()` declaration DSL | The arena clay allocates from — static storage, no `malloc`, sized by `AKGL_UI_ARENA_BYTES` | +| Sizing, padding, floating elements, scroll containers | Text measurement, through SDL_ttf and the font registry | +| The render command list each frame produces | Drawing those commands, through the render backend | +| Hover and pointer-over queries | Feeding it the mouse, and telling *you* which events the UI consumed | + +**There are two ways in, and they compose.** The widget helpers — `akgl_ui_dialog`, +`akgl_ui_label`, `akgl_ui_menu` — cover the common cases in one call each, and a simple +game never touches a `CLAY()` macro. When a screen outgrows them, you write clay's +declarative blocks yourself between the same two frame calls, with the whole DSL +available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does +both: its title menu and HUD are widgets, its options screen is raw `CLAY()`. + +Like collision, the subsystem is optional at runtime rather than at build time: it is +always compiled in, costs static storage until `akgl_ui_init` runs, and a game that +never calls that pays nothing else. + +**One warning before any code.** libakgl ships clay inside `libakgl.so` and exports its +symbols, because the `CLAY()` macros in *your* translation units expand to calls into +them. Do not define `CLAY_IMPLEMENTATION` anywhere and do not link a second copy of clay +— two definitions of the same symbols, and the loader picks one silently. + +## Bring it up, lay something out + +`akgl_ui_init(width, height)` takes the layout size as parameters — deliberately not +read from the camera or the window, so a headless program can bring the UI up with no +renderer at all. It bounds clay to the `AKGL_UI_*` ceilings, checks the arena fits them, +and refuses **with both byte counts in the message** when it does not: a raised ceiling +without a raised arena is a loud startup failure, never a corruption at frame forty +thousand. + +After that, a frame of UI is a bracket with declarations inside: + +```c run=akglapp +PASS(errctx, akgl_ui_init(320, 240)); + +PASS(errctx, akgl_ui_frame_begin()); +CLAY({ + .id = CLAY_ID("panel"), + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(120), + .height = CLAY_SIZING_FIXED(40) + } + }, + .backgroundColor = { 24, 20, 37, 255 } +}) {} +PASS(errctx, akgl_ui_frame_end(akgl_renderer)); + +PASS(errctx, akgl_ui_shutdown()); +printf("one panel, laid out and drawn\n"); +``` + +```output +one panel, laid out and drawn +``` + +`frame_begin` starts the clay layout and feeds it the pointer state the event handler +has been accumulating; `frame_end` computes the layout and draws every render command it +produces through the backend — fills and rounded fills, borders, clip rectangles, text, +and sprites. Everything lands in screen coordinates on top of whatever is already on the +target. + +Text needs one more step at startup: fonts. clay names a font by a `uint16_t` fontId; +libakgl names one by a registry key ([Chapter 17](17-text-and-fonts.md)). The bridge is +`akgl_ui_font_register`, and the demo's whole font story is three lines: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE)); + PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT)); + PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid)); +``` + +The first font registered gets id 0, which is what the widgets' default style uses — so +a one-font game never mentions a fontId again. Two things worth knowing before they +surprise you: + +- **`Clay_TextElementConfig.fontSize` is ignored.** A libakgl font bakes its size in at + load ([Chapter 17](17-text-and-fonts.md) explains why); the size text renders at is + the size the font behind its id was loaded at. One face at two sizes is two loads, two + registrations, two ids. +- The table stores the *name* and resolves it per use, so `akgl_text_unloadfont` on a + registered font makes the next frame fail loudly with the name in the message — not + dangle. + +## The frame contract + +Where the bracket goes in a real frame, from the demo — compare the JRPG's `frame()`, +which calls `akgl_game_update` where this program paints a checkerboard: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + if ( state == UIDEMO_STATE_PLAY ) { + PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT)); + score += 1; + } + + PASS(errctx, akgl_ui_frame_begin()); + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, declare_title()); + break; + case UIDEMO_STATE_OPTIONS: + PASS(errctx, declare_options()); + break; + case UIDEMO_STATE_PLAY: + PASS(errctx, declare_play()); + break; + default: + break; + } + PASS(errctx, akgl_ui_frame_end(akgl_renderer)); +``` + +The UI draws after the world because it is declared after the world — the overlay slot +between `akgl_game_update` and the backend's `frame_end` is exactly where the JRPG drew +its hand-rolled text box, and nothing about that slot changed. + +Events go through the UI *first*. `akgl_ui_handle_event` takes every event +unconditionally — the same pass-everything contract as `akgl_controller_handle_event` — +and reports back whether the UI claimed it, so a click on a menu never leaks through and +also fires a game control: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed)); + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed)); + break; +``` + +Three rules govern what gets consumed, and each is a decision worth stating: + +- **Presses, releases and the wheel are consumed when the pointer is over any UI + element.** Motion and resizes never are — the game may care where the mouse is, and + certainly cares about its window. +- **The hit test runs against the layout the previous frame declared**, because this + frame's does not exist while events are being polled. clay retains the last tree for + exactly this purpose; one frame of staleness is the standard model's accepted cost, + and before any frame has been laid out, nothing is over anything. +- **Keyboard events are never consumed by the UI itself.** Which menu hears the arrow + keys is something the application declares — the `switch` above *is* the focus model — + not something a pointer position implies. + +## A dialog in one call — and what it replaces + +The play screen's declarations, whole: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score)); + PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives)); + PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + if ( dialog_open ) { + PASS(errctx, akgl_ui_dialog("dialog", + "This panel is one call. Space dismisses it; " + "compare examples/jrpg/textbox.c.", + NULL)); + } +``` + +Note what is absent: no `visible` flag, no draw call, no geometry. The dialog is open +because this frame declares it — a declarative frame *is* the flag. The text buffers are +`static` because clay borrows the pointer until `frame_end` rather than copying; format +your score into storage that outlives the bracket. + +Now the same panel the way [Chapter 21](21-tutorial-jrpg.md) builds it, which is the +comparison this chapter owes you. The hand-rolled version keeps a flag and a copy of the +string, and its draw function does the geometry itself: + +```c excerpt=examples/jrpg/textbox.c + panel.x = TEXTBOX_MARGIN; + panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN); + panel.h = TEXTBOX_HEIGHT; + panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h; + + PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL)); + PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE)); + PASS(errctx, + akgl_text_rendertextat( + font, + textbox_text, + TEXTBOX_INK, + (int)(panel.w - (2.0f * TEXTBOX_PADDING)), + (int)(panel.x + TEXTBOX_PADDING), + (int)(panel.y + TEXTBOX_PADDING) + )); +``` + +The widget's default style is **deliberately that panel's palette** — near-black fill, +parchment edge and ink, 8 pixels of padding — so the two produce the same picture, and +the trade is visible with nothing hidden in a theme: + +- `textbox.c` is 125 lines you own completely. It costs no new concepts, no arena, no + frame bracket; its geometry is four assignments you can read. For one panel, that is a + perfectly good deal — which is why chapter 21 still teaches it and its game still + ships it. +- `akgl_ui_dialog` is one line that costs you the subsystem: `akgl_ui_init`, a + registered font, and the bracket in your frame. The payment buys every *next* piece of + interface — the second panel is also one line, the score label is one line, the menu + is a struct — and the layout arithmetic, wrapping, and stacking are clay's problem + from then on. + +Neither is deprecated. The library's own position: build one panel by hand; build an +interface on the subsystem. + +## Menus: one selection, three devices + +A menu is caller-owned state — a struct you keep, the way `textbox.c` keeps its statics. +There is no heap pool behind it because it owns no texture, no font, and no registry +entry: + +```c excerpt=examples/uidemo/uidemo.c +static akgl_UiMenu title_menu = { + .id = "title", + .items = { "Start", "Options", "Quit" }, + .count = 3, +}; +``` + +Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and +they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the +arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad +South; the mouse selects by *moving onto* a row and activates by clicking one, during +the declaration itself. A stationary pointer claims nothing — parking the mouse over the +menu must not pin the selection against the keyboard, which would otherwise fight it +sixty times a second and lose. + +`activated` latches until you clear it, so the check lives wherever reading it is +convenient — the demo reads it after the frame closes, which catches both input routes: + +```c excerpt=examples/uidemo/uidemo.c + if ( title_menu.activated ) { + title_menu.activated = false; + switch ( title_menu.selected ) { + case 0: + state = UIDEMO_STATE_PLAY; + score = 0; + dialog_open = false; + break; + case 1: + state = UIDEMO_STATE_OPTIONS; + break; + case 2: + default: + running = false; + break; + } + } +``` + +## Writing layout directly with CLAY() + +The options screen uses no widgets, and exists to show that the widgets are a +convenience rather than a boundary. One row of it carries every idea — hover styling +computed *inside* the declaration, and a click paired with a press edge the application +tracks itself: + +```c excerpt=examples/uidemo/uidemo.c + CLAY({ + .id = CLAY_ID("options-music"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_music = !opt_music; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } +``` + +`clicked` is one application-owned `bool`: set when a left press arrives in the event +loop — *before* `akgl_ui_handle_event` can consume it, because a click on a UI row is +always consumed — and cleared at the end of the frame. The widgets keep an equivalent +edge internally; a raw screen keeps its own. Everything else — the sizing model, scroll +containers, floating attach points, aspect ratios — is clay's DSL, and clay's README +documents it far better than a restatement here would. + +## Images and styles + +An image on a UI element is a sprite: set `.image.imageData` in a `CLAY()` declaration +to an `akgl_Sprite *` ([Chapter 10](10-spritesheets-and-sprites.md)) and its first frame +is drawn stretched to the element's box. Two current limits, both deliberate scope +rather than accident: clay's image tint colour and `CUSTOM` render commands are skipped +by the executor, and per-corner radii collapse to the top-left value — the widgets only +produce uniform corners. The mouse cursor stays the operating system's; a game that +wants a themed cursor draws a floating image element at the pointer, which makes a good +exercise. + +A style is one struct shared by all three widgets — colours, padding, corner radius, +fontId — and `NULL` means the textbox palette described above. There is no per-field +defaulting: a transparent fill and a zero radius are things a style legitimately says, +so copy the default and change what you mean to change: + +```c wrap=akglbody +akgl_UiStyle alert = { + .fill = { 120, 24, 24, 235 }, + .edge = { 240, 236, 214, 255 }, + .ink = { 240, 236, 214, 255 }, + .padding = 8.0f, + .corner_radius = 6.0f, + .fontid = 0 +}; + +PASS(errctx, akgl_ui_dialog("alert", "The reactor is on fire.", &alert)); +``` + +## When things go wrong + +Everything here reports through the ordinary error protocol +([Chapter 4](04-errors.md)) under one status, `AKGL_ERR_UI`, and the message says which +refusal it was. Lifecycle misuse fails at the call that misused it: + +```text +/home/andrew/source/libakgl/src/ui.c:ui_widget_ready:850: 262 (UI Error) : Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end +/home/andrew/source/libakgl/src/ui.c:akgl_ui_dialog:866 +/home/andrew/source/libakgl/main.c:main:13 +/home/andrew/source/libakgl/main.c:main:16: Unhandled Error 262 (UI Error): Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end +``` + +Errors *inside the layout* cannot fail at the call that caused them, because clay +reports through a void callback with libakgl nowhere on the stack. So they are logged as +they happen, stashed, and raised from `akgl_ui_frame_end` — the earliest point the +protocol can carry them — with the first message and a count of the rest. A frame that +fails is one bad frame: the next `akgl_ui_frame_begin` is legal, and the executor clears +any clip rectangle on its way out so a failed UI cannot leave the next frame's *world* +clipped. + +The ceilings — elements, measured words, arena bytes, fonts, text-run length, menu +entries — are all in [Chapter 23](23-appendix-limits.md), every one of them overridable, +and every refusal names the number to raise. + +## Build it and run it + +The demo builds with the library: + +```sh norun +cmake -S . -B build +cmake --build build -j$(nproc) +./build/examples/uidemo/uidemo +``` + +Arrow keys, Return, the D-pad and the mouse all drive the title menu; Space opens the +dialog on the play screen; Escape backs out. `ctest -R example_uidemo` runs the same +program headless through a scripted tour of every screen. + +## Where to look next + +- [clay's README](https://github.com/nicbarker/clay) — the layout DSL itself: sizing, + floating elements, scroll containers, and the debug tools. +- [Chapter 16](16-input.md) — the control maps that own the keyboard and gamepad once + the UI has declined an event. +- [Chapter 17](17-text-and-fonts.md) — fonts, the registry, and what text costs per + frame; a UI panel of text pays the same immediate-mode price. +- [Chapter 21](21-tutorial-jrpg.md) — the hand-rolled text box this chapter keeps + comparing against, in the game that earns it. +- [Chapter 23](23-appendix-limits.md) — every `AKGL_UI_*` limit and the `ui.h` status + cross-reference. diff --git a/docs/22-appendix-limits.md b/docs/23-appendix-limits.md similarity index 87% rename from docs/22-appendix-limits.md rename to docs/23-appendix-limits.md index a996d61..d0fbb03 100644 --- a/docs/22-appendix-limits.md +++ b/docs/23-appendix-limits.md @@ -1,4 +1,4 @@ -# 22. Appendix: limits, statuses and properties +# 23. Appendix: limits, statuses and properties Three reference tables that no single chapter owns: which function raises which status, every compile-time bound, and every configuration property the library reads. @@ -148,6 +148,9 @@ What the statuses mean is [Chapter 4](04-errors.md). | `akgl_draw_circle` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` | | `akgl_draw_flood_fill` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, `AKERR_OUTOFBOUNDS` past `AKGL_DRAW_MAX_FLOOD_SPANS` | | `akgl_draw_copy_region`, `_paste_region` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` | +| `akgl_draw_filled_rounded_rect` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` | +| `akgl_draw_arc` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` | +| `akgl_draw_set_clip` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` — a `NULL` rectangle is legal here and clears the clip | ### `tilemap.h` @@ -176,6 +179,22 @@ What the statuses mean is [Chapter 4](04-errors.md). | `akgl_controller_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site | | `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` | +### `ui.h` + +| Function | Raises | +|---|---| +| `akgl_ui_init` | `AKERR_OUTOFBOUNDS`, `AKGL_ERR_UI` — already initialized, or an arena clay's structures do not fit; the message carries both byte counts | +| `akgl_ui_shutdown` | *nothing — idempotent by design* | +| `akgl_ui_resize` | `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` | +| `akgl_ui_font_register` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` past `AKGL_UI_FONT_NAME_LENGTH` | +| `akgl_ui_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` from a resize event | +| `akgl_ui_frame_begin` | `AKGL_ERR_UI` — uninitialized, or a frame already open | +| `akgl_ui_frame_end` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` — no frame open, clay layout errors, an over-long text run, an unresolvable fontId — plus whatever the draw primitives raise | +| `akgl_ui_dialog`, `_label`, `_menu` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` outside the frame bracket; `_label` and `_menu` add `AKERR_OUTOFBOUNDS` | +| `akgl_ui_menu_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` | +| `akgl_ui_execute_commands` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, plus whatever the draw primitives raise | +| `akgl_ui_measure_text` | *cannot report — clay's callback signature; failures stash and surface from `akgl_ui_frame_end`* | + ### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h` | Function | Raises | @@ -196,8 +215,9 @@ What the statuses mean is [Chapter 4](04-errors.md). ## B. Compile-time limits -Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight -and `AKGL_CCD_ARENA_BYTES` are overridable**, and even those have to be overridden for the whole build — see +Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight, +`AKGL_CCD_ARENA_BYTES` and the `AKGL_UI_*` family are overridable**, and even those have to +be overridden for the whole build — see [Chapter 5](05-the-heap.md), "The ceilings are a compile-time ABI constraint". The rest are plain `#define`s with no `#ifndef` guard: changing one means editing the header and rebuilding libakgl and everything linking it. @@ -347,6 +367,31 @@ keeps instead of recursing per pixel. A region needing more pending runs at once `AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape needs a few dozen. +`AKGL_DRAW_ROUNDED_RECT_SEGMENTS` is 16 — triangles per corner of +`akgl_draw_filled_rounded_rect`. `AKGL_DRAW_ARC_MAX_POINTS` is 64 — the vertex ceiling one +`akgl_draw_arc` spends, two per step along the curve; the step count scales with the swept +angle under it. Both size fixed arrays, so neither is a per-call cost and neither is +overridable. + +### The UI + +Every `AKGL_UI_*` limit is guarded by `#ifndef`, so a consumer may override any of them the +way the pool ceilings are overridden — for the whole build, before `` is seen. +`akgl_ui_init` checks that the arena still fits the element and word ceilings and refuses +with both byte counts in the message when it does not, so a raised ceiling without a raised +arena is a loud startup failure rather than a corruption. + +| Constant | Value | Bounds | +|---|---|---| +| `AKGL_UI_MAX_ELEMENTS` | 1024 | Elements one layout may declare | +| `AKGL_UI_MAX_MEASURE_WORDS` | 4096 | Words clay's text-measurement cache holds | +| `AKGL_UI_ARENA_BYTES` | 1048576 | Static storage clay lays its structures out in (812544 needed at the defaults) | +| `AKGL_UI_MAX_FONTS` | 8 | fontId table slots | +| `AKGL_UI_FONT_NAME_LENGTH` | 64 | Bytes per fontId slot's registry name, terminator included | +| `AKGL_UI_MAX_TEXT_BYTES` | 1024 | Bytes one text render command's line may occupy | +| `AKGL_UI_MENU_MAX_ITEMS` | 16 | Entries one `akgl_UiMenu` may carry | +| `AKGL_UI_DIALOG_HEIGHT` | 56 | Height of the `akgl_ui_dialog` panel, in pixels | + ### Audio ```c excerpt=include/akgl/audio.h @@ -383,8 +428,8 @@ budget and was wrong by a factor of a thousand, blocking for roughly sixteen min | Constant | Value | |---|---| | `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) | -| `AKGL_ERR_COUNT` | 5 | -| `AKGL_ERR_LIMIT` | 261 — **the one-past-the-end sentinel, not a status** | +| `AKGL_ERR_COUNT` | 7 | +| `AKGL_ERR_LIMIT` | 263 — **the one-past-the-end sentinel, not a status** | ## C. Configuration properties diff --git a/docs/README.md b/docs/README.md index c47e41f..a116205 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,8 @@ per-function reference, build the Doxygen output with `doxygen Doxyfile`. | **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings | | **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Thirteen steps from an empty directory to a game with gravity, a jump, coins and hazards. **Start here** | | **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | Thirteen more, for four-way movement, NPCs, a text box, a follower, and freezing the world | -| **[22. Appendix](22-appendix-limits.md)** | Every limit, every status, every configuration property | +| **[22. User interfaces](22-ui.md)** | Menus, HUDs and dialogs on the clay layout engine: the widgets, the frame bracket, and writing `CLAY()` yourself | +| **[23. Appendix](23-appendix-limits.md)** | Every limit, every status, every configuration property | **If you are new, read chapter 20 first and read it in order.** It builds a working game from an empty directory and teaches the library as it needs each piece; chapter 21 assumes it. diff --git a/docs/images/uidemo.png b/docs/images/uidemo.png new file mode 100644 index 0000000000000000000000000000000000000000..67e5fe16aedb12a6ff4c0233e4fb37566a77f6d7 GIT binary patch literal 11380 zcmeHtc{rO}`)>NRYVVqM=ayo3+p3|cxx~;(wG^$HAVpD>wA4(})-JoPp=zq4X*V&? zQ-lbrXiYH>L1K(Cric(Czt#Qy&Ue1^o$ov6{ClozyWW3zFY8_FS?hV${oMEc@{W<7 zz#-v75C}xz*3IiC5Xj%2LLmFbfA|~tOLdt+83gh(c3j@}L0laDy|3?rs1Ks&?vJ*@9mkqveMIyG zJliE7HGbx~zDX5#r9HV;#rk|)$GvDaOlLfmr8PV1v!aFU93xtU?rfSjxpn`cThbdN zrltQuGjM(STO95p(r(EN0=ZLo{vHH!;7&aR^25ic`yfAE-nSp}^V7c_gj|O3@I#(L z@?P+rJOOj+sT$HFKx0xqj_c$}%`)zTA*7I+m|{2o5}&)2a@D|}T>s+`hfaY29J>f2 z@z8*+dAi)VPwN9_R>Js$=5`Nl`%Lk%=KTjD;|f>5f4RPrKWUAHRPkTVjuw@Wx4kK= z`X==Z;=X?R4di)jqI`^>Z^5x+%3mH?^k$NZ;H-%^XO(bBosY8kug>UQ1*~b-GyNPk z6Cnw_C(b*(~n(COxv!6CQcG7bYd;I$V0d zM|EBZ9WW7R+$?Lm6%#Ar+Mj*bbD@u=y%%6MSeP!}%H(+}mGmv$ zBX8aNbG6-io;JOfig>|6T)FbhBo~e|TB#FPcswZ;A%s~N-6k<;QS+40rI$y)h39oe zFC3R1QgJu)pZtRIT?k;#A2q*UxR>SEu9jn2B*)pQQ$g(#rdr}ga9pR`?Zq1#upJhr zIHa&JnDtb{*FE077HcABe1%E~T%>it{g>}cV|#j6MhSriDa6p7XFIGinBRsHf8C}{ zo+wO^Ha)#yTK;-Y2j?|ruCL;?Ys*~l0z0A|$_~+QW%lRcWZ@?eC4ZzRryLVzyL+#{ z2xJV{6GP}-pW+M>L*^G3>60e&6SfYJ07tOnf}-Tm#eocPJTfwDbsmi)!VVf1*^kk; zXFWW8b~ZzOdNb1=Kdvz`MTV?@tB#W*f!%e9HGiPTl(FpbqiYZzbcb8oyV0gMZpY$v zqqm!_t*vW!yk;vraGqoDpUmi!TNB`$D>~g}t|!}O7u@?Et@P)$Nnt`9_JRx+V~Gb- zV;{>atli8qsyp!rgjG{(ObtnKS}(=p1NEWEm1=hU(9jHH7JYYJ66Lfpr#>+;wV}gc z@U1f#70!RFUrAm|_n?zMkhI~Uvz^!2qdv286`mtX>f6sva=q;2g<^7V-D+XtcQ!@2 zQ{pM7tkV(_Dk>^`9UMMMj@aS~lTuRJCK|Igpwv+RTwOjSnByOR{4z`8PO_PZb_CK* zYeCIH^4DJn@+`|78a2q6G>vV$8b8JelIywtT*@d_D`czh%S(>-_Wg%9Z$^N_1NL)! zn}@YLW~o0sWL39eGPQ62)uapCib_g(8-ozY2kGXRLKP1SOMT8puS(-favVt?DqrNT zpCe=GHCJY0S_IS3Fe|XZ5BY9y^(_uFqS9bOZoJcqd3PJ_puTi6X>YIKZQPVce=dr{ zCJ72|l7nQE^8%-${8Uv3L{vlUa+!&Vp0_Z@(pkol^s!)NEsjx^5z3WhUtU@o#O$|J zMN&+s5uc(&Tu&P%E4wu$$_DfC@dY#AA3L#W(-K{DU02rz4wpP923}stq1@~za>QLp zVs1`OwE=6H!)2DIg*-6X!J0ZGz3=5TaaIMaNKo*2yvgt|zR7-|p}`*N%SAl(QC z`jYRCb#|&sN%@0gmaLSddC8J%3Zsb(-wWLiFR~LZ9DIw{&yhBl77`onSiNPcGzfqD zb`y;!{Mhz8fv}6-^Fg;nqsm;1+=qLzY$~LMw7HrOx}G$0D>stf`1mj^3dp7I{U=;q zv+C{$t$%8k?7ga{K0Fhu#T@V@%E&ad6(F!NAa@+99m znY=k$I_npyD)QY>_QqL1zsfi%w9RxzUWOC#Fl)tC2D?P36ZhETujPr!adC@_iv{tm zt*w=h3QgQctF#T0nirAcs_0s*AM(onpRu8NFK71!J3#9%3uiu%?)dN`~GE0$8F;}r+0(V3*FtX?ZMY! zD+G)CbV*T0R2R9sx3{*q_zp~*x$%V5-p-y$8adHvuFrFzTuiHaHpeu@D2=Qfyt$*F z<2iC7RioltYFpx~%lpK2K7?$q_xsUpD&2Y=8MNz4@+!`)^;K2F;ijdfrF@4Dtus1; z8reyR&SX{3;)!D-A|f6h#E1j+D`{)#ixnPfq}1*1)n1wYTyyK%pc^;8A`(oJiNu4+bhjntGh^p}9fxnvCNj}{6=NEky2kJ*(_x&(B03 zU#+22RfKyuyhBSt7g+bE4@t2rUZ2{tg2T}qX2U@NcSDT!&`3>TlBEioYr$dHGCS0$ z?&5xmP;Hd|<;y8uchDd&3v5*Apk(cFq!bmG6Sf!j*Y`F|cdMLpaZ6E^4HsCvn`hyR zK&-srliJ;K(zhK|Baeb&M8z`@&%;K71Iz@ZE@6cD__H z$+_8qC9vgq(aV+3yb8nX`hxd)W08#ls5wL|o4yma%emD0b)10WIiHk!F2 zK33zu`c~3(i7sP{_jY0~6c{9-+-L)GcET78;-|xhmHi&Bz;VmVW5JOppYFR1bzYwf zn(O*T3{9s5ucWQ6*0;CkkJSjFH@XjToKm@4ErXkzo3XJDaZ*B;(0f^5zvj4h?ppO! zdr>>zDY=@PnN4RJ!i3IjC8>h7qHa;kv*9IjVh63!F*|^6M3o;jzdY(H-fC+w z0GD=#j)M#C7_{$~NRqj}9U%Za6uBlTB`Ip4ui-+wq5YugRfmklLpes%zHztjlN+zE z+aQL#**sY2=&BW9hQN8%pekKT4I_7U8C4{m7?~J+wIi;pz3u&%*B=4GyQevRL_zE# zK!BJ`&8d9`4w?Y`iAns4&Hf_}tWOsc+WPTxKs~^G=xRoQvObxc`VW+N`DK{G0Uj=X zXg+4d^t`6-eh4jKHG8j06p2O#cK#bg`SeK3e$=Ra{yBjE{yvfa7v!jaMriBt4&m6J z;8lvNJfjXU<9|cc|A{aEWexRz&RNR%1L5)qC9u-}1I+z%j7Z+we}~6>(|-;BPd&h2 z!TIat{8I$}N~@=kziQ53Md6=P;$K;lzk>7sS8(q2hs;d(-tTlNA04??(^Z(9d03gEZy(4+~A@xqv6DN!7(0S9- zTh+d`?Jq$y!1;u%Pge_Ee>y!KKyR5oE^ zWT~X4Tm*Oc)3q8EkAZ9x$rb>io15r1p{(0=RF;Ygyj@vO$jCQ`-9Of{6ha2t=$^5*xT{(i(o_Gl@}-AG=s+_$eF=8Uz7m{`EZ+{v+O-&c|Ru%M0C4vp1t z;{7-u!{sPNofDBrA8+s7OvADS6PVwHzP|~{HMA%7zq*K86bbz8J>mETys72v!%ZGgZADFcyHZqK5cf2TUR=qIa4`lfi}y(uq&pJ_8O3;ovY)?iR+xJ2h_z z3JL9QO_@J?7B-MStoqoI3&z2Yh)SNXt5tBgO7$AkXlgF}f&WbTL+jnu39Y?N+|Qw2 z+ji8$5}$*ahd#jk4YQ2)XBpX@Ik8F3xW!@On~^++|CL7j#C7{0K_|Ua$Z$5mOf>57 z3y21St+^6LQAmZ;cFeuJ_LN9NgV_^A=e7?h4#nF$=-k<}3YwZ<2L_ryOQxiHgdnZf ztu7Kf*c%{IY}(tK-rwIHP+R{&B~Zr78)K%Q2J`>cl4WmZb{~bha{WsHnu{uPDd=-a zpq2MY;_pSEvpS=1R3vLV*IP>L#vjC22@*OEfhLv zYEme4>ZbxO-1Y4ye`{wN738ytzFqZ`id%8BVb94O?@F#inR`3pGcz;PYF|reg`_L4E%G%mX{hS0DoU&WbNB@=Y)Q#fud+I|U z{8ChFg3Li)f2-nqV*>@jf3g;yl+hZ!n8DYY1?;XOBuHSbAc8h}&&KonDV|H-dX4FgT_wNF( zLw(mK6PlwiN}6|Uyz5F!N^a?ESXAU1SPgl={Kayud#BR;HL9yM080a-p6zISVClZa%i>pj6RL<#D94d8yOj_pwKv&KV0d*(#?0$#Gr<9 zEy8G_4x{cv3}!(rEoVV(DlF{07U9QQ%b8uW?QU+ifzB)~nN4>g?x&=s$xw)C6Lh#@ZB>_h4nev;}6XtEec( zSPRUzB}Qt|6!4Q}febN@v5(gaz5L?hV#}gi4h}`WOI!M8W*HR~=8luGx8hDb(9^Ix zy=>Xc3YXl$UkS4r7#NT;-uq~*j?x_7RXQHW$}2B_A|<7VH49>k`m?0cx(0>y&tv$&ta6XR=K`<94ICv-k7D=n=+ zl%ogo^V_TY0Jl}+D{}PoX^s0gaiGqpX@{bghVX!1RYuo_v&w>5%QDzAqN1gx%knnL z3K9|?(_gLK+~!fJ4yoI7t~6B-d0shdMO}2O@((L71~Bn1OE|kg7e~#D{x3x zX{@xx6Xgrz5vWiZbMKx(=FhwlhiIdRV(h)VD7ofvKzdFtEgd^GlVe+R$1}YPv+fYq0d{YsRirOxic9{r$t!SbpoR^D>OL2JbyODd+1v<%F7%p$1}~H27kChlkah zyvq7YmRa*_>~s0#3N6m=dl{^^597$um+HB@-7vPJ}V`Kb8b7i0sZu;sL)p3a7U+F8TWim%X-pQPcH(X zAbzTcdrv%c|J0<5h|#y!R=M-%zmiC5saD}T32U_u-%xubG|EI@#jWQurYG0jlu=YV z6H{6$JN$jy(swJZIV;C{$<<%N9Lih-jvk>MJPby-=6&N!vniK3+9_Q zf@v_Wq};Lywr{xXB;eLRf3^?a99{_H_3!^sy~bpAFXuOd6e+N;^&GFqA<(r^NtP8B z1@GnHCq$1Q-@doL$2o&Fn`$Px4SxMPg(t9XxVQ|wR~ey%;(UMtQQ;9B9>L4r!PyW3 z;=b`EF0i%dmdDyFJn9yP%Y5$L8+osyrST*n@XPP3K*gDvJ(=AzdZ?L|5sOEmh=|aN zk1mEnK9v>><{+<#ayGc8?}5bt27H$T2ddP78Z`tyZJWW*l9>u_e9H*|;z)!Lx5gOr zM%B}%FUK?;NmY9tay4^T%sKJGKf(!3cpqd3?XL~K+?J>floes+Z1ej+%Gd2Wj#b-J zQ9DQgq%GIK?%KYksi}EhBj}EwUuU=gtb!DmfyEkQh?f-C>6Qr33KS=LZEChZdjeed zbxTW2o+F#dwk|+)1r@U~+}Gc~=xt&m5CQk_K7*R40Q_nHprz;1e#+6wDLvkuN{soo zA_WETz_l~?^XJdq>A@h!^l#y{om*W+)RvVQr7reV$BtC|29QP@!fJv7%nS_-5O{A! z7N&5}B^fPqiW=uUM7r^-r0615ry5*@k_MSWBbHU(GtCSA@FY1->COCyEE$R5Yu1$* zaJ(%5soNqR)B5cjhrJl~9fLPKe_UC~4ZV~^<+Q$)6u|&!5KmQ=!4kQ9pWjM8ZfFp_ zdUcQ8YiyaG&b7b)qpZ!=(ujY4IU@do_6rDvU;eunK+|z|05^R&_we@smn@9nkR?iv znF6x?mIkjF|4_rl*I>V{J()r=VF08t>A1Y+7)+g0%awW0ZTaOm<3xadeMRD4r7;6) z5As7|5@s<)X&6LtFw4J3eJ#+} z)K8sLSKs3{cS8M67+?8ci~vuwa-T{Wi+zk?Taz1L9kX+DZ{o}Izk`C?@*^KV&D5I5 zyjwDYJeB%tYF-cGlAm!`NBoaE4m~P0H5Dj5uCenq{^FaA*8!_Co#IivxK_}9e2uLT z5={2Oufr7~yC_X_p48-IY|OH?5dbdCrQyKkQAh2>Hz_Ip44Q1{+!2ZPU3UvUT<+a; z4YFmq&XXD3eaIH)d|N<05dKzbS)C%t-QkW7Nfl;u4;E2~sJoWp3^aGgCNr#Az}6+t z-;!w^B0$dY{QBk{&=jFqP0d`RcU?GLK5-aq#58M9$+`8jIQn+CoXAgI8q(6<%cCmS z!k?t5c<9cwCIm4TfY6o*uy`ynpQV?9WC z*u`*hfC%bF0qDelfDQBq!!$O_@pVZ3(=H%${`%{#Mzbd<31u!EjVEJa2;-w7u+g@* zbYo0*iBm8Ta7*3IljtKXq0>j4)`iV;Oagl{_P`AaiqerIM}*E?S{rsfs^Ke~y`mri zPe?se#3^-ZLF>4a&SarK$U3 zfVV(Xb9E}l66jGjWiBH?`DHfL1NBrd>it-;gD1V|CO|R0nF5rmH4)A!4If$;kc5AW zh(R+t4)gHv_|SH3?C|()lBN?PJ$wf!uWV*;+qd~-06e7r?v=^~nzVSx#npxvec2OxoiI@V zL$EPPIq_sX1Flo|gM@J?=HEhbK9Q%rlevCvKrbgM)z>NnKkI5-w*Y&CS# zlhHmZat`@Dl#$|8Rti#(msjZZOKagHJr1kmh2#x@+5Hv=psIel_r^auw@pR}t*qhd zc#`qY>049go_HUboFP}ZVhKi%Z!Z=_9 zKy#6mVJm*|=V}04d6Sd^9^pVLm`)qadwY9VAl>BWb5_dB;hUSsM@Mac`Ni>dyP|g# zkgou=F#fPR4U5p7BOiwGC@v z39sCk@4<5P#0#=*@s5ukMey(qc#c(Drt}l={xM=j^6Vv8?P}zYAM$YPD zA&3OnSWqh$9RPR-e8|pLpO0T*wm{VU(o)9BViRdu*|Bfm?m`njpjyUiUzxNvG*BMx z7ZlaV;i72m+skA7Uqzj3F0y3;}v>+y(;K!JBAIy z6_v{3O~6YT=$$2%p6bnwc?KIug>}K(Gbd%K(tOA(KBw%{fwVr?gROmSfCaMY(D1OB z`ubu}j5t^OVzK8;>v1Htv-p0kwl)(8lxK84VX7XWV7 z!F2_dyZ5)UC*aVEkigH)QDxC$T25AHfdk=$?E81Iu`1rv`2hOs)X|Qf+mI;@;rPNh zW4CoFCHIF0{`6(2YiOL3zZW+WLML~4jWu7t5iSTQkM?)=S;iP~nBOu$KLRHeLjqzl zGN$xmJ}8v_A`wY|RS$VgPj5u-Wn~Wab|^kfki>mV<}Zp>RjrEaRU-eMf%9^nD+AhR zU!|V>cxeI7L{ci$4YHbX-gh6hHYF!sSjF8h7F8X@%-pMqieiG&@%C->Hap0FzQ;aT z)dV^-WlHVs)%wM87g&|zP2_9)s`%mgqMj-?Ne{B#o$z9SX+`kAEGa7sok?&>zrnOE z{WaIF#-5Gd4{p_Rw<(b`ngo84{BNC|P&bQ`!JDvcpGVmrN#2ihtfrqkk4S^UsC3$1 zg3fAhzd-jr(CBd&UmC}zo3fnYMSf9YqdJh)OES!qVk3+;CM|7hejc^Gu0oemQd;ax zyWQxYDtKng515T4ZhUEOmJ`<@lFCSXto`X^^*4U{832y#Ih(Ud)5=K7O>Qk{%)Lgp znpQ&$pjfl+B+hLwAK=mf6+FQNP!ca+>Yb1!TUF$~E@KqQDmq@@8YsA>E4=6c-4wPX zyaOH@&?jo=8K(TPlCDHFC!78-FrbTc{IjylZ3@?JIqs(m+f}^@TA_qCa;yIvh8Jje zSQnYJc6WaQ#vfP>b$4nDxC=lQ!z)9sV2%j07g`f~%awwsl6W9ctlT(Y^MGAS;LO(M zVi9g_J$E42yw|zykMuu&VO|F2Irhenr`5bDz_0;q8NQQWhTpw&Ab(l~$Qi5vR2!|G zhfDyf+?#->D*bp_^3|&v2M&}Mn&$-pBT#1=cYXX;4`)YRKL@n;K7ZO0O2F-d?;sK> zd?zCLet2M-9$tg=y5;`+GyVGyU=RUA;F*ShYMI%=r018nD+1c@4 zOjLuQ3HAx^lLQP5x9v|rGUA;z$ALHN^l27I=jCO4Nm*H7w&Vl}0Po6|FZ^T06N{lc z(H^GFQ7O1U#^}ENpJ^mSN@5~#s!e|ViUWYYpUs2;5NDYl{{12?Ei1!$j=0ju{|cLd z8|#mo<8$3g!JB826`X#Lii$GH4c*ZO3~l7YG85^4kG}_8fE7&u*xNX4Q9#pl3=k1pV|*v zFkq8VstYv~R?HXy#4l*0hI@Mp3{y^kA%TDI0Rj@Bu^N8tbzlCxf)DKu;FoLsSLh@D zn&({Rui0)r=UH4@de{;h$@6><&)Qf+Z zW1p6OXZa!R8y8!E8v%0SkAGu#_`6cjOytu5$U4@O7Xqo1kwl$*atk9QH_y2J-ADV~ l2*F_dUvz~(6{d0SzPWcJiIwqfmm%QimaftD!mIb5{})7HPTv3k literal 0 HcmV?d00001 From f772da7296287e444226e3b8cf1cb18a8f528c4e Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 11:34:12 -0400 Subject: [PATCH 09/15] Record the UI's paper trail and bump to 0.9.0 Two TODO.md notes the UI work created. The text texture ring cache (plan item 2) now has its second consumer, and the bigger one: the UI executor draws every TEXT render command through akgl_text_rendertextat, one wrapped line per command per frame, so a five-row menu re-rasterizes five lines at 60 Hz against the original case of one HUD line changing once a second. And akgl_ControlMap's mouseid/penid fields stay dead deliberately -- the mouse path lives in the UI subsystem because control maps and pointer state are different consumers, and the note says where the work lands when a game wants mouse-driven actors. 0.8.0 to 0.9.0: a new public header, a new status code, three new drawing primitives and a new subsystem is new API, and while the major version is 0 the soname carries major.minor -- libakgl.so.0.9 is what makes 0.8.0 and 0.9.0 unmixable at load time, which is the honesty the bump is for. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- CMakeLists.txt | 2 +- TODO.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d07f9b..85e9aca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) # The single source of truth for the library version. It drives the generated # include/akgl/version.h, the shared library's VERSION and SOVERSION, and the # Version field in akgl.pc. Bump it here and nowhere else. -project(akgl VERSION 0.8.0 LANGUAGES C) +project(akgl VERSION 0.9.0 LANGUAGES C) # Memory checking reuses the suites that already exist -- `ctest -T memcheck` # runs every registered test under valgrind -- rather than adding programs of its diff --git a/TODO.md b/TODO.md index 2bd22a2..aeb03ef 100644 --- a/TODO.md +++ b/TODO.md @@ -1273,6 +1273,15 @@ engines spend the same frame**. cached") with the raw-SDL control row the perf rules require. Budget to move: `tests/perf_render.c:391`. + **The UI subsystem is this cache's second consumer, and the bigger one.** + `akgl_ui_execute_commands` (`src/ui.c`) draws every TEXT render command + through `akgl_text_rendertextat`, one wrapped line per command per frame — + a menu of five rows re-rasterizes five lines at 60 Hz whether or not any + of them changed, where the original HUD case was one line changing once a + second. Abstract-on-the-second-consumer says this item's time has come; + the key already fits, since clay hands the executor stable text bytes + between frames. + 3. **Non-raising sprite lookup — target 10, a design change.** Give `akgl_character_sprite_get` (`src/character.c:84-94`) the companion that returns `NULL` without raising, then convert the three sites that raise @@ -2196,6 +2205,18 @@ a live defect -- but nothing rejects it, and `max_timestep` is caller-settable. `renderfunc`. This item is the half that remains: one uniform `scale`, with no way to expand a single axis. +3. **`akgl_ControlMap.mouseid` and `.penid` are still dead fields, deliberately.** + The UI subsystem (0.9.0) brought the mouse into the library, and it would have + been natural to wire these up on the way -- it did not, on purpose. Control maps + translate device events into *actor handler calls*, and the UI wants absolute + pointer state per event; those are different consumers, and building + gameplay-mouse machinery into `src/controller.c` with no game asking for it is + the abstract-before-the-second-consumer mistake. The mouse path lives in + `src/ui.c` (`akgl_ui_handle_event`); when a game wants mouse-driven actors, + *that* is the consumer these fields were declared for, and the work lands in + `controller.c` then. Same for `.axis`, `.axis_range_min` and `.axis_range_max`, + which `akgl_controller_handle_event` has never consulted. + ## Found while writing the manual Twenty-one chapters and two tutorial games were written against `src/` rather than From f2efbdf1b7ccb5ad9e2154a2ef833175ba7d4b14 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 13:06:22 -0400 Subject: [PATCH 10/15] Fill the gaps the chapter 22 weaker-model reader found The AGENTS.md "Testing a Tutorial" pass: chapter 22 was handed to a reader on a weaker model with the library tree stripped of examples/ and docs/, and they had to build a menu/HUD/dialog program from the chapter alone. They built one that compiled and ran -- and reported success their own screenshots disproved, which is why the protocol says to verify: their synthesized key events set only the scancode, akgl_ui_menu_handle_event matches keycodes, and their program never actually left the title screen. With that one line fixed, everything they had built from the chapter worked. Three findings survived verification against eight reported: - The chapter never stated its prerequisites, so the reader burned most of the session on library bring-up it does not cover and invented goto error handling for main(). A new "What this chapter assumes" paragraph points at chapters 3, 7 and 17, and at the demo's startup() and main() as the complete reference. - Nothing named the menu's exact keys, or that they are keycodes rather than scancodes -- invisible with a real keyboard, fatal for synthesized events, which the demo's own --demo script teaches. The menus section now names SDLK_UP/DOWN/RETURN, the gamepad buttons, and the key.key trap. - Verifying their dialog screenshot showed a message wrapping past the fixed AKGL_UI_DIALOG_HEIGHT runs visibly out of the panel. Deliberate -- same as the hand-rolled textbox, and visible overflow beats silent truncation -- but undocumented; akgl_ui_dialog's header note now says so. Rejected for the record, per protocol: akgl_game_init already initializes the property registry (src/game.c:219), and the font they "found" in a different directory was the build tree's file(COPY) of tests/assets. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- docs/22-ui.md | 18 ++++++++++++++++-- include/akgl/ui.h | 7 +++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/22-ui.md b/docs/22-ui.md index 88ef180..d79ef20 100644 --- a/docs/22-ui.md +++ b/docs/22-ui.md @@ -28,6 +28,15 @@ declarative blocks yourself between the same two frame calls, with the whole DSL available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does both: its title menu and HUD are widgets, its options screen is raw `CLAY()`. +**What this chapter assumes.** A program that already brings libakgl up — the +`akgl_game_init` startup order from [Chapter 7](07-the-game-and-the-frame.md) (or +[Chapter 3](03-getting-started.md)'s smallest window), plus one loaded font from +[Chapter 17](17-text-and-fonts.md). None of that is restated here, and the UI adds only +three calls to it, shown below. When something in *your* bring-up fights you, the demo's +`startup()` in [`examples/uidemo/uidemo.c`](../examples/uidemo/uidemo.c) is the complete +working sequence to diff against — including the `main()` shape, which the runnable +snippets in this manual deliberately hide. + Like collision, the subsystem is optional at runtime rather than at build time: it is always compiled in, costs static storage until `akgl_ui_init` runs, and a game that never calls that pays nothing else. @@ -242,8 +251,13 @@ static akgl_UiMenu title_menu = { Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad -South; the mouse selects by *moving onto* a row and activates by clicking one, during -the declaration itself. A stationary pointer claims nothing — parking the mouse over the +South — exactly `SDLK_UP`, `SDLK_DOWN` and `SDLK_RETURN` against `SDL_Event.key.key`, +and `SDL_GAMEPAD_BUTTON_DPAD_UP`, `_DPAD_DOWN` and `_SOUTH` against +`SDL_Event.gbutton.button`. Keycodes, not scancodes — which only matters when you +*synthesize* events, as the demo's `--demo` script does: a hand-built key event must +fill in `key.key`, or it matches nothing and the menu silently ignores it. The mouse +selects by *moving onto* a row and activates by clicking one, during the declaration +itself. A stationary pointer claims nothing — parking the mouse over the menu must not pin the selection against the keyboard, which would otherwise fight it sixty times a second and lose. diff --git a/include/akgl/ui.h b/include/akgl/ui.h index 01f43da..58ebcf2 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -391,6 +391,13 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_end(akgl_RenderBackend *self); * -- clay keeps the pointer, not a copy. May be empty, which * draws an empty panel. * @param style Look to draw with, or `NULL` for the textbox palette. + * + * @note The panel's height is fixed at #AKGL_UI_DIALOG_HEIGHT and the text is + * not clipped to it: a message that wraps to more lines than fit runs + * past the bottom border, visibly. That is the same behaviour as the + * hand-rolled panel this widget mirrors, and it is deliberate -- + * overflow you can see beats truncation you cannot. Size the height for + * your longest message, or raise the override. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is From 2242cfac9fcc7271b63fa7ef147852dd6759556b Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 13:39:17 -0400 Subject: [PATCH 11/15] Unbreak CI: https submodule URLs, and three suites that assumed a display CI has never gone green in the retained run history, and every failure is the same 20-second death during checkout: six submodules -- SDL, SDL_image, SDL_mixer, SDL_ttf, jansson, semver -- were registered with git@github.com: SSH URLs, and the runner has no GitHub key. The https submodules (libccd, tg, clay, and the two starfort ones) clone fine in the same run. All six now use https, which is what every stanza added since already did; these are public upstream repositories nothing here pushes to, so SSH bought nothing. Second problem, waiting right behind the first: the character, sprite and tilemap suites were the only three of seventeen that never set the dummy video/audio/software-render hints, so on a headless runner they die in SDL_Init with "No available video device" before testing anything. They now set the same three hints the other fourteen suites set. (How this survived: every local run happens on a machine with a display, and CI never got far enough to run a test.) Pre-flighted locally against every job the workflow defines, headless with no driver variables in the environment, exactly as the runner would: Debug with -Werror and coverage builds clean and all 37 tests pass including the coverage fixtures; RelWithDebInfo with -Werror builds clean and the perf suites pass their budgets at full scale; doxygen Doxyfile exits 0 under FAIL_ON_WARNINGS; and the new ui suite runs under valgrind with zero errors and no leaks. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- .gitmodules | 12 ++++++------ tests/character.c | 4 ++++ tests/sprite.c | 4 ++++ tests/tilemap.c | 4 ++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 1530379..e0a7d83 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,18 +1,18 @@ [submodule "deps/semver"] path = deps/semver - url = git@github.com:h2non/semver.c.git + url = https://github.com/h2non/semver.c.git [submodule "deps/SDL"] path = deps/SDL - url = git@github.com:libsdl-org/SDL.git + url = https://github.com/libsdl-org/SDL.git [submodule "deps/SDL_image"] path = deps/SDL_image - url = git@github.com:libsdl-org/SDL_image.git + url = https://github.com/libsdl-org/SDL_image.git [submodule "deps/SDL_mixer"] path = deps/SDL_mixer - url = git@github.com:libsdl-org/SDL_mixer.git + url = https://github.com/libsdl-org/SDL_mixer.git [submodule "deps/SDL_ttf"] path = deps/SDL_ttf - url = git@github.com:libsdl-org/SDL_ttf.git + url = https://github.com/libsdl-org/SDL_ttf.git [submodule "deps/libsdlerror"] path = deps/libakerror url = https://source.starfort.tech/andrew/libakerror.git @@ -21,7 +21,7 @@ url = https://source.starfort.tech/andrew/libakstdlib.git [submodule "deps/jansson"] path = deps/jansson - url = git@github.com:akheron/jansson.git + url = https://github.com/akheron/jansson.git [submodule "deps/libccd"] path = deps/libccd url = https://github.com/danfis/libccd.git diff --git a/tests/character.c b/tests/character.c index 794cfc4..5f47b0a 100644 --- a/tests/character.c +++ b/tests/character.c @@ -284,6 +284,10 @@ akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void) 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()); akgl_renderer = &akgl_default_renderer; diff --git a/tests/sprite.c b/tests/sprite.c index 65147bf..248368a 100644 --- a/tests/sprite.c +++ b/tests/sprite.c @@ -240,6 +240,10 @@ 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()); akgl_renderer = &akgl_default_renderer; diff --git a/tests/tilemap.c b/tests/tilemap.c index 233146f..5ad91c4 100644 --- a/tests/tilemap.c +++ b/tests/tilemap.c @@ -740,6 +740,10 @@ 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()); akgl_gamemap = &akgl_default_gamemap; From 219d7182f18b2cb53b94ad7b8b926654b0c79a40 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 14:02:07 -0400 Subject: [PATCH 12/15] Install graphviz where doxygen runs The submodule fix got the cmake_build job through checkout, dependencies, configure and the whole compile for the first time in the run history -- and into the next latent failure: `doxygen Doxyfile` generates dot graphs and the Doxyfile fails on warnings, but the runner image has no `dot`, so every graph reported "Problems running dot: exit code=127" and the step failed after an otherwise clean build. Locally graphviz was installed, which is why the pre-flight missed it. One package in the one job that builds documentation. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- .gitea/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f7b24e8..33f7885 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: run: | sudo apt-get update -y sudo apt-get install -y \ - cmake doxygen gcc gcovr pkg-config \ + cmake doxygen gcc gcovr graphviz pkg-config \ libasound2-dev libfreetype-dev libharfbuzz-dev \ libpng-dev libtiff-dev libwebp-dev \ libudev-dev libx11-dev libxcursor-dev libxext-dev \ From d8458d1e683398ac592a27303d8cc7be40cc9385 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 14:19:05 -0400 Subject: [PATCH 13/15] Stop set_tests_properties from eating the test environment lists The example games failed on the CI runner with "ALSA: Couldn't open audio device" despite SDL_AUDIODRIVER=dummy sitting right there in their CMake -- because it never reached the process. set_tests_properties parses its PROPERTIES arguments as name/value pairs, so a semicolon-separated value list is split and every element after the first is consumed as a bogus property name: ENVIRONMENT kept only SDL_VIDEODRIVER=dummy, and the LD_LIBRARY_PATH prepend list kept only its first directory. ctest --show-only=json-v1 shows the truncation plainly. Nobody could see it. Video survived because SDL3 falls through its driver list to dummy on a headless machine with no hint at all; audio survived on every developer machine because the real device works there; the library paths survived because RPATH covered them. A runner with no sound card was the first environment where any of it mattered. Every list-valued test property now goes through set_property(TEST ...), which takes real list arguments -- the examples' ENVIRONMENT triplets and vendored-path modifications, the suites' LD_LIBRARY_PATH prepends, and the docs harness's driver variables. Verified by property dump (3 environment entries and all 7 prepends present) and by running the example tests with ALSA_CONFIG_PATH=/dev/null, which now pass where a real ALSA open would fail: the dummy driver is finally the one being asked. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- CMakeLists.txt | 31 +++++++++++++++++++++------- examples/jrpg/CMakeLists.txt | 27 ++++++++++++++---------- examples/sidescroller/CMakeLists.txt | 14 ++++++++++--- examples/uidemo/CMakeLists.txt | 25 ++++++++++++---------- 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 85e9aca..fe85fff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -662,20 +662,26 @@ if(AKGL_VENDORED_DEPENDENCIES) "${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror" "${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib" ) + # set_property rather than set_tests_properties for the list-valued + # property: set_tests_properties parses its PROPERTIES arguments as + # name/value pairs, so a semicolon-separated value is split and every + # element after the first is consumed as a bogus property name -- which + # silently reduced this prepend list to its first directory for as long as + # it existed. RPATH covered for it locally; a runner found it. if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") set(AKGL_TEST_ENV_MOD "") foreach(dir IN LISTS AKGL_TEST_LIBPATH) list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() - set_tests_properties( - ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} - PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}" + set_property( + TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} + PROPERTY ENVIRONMENT_MODIFICATION ${AKGL_TEST_ENV_MOD} ) else() string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") - set_tests_properties( - ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} - PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" + set_property( + TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} + PROPERTY ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" ) endif() endif() @@ -783,7 +789,12 @@ add_test( set_tests_properties(docs_examples PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 900 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software" +) +# set_property for the same pair-splitting reason as the suites above. +set_property(TEST docs_examples PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_AUDIODRIVER=dummy" + "SDL_RENDER_DRIVER=software" ) # Every figure in docs/ re-rendered and byte-compared against the tracked copy. @@ -808,7 +819,11 @@ add_test( set_tests_properties(docs_screenshots PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 900 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software" +) +set_property(TEST docs_screenshots PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_AUDIODRIVER=dummy" + "SDL_RENDER_DRIVER=software" ) # Regenerating the figures is a deliberate act, never part of a build: the PNGs diff --git a/examples/jrpg/CMakeLists.txt b/examples/jrpg/CMakeLists.txt index d67abbb..b353240 100644 --- a/examples/jrpg/CMakeLists.txt +++ b/examples/jrpg/CMakeLists.txt @@ -47,26 +47,31 @@ endif() # it to suppress the vendored projects' registrations and lifts the suppression # again long before examples/ is added. add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo) -set_tests_properties(example_jrpg PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +set_tests_properties(example_jrpg PROPERTIES TIMEOUT 120) +# set_property, not set_tests_properties: the latter parses its PROPERTIES +# arguments as name/value *pairs*, so a semicolon-separated value is split and +# every element after the first is consumed as a bogus property name. The +# audio and render entries of this list were silently lost that way -- +# invisible on any machine whose real audio device works, and found the first +# time CI ran on a runner without one. +set_property(TEST example_jrpg PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_RENDER_DRIVER=software" + "SDL_AUDIODRIVER=dummy" ) -# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has -# to go in the same property rather than a second one. Only needed when the -# dependencies were vendored; an installed build resolves them normally. +# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they +# were vendored; an installed build resolves them normally. if(AKGL_VENDORED_DEPENDENCIES) if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") set(JRPG_TEST_ENV_MOD "") foreach(dir IN LISTS AKGL_TEST_LIBPATH) list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() - set_tests_properties(example_jrpg - PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}") + set_property(TEST example_jrpg PROPERTY ENVIRONMENT_MODIFICATION ${JRPG_TEST_ENV_MOD}) else() string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") - set_tests_properties(example_jrpg PROPERTIES - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" - ) + set_property(TEST example_jrpg APPEND PROPERTY ENVIRONMENT + "LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}") endif() endif() diff --git a/examples/sidescroller/CMakeLists.txt b/examples/sidescroller/CMakeLists.txt index e397d0a..f58687b 100644 --- a/examples/sidescroller/CMakeLists.txt +++ b/examples/sidescroller/CMakeLists.txt @@ -41,7 +41,15 @@ endif() # while this project is top-level, and by the time examples/ is added the # suppression has already been lifted. An embedded build gets the builtin. add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay) -set_tests_properties(example_sidescroller PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +set_tests_properties(example_sidescroller PROPERTIES TIMEOUT 120) +# set_property, not set_tests_properties: the latter parses its PROPERTIES +# arguments as name/value *pairs*, so a semicolon-separated value is split and +# every element after the first is consumed as a bogus property name. The +# audio and render entries of this very list were silently lost that way for +# as long as the list existed -- invisible on any machine whose real audio +# device works, and found the first time CI ran on a runner without one. +set_property(TEST example_sidescroller PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_RENDER_DRIVER=software" + "SDL_AUDIODRIVER=dummy" ) diff --git a/examples/uidemo/CMakeLists.txt b/examples/uidemo/CMakeLists.txt index 65d87d6..f509fd5 100644 --- a/examples/uidemo/CMakeLists.txt +++ b/examples/uidemo/CMakeLists.txt @@ -34,26 +34,29 @@ endif() # main() returns. There is no physics here, so nothing needs a driven clock; # `--frames` is a backstop above the script's last step, not the exit path. add_test(NAME example_uidemo COMMAND uidemo --frames 240 --demo) -set_tests_properties(example_uidemo PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +set_tests_properties(example_uidemo PROPERTIES TIMEOUT 120) +# set_property, not set_tests_properties: the latter parses its PROPERTIES +# arguments as name/value *pairs*, so a semicolon-separated value is split and +# every element after the first is consumed as a bogus property name. See the +# same note in the other examples' CMakeLists. +set_property(TEST example_uidemo PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_RENDER_DRIVER=software" + "SDL_AUDIODRIVER=dummy" ) -# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has -# to go in the same property rather than a second one. Only needed when the -# dependencies were vendored; an installed build resolves them normally. +# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they +# were vendored; an installed build resolves them normally. if(AKGL_VENDORED_DEPENDENCIES) if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") set(UIDEMO_TEST_ENV_MOD "") foreach(dir IN LISTS AKGL_TEST_LIBPATH) list(APPEND UIDEMO_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() - set_tests_properties(example_uidemo - PROPERTIES ENVIRONMENT_MODIFICATION "${UIDEMO_TEST_ENV_MOD}") + set_property(TEST example_uidemo PROPERTY ENVIRONMENT_MODIFICATION ${UIDEMO_TEST_ENV_MOD}) else() string(REPLACE ";" ":" UIDEMO_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") - set_tests_properties(example_uidemo PROPERTIES - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" - ) + set_property(TEST example_uidemo APPEND PROPERTY ENVIRONMENT + "LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}") endif() endif() From be3c8ae85015c447712a91331d4e73395ffc1870 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 14:29:42 -0400 Subject: [PATCH 14/15] Pin upload-artifact to v3: v4 refuses to run against Gitea The env-list fix got every test green on the runner for the first time -- 37 of 37, the JUnit publish included -- and the job then failed in the artifact upload: upload-artifact@v4 speaks GitHub's v2 artifact service and hard-refuses any host that is not github.com ("not currently supported on GHES"). Gitea implements the v3 artifact API, so the three upload steps -- coverage, the benchmark tables, the valgrind logs -- pin to v3. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- .gitea/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 33f7885..8eac352 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -56,7 +56,7 @@ jobs: fail_on_failure: 'true' - name: Upload coverage reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: code-coverage path: build/coverage/ @@ -123,7 +123,7 @@ jobs: # the next baseline if somebody re-records PERFORMANCE.md. - name: Upload benchmark tables if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: performance-baseline path: perf-baseline.txt @@ -170,7 +170,7 @@ jobs: run: scripts/memcheck.sh - name: Upload valgrind logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: valgrind-logs path: build/Testing/Temporary/MemoryChecker.*.log From 5560edf41020115826b582d4ba68365c55d3fa76 Mon Sep 17 00:00:00 2001 From: Andrew Kesterson Date: Sun, 2 Aug 2026 15:12:39 -0400 Subject: [PATCH 15/15] Exclude the two shell-script tests from the memcheck run The memory job's first-ever complete valgrind pass on the runner reported every real suite and all three example games clean -- and 414 findings in docs_examples and docs_screenshots. Those two tests are bash scripts that drive gcc and run the compiled snippets as children; valgrind wraps the test command and does not trace children, so all 414 were /bin/bash's own by-design leaks and not one byte of libakgl. Memchecking them proves nothing the suites and example games do not already prove as real binaries under the same valgrind run. memcheck.sh now excludes exactly those two by name, with the reasoning in a comment beside the exclusion -- per the suppressions file's own rule that nothing gets quieted without a stated reason. Verified locally: full run over the RelWithDebInfo tree, every suite and example under valgrind, zero findings, exit 0. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71 --- scripts/memcheck.sh | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/memcheck.sh b/scripts/memcheck.sh index df8c9d9..bcb050e 100755 --- a/scripts/memcheck.sh +++ b/scripts/memcheck.sh @@ -49,8 +49,20 @@ rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log # The run itself is allowed to fail: a suite that valgrind makes slow enough to # trip an assertion still produced a log worth reading, and the log is what # decides the exit status below. +# +# docs_examples and docs_screenshots are excluded, and the exclusion deserves +# its reasons stated: those two tests are *shell scripts* that drive gcc and +# run the compiled snippets as child processes. Valgrind wraps the test +# command, does not trace children, and so spends the whole test memchecking +# /bin/bash -- 413 of bash's own by-design leaks on the first CI run that got +# this far, and not one byte of libakgl. The snippet programs themselves are +# ordinary akgl consumers whose code paths the suites and example games +# already cover below, under valgrind, as real binaries. A caller's own -E +# overrides this one (ctest takes the last), which is fine: a narrowed run +# chose its scope on purpose. set +e -ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure "$@" +ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure \ + -E '^(docs_examples|docs_screenshots)$' "$@" AKGL_CTEST_STATUS=$? set -e