18 Commits

Author SHA1 Message Date
5560edf410 Exclude the two shell-script tests from the memcheck run
Some checks are pending
libakgl CI Build / mutation_test (push) Waiting to run
libakgl CI Build / cmake_build (push) Successful in 8m54s
libakgl CI Build / performance (push) Successful in 9m48s
libakgl CI Build / memory_check (push) Successful in 14m39s
The memory job's first-ever complete valgrind pass on the runner reported
every real suite and all three example games clean -- and 414 findings in
docs_examples and docs_screenshots. Those two tests are bash scripts that
drive gcc and run the compiled snippets as children; valgrind wraps the test
command and does not trace children, so all 414 were /bin/bash's own
by-design leaks and not one byte of libakgl. Memchecking them proves nothing
the suites and example games do not already prove as real binaries under the
same valgrind run.

memcheck.sh now excludes exactly those two by name, with the reasoning in a
comment beside the exclusion -- per the suppressions file's own rule that
nothing gets quieted without a stated reason. Verified locally: full run over
the RelWithDebInfo tree, every suite and example under valgrind, zero
findings, exit 0.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 15:12:39 -04:00
be3c8ae850 Pin upload-artifact to v3: v4 refuses to run against Gitea
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m18s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / memory_check (push) Failing after 18m50s
libakgl CI Build / mutation_test (push) Has been cancelled
The env-list fix got every test green on the runner for the first time --
37 of 37, the JUnit publish included -- and the job then failed in the
artifact upload: upload-artifact@v4 speaks GitHub's v2 artifact service and
hard-refuses any host that is not github.com ("not currently supported on
GHES"). Gitea implements the v3 artifact API, so the three upload steps --
coverage, the benchmark tables, the valgrind logs -- pin to v3.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:29:42 -04:00
d8458d1e68 Stop set_tests_properties from eating the test environment lists
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m56s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
The example games failed on the CI runner with "ALSA: Couldn't open audio
device" despite SDL_AUDIODRIVER=dummy sitting right there in their CMake --
because it never reached the process. set_tests_properties parses its
PROPERTIES arguments as name/value pairs, so a semicolon-separated value
list is split and every element after the first is consumed as a bogus
property name: ENVIRONMENT kept only SDL_VIDEODRIVER=dummy, and the
LD_LIBRARY_PATH prepend list kept only its first directory. ctest
--show-only=json-v1 shows the truncation plainly.

Nobody could see it. Video survived because SDL3 falls through its driver
list to dummy on a headless machine with no hint at all; audio survived on
every developer machine because the real device works there; the library
paths survived because RPATH covered them. A runner with no sound card was
the first environment where any of it mattered.

Every list-valued test property now goes through set_property(TEST ...),
which takes real list arguments -- the examples' ENVIRONMENT triplets and
vendored-path modifications, the suites' LD_LIBRARY_PATH prepends, and the
docs harness's driver variables. Verified by property dump (3 environment
entries and all 7 prepends present) and by running the example tests with
ALSA_CONFIG_PATH=/dev/null, which now pass where a real ALSA open would
fail: the dummy driver is finally the one being asked.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:19:05 -04:00
219d7182f1 Install graphviz where doxygen runs
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m54s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
The submodule fix got the cmake_build job through checkout, dependencies,
configure and the whole compile for the first time in the run history -- and
into the next latent failure: `doxygen Doxyfile` generates dot graphs and the
Doxyfile fails on warnings, but the runner image has no `dot`, so every graph
reported "Problems running dot: exit code=127" and the step failed after an
otherwise clean build. Locally graphviz was installed, which is why the
pre-flight missed it. One package in the one job that builds documentation.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:02:07 -04:00
2242cfac9f Unbreak CI: https submodule URLs, and three suites that assumed a display
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m4s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
CI has never gone green in the retained run history, and every failure is
the same 20-second death during checkout: six submodules -- SDL, SDL_image,
SDL_mixer, SDL_ttf, jansson, semver -- were registered with git@github.com:
SSH URLs, and the runner has no GitHub key. The https submodules (libccd,
tg, clay, and the two starfort ones) clone fine in the same run. All six now
use https, which is what every stanza added since already did; these are
public upstream repositories nothing here pushes to, so SSH bought nothing.

Second problem, waiting right behind the first: the character, sprite and
tilemap suites were the only three of seventeen that never set the dummy
video/audio/software-render hints, so on a headless runner they die in
SDL_Init with "No available video device" before testing anything. They now
set the same three hints the other fourteen suites set. (How this survived:
every local run happens on a machine with a display, and CI never got far
enough to run a test.)

Pre-flighted locally against every job the workflow defines, headless with
no driver variables in the environment, exactly as the runner would: Debug
with -Werror and coverage builds clean and all 37 tests pass including the
coverage fixtures; RelWithDebInfo with -Werror builds clean and the perf
suites pass their budgets at full scale; doxygen Doxyfile exits 0 under
FAIL_ON_WARNINGS; and the new ui suite runs under valgrind with zero errors
and no leaks.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 13:39:17 -04:00
f2efbdf1b7 Fill the gaps the chapter 22 weaker-model reader found
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 22s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 18s
The AGENTS.md "Testing a Tutorial" pass: chapter 22 was handed to a reader on
a weaker model with the library tree stripped of examples/ and docs/, and
they had to build a menu/HUD/dialog program from the chapter alone. They
built one that compiled and ran -- and reported success their own screenshots
disproved, which is why the protocol says to verify: their synthesized key
events set only the scancode, akgl_ui_menu_handle_event matches keycodes, and
their program never actually left the title screen. With that one line fixed,
everything they had built from the chapter worked.

Three findings survived verification against eight reported:

- The chapter never stated its prerequisites, so the reader burned most of
  the session on library bring-up it does not cover and invented goto error
  handling for main(). A new "What this chapter assumes" paragraph points at
  chapters 3, 7 and 17, and at the demo's startup() and main() as the
  complete reference.
- Nothing named the menu's exact keys, or that they are keycodes rather than
  scancodes -- invisible with a real keyboard, fatal for synthesized events,
  which the demo's own --demo script teaches. The menus section now names
  SDLK_UP/DOWN/RETURN, the gamepad buttons, and the key.key trap.
- Verifying their dialog screenshot showed a message wrapping past the fixed
  AKGL_UI_DIALOG_HEIGHT runs visibly out of the panel. Deliberate -- same as
  the hand-rolled textbox, and visible overflow beats silent truncation --
  but undocumented; akgl_ui_dialog's header note now says so.

Rejected for the record, per protocol: akgl_game_init already initializes the
property registry (src/game.c:219), and the font they "found" in a different
directory was the build tree's file(COPY) of tests/assets.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 13:06:22 -04:00
f772da7296 Record the UI's paper trail and bump to 0.9.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 23s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
Two TODO.md notes the UI work created. The text texture ring cache (plan
item 2) now has its second consumer, and the bigger one: the UI executor
draws every TEXT render command through akgl_text_rendertextat, one wrapped
line per command per frame, so a five-row menu re-rasterizes five lines at
60 Hz against the original case of one HUD line changing once a second.
And akgl_ControlMap's mouseid/penid fields stay dead deliberately -- the
mouse path lives in the UI subsystem because control maps and pointer state
are different consumers, and the note says where the work lands when a game
wants mouse-driven actors.

0.8.0 to 0.9.0: a new public header, a new status code, three new drawing
primitives and a new subsystem is new API, and while the major version is 0
the soname carries major.minor -- libakgl.so.0.9 is what makes 0.8.0 and
0.9.0 unmixable at load time, which is the honesty the bump is for.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:34:12 -04:00
8f848d80be Document the UI subsystem: chapter 22, with the appendix moving to 23
docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl
owns, bring-up and the arena refusal contract, the frame bracket and where it
sits in a real frame, the consumed-event rules (with the one-frame-stale hit
test stated plainly), the three widgets, menus across keyboard, gamepad and
mouse, writing raw CLAY() with an application-owned press edge, images and
styles, and how layout errors surface from frame_end with a captured trace.
Every listing is a checked block: one linked-and-run example with pinned
output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c,
and the chapter figure is a frame out of uidemo via docs_game_figures.

The chapter owes and pays the comparison the tutorials earn: the dialog
widget's default style reproduces the JRPG textbox palette, the two listings
sit side by side, and the trade is stated -- 125 lines you own completely
against one call that costs you the subsystem. Neither is deprecated;
chapter 21 keeps teaching the hand-rolled panel on purpose.

The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC
updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits
table, and the new draw primitives' rows. Its status-band figures were stale
at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at
7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the
name list.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:32:20 -04:00
4ddd8d1ad3 Add the UI demo: title menu, raw-CLAY options screen, HUD and dialog
examples/uidemo is the program the UI chapter quotes. Three screens, three
ways of building an interface: the title screen is one akgl_UiMenu plus a
heading label; the options screen is written in raw CLAY() declarations --
hover highlighting inside the declaration, clicks paired with the
application's own press edge -- because the widgets are a convenience, not a
boundary; and the play screen is two HUD labels and the one-call dialog over
a checkerboard standing in for a game world. No tilemap, no actors, no
physics: everything left on screen is the subject.

Its route_event() is the documented call-order contract in the flesh -- UI
first, consumed events stop there, then the current screen's keys -- and the
screen routing is the focus model, stated rather than invented.

The headless smoke run (example_uidemo, --frames 240 --demo) tours every
path through the real event chain: a mouse click on the centred menu (the
consumed path, landing on the middle row by symmetry), keyboard into and out
of the options screen, the dialog opened and dismissed, and Quit confirmed
from the menu. The run prints its score and settings so a pass can be read,
not just counted. docs_game_figures gains a uidemo frame -- the play screen
with the dialog up -- for the chapter to come.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:24:58 -04:00
badaf570ab Stop a stationary pointer from pinning the menu selection
A pointer parked over a menu row re-claimed the selection on every frame's
declaration, so keyboard navigation fought the mouse sixty times a second
and lost -- press Up, and the hover put the selection straight back. Found
by the UI demo's scripted tour: its mouse click leaves the pointer resting
on the menu, and the Up keystroke that followed did nothing.

