Files
akbasic/src/ui_akgl.c
Tachikoma b434be1901 Port onto libakstdlib 2b79aca and convert the eight bool predicates
akbasic's src/ now calls libakstdlib 313 times and raw libc 7 -- 2.2%
bypassed, against 86.4% on the same tree before this. The submodule bump
669b2b3 -> 2b79aca needed no source change of its own: the release is
drop-in for what akbasic already used.

Seven of the eight sites the earlier port left on raw libc change their own
signature rather than swallowing an error, per andrew's ruling on
libakstdlib#38. word_is, the is_waiting_for pair, the scanner's is_at_end,
peek, peek_next and match_next_char, format.c's overflow, and sink_akgl's
scroll/newline/putchar_at/echo_line/edit_key chain all return an
akerr_ErrorContext * and hand the answer back through an out parameter.
is_waiting_for and is_waiting_for_any are a public header change; every
call site that used one as a term in a condition hoists it into a
statement first.

verb_compare is the eighth and stays on strcmp. bsearch(3) fixes the
comparator's signature, so there is no out parameter to report through --
which is what libakstdlib#38 concluded. It carries a comment saying so and
saying why the bypass is safe there.

Six snprintf sites stay raw because they want truncation as an answer
rather than an error, and aksl_snprintf cannot express that until
libakstdlib#34 hands the required length back. Each of the six says so at
the site. Two of them, in host.c, are a latent defect rather than a
decision: a host type name over 31 characters truncates silently and two
sharing a prefix then collide, where structtype.c refuses the same case.

DLOAD leaked a file descriptor. Its read loop sat inside an ATTEMPT and the
PASS in it returned past CLEANUP, so a scan error left the file open.
Hoisting the loop into its own helper to convert fgets fixes it.

Refs libakstdlib#26, libakstdlib#38

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:23:32 -04:00

412 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 <akerror.h>
#include <akstdlib.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);
}
PASS(errctx, aksl_strcpy(state->dialogtext, sizeof(state->dialogtext), text));
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);
}
PASS(errctx, aksl_strcpy(state->labeltext[slot], sizeof(state->labeltext[slot]), text));
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);
PASS(errctx, aksl_strcpy(state->menuitems[slot][i], sizeof(state->menuitems[slot][i]),
items[i]));
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());
PASS(errctx, aksl_memset(state, 0, sizeof(*state)));
state->renderer = renderer;
PASS(errctx, aksl_strcpy(state->fontname, sizeof(state->fontname), UI_FONT_NAME));
/*
* 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++ ) {
PASS(errctx, aksl_snprintf(&count, state->labelid[i], sizeof(state->labelid[i]),
"hud%d", i + 1));
}
for ( i = 0; i < AKBASIC_UI_MAX_MENUS; i++ ) {
PASS(errctx, aksl_snprintf(&count, state->menuid[i], sizeof(state->menuid[i]),
"menu%d", i + 1));
state->menus[i].id = state->menuid[i];
}
/*
* **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;
}