The terminal UI

Architecture — part 10 of 13

lib/tui.rb is the whole UI layer: colors, single-keypress choices, arrow-key menus, the screens they live on and a spinner -- pure stdlib (io/console), plain VT100 sequences, no terminfo and no gems. Four deliberate constraints:

  • Everything degrades. Tui.interactive? gates every enhancement, so piped, scripted and cron runs keep the exact line-based behavior (and escape-code-free output) they always had. Colors additionally honor NO_COLOR and TERM=dumb.
  • A screen that stays put. Tui.frame(rows, keep_last:) paints from the top of the viewport over whatever the previous frame left there, writing the rows in one go and ending the last without a newline -- a full-height frame that ends in one scrolls the view by exactly what this exists to prevent. keep_last names the rows that survive a window too short to hold the frame (the keys, normally); what gets dropped instead is the end of the list, which the cursor can still scroll to. Everything interactive is built on it: the wizard menu, the publishing queue, Tui.menu, Tui.browse, the props dialog and the questions in ./setup.sh, ./style.sh and ./import.sh.
  • Not the alternate screen. \e[?1049h would discard its plane on exit, and this CLI prints things worth keeping -- a draft's address, what a deploy uploaded, what refused. The last frame stays where it was drawn and the scrollback above it is never touched.
  • Raw mode around a frame, never a session. Tui::Screen#key holds raw for one paint-and-read and gives it back, so no crash can leave the user's shell broken -- the classic TUI failure mode. A lone Esc is told apart from an arrow key by a 50 ms wait for the rest of the sequence.

Tui.screen yields a Screen: paint puts a frame up, key waits for one keypress, and leave hands the terminal to something that prints more than a frame can hold (a build, a publish) and takes it back afterwards. Nothing in it knows what a post or a queue is. A window resized while key waits answers :resize, which every caller treats as "paint again": a trap alone would not do, because getch blocks in the kernel and the handler would run with the read still parked, so the handler writes a byte to a pipe that key waits on alongside the terminal.

Two rules follow from painting at the top, and both are easy to get wrong. Anything a caller prints just before a picker is painted over, so the rows that belong above a list travel into it as header: rather than being printed first. And a list longer than the window scrolls inside it, on a window measured once when the picker opens: Tui.menu and Tui.browse read the terminal's height before their loop and keep it, because the arithmetic deciding which rows are visible has to stay valid for the life of the call. Answering a resize mid-wait is therefore the privilege of the screens that own a Screen -- the wizard menu, the publishing queue and the props dialog -- and everywhere else a resized window straightens itself on the next screen rather than on the spot. context: adds one line under the cursor saying what the selected row is -- what a version said, where the row itself can only say when it was kept -- and it is called for the selected row only.

lib/qr_code.rb renders a draft's preview URL as a scannable QR code in half-block glyphs -- the smallest correct subset of the spec that the job needs (byte mode, EC level L, versions 1-5, fixed mask 0), verified module-for-module against a reference encoder.

lib/preview_server.rb backs ./blog.sh preview: a minimal TCPServer-based static file server (GET/HEAD, no keep-alive, thread-per-connection), in place of the obvious ruby -run -e httpd one-liner -- that depends on webrick, a default gem some distros split out of their minimal Ruby package (see Decisions). Percent-decodes the request path itself and rejects anything that resolves outside the served root, the one security property a static file server actually needs. It answers byte ranges (206, and 416 for a range past the end) and streams the file rather than reading it whole: without ranges Safari refuses to play a media element at all, and seeking in a video or audio post is broken everywhere else -- a preview that cannot show what the deployed site does is not a preview. Every extension the engine can attach to a post has a MIME type here, for the same reason.