Hover now moves the selection only on frames the pointer actually moved (or
clicked), through a frame-latched motion edge beside the existing press
edge. Parking the mouse claims nothing; moving it onto a row still selects,
clicking still selects and activates. Regression test drives the exact
sequence: click a row, move the selection away by keyboard, redeclare, and
assert the stationary pointer stole nothing back.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:24:57 -04:00
0679d40ee4 Add the widget helpers: dialog, HUD label, and menu
The other half of the hybrid decision: an application that wants a dialog
box, a score counter or a main menu gets each in one call, without touching a
CLAY() macro -- and an application that outgrows them still has clay's whole
DSL, because the widgets are nothing but functions that declare the same
elements between the same frame bracket.

akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the
layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL
style *is* the textbox palette -- near-black fill, parchment edge and ink,
8px padding -- deliberately, so the widget and the 125-line hand-rolled panel
it stands beside in the comparison chapter produce the same picture. Showing
and hiding is the frame's business: call it while somebody is talking, don't
while nobody is; a declarative frame is the visible flag.

akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by
the style's padding. akgl_ui_menu declares a centred vertical menu from a
caller-owned struct (no heap layer: it owns no texture, no font, no registry
entry), with the selected row drawn inverted, hover moving the selection and
the frame-latched press edge activating it. akgl_ui_menu_handle_event drives
the same struct from Up/Down/Return and D-pad/South with wrapping, slotting
into the event chain after akgl_ui_handle_event; routing events to a menu is
the application's focus model, stated rather than invented.

Tests cover the dialog's geometry and palette by pixel, label anchoring by
quadrant, menu validation, the inverted highlight, keyboard and gamepad
wrap/activate/pass-through, and the full two-frame mouse protocol -- click
aimed at the second row's real box via Clay_GetElementData, seen by the next
frame's declaration as selection plus activation.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:17:32 -04:00
566b870a5c Feed the mouse to the UI and tell the host which events it consumed
akgl_ui_handle_event is the mouse half of the input story -- keyboard and
gamepad stay with the control maps, because keyboard focus is something an
application declares rather than something a pointer position implies. It
handles motion, left-button presses and releases, the wheel and window
resizes; everything else, and everything while the subsystem is inert,
reports consumed=false and succeeds, the same pass-everything contract
akgl_controller_handle_event already has. The documented call order is UI
first, control maps second, skipping on consumed.

A press, release or wheel is consumed when the pointer is over any UI
element. The hit test asks clay against the layout the previous frame
retained -- this frame's does not exist while events are polled, and one
frame of staleness is the standard model's accepted cost -- with clay's
internal full-screen Clay__RootContainer excluded, because being inside the
window is not being over the interface (unexcluded, every click anywhere was
consumed; the test that pins this caught it).

Pointer state is fed to clay per event, as clay documents; the press edge is
latched once per frame_begin for the widgets to come, rather than trusting
Clay_PointerData's per-SetPointerState edge, which advances per mouse event.
frame_begin also drives Clay_UpdateScrollContainers from the accumulated
wheel (32 pixels per notch) and a wall-clock dt.

Tests pin the whole contract: pass-through before init, no consumption
before any layout exists, press/release inside vs beside a panel, right
button ignored, motion never consumed, wheel consumed only over the UI,
resize reaching the layout engine, and key events left alone.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:10:35 -04:00
3b3bfcbe10 Render clay layouts: fonts, text measurement, executor and the frame bracket
The UI subsystem now draws. akgl_ui_frame_begin/frame_end bracket one frame's
CLAY() declarations: begin clears the error stash and starts the clay layout,
end computes it and walks the render commands through a backend -- RECTANGLE
as (rounded) fills, BORDER as radius-shortened edge fills plus corner arcs,
TEXT through akgl_text_rendertextat one wrapped line per command, IMAGE as an
akgl_Sprite's first frame stretched to the bounding box, and the SCISSOR pair
through akgl_draw_set_clip, cleared again on any exit so a failed frame
cannot leave the world clipped.

akgl_ui_font_register maps registry font names onto clay's uint16_t fontIds.
The table keeps the *name* and resolves it per use -- fonts are not reference
counted, and a cached TTF_Font* would dangle where a name reports "gone"
honestly. Clay_TextElementConfig.fontSize is deliberately ignored: libakgl
bakes the size into the handle at load, so one face at two sizes is two ids.

The measure bridge passes clay's non-NUL-terminated slices straight to
SDL_ttf's explicit-length TTF_GetStringSize -- no copy, no scratch. Its
signature has no error channel, so failures report zero-by-zero and stash a
message that frame_end raises, the same route clay's own void error handler
uses; layout errors take precedence over drawing and one bad frame leaves the
next one clean.

Tests drive real CLAY() layouts through the software renderer and read pixels
back: fill placement, border edges with an empty middle, a child clipped by
its container, text landing in its colour, slice-vs-whole measurement
agreement, fontId table dedupe/refusal/exhaustion, and the bracket's
begin/begin, end-without-begin and failure-recovery contracts.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:06:27 -04:00
064e6569e8 Add rounded-rect fill, arc stroke and clip-rect drawing primitives
The UI subsystem's render executor needs all three -- clay emits rectangles
with corner radii, per-side borders, and scissor commands -- but they earn
their place in draw.h on the same terms as everything else there: a rounded
panel, a ring gauge and a clipped viewport are things a game wants with or
without a layout engine.

akgl_draw_filled_rounded_rect decomposes into three band fills plus four
quarter-circle triangle fans through SDL_RenderGeometry, with the radius
clamped to half the shorter side. akgl_draw_arc strokes between an outer and
inner radius as one triangle strip, stepping in proportion to the swept angle
under a fixed vertex cap -- no VLAs, unlike clay's own SDL3 reference
renderer. akgl_draw_set_clip wraps SDL_SetRenderClipRect and is the one draw
entry point where a NULL rectangle is legal, because that is how a clip is
cleared.

Pixel-readback tests cover the corner geometry (inside the arc filled,
outside it untouched), the radius clamp, the zero-radius fall-through, ring
and quarter-arc coverage at mid-stroke, sweep bounds, and clip set/clear
round trips.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 10:57:23 -04:00
b7ff54b09f Vendor clay and bring the UI subsystem up: arena, lifecycle, status band
clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new
akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the
semver/libccd precedent: clay's CMakeLists declares no library target, builds
its examples by default, and requires CMake 3.27 against this project's 3.10.
src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on
the vendored-code terms but with clay's symbols deliberately exported --
consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them
never to link a second clay.

The subsystem is runtime-optional the way collision is: always compiled in,
inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_*
ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc),
and refuses with both numbers in the message when it does not fit. Measured:
812544 bytes at the default 1024 elements / 4096 measured words, against a
1 MiB arena. clay's void-callback layout errors are logged as they happen and
the first is stashed for the error protocol to raise later.

AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in
akgl_error_init. New `ui` test suite covers validation, the init/shutdown
lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same
contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h,
its licence beside libccd's, and the headers suite proves akgl/ui.h
self-contained -- clay.h compiles clean under -Wall.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 10:53:11 -04:00
75da766724 Record how to find out whether a tutorial teaches
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 20s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
docs_examples proves every listing in docs/ compiles and still matches the file
it was quoted from. It cannot prove a chapter teaches anything -- a document can
be made entirely of verified excerpts and still be unfollowable, because what a
reader needs is the glue between them.

So AGENTS.md gains "Testing a Tutorial": hand the chapter to a subagent on a
weaker model with the library, its headers and the art but not the finished
program, and make them build and run the game. The weaker model is the point; it
will not paper over a gap with knowledge the document did not give it.

The section carries the rules that make the result mean something -- copy the
tree with examples/ and docs/ removed rather than asking them not to look, let
them read the public headers because a real reader has them, do not make them
hand-author a .tmj, give them a screenshot helper, and verify their binary
yourself -- plus the sandbox recipe, and the warning to prove the build path
works before handing it over.

It also records how to read the report, because a weaker model states its own
mistakes as documentation defects with total confidence. Reproduce before
believing; and a rejected finding usually still leaves something behind.

The evidence for doing any of this is in the table at the end: four read-only
reviews of chapters 20 and 21 found real gaps and missed all three of the
defects the first build-and-run found, including a published example output the
chapter could not produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:45:28 -04:00
cf0142ea85 Say how close an NPC has to be placed to be talked to
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 18s
A reader drew their own town, put the elder 115 pixels from anywhere the player
could stand, and spent the rest of their time looking for the bug in
jrpg_cmhf_talk. There is no bug: the handler finds nobody within TALK_RANGE and
returns success, which is indistinguishable from the game not being wired up.

The chapter stated the constant and not what it means in the editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:36:25 -04:00
6e97d27e49 Fix what showed up when the tutorials were actually built and run
Two readers were given the library, the art, the chapter, and no reference
program, and told to compile and run. Both got a working game. These are the
defects that only came out because a compiler and a frame loop were involved --
four earlier read-only reviews had missed every one.

libakstdlib's header was never named. The chapters used aksl_strncpy,
aksl_snprintf and aksl_atoi throughout and said only "libakstdlib is documented
in deps/libakstdlib"; the obvious guess from the function prefix is <aksl.h> and
it is wrong. That was the only compile error the sidescroller reader hit, and it
was the chapter's fault. Both chapters now carry a per-file include table and say
which header it is.

The sidescroller's asset directory defaulted to ".". The chapter said the code
"falls back to `.`" without showing the #ifndef or that assetdir is initialised
from the macro, so a reader gets a game that only finds its art when launched
from exactly the right directory.

The sidescroller's summary line reported 0.0,0.0 airborne while the screenshot
showed the player standing on the ground. Teardown runs in main's CLEANUP block
and releases the actor pool before the summary prints. The chapter published an
example output a reader could not reproduce; the capture that makes it true is
now shown beside it.

The JRPG's CMake block interpolated ${JRPG_REPO_ROOT}, which is this repository's
own variable and undefined for anybody else -- so the font path came out wrong
and akgl_text_loadfont failed long after everything else had loaded. It is
${CMAKE_CURRENT_SOURCE_DIR} now, and the chapter says why both definitions are
needed rather than one.

Chapter 21 described --demo and a summary line in its build section that no step
wrote. Both are now a section: the script table, playback through
akgl_controller_handle_event rather than the handlers, the fixed clock step, and
printing the position before teardown. It also now says libakgl keeps no frame
counter, which a reader assumed it did.

Both map sections now say to draw it in Tiled and point at chapter 13, naming the
fields the loader needs that Tiled fills in automatically -- both readers
hand-wrote a .tmj before being given one and hit exactly those.

Excerpts across docs/ go from 190 to 198.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:34:16 -04:00
39 changed files with 4840 additions and 83 deletions

View File

@@ -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
View File

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

View File

@@ -682,6 +682,101 @@ of a hundred thousand times, and the timings a checked run prints are labelled
as meaningless. **Do not add memory-check suites**: a new path worth checking
belongs in a benchmark, where it gets both.
## Testing a Tutorial
`ctest -R docs_examples` proves every listing in `docs/` still compiles and
still matches the file it was quoted from. It cannot prove the chapter *teaches*
anything: a document can be composed entirely of verified excerpts and still be
unfollowable, because what a reader needs is the glue between them.
**So test a tutorial by making somebody follow it.** Hand it to a subagent on a
weaker model with no context beyond the chapter, and have them build the thing it
describes. The weaker model is the point -- it will not paper over a gap with
knowledge the document did not give it, which is exactly the failure mode a
capable reviewer has.
### The rules that make the result mean something
- **Give them what a real reader has, and nothing more.** The chapter, the
library source, its public headers, and the art. **Not the finished program.**
Copy the tree with `examples/` and `docs/` removed rather than telling them not
to look -- a rule they can break is not a control.
- **Headers are fair game.** The manual defers signatures to Doxygen and a real
user has `include/akgl/*.h` in front of them. Forbidding those tests a reader
who does not exist.
- **They must compile it and run it.** This is the whole thing. A design nobody
built proves nothing, and the defects that matter most do not fail to compile.
- **Verify their work yourself.** Run the binary they produced. Look at the
screenshot they took. Do not take a report's word for what it built --
see "Reading the report" below.
- **Do not make them author assets by hand.** A real reader draws a map in Tiled;
hand-writing a `.tmj` tests nothing about the chapter and eats the whole
session. Give them `docs/tutorials/assets/`. The asset *formats* are already
covered by `docs_examples`, which runs the chapter's own JSON blocks through
`akgl_sprite_load_json` and `akgl_character_load_json`.
- **Give them a screenshot helper**, marked as scaffolding and not part of the
tutorial, so there is a picture to check. Screen output is evidence; an exit
status of 0 is not.
### Setting the sandbox up
One directory per reader, because two of them building at once in the same tree
collide:
```sh
rsync -a --exclude='.git' --exclude='build' --exclude='examples' --exclude='docs' \
. "$SB/reader/libakgl/"
cp docs/20-tutorial-sidescroller.md "$SB/reader/TUTORIAL.md"
cp -r docs/tutorials/assets/sidescroller "$SB/reader/art"
mkdir -p "$SB/reader/game"
```
A consumer using the CMake the chapter itself teaches --
`add_subdirectory(../libakgl libakgl)` plus the documented
`target_link_libraries` line -- configures and builds in about a minute from
cold, and seconds after that. Tell them to use
`cmake --build build --target <theirs> --parallel`, or they will also build
every test suite in the tree.
**Prove the path works before you hand it over.** Write a throwaway consumer
that opens a window and takes a screenshot, build it, run it, and delete it. A
reader who cannot build is a reader who finds nothing, and the fault will be
yours -- the screenshot helper's first draft included `akgl/renderer.h` for
`akgl_renderer`, which is declared in `akgl/game.h`, and would have cost them
the session.
### Reading the report
**Every finding is a hypothesis until you check it.** A weaker model reports its
own mistakes as documentation defects with total confidence, and both kinds are
worth having -- but only one is worth acting on.
- Reproduce the failure before believing it. One reader reported the dialogue
freeze broken; it had drawn its own map with the NPC out of reach and never
used the one it was given. Running the reference showed the player moving zero
pixels through the whole freeze window.
- A rejected finding usually still leaves something. That same map failure was
not a code defect, but it did show the chapter stated `TALK_RANGE` without
saying what it means when you are placing NPCs -- and that a too-distant NPC
produces *success*, not an error.
- Fix the document, not the reader. If they guessed a header name, the chapter
never named it.
### What this catches that review does not
Four read-only passes over the two tutorials in `docs/` found real gaps and
missed all three of these, which the first build-and-run found immediately:
| Defect | Why reading missed it |
|---|---|
| libakstdlib's header was never named -- `aksl_*` functions, `<akstdlib.h>` file | Every reviewer already knew, or did not have to write the `#include` |
| The asset directory defaulted to `"."` instead of the compiled-in macro | Prose said "falls back", and nobody had to run it from another directory |
| A published example output the chapter could not produce | The capture happened after teardown released the pool. Nothing fails to compile |
The third is the shape to remember: **a tutorial that prints an expected result
is making a claim, and a claim nobody executed is a claim that is probably
wrong.**
## Commit & Pull Request Guidelines
Recent commits use concise, imperative summaries such as `Fix a scale bug...` and often explain related changes in one sentence. Keep each commit scoped and describe user-visible behavior. Pull requests should summarize the change, identify affected modules, list the CMake/CTest commands run, and link relevant issues. Include screenshots only for rendering, map, or `charviewer` changes.

View File

@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
# The single source of truth for the library version. It drives the generated
# 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
View File

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

Submodule deps/clay added at b25a31c1a1

View File

@@ -25,7 +25,7 @@ The house rules for *writing* code against the protocol — never a `*_RETURN` i
libakerror reserves statuses 0255 for the host's `errno` values and its own `AKERR_*`
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.

View File

@@ -344,4 +344,4 @@ all visible in `src/heap.c`.
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
- [Chapter 22](22-appendix-limits.md) — every `AKGL_MAX_*` in one place.
- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place.

View File

@@ -224,5 +224,5 @@ registry's.
- [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created,
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_*`.

View File

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

View File

@@ -37,7 +37,7 @@ why the library's own map, `akgl_default_gamemap`, is a file-scope object in
The bounds are fixed at compile time, so the size is fixed too. Every field is present
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

View File

@@ -134,19 +134,36 @@ target_compile_definitions(sidescroller
The parts that matter:
Each `.c` file includes `sidescroller.h` plus whatever it calls directly. `main.c` adds
`akgl/character.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/registry.h`, `akgl/renderer.h`,
`akgl/sprite.h` and `akgl/text.h`; `player.c` adds `akgl/controller.h`, `akgl/heap.h`,
`akgl/registry.h`, `akgl/util.h` and `<math.h>`; `actors.c` adds `akgl/heap.h`,
`akgl/registry.h` and `<math.h>`. libakgl's headers are self-contained, so including the one
that declares what you are calling is always enough.
Each `.c` file includes `sidescroller.h` plus whatever it calls directly:
| File | Adds |
|---|---|
| `main.c` | `<string.h>`, `akstdlib.h`, `SDL3_image/SDL_image.h`, `SDL3_mixer/SDL_mixer.h`, `SDL3_ttf/SDL_ttf.h`, `akgl/character.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/registry.h`, `akgl/renderer.h`, `akgl/sprite.h`, `akgl/text.h`, `akgl/tilemap.h` |
| `player.c` | `<math.h>`, `akstdlib.h`, `akgl/controller.h`, `akgl/heap.h`, `akgl/physics.h`, `akgl/registry.h`, `akgl/util.h` |
| `actors.c` | `<math.h>`, `akstdlib.h`, `akgl/heap.h`, `akgl/registry.h` |
**libakstdlib's header is `<akstdlib.h>`**, not `<aksl.h>` — its functions are prefixed
`aksl_` but the file is not. libakerror's is `<akerror.h>`. libakgl's own headers are
self-contained, so including the one that declares what you are calling is always enough.
The build file:
- You link all four SDL libraries even though this game has no text and no sound, because
libakgl itself is built against them.
- `SS_ASSET_DIR` is baked in at compile time, so the program can be run from any working
directory. The code falls back to `"."` if it is not defined.
directory. Give it a fallback so the file still compiles without CMake, and **use it as the
default**: a program that defaults to `"."` only finds its assets when it happens to be
launched from the right directory.
```c excerpt=examples/sidescroller/main.c
#ifndef SS_ASSET_DIR
#define SS_ASSET_DIR "."
#endif
```
```c excerpt=examples/sidescroller/main.c
char *assetdir = SS_ASSET_DIR;
```
### The header
@@ -649,7 +666,14 @@ reads. Load a character before its sprites and it fails on the first name it can
## 5. Draw a level
Open Tiled, make a new map, and set it up like this:
**Draw the map in [Tiled](https://mapeditor.org) and save it as `.tmj`.** The rest of this
section is what to set in the editor; Tiled writes the JSON. [Chapter 13](13-tilemaps.md) is
the reference for the format itself, and for what libakgl reads out of it and what it
ignores — read that before hand-editing a `.tmj`, because the loader needs several fields
Tiled fills in automatically (a root-level `width` and `height`, an `id` on every layer, and
`tilecount`, `columns`, `imagewidth` and `imageheight` on every tileset).
Make a new map and set it up like this:
| Setting | Value |
|---|---|
@@ -1625,6 +1649,19 @@ It prints where the player finished:
sidescroller: 240 frames, 0 of 4 coins, 0 deaths, player at 136.0,160.0 grounded
```
**Capture that position at the end of `run()`, not in `main`.** Teardown happens in `main`'s
`CLEANUP` block and releases the actor pool, so by the time the summary is printed
`ss_game.player` points at a slot that has been given back — and the line reports `0.0,0.0`:
```c excerpt=examples/sidescroller/main.c
if ( ss_game.player != NULL ) {
ss_game.final_x = ss_game.player->x;
ss_game.final_y = ss_game.player->y;
}
```
That is what `final_x` and `final_y` on `ss_Game` are for.
`grounded` and a sensible `y` are how you know collision ran. If the player is hundreds of
pixels below the level, revisit step 7.

View File

@@ -80,11 +80,16 @@ TrueType font at runtime. Bake both paths in at compile time:
```cmake
target_compile_definitions(jrpg PRIVATE
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
JRPG_ASSET_DIR="${CMAKE_CURRENT_SOURCE_DIR}/assets"
JRPG_FONT_FILE="${CMAKE_CURRENT_SOURCE_DIR}/assets/font.ttf"
)
```
**Two definitions, not one.** The font is a separate path from the assets, and a game that
defines only `JRPG_ASSET_DIR` fails at `akgl_text_loadfont` with "Couldn't open" — after
everything else has loaded, which makes it look like a font problem rather than a build
one.
Give them defaults in the header so the file still compiles on its own:
```c excerpt=examples/jrpg/jrpg.h
@@ -425,8 +430,8 @@ already works.
## 5. Draw the town
Set the map up as chapter 20 describes — orthogonal, CSV layer format, 16×16 tiles — at
30 × 20 tiles, with three layers:
Draw it in Tiled and save it as `.tmj`, set up as chapter 20 describes — orthogonal, CSV
layer format, 16×16 tiles — at 30 × 20 tiles, with three layers:
| Layer | Type | What it is |
|---|---|---|
@@ -957,6 +962,13 @@ range, otherwise do nothing.
#define TALK_RANGE 64.0f
```
**Place your NPCs with that number in mind.** The distance is measured between the two
actors' positions, and both sprites are 32 pixels, so 64 is two sprite widths — close, by
design. An NPC parked behind a building or across a wall from anywhere the player can stand
is an NPC the player can see and never talk to, and nothing reports that: the handler finds
nobody in range and returns success. If a conversation will not open, print the distance
before you go looking at the code.
```c excerpt=examples/jrpg/world.c
#define TOWNSFOLK_COUNT (sizeof(TOWNSFOLK) / sizeof(TOWNSFOLK[0]))
```
@@ -1072,6 +1084,15 @@ The `+ 16.0f` centres on the middle of a 32-pixel sprite rather than its top-lef
The loop is chapter 20's, with two additions. Drain the events first:
```c excerpt=examples/jrpg/jrpg.c
static akerr_ErrorContext *frame(long frameno)
{
PREPARE_ERROR(errctx);
SDL_Event event;
akgl_Iterator opflags = {
.flags = AKGL_ITERATOR_OP_UPDATE,
.layerid = 0
};
while ( SDL_PollEvent(&event) ) {
if ( event.type == SDL_EVENT_QUIT ) {
running = false;
@@ -1080,6 +1101,10 @@ The loop is chapter 20's, with two additions. Drain the events first:
}
```
`frameno` is the outer loop's own counter, incremented once per call. **libakgl does not keep
a frame number** — `akgl_game` has an fps figure but no counter, so a game that wants one
keeps it.
Then the camera, the world, and the panel on top of it:
```c excerpt=examples/jrpg/jrpg.c
@@ -1088,17 +1113,9 @@ Then the camera, the world, and the panel on top of it:
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
```
This game passes an iterator to `akgl_game_update` rather than `NULL`:
```c excerpt=examples/jrpg/jrpg.c
akgl_Iterator opflags = {
.flags = AKGL_ITERATOR_OP_UPDATE,
.layerid = 0
};
```
`AKGL_ITERATOR_OP_UPDATE` asks for the update pass. [Chapter 7](07-the-game-and-the-frame.md)
covers the other flags.
This game passes the `opflags` declared at the top of `frame()` to `akgl_game_update` rather
than the `NULL` chapter 20 passes. `AKGL_ITERATOR_OP_UPDATE` asks for the update pass;
[Chapter 7](07-the-game-and-the-frame.md) covers the other flags.
### Teardown
@@ -1154,6 +1171,77 @@ where the player finished:
jrpg: 320 frames, player at (280, 130)
```
### Writing the scripted run
Neither the demo nor that summary line is part of the game — both exist so the program can be
checked without a person at the keyboard, and both are worth having for exactly that reason.
The script is a table of frame numbers and keys:
```c excerpt=examples/jrpg/jrpg.h
typedef struct {
long frame; /**< Frame number this step fires on. */
SDL_Keycode key; /**< Key to synthesize. */
bool down; /**< True for a press, false for a release. */
} jrpg_ScriptStep;
```
```c excerpt=examples/jrpg/jrpg.c
static const jrpg_ScriptStep JRPG_DEMO_SCRIPT[] = {
{ 10, SDLK_RIGHT, true }, /* the per-facing walk animation */
{ 70, SDLK_RIGHT, false },
{ 75, SDLK_UP, true }, /* up the map, past the buildings */
{ 205, SDLK_UP, false },
{ 215, SDLK_SPACE, true }, /* the elder is in range: open the box */
{ 216, SDLK_SPACE, false },
{ 225, SDLK_LEFT, true }, /* frozen: AKGL_ERR_LOGICINTERRUPT eats this */
{ 245, SDLK_LEFT, false },
{ 255, SDLK_SPACE, true }, /* dismiss */
{ 256, SDLK_SPACE, false },
{ 265, SDLK_DOWN, true }, /* and walk away */
{ 285, SDLK_DOWN, false }
};
```
Playing it back is building an `SDL_Event` and handing it to the same function the real event
loop uses:
```c excerpt=examples/jrpg/jrpg.c
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
if ( JRPG_DEMO_SCRIPT[i].down ) {
event.type = SDL_EVENT_KEY_DOWN;
} else {
event.type = SDL_EVENT_KEY_UP;
}
event.key.which = 0;
event.key.key = JRPG_DEMO_SCRIPT[i].key;
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
```
**Go through `akgl_controller_handle_event`, not straight to the handlers.** A script that
calls the handlers directly still passes when the control map matches nothing at all, which
is the failure it most needs to catch.
Two more things make a headless run reproducible. Drive the clock rather than sleeping on it,
so a simulated second does not cost a real one:
```c excerpt=examples/jrpg/jrpg.c
akgl_physics->gravity_time = SDL_GetTicksNS() - JRPG_FIXED_STEP_NS;
```
```c excerpt=examples/jrpg/jrpg.c
#define JRPG_FIXED_STEP_NS (AKGL_TIME_ONESEC_NS / 60)
```
And print the position while the player still exists — before teardown releases the actor
pool:
```c excerpt=examples/jrpg/jrpg.c
printf("jrpg: %ld frames, player at (%.0f, %.0f)\n", frameno, player->x, player->y);
```
The frame number is the loop's own counter, passed down. libakgl does not keep one.
## Where to look next
- [Chapter 20](20-tutorial-sidescroller.md) — the same shape of program with gravity, a jump

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

@@ -0,0 +1,396 @@
# 22. User interfaces
![The UI demo's play screen: a LIVES label top-left, a SCORE label top-right, and a dialog panel across the bottom, over a checkerboard standing in for a game world](images/uidemo.png)
Every game eventually wants a dialog box, a score counter, or a menu — and hand-rolling
them out of rectangles and `akgl_text_rendertextat` is tolerable exactly once.
[Chapter 21](21-tutorial-jrpg.md) does it once, on purpose, and its 125-line
`textbox.c` is still the right call for one panel with no ambitions. This chapter is for
everything past that point: HUDs, menus, options screens, and the layout arithmetic that
makes them miserable to maintain by hand.
The layout engine is [clay](https://github.com/nicbarker/clay), vendored under
`deps/clay` and compiled into `libakgl.so`. Per this manual's rule, clay's own API is
documented by clay — its README is thorough — and this chapter covers what libakgl adds
or constrains. The split:
| clay owns | libakgl owns |
|---|---|
| The layout algorithm and the `CLAY()` declaration DSL | The arena clay allocates from — static storage, no `malloc`, sized by `AKGL_UI_ARENA_BYTES` |
| Sizing, padding, floating elements, scroll containers | Text measurement, through SDL_ttf and the font registry |
| The render command list each frame produces | Drawing those commands, through the render backend |
| Hover and pointer-over queries | Feeding it the mouse, and telling *you* which events the UI consumed |
**There are two ways in, and they compose.** The widget helpers — `akgl_ui_dialog`,
`akgl_ui_label`, `akgl_ui_menu` — cover the common cases in one call each, and a simple
game never touches a `CLAY()` macro. When a screen outgrows them, you write clay's
declarative blocks yourself between the same two frame calls, with the whole DSL
available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does
both: its title menu and HUD are widgets, its options screen is raw `CLAY()`.
**What this chapter assumes.** A program that already brings libakgl up — the
`akgl_game_init` startup order from [Chapter 7](07-the-game-and-the-frame.md) (or
[Chapter 3](03-getting-started.md)'s smallest window), plus one loaded font from
[Chapter 17](17-text-and-fonts.md). None of that is restated here, and the UI adds only
three calls to it, shown below. When something in *your* bring-up fights you, the demo's
`startup()` in [`examples/uidemo/uidemo.c`](../examples/uidemo/uidemo.c) is the complete
working sequence to diff against — including the `main()` shape, which the runnable
snippets in this manual deliberately hide.
Like collision, the subsystem is optional at runtime rather than at build time: it is
always compiled in, costs static storage until `akgl_ui_init` runs, and a game that
never calls that pays nothing else.
**One warning before any code.** libakgl ships clay inside `libakgl.so` and exports its
symbols, because the `CLAY()` macros in *your* translation units expand to calls into
them. Do not define `CLAY_IMPLEMENTATION` anywhere and do not link a second copy of clay
— two definitions of the same symbols, and the loader picks one silently.
## Bring it up, lay something out
`akgl_ui_init(width, height)` takes the layout size as parameters — deliberately not
read from the camera or the window, so a headless program can bring the UI up with no
renderer at all. It bounds clay to the `AKGL_UI_*` ceilings, checks the arena fits them,
and refuses **with both byte counts in the message** when it does not: a raised ceiling
without a raised arena is a loud startup failure, never a corruption at frame forty
thousand.
After that, a frame of UI is a bracket with declarations inside:
```c run=akglapp
PASS(errctx, akgl_ui_init(320, 240));
PASS(errctx, akgl_ui_frame_begin());
CLAY({
.id = CLAY_ID("panel"),
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(120),
.height = CLAY_SIZING_FIXED(40)
}
},
.backgroundColor = { 24, 20, 37, 255 }
}) {}
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
PASS(errctx, akgl_ui_shutdown());
printf("one panel, laid out and drawn\n");
```
```output
one panel, laid out and drawn
```
`frame_begin` starts the clay layout and feeds it the pointer state the event handler
has been accumulating; `frame_end` computes the layout and draws every render command it
produces through the backend — fills and rounded fills, borders, clip rectangles, text,
and sprites. Everything lands in screen coordinates on top of whatever is already on the
target.
Text needs one more step at startup: fonts. clay names a font by a `uint16_t` fontId;
libakgl names one by a registry key ([Chapter 17](17-text-and-fonts.md)). The bridge is
`akgl_ui_font_register`, and the demo's whole font story is three lines:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE));
PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT));
PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid));
```
The first font registered gets id 0, which is what the widgets' default style uses — so
a one-font game never mentions a fontId again. Two things worth knowing before they
surprise you:
- **`Clay_TextElementConfig.fontSize` is ignored.** A libakgl font bakes its size in at
load ([Chapter 17](17-text-and-fonts.md) explains why); the size text renders at is
the size the font behind its id was loaded at. One face at two sizes is two loads, two
registrations, two ids.
- The table stores the *name* and resolves it per use, so `akgl_text_unloadfont` on a
registered font makes the next frame fail loudly with the name in the message — not
dangle.
## The frame contract
Where the bracket goes in a real frame, from the demo — compare the JRPG's `frame()`,
which calls `akgl_game_update` where this program paints a checkerboard:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
if ( state == UIDEMO_STATE_PLAY ) {
PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT));
score += 1;
}
PASS(errctx, akgl_ui_frame_begin());
switch ( state ) {
case UIDEMO_STATE_TITLE:
PASS(errctx, declare_title());
break;
case UIDEMO_STATE_OPTIONS:
PASS(errctx, declare_options());
break;
case UIDEMO_STATE_PLAY:
PASS(errctx, declare_play());
break;
default:
break;
}
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
```
The UI draws after the world because it is declared after the world — the overlay slot
between `akgl_game_update` and the backend's `frame_end` is exactly where the JRPG drew
its hand-rolled text box, and nothing about that slot changed.
Events go through the UI *first*. `akgl_ui_handle_event` takes every event
unconditionally — the same pass-everything contract as `akgl_controller_handle_event` —
and reports back whether the UI claimed it, so a click on a menu never leaks through and
also fires a game control:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
if ( consumed ) {
SUCCEED_RETURN(errctx);
}
switch ( state ) {
case UIDEMO_STATE_TITLE:
PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed));
break;
```
Three rules govern what gets consumed, and each is a decision worth stating:
- **Presses, releases and the wheel are consumed when the pointer is over any UI
element.** Motion and resizes never are — the game may care where the mouse is, and
certainly cares about its window.
- **The hit test runs against the layout the previous frame declared**, because this
frame's does not exist while events are being polled. clay retains the last tree for
exactly this purpose; one frame of staleness is the standard model's accepted cost,
and before any frame has been laid out, nothing is over anything.
- **Keyboard events are never consumed by the UI itself.** Which menu hears the arrow
keys is something the application declares — the `switch` above *is* the focus model —
not something a pointer position implies.
## A dialog in one call — and what it replaces
The play screen's declarations, whole:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score));
PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives));
PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
if ( dialog_open ) {
PASS(errctx, akgl_ui_dialog("dialog",
"This panel is one call. Space dismisses it; "
"compare examples/jrpg/textbox.c.",
NULL));
}
```
Note what is absent: no `visible` flag, no draw call, no geometry. The dialog is open
because this frame declares it — a declarative frame *is* the flag. The text buffers are
`static` because clay borrows the pointer until `frame_end` rather than copying; format
your score into storage that outlives the bracket.
Now the same panel the way [Chapter 21](21-tutorial-jrpg.md) builds it, which is the
comparison this chapter owes you. The hand-rolled version keeps a flag and a copy of the
string, and its draw function does the geometry itself:
```c excerpt=examples/jrpg/textbox.c
panel.x = TEXTBOX_MARGIN;
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
panel.h = TEXTBOX_HEIGHT;
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
PASS(errctx,
akgl_text_rendertextat(
font,
textbox_text,
TEXTBOX_INK,
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
(int)(panel.x + TEXTBOX_PADDING),
(int)(panel.y + TEXTBOX_PADDING)
));
```
The widget's default style is **deliberately that panel's palette** — near-black fill,
parchment edge and ink, 8 pixels of padding — so the two produce the same picture, and
the trade is visible with nothing hidden in a theme:
- `textbox.c` is 125 lines you own completely. It costs no new concepts, no arena, no
frame bracket; its geometry is four assignments you can read. For one panel, that is a
perfectly good deal — which is why chapter 21 still teaches it and its game still
ships it.
- `akgl_ui_dialog` is one line that costs you the subsystem: `akgl_ui_init`, a
registered font, and the bracket in your frame. The payment buys every *next* piece of
interface — the second panel is also one line, the score label is one line, the menu
is a struct — and the layout arithmetic, wrapping, and stacking are clay's problem
from then on.
Neither is deprecated. The library's own position: build one panel by hand; build an
interface on the subsystem.
## Menus: one selection, three devices
A menu is caller-owned state — a struct you keep, the way `textbox.c` keeps its statics.
There is no heap pool behind it because it owns no texture, no font, and no registry
entry:
```c excerpt=examples/uidemo/uidemo.c
static akgl_UiMenu title_menu = {
.id = "title",
.items = { "Start", "Options", "Quit" },
.count = 3,
};
```
Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and
they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the
arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad
South — exactly `SDLK_UP`, `SDLK_DOWN` and `SDLK_RETURN` against `SDL_Event.key.key`,
and `SDL_GAMEPAD_BUTTON_DPAD_UP`, `_DPAD_DOWN` and `_SOUTH` against
`SDL_Event.gbutton.button`. Keycodes, not scancodes — which only matters when you
*synthesize* events, as the demo's `--demo` script does: a hand-built key event must
fill in `key.key`, or it matches nothing and the menu silently ignores it. The mouse
selects by *moving onto* a row and activates by clicking one, during the declaration
itself. A stationary pointer claims nothing — parking the mouse over the
menu must not pin the selection against the keyboard, which would otherwise fight it
sixty times a second and lose.
`activated` latches until you clear it, so the check lives wherever reading it is
convenient — the demo reads it after the frame closes, which catches both input routes:
```c excerpt=examples/uidemo/uidemo.c
if ( title_menu.activated ) {
title_menu.activated = false;
switch ( title_menu.selected ) {
case 0:
state = UIDEMO_STATE_PLAY;
score = 0;
dialog_open = false;
break;
case 1:
state = UIDEMO_STATE_OPTIONS;
break;
case 2:
default:
running = false;
break;
}
}
```
## Writing layout directly with CLAY()
The options screen uses no widgets, and exists to show that the widgets are a
convenience rather than a boundary. One row of it carries every idea — hover styling
computed *inside* the declaration, and a click paired with a press edge the application
tracks itself:
```c excerpt=examples/uidemo/uidemo.c
CLAY({
.id = CLAY_ID("options-music"),
.layout = { .padding = { 8, 8, 4, 4 } },
.backgroundColor = Clay_Hovered()
? (Clay_Color){ 64, 58, 88, 255 }
: (Clay_Color){ 0, 0, 0, 0 }
}) {
if ( Clay_Hovered() && clicked ) {
opt_music = !opt_music;
}
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }),
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
}
```
`clicked` is one application-owned `bool`: set when a left press arrives in the event
loop — *before* `akgl_ui_handle_event` can consume it, because a click on a UI row is
always consumed — and cleared at the end of the frame. The widgets keep an equivalent
edge internally; a raw screen keeps its own. Everything else — the sizing model, scroll
containers, floating attach points, aspect ratios — is clay's DSL, and clay's README
documents it far better than a restatement here would.
## Images and styles
An image on a UI element is a sprite: set `.image.imageData` in a `CLAY()` declaration
to an `akgl_Sprite *` ([Chapter 10](10-spritesheets-and-sprites.md)) and its first frame
is drawn stretched to the element's box. Two current limits, both deliberate scope
rather than accident: clay's image tint colour and `CUSTOM` render commands are skipped
by the executor, and per-corner radii collapse to the top-left value — the widgets only
produce uniform corners. The mouse cursor stays the operating system's; a game that
wants a themed cursor draws a floating image element at the pointer, which makes a good
exercise.
A style is one struct shared by all three widgets — colours, padding, corner radius,
fontId — and `NULL` means the textbox palette described above. There is no per-field
defaulting: a transparent fill and a zero radius are things a style legitimately says,
so copy the default and change what you mean to change:
```c wrap=akglbody
akgl_UiStyle alert = {
.fill = { 120, 24, 24, 235 },
.edge = { 240, 236, 214, 255 },
.ink = { 240, 236, 214, 255 },
.padding = 8.0f,
.corner_radius = 6.0f,
.fontid = 0
};
PASS(errctx, akgl_ui_dialog("alert", "The reactor is on fire.", &alert));
```
## When things go wrong
Everything here reports through the ordinary error protocol
([Chapter 4](04-errors.md)) under one status, `AKGL_ERR_UI`, and the message says which
refusal it was. Lifecycle misuse fails at the call that misused it:
```text
/home/andrew/source/libakgl/src/ui.c:ui_widget_ready:850: 262 (UI Error) : Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
/home/andrew/source/libakgl/src/ui.c:akgl_ui_dialog:866
/home/andrew/source/libakgl/main.c:main:13
/home/andrew/source/libakgl/main.c:main:16: Unhandled Error 262 (UI Error): Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
```
Errors *inside the layout* cannot fail at the call that caused them, because clay
reports through a void callback with libakgl nowhere on the stack. So they are logged as
they happen, stashed, and raised from `akgl_ui_frame_end` — the earliest point the
protocol can carry them — with the first message and a count of the rest. A frame that
fails is one bad frame: the next `akgl_ui_frame_begin` is legal, and the executor clears
any clip rectangle on its way out so a failed UI cannot leave the next frame's *world*
clipped.
The ceilings — elements, measured words, arena bytes, fonts, text-run length, menu
entries — are all in [Chapter 23](23-appendix-limits.md), every one of them overridable,
and every refusal names the number to raise.
## Build it and run it
The demo builds with the library:
```sh norun
cmake -S . -B build
cmake --build build -j$(nproc)
./build/examples/uidemo/uidemo
```
Arrow keys, Return, the D-pad and the mouse all drive the title menu; Space opens the
dialog on the play screen; Escape backs out. `ctest -R example_uidemo` runs the same
program headless through a scripted tour of every screen.
## Where to look next
- [clay's README](https://github.com/nicbarker/clay) — the layout DSL itself: sizing,
floating elements, scroll containers, and the debug tools.
- [Chapter 16](16-input.md) — the control maps that own the keyboard and gamepad once
the UI has declined an event.
- [Chapter 17](17-text-and-fonts.md) — fonts, the registry, and what text costs per
frame; a UI panel of text pays the same immediate-mode price.
- [Chapter 21](21-tutorial-jrpg.md) — the hand-rolled text box this chapter keeps
comparing against, in the game that earns it.
- [Chapter 23](23-appendix-limits.md) — every `AKGL_UI_*` limit and the `ui.h` status
cross-reference.

View File

@@ -1,4 +1,4 @@
# 22. Appendix: limits, statuses and properties
# 23. Appendix: limits, statuses and properties
Three reference tables that no single chapter owns: which function raises which status,
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

View File

@@ -4,6 +4,13 @@ This file documents the harness that keeps `docs/` honest. It is not a chapter
a reader learning libakgl never needs it — and it is checked by the same harness
it describes, so the file that publishes the convention is held to it.
**The harness checks that every listing is true. It cannot check that a chapter
teaches.** For that, the tutorials get a second test: the chapter is handed to a
subagent on a weaker model with the library, the headers and the art but *not*
the finished program, and that reader has to build and run the game. See
`AGENTS.md`, "Testing a Tutorial". Four read-only reviews of chapters 20 and 21
missed three defects the first build-and-run found in minutes.
## Documentation examples
**Every fenced block in `docs/*.md` is compiled, linked, run, or matched against

View File

@@ -34,7 +34,8 @@ per-function reference, build the Doxygen output with `doxygen Doxyfile`.
| **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings |
| **[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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,4 +1,4 @@
# The two tutorial games.
# The tutorial games and demos.
#
# Each one is a complete, running program that the matching chapter quotes with
# `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()

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
# The UI demo, quoted by the UI chapter's `c excerpt=` blocks.
#
# A real target built by `all`, on the same terms as the tutorial games: a
# chapter whose program does not compile is the failure the documentation
# harness exists to stop.
add_executable(uidemo
uidemo.c
)
target_link_libraries(uidemo
PRIVATE akstdlib::akstdlib akerror::akerror akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer
jansson::jansson -lm)
target_include_directories(uidemo PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_compile_options(uidemo PRIVATE ${AKGL_WARNING_FLAGS})
# The demo needs no art -- its "world" is akgl_draw_background -- but it does
# need one font, and it uses the same test-asset face the JRPG does, compiled
# in as an absolute path for the same run-from-anywhere reason.
get_filename_component(UIDEMO_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
target_compile_definitions(uidemo PRIVATE
UIDEMO_FONT_FILE="${UIDEMO_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
)
if(AKGL_VENDORED_DEPENDENCIES)
set_target_properties(uidemo PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
endif()
# The headless smoke run. `--demo` tours every screen through the real event
# chain -- a mouse click on the title menu (the consumed path), keyboard into
# and out of the raw-CLAY options screen, the play screen's dialog opened and
# dismissed, and Quit confirmed from the menu -- rather than just proving that
# main() returns. There is no physics here, so nothing needs a driven clock;
# `--frames` is a backstop above the script's last step, not the exit path.
add_test(NAME example_uidemo COMMAND uidemo --frames 240 --demo)
set_tests_properties(example_uidemo PROPERTIES TIMEOUT 120)
# set_property, not set_tests_properties: the latter parses its PROPERTIES
# arguments as name/value *pairs*, so a semicolon-separated value is split and
# every element after the first is consumed as a bogus property name. See the
# same note in the other examples' CMakeLists.
set_property(TEST example_uidemo PROPERTY ENVIRONMENT
"SDL_VIDEODRIVER=dummy"
"SDL_RENDER_DRIVER=software"
"SDL_AUDIODRIVER=dummy"
)
# LD_LIBRARY_PATH for the vendored satellite libraries. Only needed when they
# were vendored; an installed build resolves them normally.
if(AKGL_VENDORED_DEPENDENCIES)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
set(UIDEMO_TEST_ENV_MOD "")
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
list(APPEND UIDEMO_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
endforeach()
set_property(TEST example_uidemo PROPERTY ENVIRONMENT_MODIFICATION ${UIDEMO_TEST_ENV_MOD})
else()
string(REPLACE ";" ":" UIDEMO_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_property(TEST example_uidemo APPEND PROPERTY ENVIRONMENT
"LD_LIBRARY_PATH=${UIDEMO_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}")
endif()
endif()

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

@@ -0,0 +1,609 @@
/**
* @file uidemo.c
* @brief The UI demo: a title menu, an options screen, and a HUD, three ways.
*
* The chapter on the UI subsystem quotes this program rather than restating
* it. Run it with no arguments for a window:
*
* ./examples/uidemo/uidemo
*
* The title menu answers to the arrow keys, Return, the D-pad, and the mouse.
* Start opens a stand-in play screen -- a checkerboard where a game would be
* -- with a score counter ticking in a HUD label; Space opens and closes a
* dialog panel there, and Escape backs out. Options is a screen written in
* raw CLAY() declarations, because the widgets are a convenience, not a
* boundary. `--frames N` bounds the run and `--demo` drives the whole tour
* from a script, which is what makes this runnable as a headless smoke test.
*
* What is deliberately absent: a tilemap, actors, physics. The world here is
* one draw call, so everything left is the subject -- what a UI costs and
* where it goes in a frame.
*/
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/draw.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>
#include <akgl/ui.h>
#include "uidemo.h"
/** @brief Milliseconds one 60 Hz frame is allowed to take, for the interactive loop. */
#define UIDEMO_FRAME_BUDGET_MS 16
/*
* The scripted tour `--demo` drives. Every route through the demo, by every
* input device it supports: the mouse clicks the title menu open (dead centre
* lands on the middle row by symmetry -- the menu is centred and Options is
* its middle entry), the keyboard walks back out, into the play screen,
* through the dialog, and finally down to Quit.
*
* Frame Event Key / position What it proves
*/
static const uidemo_ScriptStep UIDEMO_SCRIPT[] = {
{ 5, SDL_EVENT_MOUSE_MOTION, 0, 320.0f, 240.0f }, /* hover the menu */
{ 10, SDL_EVENT_MOUSE_BUTTON_DOWN, 0, 320.0f, 240.0f }, /* consumed click */
{ 11, SDL_EVENT_MOUSE_BUTTON_UP, 0, 320.0f, 240.0f }, /* -> Options screen */
{ 40, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */
{ 50, SDL_EVENT_KEY_DOWN, SDLK_UP, 0.0f, 0.0f }, /* select Start */
{ 60, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f }, /* -> play screen */
{ 80, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* open the dialog */
{ 120, SDL_EVENT_KEY_DOWN, SDLK_SPACE, 0.0f, 0.0f }, /* close it again */
{ 140, SDL_EVENT_KEY_DOWN, SDLK_ESCAPE, 0.0f, 0.0f }, /* back to the title */
{ 150, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Options */
{ 160, SDL_EVENT_KEY_DOWN, SDLK_DOWN, 0.0f, 0.0f }, /* down to Quit */
{ 170, SDL_EVENT_KEY_DOWN, SDLK_RETURN, 0.0f, 0.0f } /* and out */
};
#define UIDEMO_SCRIPT_STEPS (sizeof(UIDEMO_SCRIPT) / sizeof(UIDEMO_SCRIPT[0]))
static long frame_limit = 0;
/** @brief Where `--screenshot` writes, and on which frame. NULL means never. */
static char *shotpath = NULL;
static long shotframe = 0;
static bool demo = false;
static bool running = true;
static int exitstatus = 0;
static bool lowfps_warned = false;
/** @brief Which screen the frame declares. */
static uidemo_State state = UIDEMO_STATE_TITLE;
/**
* @brief The title menu. Caller-owned state, zero machinery: this struct and
* the akgl_ui_menu call each frame are the whole main menu.
*/
static akgl_UiMenu title_menu = {
.id = "title",
.items = { "Start", "Options", "Quit" },
.count = 3,
};
/** @brief The options screen's two settings. What the toggles flip. */
static bool opt_music = true;
static bool opt_sound = true;
/** @brief The play screen's HUD numbers. The score ticks so the label visibly updates. */
static int score = 0;
static int lives = 3;
/** @brief Whether the play screen's dialog is up. Space flips it. */
static bool dialog_open = false;
/**
* @brief A left click arrived this frame, wherever it landed.
*
* The raw CLAY() options screen needs a press edge to pair with
* Clay_Hovered(), and the application is the right owner of it: it sees
* every event before the UI does. Set in route_event, cleared at the end of
* each frame. The widget helpers keep their own edge internally -- this one
* exists precisely because the options screen does not use them.
*/
static bool clicked = false;
/** @brief Replacement `akgl_game.lowfpsfunc`: say it once, not sixty times a second. */
static void lowfps_quiet(void)
{
if ( lowfps_warned == false ) {
lowfps_warned = true;
SDL_Log("Frame rate is under 30 and this demo does nothing about it");
}
}
/**
* @brief Read `--frames N`, `--demo` and the screenshot flags off the command line.
*
* @param argc Argument count, from `main`.
* @param argv Argument vector, from `main`. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p argv is `NULL`.
* @throws AKERR_VALUE If a flag needing an argument is last, or its argument
* is not a number.
*/
static akerr_ErrorContext *parse_args(int argc, char *argv[])
{
PREPARE_ERROR(errctx);
int i = 0;
int number = 0;
FAIL_ZERO_RETURN(errctx, argv, AKERR_NULLPOINTER, "argv");
for ( i = 1; i < argc; i++ ) {
if ( strcmp(argv[i], "--demo") == 0 ) {
demo = true;
} else if ( strcmp(argv[i], "--frames") == 0 ) {
if ( (i + 1) >= argc ) {
FAIL_RETURN(errctx, AKERR_VALUE, "--frames needs a frame count");
}
i += 1;
PASS(errctx, aksl_atoi(argv[i], &number));
frame_limit = number;
} else if ( strcmp(argv[i], "--screenshot") == 0 ) {
if ( (i + 1) >= argc ) {
FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot needs a path");
}
i += 1;
shotpath = argv[i];
} else if ( strcmp(argv[i], "--screenshot-frame") == 0 ) {
if ( (i + 1) >= argc ) {
FAIL_RETURN(errctx, AKERR_VALUE, "--screenshot-frame needs a number");
}
i += 1;
PASS(errctx, aksl_atoi(argv[i], &number));
shotframe = number;
} else {
FAIL_RETURN(errctx, AKERR_VALUE,
"usage: uidemo [--frames N] [--demo]"
" [--screenshot PATH] [--screenshot-frame N]");
}
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Bring the library and the UI subsystem up.
*
* The UI half is three calls: load a font, akgl_ui_init at the screen size,
* and register the font for a clay fontId. The id comes back 0 because it is
* the first registered, which is what the widgets' default style uses -- so
* nothing here ever mentions a fontId again.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *startup(void)
{
PREPARE_ERROR(errctx);
uint16_t fontid = 0;
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name), "libakgl UI demo", sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version), "1.0.0", sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri), "net.aklabs.libakgl.examples.uidemo", sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
akgl_game.lowfpsfunc = &lowfps_quiet;
PASS(errctx, akgl_set_property("game.screenwidth", UIDEMO_SCREEN_WIDTH));
PASS(errctx, akgl_set_property("game.screenheight", UIDEMO_SCREEN_HEIGHT));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE));
PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT));
PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid));
SUCCEED_RETURN(errctx);
}
/**
* @brief Route one event: the UI first, then whatever the current screen wants.
*
* This is the call-order contract from akgl/ui.h in the flesh. The UI gets
* first refusal; a consumed event goes no further, which is what keeps a
* click on a menu from also being a click in the game. Only then does the
* current screen read the keyboard -- and *that* routing is the whole focus
* model: the title menu hears keys because this function sends them there
* while the title screen is up, not because anything owns "focus".
*
* @param event The event to route. Required.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *route_event(SDL_Event *event)
{
PREPARE_ERROR(errctx);
bool consumed = false;
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
if ( event->type == SDL_EVENT_QUIT ) {
running = false;
SUCCEED_RETURN(errctx);
}
// The options screen's press edge, recorded before the UI can consume
// the event -- a click on a toggle row is *always* consumed (the row is
// UI), so waiting until afterwards would record nothing.
if ( (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN)
&& (event->button.button == SDL_BUTTON_LEFT) ) {
clicked = true;
}
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
if ( consumed ) {
SUCCEED_RETURN(errctx);
}
switch ( state ) {
case UIDEMO_STATE_TITLE:
PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed));
break;
case UIDEMO_STATE_OPTIONS:
if ( (event->type == SDL_EVENT_KEY_DOWN) && (event->key.key == SDLK_ESCAPE) ) {
state = UIDEMO_STATE_TITLE;
}
break;
case UIDEMO_STATE_PLAY:
if ( event->type == SDL_EVENT_KEY_DOWN ) {
if ( event->key.key == SDLK_ESCAPE ) {
state = UIDEMO_STATE_TITLE;
} else if ( event->key.key == SDLK_SPACE ) {
dialog_open = !dialog_open;
}
}
break;
default:
break;
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Feed the scripted events due on this frame through the real routing.
*
* Synthesized SDL_Events, not direct state changes: the demo run proves the
* event chain -- consumed and not, menu and screen -- because it travels it.
*
* @param frameno The frame about to be drawn.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *demo_step(long frameno)
{
PREPARE_ERROR(errctx);
SDL_Event event;
size_t i = 0;
for ( i = 0; i < UIDEMO_SCRIPT_STEPS; i++ ) {
if ( UIDEMO_SCRIPT[i].frame != frameno ) {
continue;
}
PASS(errctx, aksl_memset((void *)&event, 0x00, sizeof(event)));
event.type = UIDEMO_SCRIPT[i].type;
switch ( UIDEMO_SCRIPT[i].type ) {
case SDL_EVENT_KEY_DOWN:
event.key.key = UIDEMO_SCRIPT[i].key;
break;
case SDL_EVENT_MOUSE_MOTION:
event.motion.x = UIDEMO_SCRIPT[i].x;
event.motion.y = UIDEMO_SCRIPT[i].y;
break;
case SDL_EVENT_MOUSE_BUTTON_DOWN:
case SDL_EVENT_MOUSE_BUTTON_UP:
event.button.button = SDL_BUTTON_LEFT;
event.button.x = UIDEMO_SCRIPT[i].x;
event.button.y = UIDEMO_SCRIPT[i].y;
break;
default:
break;
}
PASS(errctx, route_event(&event));
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Declare the title screen: one widget call.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *declare_title(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_ui_label("heading", "libakgl UI demo", AKGL_UI_ANCHOR_TOP_LEFT, NULL));
PASS(errctx, akgl_ui_menu(&title_menu));
SUCCEED_RETURN(errctx);
}
/**
* @brief Declare the options screen in raw CLAY(), no widgets anywhere.
*
* This screen exists to show the other way in: a centred panel, a column of
* rows, hover highlighting via Clay_Hovered() *inside* the declaration, and
* clicks paired with the application's own press edge. Everything the widgets
* do is done with these same pieces; a screen the widgets cannot express is
* written like this rather than waiting for a widget to exist.
*
* The row labels live in statics because clay borrows the pointer until
* frame_end -- a local buffer here would be dangling by the time the text is
* drawn.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *declare_options(void)
{
PREPARE_ERROR(errctx);
static char musicrow[32];
static char soundrow[32];
int count = 0;
PASS(errctx, aksl_snprintf(&count, musicrow, sizeof(musicrow), "Music: %s", opt_music ? "ON" : "OFF"));
PASS(errctx, aksl_snprintf(&count, soundrow, sizeof(soundrow), "Sound: %s", opt_sound ? "ON" : "OFF"));
CLAY({
.id = CLAY_ID("options"),
.layout = {
.padding = { 16, 16, 16, 16 },
.childGap = 8,
.layoutDirection = CLAY_TOP_TO_BOTTOM
},
.backgroundColor = { 24, 20, 37, 235 },
.border = { .color = { 240, 236, 214, 255 }, .width = { 1, 1, 1, 1, 0 } },
.floating = {
.attachPoints = {
.element = CLAY_ATTACH_POINT_CENTER_CENTER,
.parent = CLAY_ATTACH_POINT_CENTER_CENTER
},
.attachTo = CLAY_ATTACH_TO_ROOT
}
}) {
CLAY_TEXT(CLAY_STRING("OPTIONS"), CLAY_TEXT_CONFIG({
.textColor = { 240, 236, 214, 255 },
.fontId = 0
}));
CLAY({
.id = CLAY_ID("options-music"),
.layout = { .padding = { 8, 8, 4, 4 } },
.backgroundColor = Clay_Hovered()
? (Clay_Color){ 64, 58, 88, 255 }
: (Clay_Color){ 0, 0, 0, 0 }
}) {
if ( Clay_Hovered() && clicked ) {
opt_music = !opt_music;
}
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }),
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
}
CLAY({
.id = CLAY_ID("options-sound"),
.layout = { .padding = { 8, 8, 4, 4 } },
.backgroundColor = Clay_Hovered()
? (Clay_Color){ 64, 58, 88, 255 }
: (Clay_Color){ 0, 0, 0, 0 }
}) {
if ( Clay_Hovered() && clicked ) {
opt_sound = !opt_sound;
}
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(soundrow), .chars = soundrow }),
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
}
CLAY({
.id = CLAY_ID("options-back"),
.layout = { .padding = { 8, 8, 4, 4 } },
.backgroundColor = Clay_Hovered()
? (Clay_Color){ 64, 58, 88, 255 }
: (Clay_Color){ 0, 0, 0, 0 }
}) {
if ( Clay_Hovered() && clicked ) {
state = UIDEMO_STATE_TITLE;
}
CLAY_TEXT(CLAY_STRING("Back (Esc)"), CLAY_TEXT_CONFIG({
.textColor = { 240, 236, 214, 255 },
.fontId = 0
}));
}
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Declare the play screen's HUD: two labels, and the dialog while it is up.
*
* The score buffer is static for the same borrowed-until-frame_end reason as
* the options rows. Note what is *not* here: no visible flag, no draw call,
* no geometry. The dialog is open because this frame declares it.
*
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *declare_play(void)
{
PREPARE_ERROR(errctx);
static char scoretext[32];
static char livestext[32];
int count = 0;
PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score));
PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives));
PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
if ( dialog_open ) {
PASS(errctx, akgl_ui_dialog("dialog",
"This panel is one call. Space dismisses it; "
"compare examples/jrpg/textbox.c.",
NULL));
}
SUCCEED_RETURN(errctx);
}
/** @brief Read the render target back and write it out as a PNG, for the chapter figure. */
static akerr_ErrorContext *save_screenshot(char *path)
{
SDL_Surface *shot = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "path");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_RETURN(errctx, shot, AKGL_ERR_SDL, "SDL_RenderReadPixels: %s", SDL_GetError());
ATTEMPT {
FAIL_ZERO_BREAK(errctx, IMG_SavePNG(shot, path), AKGL_ERR_SDL,
"IMG_SavePNG(%s): %s", path, SDL_GetError());
} CLEANUP {
SDL_DestroySurface(shot);
} PROCESS(errctx) {
} FINISH(errctx, true);
SDL_Log("Wrote %s", path);
SUCCEED_RETURN(errctx);
}
/**
* @brief One frame: events, the world stand-in, the UI bracket, present.
*
* The shape to compare with the JRPG's frame(): where that program calls
* akgl_game_update between frame_start and frame_end, this one paints a
* checkerboard -- and the UI bracket sits in the same overlay slot the
* hand-rolled textbox draw did.
*
* @param frameno The frame number, for the demo script.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
static akerr_ErrorContext *frame(long frameno)
{
PREPARE_ERROR(errctx);
SDL_Event event;
while ( SDL_PollEvent(&event) ) {
PASS(errctx, route_event(&event));
}
if ( demo ) {
PASS(errctx, demo_step(frameno));
}
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
if ( state == UIDEMO_STATE_PLAY ) {
// Where a game would call akgl_game_update. The score ticking is the
// stand-in for play, so the HUD label visibly earns its redraw.
PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT));
score += 1;
}
PASS(errctx, akgl_ui_frame_begin());
switch ( state ) {
case UIDEMO_STATE_TITLE:
PASS(errctx, declare_title());
break;
case UIDEMO_STATE_OPTIONS:
PASS(errctx, declare_options());
break;
case UIDEMO_STATE_PLAY:
PASS(errctx, declare_play());
break;
default:
break;
}
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
if ( (shotpath != NULL) && (frameno == shotframe) ) {
PASS(errctx, save_screenshot(shotpath));
}
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
// The press edge lives exactly one frame: the declarations above have
// seen it, so the next frame starts clean.
clicked = false;
// Menu activation is read after the frame, so it catches both routes in:
// Return through akgl_ui_menu_handle_event before the declarations, and a
// click through the declaration itself.
if ( title_menu.activated ) {
title_menu.activated = false;
switch ( title_menu.selected ) {
case 0:
state = UIDEMO_STATE_PLAY;
score = 0;
dialog_open = false;
break;
case 1:
state = UIDEMO_STATE_OPTIONS;
break;
case 2:
default:
running = false;
break;
}
}
SUCCEED_RETURN(errctx);
}
/**
* @brief Give back what has to be given back, in the order that works.
*
* akgl_ui_shutdown first because it is the newest thing up, then the fonts
* **before** SDL_Quit -- the registry the fonts live in is an SDL property
* set, and SDL_Quit destroys it with them inside. The pools and the clay
* arena are static storage; there is nothing to free.
*/
static void teardown(void)
{
IGNORE(akgl_ui_shutdown());
IGNORE(akgl_text_unloadallfonts());
if ( akgl_window != NULL ) {
SDL_DestroyWindow(akgl_window);
akgl_window = NULL;
}
SDL_Quit();
}
int main(int argc, char *argv[])
{
PREPARE_ERROR(errctx);
long frameno = 0;
uint64_t started = 0;
uint64_t spent = 0;
ATTEMPT {
CATCH(errctx, parse_args(argc, argv));
CATCH(errctx, startup());
// The loop is the last thing in this ATTEMPT block on purpose: CATCH
// reports failure by `break`ing out of the loop, which falls straight
// into CLEANUP. See examples/jrpg/jrpg.c for the long form of this
// note.
while ( running ) {
started = SDL_GetTicks();
CATCH(errctx, frame(frameno));
frameno += 1;
if ( (frame_limit > 0) && (frameno >= frame_limit) ) {
running = false;
}
if ( demo == false ) {
spent = SDL_GetTicks() - started;
if ( spent < UIDEMO_FRAME_BUDGET_MS ) {
SDL_Delay((uint32_t)(UIDEMO_FRAME_BUDGET_MS - spent));
}
}
}
} CLEANUP {
teardown();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the UI demo could not finish");
exitstatus = 1;
} FINISH_NORETURN(errctx);
// Enough for the smoke run to be read rather than merely passed: the
// script quits from the title menu, so a run that finished its tour says
// so here.
printf("uidemo: %ld frames, score %d, music %s, sound %s\n",
frameno, score, opt_music ? "on" : "off", opt_sound ? "on" : "off");
return exitstatus;
}

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

