akgl_ui_handle_event is the mouse half of the input story -- keyboard and gamepad stay with the control maps, because keyboard focus is something an application declares rather than something a pointer position implies. It handles motion, left-button presses and releases, the wheel and window resizes; everything else, and everything while the subsystem is inert, reports consumed=false and succeeds, the same pass-everything contract akgl_controller_handle_event already has. The documented call order is UI first, control maps second, skipping on consumed. A press, release or wheel is consumed when the pointer is over any UI element. The hit test asks clay against the layout the previous frame retained -- this frame's does not exist while events are polled, and one frame of staleness is the standard model's accepted cost -- with clay's internal full-screen Clay__RootContainer excluded, because being inside the window is not being over the interface (unexcluded, every click anywhere was consumed; the test that pins this caught it). Pointer state is fed to clay per event, as clay documents; the press edge is latched once per frame_begin for the widgets to come, rather than trusting Clay_PointerData's per-SetPointerState edge, which advances per mouse event. frame_begin also drives Clay_UpdateScrollContainers from the accumulated wheel (32 pixels per notch) and a wall-clock dt. Tests pin the whole contract: pass-through before init, no consumption before any layout exists, press/release inside vs beside a panel, right button ignored, motion never consumed, wheel consumed only over the UI, resize reaching the layout engine, and key events left alone. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
371 lines
17 KiB
C
371 lines
17 KiB
C
/**
|
|
* @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 `<clay.h>` 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 <stddef.h>
|
|
#include <stdint.h>
|
|
|
|
#include <SDL3/SDL.h>
|
|
|
|
#include <akerror.h>
|
|
#include <clay.h>
|
|
|
|
#include <akgl/renderer.h>
|
|
|
|
/**
|
|
* @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 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);
|
|
|
|
/*
|
|
* 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_
|