Port the standalone SDL frontend, and give the sink a line editor

An AKGL build of `basic` was a terminal program with unused SDL linked into
it. It now opens the reference's 800x600 window, draws BASIC output in the
Commodore font, pumps events, lets you type at it, and still mirrors every
byte to stdout.

The stdout mirror is a composing sink rather than a second write inside the
interpreter, and lives in the core library where it needs no SDL. The line
editor waits for a typed line by borrowing one frame at a time from the host,
so nothing blocks and nothing owns an event loop it should not.

The whole golden corpus now runs through the SDL binary as well as the stdio
one, byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
This commit is contained in:
2026-07-31 11:21:39 -04:00
parent 66f415dfe8
commit 23dda12e24
14 changed files with 1816 additions and 106 deletions

View File

@@ -13,11 +13,20 @@
* equivalent until then.
*/
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
/*
* akgl/actor.h before akgl/controller.h, and it is not optional: controller.h
* declares two handler function pointers taking an akgl_Actor * and includes
* nothing that declares the type. Filed upstream; see the same note in
* src/input_akgl.c, which is where it was found first.
*/
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
#include <akgl/text.h>
@@ -41,6 +50,18 @@ static void scroll(akbasic_AkglSink *state)
if ( state->cursorrow > 0 ) {
state->cursorrow -= 1;
}
/*
* A line being typed is anchored to a row, and that row just moved. Follow
* it, or a backspace erases somebody else's text. Clamped at zero: a line
* long enough to scroll its own start off the top redraws from the top row,
* which is cosmetically wrong and is the only place this can be seen.
*/
if ( state->editing ) {
state->editrow -= 1;
if ( state->editrow < 0 ) {
state->editrow = 0;
}
}
}
/** @brief Move to the start of the next row, scrolling if that runs off the end. */
@@ -104,26 +125,161 @@ static akerr_ErrorContext *sink_writeln(akbasic_TextSink *self, const char *text
SUCCEED_RETURN(errctx);
}
/**
* @brief Redraw the line being typed, erasing whatever a longer one left behind.
*
* Two passes over the grid rather than one clever one. The first blanks what is
* already drawn and the second draws the current text, which leaves the cursor
* exactly where the text ends without anybody having to reproduce putchar_at's
* wrapping arithmetic a second time. Nothing is rendered here -- the grid is
* memory, and the host draws it when it draws its frame -- so the cost is a
* couple of memcpy-sized loops per keystroke.
*/
static void echo_line(akbasic_AkglSink *state)
{
int i = 0;
state->cursorrow = state->editrow;
state->cursorcol = state->editcol;
for ( i = 0; i < state->echolen; i++ ) {
putchar_at(state, ' ');
}
state->cursorrow = state->editrow;
state->cursorcol = state->editcol;
for ( i = 0; i < state->editlen; i++ ) {
putchar_at(state, state->editline[i]);
}
state->echolen = state->editlen;
}
/**
* @brief Fold one keycode into the line being typed.
*
* The keycodes are SDL's, and an unshifted printable key carries its own ASCII
* value, which is why the range test below is all the translation there is.
* Shifted characters are unreachable: the ring carries no modifier state. See
* akbasic_sink_akgl_set_pump() in akgl.h.
*/
static void edit_key(akbasic_AkglSink *state, int keycode, bool *submitted)
{
if ( keycode == '\r' || keycode == '\n' ) {
*submitted = true;
return;
}
if ( keycode == '\b' || keycode == 0x7f ) {
if ( state->editlen > 0 ) {
state->editlen -= 1;
state->editline[state->editlen] = '\0';
echo_line(state);
}
return;
}
if ( keycode == 0x1b ) {
state->editlen = 0;
state->editline[0] = '\0';
echo_line(state);
return;
}
if ( keycode < 0x20 || keycode > 0x7e ) {
/* A cursor or function key. Not an editing command here; a script's own
* GET loop is what wants those. */
return;
}
if ( state->editlen >= (int)sizeof(state->editline) - 1 ) {
/* Full. Dropped silently, exactly as a C128's 80-character limit does. */
return;
}
state->editline[state->editlen] = (char)toupper(keycode);
state->editlen += 1;
state->editline[state->editlen] = '\0';
echo_line(state);
}
/**
* @brief Collect keystrokes until a line is submitted or the host stops.
*
* Its own function because it is a loop: CATCH and the _BREAK macros expand to a
* C break, which inside a loop would escape only the loop and leave the rest of
* an ATTEMPT running with an error pending. PASS only in here, and the caller
* wraps this one call in the ATTEMPT that owns the cleanup.
*/
static akerr_ErrorContext AKERR_NOIGNORE *edit_loop(akbasic_AkglSink *state, bool *eof)
{
PREPARE_ERROR(errctx);
bool submitted = false;
bool available = false;
bool running = true;
int keycode = 0;
while ( !submitted ) {
PASS(errctx, akgl_controller_poll_key(&keycode, &available));
if ( available ) {
edit_key(state, keycode, &submitted);
continue;
}
/*
* Nothing waiting: hand the frame back to the host, which pumps the
* events that fill the ring this loop is reading. Skipping the pump
* while keys are available is what keeps a paste or a fast typist from
* costing one frame per character.
*/
PASS(errctx, state->pump(state->pumpself, &running));
if ( !running ) {
*eof = true;
SUCCEED_RETURN(errctx);
}
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *sink_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof)
{
PREPARE_ERROR(errctx);
akbasic_AkglSink *state = NULL;
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL && eof != NULL), AKERR_NULLPOINTER,
"NULL argument in akgl sink readline");
FAIL_ZERO_RETURN(errctx, (len > 1), AKBASIC_ERR_BOUNDS,
"Read buffer of %zu bytes is too small", len);
state = (akbasic_AkglSink *)self->self;
FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER,
"akgl sink has no state");
*eof = false;
dest[0] = '\0';
/*
* A drawn text layer is not a source of lines. INPUT through a graphics sink
* wants a line editor built on the keystroke ring, which is its own piece of
* work and is not done: see TODO.md section 3. Until it exists this reports
* end of input rather than pretending to have read something.
*
* EOF rather than an error, because that is the contract sink.h states:
* running off the end of input is how RUNSTREAM mode finishes normally, and
* INPUT already handles it.
* No pump means no host loop to borrow, and a sink that sat on the keyboard
* without one would deadlock the process on its first INPUT. Report end of
* input instead -- the contract sink.h states, and what INPUT already
* handles.
*/
if ( len > 0 ) {
dest[0] = '\0';
if ( state->pump == NULL ) {
*eof = true;
SUCCEED_RETURN(errctx);
}
*eof = true;
state->editing = true;
state->editrow = state->cursorrow;
state->editcol = state->cursorcol;
state->editlen = 0;
state->echolen = 0;
state->editline[0] = '\0';
ATTEMPT {
CATCH(errctx, edit_loop(state, eof));
} CLEANUP {
/* Whatever happened, stop drawing a cursor over a line nobody is typing. */
state->editing = false;
} PROCESS(errctx) {
} FINISH(errctx, true);
if ( *eof ) {
SUCCEED_RETURN(errctx);
}
strncpy(dest, state->editline, len - 1);
dest[len - 1] = '\0';
newline(state);
SUCCEED_RETURN(errctx);
}
@@ -236,5 +392,35 @@ akerr_ErrorContext *akbasic_sink_akgl_render(akbasic_TextSink *obj)
state->x,
state->y + (row * state->cellh)));
}
/*
* The cursor, drawn only while a line is being typed. The reference draws it
* the same way, as a literal underscore glyph (drawCursor,
* basicruntime_graphics.go:33), rather than as a filled rectangle -- which
* means it needs no draw primitive and cannot be a different shape from the
* text it sits in.
*/
if ( state->editing ) {
PASS(errctx, akgl_text_rendertextat(state->font, "_",
state->color, 0,
state->x + (state->cursorcol * state->cellw),
state->y + (state->cursorrow * state->cellh)));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sink_akgl_set_pump(akbasic_TextSink *obj, akbasic_AkglPump pump, void *self)
{
PREPARE_ERROR(errctx);
akbasic_AkglSink *state = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER,
"NULL sink in sink_akgl_set_pump");
state = (akbasic_AkglSink *)obj->self;
FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER,
"akgl sink has no state");
state->pump = pump;
state->pumpself = self;
SUCCEED_RETURN(errctx);
}