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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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 <ccd/ccd.h>
|
||||
@@ -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
|
||||
# <clay.h> 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()
|
||||
|
||||
1
deps/clay
vendored
Submodule
1
deps/clay
vendored
Submodule
Submodule deps/clay added at b25a31c1a1
@@ -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)
|
||||
|
||||
/**
|
||||
|
||||
161
include/akgl/ui.h
Normal file
161
include/akgl/ui.h
Normal file
@@ -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 `<clay.h>` 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 <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <clay.h>
|
||||
|
||||
/**
|
||||
* @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_
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
153
src/ui.c
Normal file
153
src/ui.c
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file ui.c
|
||||
* @brief Implements the UI subsystem: clay's arena, lifecycle and error stash.
|
||||
*/
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <clay.h>
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/ui.h>
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
21
src/ui_clay.c
Normal file
21
src/ui_clay.c
Normal file
@@ -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 <clay.h>
|
||||
* 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 <clay.h> 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 <clay.h>
|
||||
@@ -50,6 +50,7 @@
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/ui.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
112
tests/ui.c
Normal file
112
tests/ui.c
Normal file
@@ -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 <akerror.h>
|
||||
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/ui.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
Reference in New Issue
Block a user