@@ -0,0 +1,58 @@
/**
* @file uidemo.h
* @brief Shared constants and types for the UI demo.
*
* There is deliberately little here: the demo is one translation unit, and
* this header exists so the chapter can quote the constants and the script
* type without quoting the code around them.
*/
#ifndef _UIDEMO_H_
#define _UIDEMO_H_
#include <SDL3/SDL.h>
/** @brief Window and layout width, in pixels. */
#define UIDEMO_SCREEN_WIDTH "640"
/** @brief Window and layout height, in pixels. */
#define UIDEMO_SCREEN_HEIGHT "480"
/** @brief The same width as a number, for akgl_ui_init. */
#define UIDEMO_WIDTH 640
/** @brief The same height as a number. */
#define UIDEMO_HEIGHT 480
/** @brief Registry name the demo's one font is loaded under. */
#define UIDEMO_FONT_NAME "uidemo"
/** @brief Point size the font is loaded at. The size is baked into the handle. */
#define UIDEMO_FONT_SIZE 16
/**
* @brief Which screen the demo is on.
*
* Three states, three ways of building an interface: the title screen is the
* menu *widget*, the options screen is raw `CLAY()` layout, and the play
* screen is the label and dialog widgets over a moving world stand-in.
*/
typedef enum {
UIDEMO_STATE_TITLE, /**< The akgl_UiMenu title menu. */
UIDEMO_STATE_OPTIONS, /**< The hand-written CLAY() options panel. */
UIDEMO_STATE_PLAY /**< HUD labels and a dialog over the "game". */
} uidemo_State;
/**
* @brief One scripted input in a `--demo` run.
*
* Key steps carry a keycode; mouse steps carry a position. Every step is
* synthesized as a real SDL_Event and pushed through the same routing the
* interactive loop uses, so the demo exercises the event chain rather than
* poking state behind its back.
*/
typedef struct {
long frame; /**< The frame this step fires on. */
uint32_t type; /**< SDL_EVENT_KEY_DOWN, _MOUSE_MOTION, _MOUSE_BUTTON_DOWN or _MOUSE_BUTTON_UP. */
SDL_Keycode key; /**< For key steps. */
float x; /**< For mouse steps. */
float y; /**< For mouse steps. */
} uidemo_ScriptStep;
#endif // _UIDEMO_H_

