Files
akbasic/include/akbasic/akgl.h
Andrew Kesterson acd20eed9a Erase what the text layer drew: three GUI bugs, one cause
The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:

  - a backspaced character stayed on screen, including across a line wrap
  - a scroll left the old rows behind, "papered over with garbage"
  - the editing cursor smeared an underscore along every column it passed
    through, which read as an underline under the text being typed

render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.

echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.

The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.

Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:03:37 -04:00

266 lines
12 KiB
C

/**
* @file akgl.h
* @brief The libakgl-backed implementations of the sink and the three devices.
*
* Everything declared here lives in the separate `akbasic_akgl` target, which is
* the only part of this project that links SDL. The core library builds and its
* whole test suite runs on a machine with no SDL on it; that is why the records
* these initializers populate are plain function-pointer structs and why this
* header is the only one that includes a libakgl header.
*
* **The interpreter owns no window, no renderer and no event loop.** Every one
* of these takes something the host already created and draws or plays through
* it. None of them creates a device, and none of them pumps events.
*
* Each initializer calls akgl_error_init() first. It reserves libakgl's 256-260
* status band and names every AKGL_ERR_* code; akgl_game_init() calls it as its
* first statement, but a program driving subsystems directly -- which is exactly
* what an embedded interpreter does -- never goes through akgl_game_init() and
* has to call it itself. Skip it and every AKGL_ERR_* that reaches a stack trace
* prints "Unknown Error". It is idempotent, so a host that already called it
* loses nothing.
*/
#ifndef _AKBASIC_AKGL_H_
#define _AKBASIC_AKGL_H_
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akgl/renderer.h>
#include <akgl/version.h>
/*
* Everything in this target now depends on APIs that arrived in libakgl 0.3.0:
* akgl_render_bind2d(), akgl_audio_sweep(), and akgl_Keystroke with
* akgl_controller_poll_keystroke(). Refused here rather than at link time,
* because a missing symbol names a function and this names the release.
*
* The soname carries MAJOR.MINOR while the major is 0, so 0.2 and 0.3 are
* different ABIs and a mismatch is normally caught at load. This catches the
* case the soname cannot: a build against 0.2 headers that happens to find a
* 0.3 library, or the reverse.
*/
#if !AKGL_VERSION_AT_LEAST(0, 3, 0)
#error "akbasic's libakgl adaptors require libakgl 0.3.0 or later"
#endif
#include <akbasic/audio.h>
#include <akbasic/graphics.h>
#include <akbasic/input.h>
#include <akbasic/sink.h>
/** @brief How many saved SSHAPE regions the graphics backend will hold at once. */
#define AKBASIC_AKGL_MAX_SHAPES 16
/**
* @brief One frame of the host's own loop, borrowed by the sink's line editor.
*
* The sink has to be able to wait for a typed line without owning an event loop,
* and those two requirements only meet in one place: the host hands over a
* callback that does exactly one frame -- pump events, redraw, present -- and
* the editor calls it between keystrokes. The loop is still the host's; the
* editor just borrows it a frame at a time.
*
* Setting @p running false is how the host says the window closed. The editor
* reports end of input rather than raising, because that is what running out of
* input means everywhere else in sink.h.
*
* @param self Whatever the host passed to akbasic_sink_akgl_set_pump().
* @param running Set false to stop waiting; the editor then reports EOF.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
typedef akerr_ErrorContext AKERR_NOIGNORE *(*akbasic_AkglPump)(void *self, bool *running);
/**
* @brief State for the libakgl-backed text sink.
*
* The cursor, the wrap and the scroll live here rather than in the interpreter:
* everything in the reference's basicruntime_graphics.go except Write and
* Println, which are the sink interface itself.
*/
typedef struct
{
akgl_RenderBackend *renderer;
TTF_Font *font;
SDL_Color color;
/**
* What a text cell is erased to before its glyphs are drawn. Opaque black
* by default, which is a Commodore console and is what makes the text layer
* *authoritative* over the rows it occupies rather than transparent over
* them -- see akbasic_sink_akgl_render().
*/
SDL_Color background;
int x; /* pixel origin of the text area */
int y;
int width; /* pixel size of the text area */
int height;
int cellw; /* one character cell, measured from the font */
int cellh;
int columns; /* the character grid the cell size works out to */
int rows;
int cursorcol;
int cursorrow;
/*
* The scrollback the sink redraws every frame. A fixed grid rather than a
* list of lines: the interpreter allocates nothing, and neither does this.
*/
char text[64][256];
/**
* Which rows carried glyphs when this sink last rendered. A row that has
* emptied since -- scrolled away, cleared, or shortened -- still has to be
* erased, and after it is erased there is no longer anything in the grid to
* say it once had something in it.
*/
bool drawn[64];
/*
* The line editor. Nothing here is live except while readline is running,
* and `editing` is what says so -- render() draws a cursor only then, and
* the scroll adjusts `editrow` only then.
*/
akbasic_AkglPump pump;
void *pumpself;
bool editing;
int editrow; /* where the line being typed starts */
int editcol;
int editlen; /* characters accepted so far */
int echolen; /* characters currently drawn, so a backspace */
/* knows how much to erase */
char editline[256];
} akbasic_AkglSink;
/**
* @brief State for the libakgl-backed graphics backend.
*
* The shape pool is why this exists at all. SSHAPE hands the BASIC program a
* handle rather than the pixels -- see TODO.md section 5 -- and these are the
* surfaces those handles refer to.
*/
typedef struct
{
akgl_RenderBackend *renderer;
SDL_Surface *shapes[AKBASIC_AKGL_MAX_SHAPES];
int shapecount;
} akbasic_AkglGraphics;
/**
* @brief Point a text sink at a renderer and a font the host already has.
*
* The character grid is derived by measuring one glyph with akgl_text_measure(),
* which is the direct equivalent of the reference's font.SizeUTF8("A") -- and
* which did not exist in libakgl until 42b60f7. A font that is not monospaced
* still works; the grid is then sized by whatever "A" happens to measure, and
* proportional glyphs simply do not line up in columns.
*
* @param obj Object to initialize, inspect, or modify.
* @param state Storage for the sink's own state; must outlive the sink.
* @param renderer The renderer the host already initialized; not created here.
* @param font An open font; not opened or closed here.
* @param w Width in pixels of the text area.
* @param h Height in pixels of the text area.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When any pointer argument is NULL.
* @throws AKBASIC_ERR_VALUE When the font measures a zero-width cell, which would divide by zero.
* @throws AKBASIC_ERR_BOUNDS When the area is too small for even one character.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_akgl(akbasic_TextSink *obj, akbasic_AkglSink *state, akgl_RenderBackend *renderer, TTF_Font *font, int w, int h);
/**
* @brief Draw whatever the sink currently holds.
*
* Separate from the sink's write path because the interpreter does not own the
* frame: a host calls this when it is drawing, not when the script happens to
* PRINT. A driver that only wants stdout never calls it at all.
*
* @param obj The sink to draw.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKGL_ERR_SDL When the renderer refuses the text.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_akgl_render(akbasic_TextSink *obj);
/**
* @brief Give the sink a line editor by lending it one frame of the host's loop.
*
* Without this, readline reports end of input immediately: a drawn text layer is
* not a source of lines, and a sink that blocked on the keyboard with no way to
* pump events would deadlock the process on its first INPUT. With it, readline
* collects keystrokes from libakgl's ring -- the one the host's own event pump
* fills -- echoes them into the grid, and returns the line on Return.
*
* **It is a real keyboard as of libakgl 0.3.0.** The ring carries the composed
* UTF-8 text SDL worked out from the keystroke, so shifted characters, a
* keyboard layout, a compose key and a dead key all do what the person typing
* expects -- including the double quote, without which a BASIC string literal
* cannot be typed at all. Until 0.3.0 the ring reported a keycode and nothing
* else, so none of that was reachable and letters were folded to upper case;
* that was libakgl API-gap item 10, filed from here.
*
* Composed text is taken over the keycode wherever there is any, which is what
* makes the layout work: on an AZERTY keyboard the key SDL calls `SDLK_Q`
* composes to "a", and "a" is what the program should see.
*
* Editing is Backspace to erase one character and Escape to abandon the line.
* There is no cursor movement within the line: the ring reports the arrow keys,
* but a script's own GET loop wants them.
*
* @param obj The sink to give an editor to.
* @param pump One frame of the host's loop, or NULL to take the editor away.
* @param self Passed back to @p pump untouched.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL or carries no sink state.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_akgl_set_pump(akbasic_TextSink *obj, akbasic_AkglPump pump, void *self);
/**
* @brief Point a graphics backend at a renderer the host already has.
* @param obj Object to initialize, inspect, or modify.
* @param state Storage for the shape pool; must outlive the backend.
* @param renderer The renderer the host already initialized; not created here.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When any argument is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_graphics_init_akgl(akbasic_GraphicsBackend *obj, akbasic_AkglGraphics *state, akgl_RenderBackend *renderer);
/**
* @brief Wire an audio backend to libakgl's tone generator.
*
* Calls akgl_audio_init(), which opens an SDL audio device. A host that owns its
* own audio pipeline can call akgl_audio_init() itself beforehand -- it is
* idempotent in the sense that mattered upstream: the voice table works whether
* or not a device is open.
*
* @param obj Object to initialize, inspect, or modify.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
* @throws AKGL_ERR_SDL When no audio device can be opened.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_init_akgl(akbasic_AudioBackend *obj);
/**
* @brief Wire an input backend to libakgl's keystroke ring.
*
* Reads only. The ring is filled by akgl_controller_handle_event(), which the
* *host* calls as it pumps SDL events -- so a script gets keystrokes without the
* interpreter owning the event loop.
*
* Worth knowing before lending this to a script: that ring is process-global and
* the host's own control maps read the same events. A script sitting in a GET
* loop drains keystrokes the game will then never see. A host that cares either
* withholds the input backend or supplies a filtered one of its own.
*
* @param obj Object to initialize, inspect, or modify.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_input_init_akgl(akbasic_InputBackend *obj);
#endif // _AKBASIC_AKGL_H_