Files
libakgl/src/text.c
Tachikoma 7a95e50ea4 Make savegames, optional array elements, empty text and coverage work
Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27.

The savegame name tables carry no length prefix, so writer and reader have to
agree on a field width exactly. The writer used each object's own maximum name
length -- 512 for a spritesheet, a filename -- and the reader used
AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH
constants drive both sides now.

The failure turned out to be worse than "cannot be read back": a reader
stepping the wrong width does not run off anything, it finds a run of zeros
inside an entry, stops early, and reports success with silently wrong maps. A
test asserting only that the load succeeded passed against the broken reader.
So akgl_game_load checks it is at EOF once the tables are read, which turns a
width disagreement into AKERR_IO instead of a corruption. That is the assertion
the new roundtrip test -- the first with all four registries populated -- hangs
on.

akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS,
which is what the array index accessors report, so "this element is optional"
works for an array element and not only for an object member. The arm goes
above the one holding the memcpy: HANDLE_GROUP emits no break and every arm
falls into that body.

That test needed a second attempt. with_default returns *the context it was
given* when it does not handle the status, so TEST_EXPECT_OK -- which releases
whatever the statement returns -- double-released it against a CLEANUP block
that released it too, and a double-released context corrupts the failure rather
than reporting it. The first draft passed against the unfixed library.

akgl_text_rendertextat returns success without rasterizing for the empty
string, matching akgl_text_measure, which has always accepted it. The check
sits after the font and backend guards, so drawing nothing still refuses what
drawing something refuses. tests/text.c had this case written and unasserted
waiting for the two halves of the header to agree.

character_load_json_state_int_from_strings guards dest rather than testing
states twice. Not asserted: the function is static with one call site that
passes a real pointer, so the guard cannot fire, and reaching it from a test
would mean giving it external linkage purely for that.

Both gcovr invocations take the build tree as an explicit positional search
path. gcovr searches --root when given none, which is the source directory,
where build trees live; --object-directory does not narrow it. Verified by
building two instrumented trees with a source edit between them: the old
invocation fails with "Got function write_exact on multiple lines: 46, 48" and
exits 64, the new one exits 0 and the full coverage run passes with the stale
tree still present.

25/25 pass, reindent --check, check_api_surface and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 07:34:51 -04:00

194 lines
6.7 KiB
C

/**
* @file text.c
* @brief Implements the text subsystem.
*/
#include <akerror.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akgl/text.h>
#include <akgl/registry.h>
#include <akgl/game.h>
akerr_ErrorContext *akgl_text_loadfont(char *name, char *filepath, int size)
{
TTF_Font *font = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name");
FAIL_ZERO_RETURN(errctx, filepath, AKERR_NULLPOINTER, "Null filepath");
font = TTF_OpenFont(filepath, size);
FAIL_ZERO_RETURN(errctx, font, AKGL_ERR_SDL, "%s", SDL_GetError());
// Loading over an existing name used to abandon the font it displaced --
// there was nothing in the library that could close one. The old font goes
// back first now, and only once the new one has opened, so a failed load
// leaves the caller with the font they already had.
if ( SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL) != NULL ) {
PASS(errctx, akgl_text_unloadfont(name));
}
FAIL_ZERO_RETURN(
errctx,
SDL_SetPointerProperty(AKGL_REGISTRY_FONT, name, (void *)font),
AKERR_KEY,
"Unable to add font %p to registry as %s : %s",
(void *)font,
name,
SDL_GetError());
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_text_unloadfont(char *name)
{
TTF_Font *font = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "Null font name");
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, name, NULL);
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font named %s in the registry", name);
// Cleared before the close, so a font is never reachable through the
// registry after it has been handed back to SDL_ttf.
SDL_ClearProperty(AKGL_REGISTRY_FONT, name);
TTF_CloseFont(font);
SUCCEED_RETURN(errctx);
}
/**
* @brief SDL_EnumerateProperties callback: close one registered font.
*
* The properties set cannot be modified while it is being enumerated, so this
* only closes; akgl_text_unloadallfonts clears the whole set afterwards.
*/
static void unload_font_iterate(void *userdata, SDL_PropertiesID props, const char *name)
{
TTF_Font *font = NULL;
if ( name == NULL ) {
return;
}
font = (TTF_Font *)SDL_GetPointerProperty(props, name, NULL);
if ( font != NULL ) {
TTF_CloseFont(font);
}
}
akerr_ErrorContext *akgl_text_unloadallfonts(void)
{
PREPARE_ERROR(errctx);
if ( AKGL_REGISTRY_FONT == 0 ) {
SUCCEED_RETURN(errctx);
}
SDL_EnumerateProperties(AKGL_REGISTRY_FONT, &unload_font_iterate, NULL);
SDL_DestroyProperties(AKGL_REGISTRY_FONT);
AKGL_REGISTRY_FONT = 0;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_text_rendertextat(TTF_Font *font, char *text, SDL_Color color, int wraplength, int x, int y)
{
SDL_Surface *textsurf = NULL;
SDL_Texture *texture = NULL;
SDL_FRect dest;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, font, AKERR_NULLPOINTER, "NULL font");
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "NULL text string");
// Checked before anything is rasterized, and checked at all because a
// backend that has an SDL_Renderer but was never run through
// akgl_render_2d_bind has a NULL draw_texture -- which is exactly the state
// akgl_render_2d_init used to be the only escape from.
FAIL_ZERO_RETURN(errctx, akgl_renderer, AKERR_NULLPOINTER, "No renderer backend");
FAIL_ZERO_RETURN(errctx, akgl_renderer->sdl_renderer, AKERR_NULLPOINTER, "No valid SDL rendering backend");
FAIL_ZERO_RETURN(errctx, akgl_renderer->draw_texture, AKERR_NULLPOINTER, "Renderer backend has no draw_texture");
// Drawing nothing is not a failure. SDL_ttf returns NULL with "Text has
// zero width" from both rasterizers for "", so this used to report
// AKERR_NULLPOINTER for what a caller means as "draw an empty line" --
// while akgl_text_measure("") is documented as legal and returns 0 wide by
// one line high. Two halves of one header disagreeing about one string.
if ( text[0] == '\0' ) {
SUCCEED_RETURN(errctx);
}
if ( wraplength > 0 ) {
textsurf = TTF_RenderText_Blended_Wrapped(
font,
text,
0,
color,
wraplength);
} else {
textsurf = TTF_RenderText_Blended(
font,
text,
0,
color);
}
FAIL_ZERO_RETURN(errctx, textsurf, AKERR_NULLPOINTER, "%s", SDL_GetError());
// Both were destroyed only after the draw succeeded, so a failed texture
// upload leaked the surface and a failed draw leaked both -- on a HUD line
// redrawn every frame, that is a leak per frame.
ATTEMPT {
texture = SDL_CreateTextureFromSurface(akgl_renderer->sdl_renderer, textsurf);
FAIL_ZERO_BREAK(errctx, texture, AKERR_NULLPOINTER, "%s", SDL_GetError());
dest.x = x;
dest.y = y;
SDL_GetTextureSize(texture, &dest.w, &dest.h);
CATCH(errctx, akgl_renderer->draw_texture(akgl_renderer, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE));
} CLEANUP {
if ( texture != NULL ) {
SDL_DestroyTexture(texture);
texture = NULL;
}
SDL_DestroySurface(textsurf);
textsurf = NULL;
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_text_measure(TTF_Font *font, char *text, int *w, int *h)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, font, AKERR_NULLPOINTER, "NULL font");
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "NULL text string");
FAIL_ZERO_RETURN(errctx, w, AKERR_NULLPOINTER, "NULL width destination");
FAIL_ZERO_RETURN(errctx, h, AKERR_NULLPOINTER, "NULL height destination");
// A zero length means "the string is null terminated", not "the empty
// string" -- an empty text measures 0 wide and one line high.
FAIL_ZERO_RETURN(
errctx,
TTF_GetStringSize(font, text, 0, w, h),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_text_measure_wrapped(TTF_Font *font, char *text, int wraplength, int *w, int *h)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, font, AKERR_NULLPOINTER, "NULL font");
FAIL_ZERO_RETURN(errctx, text, AKERR_NULLPOINTER, "NULL text string");
FAIL_ZERO_RETURN(errctx, w, AKERR_NULLPOINTER, "NULL width destination");
FAIL_ZERO_RETURN(errctx, h, AKERR_NULLPOINTER, "NULL height destination");
// SDL_ttf takes the wrap width as an int and reads a negative one as a
// very large unsigned width, which silently disables wrapping instead of
// reporting anything. Refuse it here rather than return a wrong measurement.
FAIL_NONZERO_RETURN(
errctx,
(wraplength < 0),
AKERR_OUTOFBOUNDS,
"Wrap length %d is negative",
wraplength);
FAIL_ZERO_RETURN(
errctx,
TTF_GetStringSizeWrapped(font, text, 0, wraplength, w, h),
AKGL_ERR_SDL,
"%s",
SDL_GetError());
SUCCEED_RETURN(errctx);
}