// lesson: painting-the-screen

Bytes Out โ€” Painting the Screen

Output is the same CSI grammar in the other direction: you write() command sequences and the terminal executes them. The handful an editor lives on:

ESC [ 2 J        erase the whole screen
ESC [ K          erase from the cursor to the end of the line
ESC [ H          cursor to row 1, column 1 (home)
ESC [ 12 ; 40 H  cursor to row 12, column 40  โ€” 1-BASED, row;col
ESC [ 7 m        SGR (Select Graphic Rendition): inverted video (swap fg/bg)
ESC [ m          SGR: reset all attributes
ESC [ ? 25 l     hide the cursor        (l = reset a private mode)
ESC [ ? 25 h     show the cursor        (h = set a private mode)

Cursor addressing being 1-based while every array in your program is 0-based is a permanent off-by-one hazard; we'll bury the +1 in exactly one function and never think about it again. The ?-prefixed numbers are private modes โ€” extensions beyond ANSI X3.64, in a namespace DEC reserved for itself, which is why they look different. Two more private modes are worth knowing even though our tests don't cover them: ESC [ ? 1049 h switches to the alternate screen buffer (vim's trick: your shell scrollback is untouched underneath and reappears on exit โ€” pair the enter/leave with a ScopedAction), and ESC [ ? 2004 h enables bracketed paste, making a paste arrive wrapped in ESC [ 200 ~ โ€ฆ ESC [ 201 ~ markers so 500 pasted characters don't get interpreted as 500 keystrokes (in vi, pasting text containing j would otherwise move the cursor...).

Flicker, and the one-write rule

The naive render loop clears the screen, then prints each line:

write(fd, "\x1b[2J", 4);              // blank everything   <-- flicker!
for (auto& line : rows) write(fd, ...);

Between the clear and the last line, the terminal may refresh its own display โ€” the user sees a blank frame, i.e. flicker. Two fixes, both standard practice:

  • Never clear the whole screen. Redraw every line over the old content and erase only each line's tail with ESC [ K. Nothing is ever blank.
  • One write() per frame. Build the entire frame โ€” cursor-hide, home, every row, cursor-park, cursor-show โ€” in a memory buffer, then hand the terminal one syscall's worth of bytes. Fewer syscalls, and no torn intermediate states. (kilo calls this the "append buffer" abuf; in C it's forty lines of realloc โ€” in C++ it is std::string and operator+=. You already have a better abuf than kilo's.)

Hiding the cursor during the repaint (?25l โ€ฆ ?25h) kills the last artifact: the cursor visibly teleporting around the screen as lines are drawn.

A note on types while we're building APIs that take text: std::string_view is the right parameter type for "some characters I will only read" โ€” it's a pointer+length pair, so it accepts a std::string, a literal, or a slice of either, without copying. The one rule: a view doesn't own, so never store one beyond the call (the classic dangling-view bug). Parameters yes, members no.

The frame builder

build_frame below is your editor's whole render path minus the write(). It takes the already-visible portion of the file (the viewport lessons will produce it), the screen size, and where the cursor should end up, and returns the exact byte sequence to send. Rows past the end of the file render as ~ โ€” vi's famous tilde column, which exists precisely to distinguish "empty line in the file" from "beyond the end of the file".

One more raw-mode consequence baked in: with OPOST off there is no automatic \n โ†’ \r\n, so the frame must end every screen row (except the last โ€” writing a newline on the bottom row would scroll the terminal) with an explicit \r\n.

โ€บ One Frame, One Write

15 pts

Implement build_frame(rows, screen_rows, screen_cols, cursor_row, cursor_col) returning the frame as a std::string, concatenated in exactly this order:

  • "\x1b[?25l" โ€” hide the cursor, then "\x1b[H" โ€” home.
  • For each screen row r in 0 .. screen_rows-1:
    • the text: rows[r] truncated to at most screen_cols characters if r < rows.size(), otherwise a single "~";
    • "\x1b[K" โ€” erase the stale tail of the old frame's line;
    • "\r\n" โ€” unless this is the last screen row.
  • "\x1b[<row>;<col>H" parking the cursor: cursor_row/cursor_col are 0-based screen coordinates; the sequence wants them 1-based.
  • "\x1b[?25h" โ€” show the cursor again.

Log in to submit a solution and earn points.

โ€บ The Status Bar

10 pts

Every vi descendant reserves a line for status: filename, a dirty marker, where you are in the file. Rendering one is a fixed-width formatting problem โ€” the bar must be exactly the screen width, no matter how long the filename is, because it's drawn in inverted video (ESC [ 7 m) and a bar one column short leaves a normal-video hole; one column long and it wraps, shearing the frame.

Implement status_bar(filename, dirty, current_line, total_lines, width):

  • Left part: the filename, or "[No Name]" if it's empty; then " [+]" appended if dirty (unsaved changes).
  • Right part: "<current_line>/<total_lines>" (both already 1-based).
  • Compose left + spaces + right padded to exactly width characters, with at least one space between the parts. If the left part is too long, truncate it (keep its prefix) so that the space and right part still fit exactly. If even the right part alone exceeds width, use its first width characters.
  • Wrap the padded content in "\x1b[7m" โ€ฆ "\x1b[m".

The returned string is what your render loop appends to the frame right after the text rows.

Log in to submit a solution and earn points.