Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs (#3)
Merges the clay-ui branch: the akgl_ui subsystem on clay v0.14, the uidemo example, chapter 22, the weaker-model tutorial-test fixes, and the CI repairs that produced this repository's first fully green run.
This commit was merged in pull request #3.
This commit is contained in:
@@ -14,7 +14,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y \
|
||||
cmake doxygen gcc gcovr pkg-config \
|
||||
cmake doxygen gcc gcovr graphviz pkg-config \
|
||||
libasound2-dev libfreetype-dev libharfbuzz-dev \
|
||||
libpng-dev libtiff-dev libwebp-dev \
|
||||
libudev-dev libx11-dev libxcursor-dev libxext-dev \
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
fail_on_failure: 'true'
|
||||
- name: Upload coverage reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: code-coverage
|
||||
path: build/coverage/
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
# the next baseline if somebody re-records PERFORMANCE.md.
|
||||
- name: Upload benchmark tables
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: performance-baseline
|
||||
path: perf-baseline.txt
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
run: scripts/memcheck.sh
|
||||
- name: Upload valgrind logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: valgrind-logs
|
||||
path: build/Testing/Temporary/MemoryChecker.*.log
|
||||
|
||||
16
.gitmodules
vendored
16
.gitmodules
vendored
@@ -1,18 +1,18 @@
|
||||
[submodule "deps/semver"]
|
||||
path = deps/semver
|
||||
url = git@github.com:h2non/semver.c.git
|
||||
url = https://github.com/h2non/semver.c.git
|
||||
[submodule "deps/SDL"]
|
||||
path = deps/SDL
|
||||
url = git@github.com:libsdl-org/SDL.git
|
||||
url = https://github.com/libsdl-org/SDL.git
|
||||
[submodule "deps/SDL_image"]
|
||||
path = deps/SDL_image
|
||||
url = git@github.com:libsdl-org/SDL_image.git
|
||||
url = https://github.com/libsdl-org/SDL_image.git
|
||||
[submodule "deps/SDL_mixer"]
|
||||
path = deps/SDL_mixer
|
||||
url = git@github.com:libsdl-org/SDL_mixer.git
|
||||
url = https://github.com/libsdl-org/SDL_mixer.git
|
||||
[submodule "deps/SDL_ttf"]
|
||||
path = deps/SDL_ttf
|
||||
url = git@github.com:libsdl-org/SDL_ttf.git
|
||||
url = https://github.com/libsdl-org/SDL_ttf.git
|
||||
[submodule "deps/libsdlerror"]
|
||||
path = deps/libakerror
|
||||
url = https://source.starfort.tech/andrew/libakerror.git
|
||||
@@ -21,10 +21,14 @@
|
||||
url = https://source.starfort.tech/andrew/libakstdlib.git
|
||||
[submodule "deps/jansson"]
|
||||
path = deps/jansson
|
||||
url = git@github.com:akheron/jansson.git
|
||||
url = https://github.com/akheron/jansson.git
|
||||
[submodule "deps/libccd"]
|
||||
path = deps/libccd
|
||||
url = https://github.com/danfis/libccd.git
|
||||
[submodule "deps/tg"]
|
||||
path = deps/tg
|
||||
url = https://github.com/tidwall/tg.git
|
||||
[submodule "clay"]
|
||||
path = deps/clay
|
||||
url = https://github.com/nicbarker/clay.git
|
||||
branch = v0.14
|
||||
|
||||
@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
|
||||
# The single source of truth for the library version. It drives the generated
|
||||
# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
|
||||
# Version field in akgl.pc. Bump it here and nowhere else.
|
||||
project(akgl VERSION 0.8.0 LANGUAGES C)
|
||||
project(akgl VERSION 0.9.0 LANGUAGES C)
|
||||
|
||||
# Memory checking reuses the suites that already exist -- `ctest -T memcheck`
|
||||
# runs every registered test under valgrind -- rather than adding programs of its
|
||||
@@ -217,6 +217,7 @@ set(AKGL_PUBLIC_HEADERS
|
||||
text
|
||||
tilemap
|
||||
types
|
||||
ui
|
||||
util
|
||||
)
|
||||
|
||||
@@ -317,6 +318,8 @@ add_library(akgl SHARED
|
||||
src/sprite.c
|
||||
src/staticstring.c
|
||||
src/tilemap.c
|
||||
src/ui.c
|
||||
src/ui_clay.c
|
||||
src/util.c
|
||||
src/version.c
|
||||
)
|
||||
@@ -368,6 +371,26 @@ set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES
|
||||
COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h"
|
||||
COMPILE_DEFINITIONS "CCD_STATIC_DEFINE")
|
||||
|
||||
# clay supplies the UI layout engine. Its sources are listed rather than
|
||||
# add_subdirectory()'d, on the semver/libccd precedent, because
|
||||
# deps/clay/CMakeLists.txt is unusable as a subproject and none of it is ours
|
||||
# to fix in a submodule:
|
||||
#
|
||||
# - it declares no library target at all -- clay is a single header, and the
|
||||
# add_library(INTERFACE) lines at the bottom are commented out upstream.
|
||||
# - option(CLAY_INCLUDE_ALL_EXAMPLES ... ON) add_subdirectory()s every
|
||||
# example by default, dragging raylib, cairo and sokol into our configure.
|
||||
# - cmake_minimum_required(VERSION 3.27) against this project's 3.10.
|
||||
#
|
||||
# src/ui_clay.c is the one translation unit that defines CLAY_IMPLEMENTATION;
|
||||
# it is 99% vendored code, so it gets -w on the same terms as semver and
|
||||
# libccd. Deliberately NOT -fvisibility=hidden, unlike libccd: the CLAY()
|
||||
# macros in a consuming game expand to Clay__OpenElement() and friends, which
|
||||
# must resolve against libakgl.so, so clay's symbols are part of the ABI on
|
||||
# purpose. The matching obligation -- a consumer must not link a second clay --
|
||||
# is documented in akgl/ui.h.
|
||||
set_source_files_properties(src/ui_clay.c PROPERTIES COMPILE_OPTIONS "-w")
|
||||
|
||||
# PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers`
|
||||
# suite compiles each public header as the first include of a translation unit
|
||||
# with only include/ available, so a public header that pulled in <ccd/ccd.h>
|
||||
@@ -406,6 +429,7 @@ set(AKGL_TEST_SUITES
|
||||
staticstring
|
||||
text
|
||||
tilemap
|
||||
ui
|
||||
util
|
||||
version
|
||||
)
|
||||
@@ -516,6 +540,11 @@ set_tests_properties(
|
||||
target_include_directories(akgl PUBLIC
|
||||
include/
|
||||
deps/semver/
|
||||
# PUBLIC, unlike libccd's PRIVATE include above, because akgl/ui.h includes
|
||||
# <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
|
||||
# rather than beside the headers it is included from.
|
||||
${CMAKE_CURRENT_BINARY_DIR}/include/
|
||||
@@ -633,20 +662,26 @@ if(AKGL_VENDORED_DEPENDENCIES)
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakerror"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/deps/libakstdlib"
|
||||
)
|
||||
# set_property rather than set_tests_properties for the list-valued
|
||||
# property: set_tests_properties parses its PROPERTIES arguments as
|
||||
# name/value pairs, so a semicolon-separated value is split and every
|
||||
# element after the first is consumed as a bogus property name -- which
|
||||
# silently reduced this prepend list to its first directory for as long as
|
||||
# it existed. RPATH covered for it locally; a runner found it.
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||
set(AKGL_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND AKGL_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTIES ENVIRONMENT_MODIFICATION "${AKGL_TEST_ENV_MOD}"
|
||||
set_property(
|
||||
TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTY ENVIRONMENT_MODIFICATION ${AKGL_TEST_ENV_MOD}
|
||||
)
|
||||
else()
|
||||
string(REPLACE ";" ":" AKGL_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_tests_properties(
|
||||
${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTIES ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
set_property(
|
||||
TEST ${AKGL_TEST_SUITES} ${AKGL_PERF_SUITES}
|
||||
PROPERTY ENVIRONMENT "LD_LIBRARY_PATH=${AKGL_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
@@ -754,7 +789,12 @@ add_test(
|
||||
set_tests_properties(docs_examples PROPERTIES
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
TIMEOUT 900
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
# set_property for the same pair-splitting reason as the suites above.
|
||||
set_property(TEST docs_examples PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
|
||||
# Every figure in docs/ re-rendered and byte-compared against the tracked copy.
|
||||
@@ -779,7 +819,11 @@ add_test(
|
||||
set_tests_properties(docs_screenshots PROPERTIES
|
||||
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
TIMEOUT 900
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
set_property(TEST docs_screenshots PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
)
|
||||
|
||||
# Regenerating the figures is a deliberate act, never part of a build: the PNGs
|
||||
@@ -832,7 +876,15 @@ if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt")
|
||||
--demo --frames 240
|
||||
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png"
|
||||
--screenshot-frame 230
|
||||
DEPENDS sidescroller jrpg
|
||||
# Frame 100 of the UI demo's scripted tour is the play screen with the
|
||||
# dialog up and the score mid-count -- all three widgets in one frame.
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
|
||||
$<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"
|
||||
VERBATIM
|
||||
)
|
||||
@@ -882,11 +934,14 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/")
|
||||
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/")
|
||||
install(TARGETS akgl DESTINATION "lib/")
|
||||
install(FILES "deps/semver/semver.h" DESTINATION "include/")
|
||||
# libccd is compiled into libakgl.so, and its BSD-3 licence requires the notice
|
||||
# to travel with the binary form. Nothing else in deps/ is linked in statically
|
||||
# -- SDL, jansson, libakerror and libakstdlib are all separate shared objects
|
||||
# that ship their own -- so this is the only third-party notice we owe.
|
||||
install(FILES "deps/clay/clay.h" DESTINATION "include/")
|
||||
# libccd and clay are compiled into libakgl.so, and their licences (BSD-3 and
|
||||
# zlib respectively) ask that the notice travel with the binary form. Nothing
|
||||
# else in deps/ is linked in statically -- SDL, jansson, libakerror and
|
||||
# libakstdlib are all separate shared objects that ship their own -- so these
|
||||
# are the only third-party notices we owe.
|
||||
install(FILES "deps/libccd/BSD-LICENSE" DESTINATION "share/doc/akgl/" RENAME "BSD-LICENSE.libccd")
|
||||
install(FILES "deps/clay/LICENSE.md" DESTINATION "share/doc/akgl/" RENAME "LICENSE.clay")
|
||||
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
|
||||
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
|
||||
endforeach()
|
||||
|
||||
21
TODO.md
21
TODO.md
@@ -1273,6 +1273,15 @@ engines spend the same frame**.
|
||||
cached") with the raw-SDL control row the perf rules require. Budget to
|
||||
move: `tests/perf_render.c:391`.
|
||||
|
||||
**The UI subsystem is this cache's second consumer, and the bigger one.**
|
||||
`akgl_ui_execute_commands` (`src/ui.c`) draws every TEXT render command
|
||||
through `akgl_text_rendertextat`, one wrapped line per command per frame —
|
||||
a menu of five rows re-rasterizes five lines at 60 Hz whether or not any
|
||||
of them changed, where the original HUD case was one line changing once a
|
||||
second. Abstract-on-the-second-consumer says this item's time has come;
|
||||
the key already fits, since clay hands the executor stable text bytes
|
||||
between frames.
|
||||
|
||||
3. **Non-raising sprite lookup — target 10, a design change.** Give
|
||||
`akgl_character_sprite_get` (`src/character.c:84-94`) the companion that
|
||||
returns `NULL` without raising, then convert the three sites that raise
|
||||
@@ -2196,6 +2205,18 @@ a live defect -- but nothing rejects it, and `max_timestep` is caller-settable.
|
||||
`renderfunc`. This item is the half that remains: one uniform `scale`, with no way
|
||||
to expand a single axis.
|
||||
|
||||
3. **`akgl_ControlMap.mouseid` and `.penid` are still dead fields, deliberately.**
|
||||
The UI subsystem (0.9.0) brought the mouse into the library, and it would have
|
||||
been natural to wire these up on the way -- it did not, on purpose. Control maps
|
||||
translate device events into *actor handler calls*, and the UI wants absolute
|
||||
pointer state per event; those are different consumers, and building
|
||||
gameplay-mouse machinery into `src/controller.c` with no game asking for it is
|
||||
the abstract-before-the-second-consumer mistake. The mouse path lives in
|
||||
`src/ui.c` (`akgl_ui_handle_event`); when a game wants mouse-driven actors,
|
||||
*that* is the consumer these fields were declared for, and the work lands in
|
||||
`controller.c` then. Same for `.axis`, `.axis_range_min` and `.axis_range_max`,
|
||||
which `akgl_controller_handle_event` has never consulted.
|
||||
|
||||
## Found while writing the manual
|
||||
|
||||
Twenty-one chapters and two tutorial games were written against `src/` rather than
|
||||
|
||||
1
deps/clay
vendored
Submodule
1
deps/clay
vendored
Submodule
Submodule deps/clay added at b25a31c1a1
@@ -25,7 +25,7 @@ The house rules for *writing* code against the protocol — never a `*_RETURN` i
|
||||
|
||||
libakerror reserves statuses 0–255 for the host's `errno` values and its own `AKERR_*`
|
||||
codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**.
|
||||
libakgl claims a band of six starting there, under the owner string `"libakgl"`:
|
||||
libakgl claims a band of seven starting there, under the owner string `"libakgl"`:
|
||||
|
||||
```c excerpt=include/akgl/error.h
|
||||
#define AKGL_ERR_OWNER "libakgl"
|
||||
@@ -37,12 +37,13 @@ libakgl claims a band of six starting there, under the owner string `"libakgl"`:
|
||||
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
|
||||
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
|
||||
#define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */
|
||||
#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */
|
||||
```
|
||||
|
||||
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 6`, one past the last
|
||||
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 7`, one past the last
|
||||
one, and exists only so `AKGL_ERR_COUNT` can be computed from it. Nothing in `src/` raises
|
||||
it and `akgl_error_init` does not name it. Do not write a `HANDLE(e, AKGL_ERR_LIMIT)`
|
||||
arm — it would catch nothing, and if a seventh real code is ever added it would silently
|
||||
arm — it would catch nothing, and if an eighth real code is ever added it would silently
|
||||
start catching that instead.
|
||||
|
||||
| Code | Value | Means | Raised by | What the caller does |
|
||||
@@ -53,10 +54,12 @@ start catching that instead.
|
||||
| `AKGL_ERR_BEHAVIOR` | 259 | A component did not behave the way its contract requires | **Nothing in the library.** Only `tests/` raises it, through `testutil.h` | Available to you for the same purpose: asserting a contract in your own code |
|
||||
| `AKGL_ERR_LOGICINTERRUPT` | 260 | **Not a failure — a control signal.** "Skip the rest of this step for this actor" | your own `movementlogicfunc` | Raise it deliberately. See the note below |
|
||||
| `AKGL_ERR_COLLISION` | 261 | A collision query could not be answered | **One cause only**: `akgl_collision_test`, when the ccd arena is exhausted. The message carries the high-water mark | Raise `AKGL_CCD_ARENA_BYTES` to the reported figure. See [Chapter 15](15-collision.md) |
|
||||
| `AKGL_ERR_UI` | 262 | The UI subsystem refused, or clay reported a layout error | `akgl_ui_*` — lifecycle misuse (init twice, a widget outside the frame bracket), an arena or table at its ceiling, and layout errors surfacing from `akgl_ui_frame_end` | The message says which; the ceilings are in [Chapter 23](23-appendix-limits.md). See [Chapter 22](22-ui.md) |
|
||||
|
||||
`akgl_error_init` reserves the band all-or-nothing and registers a name for each of the
|
||||
six: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
|
||||
`"Logic Interrupt"`, `"Collision Error"`. Those names are what a stack trace prints.
|
||||
seven: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
|
||||
`"Logic Interrupt"`, `"Collision Error"`, `"UI Error"`. Those names are what a stack trace
|
||||
prints.
|
||||
|
||||
Two rows deserve more than a cell.
|
||||
|
||||
@@ -99,7 +102,7 @@ Statuses raised by the libraries underneath also reach you unchanged. `aksl_fope
|
||||
`akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`,
|
||||
`AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`.
|
||||
|
||||
[Chapter 22](22-appendix-limits.md) has the per-function cross-reference.
|
||||
[Chapter 23](23-appendix-limits.md) has the per-function cross-reference.
|
||||
|
||||
## Table 3 — the exit status trap
|
||||
|
||||
@@ -266,6 +269,6 @@ Four things in there are libakgl-specific and worth naming:
|
||||
release.
|
||||
- [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and
|
||||
`AKERR_NULLPOINTER`.
|
||||
- [Chapter 22](22-appendix-limits.md) — which function raises what.
|
||||
- [Chapter 23](23-appendix-limits.md) — which function raises what.
|
||||
- `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion.
|
||||
- The generated Doxygen reference — every function's own `@throws` list.
|
||||
|
||||
@@ -344,4 +344,4 @@ all visible in `src/heap.c`.
|
||||
|
||||
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
|
||||
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
|
||||
- [Chapter 22](22-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
||||
- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
||||
|
||||
@@ -224,5 +224,5 @@ registry's.
|
||||
- [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created,
|
||||
and where configuration has to be in place by.
|
||||
- [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do.
|
||||
- [Chapter 22](22-appendix-limits.md) — the configuration table again, alongside every
|
||||
- [Chapter 23](23-appendix-limits.md) — the configuration table again, alongside every
|
||||
`AKGL_MAX_*`.
|
||||
|
||||
@@ -367,4 +367,4 @@ error-reporting path. See [Chapter 4](04-errors.md).
|
||||
what `draw_world` does with the layers.
|
||||
- [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not
|
||||
pace.
|
||||
- [Chapter 22](22-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
||||
- [Chapter 23](23-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
||||
|
||||
@@ -37,7 +37,7 @@ why the library's own map, `akgl_default_gamemap`, is a file-scope object in
|
||||
The bounds are fixed at compile time, so the size is fixed too. Every field is present
|
||||
whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same
|
||||
25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and
|
||||
changes the ABI — see [Chapter 22](22-appendix-limits.md).
|
||||
changes the ABI — see [Chapter 23](23-appendix-limits.md).
|
||||
|
||||
## Limits
|
||||
|
||||
|
||||
396
docs/22-ui.md
Normal file
396
docs/22-ui.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# 22. User interfaces
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# 22. Appendix: limits, statuses and properties
|
||||
# 23. Appendix: limits, statuses and properties
|
||||
|
||||
Three reference tables that no single chapter owns: which function raises which status,
|
||||
every compile-time bound, and every configuration property the library reads.
|
||||
@@ -148,6 +148,9 @@ What the statuses mean is [Chapter 4](04-errors.md).
|
||||
| `akgl_draw_circle` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_flood_fill` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, `AKERR_OUTOFBOUNDS` past `AKGL_DRAW_MAX_FLOOD_SPANS` |
|
||||
| `akgl_draw_copy_region`, `_paste_region` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_filled_rounded_rect` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_arc` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_set_clip` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` — a `NULL` rectangle is legal here and clears the clip |
|
||||
|
||||
### `tilemap.h`
|
||||
|
||||
@@ -176,6 +179,22 @@ What the statuses mean is [Chapter 4](04-errors.md).
|
||||
| `akgl_controller_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site |
|
||||
| `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `ui.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_ui_init` | `AKERR_OUTOFBOUNDS`, `AKGL_ERR_UI` — already initialized, or an arena clay's structures do not fit; the message carries both byte counts |
|
||||
| `akgl_ui_shutdown` | *nothing — idempotent by design* |
|
||||
| `akgl_ui_resize` | `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_ui_font_register` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` past `AKGL_UI_FONT_NAME_LENGTH` |
|
||||
| `akgl_ui_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` from a resize event |
|
||||
| `akgl_ui_frame_begin` | `AKGL_ERR_UI` — uninitialized, or a frame already open |
|
||||
| `akgl_ui_frame_end` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` — no frame open, clay layout errors, an over-long text run, an unresolvable fontId — plus whatever the draw primitives raise |
|
||||
| `akgl_ui_dialog`, `_label`, `_menu` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` outside the frame bracket; `_label` and `_menu` add `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_ui_menu_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_ui_execute_commands` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, plus whatever the draw primitives raise |
|
||||
| `akgl_ui_measure_text` | *cannot report — clay's callback signature; failures stash and surface from `akgl_ui_frame_end`* |
|
||||
|
||||
### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h`
|
||||
|
||||
| Function | Raises |
|
||||
@@ -196,8 +215,9 @@ What the statuses mean is [Chapter 4](04-errors.md).
|
||||
|
||||
## B. Compile-time limits
|
||||
|
||||
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight
|
||||
and `AKGL_CCD_ARENA_BYTES` are overridable**, and even those have to be overridden for the whole build — see
|
||||
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` eight,
|
||||
`AKGL_CCD_ARENA_BYTES` and the `AKGL_UI_*` family are overridable**, and even those have to
|
||||
be overridden for the whole build — see
|
||||
[Chapter 5](05-the-heap.md), "The ceilings are a compile-time ABI constraint". The rest are
|
||||
plain `#define`s with no `#ifndef` guard: changing one means editing the header and
|
||||
rebuilding libakgl and everything linking it.
|
||||
@@ -347,6 +367,31 @@ keeps instead of recursing per pixel. A region needing more pending runs at once
|
||||
`AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape
|
||||
needs a few dozen.
|
||||
|
||||
`AKGL_DRAW_ROUNDED_RECT_SEGMENTS` is 16 — triangles per corner of
|
||||
`akgl_draw_filled_rounded_rect`. `AKGL_DRAW_ARC_MAX_POINTS` is 64 — the vertex ceiling one
|
||||
`akgl_draw_arc` spends, two per step along the curve; the step count scales with the swept
|
||||
angle under it. Both size fixed arrays, so neither is a per-call cost and neither is
|
||||
overridable.
|
||||
|
||||
### The UI
|
||||
|
||||
Every `AKGL_UI_*` limit is guarded by `#ifndef`, so a consumer may override any of them the
|
||||
way the pool ceilings are overridden — for the whole build, before `<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
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
@@ -383,8 +428,8 @@ budget and was wrong by a factor of a thousand, blocking for roughly sixteen min
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) |
|
||||
| `AKGL_ERR_COUNT` | 5 |
|
||||
| `AKGL_ERR_LIMIT` | 261 — **the one-past-the-end sentinel, not a status** |
|
||||
| `AKGL_ERR_COUNT` | 7 |
|
||||
| `AKGL_ERR_LIMIT` | 263 — **the one-past-the-end sentinel, not a status** |
|
||||
|
||||
## C. Configuration properties
|
||||
|
||||
@@ -34,7 +34,8 @@ per-function reference, build the Doxygen output with `doxygen Doxyfile`.
|
||||
| **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings |
|
||||
| **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Thirteen steps from an empty directory to a game with gravity, a jump, coins and hazards. **Start here** |
|
||||
| **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | Thirteen more, for four-way movement, NPCs, a text box, a follower, and freezing the world |
|
||||
| **[22. Appendix](22-appendix-limits.md)** | Every limit, every status, every configuration property |
|
||||
| **[22. User interfaces](22-ui.md)** | Menus, HUDs and dialogs on the clay layout engine: the widgets, the frame bracket, and writing `CLAY()` yourself |
|
||||
| **[23. Appendix](23-appendix-limits.md)** | Every limit, every status, every configuration property |
|
||||
|
||||
**If you are new, read chapter 20 first and read it in order.** It builds a working game from
|
||||
an empty directory and teaches the library as it needs each piece; chapter 21 assumes it.
|
||||
|
||||
BIN
docs/images/uidemo.png
Normal file
BIN
docs/images/uidemo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,4 +1,4 @@
|
||||
# The two tutorial games.
|
||||
# The tutorial games and demos.
|
||||
#
|
||||
# Each one is a complete, running program that the matching chapter quotes with
|
||||
# `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot
|
||||
@@ -9,7 +9,7 @@
|
||||
# passes and land at different times, and a configure that fails because one of
|
||||
# them is not there yet would block the other. There is nothing clever about the
|
||||
# guard; it exists so an incomplete tree still builds.
|
||||
foreach(_example sidescroller jrpg)
|
||||
foreach(_example sidescroller jrpg uidemo)
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt")
|
||||
add_subdirectory(${_example})
|
||||
endif()
|
||||
|
||||
@@ -47,26 +47,31 @@ endif()
|
||||
# it to suppress the vendored projects' registrations and lifts the suppression
|
||||
# again long before examples/ is added.
|
||||
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
|
||||
set_tests_properties(example_jrpg PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||
set_tests_properties(example_jrpg PROPERTIES TIMEOUT 120)
|
||||
# set_property, not set_tests_properties: the latter parses its PROPERTIES
|
||||
# arguments as name/value *pairs*, so a semicolon-separated value is split and
|
||||
# every element after the first is consumed as a bogus property name. The
|
||||
# audio and render entries of this list were silently lost that way --
|
||||
# invisible on any machine whose real audio device works, and found the first
|
||||
# time CI ran on a runner without one.
|
||||
set_property(TEST example_jrpg PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
|
||||
# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has
|
||||
# to go in the same property rather than a second one. Only needed when the
|
||||
# dependencies were vendored; an installed build resolves them normally.
|
||||
# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they
|
||||
# were vendored; an installed build resolves them normally.
|
||||
if(AKGL_VENDORED_DEPENDENCIES)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
|
||||
set(JRPG_TEST_ENV_MOD "")
|
||||
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
|
||||
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
|
||||
endforeach()
|
||||
set_tests_properties(example_jrpg
|
||||
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
|
||||
set_property(TEST example_jrpg PROPERTY ENVIRONMENT_MODIFICATION ${JRPG_TEST_ENV_MOD})
|
||||
else()
|
||||
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
|
||||
set_tests_properties(example_jrpg PROPERTIES
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
|
||||
)
|
||||
set_property(TEST example_jrpg APPEND PROPERTY ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@@ -41,7 +41,15 @@ endif()
|
||||
# while this project is top-level, and by the time examples/ is added the
|
||||
# suppression has already been lifted. An embedded build gets the builtin.
|
||||
add_test(NAME example_sidescroller COMMAND sidescroller --frames 240 --autoplay)
|
||||
set_tests_properties(example_sidescroller PROPERTIES
|
||||
TIMEOUT 120
|
||||
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
|
||||
set_tests_properties(example_sidescroller PROPERTIES TIMEOUT 120)
|
||||
# set_property, not set_tests_properties: the latter parses its PROPERTIES
|
||||
# arguments as name/value *pairs*, so a semicolon-separated value is split and
|
||||
# every element after the first is consumed as a bogus property name. The
|
||||
# audio and render entries of this very list were silently lost that way for
|
||||
# as long as the list existed -- invisible on any machine whose real audio
|
||||
# device works, and found the first time CI ran on a runner without one.
|
||||
set_property(TEST example_sidescroller PROPERTY ENVIRONMENT
|
||||
"SDL_VIDEODRIVER=dummy"
|
||||
"SDL_RENDER_DRIVER=software"
|
||||
"SDL_AUDIODRIVER=dummy"
|
||||
)
|
||||
|
||||
62
examples/uidemo/CMakeLists.txt
Normal file
62
examples/uidemo/CMakeLists.txt
Normal 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
609
examples/uidemo/uidemo.c
Normal 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
58
examples/uidemo/uidemo.h
Normal 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_
|
||||
@@ -36,6 +36,25 @@
|
||||
*/
|
||||
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
|
||||
|
||||
/**
|
||||
* @brief Triangles each corner of akgl_draw_filled_rounded_rect() is built from.
|
||||
*
|
||||
* A quarter circle approximated by this many segments is visually round at any
|
||||
* radius a UI panel plausibly uses; the vertex arrays are fixed at this size,
|
||||
* so it is a compile-time cost, not a per-call one.
|
||||
*/
|
||||
#define AKGL_DRAW_ROUNDED_RECT_SEGMENTS 16
|
||||
|
||||
/**
|
||||
* @brief Most vertices akgl_draw_arc() will spend on one arc.
|
||||
*
|
||||
* An arc is a triangle strip between its inner and outer edge, two vertices
|
||||
* per step, so half of these are positions along the curve. The step count
|
||||
* scales with the swept angle and is clamped to fit this array -- a full
|
||||
* circle gets all of them, a sliver gets a few -- rather than allocating.
|
||||
*/
|
||||
#define AKGL_DRAW_ARC_MAX_POINTS 64
|
||||
|
||||
/**
|
||||
* @brief Paint an 8x8 grey checkerboard over a region, the way an image editor shows transparency.
|
||||
*
|
||||
@@ -242,4 +261,86 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_copy_region(akgl_RenderBackend *sel
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface *src, float32_t x, float32_t y);
|
||||
|
||||
/**
|
||||
* @brief Fill a rectangle whose corners are rounded off with quarter circles.
|
||||
*
|
||||
* The body is three axis-aligned fills and the corners are triangle fans
|
||||
* through `SDL_RenderGeometry` -- #AKGL_DRAW_ROUNDED_RECT_SEGMENTS triangles
|
||||
* each -- so nothing here is anti-aliased, matching every other primitive in
|
||||
* this header.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param rect The rectangle, in render-target pixels. Required. A zero or
|
||||
* negative width or height fills nothing and is not reported.
|
||||
* @param radius Corner radius in pixels. Zero or negative falls through to
|
||||
* akgl_draw_filled_rect() -- a square corner is not an error.
|
||||
* Larger than half the shorter side is clamped to it, which at
|
||||
* the limit turns a square into a circle rather than folding the
|
||||
* corners over each other.
|
||||
* @param color Colour to fill with, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self, `self->sdl_renderer`, or @p rect is
|
||||
* `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the draw colour cannot be read or set, or if a band
|
||||
* or corner cannot be drawn. A failure partway leaves the shape
|
||||
* partially drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Stroke a circular arc of a given thickness between two angles.
|
||||
*
|
||||
* Angles are in degrees, 0 pointing along +x (three o'clock) and increasing
|
||||
* clockwise on screen -- the same convention as the rotation argument the
|
||||
* render backend's `draw_texture` takes. The stroke runs from the arc's outer
|
||||
* edge at @p radius inward by @p thickness, drawn as one triangle strip, not
|
||||
* anti-aliased.
|
||||
*
|
||||
* @param self The backend to draw through. Required, along with its
|
||||
* `sdl_renderer`.
|
||||
* @param x Horizontal position of the arc's centre.
|
||||
* @param y Vertical position of the arc's centre.
|
||||
* @param radius Outer radius in pixels. Must be positive.
|
||||
* @param start_deg Angle the arc starts at.
|
||||
* @param end_deg Angle the arc ends at. At or below @p start_deg nothing is
|
||||
* drawn and nothing is reported, matching the zero-area
|
||||
* rectangle fills above. Neither angle is normalized, so an
|
||||
* arc crossing three o'clock is 350 to 370, not 350 to 10; a
|
||||
* sweep beyond 360 degrees is clamped to one full turn.
|
||||
* @param thickness Stroke width in pixels, measured inward from @p radius.
|
||||
* Must be positive; at or above @p radius the arc becomes a
|
||||
* filled wedge to the centre.
|
||||
* @param color Colour to stroke in, including alpha.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKERR_OUTOFBOUNDS If @p radius or @p thickness is not positive. The
|
||||
* message reports the offending value.
|
||||
* @throws AKGL_ERR_SDL If the strip cannot be drawn.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color);
|
||||
|
||||
/**
|
||||
* @brief Restrict every subsequent draw to a rectangle of the render target.
|
||||
*
|
||||
* Everything drawn through @p self after this -- primitives here, textures,
|
||||
* text -- is clipped to @p rect until the restriction is cleared. This is the
|
||||
* only entry point in this header where a `NULL` rectangle is legal rather
|
||||
* than an error: it means "clear the restriction", matching what
|
||||
* `SDL_SetRenderClipRect` does with it, and there is no other value that
|
||||
* could.
|
||||
*
|
||||
* The clip is renderer state, not saved and restored around anything: a
|
||||
* caller that sets it owns putting it back, the way a `CLEANUP` block puts
|
||||
* back a draw colour. Leaving it set clips the next frame's world too.
|
||||
*
|
||||
* @param self The backend to clip. Required, along with its `sdl_renderer`.
|
||||
* @param rect Rectangle to clip to, in render-target pixels -- integer, since
|
||||
* a clip boundary is a pixel boundary. `NULL` clears the clip.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER If @p self or `self->sdl_renderer` is `NULL`.
|
||||
* @throws AKGL_ERR_SDL If the clip cannot be set or cleared.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect);
|
||||
|
||||
#endif //_AKGL_DRAW_H_
|
||||
|
||||
@@ -76,11 +76,12 @@
|
||||
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
|
||||
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
|
||||
#define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */
|
||||
#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */
|
||||
|
||||
// One past the last libakgl status. The reservation is all-or-nothing -- a
|
||||
// subset or superset of an existing one is refused -- so this must stay one
|
||||
// past the highest code above.
|
||||
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 6)
|
||||
#define AKGL_ERR_LIMIT (AKGL_ERR_BASE + 7)
|
||||
#define AKGL_ERR_COUNT (AKGL_ERR_LIMIT - AKGL_ERR_BASE)
|
||||
|
||||
/**
|
||||
|
||||
552
include/akgl/ui.h
Normal file
552
include/akgl/ui.h
Normal 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_
|
||||
@@ -49,8 +49,20 @@ rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log
|
||||
# The run itself is allowed to fail: a suite that valgrind makes slow enough to
|
||||
# trip an assertion still produced a log worth reading, and the log is what
|
||||
# decides the exit status below.
|
||||
#
|
||||
# docs_examples and docs_screenshots are excluded, and the exclusion deserves
|
||||
# its reasons stated: those two tests are *shell scripts* that drive gcc and
|
||||
# run the compiled snippets as child processes. Valgrind wraps the test
|
||||
# command, does not trace children, and so spends the whole test memchecking
|
||||
# /bin/bash -- 413 of bash's own by-design leaks on the first CI run that got
|
||||
# this far, and not one byte of libakgl. The snippet programs themselves are
|
||||
# ordinary akgl consumers whose code paths the suites and example games
|
||||
# already cover below, under valgrind, as real binaries. A caller's own -E
|
||||
# overrides this one (ctest takes the last), which is fine: a narrowed run
|
||||
# chose its scope on purpose.
|
||||
set +e
|
||||
ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure "$@"
|
||||
ctest --test-dir "$AKGL_BUILD_DIR" -T memcheck --output-on-failure \
|
||||
-E '^(docs_examples|docs_screenshots)$' "$@"
|
||||
AKGL_CTEST_STATUS=$?
|
||||
set -e
|
||||
|
||||
|
||||
226
src/draw.c
226
src/draw.c
@@ -619,3 +619,229 @@ akerr_ErrorContext *akgl_draw_paste_region(akgl_RenderBackend *self, SDL_Surface
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fill one quarter-circle corner as a triangle fan.
|
||||
*
|
||||
* `SDL_RenderGeometry` colours from its vertices rather than the renderer's
|
||||
* draw colour, so this neither pushes nor pops it.
|
||||
*
|
||||
* @param self The backend. Assumed non-`NULL` with a live `sdl_renderer`;
|
||||
* the caller has already checked both.
|
||||
* @param cx Horizontal position of the corner's centre -- the point the
|
||||
* fan radiates from, one radius inside the rectangle.
|
||||
* @param cy Vertical position of the centre.
|
||||
* @param radius Radius of the quarter circle. Assumed positive.
|
||||
* @param start_deg Angle the quarter starts at; it sweeps 90 degrees clockwise
|
||||
* from there.
|
||||
* @param color Colour to fill with.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKGL_ERR_SDL If the fan cannot be drawn.
|
||||
*/
|
||||
static akerr_ErrorContext *fill_corner_fan(akgl_RenderBackend *self, float32_t cx, float32_t cy, float32_t radius, float32_t start_deg, SDL_Color color)
|
||||
{
|
||||
SDL_Vertex vertices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2];
|
||||
int indices[AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3];
|
||||
SDL_FColor fcolor;
|
||||
float32_t angle = 0.0f;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
fcolor.r = (float)color.r / 255.0f;
|
||||
fcolor.g = (float)color.g / 255.0f;
|
||||
fcolor.b = (float)color.b / 255.0f;
|
||||
fcolor.a = (float)color.a / 255.0f;
|
||||
|
||||
vertices[0].position.x = cx;
|
||||
vertices[0].position.y = cy;
|
||||
vertices[0].color = fcolor;
|
||||
vertices[0].tex_coord.x = 0.0f;
|
||||
vertices[0].tex_coord.y = 0.0f;
|
||||
for ( i = 0; i <= AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) {
|
||||
angle = (start_deg + ((90.0f / AKGL_DRAW_ROUNDED_RECT_SEGMENTS) * (float)i)) * (SDL_PI_F / 180.0f);
|
||||
vertices[i + 1].position.x = cx + (radius * SDL_cosf(angle));
|
||||
vertices[i + 1].position.y = cy + (radius * SDL_sinf(angle));
|
||||
vertices[i + 1].color = fcolor;
|
||||
vertices[i + 1].tex_coord.x = 0.0f;
|
||||
vertices[i + 1].tex_coord.y = 0.0f;
|
||||
}
|
||||
for ( i = 0; i < AKGL_DRAW_ROUNDED_RECT_SEGMENTS; i++ ) {
|
||||
indices[(i * 3) + 0] = 0;
|
||||
indices[(i * 3) + 1] = i + 1;
|
||||
indices[(i * 3) + 2] = i + 2;
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_RenderGeometry(
|
||||
self->sdl_renderer,
|
||||
NULL,
|
||||
vertices,
|
||||
AKGL_DRAW_ROUNDED_RECT_SEGMENTS + 2,
|
||||
indices,
|
||||
AKGL_DRAW_ROUNDED_RECT_SEGMENTS * 3),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_filled_rounded_rect(akgl_RenderBackend *self, SDL_FRect *rect, float32_t radius, SDL_Color color)
|
||||
{
|
||||
SDL_Color previous;
|
||||
SDL_FRect band;
|
||||
float32_t half = 0.0f;
|
||||
bool pushed = false;
|
||||
bool drawfailed = false;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL rectangle");
|
||||
if ( (rect->w <= 0.0f) || (rect->h <= 0.0f) ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( radius <= 0.0f ) {
|
||||
PASS(errctx, akgl_draw_filled_rect(self, rect, color));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
half = (((rect->w < rect->h) ? rect->w : rect->h) / 2.0f);
|
||||
if ( radius > half ) {
|
||||
radius = half;
|
||||
}
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, push_draw_color(self, color, &previous));
|
||||
pushed = true;
|
||||
|
||||
// The body is three bands: one the full width between the corner rows,
|
||||
// and one between the corners along each of the top and bottom edges.
|
||||
// A radius of exactly half the shorter side leaves one or more of them
|
||||
// with no area, which SDL fills as nothing -- at the limit the whole
|
||||
// shape is the four fans.
|
||||
band.x = rect->x;
|
||||
band.y = rect->y + radius;
|
||||
band.w = rect->w;
|
||||
band.h = rect->h - (radius * 2.0f);
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
|
||||
drawfailed = true;
|
||||
}
|
||||
band.x = rect->x + radius;
|
||||
band.y = rect->y;
|
||||
band.w = rect->w - (radius * 2.0f);
|
||||
band.h = radius;
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
|
||||
drawfailed = true;
|
||||
}
|
||||
band.y = rect->y + rect->h - radius;
|
||||
if ( SDL_RenderFillRect(self->sdl_renderer, &band) == false ) {
|
||||
drawfailed = true;
|
||||
}
|
||||
FAIL_NONZERO_BREAK(errctx, drawfailed, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + radius, radius, 180.0f, color));
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + radius, radius, 270.0f, color));
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + rect->w - radius, rect->y + rect->h - radius, radius, 0.0f, color));
|
||||
CATCH(errctx, fill_corner_fan(self, rect->x + radius, rect->y + rect->h - radius, radius, 90.0f, color));
|
||||
} CLEANUP {
|
||||
if ( pushed == true ) {
|
||||
IGNORE(pop_draw_color(self, &previous));
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_arc(akgl_RenderBackend *self, float32_t x, float32_t y, float32_t radius, float32_t start_deg, float32_t end_deg, float32_t thickness, SDL_Color color)
|
||||
{
|
||||
SDL_Vertex vertices[AKGL_DRAW_ARC_MAX_POINTS];
|
||||
int indices[(AKGL_DRAW_ARC_MAX_POINTS - 2) * 3];
|
||||
SDL_FColor fcolor;
|
||||
float32_t span = 0.0f;
|
||||
float32_t inner = 0.0f;
|
||||
float32_t angle = 0.0f;
|
||||
int segments = 0;
|
||||
int base = 0;
|
||||
int i = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
FAIL_NONZERO_RETURN(errctx, (radius <= 0.0f), AKERR_OUTOFBOUNDS, "Arc radius %f is not positive", (double)radius);
|
||||
FAIL_NONZERO_RETURN(errctx, (thickness <= 0.0f), AKERR_OUTOFBOUNDS, "Arc thickness %f is not positive", (double)thickness);
|
||||
span = end_deg - start_deg;
|
||||
if ( span <= 0.0f ) {
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( span > 360.0f ) {
|
||||
span = 360.0f;
|
||||
}
|
||||
inner = radius - thickness;
|
||||
if ( inner < 0.0f ) {
|
||||
inner = 0.0f;
|
||||
}
|
||||
|
||||
// Steps in proportion to the angle swept, so a sliver of arc does not
|
||||
// spend the whole vertex budget and a full circle uses all of it: the
|
||||
// quarter-circle density of AKGL_DRAW_ROUNDED_RECT_SEGMENTS, capped by
|
||||
// what AKGL_DRAW_ARC_MAX_POINTS vertices can carry at two per step.
|
||||
segments = (int)((span / 90.0f) * (float)AKGL_DRAW_ROUNDED_RECT_SEGMENTS) + 1;
|
||||
if ( segments > ((AKGL_DRAW_ARC_MAX_POINTS / 2) - 1) ) {
|
||||
segments = (AKGL_DRAW_ARC_MAX_POINTS / 2) - 1;
|
||||
}
|
||||
|
||||
fcolor.r = (float)color.r / 255.0f;
|
||||
fcolor.g = (float)color.g / 255.0f;
|
||||
fcolor.b = (float)color.b / 255.0f;
|
||||
fcolor.a = (float)color.a / 255.0f;
|
||||
for ( i = 0; i <= segments; i++ ) {
|
||||
angle = (start_deg + ((span / (float)segments) * (float)i)) * (SDL_PI_F / 180.0f);
|
||||
vertices[i * 2].position.x = x + (radius * SDL_cosf(angle));
|
||||
vertices[i * 2].position.y = y + (radius * SDL_sinf(angle));
|
||||
vertices[i * 2].color = fcolor;
|
||||
vertices[i * 2].tex_coord.x = 0.0f;
|
||||
vertices[i * 2].tex_coord.y = 0.0f;
|
||||
vertices[(i * 2) + 1].position.x = x + (inner * SDL_cosf(angle));
|
||||
vertices[(i * 2) + 1].position.y = y + (inner * SDL_sinf(angle));
|
||||
vertices[(i * 2) + 1].color = fcolor;
|
||||
vertices[(i * 2) + 1].tex_coord.x = 0.0f;
|
||||
vertices[(i * 2) + 1].tex_coord.y = 0.0f;
|
||||
}
|
||||
for ( i = 0; i < segments; i++ ) {
|
||||
base = i * 2;
|
||||
indices[(i * 6) + 0] = base;
|
||||
indices[(i * 6) + 1] = base + 2;
|
||||
indices[(i * 6) + 2] = base + 1;
|
||||
indices[(i * 6) + 3] = base + 1;
|
||||
indices[(i * 6) + 4] = base + 2;
|
||||
indices[(i * 6) + 5] = base + 3;
|
||||
}
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_RenderGeometry(
|
||||
self->sdl_renderer,
|
||||
NULL,
|
||||
vertices,
|
||||
(segments + 1) * 2,
|
||||
indices,
|
||||
segments * 6),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *akgl_draw_set_clip(akgl_RenderBackend *self, SDL_Rect *rect)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, self->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
|
||||
// NULL rect deliberately passes straight through: it is how the clip is
|
||||
// cleared, and the header says so.
|
||||
FAIL_ZERO_RETURN(
|
||||
errctx,
|
||||
SDL_SetRenderClipRect(self->sdl_renderer, rect),
|
||||
AKGL_ERR_SDL,
|
||||
"%s",
|
||||
SDL_GetError());
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ akerr_ErrorContext *akgl_error_init(void)
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_COLLISION, "Collision Error"));
|
||||
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_UI, "UI Error"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
21
src/ui_clay.c
Normal file
21
src/ui_clay.c
Normal 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>
|
||||
@@ -284,6 +284,10 @@ akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void)
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
#include <akgl/text.h>
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/types.h>
|
||||
#include <akgl/ui.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/**
|
||||
|
||||
180
tests/draw.c
180
tests/draw.c
@@ -628,6 +628,183 @@ akerr_ErrorContext *test_draw_background(void)
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_filled_rounded_rect(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_FRect rect = { 8.0f, 8.0f, 40.0f, 24.0f };
|
||||
SDL_FRect square = { 40.0f, 40.0f, 16.0f, 16.0f };
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 8.0f, testred),
|
||||
"filling a rounded rectangle");
|
||||
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 28, 20, testred),
|
||||
"the middle of the rounded rectangle is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 28, 8, testred),
|
||||
"the top edge between the corners is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 20, testred),
|
||||
"the left edge between the corners is unfilled");
|
||||
// 12,12 is inside the corner's quarter circle (5.7px from its centre
|
||||
// at 16,16); 8,8 and 9,9 are outside it (11.3px and 9.9px). The fan's
|
||||
// triangles are chords of the circle, so they cover nothing beyond it.
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 12, 12, testred),
|
||||
"the inside of the rounded corner is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testblack),
|
||||
"the square corner pixel was filled despite the rounding");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 9, 9, testblack),
|
||||
"a pixel outside the corner arc was filled");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// A radius beyond half the shorter side clamps to it, which on a
|
||||
// square is a circle: centre filled, corners untouched.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &square, 100.0f, testgreen),
|
||||
"filling a rounded rectangle with an oversized radius");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 48, 48, testgreen),
|
||||
"the centre of the clamped-radius square is unfilled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 41, 41, testblack),
|
||||
"clamping the radius still filled the square's corner");
|
||||
|
||||
// Radius zero is the plain fill, corners and all.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rounded_rect(akgl_renderer, &rect, 0.0f, testred),
|
||||
"a zero radius falling through to the plain fill");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred),
|
||||
"a zero radius did not fill the square corner");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_filled_rounded_rect(NULL, &rect, 4.0f, testred),
|
||||
"rounded fill through a NULL backend");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_filled_rounded_rect(akgl_renderer, NULL, 4.0f, testred),
|
||||
"rounded fill of a NULL rectangle");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_arc(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
// A full ring: outer radius 20, stroke 4, so mid-stroke is radius 18.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 360.0f, 4.0f, testred),
|
||||
"stroking a full circle");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 50, 32, testred),
|
||||
"the ring is missing at three o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 50, testred),
|
||||
"the ring is missing at six o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 14, 32, testred),
|
||||
"the ring is missing at nine o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testred),
|
||||
"the ring is missing at twelve o'clock");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack),
|
||||
"the centre of the ring was filled");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 42, 32, testblack),
|
||||
"the stroke bled inside its inner radius");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
// A quarter arc sweeps clockwise from three o'clock to six o'clock;
|
||||
// 44,44 is on its mid-stroke at 45 degrees, twelve o'clock is not in
|
||||
// the sweep at all.
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testgreen),
|
||||
"stroking a quarter arc");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 44, 44, testgreen),
|
||||
"the quarter arc is missing at its midpoint");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 14, testblack),
|
||||
"the quarter arc drew outside its sweep");
|
||||
|
||||
// An empty sweep draws nothing and reports nothing.
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 90.0f, 90.0f, 4.0f, testred),
|
||||
"an arc with no sweep");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 0.0f, 0.0f, 90.0f, 4.0f, testred),
|
||||
"an arc with no radius");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_OUTOFBOUNDS,
|
||||
akgl_draw_arc(akgl_renderer, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, -1.0f, testred),
|
||||
"an arc with a negative thickness");
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_arc(NULL, 32.0f, 32.0f, 20.0f, 0.0f, 90.0f, 4.0f, testred),
|
||||
"an arc through a NULL backend");
|
||||
} CLEANUP {
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *test_draw_set_clip(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
SDL_Surface *shot = NULL;
|
||||
SDL_Rect clip = { 0, 0, 16, 16 };
|
||||
SDL_FRect everything = { 0.0f, 0.0f, (float)TEST_TARGET_SIZE, (float)TEST_TARGET_SIZE };
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, clear_target());
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, &clip),
|
||||
"setting the clip rectangle");
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testred),
|
||||
"filling the whole target under a clip");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, testred),
|
||||
"the fill did not reach inside the clip rectangle");
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testblack),
|
||||
"the fill escaped the clip rectangle");
|
||||
SDL_DestroySurface(shot);
|
||||
shot = NULL;
|
||||
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_set_clip(akgl_renderer, NULL),
|
||||
"clearing the clip rectangle");
|
||||
TEST_EXPECT_OK(errctx, akgl_draw_filled_rect(akgl_renderer, &everything, testgreen),
|
||||
"filling the whole target after clearing the clip");
|
||||
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
|
||||
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
|
||||
TEST_ASSERT(errctx, pixel_is(shot, 32, 32, testgreen),
|
||||
"clearing the clip did not restore the full target");
|
||||
|
||||
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
|
||||
akgl_draw_set_clip(NULL, &clip),
|
||||
"clipping through a NULL backend");
|
||||
} CLEANUP {
|
||||
// The clip is renderer state; leaving it set would clip every test
|
||||
// after this one.
|
||||
IGNORE(akgl_draw_set_clip(akgl_renderer, NULL));
|
||||
if ( shot != NULL ) {
|
||||
SDL_DestroySurface(shot);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
@@ -668,6 +845,9 @@ int main(void)
|
||||
CATCH(errctx, test_draw_preserves_render_draw_color());
|
||||
CATCH(errctx, test_draw_backend_without_a_renderer());
|
||||
CATCH(errctx, test_draw_background());
|
||||
CATCH(errctx, test_draw_filled_rounded_rect());
|
||||
CATCH(errctx, test_draw_arc());
|
||||
CATCH(errctx, test_draw_set_clip());
|
||||
} CLEANUP {
|
||||
SDL_Quit();
|
||||
} PROCESS(errctx) {
|
||||
|
||||
@@ -33,7 +33,8 @@ akerr_ErrorContext *test_error_init_owns_the_status_band(void)
|
||||
{ AKGL_ERR_HEAP, "Heap Error" },
|
||||
{ AKGL_ERR_BEHAVIOR, "Behavior Error" },
|
||||
{ AKGL_ERR_LOGICINTERRUPT, "Logic Interrupt" },
|
||||
{ AKGL_ERR_COLLISION, "Collision Error" }
|
||||
{ AKGL_ERR_COLLISION, "Collision Error" },
|
||||
{ AKGL_ERR_UI, "UI Error" }
|
||||
};
|
||||
bool named = true;
|
||||
int i = 0;
|
||||
|
||||
@@ -240,6 +240,10 @@ int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
@@ -740,6 +740,10 @@ int main(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
akgl_gamemap = &akgl_default_gamemap;
|
||||
|
||||
1033
tests/ui.c
Normal file
1033
tests/ui.c
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user