Odin and Me ยท Chapter 10

Chapter 10: Muninn gets a face

This chapter:

  • Promote Chapter 7's screen demo into shared helpers the whole project uses.
  • Wire a real main menu to the note store from Chapter 9.
  • Build the add, list, search and delete screens.
  • Load notes on startup, and save after every change that matters.
  • End the chapter with a complete, usable Muninn.

Where we left off: Chapter 9 gave you a working note store with no way to reach it except a temporary main. This chapter gives Muninn its actual interface: Chapter 7's ANSI screen layer, wired to Chapter 9's store, behind a real menu.


10.1 The screen layer, promoted

screen.odin is Chapter 7's demo, unchanged in substance, now living in the growing project alongside note.odin and store.odin:

// screen.odin
package main

import "core:bufio"
import "core:fmt"
import "core:strings"
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() {}
}

clear_screen :: proc() {
	fmt.print("\x1b[2J\x1b[H")
}

move_cursor :: proc(row, col: int) {
	fmt.printf("\x1b[%d;%dH", row, col)
}

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")
}

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))
}

banner :: proc() {
	set_colour(.Cyan)
	fmt.println("โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—")
	fmt.println("โ•‘      MUNINN      โ•‘")
	fmt.println("โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•")
	reset_colour()
}

press_enter_to_continue :: proc(scanner: ^bufio.Scanner) {
	fmt.print("Press Enter to continue...")
	read_line(scanner)
}

One addition since Chapter 7: press_enter_to_continue, a small helper for the "give the user a moment to see what happened before the screen clears" pause every menu action ends with. It calls read_line, which now lives in its own file:

// input.odin
package main

import "core:bufio"
import "core:strings"

read_line :: proc(scanner: ^bufio.Scanner) -> (line: string, ok: bool) {
	if !bufio.scanner_scan(scanner) {
		return "", false
	}
	return strings.trim_space(bufio.scanner_text(scanner)), true
}

10.2 The menu, for real

menu.odin replaces Chapter 9's temporary main entirely. print_main_menu draws the choices; run_menu is the loop that clears, redraws, reads one choice, and dispatches:

// menu.odin
package main

import "core:bufio"
import "core:fmt"
import "core:strconv"

print_main_menu :: proc() {
	fmt.println("1. Add a note")
	fmt.println("2. List notes")
	fmt.println("3. Search notes")
	fmt.println("4. Delete a note")
	fmt.println("Q. Quit")
	fmt.print("Choose: ")
}

run_menu :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	for {
		clear_screen()
		banner()
		fmt.println()
		print_main_menu()

		choice, ok := read_line(scanner)
		if !ok {
			return
		}

		switch choice {
		case "1":
			do_add(store, scanner)
		case "2":
			do_list(store)
		case "3":
			do_search(store, scanner)
		case "4":
			do_delete(store, scanner)
		case "Q", "q":
			reset_colour()
			fmt.println("Goodbye!")
			return
		case:
			fmt.println("Not a valid choice.")
		}

		press_enter_to_continue(scanner)
	}
}

This is Chapter 7's menu loop with real work behind each choice instead of placeholder text: the same clear-redraw-read-dispatch shape, the same one-shot input rule, now calling into four small procedures that each do one menu item's job.

10.3 Add

do_add :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	fmt.print("Title: ")
	title, title_ok := read_line(scanner)
	if !title_ok {
		return
	}
	fmt.print("Body: ")
	body, body_ok := read_line(scanner)
	if !body_ok {
		return
	}
	id := add_note(store, title, body)
	save_notes(store)
	fmt.printfln("Added note %d.", id)
}

Two prompts, reusing Chapter 3's read-a-line pattern via read_line, then straight into add_note from Chapter 9. save_notes(store) runs immediately after; ยง10.6 explains why every mutating action saves right away rather than waiting for the user to quit.

print_notes_table :: proc(notes: []Note) {
	if len(notes) == 0 {
		fmt.println("(no notes yet)")
		return
	}
	for n in notes {
		fmt.printfln("%4d  %-20s  %s", n.id, n.title, n.body)
	}
}

do_list :: proc(store: ^Store) {
	print_notes_table(list_notes(store))
}

do_search :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	fmt.print("Search title for: ")
	query, ok := read_line(scanner)
	if !ok {
		return
	}
	results := search_notes(store, query)
	defer delete(results)
	print_notes_table(results[:])
}

print_notes_table is shared by both do_list and do_search (and, in a moment, do_delete too): one place that decides how a list of notes looks on screen. %4d right-aligns the id in a 4-character column; %-20s left-aligns the title in a 20-character one, so a run of notes lines up into readable columns without you hand-padding anything.

$ odin run .
...
1. Add a note
2. List notes
3. Search notes
4. Delete a note
Q. Quit
Choose: 2
   1  Shopping              Milk and eggs
   2  Todo                  Write chapter 10
Press Enter to continue...

10.5 Delete

do_delete :: proc(store: ^Store, scanner: ^bufio.Scanner) {
	print_notes_table(list_notes(store))
	if len(store.notes) == 0 {
		return
	}

	fmt.print("Delete which id? ")
	line, ok := read_line(scanner)
	if !ok {
		return
	}
	id, parsed := strconv.parse_int(line)
	if !parsed {
		fmt.println("Not a number.")
		return
	}

	n, found := find_note(store, id)
	if !found {
		fmt.println("No note with that id.")
		return
	}

	fmt.printfln("Delete \"%s\"? (y/n): ", n.title)
	confirm, confirm_ok := read_line(scanner)
	if !confirm_ok || confirm != "y" {
		fmt.println("Cancelled.")
		return
	}

	delete_note(store, id)
	save_notes(store)
	fmt.println("Deleted.")
}

