Add the widget helpers: dialog, HUD label, and menu
The other half of the hybrid decision: an application that wants a dialog box, a score counter or a main menu gets each in one call, without touching a CLAY() macro -- and an application that outgrows them still has clay's whole DSL, because the widgets are nothing but functions that declare the same elements between the same frame bracket. akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL style *is* the textbox palette -- near-black fill, parchment edge and ink, 8px padding -- deliberately, so the widget and the 125-line hand-rolled panel it stands beside in the comparison chapter produce the same picture. Showing and hiding is the frame's business: call it while somebody is talking, don't while nobody is; a declarative frame is the visible flag. akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by the style's padding. akgl_ui_menu declares a centred vertical menu from a caller-owned struct (no heap layer: it owns no texture, no font, no registry entry), with the selected row drawn inverted, hover moving the selection and the frame-latched press edge activating it. akgl_ui_menu_handle_event drives the same struct from Up/Down/Return and D-pad/South with wrapping, slotting into the event chain after akgl_ui_handle_event; routing events to a menu is the application's focus model, stated rather than invented. Tests cover the dialog's geometry and palette by pixel, label anchoring by quadrant, menu validation, the inverted highlight, keyboard and gamepad wrap/activate/pass-through, and the full two-frame mouse protocol -- click aimed at the second row's real box via Clay_GetElementData, seen by the next frame's declaration as selection plus activation. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
This commit is contained in:
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