diff --git a/include/akgl/ui.h b/include/akgl/ui.h index 26a8217..168042d 100644 --- a/include/akgl/ui.h +++ b/include/akgl/ui.h @@ -44,6 +44,7 @@ #include #include +#include /** * @brief Most elements one layout may declare. @@ -117,6 +118,82 @@ #define AKGL_UI_MAX_TEXT_BYTES 1024 #endif +/** + * @brief Most entries one akgl_UiMenu may carry. + * + * A menu deeper than this wants scrolling and sub-screens, which is `CLAY()` + * territory rather than a widget's. + */ +#ifndef AKGL_UI_MENU_MAX_ITEMS +#define AKGL_UI_MENU_MAX_ITEMS 16 +#endif + +/** + * @brief Height of the akgl_ui_dialog() panel, in pixels. + * + * Two lines of 12pt monospace plus padding -- the JRPG textbox's height, kept + * so the widget's output is comparable with the hand-rolled panel it + * replaces. + */ +#ifndef AKGL_UI_DIALOG_HEIGHT +#define AKGL_UI_DIALOG_HEIGHT 56 +#endif + +/** + * @brief One look, shared by every widget: colours, spacing, corners, font. + * + * Passing `NULL` wherever a style is taken means the library default, which + * is deliberately the JRPG textbox's palette -- near-black fill (24,20,37,235) + * with parchment edge and ink (240,236,214,255), 8 pixels of padding, square + * corners, fontId 0. A caller wanting one change copies those values and + * changes one; there is no per-field "zero means default" magic, because a + * transparent fill and a zero radius are things a style legitimately says. + */ +typedef struct { + SDL_Color fill; /**< Panel background. */ + SDL_Color edge; /**< Border colour. Drawn one pixel wide where a widget has a border. */ + SDL_Color ink; /**< Text colour. */ + float32_t padding; /**< Pixels between a panel's edge and its content, and between a widget and the screen edge it anchors to. */ + float32_t corner_radius; /**< Corner rounding in pixels. 0 is square. */ + uint16_t fontid; /**< The clay fontId text renders with, from akgl_ui_font_register(). */ +} akgl_UiStyle; + +/** + * @brief Where on the screen akgl_ui_label() pins itself. + */ +typedef enum { + AKGL_UI_ANCHOR_TOP_LEFT, /**< Inset by the style's padding from the top-left corner. */ + AKGL_UI_ANCHOR_TOP_RIGHT, /**< Inset from the top-right corner. */ + AKGL_UI_ANCHOR_BOTTOM_LEFT, /**< Inset from the bottom-left corner. */ + AKGL_UI_ANCHOR_BOTTOM_RIGHT, /**< Inset from the bottom-right corner. */ + AKGL_UI_ANCHOR_CENTER /**< Dead centre, no inset. */ +} akgl_UiAnchor; + +/** + * @brief One vertical menu: its entries, its selection, and whether it fired. + * + * Caller-owned, the way the JRPG's textbox state is a static in the game -- + * there is no heap layer behind this because it owns no texture, no font and + * no registry entry; it is a struct of borrowed pointers and two integers. + * Declare it static, fill in `id`, `items` and `count`, and leave the rest + * zeroed. + * + * `selected` is yours to read and the menu's to move: keyboard and gamepad + * move it through akgl_ui_menu_handle_event(), and the mouse moves it by + * hovering while akgl_ui_menu() declares the rows. `activated` latches true + * on Return, gamepad South, or a click on a row; the caller acts on it and + * sets it back to false -- the menu never clears it, so an unread activation + * is not lost between frames. + */ +typedef struct { + char *id; /**< Element id prefix, unique per menu. Required. Borrowed. */ + char *items[AKGL_UI_MENU_MAX_ITEMS]; /**< The entries, top to bottom. Borrowed; they must outlive the frame. */ + int32_t count; /**< How many of @c items are set. 1 to #AKGL_UI_MENU_MAX_ITEMS. */ + int32_t selected; /**< Index of the highlighted entry. In and out. */ + bool activated; /**< Out: the selected entry was confirmed. Caller clears. */ + akgl_UiStyle *style; /**< Look to draw with, or `NULL` for the library default. */ +} akgl_UiMenu; + /** * @brief Bring the UI subsystem up. * @@ -298,6 +375,101 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_begin(void); */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_end(akgl_RenderBackend *self); +/** + * @brief Declare a dialog panel: the JRPG textbox in one call. + * + * A panel across the bottom of the screen, inset by the style's padding, + * #AKGL_UI_DIALOG_HEIGHT pixels tall, with a one-pixel border and the text + * wrapped inside it. Legal only between akgl_ui_frame_begin() and + * akgl_ui_frame_end(). Showing and hiding is the frame's business: call this + * while somebody is talking and don't while nobody is -- there is no + * `visible` flag, because a declarative frame *is* the flag. + * + * @param id Element id for the panel, unique within the frame. Required. + * Borrowed until frame_end. + * @param text What the dialog says. Required, and borrowed until frame_end + * -- clay keeps the pointer, not a copy. May be empty, which + * draws an empty panel. + * @param style Look to draw with, or `NULL` for the textbox palette. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style); + +/** + * @brief Declare a HUD label: one line of text on a padded panel, pinned to a corner. + * + * Fit-sized around its text, anchored per @p anchor and inset by the style's + * padding. The score counter and the lives counter are two calls to this with + * two ids. Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). + * + * The text is borrowed until frame_end, which matters for the obvious use: + * format the score into a buffer that outlives the frame bracket, not into a + * scope that closes before the draw. + * + * @param id Element id for the label, unique within the frame. Required. + * @param text What the label says. Required; empty draws an empty chip. + * @param anchor Which corner (or the centre) to pin to. + * @param style Look to draw with, or `NULL` for the library default. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + * @throws AKERR_OUTOFBOUNDS If @p anchor is not one of the enum's values. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style); + +/** + * @brief Declare a vertical menu from caller-owned state, centred on screen. + * + * One row per entry, the selected row drawn inverted (ink on fill swaps to + * fill on ink). Hovering a row with the mouse moves `selected` to it; a left + * click on a row sets `activated`. Keyboard and gamepad go through + * akgl_ui_menu_handle_event(), which the application calls for whichever + * menu currently has its attention -- that routing *is* the focus model. + * Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). + * + * @param menu The menu state. Required, with a non-`NULL` `id`, a `count` in + * range, every used entry non-`NULL`, and `selected` pointing at + * an entry. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p menu, its `id`, or any of its first + * `count` items is `NULL`. The message names the index. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + * @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu(akgl_UiMenu *menu); + +/** + * @brief Drive a menu from a keyboard or gamepad event. + * + * Up and Down arrows and the D-pad move the selection, wrapping at both + * ends; Return and the gamepad South button set `activated`. Anything else + * -- and anything while the subsystem is inert -- reports @p consumed false + * and succeeds, so it slots into the same pass-everything event chain as + * akgl_ui_handle_event(), after it and before the control maps: + * + * PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed)); + * if ( consumed ) { continue; } + * if ( menu_active ) { + * PASS(errctx, akgl_ui_menu_handle_event(&menu, &event, &consumed)); + * if ( consumed ) { continue; } + * } + * PASS(errctx, akgl_controller_handle_event(&app, &event)); + * + * @param menu The menu the application is routing input to. Required, + * with `count` in range. + * @param event The event SDL handed the loop. Required. + * @param consumed Receives whether the menu claimed the event. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p menu, @p event, or @p consumed is `NULL`. + * @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed); + /* * The following is part of the internal API. It is exposed so the test suite * can reach it and is not meant to be called by a game. diff --git a/src/ui.c b/src/ui.c index 8c5b833..7ff8295 100644 --- a/src/ui.c +++ b/src/ui.c @@ -87,6 +87,28 @@ static float ui_wheel_y; /** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */ static uint64_t ui_last_frame_ns; +/** @brief The layout width akgl_ui_init or the last resize established. The dialog spans it. */ +static int ui_layout_width; +/** @brief The layout height, kept alongside #ui_layout_width. */ +static int ui_layout_height; + +/** + * @brief What `NULL` means wherever a widget takes a style. + * + * The JRPG textbox's palette, on purpose: the widget that replaces that panel + * should reproduce it, so the two are comparable pixel for pixel. + * + * Field Value Meaning + */ +static const akgl_UiStyle ui_default_style = { + { 24, 20, 37, 235 }, /* fill: near-black, slightly translucent */ + { 240, 236, 214, 255 }, /* edge: parchment */ + { 240, 236, 214, 255 }, /* ink: parchment */ + 8.0f, /* padding */ + 0.0f, /* corner_radius: square */ + 0 /* fontid: the first font registered */ +}; + /** * @brief First error clay reported since the stash was last cleared. * @@ -219,6 +241,8 @@ akerr_ErrorContext *akgl_ui_init(int width, int height) // Needs the context Clay_Initialize just made current, so it cannot move // earlier. Without a measure function every text element is an error. Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL); + ui_layout_width = width; + ui_layout_height = height; SUCCEED_RETURN(errctx); } @@ -241,6 +265,8 @@ akerr_ErrorContext *akgl_ui_shutdown(void) ui_wheel_x = 0.0f; ui_wheel_y = 0.0f; ui_last_frame_ns = 0; + ui_layout_width = 0; + ui_layout_height = 0; SUCCEED_RETURN(errctx); } @@ -255,6 +281,8 @@ akerr_ErrorContext *akgl_ui_resize(int width, int height) dimensions.width = (float)width; dimensions.height = (float)height; Clay_SetLayoutDimensions(dimensions); + ui_layout_width = width; + ui_layout_height = height; SUCCEED_RETURN(errctx); } @@ -768,6 +796,301 @@ akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self) SUCCEED_RETURN(errctx); } +/** @brief Wrap a caller's C string as the length-and-pointer form clay takes. Borrowed, not copied. */ +static Clay_String ui_string_from(char *text) +{ + Clay_String out; + + out.isStaticallyAllocated = false; + out.length = (int32_t)SDL_strlen(text); + out.chars = text; + return out; +} + +/** @brief Convert an SDL_Color to clay's float 0-255 convention. */ +static Clay_Color ui_clay_color(SDL_Color color) +{ + Clay_Color out; + + out.r = (float)color.r; + out.g = (float)color.g; + out.b = (float)color.b; + out.a = (float)color.a; + return out; +} + +/** + * @brief The two checks every widget makes before declaring anything. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + */ +static akerr_ErrorContext *ui_widget_ready(void) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_NONZERO_RETURN( + errctx, + (ui_in_frame == false), + AKGL_ERR_UI, + "Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end"); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style) +{ + const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style; + uint16_t pad = (uint16_t)look->padding; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null dialog id"); + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null dialog text"); + PASS(errctx, ui_widget_ready()); + + CLAY({ + .id = CLAY_SIDI(ui_string_from(id), 0), + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED((float)ui_layout_width - (look->padding * 2.0f)), + .height = CLAY_SIZING_FIXED(AKGL_UI_DIALOG_HEIGHT) + }, + .padding = { pad, pad, pad, pad } + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .border = { + .color = ui_clay_color(look->edge), + .width = { 1, 1, 1, 1, 0 } + }, + .floating = { + .offset = { 0.0f, -look->padding }, + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_BOTTOM, + .parent = CLAY_ATTACH_POINT_CENTER_BOTTOM + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({ + .textColor = ui_clay_color(look->ink), + .fontId = look->fontid + })); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style) +{ + const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style; + uint16_t pad = (uint16_t)look->padding; + Clay_FloatingAttachPointType attach = CLAY_ATTACH_POINT_LEFT_TOP; + Clay_Vector2 offset = { 0.0f, 0.0f }; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null label id"); + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null label text"); + PASS(errctx, ui_widget_ready()); + + /* + * Anchor Attach point Inset + */ + switch ( anchor ) { + case AKGL_UI_ANCHOR_TOP_LEFT: + attach = CLAY_ATTACH_POINT_LEFT_TOP; + offset.x = look->padding; + offset.y = look->padding; + break; + case AKGL_UI_ANCHOR_TOP_RIGHT: + attach = CLAY_ATTACH_POINT_RIGHT_TOP; + offset.x = -look->padding; + offset.y = look->padding; + break; + case AKGL_UI_ANCHOR_BOTTOM_LEFT: + attach = CLAY_ATTACH_POINT_LEFT_BOTTOM; + offset.x = look->padding; + offset.y = -look->padding; + break; + case AKGL_UI_ANCHOR_BOTTOM_RIGHT: + attach = CLAY_ATTACH_POINT_RIGHT_BOTTOM; + offset.x = -look->padding; + offset.y = -look->padding; + break; + case AKGL_UI_ANCHOR_CENTER: + attach = CLAY_ATTACH_POINT_CENTER_CENTER; + break; + default: + FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Anchor %d is not an akgl_UiAnchor", (int)anchor); + } + + CLAY({ + .id = CLAY_SIDI(ui_string_from(id), 0), + .layout = { + .padding = { pad, pad, pad, pad } + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .floating = { + .offset = offset, + .attachPoints = { .element = attach, .parent = attach }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({ + .textColor = ui_clay_color(look->ink), + .fontId = look->fontid + })); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu) +{ + const akgl_UiStyle *look = NULL; + uint16_t pad = 0; + Clay_String idstring; + Clay_Color rowink; + Clay_Color rowfill; + bool rowselected = false; + int32_t i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu"); + FAIL_ZERO_RETURN(errctx, menu->id, AKERR_NULLPOINTER, "Null menu id"); + FAIL_NONZERO_RETURN( + errctx, + ((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)), + AKERR_OUTOFBOUNDS, + "A menu carries 1 to %d entries; this one claims %d", + AKGL_UI_MENU_MAX_ITEMS, + menu->count); + FAIL_NONZERO_RETURN( + errctx, + ((menu->selected < 0) || (menu->selected >= menu->count)), + AKERR_OUTOFBOUNDS, + "Selection %d is outside this menu's %d entries", + menu->selected, + menu->count); + for ( i = 0; i < menu->count; i++ ) { + FAIL_ZERO_RETURN(errctx, menu->items[i], AKERR_NULLPOINTER, "Menu entry %d is NULL", i); + } + PASS(errctx, ui_widget_ready()); + + look = ( menu->style != NULL ) ? menu->style : &ui_default_style; + pad = (uint16_t)look->padding; + idstring = ui_string_from(menu->id); + + CLAY({ + .id = CLAY_SIDI(idstring, 0), + .layout = { + .padding = { pad, pad, pad, pad }, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .border = { + .color = ui_clay_color(look->edge), + .width = { 1, 1, 1, 1, 0 } + }, + .floating = { + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_CENTER, + .parent = CLAY_ATTACH_POINT_CENTER_CENTER + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + for ( i = 0; i < menu->count; i++ ) { + // The selected row draws inverted. An unselected row declares no + // background at all -- alpha 0 emits no render command -- so the + // panel's fill shows through rather than being repainted. + rowselected = (i == menu->selected); + rowink = rowselected ? ui_clay_color(look->fill) : ui_clay_color(look->ink); + rowfill = rowselected ? ui_clay_color(look->ink) : (Clay_Color){ 0, 0, 0, 0 }; + CLAY({ + .id = CLAY_SIDI(idstring, (uint32_t)(i + 1)), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(0) }, + .padding = { pad, pad, (uint16_t)(pad / 2), (uint16_t)(pad / 2) } + }, + .backgroundColor = rowfill + }) { + // Mouse selection: hovering a row makes it the selection, and + // the frame-latched press edge on a hovered row confirms it. + // The row highlight catches up next frame, which at any + // playable frame rate is invisible. + if ( Clay_Hovered() ) { + menu->selected = i; + if ( ui_pointer_pressed ) { + menu->activated = true; + } + } + CLAY_TEXT(ui_string_from(menu->items[i]), CLAY_TEXT_CONFIG({ + .textColor = rowink, + .fontId = look->fontid + })); + } + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination"); + FAIL_NONZERO_RETURN( + errctx, + ((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)), + AKERR_OUTOFBOUNDS, + "A menu carries 1 to %d entries; this one claims %d", + AKGL_UI_MENU_MAX_ITEMS, + menu->count); + FAIL_NONZERO_RETURN( + errctx, + ((menu->selected < 0) || (menu->selected >= menu->count)), + AKERR_OUTOFBOUNDS, + "Selection %d is outside this menu's %d entries", + menu->selected, + menu->count); + *consumed = false; + if ( ui_context == NULL ) { + SUCCEED_RETURN(errctx); + } + + switch ( event->type ) { + case SDL_EVENT_KEY_DOWN: + if ( event->key.key == SDLK_UP ) { + menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1); + *consumed = true; + } else if ( event->key.key == SDLK_DOWN ) { + menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1); + *consumed = true; + } else if ( event->key.key == SDLK_RETURN ) { + menu->activated = true; + *consumed = true; + } + break; + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ) { + menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1); + *consumed = true; + } else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ) { + menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1); + *consumed = true; + } else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ) { + menu->activated = true; + *consumed = true; + } + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + void akgl_ui_arena_limit(size_t limit) { if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) { diff --git a/tests/ui.c b/tests/ui.c index f0a3c8f..dfa1bff 100644 --- a/tests/ui.c +++ b/tests/ui.c @@ -630,6 +630,294 @@ akerr_ErrorContext *test_ui_handle_event(void) SUCCEED_RETURN(e); } +/** @brief The default widget palette's fill, for pixel assertions. Matches src/ui.c. */ +static const SDL_Color widgetfill = { 24, 20, 37, 235 }; +/** @brief The default widget palette's edge and ink. */ +static const SDL_Color widgetink = { 240, 236, 214, 255 }; + +/** + * @brief The dialog widget draws the textbox: fill inside, border on the edge, text on top. + * + * On a 64x64 layout with the default 8px padding the panel spans (8,0) to + * (56,56) -- the width follows the layout, the height is + * AKGL_UI_DIALOG_HEIGHT. The palette asserted here is the point: the default + * style *is* the JRPG textbox's, so the comparison chapter can put the two + * side by side. + */ +akerr_ErrorContext *test_ui_dialog_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL), + "a dialog was declared with no subsystem"); + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the dialog test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the dialog test font failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL), + "a dialog was declared outside the frame bracket"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the dialog frame failed"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog(NULL, "words", NULL), + "a NULL dialog id was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog("dialog", NULL, NULL), + "NULL dialog text was accepted"); + TEST_EXPECT_OK(e, akgl_ui_dialog("dialog", "Hi", NULL), "declaring the dialog failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the dialog frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(e, pixel_is(shot, 32, 40, widgetfill), + "the dialog panel's fill is missing from its middle"); + TEST_ASSERT(e, pixel_is(shot, 8, 28, widgetink), + "the dialog panel's border is missing from its left edge"); + TEST_ASSERT(e, pixel_is(shot, 4, 28, testblack), + "the dialog painted outside its margin"); + TEST_ASSERT(e, pixel_is(shot, 32, 60, testblack), + "the dialog painted over the margin below it"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Labels pin to their corners and stay out of the others. + */ +akerr_ErrorContext *test_ui_label_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + bool topright = false; + bool bottomleft = false; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the label test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the label test font failed"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the label frame failed"); + TEST_EXPECT_OK(e, akgl_ui_label("score", "9", AKGL_UI_ANCHOR_TOP_RIGHT, NULL), + "declaring the top-right label failed"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, + akgl_ui_label("bogus", "9", (akgl_UiAnchor)99, NULL), + "a nonsense anchor was accepted"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the label frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + // The label is fit-sized around its glyph, so where exactly it ends + // is the font's business; the assertions are per-quadrant instead. + for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) { + for ( x = (TEST_TARGET_SIZE / 2); x < TEST_TARGET_SIZE; x++ ) { + if ( pixel_is(shot, x, y, widgetfill) ) { + topright = true; + } + } + } + // The far corner, not the whole quadrant: a padded label on a 64px + // target is fat enough to graze the quadrant boundary, and where its + // edge falls is the font's business, not this test's. + for ( y = (TEST_TARGET_SIZE - 16); y < TEST_TARGET_SIZE; y++ ) { + for ( x = 0; x < 16; x++ ) { + if ( pixel_is(shot, x, y, widgetfill) ) { + bottomleft = true; + } + } + } + TEST_ASSERT(e, topright, "the top-right label left no fill in its quadrant"); + TEST_ASSERT(e, bottomleft == false, "the top-right label reached the opposite corner"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The menu widget: validation, the inverted highlight, and mouse selection. + * + * The mouse half runs the real two-frame protocol: frame one lays the menu + * out, the click lands between frames against the retained tree, and frame + * two's declaration sees the hover and the latched press edge. Row geometry + * comes from Clay_GetElementData rather than being derived here, so the test + * cannot drift from however the menu lays its rows out. + */ +akerr_ErrorContext *test_ui_menu_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + SDL_Event event; + Clay_ElementData rowdata; + static akgl_UiMenu menu = { + .id = "testmenu", + .items = { "New Game", "Options", "Quit" }, + .count = 3, + }; + akgl_UiMenu broken; + uint16_t fontid = 0; + bool consumed = false; + bool inverted = false; + int token = 1; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the menu test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the menu test font failed"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(NULL), + "a NULL menu was accepted"); + broken = menu; + broken.count = 0; + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken), + "an empty menu was accepted"); + broken = menu; + broken.selected = 7; + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken), + "a selection outside the menu was accepted"); + broken = menu; + broken.items[1] = NULL; + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(&broken), + "a NULL entry was accepted"); + + // Frame one: the menu as declared, selection on the first row. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the first menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "declaring the menu failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the first menu frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + for ( y = 0; y < TEST_TARGET_SIZE; y++ ) { + for ( x = 0; x < TEST_TARGET_SIZE; x++ ) { + if ( pixel_is(shot, x, y, widgetink) ) { + inverted = true; + } + } + } + TEST_ASSERT(e, inverted, "no row carries the inverted highlight"); + + // The click: aimed at the second row's real box, landed between + // frames, seen by the declaration in frame two. + rowdata = Clay_GetElementData(Clay_GetElementIdWithIndex(CLAY_STRING("testmenu"), 2)); + TEST_ASSERT(e, rowdata.found, "the second row's element id cannot be found"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f); + event.motion.y = rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "moving onto the second row was refused"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, + rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f), + rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f)); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the click on the second row was refused"); + TEST_ASSERT(e, consumed == true, "the click on the menu was not consumed"); + + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the second menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the second menu frame failed"); + + TEST_ASSERT(e, menu.selected == 1, + "clicking the second row moved the selection to %d, not 1", menu.selected); + TEST_ASSERT(e, menu.activated == true, "clicking a row did not activate the menu"); + menu.activated = false; + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Keyboard and gamepad drive a menu: wrap both ways, confirm, ignore the rest. + */ +akerr_ErrorContext *test_ui_menu_events(void) +{ + PREPARE_ERROR(e); + SDL_Event event; + static akgl_UiMenu menu = { + .id = "eventmenu", + .items = { "One", "Two", "Three" }, + .count = 3, + }; + bool consumed = false; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the menu event test failed"); + menu.selected = 0; + menu.activated = false; + + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_ASSERT(e, consumed && (menu.selected == 1), "Down did not move the selection to 1"); + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_ASSERT(e, menu.selected == 0, "Down from the last row did not wrap to the first"); + event.key.key = SDLK_UP; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Up was refused"); + TEST_ASSERT(e, menu.selected == 2, "Up from the first row did not wrap to the last"); + event.key.key = SDLK_RETURN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Return was refused"); + TEST_ASSERT(e, consumed && menu.activated, "Return did not activate the menu"); + menu.activated = false; + event.key.key = SDLK_ESCAPE; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Escape was refused"); + TEST_ASSERT(e, consumed == false, "a key the menu does not use was consumed"); + + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN; + event.gbutton.button = SDL_GAMEPAD_BUTTON_DPAD_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "D-pad down was refused"); + TEST_ASSERT(e, consumed && (menu.selected == 0), "D-pad down did not wrap to the first row"); + event.gbutton.button = SDL_GAMEPAD_BUTTON_SOUTH; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "South was refused"); + TEST_ASSERT(e, consumed && menu.activated, "South did not activate the menu"); + menu.activated = false; + event.gbutton.button = SDL_GAMEPAD_BUTTON_EAST; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "East was refused"); + TEST_ASSERT(e, consumed == false, "a button the menu does not use was consumed"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(NULL, &event, &consumed), + "a NULL menu was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(&menu, NULL, &consumed), + "a NULL event was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(&menu, &event, NULL), + "a NULL consumed destination was accepted"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + /** * @brief A layout error inside clay must surface from frame_end, not vanish. * @@ -714,6 +1002,10 @@ int main(void) CATCH(errctx, test_ui_layout_pixels()); CATCH(errctx, test_ui_layout_text()); CATCH(errctx, test_ui_handle_event()); + CATCH(errctx, test_ui_dialog_widget()); + CATCH(errctx, test_ui_label_widget()); + CATCH(errctx, test_ui_menu_widget()); + CATCH(errctx, test_ui_menu_events()); CATCH(errctx, test_ui_layout_error_surfaces()); } CLEANUP { IGNORE(akgl_text_unloadallfonts());