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:
@@ -39,6 +39,25 @@
|
||||
/** @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.
|
||||
*
|
||||
@@ -69,6 +88,21 @@ typedef struct
|
||||
* list of lines: the interpreter allocates nothing, and neither does this.
|
||||
*/
|
||||
char text[64][256];
|
||||
|
||||
/*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
@@ -121,6 +155,34 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_akgl(akbasic_TextSink *obj,
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* **What the editor cannot do, and why.** akgl_controller_poll_key() reports a
|
||||
* keycode and nothing else: no modifier state and no composed text. So the
|
||||
* editor cannot see Shift, which puts every shifted character out of reach and
|
||||
* makes lowercase unreachable. Letters are folded to upper case, which is what a
|
||||
* C128 does anyway and what the C64 font is drawn for. Filed upstream against
|
||||
* libakgl; when the ring carries modifiers this becomes a real keyboard.
|
||||
*
|
||||
* Editing is Backspace to erase one character and Escape to abandon the line.
|
||||
* There is no cursor movement within the line, for the same reason: 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.
|
||||
|
||||
177
include/akbasic/frontend.h
Normal file
177
include/akbasic/frontend.h
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file frontend.h
|
||||
* @brief The standalone SDL frontend: a host, not part of the interpreter.
|
||||
*
|
||||
* Everything else under include/akbasic deliberately owns no window, no renderer
|
||||
* and no event loop -- goal 3 forbids it, because a game embedding this
|
||||
* interpreter already has all three. This file is the other side of that line.
|
||||
* It is the *host* the standalone `basic` executable needs, ported from the Go
|
||||
* reference's main.go and basicruntime_graphics.go, and it is built into its own
|
||||
* target so the separation is visible in the build graph rather than only in a
|
||||
* comment: `akbasic` is the interpreter, `akbasic_akgl` is the adaptors, and
|
||||
* `akbasic_frontend` is the one thing here that creates a window.
|
||||
*
|
||||
* A game does not link this. It creates its own window and calls the four
|
||||
* initializers in akbasic/akgl.h against it.
|
||||
*/
|
||||
|
||||
#ifndef _AKBASIC_FRONTEND_H_
|
||||
#define _AKBASIC_FRONTEND_H_
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
|
||||
#include <akerror.h>
|
||||
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
#include <akbasic/akgl.h>
|
||||
#include <akbasic/runtime.h>
|
||||
#include <akbasic/sink.h>
|
||||
|
||||
/**
|
||||
* @brief Interpreter steps run between two frames.
|
||||
*
|
||||
* The reason there is a number here at all is TODO.md section 1.6: the library
|
||||
* steps, it does not run, precisely so a host can bound it. An unbounded run
|
||||
* would starve the event pump, and a window that does not answer its close
|
||||
* button is a window the desktop kills.
|
||||
*
|
||||
* 256 rather than 1 because a frame costs far more than a step and a tight
|
||||
* program should not be paced at 60 lines per second. It is a pacing knob and
|
||||
* nothing else -- no golden output depends on it, since a bounded run reproduces
|
||||
* an unbounded one exactly.
|
||||
*/
|
||||
#define AKBASIC_FRONTEND_STEPS_PER_FRAME 256
|
||||
|
||||
/** @brief Window size the reference opens, from deps/basicinterpret/main.go. */
|
||||
#define AKBASIC_FRONTEND_WIDTH 800
|
||||
/** @brief Window height the reference opens. */
|
||||
#define AKBASIC_FRONTEND_HEIGHT 600
|
||||
/** @brief Font size the reference opens its font at. */
|
||||
#define AKBASIC_FRONTEND_FONT_SIZE 16
|
||||
|
||||
/**
|
||||
* @brief Everything the standalone program owns.
|
||||
*
|
||||
* All of it by value: the frontend allocates nothing, and a driver puts one of
|
||||
* these in static storage next to its runtime for the same reason the runtime is
|
||||
* static -- neither belongs on a default stack.
|
||||
*/
|
||||
typedef struct akbasic_AkglFrontend
|
||||
{
|
||||
SDL_Window *window;
|
||||
akgl_RenderBackend *renderer;
|
||||
TTF_Font *font;
|
||||
int width;
|
||||
int height;
|
||||
|
||||
/*
|
||||
* Three sinks, because output goes two places. The tee is what the runtime
|
||||
* is given; the other two are its halves. TODO.md section 3 item 5: the
|
||||
* second write belongs out here, never inside the interpreter.
|
||||
*/
|
||||
akbasic_AkglSink akglstate;
|
||||
akbasic_TextSink akglsink;
|
||||
akbasic_StdioSink stdiostate;
|
||||
akbasic_TextSink stdiosink;
|
||||
akbasic_TeeSink teestate;
|
||||
akbasic_TextSink sink;
|
||||
|
||||
akbasic_GraphicsBackend graphics;
|
||||
akbasic_AkglGraphics graphicsstate;
|
||||
akbasic_AudioBackend audio;
|
||||
akbasic_InputBackend input;
|
||||
|
||||
/** False once the window has been closed; the drive loop stops on it. */
|
||||
bool running;
|
||||
/** True when this frontend called SDL_Init and owes it an SDL_Quit. */
|
||||
bool ownssdl;
|
||||
/** True when an audio device opened. A machine with none still runs. */
|
||||
bool audioready;
|
||||
} akbasic_AkglFrontend;
|
||||
|
||||
/**
|
||||
* @brief Bring up SDL, a window, a renderer, a font and all four adaptors.
|
||||
*
|
||||
* Reproduces the reference's main.go: an 800x600 window titled "BASIC" and the
|
||||
* Commodore font at 16 points. Audio is best-effort -- a machine with no sound
|
||||
* device still runs BASIC, it just refuses SOUND and PLAY with
|
||||
* AKBASIC_ERR_DEVICE, which is the same answer a stdio build gives.
|
||||
*
|
||||
* @param obj Object to initialize, inspect, or modify.
|
||||
* @param title Window title.
|
||||
* @param w Window width in pixels.
|
||||
* @param h Window height in pixels.
|
||||
* @param fontpath TTF to open for the text layer.
|
||||
* @param fontsize Point size to open it at.
|
||||
* @param mirror Where output is mirrored in addition to the window; NULL selects stdout.
|
||||
* @param in Where the runtime reads its program, or NULL to read typed lines from the window.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When `obj`, `title` or `fontpath` is NULL.
|
||||
* @throws AKGL_ERR_SDL When SDL, the window, the renderer or the font refuses to open.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_init(akbasic_AkglFrontend *obj, const char *title, int w, int h, const char *fontpath, int fontsize, FILE *mirror, FILE *in);
|
||||
|
||||
/**
|
||||
* @brief Give a runtime this frontend's sink and its three devices.
|
||||
*
|
||||
* Initializes @p rt as well, because a runtime has to be told its sink at
|
||||
* initialization and there is nothing useful a caller could do between the two
|
||||
* calls.
|
||||
*
|
||||
* @param obj Object to initialize, inspect, or modify.
|
||||
* @param rt The runtime to wire up.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When either argument is NULL.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_attach(akbasic_AkglFrontend *obj, akbasic_Runtime *rt);
|
||||
|
||||
/**
|
||||
* @brief One frame: pump events, clear, draw the text layer, present.
|
||||
*
|
||||
* Deliberately shaped as an akbasic_AkglPump so the sink's line editor can be
|
||||
* handed this exact function -- the editor waits for a typed line by borrowing
|
||||
* frames from here, which is how INPUT works without anybody blocking.
|
||||
*
|
||||
* The graphics verbs draw straight to the renderer and are *not* redrawn from a
|
||||
* display list, so this does not clear the target: a DRAW from three frames ago
|
||||
* has to still be on screen. The text layer is drawn over whatever is there,
|
||||
* which is also what a C128 does with its text and bitmap planes.
|
||||
*
|
||||
* @param self The frontend, as a void * so this matches akbasic_AkglPump.
|
||||
* @param running Set false when the window has been closed.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When either argument is NULL.
|
||||
* @throws AKGL_ERR_SDL When the renderer refuses to present.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_pump(void *self, bool *running);
|
||||
|
||||
/**
|
||||
* @brief Run the interpreter to completion, a bounded number of steps per frame.
|
||||
*
|
||||
* This is the standalone program's whole loop. It ends when the script sets
|
||||
* AKBASIC_MODE_QUIT or the window is closed, and either is a clean exit -- a
|
||||
* closed window is a user quitting, not a failure.
|
||||
*
|
||||
* @param obj Object to initialize, inspect, or modify.
|
||||
* @param rt The runtime to drive.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When either argument is NULL.
|
||||
* @throws AKERR_* Whatever the script raised and nothing handled.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_drive(akbasic_AkglFrontend *obj, akbasic_Runtime *rt);
|
||||
|
||||
/**
|
||||
* @brief Close the font, the renderer, the window and SDL, in that order.
|
||||
*
|
||||
* Returns nothing and refuses nothing: this runs on the way out, including on
|
||||
* the way out of a failure, and there is no caller left to tell.
|
||||
*
|
||||
* @param obj Object to initialize, inspect, or modify.
|
||||
*/
|
||||
void akbasic_frontend_akgl_shutdown(akbasic_AkglFrontend *obj);
|
||||
|
||||
#endif // _AKBASIC_FRONTEND_H_
|
||||
@@ -51,4 +51,43 @@ typedef struct
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_stdio(akbasic_TextSink *obj, akbasic_StdioSink *state, FILE *out, FILE *in);
|
||||
|
||||
/**
|
||||
* @brief State for a sink that writes to two others.
|
||||
*
|
||||
* The reference mirrors every line to stdout *and* to its SDL surface, and that
|
||||
* mirror is what makes the golden corpus runnable. Reproducing it as a second
|
||||
* hardcoded write inside the interpreter is exactly what the sink boundary
|
||||
* exists to prevent, so the composition lives out here instead: two sinks in,
|
||||
* one sink out, and the interpreter still only knows about one.
|
||||
*/
|
||||
typedef struct
|
||||
{
|
||||
akbasic_TextSink *primary;
|
||||
akbasic_TextSink *mirror;
|
||||
akbasic_TextSink *reader;
|
||||
} akbasic_TeeSink;
|
||||
|
||||
/**
|
||||
* @brief Compose two sinks into one that writes to both.
|
||||
*
|
||||
* Writes go to @p primary first and then to @p mirror; an error from either one
|
||||
* stops the pair, so a failed write is never half-reported.
|
||||
*
|
||||
* @p reader says which of the two answers readline, because only one of them can
|
||||
* and the answer is not derivable. A file-mode driver reads its program from the
|
||||
* stdio half while drawing through the akgl half; an interactive one reads from
|
||||
* the akgl line editor and mirrors to stdout. Passing NULL makes readline report
|
||||
* end of input, which is the honest answer for a pair that has no input.
|
||||
*
|
||||
* @param obj Object to initialize, inspect, or modify.
|
||||
* @param state Storage for the three pointers; must outlive the sink.
|
||||
* @param primary The sink written first; must not be NULL.
|
||||
* @param mirror The sink written second; must not be NULL.
|
||||
* @param reader Whichever of the two supplies readline, or NULL for none.
|
||||
* @return `NULL` on success, otherwise an error context owned by the caller.
|
||||
* @throws AKERR_NULLPOINTER When `obj`, `state`, `primary` or `mirror` is NULL.
|
||||
* @throws AKBASIC_ERR_VALUE When `reader` is neither `primary` nor `mirror`.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_tee(akbasic_TextSink *obj, akbasic_TeeSink *state, akbasic_TextSink *primary, akbasic_TextSink *mirror, akbasic_TextSink *reader);
|
||||
|
||||
#endif // _AKBASIC_SINK_H_
|
||||
|
||||
Reference in New Issue
Block a user