Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs #3

Merged
andrew merged 15 commits from clay-ui into main 2026-08-02 16:14:50 -04:00
35 changed files with 4590 additions and 60 deletions

View File

@@ -14,7 +14,7 @@ jobs:
run: | run: |
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -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 \ libasound2-dev libfreetype-dev libharfbuzz-dev \
libpng-dev libtiff-dev libwebp-dev \ libpng-dev libtiff-dev libwebp-dev \
libudev-dev libx11-dev libxcursor-dev libxext-dev \ libudev-dev libx11-dev libxcursor-dev libxext-dev \
@@ -56,7 +56,7 @@ jobs:
fail_on_failure: 'true' fail_on_failure: 'true'
- name: Upload coverage reports - name: Upload coverage reports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: code-coverage name: code-coverage
path: build/coverage/ path: build/coverage/
@@ -123,7 +123,7 @@ jobs:
# the next baseline if somebody re-records PERFORMANCE.md. # the next baseline if somebody re-records PERFORMANCE.md.
- name: Upload benchmark tables - name: Upload benchmark tables
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: performance-baseline name: performance-baseline
path: perf-baseline.txt path: perf-baseline.txt
@@ -170,7 +170,7 @@ jobs:
run: scripts/memcheck.sh run: scripts/memcheck.sh
- name: Upload valgrind logs - name: Upload valgrind logs
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: valgrind-logs name: valgrind-logs
path: build/Testing/Temporary/MemoryChecker.*.log path: build/Testing/Temporary/MemoryChecker.*.log

16
.gitmodules vendored
View File

@@ -1,18 +1,18 @@
[submodule "deps/semver"] [submodule "deps/semver"]
path = deps/semver path = deps/semver
url = git@github.com:h2non/semver.c.git url = https://github.com/h2non/semver.c.git
[submodule "deps/SDL"] [submodule "deps/SDL"]
path = 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"] [submodule "deps/SDL_image"]
path = 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"] [submodule "deps/SDL_mixer"]
path = 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"] [submodule "deps/SDL_ttf"]
path = 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"] [submodule "deps/libsdlerror"]
path = deps/libakerror path = deps/libakerror
url = https://source.starfort.tech/andrew/libakerror.git url = https://source.starfort.tech/andrew/libakerror.git
@@ -21,10 +21,14 @@
url = https://source.starfort.tech/andrew/libakstdlib.git url = https://source.starfort.tech/andrew/libakstdlib.git
[submodule "deps/jansson"] [submodule "deps/jansson"]
path = deps/jansson path = deps/jansson
url = git@github.com:akheron/jansson.git url = https://github.com/akheron/jansson.git
[submodule "deps/libccd"] [submodule "deps/libccd"]
path = deps/libccd path = deps/libccd
url = https://github.com/danfis/libccd.git url = https://github.com/danfis/libccd.git
[submodule "deps/tg"] [submodule "deps/tg"]
path = deps/tg path = deps/tg
url = https://github.com/tidwall/tg.git url = https://github.com/tidwall/tg.git
[submodule "clay"]
path = deps/clay
url = https://github.com/nicbarker/clay.git
branch = v0.14

View File

