diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index f7b24e8..8eac352 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: run: | sudo apt-get update -y sudo apt-get install -y \ - cmake doxygen gcc gcovr pkg-config \ + cmake doxygen gcc gcovr graphviz pkg-config \ libasound2-dev libfreetype-dev libharfbuzz-dev \ libpng-dev libtiff-dev libwebp-dev \ libudev-dev libx11-dev libxcursor-dev libxext-dev \ @@ -56,7 +56,7 @@ jobs: fail_on_failure: 'true' - name: Upload coverage reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: code-coverage path: build/coverage/ @@ -123,7 +123,7 @@ jobs: # the next baseline if somebody re-records PERFORMANCE.md. - name: Upload benchmark tables if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: performance-baseline path: perf-baseline.txt @@ -170,7 +170,7 @@ jobs: run: scripts/memcheck.sh - name: Upload valgrind logs if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: valgrind-logs path: build/Testing/Temporary/MemoryChecker.*.log diff --git a/.gitmodules b/.gitmodules index b8ebc03..e0a7d83 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,18 +1,18 @@ [submodule "deps/semver"] path = deps/semver - url = git@github.com:h2non/semver.c.git + url = https://github.com/h2non/semver.c.git [submodule "deps/SDL"] path = deps/SDL - url = git@github.com:libsdl-org/SDL.git + url = https://github.com/libsdl-org/SDL.git [submodule "deps/SDL_image"] path = deps/SDL_image - url = git@github.com:libsdl-org/SDL_image.git + url = https://github.com/libsdl-org/SDL_image.git [submodule "deps/SDL_mixer"] path = deps/SDL_mixer - url = git@github.com:libsdl-org/SDL_mixer.git + url = https://github.com/libsdl-org/SDL_mixer.git [submodule "deps/SDL_ttf"] path = deps/SDL_ttf - url = git@github.com:libsdl-org/SDL_ttf.git + url = https://github.com/libsdl-org/SDL_ttf.git [submodule "deps/libsdlerror"] path = deps/libakerror url = https://source.starfort.tech/andrew/libakerror.git @@ -21,10 +21,14 @@ url = https://source.starfort.tech/andrew/libakstdlib.git [submodule "deps/jansson"] path = deps/jansson - url = git@github.com:akheron/jansson.git + url = https://github.com/akheron/jansson.git [submodule "deps/libccd"] path = deps/libccd url = https://github.com/danfis/libccd.git [submodule "deps/tg"] path = deps/tg url = https://github.com/tidwall/tg.git +[submodule "clay"] + path = deps/clay + url = https://github.com/nicbarker/clay.git + branch = v0.14 diff --git a/CMakeLists.txt b/CMakeLists.txt index a1ecffa..fe85fff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10) # The single source of truth for the library version. It drives the generated # include/akgl/version.h, the shared library's VERSION and SOVERSION, and the # Version field in akgl.pc. Bump it here and nowhere else. -project(akgl VERSION 0.8.0 LANGUAGES C) +project(akgl VERSION 0.9.0 LANGUAGES C) # Memory checking reuses the suites that already exist -- `ctest -T memcheck` # runs every registered test under valgrind -- rather than adding programs of its @@ -217,6 +217,7 @@ set(AKGL_PUBLIC_HEADERS text tilemap types + ui util ) @@ -317,6 +318,8 @@ add_library(akgl SHARED src/sprite.c src/staticstring.c src/tilemap.c + src/ui.c + src/ui_clay.c src/util.c src/version.c ) @@ -368,6 +371,26 @@ set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h" COMPILE_DEFINITIONS "CCD_STATIC_DEFINE") +# clay supplies the UI layout engine. Its sources are listed rather than +# add_subdirectory()'d, on the semver/libccd precedent, because +# deps/clay/CMakeLists.txt is unusable as a subproject and none of it is ours +# to fix in a submodule: +# +# - it declares no library target at all -- clay is a single header, and the +# add_library(INTERFACE) lines at the bottom are commented out upstream. +# - option(CLAY_INCLUDE_ALL_EXAMPLES ... ON) add_subdirectory()s every +# example by default, dragging raylib, cairo and sokol into our configure. +# - cmake_minimum_required(VERSION 3.27) against this project's 3.10. +# +# src/ui_clay.c is the one translation unit that defines CLAY_IMPLEMENTATION; +# it is 99% vendored code, so it gets -w on the same terms as semver and +# libccd. Deliberately NOT -fvisibility=hidden, unlike libccd: the CLAY() +# macros in a consuming game expand to Clay__OpenElement() and friends, which +# must resolve against libakgl.so, so clay's symbols are part of the ABI on +# purpose. The matching obligation -- a consumer must not link a second clay -- +# is documented in akgl/ui.h. +set_source_files_properties(src/ui_clay.c PROPERTIES COMPILE_OPTIONS "-w") + # PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers` # suite compiles each public header as the first include of a translation unit # with only include/ available, so a public header that pulled in @@ -406,6 +429,7 @@ set(AKGL_TEST_SUITES staticstring text tilemap + ui util version ) @@ -516,6 +540,11 @@ set_tests_properties( target_include_directories(akgl PUBLIC include/ deps/semver/ + # PUBLIC, unlike libccd's PRIVATE include above, because akgl/ui.h includes + # in its public interface -- consumers write CLAY() blocks, so + # hiding the header would hide the point. clay.h is installed beside + # semver.h for the same reason. + deps/clay/ # akgl/version.h is generated by configure_file, so it lives in the build tree # rather than beside the headers it is included from. ${CMAKE_CURRENT_BINARY_DIR}/include/ @@ -633,20 +662,26 @@ if(AKGL_VENDORED_DEPENDENCIES) "${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror" "${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib" ) + # set_property rather than set_tests_properties for the list-valued + # property: set_tests_properties parses its PROPERTIES arguments as + # name/value pairs, so a semicolon-separated value is split and every + # element after the first is consumed as a bogus property name -- which + # silently reduced this prepend list to its first directory for as long as + # it existed. RPATH covered for it locally; a runner found it. if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") set(AKGL_TEST_ENV_MOD "") foreach(dir IN LISTS AKGL_TEST_LIBPATH) list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() - set_tests_properties( - ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} - PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}" + set_property( + TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} + PROPERTY ENVIRONMENT_MODIFICATION ${AKGL_TEST_ENV_MOD} ) else() string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") - set_tests_properties( - ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} - PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" + set_property( + TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} + PROPERTY ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" ) endif() endif() @@ -754,7 +789,12 @@ add_test( set_tests_properties(docs_examples PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 900 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software" +) +# set_property for the same pair-splitting reason as the suites above. +set_property(TEST docs_examples PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_AUDIODRIVER=dummy" + "SDL_RENDER_DRIVER=software" ) # Every figure in docs/ re-rendered and byte-compared against the tracked copy. @@ -779,7 +819,11 @@ add_test( set_tests_properties(docs_screenshots PROPERTIES WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" TIMEOUT 900 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software" +) +set_property(TEST docs_screenshots PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_AUDIODRIVER=dummy" + "SDL_RENDER_DRIVER=software" ) # Regenerating the figures is a deliberate act, never part of a build: the PNGs @@ -832,7 +876,15 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt") --demo --frames 240 --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png" --screenshot-frame 230 - DEPENDS sidescroller jrpg + # Frame 100 of the UI demo's scripted tour is the play screen with the + # dialog up and the score mid-count -- all three widgets in one frame. + COMMAND ${CMAKE_COMMAND} -E env + SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy + $ + --demo --frames 240 + --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/uidemo.png" + --screenshot-frame 100 + DEPENDS sidescroller jrpg uidemo COMMENT "Regenerating the tutorial figures in docs/images" VERBATIM ) @@ -882,11 +934,14 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/") install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/") install(TARGETS akgl DESTINATION "lib/") install(FILES "deps/semver/semver.h" DESTINATION "include/") -# libccd is compiled into libakgl.so, and its BSD-3 licence requires the notice -# to travel with the binary form. Nothing else in deps/ is linked in statically -# -- SDL, jansson, libakerror and libakstdlib are all separate shared objects -# that ship their own -- so this is the only third-party notice we owe. +install(FILES "deps/clay/clay.h" DESTINATION "include/") +# libccd and clay are compiled into libakgl.so, and their licences (BSD-3 and +# zlib respectively) ask that the notice travel with the binary form. Nothing +# else in deps/ is linked in statically -- SDL, jansson, libakerror and +# libakstdlib are all separate shared objects that ship their own -- so these +# are the only third-party notices we owe. install(FILES "deps/libccd/BSD-LICENSE" DESTINATION "share/doc/akgl/" RENAME "BSD-LICENSE.libccd") +install(FILES "deps/clay/LICENSE.md" DESTINATION "share/doc/akgl/" RENAME "LICENSE.clay") foreach(header IN LISTS AKGL_PUBLIC_HEADERS) install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/") endforeach() diff --git a/TODO.md b/TODO.md index 2bd22a2..aeb03ef 100644 --- a/TODO.md +++ b/TODO.md @@ -1273,6 +1273,15 @@ engines spend the same frame**. cached") with the raw-SDL control row the perf rules require. Budget to move: `tests/perf_render.c:391`. + **The UI subsystem is this cache's second consumer, and the bigger one.** + `akgl_ui_execute_commands` (`src/ui.c`) draws every TEXT render command + through `akgl_text_rendertextat`, one wrapped line per command per frame — + a menu of five rows re-rasterizes five lines at 60 Hz whether or not any + of them changed, where the original HUD case was one line changing once a + second. Abstract-on-the-second-consumer says this item's time has come; + the key already fits, since clay hands the executor stable text bytes + between frames. + 3. **Non-raising sprite lookup — target 10, a design change.** Give `akgl_character_sprite_get` (`src/character.c:84-94`) the companion that returns `NULL` without raising, then convert the three sites that raise @@ -2196,6 +2205,18 @@ a live defect -- but nothing rejects it, and `max_timestep` is caller-settable. `renderfunc`. This item is the half that remains: one uniform `scale`, with no way to expand a single axis. +3. **`akgl_ControlMap.mouseid` and `.penid` are still dead fields, deliberately.** + The UI subsystem (0.9.0) brought the mouse into the library, and it would have + been natural to wire these up on the way -- it did not, on purpose. Control maps + translate device events into *actor handler calls*, and the UI wants absolute + pointer state per event; those are different consumers, and building + gameplay-mouse machinery into `src/controller.c` with no game asking for it is + the abstract-before-the-second-consumer mistake. The mouse path lives in + `src/ui.c` (`akgl_ui_handle_event`); when a game wants mouse-driven actors, + *that* is the consumer these fields were declared for, and the work lands in + `controller.c` then. Same for `.axis`, `.axis_range_min` and `.axis_range_max`, + which `akgl_controller_handle_event` has never consulted. + ## Found while writing the manual Twenty-one chapters and two tutorial games were written against `src/` rather than diff --git a/deps/clay b/deps/clay new file mode 160000 index 0000000..b25a31c --- /dev/null +++ b/deps/clay @@ -0,0 +1 @@ +Subproject commit b25a31c1a152915cd7dd6796e6592273e5a10aac diff --git a/docs/04-errors.md b/docs/04-errors.md index 451a5f0..64d0277 100644 --- a/docs/04-errors.md +++ b/docs/04-errors.md @@ -25,7 +25,7 @@ The house rules for *writing* code against the protocol — never a `*_RETURN` i libakerror reserves statuses 0–255 for the host's `errno` values and its own `AKERR_*` codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**. -libakgl claims a band of six starting there, under the owner string `"libakgl"`: +libakgl claims a band of seven starting there, under the owner string `"libakgl"`: ```c excerpt=include/akgl/error.h #define AKGL_ERR_OWNER "libakgl" @@ -37,12 +37,13 @@ libakgl claims a band of six starting there, under the owner string `"libakgl"`: #define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */ #define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */ #define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */ +#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */ ``` -**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 6`, one past the last +**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 7`, one past the last one, and exists only so `AKGL_ERR_COUNT` can be computed from it. Nothing in `src/` raises it and `akgl_error_init` does not name it. Do not write a `HANDLE(e, AKGL_ERR_LIMIT)` -arm — it would catch nothing, and if a seventh real code is ever added it would silently +arm — it would catch nothing, and if an eighth real code is ever added it would silently start catching that instead. | Code | Value | Means | Raised by | What the caller does | @@ -53,10 +54,12 @@ start catching that instead. | `AKGL_ERR_BEHAVIOR` | 259 | A component did not behave the way its contract requires | **Nothing in the library.** Only `tests/` raises it, through `testutil.h` | Available to you for the same purpose: asserting a contract in your own code | | `AKGL_ERR_LOGICINTERRUPT` | 260 | **Not a failure — a control signal.** "Skip the rest of this step for this actor" | your own `movementlogicfunc` | Raise it deliberately. See the note below | | `AKGL_ERR_COLLISION` | 261 | A collision query could not be answered | **One cause only**: `akgl_collision_test`, when the ccd arena is exhausted. The message carries the high-water mark | Raise `AKGL_CCD_ARENA_BYTES` to the reported figure. See [Chapter 15](15-collision.md) | +| `AKGL_ERR_UI` | 262 | The UI subsystem refused, or clay reported a layout error | `akgl_ui_*` — lifecycle misuse (init twice, a widget outside the frame bracket), an arena or table at its ceiling, and layout errors surfacing from `akgl_ui_frame_end` | The message says which; the ceilings are in [Chapter 23](23-appendix-limits.md). See [Chapter 22](22-ui.md) | `akgl_error_init` reserves the band all-or-nothing and registers a name for each of the -six: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`, -`"Logic Interrupt"`, `"Collision Error"`. Those names are what a stack trace prints. +seven: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`, +`"Logic Interrupt"`, `"Collision Error"`, `"UI Error"`. Those names are what a stack trace +prints. Two rows deserve more than a cell. @@ -99,7 +102,7 @@ Statuses raised by the libraries underneath also reach you unchanged. `aksl_fope `akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`, `AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`. -[Chapter 22](22-appendix-limits.md) has the per-function cross-reference. +[Chapter 23](23-appendix-limits.md) has the per-function cross-reference. ## Table 3 — the exit status trap @@ -266,6 +269,6 @@ Four things in there are libakgl-specific and worth naming: release. - [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and `AKERR_NULLPOINTER`. -- [Chapter 22](22-appendix-limits.md) — which function raises what. +- [Chapter 23](23-appendix-limits.md) — which function raises what. - `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion. - The generated Doxygen reference — every function's own `@throws` list. diff --git a/docs/05-the-heap.md b/docs/05-the-heap.md index 713b5e2..5db47c9 100644 --- a/docs/05-the-heap.md +++ b/docs/05-the-heap.md @@ -344,4 +344,4 @@ all visible in `src/heap.c`. - [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables. - [Chapter 6](06-the-registry.md) — the registries the releases clear entries from. -- [Chapter 22](22-appendix-limits.md) — every `AKGL_MAX_*` in one place. +- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place. diff --git a/docs/06-the-registry.md b/docs/06-the-registry.md index 41056d3..37758f8 100644 --- a/docs/06-the-registry.md +++ b/docs/06-the-registry.md @@ -224,5 +224,5 @@ registry's. - [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created, and where configuration has to be in place by. - [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do. -- [Chapter 22](22-appendix-limits.md) — the configuration table again, alongside every +- [Chapter 23](23-appendix-limits.md) — the configuration table again, alongside every `AKGL_MAX_*`. diff --git a/docs/07-the-game-and-the-frame.md b/docs/07-the-game-and-the-frame.md index 18268f0..3c6e13d 100644 --- a/docs/07-the-game-and-the-frame.md +++ b/docs/07-the-game-and-the-frame.md @@ -367,4 +367,4 @@ error-reporting path. See [Chapter 4](04-errors.md). what `draw_world` does with the layers. - [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not pace. -- [Chapter 22](22-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants. +- [Chapter 23](23-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants. diff --git a/docs/13-tilemaps.md b/docs/13-tilemaps.md index 0536676..33afb3d 100644 --- a/docs/13-tilemaps.md +++ b/docs/13-tilemaps.md @@ -37,7 +37,7 @@ why the library's own map, `akgl_default_gamemap`, is a file-scope object in The bounds are fixed at compile time, so the size is fixed too. Every field is present whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same 25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and -changes the ABI — see [Chapter 22](22-appendix-limits.md). +changes the ABI — see [Chapter 23](23-appendix-limits.md). ## Limits diff --git a/docs/22-ui.md b/docs/22-ui.md new file mode 100644 index 0000000..d79ef20 --- /dev/null +++ b/docs/22-ui.md @@ -0,0 +1,396 @@ +# 22. User interfaces + +![The UI demo's play screen: a LIVES label top-left, a SCORE label top-right, and a dialog panel across the bottom, over a checkerboard standing in for a game world](images/uidemo.png) + +Every game eventually wants a dialog box, a score counter, or a menu — and hand-rolling +them out of rectangles and `akgl_text_rendertextat` is tolerable exactly once. +[Chapter 21](21-tutorial-jrpg.md) does it once, on purpose, and its 125-line +`textbox.c` is still the right call for one panel with no ambitions. This chapter is for +everything past that point: HUDs, menus, options screens, and the layout arithmetic that +makes them miserable to maintain by hand. + +The layout engine is [clay](https://github.com/nicbarker/clay), vendored under +`deps/clay` and compiled into `libakgl.so`. Per this manual's rule, clay's own API is +documented by clay — its README is thorough — and this chapter covers what libakgl adds +or constrains. The split: + +| clay owns | libakgl owns | +|---|---| +| The layout algorithm and the `CLAY()` declaration DSL | The arena clay allocates from — static storage, no `malloc`, sized by `AKGL_UI_ARENA_BYTES` | +| Sizing, padding, floating elements, scroll containers | Text measurement, through SDL_ttf and the font registry | +| The render command list each frame produces | Drawing those commands, through the render backend | +| Hover and pointer-over queries | Feeding it the mouse, and telling *you* which events the UI consumed | + +**There are two ways in, and they compose.** The widget helpers — `akgl_ui_dialog`, +`akgl_ui_label`, `akgl_ui_menu` — cover the common cases in one call each, and a simple +game never touches a `CLAY()` macro. When a screen outgrows them, you write clay's +declarative blocks yourself between the same two frame calls, with the whole DSL +available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does +both: its title menu and HUD are widgets, its options screen is raw `CLAY()`. + +**What this chapter assumes.** A program that already brings libakgl up — the +`akgl_game_init` startup order from [Chapter 7](07-the-game-and-the-frame.md) (or +[Chapter 3](03-getting-started.md)'s smallest window), plus one loaded font from +[Chapter 17](17-text-and-fonts.md). None of that is restated here, and the UI adds only +three calls to it, shown below. When something in *your* bring-up fights you, the demo's +`startup()` in [`examples/uidemo/uidemo.c`](../examples/uidemo/uidemo.c) is the complete +working sequence to diff against — including the `main()` shape, which the runnable +snippets in this manual deliberately hide. + +Like collision, the subsystem is optional at runtime rather than at build time: it is +always compiled in, costs static storage until `akgl_ui_init` runs, and a game that +never calls that pays nothing else. + +**One warning before any code.** libakgl ships clay inside `libakgl.so` and exports its +symbols, because the `CLAY()` macros in *your* translation units expand to calls into +them. Do not define `CLAY_IMPLEMENTATION` anywhere and do not link a second copy of clay +— two definitions of the same symbols, and the loader picks one silently. + +## Bring it up, lay something out + +`akgl_ui_init(width, height)` takes the layout size as parameters — deliberately not +read from the camera or the window, so a headless program can bring the UI up with no +renderer at all. It bounds clay to the `AKGL_UI_*` ceilings, checks the arena fits them, +and refuses **with both byte counts in the message** when it does not: a raised ceiling +without a raised arena is a loud startup failure, never a corruption at frame forty +thousand. + +After that, a frame of UI is a bracket with declarations inside: + +```c run=akglapp +PASS(errctx, akgl_ui_init(320, 240)); + +PASS(errctx, akgl_ui_frame_begin()); +CLAY({ + .id = CLAY_ID("panel"), + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(120), + .height = CLAY_SIZING_FIXED(40) + } + }, + .backgroundColor = { 24, 20, 37, 255 } +}) {} +PASS(errctx, akgl_ui_frame_end(akgl_renderer)); + +PASS(errctx, akgl_ui_shutdown()); +printf("one panel, laid out and drawn\n"); +``` + +```output +one panel, laid out and drawn +``` + +`frame_begin` starts the clay layout and feeds it the pointer state the event handler +has been accumulating; `frame_end` computes the layout and draws every render command it +produces through the backend — fills and rounded fills, borders, clip rectangles, text, +and sprites. Everything lands in screen coordinates on top of whatever is already on the +target. + +Text needs one more step at startup: fonts. clay names a font by a `uint16_t` fontId; +libakgl names one by a registry key ([Chapter 17](17-text-and-fonts.md)). The bridge is +`akgl_ui_font_register`, and the demo's whole font story is three lines: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE)); + PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT)); + PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid)); +``` + +The first font registered gets id 0, which is what the widgets' default style uses — so +a one-font game never mentions a fontId again. Two things worth knowing before they +surprise you: + +- **`Clay_TextElementConfig.fontSize` is ignored.** A libakgl font bakes its size in at + load ([Chapter 17](17-text-and-fonts.md) explains why); the size text renders at is + the size the font behind its id was loaded at. One face at two sizes is two loads, two + registrations, two ids. +- The table stores the *name* and resolves it per use, so `akgl_text_unloadfont` on a + registered font makes the next frame fail loudly with the name in the message — not + dangle. + +## The frame contract + +Where the bracket goes in a real frame, from the demo — compare the JRPG's `frame()`, +which calls `akgl_game_update` where this program paints a checkerboard: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + if ( state == UIDEMO_STATE_PLAY ) { + PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT)); + score += 1; + } + + PASS(errctx, akgl_ui_frame_begin()); + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, declare_title()); + break; + case UIDEMO_STATE_OPTIONS: + PASS(errctx, declare_options()); + break; + case UIDEMO_STATE_PLAY: + PASS(errctx, declare_play()); + break; + default: + break; + } + PASS(errctx, akgl_ui_frame_end(akgl_renderer)); +``` + +The UI draws after the world because it is declared after the world — the overlay slot +between `akgl_game_update` and the backend's `frame_end` is exactly where the JRPG drew +its hand-rolled text box, and nothing about that slot changed. + +Events go through the UI *first*. `akgl_ui_handle_event` takes every event +unconditionally — the same pass-everything contract as `akgl_controller_handle_event` — +and reports back whether the UI claimed it, so a click on a menu never leaks through and +also fires a game control: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed)); + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed)); + break; +``` + +Three rules govern what gets consumed, and each is a decision worth stating: + +- **Presses, releases and the wheel are 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; one frame of staleness is the standard model's accepted cost, + and before any frame has been laid out, nothing is over anything. +- **Keyboard events are never consumed by the UI itself.** Which menu hears the arrow + keys is something the application declares — the `switch` above *is* the focus model — + not something a pointer position implies. + +## A dialog in one call — and what it replaces + +The play screen's declarations, whole: + +```c excerpt=examples/uidemo/uidemo.c + PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score)); + PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives)); + PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + if ( dialog_open ) { + PASS(errctx, akgl_ui_dialog("dialog", + "This panel is one call. Space dismisses it; " + "compare examples/jrpg/textbox.c.", + NULL)); + } +``` + +Note what is absent: no `visible` flag, no draw call, no geometry. The dialog is open +because this frame declares it — a declarative frame *is* the flag. The text buffers are +`static` because clay borrows the pointer until `frame_end` rather than copying; format +your score into storage that outlives the bracket. + +Now the same panel the way [Chapter 21](21-tutorial-jrpg.md) builds it, which is the +comparison this chapter owes you. The hand-rolled version keeps a flag and a copy of the +string, and its draw function does the geometry itself: + +```c excerpt=examples/jrpg/textbox.c + panel.x = TEXTBOX_MARGIN; + panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN); + panel.h = TEXTBOX_HEIGHT; + panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h; + + PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL)); + PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE)); + PASS(errctx, + akgl_text_rendertextat( + font, + textbox_text, + TEXTBOX_INK, + (int)(panel.w - (2.0f * TEXTBOX_PADDING)), + (int)(panel.x + TEXTBOX_PADDING), + (int)(panel.y + TEXTBOX_PADDING) + )); +``` + +The widget's default style is **deliberately that panel's palette** — near-black fill, +parchment edge and ink, 8 pixels of padding — so the two produce the same picture, and +the trade is visible with nothing hidden in a theme: + +- `textbox.c` is 125 lines you own completely. It costs no new concepts, no arena, no + frame bracket; its geometry is four assignments you can read. For one panel, that is a + perfectly good deal — which is why chapter 21 still teaches it and its game still + ships it. +- `akgl_ui_dialog` is one line that costs you the subsystem: `akgl_ui_init`, a + registered font, and the bracket in your frame. The payment buys every *next* piece of + interface — the second panel is also one line, the score label is one line, the menu + is a struct — and the layout arithmetic, wrapping, and stacking are clay's problem + from then on. + +Neither is deprecated. The library's own position: build one panel by hand; build an +interface on the subsystem. + +## Menus: one selection, three devices + +A menu is caller-owned state — a struct you keep, the way `textbox.c` keeps its statics. +There is no heap pool behind it because it owns no texture, no font, and no registry +entry: + +```c excerpt=examples/uidemo/uidemo.c +static akgl_UiMenu title_menu = { + .id = "title", + .items = { "Start", "Options", "Quit" }, + .count = 3, +}; +``` + +Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and +they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the +arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad +South — exactly `SDLK_UP`, `SDLK_DOWN` and `SDLK_RETURN` against `SDL_Event.key.key`, +and `SDL_GAMEPAD_BUTTON_DPAD_UP`, `_DPAD_DOWN` and `_SOUTH` against +`SDL_Event.gbutton.button`. Keycodes, not scancodes — which only matters when you +*synthesize* events, as the demo's `--demo` script does: a hand-built key event must +fill in `key.key`, or it matches nothing and the menu silently ignores it. The mouse +selects by *moving onto* a row and activates by clicking one, during the declaration +itself. A stationary pointer claims nothing — parking the mouse over the +menu must not pin the selection against the keyboard, which would otherwise fight it +sixty times a second and lose. + +`activated` latches until you clear it, so the check lives wherever reading it is +convenient — the demo reads it after the frame closes, which catches both input routes: + +```c excerpt=examples/uidemo/uidemo.c + if ( title_menu.activated ) { + title_menu.activated = false; + switch ( title_menu.selected ) { + case 0: + state = UIDEMO_STATE_PLAY; + score = 0; + dialog_open = false; + break; + case 1: + state = UIDEMO_STATE_OPTIONS; + break; + case 2: + default: + running = false; + break; + } + } +``` + +## Writing layout directly with CLAY() + +The options screen uses no widgets, and exists to show that the widgets are a +convenience rather than a boundary. One row of it carries every idea — hover styling +computed *inside* the declaration, and a click paired with a press edge the application +tracks itself: + +```c excerpt=examples/uidemo/uidemo.c + CLAY({ + .id = CLAY_ID("options-music"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_music = !opt_music; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } +``` + +`clicked` is one application-owned `bool`: set when a left press arrives in the event +loop — *before* `akgl_ui_handle_event` can consume it, because a click on a UI row is +always consumed — and cleared at the end of the frame. The widgets keep an equivalent +edge internally; a raw screen keeps its own. Everything else — the sizing model, scroll +containers, floating attach points, aspect ratios — is clay's DSL, and clay's README +documents it far better than a restatement here would. + +## Images and styles + +An image on a UI element is a sprite: set `.image.imageData` in a `CLAY()` declaration +to an `akgl_Sprite *` ([Chapter 10](10-spritesheets-and-sprites.md)) and its first frame +is drawn stretched to the element's box. Two current limits, both deliberate scope +rather than accident: clay's image tint colour and `CUSTOM` render commands are skipped +by the executor, and per-corner radii collapse to the top-left value — the widgets only +produce uniform corners. The mouse cursor stays the operating system's; a game that +wants a themed cursor draws a floating image element at the pointer, which makes a good +exercise. + +A style is one struct shared by all three widgets — colours, padding, corner radius, +fontId — and `NULL` means the textbox palette described above. There is no per-field +defaulting: a transparent fill and a zero radius are things a style legitimately says, +so copy the default and change what you mean to change: + +```c wrap=akglbody +akgl_UiStyle alert = { + .fill = { 120, 24, 24, 235 }, + .edge = { 240, 236, 214, 255 }, + .ink = { 240, 236, 214, 255 }, + .padding = 8.0f, + .corner_radius = 6.0f, + .fontid = 0 +}; + +PASS(errctx, akgl_ui_dialog("alert", "The reactor is on fire.", &alert)); +``` + +## When things go wrong + +Everything here reports through the ordinary error protocol +([Chapter 4](04-errors.md)) under one status, `AKGL_ERR_UI`, and the message says which +refusal it was. Lifecycle misuse fails at the call that misused it: + +```text +/home/andrew/source/libakgl/src/ui.c:ui_widget_ready:850: 262 (UI Error) : Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end +/home/andrew/source/libakgl/src/ui.c:akgl_ui_dialog:866 +/home/andrew/source/libakgl/main.c:main:13 +/home/andrew/source/libakgl/main.c:main:16: Unhandled Error 262 (UI Error): Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end +``` + +Errors *inside the layout* cannot fail at the call that caused them, because clay +reports through a void callback with libakgl nowhere on the stack. So they are logged as +they happen, stashed, and raised from `akgl_ui_frame_end` — the earliest point the +protocol can carry them — with the first message and a count of the rest. A frame that +fails is one bad frame: the next `akgl_ui_frame_begin` is legal, and the executor clears +any clip rectangle on its way out so a failed UI cannot leave the next frame's *world* +clipped. + +The ceilings — elements, measured words, arena bytes, fonts, text-run length, menu +entries — are all in [Chapter 23](23-appendix-limits.md), every one of them overridable, +and every refusal names the number to raise. + +## Build it and run it + +The demo builds with the library: + +```sh norun +cmake -S . -B build +cmake --build build -j$(nproc) +./build/examples/uidemo/uidemo +``` + +Arrow keys, Return, the D-pad and the mouse all drive the title menu; Space opens the +dialog on the play screen; Escape backs out. `ctest -R example_uidemo` runs the same +program headless through a scripted tour of every screen. + +## Where to look next + +- [clay's README](https://github.com/nicbarker/clay) — the layout DSL itself: sizing, + floating elements, scroll containers, and the debug tools. +- [Chapter 16](16-input.md) — the control maps that own the keyboard and gamepad once + the UI has declined an event. +- [Chapter 17](17-text-and-fonts.md) — fonts, the registry, and what text costs per + frame; a UI panel of text pays the same immediate-mode price. +- [Chapter 21](21-tutorial-jrpg.md) — the hand-rolled text box this chapter keeps + comparing against, in the game that earns it. +- [Chapter 23](23-appendix-limits.md) — every `AKGL_UI_*` limit and the `ui.h` status + cross-reference. diff --git a/docs/22-appendix-limits.md b/docs/23-appendix-limits.md similarity index 87% rename from docs/22-appendix-limits.md rename to docs/23-appendix-limits.md index a996d61..d0fbb03 100644 --- a/docs/22-appendix-limits.md +++ b/docs/23-appendix-limits.md @@ -1,4 +1,4 @@ -# 22. Appendix: limits, statuses and properties +# 23. Appendix: limits, statuses and properties Three reference tables that no single chapter owns: which function raises which status, every compile-time bound, and every configuration property the library reads. @@ -148,6 +148,9 @@ What the statuses mean is [Chapter 4](04-errors.md). | `akgl_draw_circle` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` | | `akgl_draw_flood_fill` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, `AKERR_OUTOFBOUNDS` past `AKGL_DRAW_MAX_FLOOD_SPANS` | | `akgl_draw_copy_region`, `_paste_region` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` | +| `akgl_draw_filled_rounded_rect` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` | +| `akgl_draw_arc` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` | +| `akgl_draw_set_clip` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` — a `NULL` rectangle is legal here and clears the clip | ### `tilemap.h` @@ -176,6 +179,22 @@ What the statuses mean is [Chapter 4](04-errors.md). | `akgl_controller_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site | | `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` | +### `ui.h` + +| Function | Raises | +|---|---| +| `akgl_ui_init` | `AKERR_OUTOFBOUNDS`, `AKGL_ERR_UI` — already initialized, or an arena clay's structures do not fit; the message carries both byte counts | +| `akgl_ui_shutdown` | *nothing — idempotent by design* | +| `akgl_ui_resize` | `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` | +| `akgl_ui_font_register` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` past `AKGL_UI_FONT_NAME_LENGTH` | +| `akgl_ui_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` from a resize event | +| `akgl_ui_frame_begin` | `AKGL_ERR_UI` — uninitialized, or a frame already open | +| `akgl_ui_frame_end` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` — no frame open, clay layout errors, an over-long text run, an unresolvable fontId — plus whatever the draw primitives raise | +| `akgl_ui_dialog`, `_label`, `_menu` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` outside the frame bracket; `_label` and `_menu` add `AKERR_OUTOFBOUNDS` | +| `akgl_ui_menu_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` | +| `akgl_ui_execute_commands` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, plus whatever the draw primitives raise | +| `akgl_ui_measure_text` | *cannot report — clay's callback signature; failures stash and surface from `akgl_ui_frame_end`* | + ### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h` | Function | Raises | @@ -196,8 +215,9 @@ What the statuses mean is [Chapter 4](04-errors.md). ## B. Compile-time limits -Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight -and `AKGL_CCD_ARENA_BYTES` are overridable**, and even those have to be overridden for the whole build — see +Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight, +`AKGL_CCD_ARENA_BYTES` and the `AKGL_UI_*` family are overridable**, and even those have to +be overridden for the whole build — see [Chapter 5](05-the-heap.md), "The ceilings are a compile-time ABI constraint". The rest are plain `#define`s with no `#ifndef` guard: changing one means editing the header and rebuilding libakgl and everything linking it. @@ -347,6 +367,31 @@ keeps instead of recursing per pixel. A region needing more pending runs at once `AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape needs a few dozen. +`AKGL_DRAW_ROUNDED_RECT_SEGMENTS` is 16 — triangles per corner of +`akgl_draw_filled_rounded_rect`. `AKGL_DRAW_ARC_MAX_POINTS` is 64 — the vertex ceiling one +`akgl_draw_arc` spends, two per step along the curve; the step count scales with the swept +angle under it. Both size fixed arrays, so neither is a per-call cost and neither is +overridable. + +### The UI + +Every `AKGL_UI_*` limit is guarded by `#ifndef`, so a consumer may override any of them the +way the pool ceilings are overridden — for the whole build, before `` is seen. +`akgl_ui_init` checks that the arena still fits the element and word ceilings and refuses +with both byte counts in the message when it does not, so a raised ceiling without a raised +arena is a loud startup failure rather than a corruption. + +| Constant | Value | Bounds | +|---|---|---| +| `AKGL_UI_MAX_ELEMENTS` | 1024 | Elements one layout may declare | +| `AKGL_UI_MAX_MEASURE_WORDS` | 4096 | Words clay's text-measurement cache holds | +| `AKGL_UI_ARENA_BYTES` | 1048576 | Static storage clay lays its structures out in (812544 needed at the defaults) | +| `AKGL_UI_MAX_FONTS` | 8 | fontId table slots | +| `AKGL_UI_FONT_NAME_LENGTH` | 64 | Bytes per fontId slot's registry name, terminator included | +| `AKGL_UI_MAX_TEXT_BYTES` | 1024 | Bytes one text render command's line may occupy | +| `AKGL_UI_MENU_MAX_ITEMS` | 16 | Entries one `akgl_UiMenu` may carry | +| `AKGL_UI_DIALOG_HEIGHT` | 56 | Height of the `akgl_ui_dialog` panel, in pixels | + ### Audio ```c excerpt=include/akgl/audio.h @@ -383,8 +428,8 @@ budget and was wrong by a factor of a thousand, blocking for roughly sixteen min | Constant | Value | |---|---| | `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) | -| `AKGL_ERR_COUNT` | 5 | -| `AKGL_ERR_LIMIT` | 261 — **the one-past-the-end sentinel, not a status** | +| `AKGL_ERR_COUNT` | 7 | +| `AKGL_ERR_LIMIT` | 263 — **the one-past-the-end sentinel, not a status** | ## C. Configuration properties diff --git a/docs/README.md b/docs/README.md index c47e41f..a116205 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,8 @@ per-function reference, build the Doxygen output with `doxygen Doxyfile`. | **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings | | **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Thirteen steps from an empty directory to a game with gravity, a jump, coins and hazards. **Start here** | | **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | Thirteen more, for four-way movement, NPCs, a text box, a follower, and freezing the world | -| **[22. Appendix](22-appendix-limits.md)** | Every limit, every status, every configuration property | +| **[22. User interfaces](22-ui.md)** | Menus, HUDs and dialogs on the clay layout engine: the widgets, the frame bracket, and writing `CLAY()` yourself | +| **[23. Appendix](23-appendix-limits.md)** | Every limit, every status, every configuration property | **If you are new, read chapter 20 first and read it in order.** It builds a working game from an empty directory and teaches the library as it needs each piece; chapter 21 assumes it. diff --git a/docs/images/uidemo.png b/docs/images/uidemo.png new file mode 100644 index 0000000..67e5fe1 Binary files /dev/null and b/docs/images/uidemo.png differ diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 4b444df..adf1cfb 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,4 +1,4 @@ -# The two tutorial games. +# The tutorial games and demos. # # Each one is a complete, running program that the matching chapter quotes with # `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot @@ -9,7 +9,7 @@ # passes and land at different times, and a configure that fails because one of # them is not there yet would block the other. There is nothing clever about the # guard; it exists so an incomplete tree still builds. -foreach(_example sidescroller jrpg) +foreach(_example sidescroller jrpg uidemo) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt") add_subdirectory(${_example}) endif() diff --git a/examples/jrpg/CMakeLists.txt b/examples/jrpg/CMakeLists.txt index d67abbb..b353240 100644 --- a/examples/jrpg/CMakeLists.txt +++ b/examples/jrpg/CMakeLists.txt @@ -47,26 +47,31 @@ endif() # it to suppress the vendored projects' registrations and lifts the suppression # again long before examples/ is added. add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo) -set_tests_properties(example_jrpg PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +set_tests_properties(example_jrpg PROPERTIES TIMEOUT 120) +# set_property, not set_tests_properties: the latter parses its PROPERTIES +# arguments as name/value *pairs*, so a semicolon-separated value is split and +# every element after the first is consumed as a bogus property name. The +# audio and render entries of this list were silently lost that way -- +# invisible on any machine whose real audio device works, and found the first +# time CI ran on a runner without one. +set_property(TEST example_jrpg PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_RENDER_DRIVER=software" + "SDL_AUDIODRIVER=dummy" ) -# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has -# to go in the same property rather than a second one. Only needed when the -# dependencies were vendored; an installed build resolves them normally. +# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they +# were vendored; an installed build resolves them normally. if(AKGL_VENDORED_DEPENDENCIES) if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") set(JRPG_TEST_ENV_MOD "") foreach(dir IN LISTS AKGL_TEST_LIBPATH) list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") endforeach() - set_tests_properties(example_jrpg - PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}") + set_property(TEST example_jrpg PROPERTY ENVIRONMENT_MODIFICATION ${JRPG_TEST_ENV_MOD}) else() string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") - set_tests_properties(example_jrpg PROPERTIES - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" - ) + set_property(TEST example_jrpg APPEND PROPERTY ENVIRONMENT + "LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}") endif() endif() diff --git a/examples/sidescroller/CMakeLists.txt b/examples/sidescroller/CMakeLists.txt index e397d0a..f58687b 100644 --- a/examples/sidescroller/CMakeLists.txt +++ b/examples/sidescroller/CMakeLists.txt @@ -41,7 +41,15 @@ endif() # while this project is top-level, and by the time examples/ is added the # suppression has already been lifted. An embedded build gets the builtin. add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay) -set_tests_properties(example_sidescroller PROPERTIES - TIMEOUT 120 - ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" +set_tests_properties(example_sidescroller PROPERTIES TIMEOUT 120) +# set_property, not set_tests_properties: the latter parses its PROPERTIES +# arguments as name/value *pairs*, so a semicolon-separated value is split and +# every element after the first is consumed as a bogus property name. The +# audio and render entries of this very list were silently lost that way for +# as long as the list existed -- invisible on any machine whose real audio +# device works, and found the first time CI ran on a runner without one. +set_property(TEST example_sidescroller PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_RENDER_DRIVER=software" + "SDL_AUDIODRIVER=dummy" ) diff --git a/examples/uidemo/CMakeLists.txt b/examples/uidemo/CMakeLists.txt new file mode 100644 index 0000000..f509fd5 --- /dev/null +++ b/examples/uidemo/CMakeLists.txt @@ -0,0 +1,62 @@ +# The UI demo, quoted by the UI chapter's `c excerpt=` blocks. +# +# A real target built by `all`, on the same terms as the tutorial games: a +# chapter whose program does not compile is the failure the documentation +# harness exists to stop. + +add_executable(uidemo + uidemo.c +) + +target_link_libraries(uidemo + PRIVATE akstdlib::akstdlib akerror::akerror akgl + SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer + jansson::jansson -lm) +target_include_directories(uidemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_options(uidemo PRIVATE ${AKGL_WARNING_FLAGS}) + +# The demo needs no art -- its "world" is akgl_draw_background -- but it does +# need one font, and it uses the same test-asset face the JRPG does, compiled +# in as an absolute path for the same run-from-anywhere reason. +get_filename_component(UIDEMO_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) +target_compile_definitions(uidemo PRIVATE + UIDEMO_FONT_FILE="${UIDEMO_REPO_ROOT}/tests/assets/akgl_test_mono.ttf" +) + +if(AKGL_VENDORED_DEPENDENCIES) + set_target_properties(uidemo PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}") +endif() + +# The headless smoke run. `--demo` tours every screen through the real event +# chain -- a mouse click on the title menu (the consumed path), keyboard into +# and out of the raw-CLAY options screen, the play screen's dialog opened and +# dismissed, and Quit confirmed from the menu -- rather than just proving that +# main() returns. There is no physics here, so nothing needs a driven clock; +# `--frames` is a backstop above the script's last step, not the exit path. +add_test(NAME example_uidemo COMMAND uidemo --frames 240 --demo) +set_tests_properties(example_uidemo PROPERTIES TIMEOUT 120) +# set_property, not set_tests_properties: the latter parses its PROPERTIES +# arguments as name/value *pairs*, so a semicolon-separated value is split and +# every element after the first is consumed as a bogus property name. See the +# same note in the other examples' CMakeLists. +set_property(TEST example_uidemo PROPERTY ENVIRONMENT + "SDL_VIDEODRIVER=dummy" + "SDL_RENDER_DRIVER=software" + "SDL_AUDIODRIVER=dummy" +) + +# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they +# were vendored; an installed build resolves them normally. +if(AKGL_VENDORED_DEPENDENCIES) + if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") + set(UIDEMO_TEST_ENV_MOD "") + foreach(dir IN LISTS AKGL_TEST_LIBPATH) + list(APPEND UIDEMO_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") + endforeach() + set_property(TEST example_uidemo PROPERTY ENVIRONMENT_MODIFICATION ${UIDEMO_TEST_ENV_MOD}) + else() + string(REPLACE ";" ":" UIDEMO_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") + set_property(TEST example_uidemo APPEND PROPERTY ENVIRONMENT + "LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}") + endif() +endif() diff --git a/examples/uidemo/uidemo.c b/examples/uidemo/uidemo.c new file mode 100644 index 0000000..d8152cf --- /dev/null +++ b/examples/uidemo/uidemo.c @@ -0,0 +1,609 @@ +/** + * @file uidemo.c + * @brief The UI demo: a title menu, an options screen, and a HUD, three ways. + * + * The chapter on the UI subsystem quotes this program rather than restating + * it. Run it with no arguments for a window: + * + * ./examples/uidemo/uidemo + * + * The title menu answers to the arrow keys, Return, the D-pad, and the mouse. + * Start opens a stand-in play screen -- a checkerboard where a game would be + * -- with a score counter ticking in a HUD label; Space opens and closes a + * dialog panel there, and Escape backs out. Options is a screen written in + * raw CLAY() declarations, because the widgets are a convenience, not a + * boundary. `--frames N` bounds the run and `--demo` drives the whole tour + * from a script, which is what makes this runnable as a headless smoke test. + * + * What is deliberately absent: a tilemap, actors, physics. The world here is + * one draw call, so everything left is the subject -- what a UI costs and + * where it goes in a frame. + */ + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "uidemo.h" + +/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */ +#define UIDEMO_FRAME_BUDGET_MS 16 + +/* + * The scripted tour `--demo` drives. Every route through the demo, by every + * input device it supports: the mouse clicks the title menu open (dead centre + * lands on the middle row by symmetry -- the menu is centred and Options is + * its middle entry), the keyboard walks back out, into the play screen, + * through the dialog, and finally down to Quit. + * + * Frame Event Key / position What it proves + */ +static const uidemo_ScriptStep UIDEMO_SCRIPT[] = { + { 5, SDL_EVENT_MOUSE_MOTION, 0, 320.0f, 240.0f }, /* hover the menu */ + { 10, SDL_EVENT_MOUSE_BUTTON_DOWN, 0, 320.0f, 240.0f }, /* consumed click */ + { 11, SDL_EVENT_MOUSE_BUTTON_UP, 0, 320.0f, 240.0f }, /* -> Options screen */ + { 40, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */ + { 50, SDL_EVENT_KEY_DOWN, SDLK_UP, 0.0f, 0.0f }, /* select Start */ + { 60, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f }, /* -> play screen */ + { 80, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* open the dialog */ + { 120, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* close it again */ + { 140, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */ + { 150, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Options */ + { 160, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Quit */ + { 170, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f } /* and out */ +}; + +#define UIDEMO_SCRIPT_STEPS (sizeof(UIDEMO_SCRIPT) / sizeof(UIDEMO_SCRIPT[0])) + +static long frame_limit = 0; +/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */ +static char *shotpath = NULL; +static long shotframe = 0; +static bool demo = false; +static bool running = true; +static int exitstatus = 0; +static bool lowfps_warned = false; + +/** @brief Which screen the frame declares. */ +static uidemo_State state = UIDEMO_STATE_TITLE; + +/** + * @brief The title menu. Caller-owned state, zero machinery: this struct and + * the akgl_ui_menu call each frame are the whole main menu. + */ +static akgl_UiMenu title_menu = { + .id = "title", + .items = { "Start", "Options", "Quit" }, + .count = 3, +}; + +/** @brief The options screen's two settings. What the toggles flip. */ +static bool opt_music = true; +static bool opt_sound = true; + +/** @brief The play screen's HUD numbers. The score ticks so the label visibly updates. */ +static int score = 0; +static int lives = 3; +/** @brief Whether the play screen's dialog is up. Space flips it. */ +static bool dialog_open = false; + +/** + * @brief A left click arrived this frame, wherever it landed. + * + * The raw CLAY() options screen needs a press edge to pair with + * Clay_Hovered(), and the application is the right owner of it: it sees + * every event before the UI does. Set in route_event, cleared at the end of + * each frame. The widget helpers keep their own edge internally -- this one + * exists precisely because the options screen does not use them. + */ +static bool clicked = false; + +/** @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. */ +static void lowfps_quiet(void) +{ + if ( lowfps_warned == false ) { + lowfps_warned = true; + SDL_Log("Frame rate is under 30 and this demo does nothing about it"); + } +} + +/** + * @brief Read `--frames N`, `--demo` and the screenshot flags off the command line. + * + * @param argc Argument count, from `main`. + * @param argv Argument vector, from `main`. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p argv is `NULL`. + * @throws AKERR_VALUE If a flag needing an argument is last, or its argument + * is not a number. + */ +static akerr_ErrorContext *parse_args(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + int i = 0; + int number = 0; + + FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv"); + + for ( i = 1; i < argc; i++ ) { + if ( strcmp(argv[i], "--demo") == 0 ) { + demo = true; + } else if ( strcmp(argv[i], "--frames") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count"); + } + i += 1; + PASS(errctx, aksl_atoi(argv[i], &number)); + frame_limit = number; + } else if ( strcmp(argv[i], "--screenshot") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot needs a path"); + } + i += 1; + shotpath = argv[i]; + } else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) { + if ( (i + 1) >= argc ) { + FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot-frame needs a number"); + } + i += 1; + PASS(errctx, aksl_atoi(argv[i], &number)); + shotframe = number; + } else { + FAIL_RETURN(errctx, AKERR_VALUE, + "usage: uidemo [--frames N] [--demo]" + " [--screenshot PATH] [--screenshot-frame N]"); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Bring the library and the UI subsystem up. + * + * The UI half is three calls: load a font, akgl_ui_init at the screen size, + * and register the font for a clay fontId. The id comes back 0 because it is + * the first registered, which is what the widgets' default style uses -- so + * nothing here ever mentions a fontId again. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *startup(void) +{ + PREPARE_ERROR(errctx); + uint16_t fontid = 0; + + PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl UI demo", sizeof(akgl_game.name) - 1)); + PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1)); + PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.uidemo", sizeof(akgl_game.uri) - 1)); + + PASS(errctx, akgl_game_init()); + akgl_game.lowfpsfunc = &lowfps_quiet; + + PASS(errctx, akgl_set_property("game.screenwidth", UIDEMO_SCREEN_WIDTH)); + PASS(errctx, akgl_set_property("game.screenheight", UIDEMO_SCREEN_HEIGHT)); + PASS(errctx, akgl_render_2d_init(akgl_renderer)); + + PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE)); + PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT)); + PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid)); + + SUCCEED_RETURN(errctx); +} + +/** + * @brief Route one event: the UI first, then whatever the current screen wants. + * + * This is the call-order contract from akgl/ui.h in the flesh. The UI gets + * first refusal; a consumed event goes no further, which is what keeps a + * click on a menu from also being a click in the game. Only then does the + * current screen read the keyboard -- and *that* routing is the whole focus + * model: the title menu hears keys because this function sends them there + * while the title screen is up, not because anything owns "focus". + * + * @param event The event to route. Required. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *route_event(SDL_Event *event) +{ + PREPARE_ERROR(errctx); + bool consumed = false; + + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event"); + + if ( event->type == SDL_EVENT_QUIT ) { + running = false; + SUCCEED_RETURN(errctx); + } + // The options screen's press edge, recorded before the UI can consume + // the event -- a click on a toggle row is *always* consumed (the row is + // UI), so waiting until afterwards would record nothing. + if ( (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) + && (event->button.button == SDL_BUTTON_LEFT) ) { + clicked = true; + } + + PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed)); + if ( consumed ) { + SUCCEED_RETURN(errctx); + } + + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed)); + break; + case UIDEMO_STATE_OPTIONS: + if ( (event->type == SDL_EVENT_KEY_DOWN) && (event->key.key == SDLK_ESCAPE) ) { + state = UIDEMO_STATE_TITLE; + } + break; + case UIDEMO_STATE_PLAY: + if ( event->type == SDL_EVENT_KEY_DOWN ) { + if ( event->key.key == SDLK_ESCAPE ) { + state = UIDEMO_STATE_TITLE; + } else if ( event->key.key == SDLK_SPACE ) { + dialog_open = !dialog_open; + } + } + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Feed the scripted events due on this frame through the real routing. + * + * Synthesized SDL_Events, not direct state changes: the demo run proves the + * event chain -- consumed and not, menu and screen -- because it travels it. + * + * @param frameno The frame about to be drawn. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *demo_step(long frameno) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + size_t i = 0; + + for ( i = 0; i < UIDEMO_SCRIPT_STEPS; i++ ) { + if ( UIDEMO_SCRIPT[i].frame != frameno ) { + continue; + } + PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event))); + event.type = UIDEMO_SCRIPT[i].type; + switch ( UIDEMO_SCRIPT[i].type ) { + case SDL_EVENT_KEY_DOWN: + event.key.key = UIDEMO_SCRIPT[i].key; + break; + case SDL_EVENT_MOUSE_MOTION: + event.motion.x = UIDEMO_SCRIPT[i].x; + event.motion.y = UIDEMO_SCRIPT[i].y; + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + case SDL_EVENT_MOUSE_BUTTON_UP: + event.button.button = SDL_BUTTON_LEFT; + event.button.x = UIDEMO_SCRIPT[i].x; + event.button.y = UIDEMO_SCRIPT[i].y; + break; + default: + break; + } + PASS(errctx, route_event(&event)); + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the title screen: one widget call. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_title(void) +{ + PREPARE_ERROR(errctx); + PASS(errctx, akgl_ui_label("heading", "libakgl UI demo", AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + PASS(errctx, akgl_ui_menu(&title_menu)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the options screen in raw CLAY(), no widgets anywhere. + * + * This screen exists to show the other way in: a centred panel, a column of + * rows, hover highlighting via Clay_Hovered() *inside* the declaration, and + * clicks paired with the application's own press edge. Everything the widgets + * do is done with these same pieces; a screen the widgets cannot express is + * written like this rather than waiting for a widget to exist. + * + * The row labels live in statics because clay borrows the pointer until + * frame_end -- a local buffer here would be dangling by the time the text is + * drawn. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_options(void) +{ + PREPARE_ERROR(errctx); + static char musicrow[32]; + static char soundrow[32]; + int count = 0; + + PASS(errctx, aksl_snprintf(&count, musicrow, sizeof(musicrow), "Music: %s", opt_music ? "ON" : "OFF")); + PASS(errctx, aksl_snprintf(&count, soundrow, sizeof(soundrow), "Sound: %s", opt_sound ? "ON" : "OFF")); + + CLAY({ + .id = CLAY_ID("options"), + .layout = { + .padding = { 16, 16, 16, 16 }, + .childGap = 8, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = { 24, 20, 37, 235 }, + .border = { .color = { 240, 236, 214, 255 }, .width = { 1, 1, 1, 1, 0 } }, + .floating = { + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_CENTER, + .parent = CLAY_ATTACH_POINT_CENTER_CENTER + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(CLAY_STRING("OPTIONS"), CLAY_TEXT_CONFIG({ + .textColor = { 240, 236, 214, 255 }, + .fontId = 0 + })); + + CLAY({ + .id = CLAY_ID("options-music"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_music = !opt_music; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } + + CLAY({ + .id = CLAY_ID("options-sound"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + opt_sound = !opt_sound; + } + CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(soundrow), .chars = soundrow }), + CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 })); + } + + CLAY({ + .id = CLAY_ID("options-back"), + .layout = { .padding = { 8, 8, 4, 4 } }, + .backgroundColor = Clay_Hovered() + ? (Clay_Color){ 64, 58, 88, 255 } + : (Clay_Color){ 0, 0, 0, 0 } + }) { + if ( Clay_Hovered() && clicked ) { + state = UIDEMO_STATE_TITLE; + } + CLAY_TEXT(CLAY_STRING("Back (Esc)"), CLAY_TEXT_CONFIG({ + .textColor = { 240, 236, 214, 255 }, + .fontId = 0 + })); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Declare the play screen's HUD: two labels, and the dialog while it is up. + * + * The score buffer is static for the same borrowed-until-frame_end reason as + * the options rows. Note what is *not* here: no visible flag, no draw call, + * no geometry. The dialog is open because this frame declares it. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *declare_play(void) +{ + PREPARE_ERROR(errctx); + static char scoretext[32]; + static char livestext[32]; + int count = 0; + + PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score)); + PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives)); + PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL)); + PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL)); + if ( dialog_open ) { + PASS(errctx, akgl_ui_dialog("dialog", + "This panel is one call. Space dismisses it; " + "compare examples/jrpg/textbox.c.", + NULL)); + } + SUCCEED_RETURN(errctx); +} + +/** @brief Read the render target back and write it out as a PNG, for the chapter figure. */ +static akerr_ErrorContext *save_screenshot(char *path) +{ + SDL_Surface *shot = NULL; + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError()); + + ATTEMPT { + FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL, + "IMG_SavePNG(%s): %s", path, SDL_GetError()); + } CLEANUP { + SDL_DestroySurface(shot); + } PROCESS(errctx) { + } FINISH(errctx, true); + SDL_Log("Wrote %s", path); + SUCCEED_RETURN(errctx); +} + +/** + * @brief One frame: events, the world stand-in, the UI bracket, present. + * + * The shape to compare with the JRPG's frame(): where that program calls + * akgl_game_update between frame_start and frame_end, this one paints a + * checkerboard -- and the UI bracket sits in the same overlay slot the + * hand-rolled textbox draw did. + * + * @param frameno The frame number, for the demo script. + * @return `NULL` on success, otherwise an error context owned by the caller. + */ +static akerr_ErrorContext *frame(long frameno) +{ + PREPARE_ERROR(errctx); + SDL_Event event; + + while ( SDL_PollEvent(&event) ) { + PASS(errctx, route_event(&event)); + } + if ( demo ) { + PASS(errctx, demo_step(frameno)); + } + + PASS(errctx, akgl_renderer->frame_start(akgl_renderer)); + if ( state == UIDEMO_STATE_PLAY ) { + // Where a game would call akgl_game_update. The score ticking is the + // stand-in for play, so the HUD label visibly earns its redraw. + PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT)); + score += 1; + } + + PASS(errctx, akgl_ui_frame_begin()); + switch ( state ) { + case UIDEMO_STATE_TITLE: + PASS(errctx, declare_title()); + break; + case UIDEMO_STATE_OPTIONS: + PASS(errctx, declare_options()); + break; + case UIDEMO_STATE_PLAY: + PASS(errctx, declare_play()); + break; + default: + break; + } + PASS(errctx, akgl_ui_frame_end(akgl_renderer)); + + if ( (shotpath != NULL) && (frameno == shotframe) ) { + PASS(errctx, save_screenshot(shotpath)); + } + PASS(errctx, akgl_renderer->frame_end(akgl_renderer)); + + // The press edge lives exactly one frame: the declarations above have + // seen it, so the next frame starts clean. + clicked = false; + + // Menu activation is read after the frame, so it catches both routes in: + // Return through akgl_ui_menu_handle_event before the declarations, and a + // click through the declaration itself. + if ( title_menu.activated ) { + title_menu.activated = false; + switch ( title_menu.selected ) { + case 0: + state = UIDEMO_STATE_PLAY; + score = 0; + dialog_open = false; + break; + case 1: + state = UIDEMO_STATE_OPTIONS; + break; + case 2: + default: + running = false; + break; + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Give back what has to be given back, in the order that works. + * + * akgl_ui_shutdown first because it is the newest thing up, then the fonts + * **before** SDL_Quit -- the registry the fonts live in is an SDL property + * set, and SDL_Quit destroys it with them inside. The pools and the clay + * arena are static storage; there is nothing to free. + */ +static void teardown(void) +{ + IGNORE(akgl_ui_shutdown()); + IGNORE(akgl_text_unloadallfonts()); + if ( akgl_window != NULL ) { + SDL_DestroyWindow(akgl_window); + akgl_window = NULL; + } + SDL_Quit(); +} + +int main(int argc, char *argv[]) +{ + PREPARE_ERROR(errctx); + long frameno = 0; + uint64_t started = 0; + uint64_t spent = 0; + + ATTEMPT { + CATCH(errctx, parse_args(argc, argv)); + CATCH(errctx, startup()); + + // The loop is the last thing in this ATTEMPT block on purpose: CATCH + // reports failure by `break`ing out of the loop, which falls straight + // into CLEANUP. See examples/jrpg/jrpg.c for the long form of this + // note. + while ( running ) { + started = SDL_GetTicks(); + CATCH(errctx, frame(frameno)); + frameno += 1; + if ( (frame_limit > 0) && (frameno >= frame_limit) ) { + running = false; + } + if ( demo == false ) { + spent = SDL_GetTicks() - started; + if ( spent < UIDEMO_FRAME_BUDGET_MS ) { + SDL_Delay((uint32_t)(UIDEMO_FRAME_BUDGET_MS - spent)); + } + } + } + } CLEANUP { + teardown(); + } PROCESS(errctx) { + } HANDLE_DEFAULT(errctx) { + LOG_ERROR_WITH_MESSAGE(errctx, "the UI demo could not finish"); + exitstatus = 1; + } FINISH_NORETURN(errctx); + + // Enough for the smoke run to be read rather than merely passed: the + // script quits from the title menu, so a run that finished its tour says + // so here. + printf("uidemo: %ld frames, score %d, music %s, sound %s\n", + frameno, score, opt_music ? "on" : "off", opt_sound ? "on" : "off"); + return exitstatus; +} diff --git a/examples/uidemo/uidemo.h b/examples/uidemo/uidemo.h new file mode 100644 index 0000000..ba6e1e3 --- /dev/null +++ b/examples/uidemo/uidemo.h @@ -0,0 +1,58 @@ +/** + * @file uidemo.h + * @brief Shared constants and types for the UI demo. + * + * There is deliberately little here: the demo is one translation unit, and + * this header exists so the chapter can quote the constants and the script + * type without quoting the code around them. + */ + +#ifndef _UIDEMO_H_ +#define _UIDEMO_H_ + +#include + +/** @brief Window and layout width, in pixels. */ +#define UIDEMO_SCREEN_WIDTH "640" +/** @brief Window and layout height, in pixels. */ +#define UIDEMO_SCREEN_HEIGHT "480" +/** @brief The same width as a number, for akgl_ui_init. */ +#define UIDEMO_WIDTH 640 +/** @brief The same height as a number. */ +#define UIDEMO_HEIGHT 480 + +/** @brief Registry name the demo's one font is loaded under. */ +#define UIDEMO_FONT_NAME "uidemo" +/** @brief Point size the font is loaded at. The size is baked into the handle. */ +#define UIDEMO_FONT_SIZE 16 + +/** + * @brief Which screen the demo is on. + * + * Three states, three ways of building an interface: the title screen is the + * menu *widget*, the options screen is raw `CLAY()` layout, and the play + * screen is the label and dialog widgets over a moving world stand-in. + */ +typedef enum { + UIDEMO_STATE_TITLE, /**< The akgl_UiMenu title menu. */ + UIDEMO_STATE_OPTIONS, /**< The hand-written CLAY() options panel. */ + UIDEMO_STATE_PLAY /**< HUD labels and a dialog over the "game". */ +} uidemo_State; + +/** + * @brief One scripted input in a `--demo` run. + * + * Key steps carry a keycode; mouse steps carry a position. Every step is + * synthesized as a real SDL_Event and pushed through the same routing the + * interactive loop uses, so the demo exercises the event chain rather than + * poking state behind its back. + */ +typedef struct { + long frame; /**< The frame this step fires on. */ + uint32_t type; /**< SDL_EVENT_KEY_DOWN, _MOUSE_MOTION, _MOUSE_BUTTON_DOWN or _MOUSE_BUTTON_UP. */ + SDL_Keycode key; /**< For key steps. */ + float x; /**< For mouse steps. */ + float y; /**< For mouse steps. */ +} uidemo_ScriptStep; + +#endif // _UIDEMO_H_ diff --git a/include/akgl/draw.h b/include/akgl/draw.h index ced1e70..425461f 100644 --- a/include/akgl/draw.h +++ b/include/akgl/draw.h @@ -36,6 +36,25 @@ */ #define AKGL_DRAW_MAX_FLOOD_SPANS 4096 +/** + * @brief Triangles each corner of akgl_draw_filled_rounded_rect() is built from. + * + * A quarter circle approximated by this many segments is visually round at any + * radius a UI panel plausibly uses; the vertex arrays are fixed at this size, + * so it is a compile-time cost, not a per-call one. + */ +#define AKGL_DRAW_ROUNDED_RECT_SEGMENTS 16 + +/** + * @brief Most vertices akgl_draw_arc() will spend on one arc. + * + * An arc is a triangle strip between its inner and outer edge, two vertices + * per step, so half of these are positions along the curve. The step count + * scales with the swept angle and is clamped to fit this array -- a full + * circle gets all of them, a sliver gets a few -- rather than allocating. + */ +#define AKGL_DRAW_ARC_MAX_POINTS 64 + /** * @brief Paint an 8x8 grey checkerboard over a region, the way an image editor shows transparency. * @@ -242,4 +261,86 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_copy_region(akgl_RenderBackend *sel */ akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y); +/** + * @brief Fill a rectangle whose corners are rounded off with quarter circles. + * + * The body is three axis-aligned fills and the corners are triangle fans + * through `SDL_RenderGeometry` -- #AKGL_DRAW_ROUNDED_RECT_SEGMENTS triangles + * each -- so nothing here is anti-aliased, matching every other primitive in + * this header. + * + * @param self The backend to draw through. Required, along with its + * `sdl_renderer`. + * @param rect The rectangle, in render-target pixels. Required. A zero or + * negative width or height fills nothing and is not reported. + * @param radius Corner radius in pixels. Zero or negative falls through to + * akgl_draw_filled_rect() -- a square corner is not an error. + * Larger than half the shorter side is clamped to it, which at + * the limit turns a square into a circle rather than folding the + * corners over each other. + * @param color Colour to fill with, including alpha. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p rect is + * `NULL`. + * @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if a band + * or corner cannot be drawn. A failure partway leaves the shape + * partially drawn. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color); + +/** + * @brief Stroke a circular arc of a given thickness between two angles. + * + * Angles are in degrees, 0 pointing along +x (three o'clock) and increasing + * clockwise on screen -- the same convention as the rotation argument the + * render backend's `draw_texture` takes. The stroke runs from the arc's outer + * edge at @p radius inward by @p thickness, drawn as one triangle strip, not + * anti-aliased. + * + * @param self The backend to draw through. Required, along with its + * `sdl_renderer`. + * @param x Horizontal position of the arc's centre. + * @param y Vertical position of the arc's centre. + * @param radius Outer radius in pixels. Must be positive. + * @param start_deg Angle the arc starts at. + * @param end_deg Angle the arc ends at. At or below @p start_deg nothing is + * drawn and nothing is reported, matching the zero-area + * rectangle fills above. Neither angle is normalized, so an + * arc crossing three o'clock is 350 to 370, not 350 to 10; a + * sweep beyond 360 degrees is clamped to one full turn. + * @param thickness Stroke width in pixels, measured inward from @p radius. + * Must be positive; at or above @p radius the arc becomes a + * filled wedge to the centre. + * @param color Colour to stroke in, including alpha. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`. + * @throws AKERR_OUTOFBOUNDS If @p radius or @p thickness is not positive. The + * message reports the offending value. + * @throws AKGL_ERR_SDL If the strip cannot be drawn. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color); + +/** + * @brief Restrict every subsequent draw to a rectangle of the render target. + * + * Everything drawn through @p self after this -- primitives here, textures, + * text -- is clipped to @p rect until the restriction is cleared. This is the + * only entry point in this header where a `NULL` rectangle is legal rather + * than an error: it means "clear the restriction", matching what + * `SDL_SetRenderClipRect` does with it, and there is no other value that + * could. + * + * The clip is renderer state, not saved and restored around anything: a + * caller that sets it owns putting it back, the way a `CLEANUP` block puts + * back a draw colour. Leaving it set clips the next frame's world too. + * + * @param self The backend to clip. Required, along with its `sdl_renderer`. + * @param rect Rectangle to clip to, in render-target pixels -- integer, since + * a clip boundary is a pixel boundary. `NULL` clears the clip. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`. + * @throws AKGL_ERR_SDL If the clip cannot be set or cleared. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect); + #endif //_AKGL_DRAW_H_ diff --git a/include/akgl/error.h b/include/akgl/error.h index 997afc5..7fa0bbf 100644 --- a/include/akgl/error.h +++ b/include/akgl/error.h @@ -76,11 +76,12 @@ #define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */ #define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */ #define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */ +#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */ // One past the last libakgl status. The reservation is all-or-nothing -- a // subset or superset of an existing one is refused -- so this must stay one // past the highest code above. -#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 6) +#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 7) #define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE) /** diff --git a/include/akgl/ui.h b/include/akgl/ui.h new file mode 100644 index 0000000..58ebcf2 --- /dev/null +++ b/include/akgl/ui.h @@ -0,0 +1,552 @@ +/** + * @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. + * + * @note The panel's height is fixed at #AKGL_UI_DIALOG_HEIGHT and the text is + * not clipped to it: a message that wraps to more lines than fit runs + * past the bottom border, visibly. That is the same behaviour as the + * hand-rolled panel this widget mirrors, and it is deliberate -- + * overflow you can see beats truncation you cannot. Size the height for + * your longest message, or raise the override. + * @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_ diff --git a/scripts/memcheck.sh b/scripts/memcheck.sh index df8c9d9..bcb050e 100755 --- a/scripts/memcheck.sh +++ b/scripts/memcheck.sh @@ -49,8 +49,20 @@ rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log # The run itself is allowed to fail: a suite that valgrind makes slow enough to # trip an assertion still produced a log worth reading, and the log is what # decides the exit status below. +# +# docs_examples and docs_screenshots are excluded, and the exclusion deserves +# its reasons stated: those two tests are *shell scripts* that drive gcc and +# run the compiled snippets as child processes. Valgrind wraps the test +# command, does not trace children, and so spends the whole test memchecking +# /bin/bash -- 413 of bash's own by-design leaks on the first CI run that got +# this far, and not one byte of libakgl. The snippet programs themselves are +# ordinary akgl consumers whose code paths the suites and example games +# already cover below, under valgrind, as real binaries. A caller's own -E +# overrides this one (ctest takes the last), which is fine: a narrowed run +# chose its scope on purpose. set +e -ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure "$@" +ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure \ + -E '^(docs_examples|docs_screenshots)$' "$@" AKGL_CTEST_STATUS=$? set -e diff --git a/src/draw.c b/src/draw.c index ae9c399..6d3eb58 100644 --- a/src/draw.c +++ b/src/draw.c @@ -619,3 +619,229 @@ akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface } FINISH(errctx, true); SUCCEED_RETURN(errctx); } + +/** + * @brief Fill one quarter-circle corner as a triangle fan. + * + * `SDL_RenderGeometry` colours from its vertices rather than the renderer's + * draw colour, so this neither pushes nor pops it. + * + * @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`; + * the caller has already checked both. + * @param cx Horizontal position of the corner's centre -- the point the + * fan radiates from, one radius inside the rectangle. + * @param cy Vertical position of the centre. + * @param radius Radius of the quarter circle. Assumed positive. + * @param start_deg Angle the quarter starts at; it sweeps 90 degrees clockwise + * from there. + * @param color Colour to fill with. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_SDL If the fan cannot be drawn. + */ +static akerr_ErrorContext *fill_corner_fan(akgl_RenderBackend *self, float32_t cx, float32_t cy, float32_t radius, float32_t start_deg, SDL_Color color) +{ + SDL_Vertex vertices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2]; + int indices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3]; + SDL_FColor fcolor; + float32_t angle = 0.0f; + int i = 0; + + PREPARE_ERROR(errctx); + fcolor.r = (float)color.r / 255.0f; + fcolor.g = (float)color.g / 255.0f; + fcolor.b = (float)color.b / 255.0f; + fcolor.a = (float)color.a / 255.0f; + + vertices[0].position.x = cx; + vertices[0].position.y = cy; + vertices[0].color = fcolor; + vertices[0].tex_coord.x = 0.0f; + vertices[0].tex_coord.y = 0.0f; + for ( i = 0; i <= AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) { + angle = (start_deg + ((90.0f / AKGL_DRAW_ROUNDED_RECT_SEGMENTS) * (float)i)) * (SDL_PI_F / 180.0f); + vertices[i + 1].position.x = cx + (radius * SDL_cosf(angle)); + vertices[i + 1].position.y = cy + (radius * SDL_sinf(angle)); + vertices[i + 1].color = fcolor; + vertices[i + 1].tex_coord.x = 0.0f; + vertices[i + 1].tex_coord.y = 0.0f; + } + for ( i = 0; i < AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) { + indices[(i * 3) + 0] = 0; + indices[(i * 3) + 1] = i + 1; + indices[(i * 3) + 2] = i + 2; + } + FAIL_ZERO_RETURN( + errctx, + SDL_RenderGeometry( + self->sdl_renderer, + NULL, + vertices, + AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2, + indices, + AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color) +{ + SDL_Color previous; + SDL_FRect band; + float32_t half = 0.0f; + bool pushed = false; + bool drawfailed = false; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); + FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend"); + FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL rectangle"); + if ( (rect->w <= 0.0f) || (rect->h <= 0.0f) ) { + SUCCEED_RETURN(errctx); + } + if ( radius <= 0.0f ) { + PASS(errctx, akgl_draw_filled_rect(self, rect, color)); + SUCCEED_RETURN(errctx); + } + half = (((rect->w < rect->h) ? rect->w : rect->h) / 2.0f); + if ( radius > half ) { + radius = half; + } + + ATTEMPT { + CATCH(errctx, push_draw_color(self, color, &previous)); + pushed = true; + + // The body is three bands: one the full width between the corner rows, + // and one between the corners along each of the top and bottom edges. + // A radius of exactly half the shorter side leaves one or more of them + // with no area, which SDL fills as nothing -- at the limit the whole + // shape is the four fans. + band.x = rect->x; + band.y = rect->y + radius; + band.w = rect->w; + band.h = rect->h - (radius * 2.0f); + if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) { + drawfailed = true; + } + band.x = rect->x + radius; + band.y = rect->y; + band.w = rect->w - (radius * 2.0f); + band.h = radius; + if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) { + drawfailed = true; + } + band.y = rect->y + rect->h - radius; + if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) { + drawfailed = true; + } + FAIL_NONZERO_BREAK(errctx, drawfailed, AKGL_ERR_SDL, "%s", SDL_GetError()); + + CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + radius, radius, 180.0f, color)); + CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + radius, radius, 270.0f, color)); + CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + rect->h - radius, radius, 0.0f, color)); + CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + rect->h - radius, radius, 90.0f, color)); + } CLEANUP { + if ( pushed == true ) { + IGNORE(pop_draw_color(self, &previous)); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color) +{ + SDL_Vertex vertices[AKGL_DRAW_ARC_MAX_POINTS]; + int indices[(AKGL_DRAW_ARC_MAX_POINTS - 2) * 3]; + SDL_FColor fcolor; + float32_t span = 0.0f; + float32_t inner = 0.0f; + float32_t angle = 0.0f; + int segments = 0; + int base = 0; + int i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); + FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend"); + FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_OUTOFBOUNDS, "Arc radius %f is not positive", (double)radius); + FAIL_NONZERO_RETURN(errctx, (thickness <= 0.0f), AKERR_OUTOFBOUNDS, "Arc thickness %f is not positive", (double)thickness); + span = end_deg - start_deg; + if ( span <= 0.0f ) { + SUCCEED_RETURN(errctx); + } + if ( span > 360.0f ) { + span = 360.0f; + } + inner = radius - thickness; + if ( inner < 0.0f ) { + inner = 0.0f; + } + + // Steps in proportion to the angle swept, so a sliver of arc does not + // spend the whole vertex budget and a full circle uses all of it: the + // quarter-circle density of AKGL_DRAW_ROUNDED_RECT_SEGMENTS, capped by + // what AKGL_DRAW_ARC_MAX_POINTS vertices can carry at two per step. + segments = (int)((span / 90.0f) * (float)AKGL_DRAW_ROUNDED_RECT_SEGMENTS) + 1; + if ( segments > ((AKGL_DRAW_ARC_MAX_POINTS / 2) - 1) ) { + segments = (AKGL_DRAW_ARC_MAX_POINTS / 2) - 1; + } + + fcolor.r = (float)color.r / 255.0f; + fcolor.g = (float)color.g / 255.0f; + fcolor.b = (float)color.b / 255.0f; + fcolor.a = (float)color.a / 255.0f; + for ( i = 0; i <= segments; i++ ) { + angle = (start_deg + ((span / (float)segments) * (float)i)) * (SDL_PI_F / 180.0f); + vertices[i * 2].position.x = x + (radius * SDL_cosf(angle)); + vertices[i * 2].position.y = y + (radius * SDL_sinf(angle)); + vertices[i * 2].color = fcolor; + vertices[i * 2].tex_coord.x = 0.0f; + vertices[i * 2].tex_coord.y = 0.0f; + vertices[(i * 2) + 1].position.x = x + (inner * SDL_cosf(angle)); + vertices[(i * 2) + 1].position.y = y + (inner * SDL_sinf(angle)); + vertices[(i * 2) + 1].color = fcolor; + vertices[(i * 2) + 1].tex_coord.x = 0.0f; + vertices[(i * 2) + 1].tex_coord.y = 0.0f; + } + for ( i = 0; i < segments; i++ ) { + base = i * 2; + indices[(i * 6) + 0] = base; + indices[(i * 6) + 1] = base + 2; + indices[(i * 6) + 2] = base + 1; + indices[(i * 6) + 3] = base + 1; + indices[(i * 6) + 4] = base + 2; + indices[(i * 6) + 5] = base + 3; + } + FAIL_ZERO_RETURN( + errctx, + SDL_RenderGeometry( + self->sdl_renderer, + NULL, + vertices, + (segments + 1) * 2, + indices, + segments * 6), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); + FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend"); + // NULL rect deliberately passes straight through: it is how the clip is + // cleared, and the header says so. + FAIL_ZERO_RETURN( + errctx, + SDL_SetRenderClipRect(self->sdl_renderer, rect), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} diff --git a/src/error.c b/src/error.c index 1df3869..644f448 100644 --- a/src/error.c +++ b/src/error.c @@ -21,5 +21,6 @@ akerr_ErrorContext *akgl_error_init(void) PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error")); PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt")); PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_COLLISION, "Collision Error")); + PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_UI, "UI Error")); SUCCEED_RETURN(errctx); } diff --git a/src/ui.c b/src/ui.c new file mode 100644 index 0000000..754bdc4 --- /dev/null +++ b/src/ui.c @@ -0,0 +1,1121 @@ +/** + * @file ui.c + * @brief Implements the UI subsystem: clay's arena, fonts, rendering and lifecycle. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Bytes of the first clay error's text kept for reporting. + * + * Sized to hold every message clay 0.14 actually emits -- they are one + * sentence each -- with room for a longer one to truncate into rather than + * overflow. + */ +#define UI_ERROR_TEXT_LENGTH 256 + +/** @brief The arena clay lays its structures out in. Static storage, so this file allocates nothing. */ +static uint8_t ui_arena_bytes[AKGL_UI_ARENA_BYTES]; +/** @brief Usable bytes. #AKGL_UI_ARENA_BYTES unless a test has narrowed it. */ +static size_t ui_arena_usable = AKGL_UI_ARENA_BYTES; +/** @brief The context Clay_Initialize handed back, or NULL before init / after shutdown. */ +static Clay_Context *ui_context; +/** @brief Whether a frame is open -- between frame_begin and frame_end. */ +static bool ui_in_frame; + +/** + * @brief The fontId table: slot N holds the registry name behind clay fontId N. + * + * Names, not `TTF_Font *` handles -- fonts are not reference counted, and a + * name resolved per use through #AKGL_REGISTRY_FONT reports "gone" honestly + * where a cached handle would dangle. + */ +static char ui_fonts[AKGL_UI_MAX_FONTS][AKGL_UI_FONT_NAME_LENGTH]; +/** @brief Slots of #ui_fonts in use. Ids 0 to this minus one are live. */ +static int ui_font_count; + +/** @brief Scratch one TEXT render command's line is copied through, so SDL_ttf gets a C string. */ +static char ui_text_scratch[AKGL_UI_MAX_TEXT_BYTES]; + +/** + * @brief Pixels one wheel notch scrolls a clay scroll container. + * + * SDL reports the wheel in notches; clay wants pixels. 32 is three lines of + * 16pt text per notch, which is what desktop toolkits converge on. + */ +#define UI_WHEEL_NOTCH_PIXELS 32.0f + +/** @brief Where the pointer last was, in layout pixels. Fed to clay per event. */ +static float ui_pointer_x; +/** @brief Vertical half of the pointer position. */ +static float ui_pointer_y; +/** @brief Whether the left button is currently held. */ +static bool ui_pointer_down; +/** + * @brief A left press arrived since the last frame_begin latched. + * + * Widgets read the *latched* copy (#ui_pointer_pressed) rather than clay's + * `Clay_PointerData.state`, because clay advances its press-edge per + * Clay_SetPointerState call and several of those can land between two + * layouts -- one per motion event -- which turns "pressed this frame" into + * "pressed since the last mouse event", an edge no widget can trust. + */ +static bool ui_pointer_pending_pressed; +/** @brief The frame-stable press edge: one left press happened before this frame. */ +static bool ui_pointer_pressed; +/** @brief Motion arrived since the last frame_begin latched. Companion to the press edge. */ +static bool ui_pointer_pending_moved; +/** + * @brief The frame-stable motion edge: the pointer moved before this frame. + * + * What keeps a menu's hover-selection polite. A stationary pointer parked + * over a row must not re-claim the selection every frame -- the keyboard + * would fight it and lose, sixty times a second -- so hover only moves the + * selection on frames the mouse actually moved. Found the way these things + * are found: the demo script pressed Up and the selection did not move. + */ +static bool ui_pointer_moved; +/** @brief Wheel notches accumulated since the last frame_begin, horizontal. */ +static float ui_wheel_x; +/** @brief Wheel notches accumulated since the last frame_begin, vertical. */ +static float ui_wheel_y; +/** @brief When the last frame_begin ran, for the scroll containers' dt. 0 before the first. */ +static uint64_t ui_last_frame_ns; + +/** @brief The layout width akgl_ui_init or the last resize established. The dialog spans it. */ +static int ui_layout_width; +/** @brief The layout height, kept alongside #ui_layout_width. */ +static int ui_layout_height; + +/** + * @brief What `NULL` means wherever a widget takes a style. + * + * The JRPG textbox's palette, on purpose: the widget that replaces that panel + * should reproduce it, so the two are comparable pixel for pixel. + * + * Field Value Meaning + */ +static const akgl_UiStyle ui_default_style = { + { 24, 20, 37, 235 }, /* fill: near-black, slightly translucent */ + { 240, 236, 214, 255 }, /* edge: parchment */ + { 240, 236, 214, 255 }, /* ink: parchment */ + 8.0f, /* padding */ + 0.0f, /* corner_radius: square */ + 0 /* fontid: the first font registered */ +}; + +/** + * @brief First error clay reported since the stash was last cleared. + * + * clay's error handler is a void callback with no way to refuse or return, + * and the measure callback's signature is no better -- a layout error cannot + * surface at the call that caused it, because libakgl is nowhere on the stack + * when it happens. Both stash the first message here and akgl_ui_frame_end + * raises it, which is the earliest point the error protocol can carry it. + * Later errors in the same frame are counted but not kept: the first one is + * almost always the cause and the rest its consequences. + */ +static char ui_clay_error_text[UI_ERROR_TEXT_LENGTH]; +/** @brief How many errors have been stashed since the stash was cleared. */ +static uint32_t ui_clay_error_count; + +/** + * @brief Log a layout-time failure now and stash the first one for frame_end. + * + * The shared back half of the clay error handler and the measure callback -- + * the two places an error can happen with no way to return it. + * + * @param format printf format for the message, followed by its arguments. + */ +static void ui_stash_error(const char *format, ...) +{ + va_list args; + int count = 0; + + ui_clay_error_count += 1; + if ( ui_clay_error_count > 1 ) { + // Still worth logging: the operator watching the log sees the whole + // cascade, even though frame_end will raise only the first. + va_start(args, format); + SDL_LogMessageV(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_ERROR, format, args); + va_end(args); + return; + } + va_start(args, format); + IGNORE(aksl_vsnprintf(&count, ui_clay_error_text, sizeof(ui_clay_error_text), format, args)); + va_end(args); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "%s", ui_clay_error_text); +} + +/** + * @brief The error handler given to Clay_Initialize. + * + * Clay_String is a length and a pointer, not a C string, so the text goes + * through the stash with an explicit precision. + */ +static void ui_clay_error(Clay_ErrorData error) +{ + ui_stash_error("clay: %.*s", (int)error.errorText.length, error.errorText.chars); +} + +/** + * @brief Resolve a clay fontId to the live font handle behind it. + * + * @param fontid The id a text element carried. + * @param dest Receives the font. Assumed non-`NULL`; internal callers only. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the id was never registered, or the name behind it + * has left the registry since. + */ +static akerr_ErrorContext *ui_font_for_id(uint16_t fontid, TTF_Font **dest) +{ + PREPARE_ERROR(errctx); + FAIL_NONZERO_RETURN( + errctx, + ((int)fontid >= ui_font_count), + AKGL_ERR_UI, + "fontId %u was never registered; akgl_ui_font_register has issued %d", + fontid, + ui_font_count); + *dest = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, ui_fonts[fontid], NULL); + FAIL_ZERO_RETURN( + errctx, + *dest, + AKGL_ERR_UI, + "The font named %s behind fontId %u is no longer in the registry", + ui_fonts[fontid], + fontid); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_init(int width, int height) +{ + uint32_t needed = 0; + Clay_Arena arena; + Clay_ErrorHandler handler = { &ui_clay_error, NULL }; + Clay_Dimensions dimensions; + + PREPARE_ERROR(errctx); + FAIL_NONZERO_RETURN(errctx, (width <= 0), AKERR_OUTOFBOUNDS, "Layout width %d is not positive", width); + FAIL_NONZERO_RETURN(errctx, (height <= 0), AKERR_OUTOFBOUNDS, "Layout height %d is not positive", height); + FAIL_NONZERO_RETURN( + errctx, + (ui_context != NULL), + AKGL_ERR_UI, + "The UI subsystem is already initialized; akgl_ui_shutdown first"); + + // Ceilings before measurement: Clay_MinMemorySize reports what the + // *current* ceilings need, so they must be set first. Element count + // before word count, and not the other way around -- with no context + // yet, Clay_SetMaxElementCount also overwrites the word-cache default + // with elements * 2, and would silently undo a word count set before it. + Clay_SetMaxElementCount(AKGL_UI_MAX_ELEMENTS); + Clay_SetMaxMeasureTextCacheWordCount(AKGL_UI_MAX_MEASURE_WORDS); + needed = Clay_MinMemorySize(); + FAIL_NONZERO_RETURN( + errctx, + ((size_t)needed > ui_arena_usable), + AKGL_ERR_UI, + "clay needs %u bytes for %d elements and %d measured words; the arena holds %u. Raise AKGL_UI_ARENA_BYTES.", + needed, + AKGL_UI_MAX_ELEMENTS, + AKGL_UI_MAX_MEASURE_WORDS, + (uint32_t)ui_arena_usable); + + ui_clay_error_text[0] = '\0'; + ui_clay_error_count = 0; + arena = Clay_CreateArenaWithCapacityAndMemory(ui_arena_usable, ui_arena_bytes); + dimensions.width = (float)width; + dimensions.height = (float)height; + ui_context = Clay_Initialize(arena, dimensions, handler); + // The capacity check above is the one that should fail; this one exists + // because Clay_Initialize reports refusal by returning NULL and a NULL + // context would otherwise surface as a crash inside the first CLAY() + // block, far from the cause. + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "Clay_Initialize refused: %s", ui_clay_error_text); + // Needs the context Clay_Initialize just made current, so it cannot move + // earlier. Without a measure function every text element is an error. + Clay_SetMeasureTextFunction(&akgl_ui_measure_text, NULL); + ui_layout_width = width; + ui_layout_height = height; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_shutdown(void) +{ + PREPARE_ERROR(errctx); + // clay holds no resource but the arena, and the arena is ours: there is + // nothing to hand back, so shutdown is forgetting. The next init lays a + // fresh context over the same bytes. + ui_context = NULL; + ui_in_frame = false; + ui_font_count = 0; + ui_clay_error_text[0] = '\0'; + ui_clay_error_count = 0; + ui_pointer_x = 0.0f; + ui_pointer_y = 0.0f; + ui_pointer_down = false; + ui_pointer_pending_pressed = false; + ui_pointer_pressed = false; + ui_pointer_pending_moved = false; + ui_pointer_moved = false; + ui_wheel_x = 0.0f; + ui_wheel_y = 0.0f; + ui_last_frame_ns = 0; + ui_layout_width = 0; + ui_layout_height = 0; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_resize(int width, int height) +{ + Clay_Dimensions dimensions; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_NONZERO_RETURN(errctx, (width <= 0), AKERR_OUTOFBOUNDS, "Layout width %d is not positive", width); + FAIL_NONZERO_RETURN(errctx, (height <= 0), AKERR_OUTOFBOUNDS, "Layout height %d is not positive", height); + dimensions.width = (float)width; + dimensions.height = (float)height; + Clay_SetLayoutDimensions(dimensions); + ui_layout_width = width; + ui_layout_height = height; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_font_register(char *name, uint16_t *fontid) +{ + int i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name"); + FAIL_ZERO_RETURN(errctx, fontid, AKERR_NULLPOINTER, "Null fontid destination"); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_ZERO_RETURN( + errctx, + SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL), + AKGL_ERR_UI, + "No font named %s in the registry; akgl_text_loadfont first", + name); + for ( i = 0; i < ui_font_count; i++ ) { + if ( SDL_strcmp(ui_fonts[i], name) == 0 ) { + *fontid = (uint16_t)i; + SUCCEED_RETURN(errctx); + } + } + FAIL_NONZERO_RETURN( + errctx, + (ui_font_count >= AKGL_UI_MAX_FONTS), + AKGL_ERR_UI, + "All %d fontId slots are taken. Raise AKGL_UI_MAX_FONTS.", + AKGL_UI_MAX_FONTS); + PASS(errctx, aksl_strcpy(ui_fonts[ui_font_count], AKGL_UI_FONT_NAME_LENGTH, name)); + *fontid = (uint16_t)ui_font_count; + ui_font_count += 1; + SUCCEED_RETURN(errctx); +} + +Clay_Dimensions akgl_ui_measure_text(Clay_StringSlice text, Clay_TextElementConfig *config, void *userData) +{ + Clay_Dimensions dimensions = { 0.0f, 0.0f }; + TTF_Font *font = NULL; + int w = 0; + int h = 0; + + (void)userData; + // No error protocol in here: clay's callback signature returns dimensions + // by value, so failure is zero-by-zero plus a stashed message for + // frame_end. The checks mirror ui_font_for_id without borrowing it, + // because a context prepared here would have nowhere to go. + if ( config == NULL ) { + ui_stash_error("Text measurement was asked for with no element configuration"); + return dimensions; + } + if ( (int)config->fontId >= ui_font_count ) { + ui_stash_error( + "fontId %u was never registered; akgl_ui_font_register has issued %d", + config->fontId, + ui_font_count); + return dimensions; + } + font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, ui_fonts[config->fontId], NULL); + if ( font == NULL ) { + ui_stash_error( + "The font named %s behind fontId %u is no longer in the registry", + ui_fonts[config->fontId], + config->fontId); + return dimensions; + } + if ( text.length == 0 ) { + return dimensions; + } + // The slice is not NUL-terminated and does not need to be: SDL_ttf takes + // an explicit byte length, so the measurement happens in place with no + // copy -- this callback runs for every word clay has not cached. + if ( TTF_GetStringSize(font, text.chars, (size_t)text.length, &w, &h) == false ) { + ui_stash_error("Measuring %d bytes of text failed: %s", text.length, SDL_GetError()); + return dimensions; + } + dimensions.width = (float)w; + dimensions.height = (float)h; + return dimensions; +} + +/** @brief Clamp one clay colour channel (a float, conventionally 0-255) to a byte. */ +static uint8_t ui_color_channel(float value) +{ + if ( value <= 0.0f ) { + return 0; + } + if ( value >= 255.0f ) { + return 255; + } + return (uint8_t)(value + 0.5f); +} + +/** @brief Convert a clay colour to the SDL_Color every draw primitive takes. */ +static SDL_Color ui_color_from_clay(Clay_Color color) +{ + SDL_Color out; + + out.r = ui_color_channel(color.r); + out.g = ui_color_channel(color.g); + out.b = ui_color_channel(color.b); + out.a = ui_color_channel(color.a); + return out; +} + +/** + * @brief Draw one BORDER command: four edge fills and up to four corner arcs. + * + * The edges are shortened by the corner radius and the corners are stroked as + * quarter arcs of the adjacent widths' larger value -- the approximation is + * only visible when two adjacent sides have different widths *and* a radius, + * which nothing in this library produces. + * + * @param self The backend. Assumed checked by the caller. + * @param command The BORDER render command. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_* Whatever the fills and arcs underneath raise. + */ +static akerr_ErrorContext *ui_execute_border(akgl_RenderBackend *self, Clay_RenderCommand *command) +{ + Clay_BorderRenderData *border = &command->renderData.border; + Clay_BoundingBox box = command->boundingBox; + SDL_Color color; + SDL_FRect edge; + float32_t radius = border->cornerRadius.topLeft; + float32_t thickness = 0.0f; + + PREPARE_ERROR(errctx); + color = ui_color_from_clay(border->color); + if ( border->width.left > 0 ) { + edge.x = box.x; + edge.y = box.y + radius; + edge.w = (float32_t)border->width.left; + edge.h = box.height - (radius * 2.0f); + PASS(errctx, akgl_draw_filled_rect(self, &edge, color)); + } + if ( border->width.right > 0 ) { + edge.x = box.x + box.width - (float32_t)border->width.right; + edge.y = box.y + radius; + edge.w = (float32_t)border->width.right; + edge.h = box.height - (radius * 2.0f); + PASS(errctx, akgl_draw_filled_rect(self, &edge, color)); + } + if ( border->width.top > 0 ) { + edge.x = box.x + radius; + edge.y = box.y; + edge.w = box.width - (radius * 2.0f); + edge.h = (float32_t)border->width.top; + PASS(errctx, akgl_draw_filled_rect(self, &edge, color)); + } + if ( border->width.bottom > 0 ) { + edge.x = box.x + radius; + edge.y = box.y + box.height - (float32_t)border->width.bottom; + edge.w = box.width - (radius * 2.0f); + edge.h = (float32_t)border->width.bottom; + PASS(errctx, akgl_draw_filled_rect(self, &edge, color)); + } + if ( radius > 0.0f ) { + thickness = (float32_t)((border->width.top > border->width.left) ? border->width.top : border->width.left); + if ( thickness > 0.0f ) { + PASS(errctx, akgl_draw_arc(self, box.x + radius, box.y + radius, radius, 180.0f, 270.0f, thickness, color)); + } + thickness = (float32_t)((border->width.top > border->width.right) ? border->width.top : border->width.right); + if ( thickness > 0.0f ) { + PASS(errctx, akgl_draw_arc(self, box.x + box.width - radius, box.y + radius, radius, 270.0f, 360.0f, thickness, color)); + } + thickness = (float32_t)((border->width.bottom > border->width.right) ? border->width.bottom : border->width.right); + if ( thickness > 0.0f ) { + PASS(errctx, akgl_draw_arc(self, box.x + box.width - radius, box.y + box.height - radius, radius, 0.0f, 90.0f, thickness, color)); + } + thickness = (float32_t)((border->width.bottom > border->width.left) ? border->width.bottom : border->width.left); + if ( thickness > 0.0f ) { + PASS(errctx, akgl_draw_arc(self, box.x + radius, box.y + box.height - radius, radius, 90.0f, 180.0f, thickness, color)); + } + } + SUCCEED_RETURN(errctx); +} + +/** + * @brief Draw one TEXT command through akgl_text_rendertextat(). + * + * clay wraps text itself -- each command is one line -- so the line is drawn + * unwrapped at the command's position. The slice is copied through the + * bounded scratch because akgl_text_rendertextat takes a C string. + * + * @param self The backend. Assumed checked by the caller. + * @param command The TEXT render command. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the line exceeds #AKGL_UI_MAX_TEXT_BYTES or the + * fontId does not resolve. + * @throws AKERR_* Whatever the copy or the draw underneath raises. + */ +static akerr_ErrorContext *ui_execute_text(akgl_RenderBackend *self, Clay_RenderCommand *command) +{ + Clay_TextRenderData *text = &command->renderData.text; + TTF_Font *font = NULL; + + PREPARE_ERROR(errctx); + if ( text->stringContents.length == 0 ) { + SUCCEED_RETURN(errctx); + } + FAIL_NONZERO_RETURN( + errctx, + (text->stringContents.length >= (int32_t)sizeof(ui_text_scratch)), + AKGL_ERR_UI, + "A text run of %d bytes exceeds AKGL_UI_MAX_TEXT_BYTES (%d)", + text->stringContents.length, + (int)sizeof(ui_text_scratch)); + PASS(errctx, ui_font_for_id(text->fontId, &font)); + PASS(errctx, aksl_strncpy( + ui_text_scratch, + sizeof(ui_text_scratch), + text->stringContents.chars, + (size_t)text->stringContents.length)); + PASS(errctx, akgl_text_rendertextat( + font, + ui_text_scratch, + ui_color_from_clay(text->textColor), + 0, + (int)command->boundingBox.x, + (int)command->boundingBox.y)); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Draw one IMAGE command: a sprite's first frame stretched to the box. + * + * @param self The backend. Assumed checked by the caller, `draw_texture` + * included. + * @param command The IMAGE render command, whose `imageData` is an + * `akgl_Sprite *` by the contract in akgl/ui.h. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the command carries no sprite, or the sprite has no + * sheet behind it. + * @throws AKERR_* Whatever the frame lookup or the draw underneath raises. + */ +static akerr_ErrorContext *ui_execute_image(akgl_RenderBackend *self, Clay_RenderCommand *command) +{ + akgl_Sprite *sprite = (akgl_Sprite *)command->renderData.image.imageData; + SDL_FRect src; + SDL_FRect dest; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN( + errctx, + sprite, + AKGL_ERR_UI, + "An IMAGE element carries no sprite; .image.imageData must be an akgl_Sprite pointer"); + FAIL_ZERO_RETURN( + errctx, + sprite->sheet, + AKGL_ERR_UI, + "The sprite named %s behind an IMAGE element has no sheet", + sprite->name); + PASS(errctx, akgl_spritesheet_coords_for_frame(sprite, &src, sprite->frameids[0])); + dest.x = command->boundingBox.x; + dest.y = command->boundingBox.y; + dest.w = command->boundingBox.width; + dest.h = command->boundingBox.height; + PASS(errctx, self->draw_texture(self, sprite->sheet->texture, &src, &dest, 0, NULL, SDL_FLIP_NONE)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_execute_commands(akgl_RenderBackend *self, Clay_RenderCommandArray *commands) +{ + Clay_RenderCommand *command = NULL; + SDL_FRect fill; + SDL_Rect clip; + bool clipped = false; + static bool custom_skip_logged = false; + int32_t i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); + FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend"); + FAIL_ZERO_RETURN(errctx, self->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture"); + FAIL_ZERO_RETURN(errctx, commands, AKERR_NULLPOINTER, "NULL command array"); + + ATTEMPT { + // The loop is the whole ATTEMPT body on purpose: CATCH reports + // failure by break, which must leave the block, and here leaving the + // loop is leaving the block. + for ( i = 0; i < commands->length; i++ ) { + command = Clay_RenderCommandArray_Get(commands, i); + switch ( command->commandType ) { + case CLAY_RENDER_COMMAND_TYPE_RECTANGLE: + fill.x = command->boundingBox.x; + fill.y = command->boundingBox.y; + fill.w = command->boundingBox.width; + fill.h = command->boundingBox.height; + CATCH(errctx, akgl_draw_filled_rounded_rect( + self, + &fill, + command->renderData.rectangle.cornerRadius.topLeft, + ui_color_from_clay(command->renderData.rectangle.backgroundColor))); + break; + case CLAY_RENDER_COMMAND_TYPE_BORDER: + CATCH(errctx, ui_execute_border(self, command)); + break; + case CLAY_RENDER_COMMAND_TYPE_TEXT: + CATCH(errctx, ui_execute_text(self, command)); + break; + case CLAY_RENDER_COMMAND_TYPE_IMAGE: + CATCH(errctx, ui_execute_image(self, command)); + break; + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_START: + clip.x = (int)command->boundingBox.x; + clip.y = (int)command->boundingBox.y; + clip.w = (int)command->boundingBox.width; + clip.h = (int)command->boundingBox.height; + CATCH(errctx, akgl_draw_set_clip(self, &clip)); + clipped = true; + break; + case CLAY_RENDER_COMMAND_TYPE_SCISSOR_END: + CATCH(errctx, akgl_draw_set_clip(self, NULL)); + clipped = false; + break; + case CLAY_RENDER_COMMAND_TYPE_CUSTOM: + // Skipped in this version, once in the log rather than once + // per frame -- sixty repeats a second is how a log gets + // ignored. + if ( custom_skip_logged == false ) { + SDL_Log("akgl_ui: CUSTOM render commands are not handled and were skipped"); + custom_skip_logged = true; + } + break; + default: + break; + } + // A CATCH inside a case reports failure with `break`, and that + // break binds to the *switch* -- so a failed command falls out + // here with errctx carrying the failure, and this is what leaves + // the loop. On success errctx is still NULL; check the pointer, + // not a status through it. + if ( errctx != NULL ) { + break; + } + } + } CLEANUP { + // A failed frame must not leave the next frame's world clipped. + if ( clipped == true ) { + IGNORE(akgl_draw_set_clip(self, NULL)); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Report whether the pointer is over any element of the retained layout. + * + * The one question the consumed contract turns on. Asked of clay rather than + * answered from our own geometry, so floating elements, z-order and culling + * all mean whatever clay says they mean -- with one exception: clay wraps + * every layout in an internal `Clay__RootContainer` sized to the whole + * screen, so "over anything" naively answers yes for every pixel of the + * window. The root is excluded here; being inside the window is not being + * over the interface. + */ +static bool ui_pointer_over_anything(void) +{ + static uint32_t rootid = 0; + Clay_ElementIdArray over = Clay_GetPointerOverIds(); + int32_t i = 0; + + if ( rootid == 0 ) { + rootid = Clay_GetElementId(CLAY_STRING("Clay__RootContainer")).id; + } + for ( i = 0; i < over.length; i++ ) { + if ( over.internalArray[i].id != rootid ) { + return true; + } + } + return false; +} + +akerr_ErrorContext *akgl_ui_handle_event(void *appstate, SDL_Event *event, bool *consumed) +{ + Clay_Vector2 position; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, appstate, AKERR_NULLPOINTER, "NULL appstate"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination"); + *consumed = false; + // Inert until initialized, like the rest of the subsystem: the host is + // told "not mine" rather than "not ready", so its event loop needs no + // knowledge of whether the UI is up. + if ( ui_context == NULL ) { + SUCCEED_RETURN(errctx); + } + + switch ( event->type ) { + case SDL_EVENT_MOUSE_MOTION: + ui_pointer_x = event->motion.x; + ui_pointer_y = event->motion.y; + ui_pointer_pending_moved = true; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + // Fed per event, not per frame, which is the usage clay documents: + // hover tracks the pointer through fast motion instead of sampling + // wherever it happened to be when the frame began. + Clay_SetPointerState(position, ui_pointer_down); + break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + if ( event->button.button != SDL_BUTTON_LEFT ) { + break; + } + ui_pointer_x = event->button.x; + ui_pointer_y = event->button.y; + ui_pointer_down = true; + ui_pointer_pending_pressed = true; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + Clay_SetPointerState(position, true); + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_MOUSE_BUTTON_UP: + if ( event->button.button != SDL_BUTTON_LEFT ) { + break; + } + ui_pointer_x = event->button.x; + ui_pointer_y = event->button.y; + ui_pointer_down = false; + position.x = ui_pointer_x; + position.y = ui_pointer_y; + Clay_SetPointerState(position, false); + // The release is consumed on the same terms as the press: a click + // that began on a panel must not leak its other half to the game. + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_MOUSE_WHEEL: + ui_wheel_x += event->wheel.x; + ui_wheel_y += event->wheel.y; + *consumed = ui_pointer_over_anything(); + break; + case SDL_EVENT_WINDOW_RESIZED: + PASS(errctx, akgl_ui_resize(event->window.data1, event->window.data2)); + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_frame_begin(void) +{ + Clay_Vector2 scroll; + uint64_t now = 0; + float dt = 0.0f; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_NONZERO_RETURN( + errctx, + ui_in_frame, + AKGL_ERR_UI, + "A UI frame is already open; a frame_begin/frame_begin sequence means a frame_end went missing"); + ui_clay_error_text[0] = '\0'; + ui_clay_error_count = 0; + + // Latch the press and motion edges for the widgets this frame declares, + // then let the event loop start accumulating the next ones. + ui_pointer_pressed = ui_pointer_pending_pressed; + ui_pointer_pending_pressed = false; + ui_pointer_moved = ui_pointer_pending_moved; + ui_pointer_pending_moved = false; + + now = SDL_GetTicksNS(); + if ( ui_last_frame_ns != 0 ) { + dt = (float)(now - ui_last_frame_ns) / (float)AKGL_TIME_ONESEC_NS; + } + ui_last_frame_ns = now; + scroll.x = ui_wheel_x * UI_WHEEL_NOTCH_PIXELS; + scroll.y = ui_wheel_y * UI_WHEEL_NOTCH_PIXELS; + ui_wheel_x = 0.0f; + ui_wheel_y = 0.0f; + Clay_UpdateScrollContainers(false, scroll, dt); + + Clay_BeginLayout(); + ui_in_frame = true; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_frame_end(akgl_RenderBackend *self) +{ + Clay_RenderCommandArray commands; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self"); + FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend"); + FAIL_ZERO_RETURN(errctx, self->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture"); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_NONZERO_RETURN( + errctx, + (ui_in_frame == false), + AKGL_ERR_UI, + "No UI frame is open; akgl_ui_frame_begin starts one"); + // Closed before anything can fail, so one bad frame is one bad frame and + // the next frame_begin is legal rather than "already open". + ui_in_frame = false; + commands = Clay_EndLayout(); + // Layout errors take precedence over drawing: a layout that failed is not + // a layout, and drawing its partial commands would only bury the cause + // under whatever the draw then reported. + FAIL_NONZERO_RETURN( + errctx, + (ui_clay_error_count > 0), + AKGL_ERR_UI, + "clay reported %u layout error(s); the first: %s", + ui_clay_error_count, + ui_clay_error_text); + PASS(errctx, akgl_ui_execute_commands(self, &commands)); + SUCCEED_RETURN(errctx); +} + +/** @brief Wrap a caller's C string as the length-and-pointer form clay takes. Borrowed, not copied. */ +static Clay_String ui_string_from(char *text) +{ + Clay_String out; + + out.isStaticallyAllocated = false; + out.length = (int32_t)SDL_strlen(text); + out.chars = text; + return out; +} + +/** @brief Convert an SDL_Color to clay's float 0-255 convention. */ +static Clay_Color ui_clay_color(SDL_Color color) +{ + Clay_Color out; + + out.r = (float)color.r; + out.g = (float)color.g; + out.b = (float)color.b; + out.a = (float)color.a; + return out; +} + +/** + * @brief The two checks every widget makes before declaring anything. + * + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKGL_ERR_UI If the subsystem is not initialized or no frame is + * open. + */ +static akerr_ErrorContext *ui_widget_ready(void) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, ui_context, AKGL_ERR_UI, "The UI subsystem is not initialized"); + FAIL_NONZERO_RETURN( + errctx, + (ui_in_frame == false), + AKGL_ERR_UI, + "Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end"); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_dialog(char *id, char *text, akgl_UiStyle *style) +{ + const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style; + uint16_t pad = (uint16_t)look->padding; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null dialog id"); + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null dialog text"); + PASS(errctx, ui_widget_ready()); + + CLAY({ + .id = CLAY_SIDI(ui_string_from(id), 0), + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED((float)ui_layout_width - (look->padding * 2.0f)), + .height = CLAY_SIZING_FIXED(AKGL_UI_DIALOG_HEIGHT) + }, + .padding = { pad, pad, pad, pad } + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .border = { + .color = ui_clay_color(look->edge), + .width = { 1, 1, 1, 1, 0 } + }, + .floating = { + .offset = { 0.0f, -look->padding }, + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_BOTTOM, + .parent = CLAY_ATTACH_POINT_CENTER_BOTTOM + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({ + .textColor = ui_clay_color(look->ink), + .fontId = look->fontid + })); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_label(char *id, char *text, akgl_UiAnchor anchor, akgl_UiStyle *style) +{ + const akgl_UiStyle *look = ( style != NULL ) ? style : &ui_default_style; + uint16_t pad = (uint16_t)look->padding; + Clay_FloatingAttachPointType attach = CLAY_ATTACH_POINT_LEFT_TOP; + Clay_Vector2 offset = { 0.0f, 0.0f }; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, id, AKERR_NULLPOINTER, "Null label id"); + FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "Null label text"); + PASS(errctx, ui_widget_ready()); + + /* + * Anchor Attach point Inset + */ + switch ( anchor ) { + case AKGL_UI_ANCHOR_TOP_LEFT: + attach = CLAY_ATTACH_POINT_LEFT_TOP; + offset.x = look->padding; + offset.y = look->padding; + break; + case AKGL_UI_ANCHOR_TOP_RIGHT: + attach = CLAY_ATTACH_POINT_RIGHT_TOP; + offset.x = -look->padding; + offset.y = look->padding; + break; + case AKGL_UI_ANCHOR_BOTTOM_LEFT: + attach = CLAY_ATTACH_POINT_LEFT_BOTTOM; + offset.x = look->padding; + offset.y = -look->padding; + break; + case AKGL_UI_ANCHOR_BOTTOM_RIGHT: + attach = CLAY_ATTACH_POINT_RIGHT_BOTTOM; + offset.x = -look->padding; + offset.y = -look->padding; + break; + case AKGL_UI_ANCHOR_CENTER: + attach = CLAY_ATTACH_POINT_CENTER_CENTER; + break; + default: + FAIL_RETURN(errctx, AKERR_OUTOFBOUNDS, "Anchor %d is not an akgl_UiAnchor", (int)anchor); + } + + CLAY({ + .id = CLAY_SIDI(ui_string_from(id), 0), + .layout = { + .padding = { pad, pad, pad, pad } + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .floating = { + .offset = offset, + .attachPoints = { .element = attach, .parent = attach }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + CLAY_TEXT(ui_string_from(text), CLAY_TEXT_CONFIG({ + .textColor = ui_clay_color(look->ink), + .fontId = look->fontid + })); + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_menu(akgl_UiMenu *menu) +{ + const akgl_UiStyle *look = NULL; + uint16_t pad = 0; + Clay_String idstring; + Clay_Color rowink; + Clay_Color rowfill; + bool rowselected = false; + int32_t i = 0; + + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu"); + FAIL_ZERO_RETURN(errctx, menu->id, AKERR_NULLPOINTER, "Null menu id"); + FAIL_NONZERO_RETURN( + errctx, + ((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)), + AKERR_OUTOFBOUNDS, + "A menu carries 1 to %d entries; this one claims %d", + AKGL_UI_MENU_MAX_ITEMS, + menu->count); + FAIL_NONZERO_RETURN( + errctx, + ((menu->selected < 0) || (menu->selected >= menu->count)), + AKERR_OUTOFBOUNDS, + "Selection %d is outside this menu's %d entries", + menu->selected, + menu->count); + for ( i = 0; i < menu->count; i++ ) { + FAIL_ZERO_RETURN(errctx, menu->items[i], AKERR_NULLPOINTER, "Menu entry %d is NULL", i); + } + PASS(errctx, ui_widget_ready()); + + look = ( menu->style != NULL ) ? menu->style : &ui_default_style; + pad = (uint16_t)look->padding; + idstring = ui_string_from(menu->id); + + CLAY({ + .id = CLAY_SIDI(idstring, 0), + .layout = { + .padding = { pad, pad, pad, pad }, + .layoutDirection = CLAY_TOP_TO_BOTTOM + }, + .backgroundColor = ui_clay_color(look->fill), + .cornerRadius = CLAY_CORNER_RADIUS(look->corner_radius), + .border = { + .color = ui_clay_color(look->edge), + .width = { 1, 1, 1, 1, 0 } + }, + .floating = { + .attachPoints = { + .element = CLAY_ATTACH_POINT_CENTER_CENTER, + .parent = CLAY_ATTACH_POINT_CENTER_CENTER + }, + .attachTo = CLAY_ATTACH_TO_ROOT + } + }) { + for ( i = 0; i < menu->count; i++ ) { + // The selected row draws inverted. An unselected row declares no + // background at all -- alpha 0 emits no render command -- so the + // panel's fill shows through rather than being repainted. + rowselected = (i == menu->selected); + rowink = rowselected ? ui_clay_color(look->fill) : ui_clay_color(look->ink); + rowfill = rowselected ? ui_clay_color(look->ink) : (Clay_Color){ 0, 0, 0, 0 }; + CLAY({ + .id = CLAY_SIDI(idstring, (uint32_t)(i + 1)), + .layout = { + .sizing = { .width = CLAY_SIZING_GROW(0) }, + .padding = { pad, pad, (uint16_t)(pad / 2), (uint16_t)(pad / 2) } + }, + .backgroundColor = rowfill + }) { + // Mouse selection: a row claims the selection when the + // pointer moves onto it or clicks it -- not merely by being + // under a stationary pointer, which would re-claim it every + // frame and the keyboard would fight it and lose. The + // frame-latched press edge on a hovered row confirms. The + // row highlight catches up next frame, which at any playable + // frame rate is invisible. + if ( Clay_Hovered() && (ui_pointer_moved || ui_pointer_pressed) ) { + menu->selected = i; + if ( ui_pointer_pressed ) { + menu->activated = true; + } + } + CLAY_TEXT(ui_string_from(menu->items[i]), CLAY_TEXT_CONFIG({ + .textColor = rowink, + .fontId = look->fontid + })); + } + } + } + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akgl_ui_menu_handle_event(akgl_UiMenu *menu, SDL_Event *event, bool *consumed) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN(errctx, menu, AKERR_NULLPOINTER, "Null menu"); + FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "NULL event"); + FAIL_ZERO_RETURN(errctx, consumed, AKERR_NULLPOINTER, "NULL consumed destination"); + FAIL_NONZERO_RETURN( + errctx, + ((menu->count < 1) || (menu->count > AKGL_UI_MENU_MAX_ITEMS)), + AKERR_OUTOFBOUNDS, + "A menu carries 1 to %d entries; this one claims %d", + AKGL_UI_MENU_MAX_ITEMS, + menu->count); + FAIL_NONZERO_RETURN( + errctx, + ((menu->selected < 0) || (menu->selected >= menu->count)), + AKERR_OUTOFBOUNDS, + "Selection %d is outside this menu's %d entries", + menu->selected, + menu->count); + *consumed = false; + if ( ui_context == NULL ) { + SUCCEED_RETURN(errctx); + } + + switch ( event->type ) { + case SDL_EVENT_KEY_DOWN: + if ( event->key.key == SDLK_UP ) { + menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1); + *consumed = true; + } else if ( event->key.key == SDLK_DOWN ) { + menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1); + *consumed = true; + } else if ( event->key.key == SDLK_RETURN ) { + menu->activated = true; + *consumed = true; + } + break; + case SDL_EVENT_GAMEPAD_BUTTON_DOWN: + if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_UP ) { + menu->selected = ( menu->selected == 0 ) ? (menu->count - 1) : (menu->selected - 1); + *consumed = true; + } else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_DPAD_DOWN ) { + menu->selected = ( menu->selected == (menu->count - 1) ) ? 0 : (menu->selected + 1); + *consumed = true; + } else if ( event->gbutton.button == SDL_GAMEPAD_BUTTON_SOUTH ) { + menu->activated = true; + *consumed = true; + } + break; + default: + break; + } + SUCCEED_RETURN(errctx); +} + +void akgl_ui_arena_limit(size_t limit) +{ + if ( (limit == 0) || (limit > AKGL_UI_ARENA_BYTES) ) { + ui_arena_usable = AKGL_UI_ARENA_BYTES; + } else { + ui_arena_usable = limit; + } +} diff --git a/src/ui_clay.c b/src/ui_clay.c new file mode 100644 index 0000000..1adf5cf --- /dev/null +++ b/src/ui_clay.c @@ -0,0 +1,21 @@ +/** + * @file ui_clay.c + * @brief The one translation unit that compiles clay's implementation. + * + * clay is a single-header library: every other file that includes + * gets declarations only, and exactly one file in the whole program defines + * CLAY_IMPLEMENTATION before the include to get the definitions. This is that + * file, and it must stay the only one -- a second definition anywhere, + * including in a consuming game, is two copies of clay's globals and the + * loader picking between them silently. The warning in akgl/ui.h says so to + * consumers; this comment says so to us. + * + * Nothing else belongs here. libakgl's own UI code is src/ui.c, which + * includes like any other consumer. CMakeLists.txt compiles this + * file with -w on the same terms as deps/semver and deps/libccd: 99% of what + * this TU contains is vendored code, and a future clay bump must not be able + * to fail the build on a warning we do not own. + */ + +#define CLAY_IMPLEMENTATION +#include diff --git a/tests/character.c b/tests/character.c index 794cfc4..5f47b0a 100644 --- a/tests/character.c +++ b/tests/character.c @@ -284,6 +284,10 @@ akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void) int main(void) { PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software"); ATTEMPT { CATCH(errctx, akgl_error_init()); akgl_renderer = &akgl_default_renderer; diff --git a/tests/docs_preludes/docs_prelude.h b/tests/docs_preludes/docs_prelude.h index 477461c..ee68daa 100644 --- a/tests/docs_preludes/docs_prelude.h +++ b/tests/docs_preludes/docs_prelude.h @@ -50,6 +50,7 @@ #include #include #include +#include #include /** diff --git a/tests/draw.c b/tests/draw.c index 8088b4e..401a99c 100644 --- a/tests/draw.c +++ b/tests/draw.c @@ -628,6 +628,183 @@ akerr_ErrorContext *test_draw_background(void) SUCCEED_RETURN(errctx); } +akerr_ErrorContext *test_draw_filled_rounded_rect(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + SDL_FRect rect = { 8.0f, 8.0f, 40.0f, 24.0f }; + SDL_FRect square = { 40.0f, 40.0f, 16.0f, 16.0f }; + + ATTEMPT { + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 8.0f, testred), + "filling a rounded rectangle"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 28, 20, testred), + "the middle of the rounded rectangle is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 28, 8, testred), + "the top edge between the corners is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 8, 20, testred), + "the left edge between the corners is unfilled"); + // 12,12 is inside the corner's quarter circle (5.7px from its centre + // at 16,16); 8,8 and 9,9 are outside it (11.3px and 9.9px). The fan's + // triangles are chords of the circle, so they cover nothing beyond it. + TEST_ASSERT(errctx, pixel_is(shot, 12, 12, testred), + "the inside of the rounded corner is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testblack), + "the square corner pixel was filled despite the rounding"); + TEST_ASSERT(errctx, pixel_is(shot, 9, 9, testblack), + "a pixel outside the corner arc was filled"); + SDL_DestroySurface(shot); + shot = NULL; + + // A radius beyond half the shorter side clamps to it, which on a + // square is a circle: centre filled, corners untouched. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &square, 100.0f, testgreen), + "filling a rounded rectangle with an oversized radius"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 48, 48, testgreen), + "the centre of the clamped-radius square is unfilled"); + TEST_ASSERT(errctx, pixel_is(shot, 41, 41, testblack), + "clamping the radius still filled the square's corner"); + + // Radius zero is the plain fill, corners and all. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 0.0f, testred), + "a zero radius falling through to the plain fill"); + SDL_DestroySurface(shot); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred), + "a zero radius did not fill the square corner"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_filled_rounded_rect(NULL, &rect, 4.0f, testred), + "rounded fill through a NULL backend"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_filled_rounded_rect(akgl_renderer, NULL, 4.0f, testred), + "rounded fill of a NULL rectangle"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_draw_arc(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + + ATTEMPT { + // A full ring: outer radius 20, stroke 4, so mid-stroke is radius 18. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 360.0f, 4.0f, testred), + "stroking a full circle"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 50, 32, testred), + "the ring is missing at three o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 50, testred), + "the ring is missing at six o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 14, 32, testred), + "the ring is missing at nine o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testred), + "the ring is missing at twelve o'clock"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack), + "the centre of the ring was filled"); + TEST_ASSERT(errctx, pixel_is(shot, 42, 32, testblack), + "the stroke bled inside its inner radius"); + SDL_DestroySurface(shot); + shot = NULL; + + // A quarter arc sweeps clockwise from three o'clock to six o'clock; + // 44,44 is on its mid-stroke at 45 degrees, twelve o'clock is not in + // the sweep at all. + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testgreen), + "stroking a quarter arc"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 44, 44, testgreen), + "the quarter arc is missing at its midpoint"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testblack), + "the quarter arc drew outside its sweep"); + + // An empty sweep draws nothing and reports nothing. + TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 90.0f, 90.0f, 4.0f, testred), + "an arc with no sweep"); + + TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, + akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 0.0f, 0.0f, 90.0f, 4.0f, testred), + "an arc with no radius"); + TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS, + akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, -1.0f, testred), + "an arc with a negative thickness"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_arc(NULL, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testred), + "an arc through a NULL backend"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_draw_set_clip(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + SDL_Rect clip = { 0, 0, 16, 16 }; + SDL_FRect everything = { 0.0f, 0.0f, (float)TEST_TARGET_SIZE, (float)TEST_TARGET_SIZE }; + + ATTEMPT { + CATCH(errctx, clear_target()); + TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, &clip), + "setting the clip rectangle"); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testred), + "filling the whole target under a clip"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred), + "the fill did not reach inside the clip rectangle"); + TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack), + "the fill escaped the clip rectangle"); + SDL_DestroySurface(shot); + shot = NULL; + + TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, NULL), + "clearing the clip rectangle"); + TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testgreen), + "filling the whole target after clearing the clip"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testgreen), + "clearing the clip did not restore the full target"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_draw_set_clip(NULL, &clip), + "clipping through a NULL backend"); + } CLEANUP { + // The clip is renderer state; leaving it set would clip every test + // after this one. + IGNORE(akgl_draw_set_clip(akgl_renderer, NULL)); + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + int main(void) { PREPARE_ERROR(errctx); @@ -668,6 +845,9 @@ int main(void) CATCH(errctx, test_draw_preserves_render_draw_color()); CATCH(errctx, test_draw_backend_without_a_renderer()); CATCH(errctx, test_draw_background()); + CATCH(errctx, test_draw_filled_rounded_rect()); + CATCH(errctx, test_draw_arc()); + CATCH(errctx, test_draw_set_clip()); } CLEANUP { SDL_Quit(); } PROCESS(errctx) { diff --git a/tests/error.c b/tests/error.c index 046d863..b739b19 100644 --- a/tests/error.c +++ b/tests/error.c @@ -33,7 +33,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void) { AKGL_ERR_HEAP, "Heap Error" }, { AKGL_ERR_BEHAVIOR, "Behavior Error" }, { AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" }, - { AKGL_ERR_COLLISION, "Collision Error" } + { AKGL_ERR_COLLISION, "Collision Error" }, + { AKGL_ERR_UI, "UI Error" } }; bool named = true; int i = 0; diff --git a/tests/sprite.c b/tests/sprite.c index 65147bf..248368a 100644 --- a/tests/sprite.c +++ b/tests/sprite.c @@ -240,6 +240,10 @@ int main(void) { PREPARE_ERROR(errctx); + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software"); + ATTEMPT { CATCH(errctx, akgl_error_init()); akgl_renderer = &akgl_default_renderer; diff --git a/tests/tilemap.c b/tests/tilemap.c index 233146f..5ad91c4 100644 --- a/tests/tilemap.c +++ b/tests/tilemap.c @@ -740,6 +740,10 @@ int main(void) { PREPARE_ERROR(errctx); + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software"); + ATTEMPT { CATCH(errctx, akgl_error_init()); akgl_gamemap = &akgl_default_gamemap; diff --git a/tests/ui.c b/tests/ui.c new file mode 100644 index 0000000..4717cc5 --- /dev/null +++ b/tests/ui.c @@ -0,0 +1,1033 @@ +/** + * @file ui.c + * @brief Unit tests for the UI subsystem: lifecycle, fonts, measurement and rendering. + * + * The lifecycle and arena tests run without a renderer -- akgl_ui_init takes + * its layout dimensions as parameters precisely so that a headless program + * can bring the subsystem up. The layout tests draw real clay layouts into a + * small software renderer under the dummy video driver and read the target + * back, the same arrangement tests/draw.c uses, so their assertions are about + * pixels rather than about clay having been called. + */ + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "testutil.h" + +/** @brief Width and height of the offscreen target the layout tests draw into. */ +#define TEST_TARGET_SIZE 64 + +/** @brief The font every text test measures and draws with. */ +#define TEST_FONT_PATH "assets/akgl_test_mono.ttf" +/** @brief Registry name the test font is loaded under. */ +#define TEST_FONT_NAME "uifont" +/** @brief Point size the test font is loaded at. */ +#define TEST_FONT_SIZE 16 + +/** @brief Opaque black, what each layout test clears the target to. */ +static const SDL_Color testblack = { 0x00, 0x00, 0x00, 0xff }; +/** @brief The color most layout tests fill with. */ +static const SDL_Color testred = { 0xff, 0x00, 0x00, 0xff }; +/** @brief A second color, for borders against fills. */ +static const SDL_Color testgreen = { 0x00, 0xff, 0x00, 0xff }; + +/** @brief Clear the whole target to opaque black. */ +static akerr_ErrorContext *clear_target(void) +{ + PREPARE_ERROR(errctx); + FAIL_ZERO_RETURN( + errctx, + SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x00, 0x00, 0x00, 0xff), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + FAIL_ZERO_RETURN( + errctx, + SDL_RenderClear(akgl_renderer->sdl_renderer), + AKGL_ERR_SDL, + "%s", + SDL_GetError()); + SUCCEED_RETURN(errctx); +} + +/** @brief Report whether one pixel of @p shot carries @p color. Alpha is not compared. */ +static bool pixel_is(SDL_Surface *shot, int x, int y, SDL_Color color) +{ + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 0; + + if ( shot == NULL ) { + return false; + } + if ( !SDL_ReadSurfacePixel(shot, x, y, &r, &g, &b, &a) ) { + return false; + } + return ((r == color.r) && (g == color.g) && (b == color.b)); +} + +/** + * @brief Init must refuse bad dimensions and an uninitialized resize. + * + * Runs before anything initializes the subsystem, because the "not + * initialized" refusal is half of what it asserts. + */ +akerr_ErrorContext *test_ui_validation(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(0, 240), + "akgl_ui_init accepted a zero width"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_init(320, -1), + "akgl_ui_init accepted a negative height"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_resize(320, 240), + "akgl_ui_resize worked on an uninitialized subsystem"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(), + "akgl_ui_frame_begin worked on an uninitialized subsystem"); + } CLEANUP { + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Init, double-init refusal, idempotent shutdown, re-init. + * + * The double init has to be refused rather than absorbed: a second + * Clay_Initialize over a live context would silently discard every element id + * and scroll position the first one held, which is the kind of reset a game + * only notices as "the menu forgot where it was". + */ +akerr_ErrorContext *test_ui_lifecycle(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "first akgl_ui_init failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240), + "a second akgl_ui_init was not refused"); + TEST_EXPECT_OK(e, akgl_ui_resize(640, 480), "resize on a live subsystem failed"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "akgl_ui_shutdown failed"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "a second akgl_ui_shutdown was not a no-op"); + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "re-init after shutdown failed"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after re-init failed"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief A too-small arena must refuse at init, loudly, and recover. + * + * The narrowed limit stands in for a consumer who raised AKGL_UI_MAX_ELEMENTS + * without raising AKGL_UI_ARENA_BYTES: the refusal at startup, with both + * numbers in the message, is the whole safety story -- past init, clay trusts + * the arena completely. Same shape as the collision arena's exhaustion test, + * for the same reason: rebuilding with a smaller ceiling is not something CI + * can do. + */ +akerr_ErrorContext *test_ui_arena_exhaustion(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + akgl_ui_arena_limit(64); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_init(320, 240), + "akgl_ui_init accepted an arena clay cannot fit in"); + akgl_ui_arena_limit(0); + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), + "akgl_ui_init failed after the arena limit was restored"); + TEST_EXPECT_OK(e, akgl_ui_shutdown(), "shutdown after the exhaustion test failed"); + } CLEANUP { + akgl_ui_arena_limit(0); + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The fontId table: issue, dedupe, refuse the unknown and the overfull. + * + * The table stores names and resolves them per use, so the interesting cases + * are all at registration: an id for a loaded font, the same id again for the + * same name, a refusal for a name nothing loaded, and a refusal -- naming the + * ceiling -- when the table is full. + */ +akerr_ErrorContext *test_ui_font_register(void) +{ + PREPARE_ERROR(e); + uint16_t fontid = 99; + uint16_t again = 99; + uint16_t extra = 99; + char slotname[8]; + bool loaded = true; + bool registered = true; + int count = 0; + int i = 0; + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "font registration worked on an uninitialized subsystem"); + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the font table test failed"); + + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering a loaded font failed"); + TEST_ASSERT(e, fontid == 0, "the first fontId issued was %u, not 0", fontid); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &again), + "re-registering the same name failed"); + TEST_ASSERT(e, again == fontid, + "re-registering the same name issued a second id (%u after %u)", again, fontid); + + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register("nothing-loaded-this", &extra), + "a name absent from the font registry was registered"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_font_register(NULL, &extra), + "a NULL name was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_font_register(TEST_FONT_NAME, NULL), + "a NULL id destination was accepted"); + + // Fill the table: the same face under fresh names costs a slot each, + // because the size-baked handle is what a slot maps to. + for ( i = 1; i < AKGL_UI_MAX_FONTS; i++ ) { + IGNORE(aksl_snprintf(&count, slotname, sizeof(slotname), "f%d", i)); + TEST_ASSERT_FLAG(loaded, akgl_text_loadfont(slotname, TEST_FONT_PATH, TEST_FONT_SIZE) == NULL); + TEST_ASSERT_FLAG(registered, akgl_ui_font_register(slotname, &extra) == NULL); + } + TEST_ASSERT(e, loaded, "loading the fill fonts failed"); + TEST_ASSERT(e, registered, "registering the fill fonts failed"); + TEST_EXPECT_OK(e, akgl_text_loadfont("overflow", TEST_FONT_PATH, TEST_FONT_SIZE), + "loading the overflow font failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_font_register("overflow", &extra), + "a full fontId table issued another id"); + } CLEANUP { + IGNORE(akgl_text_unloadfont("overflow")); + for ( i = 1; i < AKGL_UI_MAX_FONTS; i++ ) { + IGNORE(aksl_snprintf(&count, slotname, sizeof(slotname), "f%d", i)); + IGNORE(akgl_text_unloadfont(slotname)); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The measure bridge must agree with akgl_text_measure, slice or not. + * + * clay hands the callback slices carved out of larger strings, so the case + * that matters is a slice whose stated length stops short of the bytes behind + * it -- five bytes of "HelloWorld" must measure exactly as "Hello" does. A + * fontId nothing issued reports zero-by-zero, the only failure channel the + * callback signature allows. + */ +akerr_ErrorContext *test_ui_measure_text(void) +{ + PREPARE_ERROR(e); + Clay_TextElementConfig config; + Clay_StringSlice slice; + Clay_Dimensions sliced; + Clay_Dimensions whole; + TTF_Font *font = NULL; + uint16_t fontid = 0; + int w = 0; + int h = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the measure test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the measure test font failed"); + font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, TEST_FONT_NAME, NULL); + FAIL_ZERO_BREAK(e, font, AKGL_ERR_BEHAVIOR, "the test font left the registry"); + + SDL_memset(&config, 0x00, sizeof(config)); + config.fontId = fontid; + + slice.chars = "HelloWorld"; + slice.baseChars = slice.chars; + slice.length = 5; + sliced = akgl_ui_measure_text(slice, &config, NULL); + TEST_EXPECT_OK(e, akgl_text_measure(font, "Hello", &w, &h), + "measuring the whole string through the text subsystem failed"); + TEST_ASSERT_FEQ(e, sliced.width, (float)w, + "a 5-byte slice of HelloWorld measured %f wide; Hello measures %d", + (double)sliced.width, w); + TEST_ASSERT_FEQ(e, sliced.height, (float)h, + "a 5-byte slice of HelloWorld measured %f high; Hello measures %d", + (double)sliced.height, h); + + slice.length = 10; + whole = akgl_ui_measure_text(slice, &config, NULL); + TEST_ASSERT(e, whole.width > sliced.width, + "HelloWorld did not measure wider than its Hello prefix"); + + config.fontId = (uint16_t)(AKGL_UI_MAX_FONTS + 1); + sliced = akgl_ui_measure_text(slice, &config, NULL); + TEST_ASSERT_FEQ(e, sliced.width, 0.0f, + "an unregistered fontId measured %f wide instead of 0", + (double)sliced.width); + TEST_ASSERT_FEQ(e, sliced.height, 0.0f, + "an unregistered fontId measured %f high instead of 0", + (double)sliced.height); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The frame bracket refuses to nest, to close unopened, and recovers from failure. + */ +akerr_ErrorContext *test_ui_frame_bracket(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the frame bracket test failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer), + "frame_end closed a frame nothing opened"); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening a frame failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_begin(), + "a second frame_begin was not refused"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), + "closing an empty frame failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer), + "frame_end closed the same frame twice"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_frame_end(NULL), + "frame_end accepted a NULL backend"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief One real layout, executed, read back: fill, border and clip land where clay put them. + * + * An outer container with 8 pixels of padding and a fixed 20x20 child pins + * the child's box to (8,8)-(28,28) without depending on any layout behaviour + * subtler than "padding is padding". + */ +akerr_ErrorContext *test_ui_layout_pixels(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the layout test failed"); + + // A filled rectangle. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the fill frame failed"); + CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) { + CLAY({ + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(20), + .height = CLAY_SIZING_FIXED(20) + } + }, + .backgroundColor = { 255, 0, 0, 255 } + }) {} + } + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the fill frame failed"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(e, pixel_is(shot, 10, 10, testred), + "the fill did not land inside the child's box"); + TEST_ASSERT(e, pixel_is(shot, 27, 27, testred), + "the fill stopped short of the child's far corner"); + TEST_ASSERT(e, pixel_is(shot, 7, 7, testblack), + "the fill leaked outside the child's box"); + TEST_ASSERT(e, pixel_is(shot, 40, 40, testblack), + "the fill reached pixels no element occupies"); + SDL_DestroySurface(shot); + shot = NULL; + + // A border: green edges two pixels wide, nothing in the middle. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the border frame failed"); + CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) { + CLAY({ + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(20), + .height = CLAY_SIZING_FIXED(20) + } + }, + .border = { + .color = { 0, 255, 0, 255 }, + .width = { 2, 2, 2, 2, 0 } + } + }) {} + } + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the border frame failed"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(e, pixel_is(shot, 18, 9, testgreen), "the top border edge is missing"); + TEST_ASSERT(e, pixel_is(shot, 18, 26, testgreen), "the bottom border edge is missing"); + TEST_ASSERT(e, pixel_is(shot, 9, 18, testgreen), "the left border edge is missing"); + TEST_ASSERT(e, pixel_is(shot, 26, 18, testgreen), "the right border edge is missing"); + TEST_ASSERT(e, pixel_is(shot, 18, 18, testblack), "the border filled its middle"); + SDL_DestroySurface(shot); + shot = NULL; + + // A clipped child: a 30x30 fill inside a 10x10 clipping container + // must stop at the container's edge. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the clip frame failed"); + CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) { + CLAY({ + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(10), + .height = CLAY_SIZING_FIXED(10) + } + }, + .clip = { .horizontal = true, .vertical = true } + }) { + CLAY({ + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(30), + .height = CLAY_SIZING_FIXED(30) + } + }, + .backgroundColor = { 255, 0, 0, 255 } + }) {} + } + } + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the clip frame failed"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(e, pixel_is(shot, 10, 10, testred), + "the clipped child did not draw inside the clip"); + TEST_ASSERT(e, pixel_is(shot, 25, 25, testblack), + "the child escaped its clipping container"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Text laid out by clay lands on the target, and in its colour. + * + * Which pixels a rasterized glyph occupies is SDL_ttf's business, so the + * assertion is that *some* pixel in the text's box carries the text colour -- + * enough to prove the measure bridge, the scratch copy and the draw all met. + */ +akerr_ErrorContext *test_ui_layout_text(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + bool found = false; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the text layout test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the layout test font failed"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the text frame failed"); + CLAY({ .layout = { .padding = { 4, 0, 4, 0 } } }) { + CLAY_TEXT(CLAY_STRING("Hi"), CLAY_TEXT_CONFIG({ + .fontId = fontid, + .textColor = { 255, 0, 0, 255 } + })); + } + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the text frame failed"); + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) { + for ( x = 0; x < (TEST_TARGET_SIZE / 2); x++ ) { + if ( pixel_is(shot, x, y, testred) ) { + found = true; + } + } + } + TEST_ASSERT(e, found, "no pixel of the laid-out text carries the text colour"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** @brief Build a left-button mouse event at a position, down or up. */ +static void make_button_event(SDL_Event *event, uint32_t type, float x, float y) +{ + SDL_memset(event, 0x00, sizeof(SDL_Event)); + event->type = type; + event->button.button = SDL_BUTTON_LEFT; + event->button.x = x; + event->button.y = y; +} + +/** + * @brief The consumed contract: clicks on the UI are the UI's, everything else passes. + * + * The hit test runs against the layout the previous frame retained, so the + * test lays one panel out, then throws events at it: a click inside is + * consumed, a click outside is not, motion never is, and -- the case most + * likely to surprise -- a click before any layout exists is not consumed, + * because there is nothing to be over. + */ +akerr_ErrorContext *test_ui_handle_event(void) +{ + PREPARE_ERROR(e); + SDL_Event event; + bool consumed = true; + int token = 1; + + ATTEMPT { + // Uninitialized: everything passes through untouched. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "an event before init was refused"); + TEST_ASSERT(e, consumed == false, "an event before init was consumed"); + + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the event test failed"); + + // Initialized but nothing laid out yet: a click lands on no tree. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a click before any layout was refused"); + TEST_ASSERT(e, consumed == false, "a click was consumed before any layout existed"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the release before any layout was refused"); + + // Lay out one 20x20 panel at (8,8) for the hit tests that follow. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the hit-test frame failed"); + CLAY({ .layout = { .padding = { 8, 0, 8, 0 } } }) { + CLAY({ + .layout = { + .sizing = { + .width = CLAY_SIZING_FIXED(20), + .height = CLAY_SIZING_FIXED(20) + } + }, + .backgroundColor = { 255, 0, 0, 255 } + }) {} + } + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the hit-test frame failed"); + + // A press on the panel is the UI's; its release is too. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the press on the panel was refused"); + TEST_ASSERT(e, consumed == true, "a press on a UI panel was not consumed"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 10.0f, 10.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the release on the panel was refused"); + TEST_ASSERT(e, consumed == true, "a release on a UI panel was not consumed"); + + // A press beside the panel belongs to the game. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 50.0f, 50.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the press beside the panel was refused"); + TEST_ASSERT(e, consumed == false, "a press beside the panel was consumed"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_UP, 50.0f, 50.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the release beside the panel was refused"); + + // A right-button press is not the UI's business at all. + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + event.button.button = SDL_BUTTON_RIGHT; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a right-button press was refused"); + TEST_ASSERT(e, consumed == false, "a right-button press was consumed"); + + // Motion is never consumed, wherever it lands. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = 10.0f; + event.motion.y = 10.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "motion over the panel was refused"); + TEST_ASSERT(e, consumed == false, "motion over a panel was consumed"); + + // The wheel goes to the UI only while the pointer is over it. The + // motion event above left the pointer on the panel. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_WHEEL; + event.wheel.y = 1.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the wheel over the panel was refused"); + TEST_ASSERT(e, consumed == true, "the wheel over a panel was not consumed"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = 50.0f; + event.motion.y = 50.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "motion off the panel was refused"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_WHEEL; + event.wheel.y = 1.0f; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the wheel beside the panel was refused"); + TEST_ASSERT(e, consumed == false, "the wheel beside the panel was consumed"); + + // A resize reaches the layout engine and still passes through. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_WINDOW_RESIZED; + event.window.data1 = TEST_TARGET_SIZE; + event.window.data2 = TEST_TARGET_SIZE; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a resize event was refused"); + TEST_ASSERT(e, consumed == false, "a resize event was consumed"); + + // A key event is not the UI's; keyboard focus is the application's. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_RETURN; + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "a key event was refused"); + TEST_ASSERT(e, consumed == false, "a key event was consumed"); + + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, 10.0f, 10.0f); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(NULL, &event, &consumed), + "a NULL appstate was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, NULL, &consumed), + "a NULL event was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_handle_event(&token, &event, NULL), + "a NULL consumed destination was accepted"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** @brief The default widget palette's fill, for pixel assertions. Matches src/ui.c. */ +static const SDL_Color widgetfill = { 24, 20, 37, 235 }; +/** @brief The default widget palette's edge and ink. */ +static const SDL_Color widgetink = { 240, 236, 214, 255 }; + +/** + * @brief The dialog widget draws the textbox: fill inside, border on the edge, text on top. + * + * On a 64x64 layout with the default 8px padding the panel spans (8,0) to + * (56,56) -- the width follows the layout, the height is + * AKGL_UI_DIALOG_HEIGHT. The palette asserted here is the point: the default + * style *is* the JRPG textbox's, so the comparison chapter can put the two + * side by side. + */ +akerr_ErrorContext *test_ui_dialog_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + + ATTEMPT { + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL), + "a dialog was declared with no subsystem"); + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the dialog test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the dialog test font failed"); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_dialog("early", "words", NULL), + "a dialog was declared outside the frame bracket"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the dialog frame failed"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog(NULL, "words", NULL), + "a NULL dialog id was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_dialog("dialog", NULL, NULL), + "NULL dialog text was accepted"); + TEST_EXPECT_OK(e, akgl_ui_dialog("dialog", "Hi", NULL), "declaring the dialog failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the dialog frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + TEST_ASSERT(e, pixel_is(shot, 32, 40, widgetfill), + "the dialog panel's fill is missing from its middle"); + TEST_ASSERT(e, pixel_is(shot, 8, 28, widgetink), + "the dialog panel's border is missing from its left edge"); + TEST_ASSERT(e, pixel_is(shot, 4, 28, testblack), + "the dialog painted outside its margin"); + TEST_ASSERT(e, pixel_is(shot, 32, 60, testblack), + "the dialog painted over the margin below it"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Labels pin to their corners and stay out of the others. + */ +akerr_ErrorContext *test_ui_label_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + uint16_t fontid = 0; + bool topright = false; + bool bottomleft = false; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the label test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the label test font failed"); + + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the label frame failed"); + TEST_EXPECT_OK(e, akgl_ui_label("score", "9", AKGL_UI_ANCHOR_TOP_RIGHT, NULL), + "declaring the top-right label failed"); + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, + akgl_ui_label("bogus", "9", (akgl_UiAnchor)99, NULL), + "a nonsense anchor was accepted"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the label frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + // The label is fit-sized around its glyph, so where exactly it ends + // is the font's business; the assertions are per-quadrant instead. + for ( y = 0; y < (TEST_TARGET_SIZE / 2); y++ ) { + for ( x = (TEST_TARGET_SIZE / 2); x < TEST_TARGET_SIZE; x++ ) { + if ( pixel_is(shot, x, y, widgetfill) ) { + topright = true; + } + } + } + // The far corner, not the whole quadrant: a padded label on a 64px + // target is fat enough to graze the quadrant boundary, and where its + // edge falls is the font's business, not this test's. + for ( y = (TEST_TARGET_SIZE - 16); y < TEST_TARGET_SIZE; y++ ) { + for ( x = 0; x < 16; x++ ) { + if ( pixel_is(shot, x, y, widgetfill) ) { + bottomleft = true; + } + } + } + TEST_ASSERT(e, topright, "the top-right label left no fill in its quadrant"); + TEST_ASSERT(e, bottomleft == false, "the top-right label reached the opposite corner"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief The menu widget: validation, the inverted highlight, and mouse selection. + * + * The mouse half runs the real two-frame protocol: frame one lays the menu + * out, the click lands between frames against the retained tree, and frame + * two's declaration sees the hover and the latched press edge. Row geometry + * comes from Clay_GetElementData rather than being derived here, so the test + * cannot drift from however the menu lays its rows out. + */ +akerr_ErrorContext *test_ui_menu_widget(void) +{ + PREPARE_ERROR(e); + SDL_Surface *shot = NULL; + SDL_Event event; + Clay_ElementData rowdata; + static akgl_UiMenu menu = { + .id = "testmenu", + .items = { "New Game", "Options", "Quit" }, + .count = 3, + }; + akgl_UiMenu broken; + uint16_t fontid = 0; + bool consumed = false; + bool inverted = false; + int token = 1; + int x = 0; + int y = 0; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the menu test failed"); + TEST_EXPECT_OK(e, akgl_ui_font_register(TEST_FONT_NAME, &fontid), + "registering the menu test font failed"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(NULL), + "a NULL menu was accepted"); + broken = menu; + broken.count = 0; + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken), + "an empty menu was accepted"); + broken = menu; + broken.selected = 7; + TEST_EXPECT_STATUS(e, AKERR_OUTOFBOUNDS, akgl_ui_menu(&broken), + "a selection outside the menu was accepted"); + broken = menu; + broken.items[1] = NULL; + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_ui_menu(&broken), + "a NULL entry was accepted"); + + // Frame one: the menu as declared, selection on the first row. + CATCH(e, clear_target()); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the first menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "declaring the menu failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the first menu frame failed"); + + shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL); + FAIL_ZERO_BREAK(e, shot, AKGL_ERR_SDL, "%s", SDL_GetError()); + for ( y = 0; y < TEST_TARGET_SIZE; y++ ) { + for ( x = 0; x < TEST_TARGET_SIZE; x++ ) { + if ( pixel_is(shot, x, y, widgetink) ) { + inverted = true; + } + } + } + TEST_ASSERT(e, inverted, "no row carries the inverted highlight"); + + // The click: aimed at the second row's real box, landed between + // frames, seen by the declaration in frame two. + rowdata = Clay_GetElementData(Clay_GetElementIdWithIndex(CLAY_STRING("testmenu"), 2)); + TEST_ASSERT(e, rowdata.found, "the second row's element id cannot be found"); + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.x = rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f); + event.motion.y = rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "moving onto the second row was refused"); + make_button_event(&event, SDL_EVENT_MOUSE_BUTTON_DOWN, + rowdata.boundingBox.x + (rowdata.boundingBox.width / 2.0f), + rowdata.boundingBox.y + (rowdata.boundingBox.height / 2.0f)); + TEST_EXPECT_OK(e, akgl_ui_handle_event(&token, &event, &consumed), + "the click on the second row was refused"); + TEST_ASSERT(e, consumed == true, "the click on the menu was not consumed"); + + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the second menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the second menu frame failed"); + + TEST_ASSERT(e, menu.selected == 1, + "clicking the second row moved the selection to %d, not 1", menu.selected); + TEST_ASSERT(e, menu.activated == true, "clicking a row did not activate the menu"); + menu.activated = false; + + // The pointer is still parked over the second row. Keyboard moves the + // selection away, and redeclaring the menu must not let the + // stationary pointer claim it back -- only motion or a click selects. + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), + "Down after the click was refused"); + TEST_ASSERT(e, menu.selected == 2, "Down did not move the selection off the hovered row"); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the third menu frame failed"); + TEST_EXPECT_OK(e, akgl_ui_menu(&menu), "redeclaring the menu a third time failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), "closing the third menu frame failed"); + TEST_ASSERT(e, menu.selected == 2, + "a stationary pointer stole the selection back to %d", menu.selected); + TEST_ASSERT(e, menu.activated == false, + "a stationary pointer activated the menu"); + } CLEANUP { + if ( shot != NULL ) { + SDL_DestroySurface(shot); + } + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief Keyboard and gamepad drive a menu: wrap both ways, confirm, ignore the rest. + */ +akerr_ErrorContext *test_ui_menu_events(void) +{ + PREPARE_ERROR(e); + SDL_Event event; + static akgl_UiMenu menu = { + .id = "eventmenu", + .items = { "One", "Two", "Three" }, + .count = 3, + }; + bool consumed = false; + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(320, 240), "init for the menu event test failed"); + menu.selected = 0; + menu.activated = false; + + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_KEY_DOWN; + event.key.key = SDLK_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_ASSERT(e, consumed && (menu.selected == 1), "Down did not move the selection to 1"); + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Down was refused"); + TEST_ASSERT(e, menu.selected == 0, "Down from the last row did not wrap to the first"); + event.key.key = SDLK_UP; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Up was refused"); + TEST_ASSERT(e, menu.selected == 2, "Up from the first row did not wrap to the last"); + event.key.key = SDLK_RETURN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Return was refused"); + TEST_ASSERT(e, consumed && menu.activated, "Return did not activate the menu"); + menu.activated = false; + event.key.key = SDLK_ESCAPE; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "Escape was refused"); + TEST_ASSERT(e, consumed == false, "a key the menu does not use was consumed"); + + SDL_memset(&event, 0x00, sizeof(SDL_Event)); + event.type = SDL_EVENT_GAMEPAD_BUTTON_DOWN; + event.gbutton.button = SDL_GAMEPAD_BUTTON_DPAD_DOWN; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "D-pad down was refused"); + TEST_ASSERT(e, consumed && (menu.selected == 0), "D-pad down did not wrap to the first row"); + event.gbutton.button = SDL_GAMEPAD_BUTTON_SOUTH; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "South was refused"); + TEST_ASSERT(e, consumed && menu.activated, "South did not activate the menu"); + menu.activated = false; + event.gbutton.button = SDL_GAMEPAD_BUTTON_EAST; + TEST_EXPECT_OK(e, akgl_ui_menu_handle_event(&menu, &event, &consumed), "East was refused"); + TEST_ASSERT(e, consumed == false, "a button the menu does not use was consumed"); + + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(NULL, &event, &consumed), + "a NULL menu was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(&menu, NULL, &consumed), + "a NULL event was accepted"); + TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, + akgl_ui_menu_handle_event(&menu, &event, NULL), + "a NULL consumed destination was accepted"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +/** + * @brief A layout error inside clay must surface from frame_end, not vanish. + * + * The measure callback and clay's own error handler both report into a stash + * with no other way out. Text declared against a fontId nothing issued is the + * easiest such error to provoke, and the frame after the failed one must be + * clean -- one bad frame, not a wedged subsystem. + */ +akerr_ErrorContext *test_ui_layout_error_surfaces(void) +{ + PREPARE_ERROR(e); + + ATTEMPT { + TEST_EXPECT_OK(e, akgl_ui_init(TEST_TARGET_SIZE, TEST_TARGET_SIZE), + "init for the layout error test failed"); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "opening the doomed frame failed"); + CLAY_TEXT(CLAY_STRING("doomed"), CLAY_TEXT_CONFIG({ + .fontId = 7, + .textColor = { 255, 255, 255, 255 } + })); + TEST_EXPECT_STATUS(e, AKGL_ERR_UI, akgl_ui_frame_end(akgl_renderer), + "a layout with an unresolvable font closed cleanly"); + TEST_EXPECT_OK(e, akgl_ui_frame_begin(), "the frame after a failed one was refused"); + TEST_EXPECT_OK(e, akgl_ui_frame_end(akgl_renderer), + "closing the recovery frame failed"); + } CLEANUP { + IGNORE(akgl_ui_shutdown()); + } PROCESS(e) { + } FINISH(e, true); + SUCCEED_RETURN(e); +} + +int main(void) +{ + PREPARE_ERROR(errctx); + + SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy"); + SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software"); + + ATTEMPT { + CATCH(errctx, akgl_error_init()); + + // The headless tests run first, before SDL is up, because running + // without a renderer is part of what they assert. + CATCH(errctx, test_ui_validation()); + CATCH(errctx, test_ui_lifecycle()); + CATCH(errctx, test_ui_arena_exhaustion()); + + akgl_renderer = &akgl_default_renderer; + FAIL_ZERO_BREAK( + errctx, + SDL_Init(SDL_INIT_VIDEO), + AKGL_ERR_SDL, + "Couldn't initialize SDL: %s", + SDL_GetError()); + FAIL_ZERO_BREAK( + errctx, + TTF_Init(), + AKGL_ERR_SDL, + "Couldn't initialize the font engine: %s", + SDL_GetError()); + CATCH(errctx, akgl_registry_init_font()); + CATCH(errctx, akgl_text_loadfont(TEST_FONT_NAME, TEST_FONT_PATH, TEST_FONT_SIZE)); + FAIL_ZERO_BREAK( + errctx, + SDL_CreateWindowAndRenderer( + "net/aklabs/libakgl/test_ui", + TEST_TARGET_SIZE, + TEST_TARGET_SIZE, + 0, + &akgl_window, + &akgl_renderer->sdl_renderer), + AKGL_ERR_SDL, + "Couldn't create window/renderer: %s", + SDL_GetError()); + CATCH(errctx, akgl_render_2d_bind(akgl_renderer)); + + CATCH(errctx, test_ui_font_register()); + CATCH(errctx, test_ui_measure_text()); + CATCH(errctx, test_ui_frame_bracket()); + CATCH(errctx, test_ui_layout_pixels()); + CATCH(errctx, test_ui_layout_text()); + CATCH(errctx, test_ui_handle_event()); + CATCH(errctx, test_ui_dialog_widget()); + CATCH(errctx, test_ui_label_widget()); + CATCH(errctx, test_ui_menu_widget()); + CATCH(errctx, test_ui_menu_events()); + CATCH(errctx, test_ui_layout_error_surfaces()); + } CLEANUP { + IGNORE(akgl_text_unloadallfonts()); + TTF_Quit(); + SDL_Quit(); + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +}