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.
10.4 List and search
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
- Add a fifth menu choice,
5. Edit a note, calling theupdate_noteprocedure from Chapter 9's exercise 1 (write it now if you skipped it then). Reusedo_delete's "list, then ask for an id" shape. - Right now
do_deleteaccepts any numberstrconv.parse_intcan parse, including ids that were never valid (-5,999):find_notejust reports "No note with that id." for those. Chapter 11 tightens this considerably; for now, just trace through what happens for each of-5,999and a non-numericabc, and confirm none of them crash the program. print_notes_table's%-20scolumn 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.save_notesis called from bothdo_addanddo_delete, but not fromdo_listordo_search. Why not? What would calling it from those two cost, given neither one changesstore?- 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.