@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
# The single source of truth for the library version. It drives the generated # 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 # include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
# Version field in akgl.pc. Bump it here and nowhere else. # 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` # Memory checking reuses the suites that already exist -- `ctest -T memcheck`
# runs every registered test under valgrind -- rather than adding programs of its # runs every registered test under valgrind -- rather than adding programs of its
@@ -217,6 +217,7 @@ set(AKGL_PUBLIC_HEADERS
text text
tilemap tilemap
types types
ui
util util
) )
@@ -317,6 +318,8 @@ add_library(akgl SHARED
src/sprite.c src/sprite.c
src/staticstring.c src/staticstring.c
src/tilemap.c src/tilemap.c
src/ui.c
src/ui_clay.c
src/util.c src/util.c
src/version.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_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h"
COMPILE_DEFINITIONS "CCD_STATIC_DEFINE") 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` # 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 # suite compiles each public header as the first include of a translation unit
# with only include/ available, so a public header that pulled in <ccd/ccd.h> # with only include/ available, so a public header that pulled in <ccd/ccd.h>
@@ -406,6 +429,7 @@ set(AKGL_TEST_SUITES
staticstring staticstring
text text
tilemap tilemap
ui
util util
version version
) )
@@ -516,6 +540,11 @@ set_tests_properties(
target_include_directories(akgl PUBLIC target_include_directories(akgl PUBLIC
include/ include/
deps/semver/ deps/semver/
# PUBLIC, unlike libccd's PRIVATE include above, because akgl/ui.h includes
# <clay.h> 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 # akgl/version.h is generated by configure_file, so it lives in the build tree
# rather than beside the headers it is included from. # rather than beside the headers it is included from.
${CMAKE_CURRENT_BINARY_DIR}/include/ ${CMAKE_CURRENT_BINARY_DIR}/include/
@@ -633,20 +662,26 @@ if(AKGL_VENDORED_DEPENDENCIES)
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror" "${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror"
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib" "${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") if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
set(AKGL_TEST_ENV_MOD "") set(AKGL_TEST_ENV_MOD "")
foreach(dir IN LISTS AKGL_TEST_LIBPATH) foreach(dir IN LISTS AKGL_TEST_LIBPATH)
list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
endforeach() endforeach()
set_tests_properties( set_property(
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}" PROPERTY ENVIRONMENT_MODIFICATION ${AKGL_TEST_ENV_MOD}
) )
else() else()
string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_tests_properties( set_property(
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES} TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" PROPERTY ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
) )
endif() endif()
endif() endif()
@@ -754,7 +789,12 @@ add_test(
set_tests_properties(docs_examples PROPERTIES set_tests_properties(docs_examples PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
TIMEOUT 900 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. # 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 set_tests_properties(docs_screenshots PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
TIMEOUT 900 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 # 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 --demo --frames 240
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png" --screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png"
--screenshot-frame 230 --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
$<TARGET_FILE:uidemo>
--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" COMMENT "Regenerating the tutorial figures in docs/images"
VERBATIM 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(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/")
install(TARGETS akgl DESTINATION "lib/") install(TARGETS akgl DESTINATION "lib/")
install(FILES "deps/semver/semver.h" DESTINATION "include/") install(FILES "deps/semver/semver.h" DESTINATION "include/")
# libccd is compiled into libakgl.so, and its BSD-3 licence requires the notice install(FILES "deps/clay/clay.h" DESTINATION "include/")
# to travel with the binary form. Nothing else in deps/ is linked in statically # libccd and clay are compiled into libakgl.so, and their licences (BSD-3 and
# -- SDL, jansson, libakerror and libakstdlib are all separate shared objects # zlib respectively) ask that the notice travel with the binary form. Nothing
# that ship their own -- so this is the only third-party notice we owe. # 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/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) foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/") install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
endforeach() endforeach()

21
TODO.md
View File

@@ -1273,6 +1273,15 @@ engines spend the same frame**.
cached") with the raw-SDL control row the perf rules require. Budget to cached") with the raw-SDL control row the perf rules require. Budget to
move: `tests/perf_render.c:391`. 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 3. **Non-raising sprite lookup — target 10, a design change.** Give
`akgl_character_sprite_get` (`src/character.c:84-94`) the companion that `akgl_character_sprite_get` (`src/character.c:84-94`) the companion that
returns `NULL` without raising, then convert the three sites that raise 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 `renderfunc`. This item is the half that remains: one uniform `scale`, with no way
to expand a single axis. 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 ## Found while writing the manual
Twenty-one chapters and two tutorial games were written against `src/` rather than Twenty-one chapters and two tutorial games were written against `src/` rather than

1
deps/clay vendored Submodule

Submodule deps/clay added at b25a31c1a1

View File

@@ -25,7 +25,7 @@ The house rules for *writing* code against the protocol — never a `*_RETURN` i
libakerror reserves statuses 0255 for the host's `errno` values and its own `AKERR_*` libakerror reserves statuses 0255 for the host's `errno` values and its own `AKERR_*`
codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**. 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 ```c excerpt=include/akgl/error.h
#define AKGL_ERR_OWNER "libakgl" #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_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_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_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 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)` 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. start catching that instead.
| Code | Value | Means | Raised by | What the caller does | | 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_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_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_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 `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"`, seven: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
`"Logic Interrupt"`, `"Collision Error"`. Those names are what a stack trace prints. `"Logic Interrupt"`, `"Collision Error"`, `"UI Error"`. Those names are what a stack trace
prints.
Two rows deserve more than a cell. 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`, `akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`,
`AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`. `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 ## Table 3 — the exit status trap
@@ -266,6 +269,6 @@ Four things in there are libakgl-specific and worth naming:
release. release.
- [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and - [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and
`AKERR_NULLPOINTER`. `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. - `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion.
- The generated Doxygen reference — every function's own `@throws` list. - The generated Doxygen reference — every function's own `@throws` list.

View File

@@ -344,4 +344,4 @@ all visible in `src/heap.c`.
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables. - [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 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.

View File

@@ -224,5 +224,5 @@ registry's.
- [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created, - [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. and where configuration has to be in place by.
- [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do. - [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_*`. `AKGL_MAX_*`.

View File

@@ -367,4 +367,4 @@ error-reporting path. See [Chapter 4](04-errors.md).
what `draw_world` does with the layers. what `draw_world` does with the layers.
- [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not - [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not
pace. 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.

View File

@@ -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 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 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 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 ## Limits

396
docs/22-ui.md Normal file
View File

@@ -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.

View File

@@ -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, Three reference tables that no single chapter owns: which function raises which status,
every compile-time bound, and every configuration property the library reads. 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_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_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_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` ### `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_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site |
| `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` | | `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` ### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h`
| Function | Raises | | Function | Raises |
@@ -196,8 +215,9 @@ What the statuses mean is [Chapter 4](04-errors.md).
## B. Compile-time limits ## B. Compile-time limits
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight 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 `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 [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 plain `#define`s with no `#ifndef` guard: changing one means editing the header and
rebuilding libakgl and everything linking it. 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 `AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape
needs a few dozen. 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 `<akgl/ui.h>` 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 ### Audio
```c excerpt=include/akgl/audio.h ```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 | | Constant | Value |
|---|---| |---|---|
| `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) | | `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) |
| `AKGL_ERR_COUNT` | 5 | | `AKGL_ERR_COUNT` | 7 |
| `AKGL_ERR_LIMIT` | 261**the one-past-the-end sentinel, not a status** | | `AKGL_ERR_LIMIT` | 263**the one-past-the-end sentinel, not a status** |
## C. Configuration properties ## C. Configuration properties

View File

@@ -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 | | **[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** | | **[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 | | **[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 **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. an empty directory and teaches the library as it needs each piece; chapter 21 assumes it.

BIN
docs/images/uidemo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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 # Each one is a complete, running program that the matching chapter quotes with
# `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot # `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 # 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 # them is not there yet would block the other. There is nothing clever about the
# guard; it exists so an incomplete tree still builds. # 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") if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt")
add_subdirectory(${_example}) add_subdirectory(${_example})
endif() endif()

View File

@@ -47,26 +47,31 @@ endif()
# it to suppress the vendored projects' registrations and lifts the suppression # it to suppress the vendored projects' registrations and lifts the suppression
# again long before examples/ is added. # again long before examples/ is added.
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo) add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
set_tests_properties(example_jrpg PROPERTIES set_tests_properties(example_jrpg PROPERTIES TIMEOUT 120)
TIMEOUT 120 # set_property, not set_tests_properties: the latter parses its PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" # 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 # LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they
# to go in the same property rather than a second one. Only needed when the # were vendored; an installed build resolves them normally.
# dependencies were vendored; an installed build resolves them normally.
if(AKGL_VENDORED_DEPENDENCIES) if(AKGL_VENDORED_DEPENDENCIES)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22") if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
set(JRPG_TEST_ENV_MOD "") set(JRPG_TEST_ENV_MOD "")
foreach(dir IN LISTS AKGL_TEST_LIBPATH) foreach(dir IN LISTS AKGL_TEST_LIBPATH)
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}") list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
endforeach() endforeach()
set_tests_properties(example_jrpg set_property(TEST example_jrpg PROPERTY ENVIRONMENT_MODIFICATION ${JRPG_TEST_ENV_MOD})
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
else() else()
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}") string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_tests_properties(example_jrpg PROPERTIES set_property(TEST example_jrpg APPEND PROPERTY ENVIRONMENT
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}" "LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}")
)
endif() endif()
endif() endif()

View File

@@ -41,7 +41,15 @@ endif()
# while this project is top-level, and by the time examples/ is added the # 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. # suppression has already been lifted. An embedded build gets the builtin.
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay) add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
set_tests_properties(example_sidescroller PROPERTIES set_tests_properties(example_sidescroller PROPERTIES TIMEOUT 120)
TIMEOUT 120 # set_property, not set_tests_properties: the latter parses its PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy" # 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"
) )

