// 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 isstd::stringandoperator+=. 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 ptsImplement 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
rin0 .. screen_rows-1:- the text:
rows[r]truncated to at mostscreen_colscharacters ifr < 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.
- the text:
"\x1b[<row>;<col>H"parking the cursor:cursor_row/cursor_colare 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 ptsEvery 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 ifdirty(unsaved changes). - Right part:
"<current_line>/<total_lines>"(both already 1-based). - Compose
left + spaces + rightpadded to exactlywidthcharacters, 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 exceedswidth, use its firstwidthcharacters. - 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.