Files
akbasic/src/ui_akgl.c
Tachikoma 3b32a682a1 Give BASIC menus, dialogs and HUD labels over libakgl's UI helpers
Group K, and the first verbs to reach the akgl_ui subsystem 0.9.0 brought
in: MENU and GETMENU and RMENU, DIALOG, HUD and UISTYLE. A program that
wanted a title screen had to draw one out of CHAR and GETKEY, which is
what both breakout tutorials make a reader do.

The interesting part is the impedance mismatch. libakgl's UI is immediate
mode -- widgets are re-declared inside a frame bracket every frame and
clay borrows their text until the bracket closes -- and a BASIC program
says MENU 1, "START" on line 100 and expects it up on line 900, several
hundred frames later. So src/ui_akgl.c is retained on this side and
immediate on that one: the record's entry points are setters that copy
into akbasic_AkglUi, and akbasic_ui_akgl_render() replays the whole set
once a frame from the host's pump. No BASIC string, which lives in the
per-line value pool, is ever what clay is handed.

The shapes are borrowed rather than invented. MENU retires the way SOLID
does -- no entries retires one, no arguments retire them all. GETMENU
holds the step loop the way GETKEY does, so parking is not blocking: the
step still returns, the host keeps its frame rate, and the sprite, audio
and collision services keep running underneath because they run before
the blocking checks. RMENU(n,1) reads and clears the way BUMP() does.
Withdrawing the device or retiring the menu releases a holding GETMENU
with 0 rather than wedging the script, which is akbasic_input_service()'s
rule for a withdrawn keyboard.

One thing a program has to know, and docs/19-user-interface.md says it
twice: a menu that is up owns the cursor keys and Return. It has to, and
retiring it gives them back -- forget the MENU n before an INPUT and the
INPUT never sees the Return that ends it.

akbasic_runtime_set_ui() is its own function rather than a fifth argument
to akbasic_runtime_set_devices(), whose signature has twenty-eight call
sites in tests and documentation that are about something else.

deps/libakgl is not touched. akgl_UiAnchor has the four corners and dead
centre, so HUD offers exactly those five; TODO.md records what a
top-centre and bottom-centre would cost upstream, along with the three
other things this deliberately leaves out. No new error code either --
DEVICE, BOUNDS, SYNTAX and TYPE cover the group, and 520 stays free.

tools/screenshot.c had to learn that "needs a font" and "draws the text
grid" are two questions. They were one, and a UI figure came out black:
the text layer owns every pixel of the rows it covers and painted over
the widgets. The new ui=1 fence attribute asks for the first without the
second; MAINTENANCE.md documents it.

112/112 in both configurations, 112/112 under ASan and UBSan, coverage
94.1% against the 90% gate with src/runtime_ui.c at 99% of lines and
100% of functions, doxygen clean, and the four new figures byte-identical
on a re-render. TODO.md section 8's gate table was stale on several
counts besides these and is refreshed with measured numbers.

Co-Authored-By: Tachikoma (Claude Code Opus 5 1M) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-02 18:37:10 -04:00

415 lines
14 KiB
C

