// lesson: screen-buffer
The Screen Model
Between "bytes arrive" and "pixels change" every terminal keeps an in-memory model of the display. So does every full-screen program talking to a terminal β vim doesn't re-send your whole file on each keystroke; it maintains what the screen should look like and sends the difference. Both directions of this course now converge on the same data structure: a grid of cells.
Why not just write() as you go?
Because of what you'd be writing to. Three separate costs punish scattered small writes:
- Syscalls aren't free. A
write()is a userβkernelβuser round trip β over a thousand times the cost of a memory store. Painting a 50Γ200 screen cell-by-cell is 10,000 syscalls to draw one frame you could send in one. - Flicker. The terminal renders whenever it likes β including after your "clear screen" but before your "draw contents". The user sees a blank flash. Batch the clear and the redraw into one write and there is no in-between state to glimpse.
- Tearing over distance. Over ssh, each write can become a packet. Half a frame per packet means the user literally watches your UI assemble.
So the architecture β the same one in kilo, vim, and ncurses β is:
mutate cells in memory β serialize to ONE byte buffer β ONE write()
Nothing touches the fd until the frame is complete.
The cell
A terminal cell is a glyph plus its styling β the "brush" that was active when it was painted:
struct cell {
char ch; /* the glyph */
unsigned char fg; /* 0-7 = basic colors, 9 = default */
unsigned char bg; /* same */
unsigned char attrs; /* CELL_BOLD | CELL_UNDERLINE | CELL_REVERSE */
};
The grid is rows * cols of these. Resist the 2D-array temptation β
struct cell grid[ROWS][COLS] hardcodes the size at compile time,
and terminals resize at runtime. One malloc(rows * cols * sizeof(struct cell)) and the classic flattening
cell = &s->cells[row * s->cols + col];
gives you a grid of any size, reallocatable on SIGWINCH. (That
row * cols + col line will appear in your dreams. It should: get
it backwards β col * rows + row β and everything almost works,
which is worse than not working.)
Alongside the grid, the screen keeps a cursor (where the next glyph lands) and the current brush (what style it lands with). SGR's statefulness, which looked like a wire-protocol quirk two lessons ago, turns out to be this: the brush is state in the screen model, and the wire just mutates it.
The append buffer
The serialize step needs a growable byte buffer to accumulate the
frame β cells, cursor moves, SGR changes β before the single write.
C won't hand you one; you'll build it (kilo calls its version
abuf, dynamic-array veterans will recognize the pattern):
struct abuf {
char *b; /* heap storage */
size_t len; /* bytes used */
size_t cap; /* bytes allocated */
};
The one interesting decision is the growth policy. Growing to
exactly the needed size makes N appends cost O(NΒ²) total (each
realloc copies everything so far). Growing geometrically β
doubling β makes the total O(N): each byte is copied at most a
constant number of times, amortized. This single idea is why
std::vector, Go slices, and Python lists are fast; today you get
to own it in nine lines.
βΊ An Append Buffer
15 ptsImplement abuf: ab_init, ab_append (arbitrary bytes),
ab_append_str (convenience for C strings), ab_free. Rules:
- Doubling growth (start at 64 when empty), so the amortized-O(1)
property actually holds. Grow at least to fit;
capmust never be less thanlen. ab_appendreturns 0 on success, -1 ifreallocfails β and on failure the buffer must be unchanged and still valid (hint:realloc's return value goes in a temporary; assigning it directly toab->bleaks the old block and corrupts the struct on failure).ab_freereleases storage and resets to a valid empty buffer (safe to reuse, safe to double-free).- Appended bytes may include NULs β track
len, neverstrlen.
Log in to submit a solution and earn points.
βΊ The Cell Grid
20 ptsBuild the screen: a heap-allocated grid of cells with a cursor and a brush. Operations:
screen_init(s, rows, cols)β allocate; every cell a space with default colors (fg = 9, bg = 9, attrs = 0); cursor at (0,0); brush = defaults. Return 0, or -1 on allocation failure.screen_free(s)β release; safe to call twice.screen_cell(s, row, col)β pointer to a cell (NULL if out of bounds β make the bounds check the one place it exists).screen_set_brush(s, fg, bg, attrs)β set the current brush.screen_put(s, ch)β stampchwith the current brush at the cursor, then advance: right one; wrap to column 0 of the next row at the right edge; on wrapping past the last row, stay on the last row (scrolling arrives in a later lesson).screen_set_cursor(s, row, col)β absolute move, clamped into bounds.screen_move_cursor(s, dr, dc)β relative move, clamped.screen_newline(s)β cursor to column 0 of the next row (clamped at the bottom).screen_backspace(s)β if the cursor is past column 0: move left and blank that cell (space, default style). At column 0: no-op.screen_clear(s)β every cell back to the init state; cursor and brush unchanged (that'sESC[2Jsemantics β remember, ED doesn't move the cursor).
Log in to submit a solution and earn points.