diff --git a/TODO.md b/TODO.md index 194dce6..fac1c82 100644 --- a/TODO.md +++ b/TODO.md @@ -1982,8 +1982,20 @@ in either corpus runs for minutes, which is why the first of these had never bee in `examples/breakout/characters` does, and it is the one thing in that listing that will break on a different font or window. Two more `RGR()` indices would answer it. -32. **A character written past a short row's terminator is stored where nothing will render - it.** A grid row is a NUL-terminated string and `putchar_at()` (`src/sink_akgl.c:89`) +32. ~~**A character written past a short row's terminator is stored where nothing will + render it.**~~ **Done** -- `putchar_at()` (`src/sink_akgl.c`) fills the gap with spaces + before storing, so a positioned write always lands. + + **Padded in `putchar_at()` rather than in `sink_moveto()`**, which is the friendlier of + the two options this entry offered and avoids its own objection: `moveto` stays + read-only, and a row is only ever padded when a character actually arrives. The pad + fills from the terminator rather than replacing it, because the buffer is not cleared + between rows -- replacing only the terminator would expose the tail of whatever longer + row used to be there, and `tests/akgl_backends.c` asserts exactly that case. + + The documented truncation on the way back is unaffected and is asserted beside it. + + The original report: A grid row is a NUL-terminated string and `putchar_at()` (`src/sink_akgl.c:89`) writes the character, advances, and terminates -- so `CHAR 1, 40, 1, "#"` on an empty row stores `#` at column 40 with `text[1][0]` still `'\0'`, and `sink_akgl_render()` draws nothing at all. The same call on a row already 50 characters long draws the `#` and erases diff --git a/docs/11-verb-reference.md b/docs/11-verb-reference.md index 66324b5..0cda9eb 100644 --- a/docs/11-verb-reference.md +++ b/docs/11-verb-reference.md @@ -18,7 +18,7 @@ for the reasoning in each case. | `BOX` | `BOX src, x1, y1, x2, y2 [,angle]` | Outline a rectangle, optionally rotated. | | `BSAVE` | `BSAVE "name", from, to` | Write a range of memory to a file. | | `CATALOG` | `CATALOG` | **Refused.** The other name for `DIRECTORY`. | -| `CHAR` | `CHAR col, x, y, "text"` | Put text at a character cell. Needs a sink with a cursor. | +| `CHAR` | `CHAR col, x, y, "text"` | Put text at a character cell. Needs a sink with a cursor. Terminates the row where it stops, so it erases whatever followed. | | `CIRCLE` | `CIRCLE src, x, y, rx, ry [,...]` | Draw an ellipse, arc or polygon. | | `CLR` | `CLR` | Drop every variable and function, keeping the program. | | `COLLECT` | `COLLECT` | **Refused.** Validates a disk's block allocation map. | diff --git a/docs/17-tutorial-breakout.md b/docs/17-tutorial-breakout.md index d33f103..79b0dab 100644 --- a/docs/17-tutorial-breakout.md +++ b/docs/17-tutorial-breakout.md @@ -211,9 +211,9 @@ RETURN Now draw it — and here is the second rule that shapes this listing: **`CHAR` terminates the row where it stops.** A row of the grid is a C string, so writing -at column N erases everything from N+1 on, and writing *past* the terminator of a short -row draws nothing at all. There is no way to poke one character into the middle of a row -and leave the rest alone. +at column N erases everything from N+1 on. Writing *past* the end of a short row is fine +— the gap fills with spaces — but there is still no way to poke one character into the +middle of a row and leave the rest of it alone. So a row is rebuilt whole and written from column 0, in one `CHAR`: @@ -732,7 +732,7 @@ game against: | Rule | Because | Where | |---|---|---| | `DIM` at the top, never in a loop | An array created inside a scope costs pool slots for good; 4096 ends the run. A scalar is free | Step 3 | -| Build a text row whole and write it from column 0 | `CHAR` terminates the row where it stops | Step 4 | +| Build a text row whole and write it from column 0 | `CHAR` terminates the row where it stops, so a second write erases the tail of the first | Step 4 | | Tell rows apart by shape, not colour | `CHAR` ignores its colour argument | Step 4 | | Compute a `MOVSPR` coordinate into a variable first | A leading sign means *move by*, not *move to* | Step 5 | | Loop the game with `GOTO`, not `DO` | 32 scopes, and jumping out of a loop leaks one | Step 6 | diff --git a/src/sink_akgl.c b/src/sink_akgl.c index 5d90eea..76503d1 100644 --- a/src/sink_akgl.c +++ b/src/sink_akgl.c @@ -76,9 +76,23 @@ static void newline(akbasic_AkglSink *state) * program that PRINTs a long string and then PRINTs again expects the second one * to start on the row after the first one ended, and only the code that placed * the characters knows which row that is. + * + * **A row is a NUL-terminated string, so a write past its end pads.** Without + * that, `CHAR 1, 40, 1, "#"` on an otherwise empty row stored the `#` at column + * 40 with `text[1][0]` still `'\0'` -- and the render loop, which stops at the + * terminator, drew nothing at all. The write succeeded, the cursor moved, the + * stdout mirror showed the character, and the window stayed blank. That silent + * nothing is the trap; the documented truncation on the way *back* is fine and + * is unaffected. TODO.md section 6 item 32. + * + * The padding is spaces rather than whatever was there: the buffer is not + * cleared between rows, so the gap holds the tail of some longer row that used + * to be here. */ static void putchar_at(akbasic_AkglSink *state, char c) { + int col = 0; + if ( c == '\n' ) { newline(state); return; @@ -86,6 +100,14 @@ static void putchar_at(akbasic_AkglSink *state, char c) if ( state->cursorcol >= state->columns || state->cursorcol >= SINK_MAX_COLUMNS - 1 ) { newline(state); } + for ( col = 0; col < state->cursorcol; col++ ) { + if ( state->text[state->cursorrow][col] == '\0' ) { + break; + } + } + for ( ; col < state->cursorcol; col++ ) { + state->text[state->cursorrow][col] = ' '; + } state->text[state->cursorrow][state->cursorcol] = c; state->cursorcol += 1; state->text[state->cursorrow][state->cursorcol] = '\0'; diff --git a/tests/akgl_backends.c b/tests/akgl_backends.c index 6ca0bcf..56db96c 100644 --- a/tests/akgl_backends.c +++ b/tests/akgl_backends.c @@ -305,6 +305,53 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_sink_grid_and_wrap(void) SUCCEED_RETURN(errctx); } +/** + * @brief A character placed past a short row's end pads rather than vanishing. + * + * A row is a NUL-terminated string, so `CHAR 1, 6, 0, "#"` on an otherwise empty + * row used to store the `#` at column 6 with `text[0][0]` still `'\0'` -- and + * the render loop, which stops at the terminator, drew nothing at all. The write + * succeeded, the cursor moved, the stdout mirror showed the character, and the + * window stayed blank. That silent nothing is the trap; the documented + * truncation on the way back is fine and is asserted here too. + * + * The padding has to be spaces rather than whatever is in the buffer, because + * the buffer is not cleared between rows: the gap holds the tail of some longer + * row that used to be here, which is the second case below. + * + * TODO.md section 6 item 32. + */ +static akerr_ErrorContext AKERR_NOIGNORE *test_sink_writes_past_a_short_row(void) +{ + PREPARE_ERROR(errctx); + + PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font, + TARGET_SIZE, TARGET_SIZE)); + PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 6, 0)); + PASS(errctx, AKGLSINK.write(&AKGLSINK, "#")); + TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], " #"); + + /* + * Writing back over a long row still truncates it -- that half is the + * documented behaviour and the reason a program builds a row whole. + */ + PASS(errctx, AKGLSINK.clear(&AKGLSINK)); + PASS(errctx, AKGLSINK.write(&AKGLSINK, "ABCDEFGHIJ")); + PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 3, 0)); + PASS(errctx, AKGLSINK.write(&AKGLSINK, "X")); + TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "ABCX"); + + /* + * And a write past *that* terminator pads with spaces rather than exposing + * the "EFGHIJ" still sitting in the buffer behind it. A pad that only + * replaced the terminator itself would leave those visible. + */ + PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 7, 0)); + PASS(errctx, AKGLSINK.write(&AKGLSINK, "Z")); + TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "ABCX Z"); + SUCCEED_RETURN(errctx); +} + /** @brief The sink's text actually reaches the target when the host renders. */ static akerr_ErrorContext AKERR_NOIGNORE *test_sink_renders(void) { @@ -562,6 +609,7 @@ int main(void) CATCH(errctx, test_paint_fills_region()); CATCH(errctx, test_shape_roundtrip()); CATCH(errctx, test_sink_grid_and_wrap()); + CATCH(errctx, test_sink_writes_past_a_short_row()); CATCH(errctx, test_sink_renders()); CATCH(errctx, test_input_backend()); CATCH(errctx, test_sprite_from_file());