Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs #3
@@ -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
323
src/ui.c
@@ -87,6 +87,28 @@ static float ui_wheel_y;
|
||||
/** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */
|
||||
static uint64_t ui_last_frame_ns;
|
||||
|
||||
/** @brief The layout width akgl_ui_init or the last resize established. The dialog spans it. */
|
||||
static int ui_layout_width;
|
||||
/** @brief The layout height, kept alongside #ui_layout_width. */
|
||||
static int ui_layout_height;
|
||||
|
||||
/**
|
||||
* @brief What `NULL` means wherever a widget takes a style.
|
||||
*
|
||||
* The JRPG textbox's palette, on purpose: the widget that replaces that panel
|
||||
* should reproduce it, so the two are comparable pixel for pixel.
|
||||
*
|
||||
* Field Value Meaning
|
||||
*/
|
||||
static const akgl_UiStyle ui_default_style = {
|
||||
{ 24, 20, 37, 235 }, /* fill: near-black, slightly translucent */
|
||||
{ 240, 236, 214, 255 }, /* edge: parchment */
|
||||
{ 240, 236, 214, 255 }, /* ink: parchment */
|
||||
8.0f, /* padding */
|
||||
0.0f, /* corner_radius: square */
|
||||
0 /* fontid: the first font registered */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief First error clay reported since the stash was last cleared.
|
||||
*
|
||||
@@ -219,6 +241,8 @@ akerr_ErrorContext *akgl_ui_init(int width, int height)
|
||||
// Needs the context Clay_Initialize just made current, so it cannot move
|
||||
// earlier. Without a measure function every text element is an error.
|
||||
Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL);
|
||||
ui_layout_width = width;
|
||||
ui_layout_height = height;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -241,6 +265,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void)
|
||||
ui_wheel_x = 0.0f;
|
||||
ui_wheel_y = 0.0f;
|
||||
ui_last_frame_ns = 0;
|
||||
ui_layout_width = 0;
|
||||
ui_layout_height = 0;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -255,6 +281,8 @@ akerr_ErrorContext *akgl_ui_resize(int width, int height)
|
||||
dimensions.width = (float)width;
|
||||
dimensions.height = (float)height;
|
||||
Clay_SetLayoutDimensions(dimensions);
|
||||
ui_layout_width = width;
|
||||
ui_layout_height = height;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -768,6 +796,301 @@ akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/** @brief Wrap a caller's C string as the length-and-pointer form clay takes. Borrowed, not copied. */
|
||||
static Clay_String ui_string_from(char *text)
|
||||
{
|
||||
Clay_String out;
|
||||
|
||||
out.isStaticallyAllocated = false;
|
||||
out.length = (int32_t)SDL_strlen(text);
|
||||
out.chars = text;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @brief Convert an SDL_Color to clay's float 0-255 convention. */
|
||||
static Clay_Color ui_clay_color(SDL_Color color)
|
||||
{
|
||||
Clay_Color out;
|
||||
|
||||
out.r = (float)color.r;
|
||||
out.g = (float)color.g;
|
||||
out.b = (float)color.b;
|
||||
out.a = (float)color.a;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The two checks every widget makes before declaring anything.
|
||||
*
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is
|
||||
* open.
|
||||
*/
|
||||
static akerr_ErrorContext *ui_widget_ready(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
(ui_in_frame == false),
|
||||
AKGL_ERR_UI,
|
||||
"Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end");
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style)
|
||||
{
|
||||
const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style;
|
||||
uint16_t pad = (uint16_t)look->padding;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null dialog id");
|
||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null dialog text");
|
||||
PASS(errctx, ui_widget_ready());
|
||||
|
||||
CLAY({
|
||||
.id = CLAY_SIDI(ui_string_from(id), 0),
|
||||
.layout = {
|
||||
.sizing = {
|
||||
.width = CLAY_SIZING_FIXED((float)ui_layout_width - (look->padding * 2.0f)),
|
||||
.height = CLAY_SIZING_FIXED(AKGL_UI_DIALOG_HEIGHT)
|
||||
},
|
||||
.padding = { pad, pad, pad, pad }
|
||||
},
|
||||
.backgroundColor = ui_clay_color(look->fill),
|
||||
.cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius),
|
||||
.border = {
|
||||
.color = ui_clay_color(look->edge),
|
||||
.width = { 1, 1, 1, 1, 0 }
|
||||
},
|
||||
.floating = {
|
||||
.offset = { 0.0f, -look->padding },
|
||||
.attachPoints = {
|
||||
.element = CLAY_ATTACH_POINT_CENTER_BOTTOM,
|
||||
.parent = CLAY_ATTACH_POINT_CENTER_BOTTOM
|
||||
},
|
||||
.attachTo = CLAY_ATTACH_TO_ROOT
|
||||
}
|
||||
}) {
|
||||
CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({
|
||||
.textColor = ui_clay_color(look->ink),
|
||||
.fontId = look->fontid
|
||||
}));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style)
|
||||
{
|
||||
const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style;
|
||||
uint16_t pad = (uint16_t)look->padding;
|
||||
Clay_FloatingAttachPointType attach = CLAY_ATTACH_POINT_LEFT_TOP;
|
||||
Clay_Vector2 offset = { 0.0f, 0.0f };
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null label id");
|
||||
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null label text");
|
||||
PASS(errctx, ui_widget_ready());
|
||||
|
||||
/*
|
||||
* Anchor Attach point Inset
|
||||
*/
|
||||
switch ( anchor ) {
|
||||
case AKGL_UI_ANCHOR_TOP_LEFT:
|
||||
attach = CLAY_ATTACH_POINT_LEFT_TOP;
|
||||
offset.x = look->padding;
|
||||
offset.y = look->padding;
|
||||
break;
|
||||
case AKGL_UI_ANCHOR_TOP_RIGHT:
|
||||
attach = CLAY_ATTACH_POINT_RIGHT_TOP;
|
||||
offset.x = -look->padding;
|
||||
offset.y = look->padding;
|
||||
break;
|
||||
case AKGL_UI_ANCHOR_BOTTOM_LEFT:
|
||||
attach = CLAY_ATTACH_POINT_LEFT_BOTTOM;
|
||||
offset.x = look->padding;
|
||||
offset.y = -look->padding;
|
||||
break;
|
||||
case AKGL_UI_ANCHOR_BOTTOM_RIGHT:
|
||||
attach = CLAY_ATTACH_POINT_RIGHT_BOTTOM;
|
||||
offset.x = -look->padding;
|
||||
offset.y = -look->padding;
|
||||
break;
|
||||
case AKGL_UI_ANCHOR_CENTER:
|
||||
attach = CLAY_ATTACH_POINT_CENTER_CENTER;
|
||||
break;
|
||||
default:
|
||||
FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Anchor %d is not an akgl_UiAnchor", (int)anchor);
|
||||
}
|
||||
|
||||
CLAY({
|
||||
.id = CLAY_SIDI(ui_string_from(id), 0),
|
||||
.layout = {
|
||||
.padding = { pad, pad, pad, pad }
|
||||
},
|
||||
.backgroundColor = ui_clay_color(look->fill),
|
||||
.cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius),
|
||||
.floating = {
|
||||
.offset = offset,
|
||||
.attachPoints = { .element = attach, .parent = attach },
|
||||
.attachTo = CLAY_ATTACH_TO_ROOT
|
||||
}
|
||||
}) {
|
||||
CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({
|
||||
.textColor = ui_clay_color(look->ink),
|
||||
.fontId = look->fontid
|
||||
}));
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu)
|
||||
{
|
||||
const akgl_UiStyle *look = NULL;
|
||||
uint16_t pad = 0;
|
||||
Clay_String idstring;
|
||||
Clay_Color rowink;
|
||||
Clay_Color rowfill;
|
||||
bool rowselected = false;
|
||||
int32_t i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu");
|
||||
FAIL_ZERO_RETURN(errctx, menu->id, AKERR_NULLPOINTER, "Null menu id");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"A menu carries 1 to %d entries; this one claims %d",
|
||||
AKGL_UI_MENU_MAX_ITEMS,
|
||||
menu->count);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((menu->selected < 0) || (menu->selected >= menu->count)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Selection %d is outside this menu's %d entries",
|
||||
menu->selected,
|
||||
menu->count);
|
||||
for ( i = 0; i < menu->count; i++ ) {
|
||||
FAIL_ZERO_RETURN(errctx, menu->items[i], AKERR_NULLPOINTER, "Menu entry %d is NULL", i);
|
||||
}
|
||||
PASS(errctx, ui_widget_ready());
|
||||
|
||||
look = ( menu->style != NULL ) ? menu->style : &ui_default_style;
|
||||
pad = (uint16_t)look->padding;
|
||||
idstring = ui_string_from(menu->id);
|
||||
|
||||
CLAY({
|
||||
.id = CLAY_SIDI(idstring, 0),
|
||||
.layout = {
|
||||
.padding = { pad, pad, pad, pad },
|
||||
.layoutDirection = CLAY_TOP_TO_BOTTOM
|
||||
},
|
||||
.backgroundColor = ui_clay_color(look->fill),
|
||||
.cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius),
|
||||
.border = {
|
||||
.color = ui_clay_color(look->edge),
|
||||
.width = { 1, 1, 1, 1, 0 }
|
||||
},
|
||||
.floating = {
|
||||
.attachPoints = {
|
||||
.element = CLAY_ATTACH_POINT_CENTER_CENTER,
|
||||
.parent = CLAY_ATTACH_POINT_CENTER_CENTER
|
||||
},
|
||||
.attachTo = CLAY_ATTACH_TO_ROOT
|
||||
}
|
||||
}) {
|
||||
for ( i = 0; i < menu->count; i++ ) {
|
||||
// The selected row draws inverted. An unselected row declares no
|
||||
// background at all -- alpha 0 emits no render command -- so the
|
||||
// panel's fill shows through rather than being repainted.
|
||||
rowselected = (i == menu->selected);
|
||||
rowink = rowselected ? ui_clay_color(look->fill) : ui_clay_color(look->ink);
|
||||
rowfill = rowselected ? ui_clay_color(look->ink) : (Clay_Color){ 0, 0, 0, 0 };
|
||||
CLAY({
|
||||
.id = CLAY_SIDI(idstring, (uint32_t)(i + 1)),
|
||||
.layout = {
|
||||
.sizing = { .width = CLAY_SIZING_GROW(0) },
|
||||
.padding = { pad, pad, (uint16_t)(pad / 2), (uint16_t)(pad / 2) }
|
||||
},
|
||||
.backgroundColor = rowfill
|
||||
}) {
|
||||
// Mouse selection: hovering a row makes it the selection, and
|
||||
// the frame-latched press edge on a hovered row confirms it.
|
||||
// The row highlight catches up next frame, which at any
|
||||
// playable frame rate is invisible.
|
||||
if ( Clay_Hovered() ) {
|
||||
menu->selected = i;
|
||||
if ( ui_pointer_pressed ) {
|
||||
menu->activated = true;
|
||||
}
|
||||
}
|
||||
CLAY_TEXT(ui_string_from(menu->items[i]), CLAY_TEXT_CONFIG({
|
||||
.textColor = rowink,
|
||||
.fontId = look->fontid
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu");
|
||||
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event");
|
||||
FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination");
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"A menu carries 1 to %d entries; this one claims %d",
|
||||
AKGL_UI_MENU_MAX_ITEMS,
|
||||
menu->count);
|
||||
FAIL_NONZERO_RETURN(
|
||||
errctx,
|
||||
((menu->selected < 0) || (menu->selected >= menu->count)),
|
||||
AKERR_OUTOFBOUNDS,
|
||||
"Selection %d is outside this menu's %d entries",
|
||||
menu->selected,
|
||||
menu->count);
|
||||
*consumed = false;
|
||||
if ( ui_context == NULL ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
switch ( event->type ) {
|
||||
case SDL_EVENT_KEY_DOWN:
|
||||
if ( event->key.key == SDLK_UP ) {
|
||||
menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1);
|
||||
*consumed = true;
|
||||
} else if ( event->key.key == SDLK_DOWN ) {
|
||||
menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1);
|
||||
*consumed = true;
|
||||
} else if ( event->key.key == SDLK_RETURN ) {
|
||||
menu->activated = true;
|
||||
*consumed = true;
|
||||
}
|
||||
break;
|
||||
case SDL_EVENT_GAMEPAD_BUTTON_DOWN:
|
||||
if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ) {
|
||||
menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1);
|
||||
*consumed = true;
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ) {
|
||||
menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1);
|
||||
*consumed = true;
|
||||
} else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ) {
|
||||
menu->activated = true;
|
||||
*consumed = true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
void akgl_ui_arena_limit(size_t limit)
|
||||
{
|
||||
if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) {
|
||||
|
||||
292
tests/ui.c
292
tests/ui.c
@@ -630,6 +630,294 @@ akerr_ErrorContext *test_ui_handle_event(void)
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/** @brief The default widget palette's fill, for pixel assertions. Matches src/ui.c. */
|
||||
static const SDL_Color widgetfill = { 24, 20, 37, 235 };
|
||||
/** @brief The default widget palette's edge and ink. */
|
||||
static const SDL_Color widgetink = { 240, 236, 214, 255 };
|
||||
|
||||
/**
|
||||
* @brief The dialog widget draws the textbox: fill inside, border on the edge, text on top.
|
||||
*
|
||||
* On a 64x64 layout with the default 8px padding the panel spans (8,0) to
|
||||
* (56,56) -- the width follows the layout, the height is
|
||||
* AKGL_UI_DIALOG_HEIGHT. The palette asserted here is the point: the default
|
||||
* style *is* the JRPG textbox's, so the comparison chapter can put the two
|
||||
* side by side.
|
||||
*/
|
||||
akerr_ErrorContext *test_ui_dialog_widget(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Surface *shot = NULL;
|
||||
uint16_t fontid = 0;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL),
|
||||
"a dialog was declared with no subsystem");
|
||||
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
|
||||
"init for the dialog test failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
|
||||
"registering the dialog test font failed");
|
||||
TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL),
|
||||
"a dialog was declared outside the frame bracket");
|
||||
|
||||
CATCH(e, clear_target());
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the dialog frame failed");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog(NULL, "words", NULL),
|
||||
"a NULL dialog id was accepted");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog("dialog", NULL, NULL),
|
||||
"NULL dialog text was accepted");
|
||||
TEST_EXPECT_OK(e, akgl_ui_dialog("dialog", "Hi", NULL), "declaring the dialog failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the dialog frame failed");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(e, pixel_is(shot, 32, 40, widgetfill),
|
||||
"the dialog panel's fill is missing from its middle");
|
||||
TEST_ASSERT(e, pixel_is(shot, 8, 28, widgetink),
|
||||
"the dialog panel's border is missing from its left edge");
|
||||
TEST_ASSERT(e, pixel_is(shot, 4, 28, testblack),
|
||||
"the dialog painted outside its margin");
|
||||
TEST_ASSERT(e, pixel_is(shot, 32, 60, testblack),
|
||||
"the dialog painted over the margin below it");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
IGNORE(akgl_ui_shutdown());
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Labels pin to their corners and stay out of the others.
|
||||
*/
|
||||
akerr_ErrorContext *test_ui_label_widget(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Surface *shot = NULL;
|
||||
uint16_t fontid = 0;
|
||||
bool topright = false;
|
||||
bool bottomleft = false;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
|
||||
"init for the label test failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
|
||||
"registering the label test font failed");
|
||||
|
||||
CATCH(e, clear_target());
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the label frame failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_label("score", "9", AKGL_UI_ANCHOR_TOP_RIGHT, NULL),
|
||||
"declaring the top-right label failed");
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS,
|
||||
akgl_ui_label("bogus", "9", (akgl_UiAnchor)99, NULL),
|
||||
"a nonsense anchor was accepted");
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the label frame failed");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
// The label is fit-sized around its glyph, so where exactly it ends
|
||||
// is the font's business; the assertions are per-quadrant instead.
|
||||
for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) {
|
||||
for ( x = (TEST_TARGET_SIZE / 2); x < TEST_TARGET_SIZE; x++ ) {
|
||||
if ( pixel_is(shot, x, y, widgetfill) ) {
|
||||
topright = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// The far corner, not the whole quadrant: a padded label on a 64px
|
||||
// target is fat enough to graze the quadrant boundary, and where its
|
||||
// edge falls is the font's business, not this test's.
|
||||
for ( y = (TEST_TARGET_SIZE - 16); y < TEST_TARGET_SIZE; y++ ) {
|
||||
for ( x = 0; x < 16; x++ ) {
|
||||
if ( pixel_is(shot, x, y, widgetfill) ) {
|
||||
bottomleft = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
TEST_ASSERT(e, topright, "the top-right label left no fill in its quadrant");
|
||||
TEST_ASSERT(e, bottomleft == false, "the top-right label reached the opposite corner");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
IGNORE(akgl_ui_shutdown());
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief The menu widget: validation, the inverted highlight, and mouse selection.
|
||||
*
|
||||
* The mouse half runs the real two-frame protocol: frame one lays the menu
|
||||
* out, the click lands between frames against the retained tree, and frame
|
||||
* two's declaration sees the hover and the latched press edge. Row geometry
|
||||
* comes from Clay_GetElementData rather than being derived here, so the test
|
||||
* cannot drift from however the menu lays its rows out.
|
||||
*/
|
||||
akerr_ErrorContext *test_ui_menu_widget(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_Event event;
|
||||
Clay_ElementData rowdata;
|
||||
static akgl_UiMenu menu = {
|
||||
.id = "testmenu",
|
||||
.items = { "New Game", "Options", "Quit" },
|
||||
.count = 3,
|
||||
};
|
||||
akgl_UiMenu broken;
|
||||
uint16_t fontid = 0;
|
||||
bool consumed = false;
|
||||
bool inverted = false;
|
||||
int token = 1;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE),
|
||||
"init for the menu test failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid),
|
||||
"registering the menu test font failed");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(NULL),
|
||||
"a NULL menu was accepted");
|
||||
broken = menu;
|
||||
broken.count = 0;
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken),
|
||||
"an empty menu was accepted");
|
||||
broken = menu;
|
||||
broken.selected = 7;
|
||||
TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken),
|
||||
"a selection outside the menu was accepted");
|
||||
broken = menu;
|
||||
broken.items[1] = NULL;
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(&broken),
|
||||
"a NULL entry was accepted");
|
||||
|
||||
// Frame one: the menu as declared, selection on the first row.
|
||||
CATCH(e, clear_target());
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the first menu frame failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "declaring the menu failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the first menu frame failed");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
for ( y = 0; y < TEST_TARGET_SIZE; y++ ) {
|
||||
for ( x = 0; x < TEST_TARGET_SIZE; x++ ) {
|
||||
if ( pixel_is(shot, x, y, widgetink) ) {
|
||||
inverted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
TEST_ASSERT(e, inverted, "no row carries the inverted highlight");
|
||||
|
||||
// The click: aimed at the second row's real box, landed between
|
||||
// frames, seen by the declaration in frame two.
|
||||
rowdata = Clay_GetElementData(Clay_GetElementIdWithIndex(CLAY_STRING("testmenu"), 2));
|
||||
TEST_ASSERT(e, rowdata.found, "the second row's element id cannot be found");
|
||||
SDL_memset(&event, 0x00, sizeof(SDL_Event));
|
||||
event.type = SDL_EVENT_MOUSE_MOTION;
|
||||
event.motion.x = rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f);
|
||||
event.motion.y = rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f);
|
||||
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
|
||||
"moving onto the second row was refused");
|
||||
make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN,
|
||||
rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f),
|
||||
rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f));
|
||||
TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed),
|
||||
"the click on the second row was refused");
|
||||
TEST_ASSERT(e, consumed == true, "the click on the menu was not consumed");
|
||||
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the second menu frame failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu failed");
|
||||
TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the second menu frame failed");
|
||||
|
||||
TEST_ASSERT(e, menu.selected == 1,
|
||||
"clicking the second row moved the selection to %d, not 1", menu.selected);
|
||||
TEST_ASSERT(e, menu.activated == true, "clicking a row did not activate the menu");
|
||||
menu.activated = false;
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
IGNORE(akgl_ui_shutdown());
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Keyboard and gamepad drive a menu: wrap both ways, confirm, ignore the rest.
|
||||
*/
|
||||
akerr_ErrorContext *test_ui_menu_events(void)
|
||||
{
|
||||
PREPARE_ERROR(e);
|
||||
SDL_Event event;
|
||||
static akgl_UiMenu menu = {
|
||||
.id = "eventmenu",
|
||||
.items = { "One", "Two", "Three" },
|
||||
.count = 3,
|
||||
};
|
||||
bool consumed = false;
|
||||
|
||||
ATTEMPT {
|
||||
TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the menu event test failed");
|
||||
menu.selected = 0;
|
||||
menu.activated = false;
|
||||
|
||||
SDL_memset(&event, 0x00, sizeof(SDL_Event));
|
||||
event.type = SDL_EVENT_KEY_DOWN;
|
||||
event.key.key = SDLK_DOWN;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused");
|
||||
TEST_ASSERT(e, consumed && (menu.selected == 1), "Down did not move the selection to 1");
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused");
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused");
|
||||
TEST_ASSERT(e, menu.selected == 0, "Down from the last row did not wrap to the first");
|
||||
event.key.key = SDLK_UP;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Up was refused");
|
||||
TEST_ASSERT(e, menu.selected == 2, "Up from the first row did not wrap to the last");
|
||||
event.key.key = SDLK_RETURN;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Return was refused");
|
||||
TEST_ASSERT(e, consumed && menu.activated, "Return did not activate the menu");
|
||||
menu.activated = false;
|
||||
event.key.key = SDLK_ESCAPE;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Escape was refused");
|
||||
TEST_ASSERT(e, consumed == false, "a key the menu does not use was consumed");
|
||||
|
||||
SDL_memset(&event, 0x00, sizeof(SDL_Event));
|
||||
event.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
|
||||
event.gbutton.button = SDL_GAMEPAD_BUTTON_DPAD_DOWN;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "D-pad down was refused");
|
||||
TEST_ASSERT(e, consumed && (menu.selected == 0), "D-pad down did not wrap to the first row");
|
||||
event.gbutton.button = SDL_GAMEPAD_BUTTON_SOUTH;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "South was refused");
|
||||
TEST_ASSERT(e, consumed && menu.activated, "South did not activate the menu");
|
||||
menu.activated = false;
|
||||
event.gbutton.button = SDL_GAMEPAD_BUTTON_EAST;
|
||||
TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "East was refused");
|
||||
TEST_ASSERT(e, consumed == false, "a button the menu does not use was consumed");
|
||||
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_ui_menu_handle_event(NULL, &event, &consumed),
|
||||
"a NULL menu was accepted");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_ui_menu_handle_event(&menu, NULL, &consumed),
|
||||
"a NULL event was accepted");
|
||||
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER,
|
||||
akgl_ui_menu_handle_event(&menu, &event, NULL),
|
||||
"a NULL consumed destination was accepted");
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_ui_shutdown());
|
||||
} PROCESS(e) {
|
||||
} FINISH(e, true);
|
||||
SUCCEED_RETURN(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief A layout error inside clay must surface from frame_end, not vanish.
|
||||
*
|
||||
@@ -714,6 +1002,10 @@ int main(void)
|
||||
CATCH(errctx, test_ui_layout_pixels());
|
||||
CATCH(errctx, test_ui_layout_text());
|
||||
CATCH(errctx, test_ui_handle_event());
|
||||
CATCH(errctx, test_ui_dialog_widget());
|
||||
CATCH(errctx, test_ui_label_widget());
|
||||
CATCH(errctx, test_ui_menu_widget());
|
||||
CATCH(errctx, test_ui_menu_events());
|
||||
CATCH(errctx, test_ui_layout_error_surfaces());
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
|
||||
Reference in New Issue
Block a user