Erase what the text layer drew: three GUI bugs, one cause

The sink painted glyphs on top of whatever was already on the renderer and
never erased anything, and the host deliberately does not clear the frame so a
DRAW survives. The grid was always correct; the screen kept the previous
frame. That surfaced as three separate-looking faults:

  - a backspaced character stayed on screen, including across a line wrap
  - a scroll left the old rows behind, "papered over with garbage"
  - the editing cursor smeared an underscore along every column it passed
    through, which read as an underline under the text being typed

render() now fills each row it is about to draw, and each row that has emptied
since it last drew, with the background first. Row by row rather than one
clear over the whole area: the text layer is authoritative over the rows it
occupies and leaves every other pixel alone, so a picture behind the text
loses only the strips the text uses instead of all of it.

echo_line() also truncates rows that hold nothing but the spaces its own erase
pass wrote, which is what backspacing back across a wrap leaves behind. A row
of spaces is text as far as render() is concerned, so it would have been
repainted forever and kept erasing whatever was under it.

The cursor glyph is removed outright, as asked. The smearing was the erase bug
rather than the cursor, so one could come back and behave -- a block would
read better than an underscore.

Four pixel-level tests, because grid assertions could never have caught any of
this. Each was checked by reverting the fix and watching it fail; two of them
were vacuous when first written and are noted as such where they are fixed.
The real-keyboard test also now rubs out a character and scrolls forty lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:03:37 -04:00
parent cfdde907df
commit acd20eed9a
5 changed files with 400 additions and 25 deletions

View File

