// 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:

  1. 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.
  2. 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.
  3. 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 pts

Implement 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; cap must never be less than len.
  • ab_append returns 0 on success, -1 if realloc fails β€” and on failure the buffer must be unchanged and still valid (hint: realloc's return value goes in a temporary; assigning it directly to ab->b leaks the old block and corrupts the struct on failure).
  • ab_free releases storage and resets to a valid empty buffer (safe to reuse, safe to double-free).
  • Appended bytes may include NULs β€” track len, never strlen.

Log in to submit a solution and earn points.

β€Ί The Cell Grid

20 pts

Build 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) β€” stamp ch with 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's ESC[2J semantics β€” remember, ED doesn't move the cursor).

Log in to submit a solution and earn points.