/**
* @file ui_akgl.c
* @brief Wires the UI backend record to libakgl's clay-backed widget helpers.
*
* The three helpers -- akgl_ui_dialog, akgl_ui_label and akgl_ui_menu -- and the
* frame bracket around them. Nothing here touches clay directly: a BASIC program
* gets the widgets, not the layout engine, and `CLAY()` blocks are the route a
* game written in C takes instead.
*
* **This file is where immediate mode meets a language that is not.** libakgl
* wants the widget set declared inside a bracket every frame and borrows the
* text until the bracket closes; a BASIC program says `MENU 1, "START"` on line
* 100 and expects it up on line 900. So the record's entry points are setters
* that copy into akbasic_AkglUi, and akbasic_ui_akgl_render() replays the whole
* set once a frame. The verbs never see a frame and the frame never sees a BASIC
* string.
*/
#include <stdio.h>
#include <string.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/registry.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include <akbasic/akgl.h>
#include <akbasic/error.h>
/**
* @brief Registry name the UI font is loaded under.
*
* Its own name rather than sharing the sink's, because the sink does not use the
* font registry at all -- it holds a `TTF_Font *` it opened itself -- and because
* a size is baked into a registered handle, so the UI's font is a different
* entry even when it is the same file.
*/
#define UI_FONT_NAME "akbasic.ui"
/**
* @brief Fail the build if the two menu ceilings ever disagree.
*
* include/akbasic/ui.h restates AKGL_UI_MENU_MAX_ITEMS rather than including an
* akgl header, which is correct and is also exactly the kind of restatement that
* rots. A mismatch would overflow akgl_UiMenu::items, so it is a negative array
* size here rather than a memory error at frame one.
*/
typedef char ui_menu_ceilings_agree[
(AKBASIC_UI_MAX_MENU_ITEMS == AKGL_UI_MENU_MAX_ITEMS) ? 1 : -1];
/** @brief Recover the backend's own state, or say that it has none. */
static akerr_ErrorContext *state_of(akbasic_UiBackend *self, akbasic_AkglUi **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in akgl UI backend");
*dest = (akbasic_AkglUi *)self->self;
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER,
"akgl UI backend has no state");
SUCCEED_RETURN(errctx);
}
/** @brief The style every widget draws with, or NULL for libakgl's own default. */
static akgl_UiStyle *look_of(akbasic_AkglUi *state)
{
return state->styled ? &state->style : NULL;
}
/** @brief The colour conversion, which is the whole impedance mismatch. */
static SDL_Color to_sdl(akbasic_Color color)
{
SDL_Color out;
out.r = color.r;
out.g = color.g;
out.b = color.b;
out.a = color.a;
return out;
}
/* ------------------------------------------------- the record's setters --- */
static akerr_ErrorContext *ui_dialog(akbasic_UiBackend *self, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
PASS(errctx, state_of(self, &state));
if ( text == NULL || text[0] == '\0' ) {
state->dialogopen = false;
state->dialogtext[0] = '\0';
SUCCEED_RETURN(errctx);
}
strncpy(state->dialogtext, text, sizeof(state->dialogtext) - 1);
state->dialogtext[sizeof(state->dialogtext) - 1] = '\0';
state->dialogopen = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *ui_label(akbasic_UiBackend *self, int slot, int anchor, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_LABELS),
AKERR_OUTOFBOUNDS, "HUD slot %d is outside the backend's range", slot);
if ( text == NULL ) {
state->labelset[slot] = false;
state->labeltext[slot][0] = '\0';
SUCCEED_RETURN(errctx);
}
strncpy(state->labeltext[slot], text, sizeof(state->labeltext[slot]) - 1);
state->labeltext[slot][sizeof(state->labeltext[slot]) - 1] = '\0';
state->labelanchor[slot] = anchor;
state->labelset[slot] = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *ui_menu(akbasic_UiBackend *self, int slot, const char *const *items, int count)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
int i = 0;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS),
AKERR_OUTOFBOUNDS, "MENU slot %d is outside the backend's range", slot);
FAIL_ZERO_RETURN(errctx, (count >= 0 && count <= AKBASIC_UI_MAX_MENU_ITEMS),
AKERR_OUTOFBOUNDS, "MENU entry count %d is outside 0..%d",
count, AKBASIC_UI_MAX_MENU_ITEMS);
FAIL_NONZERO_RETURN(errctx, (count > 0 && items == NULL), AKERR_NULLPOINTER,
"MENU was given %d entries and no list", count);
/*
* Redefining resets the selection and the latch. The entries have just
* changed meaning, so an index into the old list is not worth carrying over
* and an unread activation of an entry that no longer exists is worse.
*/
for ( i = 0; i < count; i++ ) {
FAIL_ZERO_RETURN(errctx, (items[i] != NULL), AKERR_NULLPOINTER,
"MENU entry %d is NULL", i + 1);
strncpy(state->menuitems[slot][i], items[i], sizeof(state->menuitems[slot][i]) - 1);
state->menuitems[slot][i][sizeof(state->menuitems[slot][i]) - 1] = '\0';
state->menus[slot].items[i] = state->menuitems[slot][i];
}
for ( i = count; i < AKBASIC_UI_MAX_MENU_ITEMS; i++ ) {
state->menus[slot].items[i] = NULL;
}
state->menus[slot].count = count;
state->menus[slot].selected = 0;
state->menus[slot].activated = false;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *ui_menu_state(akbasic_UiBackend *self, int slot, int *selected, bool *activated, bool clear)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
PASS(errctx, state_of(self, &state));
FAIL_ZERO_RETURN(errctx, (selected != NULL && activated != NULL), AKERR_NULLPOINTER,
"NULL destination in menu_state");
FAIL_ZERO_RETURN(errctx, (slot >= 0 && slot < AKBASIC_UI_MAX_MENUS),
AKERR_OUTOFBOUNDS, "MENU slot %d is outside the backend's range", slot);
*selected = (int)state->menus[slot].selected;
*activated = state->menus[slot].activated;
if ( clear ) {
state->menus[slot].activated = false;
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *ui_style(akbasic_UiBackend *self, akbasic_Color *fill, akbasic_Color *edge, akbasic_Color *ink, double padding, double radius)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
PASS(errctx, state_of(self, &state));
/*
* No colours means the library's default, and the way to say that is to stop
* having a style rather than to copy libakgl's values into ours. Copying them
* would make UISTYLE-with-no-arguments a snapshot of whatever the default was
* on the day this was written.
*/
if ( fill == NULL || edge == NULL || ink == NULL ) {
state->styled = false;
SUCCEED_RETURN(errctx);
}
state->style.fill = to_sdl(*fill);
state->style.edge = to_sdl(*edge);
state->style.ink = to_sdl(*ink);
state->style.padding = (float32_t)padding;
state->style.corner_radius = (float32_t)radius;
state->style.fontid = state->fontid;
state->styled = true;
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *ui_clear(akbasic_UiBackend *self)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
int i = 0;
PASS(errctx, state_of(self, &state));
state->dialogopen = false;
state->dialogtext[0] = '\0';
state->styled = false;
for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) {
state->labelset[i] = false;
state->labeltext[i][0] = '\0';
}
for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) {
state->menus[i].count = 0;
state->menus[i].selected = 0;
state->menus[i].activated = false;
}
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------- the frame --- */
/**
* @brief Declare every retained widget into the open frame.
*
* Its own function because it is three loops, and CATCH inside one escapes only
* the loop -- PASS is the only thing that may appear in here. The caller CATCHes
* this single call, which is what lets it close the bracket on the way out.
*
* Order is deliberate: labels, then the dialog, then the menus. A menu is the
* thing the player is being asked to act on, so nothing else should be able to
* overlap it.
*/
static akerr_ErrorContext AKERR_NOIGNORE *declare_widgets(akbasic_AkglUi *state)
{
PREPARE_ERROR(errctx);
akgl_UiStyle *look = look_of(state);
int i = 0;
for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) {
if ( state->labelset[i] ) {
PASS(errctx, akgl_ui_label(state->labelid[i], state->labeltext[i],
(akgl_UiAnchor)state->labelanchor[i], look));
}
}
if ( state->dialogopen ) {
PASS(errctx, akgl_ui_dialog("dialog", state->dialogtext, look));
}
for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) {
if ( state->menus[i].count > 0 ) {
state->menus[i].style = look;
PASS(errctx, akgl_ui_menu(&state->menus[i]));
}
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_ui_akgl_render(akbasic_UiBackend *obj)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
bool opened = false;
PASS(errctx, state_of(obj, &state));
if ( !state->ready ) {
SUCCEED_RETURN(errctx);
}
ATTEMPT {
CATCH(errctx, akgl_ui_frame_begin());
opened = true;
CATCH(errctx, declare_widgets(state));
/*
* Cleared before frame_end rather than after, because frame_end closes
* the bracket whether it succeeds or fails -- so a CLEANUP that closed it
* again would be closing somebody else's next frame.
*/
opened = false;
CATCH(errctx, akgl_ui_frame_end(state->renderer));
} CLEANUP {
if ( opened ) {
IGNORE(akgl_ui_frame_end(state->renderer));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_ui_akgl_handle_event(akbasic_UiBackend *obj, SDL_Event *event, bool *consumed)
{
PREPARE_ERROR(errctx);
akbasic_AkglUi *state = NULL;
int i = 0;
PASS(errctx, state_of(obj, &state));
FAIL_ZERO_RETURN(errctx, (event != NULL && consumed != NULL), AKERR_NULLPOINTER,
"NULL argument in ui_akgl_handle_event");
*consumed = false;
if ( !state->ready ) {
SUCCEED_RETURN(errctx);
}
/*
* The subsystem first: it is mouse-only and never consumes a keystroke, so
* this is safe whatever else is going on.
*/
PASS(errctx, akgl_ui_handle_event(state, event, consumed));
if ( *consumed ) {
SUCCEED_RETURN(errctx);
}
/*
* Then each menu that has entries -- and these *do* take Up, Down and
* Return. That is why the loop is over menus with a count rather than over
* all four: a program with no menu up leaves the cursor keys and Return to
* the line editor, which is what makes the REPL usable at all.
*/
for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) {
if ( state->menus[i].count == 0 ) {
continue;
}
PASS(errctx, akgl_ui_menu_handle_event(&state->menus[i], event, consumed));
if ( *consumed ) {
SUCCEED_RETURN(errctx);
}
}
SUCCEED_RETURN(errctx);
}
/* ------------------------------------------------------------- lifecycle --- */
akerr_ErrorContext *akbasic_ui_init_akgl(akbasic_UiBackend *obj, akbasic_AkglUi *state, akgl_RenderBackend *renderer, const char *fontpath, int fontsize, int width, int height)
{
PREPARE_ERROR(errctx);
int count = 0;
int i = 0;
FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL && fontpath != NULL),
AKERR_NULLPOINTER, "NULL argument in ui_init_akgl");
FAIL_ZERO_RETURN(errctx, (renderer != NULL), AKERR_NULLPOINTER,
"NULL renderer in ui_init_akgl: the host creates it, not this");
FAIL_ZERO_RETURN(errctx, (fontsize > 0 && width > 0 && height > 0), AKBASIC_ERR_VALUE,
"A %dx%d UI at %d points is not a UI", width, height, fontsize);
PASS(errctx, akgl_error_init());
memset(state, 0, sizeof(*state));
state->renderer = renderer;
strncpy(state->fontname, UI_FONT_NAME, sizeof(state->fontname) - 1);
/*
* The element ids, once. clay identifies an element by its string and keeps
* hover and scroll state against it between frames, so these have to be the
* same every frame -- which means they cannot be built on the stack of the
* function that declares them.
*/
for ( i = 0; i < AKBASIC_UI_MAX_LABELS; i++ ) {
snprintf(state->labelid[i], sizeof(state->labelid[i]), "hud%d", i + 1);
}
for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) {
snprintf(state->menuid[i], sizeof(state->menuid[i]), "menu%d", i + 1);
state->menus[i].id = state->menuid[i];
}
(void)count;
/*
* **akgl_registry_init() does not initialize the font registry** -- its own
* header says so -- and nothing else in this repository has needed it,
* because the text sink opens its font with a bare TTF_OpenFont and holds
* the handle. akgl_ui_font_register() resolves a *registry name* per use, so
* the font has to go in there. It is idempotent.
*/
PASS(errctx, akgl_registry_init_font());
PASS(errctx, akgl_text_loadfont(state->fontname, (char *)fontpath, fontsize));
PASS(errctx, akgl_ui_init(width, height));
PASS(errctx, akgl_ui_font_register(state->fontname, &state->fontid));
state->ready = true;
obj->self = state;
obj->dialog = ui_dialog;
obj->label = ui_label;
obj->menu = ui_menu;
obj->menu_state = ui_menu_state;
obj->style = ui_style;
obj->clear = ui_clear;
SUCCEED_RETURN(errctx);
}
void akbasic_ui_akgl_shutdown(akbasic_UiBackend *obj)
{
akbasic_AkglUi *state = NULL;
if ( obj == NULL || obj->self == NULL ) {
return;
}
state = (akbasic_AkglUi *)obj->self;
if ( !state->ready ) {
return;
}
/*
* The subsystem first, then the font: anything still holding a clay layout
* would be talking to a disowned context after the shutdown, and the font is
* what its text commands resolve through. Both are ignorable -- this is
* called from teardown paths that are already unwinding, and a failure to
* close something down has nowhere useful to go.
*/
IGNORE(akgl_ui_shutdown());
IGNORE(akgl_text_unloadfont(state->fontname));
state->ready = false;
}