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>
This commit is contained in:
2026-07-31 11:21:39 -04:00
parent 7897a49fb5
commit f24201fdef
14 changed files with 1816 additions and 106 deletions

294
src/frontend_akgl.c Normal file
View File

@@ -0,0 +1,294 @@
/**
* @file frontend_akgl.c
* @brief The standalone program's SDL host, ported from the Go frontend.
*
* The reference's main.go opens an 800x600 window and a Commodore font and hands
* both to the runtime, which then owns the process until MODE_QUIT
* (basicruntime.go:682). Goal 3 forbids the second half of that outright, so
* what is ported here is the first half plus the loop the reference never had to
* write: pump events, run a bounded number of interpreter steps, draw, present,
* repeat.
*
* **This file is a host.** It is the only thing in the repository that creates a
* window, and it is in its own target so that fact is visible in the build graph
* rather than only in a comment. A game embedding the interpreter does not link
* it -- it already has a window, and it calls the four initializers in
* akbasic/akgl.h against its own.
*
* Everything the four adaptors need is created here and nowhere else:
*
* window + renderer ---> the akgl sink and the graphics backend
* SDL event pump ---> akgl_controller_handle_event, the input backend
* the frame ---> akbasic_sink_akgl_render, and the line editor
*/
#include <stddef.h>
#include <string.h>
#include <akerror.h>
/*
* akgl/actor.h before akgl/controller.h: controller.h declares two handler
* function pointers taking an akgl_Actor * and includes nothing that declares
* the type, so on its own it does not compile. Filed upstream against libakgl;
* see the same note in src/input_akgl.c, which is where it turned up first.
*/
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
* game.h purely for the `renderer` global and its `_akgl_renderer` storage.
* akgl_text_rendertextat() takes no renderer argument and reads that global, so
* a host that never calls akgl_game_init() -- which is every host embedding this
* interpreter, since owning the game loop is exactly what goal 3 forbids -- has
* to populate it itself. deps/libakgl/tests/draw.c does the same.
*/
#include <akgl/game.h>
#include <akgl/renderer.h>
#include <akbasic/error.h>
#include <akbasic/frontend.h>
/** @brief Fill in the 2D backend's six vtable entries against an existing renderer.
*
* akgl_render_init2d() installs exactly these, but it also creates its own
* window from the game properties and writes to the `camera` global, which makes
* it part of the akgl_game_init() path -- and a host that already has a renderer
* is not on that path. Without them akgl_text_rendertextat() dereferences a NULL
* draw_texture and the first PRINT segfaults.
*
* Filed upstream: what is wanted is the vtable half of init2d on its own.
* tests/akgl_backends.c carries the identical six lines for the identical
* reason; delete both when it lands.
*/
static void install_2d_vtable(akgl_RenderBackend *backend)
{
backend->shutdown = &akgl_render_2d_shutdown;
backend->frame_start = &akgl_render_2d_frame_start;
backend->frame_end = &akgl_render_2d_frame_end;
backend->draw_texture = &akgl_render_2d_draw_texture;
backend->draw_mesh = &akgl_render_2d_draw_mesh;
backend->draw_world = &akgl_render_2d_draw_world;
}
akerr_ErrorContext *akbasic_frontend_akgl_init(akbasic_AkglFrontend *obj, const char *title, int w, int h, const char *fontpath, int fontsize, FILE *mirror, FILE *in)
{
PREPARE_ERROR(errctx);
akbasic_TextSink *reader = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL && title != NULL && fontpath != NULL),
AKERR_NULLPOINTER, "NULL argument in frontend_akgl_init");
FAIL_ZERO_RETURN(errctx, (w > 0 && h > 0 && fontsize > 0), AKBASIC_ERR_VALUE,
"A %dx%d window at %d points is not a window", w, h, fontsize);
memset(obj, 0, sizeof(*obj));
obj->width = w;
obj->height = h;
/* Before anything else in libakgl, so every AKGL_ERR_* has a name to print. */
PASS(errctx, akgl_error_init());
/*
* INIT_VIDEO only. The reference asks for INIT_EVERYTHING, which on a
* headless machine fails on a subsystem BASIC never touches; audio is opened
* separately below and is allowed to fail.
*/
FAIL_ZERO_RETURN(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
obj->ownssdl = true;
FAIL_ZERO_RETURN(errctx, TTF_Init(), AKGL_ERR_SDL,
"Couldn't initialize SDL_ttf: %s", SDL_GetError());
obj->renderer = &_akgl_renderer;
renderer = obj->renderer;
FAIL_ZERO_RETURN(errctx,
SDL_CreateWindowAndRenderer(title, w, h, 0,
&obj->window, &obj->renderer->sdl_renderer),
AKGL_ERR_SDL, "Couldn't create the window: %s", SDL_GetError());
window = obj->window;
install_2d_vtable(obj->renderer);
obj->font = TTF_OpenFont(fontpath, (float)fontsize);
FAIL_ZERO_RETURN(errctx, (obj->font != NULL), AKGL_ERR_SDL,
"Couldn't open the font %s: %s", fontpath, SDL_GetError());
/*
* The two halves of the output, and then the tee that joins them. Which one
* answers readline is the whole difference between the two modes: a program
* read from a file comes in through the stdio half, and typed lines come in
* through the akgl half's line editor.
*/
PASS(errctx, akbasic_sink_init_akgl(&obj->akglsink, &obj->akglstate,
obj->renderer, obj->font, w, h));
PASS(errctx, akbasic_sink_init_stdio(&obj->stdiosink, &obj->stdiostate,
(mirror != NULL ? mirror : stdout), in));
reader = (in != NULL ? &obj->stdiosink : &obj->akglsink);
PASS(errctx, akbasic_sink_init_tee(&obj->sink, &obj->teestate,
&obj->akglsink, &obj->stdiosink, reader));
/*
* The editor waits for a typed line by borrowing frames from this very
* frontend -- which is why pump() has the akbasic_AkglPump signature and can
* be installed as-is.
*/
PASS(errctx, akbasic_sink_akgl_set_pump(&obj->akglsink,
akbasic_frontend_akgl_pump, obj));
PASS(errctx, akbasic_graphics_init_akgl(&obj->graphics, &obj->graphicsstate,
obj->renderer));
PASS(errctx, akbasic_input_init_akgl(&obj->input));
/*
* Audio last, in its own subsystem, and allowed to fail. The reference asks
* for INIT_EVERYTHING up front, which makes a machine with no sound card
* refuse to run BASIC at all; here the failure costs only SOUND and PLAY,
* which are then refused with AKBASIC_ERR_DEVICE -- exactly what a stdio
* build does. The subsystem is tried first because akgl_audio_init() cannot
* open a device without it, and reporting "no audio device" for what is
* really "audio was never initialized" would send somebody looking at their
* hardware.
*/
if ( !SDL_InitSubSystem(SDL_INIT_AUDIO) ) {
SDL_Log("No audio subsystem (%s); SOUND and PLAY will be refused", SDL_GetError());
obj->audioready = false;
} else {
ATTEMPT {
CATCH(errctx, akbasic_audio_init_akgl(&obj->audio));
obj->audioready = true;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "No audio device; SOUND and PLAY will be refused");
obj->audioready = false;
} FINISH(errctx, false);
}
obj->running = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_frontend_akgl_attach(akbasic_AkglFrontend *obj, akbasic_Runtime *rt)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && rt != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_attach");
PASS(errctx, akbasic_runtime_init(rt, &obj->sink));
PASS(errctx, akbasic_runtime_set_devices(rt, &obj->graphics,
(obj->audioready ? &obj->audio : NULL),
&obj->input));
SUCCEED_RETURN(errctx);
}
/**
* @brief Drain the SDL event queue into libakgl's keystroke ring.
*
* Its own function because it is a loop, and CATCH inside one escapes only the
* loop -- PASS is the only thing that may appear in here.
*/
static akerr_ErrorContext AKERR_NOIGNORE *pump_events(akbasic_AkglFrontend *obj)
{
PREPARE_ERROR(errctx);
SDL_Event event;
while ( SDL_PollEvent(&event) ) {
if ( event.type == SDL_EVENT_QUIT ||
event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED ) {
obj->running = false;
continue;
}
/*
* Everything else goes to libakgl, which pushes key-downs into the ring
* the input backend reads before it consults its own control maps. The
* appstate is the frontend, which nothing downstream reads -- but a NULL
* one is refused outright.
*/
PASS(errctx, akgl_controller_handle_event(obj, &event));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_frontend_akgl_pump(void *self, bool *running)
{
PREPARE_ERROR(errctx);
akbasic_AkglFrontend *obj = (akbasic_AkglFrontend *)self;
FAIL_ZERO_RETURN(errctx, (obj != NULL && running != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_pump");
PASS(errctx, pump_events(obj));
*running = obj->running;
if ( !obj->running ) {
SUCCEED_RETURN(errctx);
}
/*
* No clear. The graphics verbs draw straight to the renderer rather than
* into a display list, so clearing here would wipe every DRAW the moment the
* frame it was issued in ended. The text layer is drawn over whatever is
* already there, which is also how a C128 stacks its text plane on its
* bitmap plane.
*/
PASS(errctx, akbasic_sink_akgl_render(&obj->akglsink));
FAIL_ZERO_RETURN(errctx, SDL_RenderPresent(obj->renderer->sdl_renderer),
AKGL_ERR_SDL, "Couldn't present the frame: %s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
/**
* @brief The frame loop, in its own function for the usual reason: it is a loop.
*/
static akerr_ErrorContext AKERR_NOIGNORE *drive_loop(akbasic_AkglFrontend *obj, akbasic_Runtime *rt)
{
PREPARE_ERROR(errctx);
bool running = true;
while ( running && rt->mode != AKBASIC_MODE_QUIT ) {
/*
* The host owns the clock -- the library reads none, because it owns no
* loop and must not block. SDL_GetTicks() is monotonic milliseconds,
* which is exactly what settime wants and saves this file a
* clock_gettime and a _POSIX_C_SOURCE.
*/
PASS(errctx, akbasic_runtime_settime(rt, (int64_t)SDL_GetTicks()));
PASS(errctx, akbasic_runtime_run(rt, AKBASIC_FRONTEND_STEPS_PER_FRAME));
PASS(errctx, akbasic_frontend_akgl_pump(obj, &running));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_frontend_akgl_drive(akbasic_AkglFrontend *obj, akbasic_Runtime *rt)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && rt != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_drive");
PASS(errctx, drive_loop(obj, rt));
SUCCEED_RETURN(errctx);
}
void akbasic_frontend_akgl_shutdown(akbasic_AkglFrontend *obj)
{
if ( obj == NULL ) {
return;
}
if ( obj->font != NULL ) {
TTF_CloseFont(obj->font);
obj->font = NULL;
}
if ( obj->renderer != NULL && obj->renderer->sdl_renderer != NULL ) {
SDL_DestroyRenderer(obj->renderer->sdl_renderer);
obj->renderer->sdl_renderer = NULL;
}
if ( obj->window != NULL ) {
SDL_DestroyWindow(obj->window);
obj->window = NULL;
window = NULL;
}
TTF_Quit();
if ( obj->ownssdl ) {
SDL_Quit();
obj->ownssdl = false;
}
obj->running = false;
}

View File

@@ -3,21 +3,28 @@
* @brief The standalone driver.
*
* Everything that belongs to a program rather than to a library lives here: argv
* handling, sink selection, the unbounded run loop, and a FINISH_NORETURN --
* which belongs only in a main(). The interpreter library itself never
* terminates the process.
* handling, which frontend to bring up, and a FINISH_NORETURN -- which belongs
* only in a main(). The interpreter library itself never terminates the process.
*
* There are two drivers in here and the build option picks between them. Without
* AKBASIC_HAVE_AKGL this is a terminal program: stdio in, stdout out, no window.
* With it, the program is an SDL host -- window, font, event pump, frame loop --
* and its output goes to the window *and* to stdout, so a piped program produces
* the same bytes either way. The whole of that second driver is
* akbasic/frontend.h; what is left here is argv and the choice.
*
* The runtime is static rather than automatic because it carries every pool the
* interpreter owns -- several megabytes -- and that will not fit on a default
* stack. An embedding game would place it in its own state for the same reason.
*/
/* clock_gettime and CLOCK_MONOTONIC. C99 alone does not declare either. */
/* clock_gettime, CLOCK_MONOTONIC and isatty. C99 alone declares none of them. */
#define _POSIX_C_SOURCE 199309L
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#include <akerror.h>
#include <akstdlib.h>
@@ -27,6 +34,13 @@
#include <akbasic/sink.h>
static akbasic_Runtime RUNTIME;
#ifdef AKBASIC_HAVE_AKGL
#include <akbasic/frontend.h>
static akbasic_AkglFrontend FRONTEND;
#else
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
@@ -82,6 +96,85 @@ static akerr_ErrorContext AKERR_NOIGNORE *drive(akbasic_Runtime *obj)
SUCCEED_RETURN(errctx);
}
/**
* @brief The terminal driver: no window, no SDL, output on stdout.
*
* @param program The already-opened program file, or NULL for an interactive REPL.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_stdio(FILE *program)
{
PREPARE_ERROR(errctx);
if ( program != NULL ) {
/*
* A file argument: read the program from it in RUNSTREAM mode, which
* files each line under its line number and then switches to RUN.
*/
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, program));
PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUNSTREAM));
} else {
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, stdin));
PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL));
}
PASS(errctx, drive(&RUNTIME));
SUCCEED_RETURN(errctx);
}
#endif /* !AKBASIC_HAVE_AKGL */
#ifdef AKBASIC_HAVE_AKGL
/**
* @brief Where to find the Commodore font.
*
* Compiled in by CMake, because the reference's relative "./fonts/..." only ever
* worked from its own source directory. AKBASIC_FONT overrides it at runtime,
* which is what an installed copy or a different font needs.
*/
#ifndef AKBASIC_FONT_PATH
#define AKBASIC_FONT_PATH "fonts/C64_Pro_Mono-STYLE.ttf"
#endif
/**
* @brief The SDL driver: an 800x600 window, and stdout still gets everything.
*
* @param program The already-opened program file, or NULL to type at the window.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_akgl(FILE *program)
{
PREPARE_ERROR(errctx);
const char *fontpath = getenv("AKBASIC_FONT");
FILE *input = program;
int mode = AKBASIC_MODE_RUNSTREAM;
if ( fontpath == NULL ) {
fontpath = AKBASIC_FONT_PATH;
}
if ( program == NULL ) {
/*
* With a terminal on the other end of stdin the window is the console
* and typed lines come from its line editor. Piped or redirected, they
* come from the pipe instead -- so `basic < program.bas` still behaves
* in an AKGL build, and the golden corpus can be driven through this
* binary as well as through the stdio one.
*/
input = (isatty(fileno(stdin)) ? NULL : stdin);
mode = AKBASIC_MODE_REPL;
}
PASS(errctx, akbasic_frontend_akgl_init(&FRONTEND, "BASIC",
AKBASIC_FRONTEND_WIDTH,
AKBASIC_FRONTEND_HEIGHT,
fontpath, AKBASIC_FRONTEND_FONT_SIZE,
stdout, input));
PASS(errctx, akbasic_frontend_akgl_attach(&FRONTEND, &RUNTIME));
PASS(errctx, akbasic_runtime_start(&RUNTIME, mode));
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
SUCCEED_RETURN(errctx);
}
#endif
int main(int argc, char **argv)
{
PREPARE_ERROR(errctx);
@@ -90,24 +183,20 @@ int main(int argc, char **argv)
ATTEMPT {
if ( argc > 1 ) {
/*
* A file argument: read the program from it in RUNSTREAM mode, which
* files each line under its line number and then switches to RUN.
*/
CATCH(errctx, aksl_fopen(argv[1], "r", &program));
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, program));
CATCH(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
CATCH(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUNSTREAM));
} else {
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, stdin));
CATCH(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
CATCH(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL));
}
CATCH(errctx, drive(&RUNTIME));
#ifdef AKBASIC_HAVE_AKGL
CATCH(errctx, run_akgl(program));
#else
CATCH(errctx, run_stdio(program));
#endif
} CLEANUP {
if ( program != NULL ) {
IGNORE(aksl_fclose(program));
}
#ifdef AKBASIC_HAVE_AKGL
akbasic_frontend_akgl_shutdown(&FRONTEND);
#endif
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "akbasic terminated on an unhandled error");

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

