/** * @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 #include #include #include #include #include #include #include /** * @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; }