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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
2026-08-02 11:17:32 -04:00
parent 566b870a5c
commit 0679d40ee4
3 changed files with 787 additions and 0 deletions

View File

@@ -44,6 +44,7 @@
#include <clay.h>
#include <akgl/renderer.h>
#include <akgl/types.h>
/**
* @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.

323
src/ui.c
View File

@@ -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) ) {

View File

@@ -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());