120
src/sink_tee.c Normal file
View File

@@ -0,0 +1,120 @@
/**
* @file sink_tee.c
* @brief A text sink that writes to two others.
*
* The reference's Write() and Println() (basicruntime_graphics.go:140,148) put
* every line on stdout *and* on an SDL surface. TODO.md section 1.5 turned that
* pair of hardcoded calls into a vtable specifically so the interpreter would
* never carry the second one, and section 3 item 5 says it again for the
* standalone AKGL driver: do not put a hardcoded second write in the
* interpreter. This is where the second write lives instead.
*
* No SDL and no libakgl. Composing two function-pointer records needs neither,
* so this stays in the core library where the stdio-only suite can test it.
*/
#include <stddef.h>
#include <akerror.h>
#include <akbasic/error.h>
#include <akbasic/sink.h>
/** @brief Fetch and validate the state behind a tee sink. */
static akerr_ErrorContext AKERR_NOIGNORE *tee_state(akbasic_TextSink *self, akbasic_TeeSink **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in tee sink");
*dest = (akbasic_TeeSink *)self->self;
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER,
"tee sink has no state");
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_write(akbasic_TextSink *self, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
PASS(errctx, state->primary->write(state->primary, text));
PASS(errctx, state->mirror->write(state->mirror, text));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_writeln(akbasic_TextSink *self, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
PASS(errctx, state->primary->writeln(state->primary, text));
PASS(errctx, state->mirror->writeln(state->mirror, text));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
FAIL_ZERO_RETURN(errctx, (dest != NULL && eof != NULL), AKERR_NULLPOINTER,
"NULL argument in tee sink readline");
if ( state->reader == NULL ) {
/*
* A pair with no reader reports end of input rather than raising, which
* is the contract sink.h states: running off the end is how RUNSTREAM
* mode finishes normally and INPUT already handles it.
*/
if ( len > 0 ) {
dest[0] = '\0';
}
*eof = true;
SUCCEED_RETURN(errctx);
}
PASS(errctx, state->reader->readline(state->reader, dest, len, eof));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_clear(akbasic_TextSink *self)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
PASS(errctx, state->primary->clear(state->primary));
PASS(errctx, state->mirror->clear(state->mirror));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sink_init_tee(akbasic_TextSink *obj, akbasic_TeeSink *state, akbasic_TextSink *primary, akbasic_TextSink *mirror, akbasic_TextSink *reader)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL), AKERR_NULLPOINTER,
"NULL argument in sink_init_tee");
FAIL_ZERO_RETURN(errctx, (primary != NULL && mirror != NULL), AKERR_NULLPOINTER,
"A tee sink needs two sinks to write to");
/*
* Refused rather than quietly accepted. A third sink here would read from
* somewhere nothing writes to, which is the sort of wiring mistake that
* shows up much later as a program that never sees its own input.
*/
FAIL_ZERO_RETURN(errctx, (reader == NULL || reader == primary || reader == mirror),
AKBASIC_ERR_VALUE,
"A tee sink's reader must be one of the two sinks it writes to");
state->primary = primary;
state->mirror = mirror;
state->reader = reader;
obj->self = state;
obj->write = tee_write;
obj->writeln = tee_writeln;
obj->readline = tee_readline;
obj->clear = tee_clear;
SUCCEED_RETURN(errctx);
}