Own every pixel each frame, and bring the cursor back as a blinking block
Some checks failed
akbasic CI Build / cmake_build (push) Failing after 19s
akbasic CI Build / sanitizers (push) Failing after 13s
akbasic CI Build / coverage (push) Failing after 16s
akbasic CI Build / akgl_build (push) Failing after 18s
akbasic CI Build / mutation_test (push) Failing after 17s

The blinking backspace was not the backspace. The sink skipped rows it
believed were already clear, tracked in a drawn[] array, which assumes a frame
inherits the frame before it. SDL_RenderPresent swaps buffers, so a frame
inherits the one two back: a row erased once is clean in one buffer and dirty
in the other, and presenting alternates between them. The first character of a
backspaced wrap flickered forever.

It reproduces on X11 and never under the dummy driver or a software renderer,
which is why the suite stayed green through two rounds of fixing it. The sink
now repaints every row it owns every frame -- a frame either owns every pixel
it presents or inherits pixels it cannot reason about.

Confirmed on real hardware with the reported input: the wrapped tail's row
reads zero across five successive captures.

The cursor returns as a blinking block, half a second per cycle, off SDL's
clock. It sits in the cell after the text rather than under it, which is what
made the underscore unreadable, and it wraps to the next row when a line
exactly fills one -- where the next character actually lands.

The new regression test paints stale pixels by hand, which is what a swapped-in
buffer hands back, so the case is covered without real hardware. Every new
assertion was checked by reverting the fix and watching it fail.

Recorded honestly in TODO.md section 5: the same buffer swap means the
graphics verbs were never reliable across frames either. A one-shot DRAW lands
in one buffer and the next present shows the other. Making that work needs a
persistent surface, which is its own commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:23:41 -04:00
parent acd20eed9a
commit df50201121
4 changed files with 222 additions and 74 deletions

View File

@@ -417,6 +417,7 @@ akerr_ErrorContext *akbasic_sink_init_akgl(akbasic_TextSink *obj, akbasic_AkglSi
state->color.b = 0xff;
state->color.a = 0xff;
/* Opaque, not transparent: the text layer owns the rows it draws. */
state->cursorperiodms = AKBASIC_SINK_CURSOR_BLINK_MS;
state->background.r = 0x00;
state->background.g = 0x00;
state->background.b = 0x00;
@@ -457,23 +458,26 @@ akerr_ErrorContext *akbasic_sink_akgl_render(akbasic_TextSink *obj)
"akgl sink has no state");
/*
* **Erase each row before drawing it.** Without this the sink paints glyphs
* on top of whatever the previous frame left, and the host deliberately does
* not clear the frame either -- §5 deviation 26, so that a DRAW survives
* past the frame it was issued in. The grid was always right and the screen
* kept the last frame, which showed up as three separate-looking bugs: a
* backspaced character that would not go away, a scroll that appeared to
* overwrite the screen with garbage, and the editing cursor smearing an
* underscore along every column it passed through.
* **Repaint every row of the text area, every frame.** Not just the rows
* that changed -- that was tried, with a `drawn[]` array marking which rows
* had carried glyphs, and it is wrong on real hardware.
*
* Row by row rather than one clear over the whole text area, and that is the
* deliberate part: the text layer is authoritative over the rows it occupies
* and leaves every other pixel alone. A program that draws a picture and
* prints one line loses one line's strip of it, the way a C128's text plane
* sits over its bitmap -- rather than losing the picture.
* SDL_RenderPresent swaps buffers. What a frame inherits is not the frame
* before it, it is the frame *two* before it, and on some backends it is
* undefined. So a row erased once is erased in one buffer and still dirty in
* the other, and presenting alternates between them: the row blinks in and
* out forever. That is exactly what a backspace across a line wrap looked
* like -- the first character of the wrapped tail flickering after the rest
* had gone. It reproduces on X11 and never under the dummy driver or a
* software renderer, which is why the suite was green.
*
* `drawn` is what makes a row that has *emptied* get erased. Once it is
* cleared there is nothing left in the grid to say it ever held anything.
* There is no cheaper correct answer available here. A frame either owns
* every pixel it presents or it inherits pixels it cannot reason about.
*
* Row by row rather than one fill over the whole area, still: the two are
* equivalent in cost here and the row loop keeps the text layer's ownership
* of exactly `rows * cellh` pixels obvious, which matters when the area is
* smaller than the window.
*
* A loop, so no CATCH and no _BREAK macros: they expand to a C break, which
* would escape the loop with an error still pending. PASS only.
@@ -483,20 +487,15 @@ akerr_ErrorContext *akbasic_sink_akgl_render(akbasic_TextSink *obj)
* put characters somewhere the cursor does not think they are.
*/
for ( row = 0; row < state->rows; row++ ) {
bool hastext = (state->text[row][0] != '\0');
SDL_FRect cells;
if ( !hastext && !state->drawn[row] ) {
continue;
}
cells.x = (float)state->x;
cells.y = (float)(state->y + (row * state->cellh));
cells.w = (float)state->width;
cells.h = (float)state->cellh;
PASS(errctx, akgl_draw_filled_rect(state->renderer, &cells, state->background));
state->drawn[row] = hastext;
if ( !hastext ) {
if ( state->text[row][0] == '\0' ) {
continue;
}
PASS(errctx, akgl_text_rendertextat(state->font, state->text[row],
@@ -506,13 +505,52 @@ akerr_ErrorContext *akbasic_sink_akgl_render(akbasic_TextSink *obj)
}
/*
* No cursor glyph. The reference draws one as a literal underscore
* (drawCursor, basicruntime_graphics.go:33) and this did too, until it turned
* out to sit *under* the text being typed and make it hard to read. Removed
* on request; §5 records it, and the smearing that made it unbearable is
* fixed by the erase above rather than by the removal -- so a cursor could
* come back and behave, if one is wanted.
* The cursor: a filled block at the cursor cell, blinking.
*
* A block rather than the reference's underscore glyph (drawCursor,
* basicruntime_graphics.go:33). The underscore sat *under* the text being
* typed and made it hard to read, which is what got it removed; a block
* occupies the cell after the text instead of the space beneath it.
*
* The clock is SDL's rather than the host's. Everywhere else in this
* interpreter the host owns the clock, because the library owns no loop and
* must not block -- but this is an adaptor that already links SDL, the
* blink is cosmetic, and threading a timestamp through the sink interface to
* animate a cursor would be a poor trade. `cursorperiodms` of zero holds it
* solid, which is what makes a frame deterministic for a test.
*/
if ( state->editing ) {
SDL_FRect cell;
bool visible = true;
if ( state->cursorperiodms > 0 ) {
visible = ((SDL_GetTicks() % state->cursorperiodms) <
(state->cursorperiodms / 2));
}
if ( visible ) {
int curcol = state->cursorcol;
int currow = state->cursorrow;
/*
* A line that exactly fills a row leaves the cursor one past the last
* column, because putchar_at only wraps when the *next* character
* arrives. Drawn there it would be off the right edge and invisible.
* Show it where the next character will actually land instead, which
* is also where a C128 puts it.
*/
if ( curcol >= state->columns ) {
curcol = 0;
currow += 1;
}
if ( currow < state->rows ) {
cell.x = (float)(state->x + (curcol * state->cellw));
cell.y = (float)(state->y + (currow * state->cellh));
cell.w = (float)state->cellw;
cell.h = (float)state->cellh;
PASS(errctx, akgl_draw_filled_rect(state->renderer, &cell, state->color));
}
}
}
SUCCEED_RETURN(errctx);
}