Delete is the one place in Muninn with a genuine confirmation prompt: list the notes, ask for an id, and ask again before actually removing anything. Nowhere else in the menu asks "are you sure"; adding a note or searching can't lose anything, so there's nothing to confirm.

Why does only delete get a confirmation prompt? Because a confirmation on every action trains a user to click straight through all of them without reading: the moment "are you sure?" stops meaning anything, it stops protecting anyone. Reserve it for the one action that actually destroys data, and it still means something when it matters.

10.6 Tying it to persistence

persist.odin is Chapter 8's save/load pair, adapted for the Store and its Note with an id and created_at alongside title and body:

// persist.odin
package main

import "core:fmt"
import "core:os"
import "core:strconv"
import "core:strings"

SAVE_FILE :: "muninn_notes.txt"
FIELD_SEP :: "\x1f"

encode_note :: proc(n: Note, b: ^strings.Builder) {
	strings.write_int(b, n.id)
	strings.write_string(b, FIELD_SEP)
	strings.write_int(b, n.created_at)
	strings.write_string(b, FIELD_SEP)
	strings.write_string(b, n.title)
	strings.write_string(b, FIELD_SEP)
	strings.write_string(b, n.body)
	strings.write_byte(b, '\n')
}

decode_note :: proc(line: string) -> (n: Note, ok: bool) {
	parts := strings.split(line, FIELD_SEP)
	defer delete(parts)
	if len(parts) != 4 {
		return {}, false
	}

	id, id_ok := strconv.parse_int(parts[0])
	created_at, ca_ok := strconv.parse_int(parts[1])
	if !id_ok || !ca_ok {
		return {}, false
	}

	return Note{
		id         = id,
		created_at = created_at,
		title      = strings.clone(parts[2]),
		body       = strings.clone(parts[3]),
	}, true
}

save_notes :: proc(store: ^Store) {
	b: strings.Builder
	strings.builder_init(&b)
	defer strings.builder_destroy(&b)

	for n in store.notes {
		encode_note(n, &b)
	}

	err := os.write_entire_file(SAVE_FILE, transmute([]byte)strings.to_string(b))
	if err != nil {
		fmt.eprintfln("could not save notes: %v", err)
	}
}

load_notes :: proc() -> Store {
	store: Store

	data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
	if err != nil {
		return store
	}
	defer delete(data)

	for line in strings.split_lines(string(data)) {
		if len(line) == 0 {
			continue
		}
		n, ok := decode_note(line)
		if ok {
			append(&store.notes, n)
			if n.id > store.next_id {
				store.next_id = n.id
			}
		}
	}

	return store
}

The one genuinely new piece: load_notes rebuilds store.next_id from the highest id it finds in the file, not just from a count of notes. Load two notes with ids 1 and 2, delete the first, save, and reload: store.next_id needs to come back as 2, not 1, or the very next add_note would hand out id 1 again, colliding with nothing currently in the store but breaking the promise that every id a note has ever had stays unique for the life of the save file.

main.odin ties everything together: enable ANSI on Windows, load whatever was saved last time, run the menu until the user quits.

// main.odin
package main

import "core:bufio"
import "core:os"

main :: proc() {
	enable_ansi()

	store := load_notes()

	scanner: bufio.Scanner
	bufio.scanner_init(&scanner, os.to_reader(os.stdin))
	defer bufio.scanner_destroy(&scanner)

	run_menu(&store, &scanner)
}

Every mutating action (do_add, do_delete) calls save_notes itself, right after it changes the store, not once, at the end, when the user quits. A "save on quit" scheme is one line shorter, but it means a crash, a closed terminal, or a killed process between two menu actions loses everything typed since the last quit. Saving after every change that matters is the simplest choice that's still honest about what a person typing into a terminal actually expects: what you just did is already safe.

Checkpoint

Muninn is a complete, usable console note-keeper: run it, and a cleared screen shows a coloured banner and a working main menu. Add notes, list them, search by title, delete one with a confirmation prompt, quit, and relaunch to find everything still there.

$ odin run .
โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•—
โ•‘      MUNINN      โ•‘
โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•

1. Add a note
2. List notes
3. Search notes
4. Delete a note
Q. Quit
Choose: 1
Title: Shopping
Body: Milk and eggs
Added note 1.
Press Enter to continue...

Exercises

  1. Add a fifth menu choice, 5. Edit a note, calling the update_note procedure from Chapter 9's exercise 1 (write it now if you skipped it then). Reuse do_delete's "list, then ask for an id" shape.
  2. Right now do_delete accepts any number strconv.parse_int can parse, including ids that were never valid (-5, 999): find_note just reports "No note with that id." for those. Chapter 11 tightens this considerably; for now, just trace through what happens for each of -5, 999 and a non-numeric abc, and confirm none of them crash the program.
  3. print_notes_table's %-20s column assumes no title is longer than 20 characters. Add a note with a much longer title and see what happens to the table's alignment. Is this a bug worth fixing for our scope here, or a reasonable simplification? Defend your answer in a sentence.
  4. save_notes is called from both do_add and do_delete, but not from do_list or do_search. Why not? What would calling it from those two cost, given neither one changes store?
  5. Delete muninn_notes.txt (or rename it, so you can put it back), then run Muninn and add three notes without quitting normally: instead, close the terminal window or send the process a kill signal (Ctrl-\ on macOS/Linux) partway through typing a fourth note's title. Relaunch. How many notes survived, and does that match what you'd predict from reading ยง10.6's save-after-every- mutation explanation?

Further reading

  • Recap: Chapters 5 (row-drawing and padding), 7 (screen/menu), 8 (persistence), 9 (the store): this chapter is assembly, not new language surface.