View File

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

View File

@@ -76,11 +76,12 @@
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
#define AKGL_ERR_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
View File

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

View File

@@ -49,8 +49,20 @@ rm -f "$AKGL_MEMCHECK_LOG"/MemoryChecker.*.log
# The run itself is allowed to fail: a suite that valgrind makes slow enough to
# 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

View File

@@ -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);
}

View File

@@ -21,5 +21,6 @@ akerr_ErrorContext *akgl_error_init(void)
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_BEHAVIOR, "Behavior Error"));
PASS(errctx, akerr_register_status_name(AKGL_ERR_OWNER, AKGL_ERR_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);
}

1121
src/ui.c Normal file

File diff suppressed because it is too large Load Diff

21
src/ui_clay.c Normal file
View File

@@ -0,0 +1,21 @@
/**
* @file ui_clay.c
* @brief The one translation unit that compiles clay's implementation.
*
* clay is a single-header library: every other file that includes <clay.h>
* gets declarations only, and exactly one file in the whole program defines
* CLAY_IMPLEMENTATION before the include to get the definitions. This is that
* file, and it must stay the only one -- a second definition anywhere,
* including in a consuming game, is two copies of clay's globals and the
* loader picking between them silently. The warning in akgl/ui.h says so to
* consumers; this comment says so to us.
*
* Nothing else belongs here. libakgl's own UI code is src/ui.c, which
* includes <clay.h> like any other consumer. CMakeLists.txt compiles this
* file with -w on the same terms as deps/semver and deps/libccd: 99% of what
* this TU contains is vendored code, and a future clay bump must not be able
* to fail the build on a warning we do not own.
*/
#define CLAY_IMPLEMENTATION
#include <clay.h>

View File

@@ -284,6 +284,10 @@ akerr_ErrorContext *test_character_sprite_rebind_releases_displaced(void)
int main(void)
{
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;

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff