/** * @file ui.h * @brief Screen-space user interface layouts, built on the clay layout engine. * * This subsystem exists because every game eventually wants a dialog box, a * HUD counter, or a menu, and hand-rolling them out of rectangles and * akgl_text_rendertextat() is tedious the first time and unmaintainable by the * fourth (`examples/jrpg/textbox.c` is the honest record of the first time). * clay computes the layout; libakgl owns everything that has to touch SDL or * the rest of the library -- the arena clay allocates from, the text * measurement it calls back into, the render commands it emits, and the * pointer state it is fed. * * There are two ways in, and they compose: * * - Write clay's declarative `CLAY({...}) { ... }` blocks yourself between * akgl_ui_frame_begin() and akgl_ui_frame_end(). The whole of clay's API * is available -- this header includes `` on purpose, the same way * SDL types appear undisguised elsewhere in this library. * - Call the widget helpers, which emit those blocks for you. * * The subsystem is optional the way collision is optional: always compiled in, * inert until akgl_ui_init() runs, and costing nothing but static storage * before that. * * @warning libakgl compiles clay into `libakgl.so` (the `CLAY_IMPLEMENTATION` * translation unit is `src/ui_clay.c`) and exports its symbols, * because the `CLAY()` macros in *your* translation units expand to * calls into them. Do not define `CLAY_IMPLEMENTATION` yourself and * do not link another copy of clay: two definitions of the same * symbols and the loader picks one silently, which is the exact * defect AGENTS.md records against an exported `renderer`. */ #ifndef _AKGL_UI_H_ #define _AKGL_UI_H_ #include #include #include #include #include #include #include /** * @brief Most elements one layout may declare. * * Passed to `Clay_SetMaxElementCount` before the arena is sized, so it is a * compile-time ceiling in the same sense as the `AKGL_MAX_HEAP_*` family: * define it before including this header to raise it, and see * #AKGL_UI_ARENA_BYTES, which must grow with it. */ #ifndef AKGL_UI_MAX_ELEMENTS #define AKGL_UI_MAX_ELEMENTS 1024 #endif /** * @brief Most whitespace-separated words clay's text measurement cache holds. * * Clay measures text one word at a time and caches the result; a cache miss * costs one call back into SDL_ttf. This bounds the cache, not the text -- a * layout with more distinct words than this still renders, it just measures * some of them every frame and clay reports the overflow through the error * handler. */ #ifndef AKGL_UI_MAX_MEASURE_WORDS #define AKGL_UI_MAX_MEASURE_WORDS 4096 #endif /** * @brief Bytes of static storage clay lays its internal structures out in. * * The arena is a fixed array in `src/ui.c` -- libakgl does not call `malloc` * at runtime, and clay never allocates behind the arena's back. What clay * needs is a function of #AKGL_UI_MAX_ELEMENTS and #AKGL_UI_MAX_MEASURE_WORDS * and is only computable at runtime, so akgl_ui_init() checks * `Clay_MinMemorySize()` against this and refuses with both numbers in the * message when it does not fit. A too-small arena is a loud failure at * startup, never a corruption at frame forty thousand. */ #ifndef AKGL_UI_ARENA_BYTES #define AKGL_UI_ARENA_BYTES (1024 * 1024) #endif /** * @brief Most fonts akgl_ui_font_register() will map to clay fontIds. * * clay identifies a font as a `uint16_t`; libakgl identifies one as a name in * #AKGL_REGISTRY_FONT. The table joining them is this many fixed slots. A * size is baked into each registered font handle, so a game using one face at * three sizes is using three slots. */ #ifndef AKGL_UI_MAX_FONTS #define AKGL_UI_MAX_FONTS 8 #endif /** * @brief Bytes each fontId table slot holds for its registry name, terminator included. */ #ifndef AKGL_UI_FONT_NAME_LENGTH #define AKGL_UI_FONT_NAME_LENGTH 64 #endif /** * @brief Bytes of scratch one text render command may occupy, terminator included. * * clay hands text to a renderer as a length-and-pointer slice, one command * per wrapped line; akgl_text_rendertextat() takes a C string, so each line * is copied through a bounded buffer on the way. A line longer than this * fails the frame loudly rather than drawing a truncation that reads as the * author's text. */ #ifndef AKGL_UI_MAX_TEXT_BYTES #define AKGL_UI_MAX_TEXT_BYTES 1024 #endif /** * @brief Most entries one akgl_UiMenu may carry. * * A menu deeper than this wants scrolling and sub-screens, which is `CLAY()` * territory rather than a widget's. */ #ifndef AKGL_UI_MENU_MAX_ITEMS #define AKGL_UI_MENU_MAX_ITEMS 16 #endif /** * @brief Height of the akgl_ui_dialog() panel, in pixels. * * Two lines of 12pt monospace plus padding -- the JRPG textbox's height, kept * so the widget's output is comparable with the hand-rolled panel it * replaces. */ #ifndef AKGL_UI_DIALOG_HEIGHT #define AKGL_UI_DIALOG_HEIGHT 56 #endif /** * @brief One look, shared by every widget: colours, spacing, corners, font. * * Passing `NULL` wherever a style is taken means the library default, which * is deliberately the JRPG textbox's palette -- near-black fill (24,20,37,235) * with parchment edge and ink (240,236,214,255), 8 pixels of padding, square * corners, fontId 0. A caller wanting one change copies those values and * changes one; there is no per-field "zero means default" magic, because a * transparent fill and a zero radius are things a style legitimately says. */ typedef struct { SDL_Color fill; /**< Panel background. */ SDL_Color edge; /**< Border colour. Drawn one pixel wide where a widget has a border. */ SDL_Color ink; /**< Text colour. */ float32_t padding; /**< Pixels between a panel's edge and its content, and between a widget and the screen edge it anchors to. */ float32_t corner_radius; /**< Corner rounding in pixels. 0 is square. */ uint16_t fontid; /**< The clay fontId text renders with, from akgl_ui_font_register(). */ } akgl_UiStyle; /** * @brief Where on the screen akgl_ui_label() pins itself. */ typedef enum { AKGL_UI_ANCHOR_TOP_LEFT, /**< Inset by the style's padding from the top-left corner. */ AKGL_UI_ANCHOR_TOP_RIGHT, /**< Inset from the top-right corner. */ AKGL_UI_ANCHOR_BOTTOM_LEFT, /**< Inset from the bottom-left corner. */ AKGL_UI_ANCHOR_BOTTOM_RIGHT, /**< Inset from the bottom-right corner. */ AKGL_UI_ANCHOR_CENTER /**< Dead centre, no inset. */ } akgl_UiAnchor; /** * @brief One vertical menu: its entries, its selection, and whether it fired. * * Caller-owned, the way the JRPG's textbox state is a static in the game -- * there is no heap layer behind this because it owns no texture, no font and * no registry entry; it is a struct of borrowed pointers and two integers. * Declare it static, fill in `id`, `items` and `count`, and leave the rest * zeroed. * * `selected` is yours to read and the menu's to move: keyboard and gamepad * move it through akgl_ui_menu_handle_event(), and the mouse moves it by * hovering while akgl_ui_menu() declares the rows. `activated` latches true * on Return, gamepad South, or a click on a row; the caller acts on it and * sets it back to false -- the menu never clears it, so an unread activation * is not lost between frames. */ typedef struct { char *id; /**< Element id prefix, unique per menu. Required. Borrowed. */ char *items[AKGL_UI_MENU_MAX_ITEMS]; /**< The entries, top to bottom. Borrowed; they must outlive the frame. */ int32_t count; /**< How many of @c items are set. 1 to #AKGL_UI_MENU_MAX_ITEMS. */ int32_t selected; /**< Index of the highlighted entry. In and out. */ bool activated; /**< Out: the selected entry was confirmed. Caller clears. */ akgl_UiStyle *style; /**< Look to draw with, or `NULL` for the library default. */ } akgl_UiMenu; /** * @brief Bring the UI subsystem up. * * Bounds clay to the `AKGL_UI_*` ceilings, checks that the arena is big * enough for them, and hands it to `Clay_Initialize` with an error handler * that routes clay's layout-time complaints into the libakgl error protocol * (they surface from akgl_ui_frame_end(), which is the first call after they * can happen). * * The layout dimensions are taken as parameters rather than read from the * camera or the window, so a headless program -- a test, a layout tool -- can * bring the UI up without a renderer existing at all. A windowed game passes * its screen size and lets akgl_ui_handle_event() track resizes from there. * * @param width Layout width in pixels. Must be positive. * @param height Layout height in pixels. Must be positive. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_OUTOFBOUNDS If @p width or @p height is not positive. * @throws AKGL_ERR_UI If the subsystem is already initialized -- call * akgl_ui_shutdown() first; if the arena cannot hold clay's * structures at the current ceilings -- the message carries the bytes * required and the bytes available, so the operator knows what to * raise #AKGL_UI_ARENA_BYTES to; or if `Clay_Initialize` refuses, * which the preceding checks should make unreachable. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_init(int width, int height); /** * @brief Return the UI subsystem to the inert state. * * clay has no teardown of its own -- everything it built lives in the arena, * and the next akgl_ui_init() lays a new context out over it. This forgets * the context and clears the stashed error state, and is idempotent, because * shutdown paths run after partial startups. * * @warning Anything that captured clay state -- and any `CLAY()` block that * runs after this -- is left talking to a context this subsystem has * disowned. Stop declaring layouts before shutting down, the same * way fonts stop being drawn before akgl_text_unloadallfonts(). * * @return `NULL`. Shutting down an uninitialized subsystem is success. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_shutdown(void); /** * @brief Tell the layout engine the render target changed size. * * akgl_ui_handle_event() calls this for `SDL_EVENT_WINDOW_RESIZED`, so a game * that routes its events through there never calls it directly. It is public * for the host that owns its own event loop. * * @param width New layout width in pixels. Must be positive. * @param height New layout height in pixels. Must be positive. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKGL_ERR_UI If the subsystem is not initialized. * @throws AKERR_OUTOFBOUNDS If @p width or @p height is not positive. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_resize(int width, int height); /** * @brief Map a font-registry name to the clay fontId that text elements name it by. * * The table stores the *name* and resolves it through #AKGL_REGISTRY_FONT on * every use, rather than caching the `TTF_Font *` -- fonts are not reference * counted, so a cached handle would dangle the moment akgl_text_unloadfont() * took the font away, while a name honestly reports "no longer registered". * Registering a name that is already in the table returns its existing id * rather than spending a second slot. * * clay's `Clay_TextElementConfig.fontSize` is **ignored** by this subsystem: * a libakgl font bakes its size in at akgl_text_loadfont() time, so the size * a text element renders at is the size the font behind its fontId was loaded * at. One face at two sizes is two loads, two registrations, two ids. * * @param name Registry key the font was published under. Required, and it * must already be in the registry -- registering first and * loading later would leave a window where a fontId resolves to * nothing. * @param fontid Receives the id to put in `Clay_TextElementConfig.fontId`. * Required. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p name or @p fontid is `NULL`. * @throws AKGL_ERR_UI If the subsystem is not initialized; if no font is * registered under @p name; or if all #AKGL_UI_MAX_FONTS slots are * taken. Each message says which. * @throws AKERR_OUTOFBOUNDS If @p name does not fit in a table slot -- * #AKGL_UI_FONT_NAME_LENGTH bytes including the terminator. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_font_register(char *name, uint16_t *fontid); /** * @brief Feed one SDL event to the UI, and learn whether the UI claimed it. * * The mouse half of the input story -- keyboard and gamepad stay with the * control maps and the widget helpers, because keyboard focus is something * the application declares, not something a pointer position implies. This * handles mouse motion, left-button presses and releases, the wheel, and * window resizes; every other event, and every event while the subsystem is * not initialized, reports @p consumed false and succeeds, so a host passes * every event through unconditionally -- the same contract * akgl_controller_handle_event() has. * * Call it *before* akgl_controller_handle_event() in the event loop, and skip * the rest of the chain when it consumed: * * while ( SDL_PollEvent(&event) ) { * PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed)); * if ( consumed ) { continue; } * PASS(errctx, akgl_controller_handle_event(&app, &event)); * } * * A press or release is consumed when the pointer is over any UI element; * motion and resizes never are (the game may care where the mouse is, and * certainly cares about its window). The hit test runs against the layout * the *previous* frame declared, because this frame's does not exist while * events are being polled -- clay retains the last tree for exactly this * purpose, and one frame of staleness is the accepted cost of the standard * model. Before any frame has been laid out, nothing is over anything and * nothing is consumed. * * @param appstate The application state pointer the event loop owns. * Required, though this implementation does not read it -- * the parameter exists so the signature matches * akgl_controller_handle_event() and can grow the same way. * @param event The event SDL handed the loop. Required. * @param consumed Receives whether the UI claimed the event. Required. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p appstate, @p event, or @p consumed is * `NULL`. * @throws AKERR_OUTOFBOUNDS If a resize event carries a non-positive size, * from akgl_ui_resize(). */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed); /** * @brief Open the UI frame: clear the error stash and begin the clay layout. * * Call once per rendered frame, after akgl_game_update() and before any * `CLAY()` block or widget helper. Everything declared between this and * akgl_ui_frame_end() is this frame's interface; a dialog is "open" because * the frame declares it, not because a mode was toggled somewhere. * * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKGL_ERR_UI If the subsystem is not initialized, or if a frame is * already open -- a begin/begin sequence means a frame_end went * missing, and absorbing it would hide the layout of one frame inside * another. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_begin(void); /** * @brief Close the UI frame: compute the layout and draw it. * * Runs `Clay_EndLayout` and walks the render commands it produces through * @p self -- fills and rounded fills, borders, clip rectangles, text through * akgl_text_rendertextat(), images through the backend's `draw_texture`. * Draws in screen coordinates on whatever is already on the target, so call * it between akgl_game_update() and the backend's `frame_end`: the UI lands * on top of the world. * * The frame is considered closed even when this fails: the next * akgl_ui_frame_begin() is legal, so one bad frame is one bad frame rather * than a wedged subsystem. * * If clay reported errors during layout -- through its handler, or the * measure callback failing -- this raises the first of them here and does not * draw, because a layout that failed is not a layout, and the raise names how * many more followed it. * * @param self The backend to draw through. Required, along with its * `sdl_renderer` and `draw_texture`. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p self, its `sdl_renderer`, or its * `draw_texture` is `NULL`. * @throws AKGL_ERR_UI If the subsystem is not initialized; if no frame is * open; if clay reported layout errors; if a text command's line * exceeds #AKGL_UI_MAX_TEXT_BYTES; or if a fontId cannot be resolved. * @throws AKERR_* Whatever the drawing primitives underneath raise. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_frame_end(akgl_RenderBackend *self); /** * @brief Declare a dialog panel: the JRPG textbox in one call. * * A panel across the bottom of the screen, inset by the style's padding, * #AKGL_UI_DIALOG_HEIGHT pixels tall, with a one-pixel border and the text * wrapped inside it. Legal only between akgl_ui_frame_begin() and * akgl_ui_frame_end(). Showing and hiding is the frame's business: call this * while somebody is talking and don't while nobody is -- there is no * `visible` flag, because a declarative frame *is* the flag. * * @param id Element id for the panel, unique within the frame. Required. * Borrowed until frame_end. * @param text What the dialog says. Required, and borrowed until frame_end * -- clay keeps the pointer, not a copy. May be empty, which * draws an empty panel. * @param style Look to draw with, or `NULL` for the textbox palette. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is * open. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style); /** * @brief Declare a HUD label: one line of text on a padded panel, pinned to a corner. * * Fit-sized around its text, anchored per @p anchor and inset by the style's * padding. The score counter and the lives counter are two calls to this with * two ids. Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). * * The text is borrowed until frame_end, which matters for the obvious use: * format the score into a buffer that outlives the frame bracket, not into a * scope that closes before the draw. * * @param id Element id for the label, unique within the frame. Required. * @param text What the label says. Required; empty draws an empty chip. * @param anchor Which corner (or the centre) to pin to. * @param style Look to draw with, or `NULL` for the library default. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p id or @p text is `NULL`. * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is * open. * @throws AKERR_OUTOFBOUNDS If @p anchor is not one of the enum's values. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style); /** * @brief Declare a vertical menu from caller-owned state, centred on screen. * * One row per entry, the selected row drawn inverted (ink on fill swaps to * fill on ink). Moving the mouse onto a row selects it; a left click on a * row selects and sets `activated`. A *stationary* pointer claims nothing -- * parking the mouse over the menu must not pin the selection against the * keyboard, which is fighting sixty times a second and losing. Keyboard and * gamepad go through * akgl_ui_menu_handle_event(), which the application calls for whichever * menu currently has its attention -- that routing *is* the focus model. * Legal only between akgl_ui_frame_begin() and akgl_ui_frame_end(). * * @param menu The menu state. Required, with a non-`NULL` `id`, a `count` in * range, every used entry non-`NULL`, and `selected` pointing at * an entry. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p menu, its `id`, or any of its first * `count` items is `NULL`. The message names the index. * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is * open. * @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu(akgl_UiMenu *menu); /** * @brief Drive a menu from a keyboard or gamepad event. * * Up and Down arrows and the D-pad move the selection, wrapping at both * ends; Return and the gamepad South button set `activated`. Anything else * -- and anything while the subsystem is inert -- reports @p consumed false * and succeeds, so it slots into the same pass-everything event chain as * akgl_ui_handle_event(), after it and before the control maps: * * PASS(errctx, akgl_ui_handle_event(&app, &event, &consumed)); * if ( consumed ) { continue; } * if ( menu_active ) { * PASS(errctx, akgl_ui_menu_handle_event(&menu, &event, &consumed)); * if ( consumed ) { continue; } * } * PASS(errctx, akgl_controller_handle_event(&app, &event)); * * @param menu The menu the application is routing input to. Required, * with `count` in range. * @param event The event SDL handed the loop. Required. * @param consumed Receives whether the menu claimed the event. Required. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p menu, @p event, or @p consumed is `NULL`. * @throws AKERR_OUTOFBOUNDS If `count` or `selected` is out of range. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed); /* * The following is part of the internal API. It is exposed so the test suite * can reach it and is not meant to be called by a game. */ /** * @brief The measure callback akgl_ui_init() hands to clay. * * clay's callback signature returns dimensions by value and has no error * channel, so this cannot raise: on any failure -- an unregistered fontId, a * font gone from the registry, SDL_ttf refusing the string -- it reports zero * by zero and stashes the failure where akgl_ui_frame_end() will raise it. * The slice is measured at its stated length through SDL_ttf directly, so it * is never copied and need not be NUL-terminated. * * @param text The slice of text clay wants measured. * @param config The text element's configuration; only `fontId` is * consulted (see akgl_ui_font_register() on `fontSize`). * @param userData Unused. clay passes back whatever init registered. * @return The slice's size in pixels, or zero by zero on failure. */ Clay_Dimensions akgl_ui_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData); /** * @brief Draw one frame's render commands through a backend. * * The renderer half of akgl_ui_frame_end(), separated so a test can hand it * a command array and read pixels back without a full frame bracket. Handles * RECTANGLE, BORDER, TEXT, IMAGE and the SCISSOR pair; CUSTOM commands and * image tint colours are skipped in this version, and per-corner radii are * collapsed to the top-left value -- the widget helpers only produce uniform * corners. * * An IMAGE command's `imageData` is an `akgl_Sprite *`; its first frame is * drawn stretched to the command's bounding box. * * Any clip rectangle a command set is cleared before this returns, success or * failure -- the UI must not leave the next frame's world clipped. * * @note TEXT commands draw through akgl_text_rendertextat(), whose contract * is the *global* `akgl_renderer` -- so a host drawing its UI through * some other backend must keep the two pointed at the same renderer, * or its text lands somewhere else. * * @param self The backend to draw through. Required, along with its * `sdl_renderer` and `draw_texture`. * @param commands The commands `Clay_EndLayout` returned. Required. * @return `NULL` on success, otherwise an error context owned by the caller. * @throws AKERR_NULLPOINTER If @p self, its `sdl_renderer`, its * `draw_texture`, or @p commands is `NULL`. * @throws AKGL_ERR_UI If a text line exceeds #AKGL_UI_MAX_TEXT_BYTES, a * fontId does not resolve, or an IMAGE command carries no sprite. * @throws AKERR_* Whatever the drawing primitives underneath raise. */ akerr_ErrorContext AKERR_NOIGNORE *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_RenderCommandArray *commands); /** * @brief Narrow the arena, so a test can drive akgl_ui_init() into refusal. * * The interesting behaviour is the refusal message naming both numbers, and * the only other way to reach it is rebuilding the library with a smaller * #AKGL_UI_ARENA_BYTES, which CI cannot do and a reader cannot repeat. Same * contract as akgl_ccd_arena_set_limit(). * * @param limit Usable bytes. 0, or anything above #AKGL_UI_ARENA_BYTES, * restores the full arena. */ void akgl_ui_arena_limit(size_t limit); #endif // _AKGL_UI_H_