Chapter 7: Menus and the DOS look
This chapter:
- Clear the screen and move the cursor with raw ANSI escape sequences.
- Print in colour, when needed.
- Draw a box with Unicode line-drawing characters.
- Print a coloured banner, and wire it all into a proper menu-loop state machine.
- Handle the one Windows-specific wrinkle, once, in a
whenblock.
Where we left off: Chapters 1 to 6 only ever printed plain lines. This chapter gives programs a face: colour, framed boxes, a screen that clears and redraws. Everything from here on starts looking like the DOS-era applications, just for giggles.
7.1 What a terminal actually is
A terminal is a stream of bytes. Most of those bytes are the characters you want printed, but some of them are escape sequences: a special byte (\x1b, the ASCII "escape" character) followed by more bytes the terminal interprets as an instruction instead of text to display. ANSI/VT100 is a decades-old, still near-universal standard for what those instructions mean: clear the screen, move the cursor, change the colour.
7.2 Clearing and positioning
Two sequences do almost everything you need:
clear_screen :: proc() {
fmt.print("\x1b[2J\x1b[H")
}
move_cursor :: proc(row, col: int) {
fmt.printf("\x1b[%d;%dH", row, col)
}
\x1b[2J clears the whole screen; \x1b[H moves the cursor to the top-left corner (row 1, column 1); clear_screen does both together, since a cleared screen with the cursor left wherever it was is rarely what you want. \x1b[{row};{col}H moves the cursor to any specific row and column.
Why wrap two-character escape codes in named procedures at all? Because
fmt.print("\x1b[2J\x1b[H")scattered through a program tells a reader nothing;clear_screen()does. The wrapping costs nothing at runtime and buys you a program that reads like what it's doing, not like a string of magic bytes you have to already know how to decode.
7.3 Colour
Colour is another escape sequence, an SGR (Select Graphic Rendition) code: \x1b[31m switches following text to red, \x1b[0m resets back to the terminal's default. A small enum keeps the codes named rather than scattered as bare numbers, the same table idiom from Chapter 6's Difficulty:
Colour :: enum {
Default,
Red,
Green,
Yellow,
Cyan,
}
colour_code :: proc(c: Colour) -> int {
switch c {
case .Default: return 39
case .Red: return 31
case .Green: return 32
case .Yellow: return 33
case .Cyan: return 36
}
return 39
}
set_colour :: proc(c: Colour) {
fmt.printf("\x1b[%dm", colour_code(c))
}
reset_colour :: proc() {
fmt.print("\x1b[0m")
}
Gotcha: always reset before your program exits, even on a crash. Colour set with
set_colourstays in effect until something resets it, including in the user's next shell prompt, if your program never callsreset_colourbefore quitting. Every colour-using program we write here pairs aset_colourwith areset_colouron every exit path, including the quit branch of the menu loop in ยง7.6. A terminal stuck showing every prompt in red until the user figures out to typeresetis a real, memorable annoyance: don't inflict it upon them without good reason!
7.4 Box-drawing characters
Unicode's box-drawing block (โโโโโโโโคโฌโดโผ and friends) gives you clean corners and straight lines without ASCII's +/-/| approximations. Drawing a box of any size reuses Chapter 5's row-by-row technique, just with different characters:
draw_box :: proc(width, height: int) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
strings.write_rune(&b, 'โ')
for _ in 0 ..< width-2 {
strings.write_rune(&b, 'โ')
}
strings.write_rune(&b, 'โ')
strings.write_byte(&b, '\n')
for _ in 0 ..< height-2 {
strings.write_rune(&b, 'โ')
for _ in 0 ..< width-2 {
strings.write_byte(&b, ' ')
}
strings.write_rune(&b, 'โ')
strings.write_byte(&b, '\n')
}
strings.write_rune(&b, 'โ')
for _ in 0 ..< width-2 {
strings.write_rune(&b, 'โ')
}
strings.write_rune(&b, 'โ')
fmt.println(strings.to_string(b))
}
โโโโโโโโโโโโโโโโโโโโ
โ โ
โ โ
โ โ
โโโโโโโโโโโโโโโโโโโโ
Top row: a corner, width - 2 horizontal lines, the other corner. Middle rows (height - 2 of them): a vertical line, width - 2 spaces, another vertical line. Bottom row: the same shape as the top, with the bottom corners. Three loops, the same "row by row" thinking from Chapter 5, just with four different characters instead of one.
7.5 A banner
A banner is the same box-drawing characters, hand-arranged once for a fixed piece of text, printed in colour:
banner :: proc() {
set_colour(.Cyan)
fmt.println("โโโโโโโโโโโโโโโโโโโโ")
fmt.println("โ MUNINN โ")
fmt.println("โโโโโโโโโโโโโโโโโโโโ")
reset_colour()
}
MUNINN is centred inside the box by hand here: six characters of padding on each side of a six-letter name inside an 18-character-wide box, using the same "count the padding" thinking Chapter 5's draw_row automated. For a fixed banner printed once at startup, writing it out directly like this is honestly clearer than building a general-purpose centring procedure just to call it once.
7.6 The menu-loop state machine
A menu loop is: clear the screen, redraw the frame and menu, read one choice, act on it, repeat, until the user quits. The "read one choice, act on it" step matters: the one-shot input rule means you never act on the same typed line twice, and never leave stale input lying around to be accidentally reused by the next prompt.
print_menu :: proc() {
fmt.println("1. Do a thing")
fmt.println("2. Do another")
fmt.println("Q. Quit")
fmt.print("Choose: ")
}
main :: proc() {
enable_ansi()
scanner: bufio.Scanner
bufio.scanner_init(&scanner, os.to_reader(os.stdin))
defer bufio.scanner_destroy(&scanner)
for {
clear_screen()
banner()
fmt.println()
print_menu()
line, ok := read_line(&scanner)
if !ok {
break
}
switch line {
case "1":
fmt.println("You did a thing! Press Enter to continue.")
read_line(&scanner)
case "2":
fmt.println("You did another thing! Press Enter to continue.")
read_line(&scanner)
case "Q", "q":
reset_colour()
fmt.println("Goodbye!")
return
case:
fmt.println("Not a valid choice. Press Enter to continue.")
read_line(&scanner)
}
}
}
Every branch that isn't quitting ends with a read_line(&scanner) used purely as a "press Enter to continue" pause: it reads and discards one line, giving the user a moment to see what happened before the screen clears and redraws for the next choice. enable_ansi() at the very top is ยง7.7's Windows aside, called unconditionally; it does nothing at all on macOS and Linux.
7.7 Windows aside: enabling ANSI on older consoles
ANSI escapes work natively in every macOS and Linux terminal, and in modern Windows Terminal and PowerShell. Older cmd.exe needs virtual terminal processing switched on once, via SetConsoleMode, before it'll interpret escape codes instead of printing them as literal text. Odin's core:sys/windows binds the Win32 call directly:
import win32 "core:sys/windows"
when ODIN_OS == .Windows {
enable_ansi :: proc() {
handle := win32.GetStdHandle(win32.STD_OUTPUT_HANDLE)
mode: win32.DWORD
win32.GetConsoleMode(handle, &mode)
win32.SetConsoleMode(handle, mode | win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING)
}
} else {
enable_ansi :: proc() {}
}
when ODIN_OS == .Windows is a compile-time condition: on macOS or Linux, the else branch is the only one that even gets compiled, so this file builds cleanly on every platform without core:sys/windows needing to do anything at all outside Windows. enable_ansi() is called once, unconditionally, at the very top of main (ยง7.6); everywhere except old Windows consoles, it's a no-op.
*Odin note:
import win32 "core:sys/windows"outside thewhen, the usage inside it. Odin doesn't allow animportstatement itself inside awhenblock: the import always happens, and the platform check happens around what you do* with the imported names instead. This is a small but real gotcha if you try to write it the other way round.
Checkpoint
A small demo program clears the screen, shows a coloured, box-framed-feeling banner, and runs a working main menu that redraws on every choice and quits cleanly on Q. This is the direct ancestor of Muninn's real menu, arriving in Chapter 9.
Exercises
- Add a third menu choice,
3. Change colour, that cycles the banner's colour through.Cyan,.Green,.Yellowand back to.Cyaneach time it's chosen (store the current colour somewhere it survives between loop iterations). Redraw and confirm the banner actually changes colour. draw_boxfrom ยง7.4 isn't called frommainin this chapter's demo: the banner is hand-drawn text instead. Calldraw_box(22, 5)right afterbanner()in the loop and see how it looks stacked underneath. Would you rather the banner itself were drawn withdraw_box, with "MUNINN" printed inside it? Sketch (in words, not code) what that would take.- Remove the
reset_colour()call from the"Q", "q"branch and run the program, quitting withQ. What does your terminal prompt look like afterwards? Put thereset_colour()back, and explain in one sentence why every exit path needs its own reset rather than relying on one at the very end ofmain. - What happens if you type an empty line (just press Enter) at the
Choose:prompt? Whichcasecatches it, and is that the behaviour you'd want? move_cursorfrom ยง7.2 is never called by this chapter's demo program. Write a tiny separate program that usesclear_screen, thenmove_cursor(3, 10), then prints a single word, and confirm it lands exactly where you expect rather than at the top-left. (Solution sketch: rows and columns are both 1-based here, matching how a person would count them by hand: row 1 is the very top row, not row 0.)
Further reading
- Wikipedia, ANSI escape code: https://en.wikipedia.org/wiki/ANSI_escape_code
- Wikipedia, Box-drawing character: https://en.wikipedia.org/wiki/Box-drawing_character
- Odin,
whenconditional compilation: https://odin-lang.org/docs/overview/#when-statement - Odin,
core:sys/windows: https://pkg.odin-lang.org/core/sys/windows/