Chapter 3: Talking to the terminal
This chapter:
- Read user input, using
core:bufio. - Parse that text into a number, and handle the case where it isn't one.
- Format output with
fmt.printf. - Write an interactive program: a temperature converter.
Where we left off: Chapter 2 gave you a feel for the Odin language. Every program so far has only ever produced output, but now you'll deal with user input too.
3.1 Scanner
Odin's standard library provides us with a way to easily get user input: a Scanner that reads one line at a time, from core:bufio, wrapped around standard input.
import "core:bufio"
import "core:os"
scanner: bufio.Scanner
bufio.scanner_init(&scanner, os.to_reader(os.stdin))
defer bufio.scanner_destroy(&scanner)
Three things happening here:
bufio.Scanneris a struct you own: you declare it as a local variable.os.to_reader(os.stdin)turns the standard input file handle into the genericio.Readerinterfacebufio.scanner_initexpects.os.stdinis Odin's name for the input stream your program's user is typing into.defer bufio.scanner_destroy(&scanner): schedule the scanner's cleanup for whenever the enclosing procedure returns. In Odin, you are responsible for cleaning up resources, as we'll see more later on. Defer is definitely your best friend for this.
Odin note:
&scannereverywhere.bufio.scanner_initandbufio.scanner_destroyboth take a pointer to yourScanner(^bufio.Scanner), so they can modify the struct you declared.&scannertakes that pointer. We'll pass scanners around by pointer throughout our code: passing the whole struct by value would hand a procedure its own private copy, which isn't what you want when a scanner needs to keep its place in the input.
3.2 Reading one line
Once you have a scanner, reading is two calls: scanner_scan asks for the next line and reports whether there was one (you're at the end of input otherwise); scanner_text gives you that line as a string.
fmt.print("Enter your name: ")
if bufio.scanner_scan(&scanner) {
name := bufio.scanner_text(&scanner)
fmt.printfln("Hello, %s!", name)
}
scanner_scan already strips the trailing newline for you: the line you get back is just the text the user typed. It's still worth trimming any other stray whitespace they might have typed around it (a leading space, a trailing tab) with core:strings:
import "core:strings"
line := strings.trim_space(bufio.scanner_text(&scanner))
fmt.print (no newline) rather than fmt.println is deliberate here: you want the prompt and the user's typed answer to end up on the same line, the way a terminal program normally looks.
Gotcha:
scanner_scanreturnsfalseat end of input, not an error you have to unwrap. If the user closes the input stream (pressing Ctrl-D on macOS/Linux, Ctrl-Z then Enter on Windows, or piping in a file that runs out),scanner_scansimply returnsfalse. Always check it before callingscanner_text: reading past the end of input isn't a crash, but proceeding as if you got a real line will hand you stale or empty data.
3.3 Parsing a number from text
Whatever the user typed is a string: "21.5", not the number 21.5. Converting it is core:strconv, and it follows the pattern you'll see everywhere from here on: a value, and a bool telling you whether it's any good.
import "core:strconv"
value, ok := strconv.parse_f64(line)
if ok {
fmt.printfln("Got %.1f", value)
} else {
fmt.println("That wasn't a number.")
}
strconv.parse_int(line) is the equivalent for whole numbers. Neither one panics or raises an exception on bad input: they hand you back ok = false and leave it entirely up to you what happens next. That "up to you" is the point: Chapter 11 turns this pattern into a proper reusable helper, but even here, in your very first interactive program, you can start to build the habit of checking ok rather than trusting the input blindly.
Odin note:
(value, ok)is everywhere. You saw multiple return values in Chapter 2 as a language feature; here's the idiom they exist to serve. Odin has no exceptions. Anything that might fail, parsing text, opening a file (Chapter 8), finding something in a collection (Chapter 9), returns an extrabool(or, where the reason for failure matters, a properErrorvalue) instead.
3.4 Formatted output
You've used fmt.printf's verbs already: %s for a string, %d for an integer. A few more:
%v: Odin's "just show me something sensible" default; works on almost any value.%.1f: a float to one decimal place.%7d: an integer padded out to at least 7 characters wide.
That last one hides a small surprise if you've used C's printf before. In C, a bare width like %7d pads with spaces. In Odin, it pads with zeros:
fmt.printfln("[%7d]", 42) // [0000042]
To get the space-padding you probably wanted, add an explicit space flag before the width:
fmt.printfln("[% 7d]", 42) // [ 42]
Gotcha: Odin's default integer/float width padding is zero, not space. If you want a right-aligned column of numbers with space padding (which you will, building a table in a moment), write the width as
% 7dor% 10.1f, space flag first. It reads a little oddly the first few times, but you get used to it quickly.
3.5 The program: a temperature converter
Let's put reading, parsing and a retry loop together to build a temperature conversion utility.
// convert.odin
package main
import "core:bufio"
import "core:fmt"
import "core:os"
import "core:strconv"
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
}
read_celsius :: proc(scanner: ^bufio.Scanner) -> f64 {
for {
fmt.print("Temperature in Celsius: ")
line, ok := read_line(scanner)
if !ok {
os.exit(1) // input closed on us, nothing sensible left to do but stop
}
value, parsed := strconv.parse_f64(line)
if parsed {
return value
}
fmt.println("Please enter a number, e.g. 21.5")
}
}
main :: proc() {
scanner: bufio.Scanner
bufio.scanner_init(&scanner, os.to_reader(os.stdin))
defer bufio.scanner_destroy(&scanner)
celsius := read_celsius(&scanner)
fahrenheit := celsius*9.0/5.0 + 32.0
fmt.printfln("%.1f°C is %.1f°F", celsius, fahrenheit)
}
Run it and try both a bad answer and a good one:
$ odin run convert.odin -file
Temperature in Celsius: hot
Please enter a number, e.g. 21.5
Temperature in Celsius: 21.5
21.5°C is 70.7°F
Read read_celsius: it's a for {} infinite loop (§2.5's third shape) that only ever leaves through the return value inside the if parsed: the "keep asking until you get a sensible answer" pattern in action. read_line exists as its own small procedure because you'll want to reuse this "read a trimmed line, or tell me input ran out" behaviour again in Chapter 6, Chapter 8, and throughout Muninn.
3.6 A second pass: a table
Now let's print a small conversion table, ten degrees at a time, right-aligned.
// table.odin
package main
import "core:fmt"
main :: proc() {
fmt.println("Celsius Fahrenheit")
for c := 0; c <= 100; c += 10 {
f := f64(c)*9.0/5.0 + 32.0
fmt.printfln("% 7d % 10.1f", c, f)
}
}
Celsius Fahrenheit
0 32.0
10 50.0
20 68.0
...
100 212.0
Notice the space flag on both % 7d and % 10.1f; leave it off and watch the column fill with zeros instead, exactly as §3.4 highlighted.
Checkpoint
convert.odin reads a Celsius temperature, re-prompting on bad input, and prints the Fahrenheit equivalent. table.odin prints a right-aligned conversion table. You have now read input, parsed it and handled failure without crashing.
Exercises
- Extend
convert.odinto also print the Kelvin equivalent (kelvin = celsius + 273.15) on the same line as the Fahrenheit result. - Write the reverse converter: prompt for a Fahrenheit temperature and print its Celsius equivalent, reusing
read_linefromconvert.odin. read_celsiuscallsos.exit(1)the moment input runs out. What would go wrong if you instead justreturn 0from that branch and letmaincarry on as if the user had typed0? (Solution sketch: try it by piping in genuinely empty input:printf "" | odin run convert.odin -file, withos.exit(1)first, then with areturn 0in its place, and compare what each version prints. Silently substituting a fake value hides the fact that nothing was ever actually entered.)- Modify
table.odinto print Celsius from -20 to 40 in steps of 5, instead of 0 to 100 in steps of 10. Watch what a negative value does to the% 7dcolumn: does it still line up? - What does
strconv.parse_f64("21.5abc")return? Doesokcome backtrueorfalse? Write a one-line test program to find out, then compare it againststrconv.parse_f64_prefix, which returns a third value: how many characters it actually managed to read as a number. (Solution sketch:parse_f64is strict: it demands the whole string be a valid number, sookcomes backfalsehere;parse_f64_prefixhappily reads the21.5and tells you it stopped after 4 characters,ok = true.read_celsiusin §3.5 wants the strict one: a user who typed21.5abcalmost certainly mistyped something, and silently accepting the21.5prefix would hide that from them.)
Further reading
- Odin
core:bufio: https://pkg.odin-lang.org/core/bufio/ - Odin
core:strconv: https://pkg.odin-lang.org/core/strconv/ - Odin
core:fmt(format verbs): https://pkg.odin-lang.org/core/fmt/ - Odin
core:strings: https://pkg.odin-lang.org/core/strings/ - Odin
core:os: https://pkg.odin-lang.org/core/os/