View File

@@ -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()

609
examples/uidemo/uidemo.c Normal file
View File

@@ -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 <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#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;
}

58
examples/uidemo/uidemo.h Normal file
View File

@@ -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 <SDL3/SDL.h>
/** @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_

View File

@@ -36,6 +36,25 @@
*/ */
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096 #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. * @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); 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_ #endif //_AKGL_DRAW_H_

View File

@@ -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_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_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_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 // 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 // subset or superset of an existing one is refused -- so this must stay one
// past the highest code above. // 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) #define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE)
/** /**

552
include/akgl/ui.h Normal file
View File

@@ -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 `<clay.h>` on purpose, the same way
* SDL types appear undisguised elsewhere in this library.
* - Call the widget helpers, which emit those blocks for you.
*
* The subsystem is optional the way collision is optional: always compiled in,
* inert until akgl_ui_init() runs, and costing nothing but static storage
* before that.
*
* @warning libakgl compiles clay into `libakgl.so` (the `CLAY_IMPLEMENTATION`
* translation unit is `src/ui_clay.c`) and exports its symbols,
* because the `CLAY()` macros in *your* translation units expand to
* calls into them. Do not define `CLAY_IMPLEMENTATION` yourself and
* do not link another copy of clay: two definitions of the same
* symbols and the loader picks one silently, which is the exact
* defect AGENTS.md records against an exported `renderer`.
*/
#ifndef _AKGL_UI_H_
#define _AKGL_UI_H_
#include <stddef.h>
#include <stdint.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <clay.h>
#include <akgl/renderer.h>
#include <akgl/types.h>
/**
* @brief Most elements one layout may declare.
*
* Passed to `Clay_SetMaxElementCount` before the arena is sized, so it is a
* compile-time ceiling in the same sense as the `AKGL_MAX_HEAP_*` family:
* define it before including this header to raise it, and see
* #AKGL_UI_ARENA_BYTES, which must grow with it.
*/
#ifndef AKGL_UI_MAX_ELEMENTS
#define AKGL_UI_MAX_ELEMENTS 1024
#endif
/**
* @brief Most whitespace-separated words clay's text measurement cache holds.
*
* Clay measures text one word at a time and caches the result; a cache miss
* costs one call back into SDL_ttf. This bounds the cache, not the text -- a
* layout with more distinct words than this still renders, it just measures
* some of them every frame and clay reports the overflow through the error
* handler.
*/
#ifndef AKGL_UI_MAX_MEASURE_WORDS
#define AKGL_UI_MAX_MEASURE_WORDS 4096
#endif
/**
* @brief Bytes of static storage clay lays its internal structures out in.
*
* The arena is a fixed array in `src/ui.c` -- libakgl does not call `malloc`
* at runtime, and clay never allocates behind the arena's back. What clay
* needs is a function of #AKGL_UI_MAX_ELEMENTS and #AKGL_UI_MAX_MEASURE_WORDS
* and is only computable at runtime, so akgl_ui_init() checks
* `Clay_MinMemorySize()` against this and refuses with both numbers in the
* message when it does not fit. A too-small arena is a loud failure at
* startup, never a corruption at frame forty thousand.
*/
#ifndef AKGL_UI_ARENA_BYTES
#define AKGL_UI_ARENA_BYTES (1024 * 1024)
#endif
/**
* @brief Most fonts akgl_ui_font_register() will map to clay fontIds.
*
* clay identifies a font as a `uint16_t`; libakgl identifies one as a name in
* #AKGL_REGISTRY_FONT. The table joining them is this many fixed slots. A
* size is baked into each registered font handle, so a game using one face at
* three sizes is using three slots.
*/
#ifndef AKGL_UI_MAX_FONTS
#define AKGL_UI_MAX_FONTS 8
#endif
/**
* @brief Bytes each fontId table slot holds for its registry name, terminator included.
*/
#ifndef AKGL_UI_FONT_NAME_LENGTH
#define AKGL_UI_FONT_NAME_LENGTH 64
#endif
/**
* @brief Bytes of scratch one text render command may occupy, terminator included.
*
* clay hands text to a renderer as a length-and-pointer slice, one command
* per wrapped line; akgl_text_rendertextat() takes a C string, so each line
* is copied through a bounded buffer on the way. A line longer than this
* fails the frame loudly rather than drawing a truncation that reads as the
* author's text.
*/
#ifndef AKGL_UI_MAX_TEXT_BYTES
#define AKGL_UI_MAX_TEXT_BYTES 1024
#endif
/**
* @brief 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_

View File

@@ -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 # 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 # trip an assertion still produced a log worth reading, and the log is what
# decides the exit status below. # 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 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=$? AKGL_CTEST_STATUS=$?
set -e set -e

View File

@@ -619,3 +619,229 @@ akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface
} FINISH(errctx, true); } FINISH(errctx, true);
SUCCEED_RETURN(errctx); 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);
}

View File

@@ -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_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_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_COLLISION, "Collision Error"));
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_UI, "UI Error"));
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }

1121
src/ui.c Normal file

File diff suppressed because it is too large Load Diff

21
src/ui_clay.c Normal file
View File

@@ -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 <clay.h>
* 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 <clay.h> 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 <clay.h>

View File

@@ -284,6 +284,10 @@ akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void)
int main(void) int main(void)
{ {
PREPARE_ERROR(errctx); 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 { ATTEMPT {
CATCH(errctx, akgl_error_init()); CATCH(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer; akgl_renderer = &akgl_default_renderer;

View File

@@ -50,6 +50,7 @@
#include <akgl/text.h> #include <akgl/text.h>
#include <akgl/tilemap.h> #include <akgl/tilemap.h>
#include <akgl/types.h> #include <akgl/types.h>
#include <akgl/ui.h>
#include <akgl/util.h> #include <akgl/util.h>
/** /**

View File

@@ -628,6 +628,183 @@ akerr_ErrorContext *test_draw_background(void)
SUCCEED_RETURN(errctx); 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) int main(void)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -668,6 +845,9 @@ int main(void)
CATCH(errctx, test_draw_preserves_render_draw_color()); CATCH(errctx, test_draw_preserves_render_draw_color());
CATCH(errctx, test_draw_backend_without_a_renderer()); CATCH(errctx, test_draw_backend_without_a_renderer());
CATCH(errctx, test_draw_background()); CATCH(errctx, test_draw_background());
CATCH(errctx, test_draw_filled_rounded_rect());
CATCH(errctx, test_draw_arc());
CATCH(errctx, test_draw_set_clip());
} CLEANUP { } CLEANUP {
SDL_Quit(); SDL_Quit();
} PROCESS(errctx) { } PROCESS(errctx) {

View File

@@ -33,7 +33,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void)
{ AKGL_ERR_HEAP, "Heap Error" }, { AKGL_ERR_HEAP, "Heap Error" },
{ AKGL_ERR_BEHAVIOR, "Behavior Error" }, { AKGL_ERR_BEHAVIOR, "Behavior Error" },
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" }, { AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" },
{ AKGL_ERR_COLLISION, "Collision Error" } { AKGL_ERR_COLLISION, "Collision Error" },
{ AKGL_ERR_UI, "UI Error" }
}; };
bool named = true; bool named = true;
int i = 0; int i = 0;

View File

@@ -240,6 +240,10 @@ int main(void)
{ {
PREPARE_ERROR(errctx); 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 { ATTEMPT {
CATCH(errctx, akgl_error_init()); CATCH(errctx, akgl_error_init());
akgl_renderer = &akgl_default_renderer; akgl_renderer = &akgl_default_renderer;

View File

@@ -740,6 +740,10 @@ int main(void)
{ {
PREPARE_ERROR(errctx); 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 { ATTEMPT {
CATCH(errctx, akgl_error_init()); CATCH(errctx, akgl_error_init());
akgl_gamemap = &akgl_default_gamemap; akgl_gamemap = &akgl_default_gamemap;

1033
tests/ui.c Normal file

File diff suppressed because it is too large Load Diff