Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs #3

Merged
andrew merged 15 commits from clay-ui into main 2026-08-02 16:14:50 -04:00
3 changed files with 787 additions and 0 deletions
Showing only changes of commit 0679d40ee4 - Show all commits

View File

@@ -44,6 +44,7 @@
#include <clay.h> #include <clay.h>
#include <akgl/renderer.h> #include <akgl/renderer.h>
#include <akgl/types.h>
/** /**
* @brief Most elements one layout may declare. * @brief Most elements one layout may declare.
@@ -117,6 +118,82 @@
#define AKGL_UI_MAX_TEXT_BYTES 1024 #define AKGL_UI_MAX_TEXT_BYTES 1024
#endif #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. * @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); 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 * 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. * 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. */ /** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */
static uint64_t ui_last_frame_ns; 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. * @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 // Needs the context Clay_Initialize just made current, so it cannot move
// earlier. Without a measure function every text element is an error. // earlier. Without a measure function every text element is an error.
Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL); Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL);
ui_layout_width = width;
ui_layout_height = height;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
@@ -241,6 +265,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void)
ui_wheel_x = 0.0f; ui_wheel_x = 0.0f;
ui_wheel_y = 0.0f; ui_wheel_y = 0.0f;
ui_last_frame_ns = 0; ui_last_frame_ns = 0;
ui_layout_width = 0;
ui_layout_height = 0;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
@@ -255,6 +281,8 @@ akerr_ErrorContext *akgl_ui_resize(int width, int height)
dimensions.width = (float)width; dimensions.width = (float)width;
dimensions.height = (float)height; dimensions.height = (float)height;
Clay_SetLayoutDimensions(dimensions); Clay_SetLayoutDimensions(dimensions);
ui_layout_width = width;
ui_layout_height = height;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
@@ -768,6 +796,301 @@ akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self)
SUCCEED_RETURN(errctx); 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) void akgl_ui_arena_limit(size_t limit)
{ {
if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) { if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) {

View File

@@ -630,6 +630,294 @@ akerr_ErrorContext *test_ui_handle_event(void)
SUCCEED_RETURN(e); 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. * @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_pixels());
CATCH(errctx, test_ui_layout_text()); CATCH(errctx, test_ui_layout_text());
CATCH(errctx, test_ui_handle_event()); 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()); CATCH(errctx, test_ui_layout_error_surfaces());
} CLEANUP { } CLEANUP {
IGNORE(akgl_text_unloadallfonts()); IGNORE(akgl_text_unloadallfonts());