Forward WINDOW through the tee sink

`akbasic_sink_init_tee()` wired write, writeln, readline, clear and --
conditionally -- moveto, and never `window`. The standalone AKGL frontend runs
the interpreter against a tee, so the fully implemented `sink_window()`
underneath it was unreachable: `WINDOW 0, 0, 20, 4` answered "WINDOW needs a
text device with a character grid, and this one has none" on the one build that
has a character grid. It reads as an oversight rather than a decision, because
`moveto` directly above it is forwarded with reasoning that applies unchanged.

`tee_window()` is that function, modelled on `tee_moveto()`, offered only when a
half can take it so the refusal still reads correctly through a stdio-only pair.

**Writing the test found a second one.** Both optional entry points are assigned
conditionally, and neither initializer cleared them first -- so a caller with a
sink on the stack got whatever was in that memory, and `CHAR` and `WINDOW`
decide whether they can act by testing those pointers for NULL. Both
`akbasic_sink_init_tee()` and `akbasic_sink_init_stdio()` now clear them. An
initializer that leaves a field alone is not an initializer.

tests/sink_tee.c gains a stand-in sink with a grid -- the two stdio halves have
neither entry point, which is exactly why they could not show that either is
forwarded -- and asserts both directions, the arguments arriving intact, and
that a grid-less pair still offers neither.

Verified end to end: `WINDOW 0, 0, 20, 4` then `PRINT` succeeds under
build-akgl/basic and still refuses by name under build/basic.

What this does *not* fix is that a drawing still has to be re-issued every
frame: the frontend never clears and SDL is double-buffered, so a drawing issued
once appears in one buffer only. Shrinking the text area makes the rest of the
window the program's; keeping something there is still the program's job. That
half is documented rather than fixed, and is filed as TODO.md section 9 item 5.

TODO.md section 6 item 31, struck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 23:57:57 -04:00
parent 5d33237eed
commit fd1f0d7c19
4 changed files with 142 additions and 1 deletions

View File

@@ -100,5 +100,13 @@ akerr_ErrorContext *akbasic_sink_init_stdio(akbasic_TextSink *obj, akbasic_Stdio
obj->writeln = stdio_writeln;
obj->readline = stdio_readline;
obj->clear = stdio_clear;
/*
* The optional entry points, cleared rather than left alone. A stream has no
* character grid, so `CHAR` and `WINDOW` must refuse by name -- and they
* decide that by testing the pointer for NULL. A caller with a sink on the
* stack would otherwise hand them whatever was in that memory.
*/
obj->moveto = NULL;
obj->window = NULL;
SUCCEED_RETURN(errctx);
}

View File

@@ -115,6 +115,34 @@ static akerr_ErrorContext *tee_moveto(akbasic_TextSink *self, int col, int row)
SUCCEED_RETURN(errctx);
}
/**
* @brief Constrain whichever half has a grid, for WINDOW.
*
* Same shape as tee_moveto() above it, and for the same reason: a stdio half has
* no grid to constrain and a windowed half does, so the call goes to whoever can
* take it and the other is left alone.
*
* This was missing, and its absence made `WINDOW` unreachable in the one build
* that has a character grid -- the standalone AKGL frontend runs the interpreter
* against a tee, so the fully implemented sink_window() underneath answered
* nothing. `WINDOW` refused with "needs a text device with a character grid" on
* the build that had one. TODO.md section 6 item 31.
*/
static akerr_ErrorContext *tee_window(akbasic_TextSink *self, int left, int top, int right, int bottom)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
if ( state->primary != NULL && state->primary->window != NULL ) {
PASS(errctx, state->primary->window(state->primary, left, top, right, bottom));
}
if ( state->mirror != NULL && state->mirror->window != NULL ) {
PASS(errctx, state->mirror->window(state->mirror, left, top, right, bottom));
}
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);
@@ -141,6 +169,14 @@ akerr_ErrorContext *akbasic_sink_init_tee(akbasic_TextSink *obj, akbasic_TeeSink
obj->writeln = tee_writeln;
obj->readline = tee_readline;
obj->clear = tee_clear;
/*
* Cleared first, because the two below are conditional and an initializer
* that leaves a field alone is not an initializer: a caller with a sink on
* the stack would get whatever was in that memory, and `CHAR` decides
* whether it can move a cursor by testing this pointer for NULL.
*/
obj->moveto = NULL;
obj->window = NULL;
/*
* Offered only when a half can actually do it, so CHAR's refusal against a
* stdio-only driver still reads correctly through a tee.
@@ -149,5 +185,11 @@ akerr_ErrorContext *akbasic_sink_init_tee(akbasic_TextSink *obj, akbasic_TeeSink
(mirror != NULL && mirror->moveto != NULL) ) {
obj->moveto = tee_moveto;
}
/* Same rule for WINDOW, so its refusal against a grid-less pair still reads
correctly through a tee. */
if ( (primary != NULL && primary->window != NULL) ||
(mirror != NULL && mirror->window != NULL) ) {
obj->window = tee_window;
}
SUCCEED_RETURN(errctx);
}