@@ -458,6 +458,257 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_text_input_is_started(void)
SUCCEED_RETURN(errctx);
}
/** @brief True when every pixel of one character cell is the background. */
static bool cell_is_blank(SDL_Surface *shot, akbasic_AkglSink *sink, int col, int row)
{
return !anything_drawn(shot, sink->x + (col * sink->cellw),
sink->y + (row * sink->cellh),
sink->cellw, sink->cellh);
}
/**
* @brief What the sink drew last frame is erased before it draws again.
*
* **Three reported symptoms, one cause.** The sink used to paint glyphs on top
* of whatever was already on the renderer and never erase anything, and the
* frontend deliberately does not clear the frame (§5 deviation 26, so a DRAW
* survives). The grid was always right; the *screen* kept the previous frame:
*
* - a backspaced character stayed on screen, so backspace looked broken
* - a scrolled line left its old pixels behind, "papered over with garbage"
* - the editing cursor left an underscore at every column it passed through,
* which read as an underline under the whole line being typed
*
* These assertions are about *pixels*, deliberately. Every other sink test
* checks the character grid, and the grid was never the problem.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_render_erases_what_it_drew(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
int row = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
/* Something on screen to erase. */
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "XYZ"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(!cell_is_blank(shot, sink, 0, 0), "XYZ should have been drawn");
SDL_DestroySurface(shot);
/*
* A clear must reach the glass, not just the grid. This is SCNCLR, and it is
* the simplest shape of the bug: the grid empties and the pixels stay.
*/
PASS(errctx, FRONTEND.akglsink.clear(&FRONTEND.akglsink));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
for ( row = 0; row < 3; row++ ) {
TEST_REQUIRE(cell_is_blank(shot, sink, 0, row),
"row %d still shows its old pixels after a clear", row);
}
SDL_DestroySurface(shot);
/* A line that gets *shorter* erases the tail it no longer occupies. */
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "ABCD"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
PASS(errctx, FRONTEND.akglsink.clear(&FRONTEND.akglsink));
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "AB"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(!cell_is_blank(shot, sink, 0, 0), "AB should still be drawn");
TEST_REQUIRE(cell_is_blank(shot, sink, 2, 0),
"the third cell held a C and must now be blank");
TEST_REQUIRE(cell_is_blank(shot, sink, 3, 0),
"the fourth cell held a D and must now be blank");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Scrolling reaches the glass: the bottom row is blank after it scrolls.
*
* `scroll()` shifts the grid up and clears the last row, and that was always
* correct. What was not correct was the screen: nothing erased the row's old
* pixels, so the text appeared to be overwritten with garbage rather than
* scrolled.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_scroll_reaches_the_screen(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
int i = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
/*
* Fill every row *and render the bottom one* before scrolling it away. The
* first version of this test wrote all the rows in one loop, which scrolled
* the bottom row off before it had ever been drawn -- so there were no stale
* pixels to find and the test passed whether or not the bug was there. It is
* only a test of the scroll if the row reaches the glass first.
*/
for ( i = 0; i < sink->rows - 1; i++ ) {
PASS(errctx, FRONTEND.akglsink.writeln(&FRONTEND.akglsink, "FULL"));
}
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "BOTTOM"));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(!cell_is_blank(shot, sink, 0, sink->rows - 1),
"the bottom row should have been drawn before we scroll it");
SDL_DestroySurface(shot);
/* Now scroll it away. The grid clears the bottom row; the screen must too. */
PASS(errctx, FRONTEND.akglsink.writeln(&FRONTEND.akglsink, ""));
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE_STR(sink->text[sink->rows - 1], "");
TEST_REQUIRE(cell_is_blank(shot, sink, 0, sink->rows - 1),
"the bottom row scrolled away but its pixels are still on screen");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Backspace erases across a line wrap, taking the cursor to the row above.
*
* Called out explicitly in the report: backspace has to clear the character and
* step back "at all times, including when the backspace key would move the
* cursor up to the previous line on the Y axis". A line long enough to wrap puts
* the tail of the edit on the row below its anchor, and backspacing past the
* wrap has to erase there and return the cursor to the row above.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_backspace_across_a_wrap(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
char line[256];
bool eof = true;
int over = 0;
int i = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
/* Two characters past the right-hand edge, so the edit wraps onto row 1. */
over = sink->columns + 2;
for ( i = 0; i < over; i++ ) {
push_text_key('x', "x");
}
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_INT((int)strlen(line), over);
TEST_REQUIRE(sink->text[1][0] != '\0', "a line past the edge must wrap onto the next row");
/*
* Render it, so row 1 genuinely has pixels to leave behind. Without this the
* pixel assertions below pass whether or not anything erases, because the
* wrapped row would never have been drawn in the first place.
*/
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
/*
* Now type the same over-long line again and backspace back past the wrap.
* The editor is re-entered, so the anchor is row 2 by now; what matters is
* that the wrapped tail is erased and the cursor comes back a row.
*/
PASS(errctx, FRONTEND.akglsink.clear(&FRONTEND.akglsink));
for ( i = 0; i < over; i++ ) {
push_text_key('y', "y");
}
for ( i = 0; i < 4; i++ ) {
push_key(SDLK_BACKSPACE);
}
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_INT((int)strlen(line), over - 4);
/* Back inside the first row: the wrap is gone, so row 1 holds nothing. */
TEST_REQUIRE_STR(sink->text[1], "");
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(cell_is_blank(shot, sink, 0, 1),
"the wrapped tail was backspaced away but is still on screen");
/* And the last surviving character is where it should be, on row 0. */
TEST_REQUIRE(!cell_is_blank(shot, sink, over - 5, 0),
"the character before the backspaces should still be drawn");
TEST_REQUIRE(cell_is_blank(shot, sink, over - 4, 0),
"the first backspaced character should be gone");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Nothing is drawn under the text being typed.
*
* The editing cursor used to be an underscore glyph drawn at the cursor cell.
* With nothing erasing the previous frame it accumulated at every column the
* cursor had passed through, which read as an underline beneath the whole line
* and made typed text hard to read. Removed outright; §5 records it.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_no_cursor_underline(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
akbasic_AkglSink *sink = NULL;
int col = 0;
int y = 0;
int lit = 0;
PASS(errctx, start_frontend(NULL));
sink = &FRONTEND.akglstate;
/*
* Rendered *while editing*, which is the only state the cursor was ever
* drawn in -- an earlier version of this test rendered after readline had
* returned, by which time `editing` is false and a re-added cursor would
* sail straight through it.
*/
PASS(errctx, FRONTEND.akglsink.write(&FRONTEND.akglsink, "ab"));
sink->editing = true;
PASS(errctx, akbasic_sink_akgl_render(&FRONTEND.akglsink));
/*
* The bottom scanline of each cell the text occupies and the one after it.
* An underscore lands there; "a" and "b" in this font do not, so anything
* lit on that line is the cursor and nothing else.
*/
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
y = sink->y + sink->cellh - 1;
for ( col = 0; col < 4; col++ ) {
if ( anything_drawn(shot, sink->x + (col * sink->cellw), y, sink->cellw, 1) ) {
lit += 1;
}
}
TEST_REQUIRE_INT(lit, 0);
SDL_DestroySurface(shot);
sink->editing = false;
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief A whole REPL session typed at the window, ending with QUIT.
*
@@ -535,6 +786,10 @@ int main(void)
CATCH(errctx, test_window_close_stops());
CATCH(errctx, test_line_editor());
CATCH(errctx, test_text_input_is_started());
CATCH(errctx, test_render_erases_what_it_drew());
CATCH(errctx, test_scroll_reaches_the_screen());
CATCH(errctx, test_backspace_across_a_wrap());
CATCH(errctx, test_no_cursor_underline());
CATCH(errctx, test_repl_session());
} CLEANUP {
stop_frontend();