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:
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) ) {
|
||||
|
||||
Reference in New Issue
Block a user