Capturing input in real time [Zig 0.14]

Since I couldn’t easily find the answer to this problem, I decided to make this post. Keep in mind that I am still learning the language, so the code snippets might not be optimal


I was trying to make a system to move around the terminal when the user presses WASD. Reading input and updating position are easy tasks.

The problem: When reading input, the user needs to press RETURN in order to complete the operation. I would need to change this interaction so that every time a key is pressed it checks which character was used.

When searching for the solution, I stumbled upon this thing called termios(3). This three-part blog post introduces in a little bit more in depth, but basically, I would need to change how line buffering is handled by the terminal. Termios has a bunch of flags, but we are looking for a property called ICANON.

ICANON, found within c_lflag, relates to what is called canonical mode. Basically:

  • If canonical mode is on, inputs are processed line by line, and you can edit the contents.
  • If noncanonical mode is on, inputs are read immediately. This is what we are looking for.

We have a starting point for our code


Imports

const reader = @import("std").io.getStdIn().reader();
const writer = @import("std").io.getStdOut().writer();

const linux = @import("std").os.linux;
const fs = @import("std").fs;

We are going to work with these imports.

  1. reader is responsible for reading the user’s input.
  2. writer is responsible for printing out texts on the terminal.
  3. linux will help us to get and set properties for termios.
  4. fs will be used to get information about the current terminal.

Main code

pub fn main() !void {
    const tty_file = try fs.openFileAbsolute("/dev/tty", .{});
    defer tty_file.close();
    const tty_fd = tty_file.handle;

    var old_settings: linux.termios = undefined;
    _ = linux.tcgetattr(tty_fd, &old_settings);

    var new_settings: linux.termios = old_settings;
    new_settings.lflag.ICANON = false;
    new_settings.lflag.ECHO = false;

    _ = linux.tcsetattr(tty_fd, linux.TCSA.NOW, &new_settings);

    try writer.print("--- starting input --- \n", .{});

    while (true) {
        try writer.print("press now: \n", .{});
        const c: u8 = reader.readByte() catch break;

        if (c == '\n') break else try writer.print("pressed {c} \n", .{c});
    }

    _ = linux.tcsetattr(tty_fd, linux.TCSA.NOW, &old_settings);

    try writer.print("--- ending --- \n", .{});
}

There is quite a bit to understand here. Let’s start from the start.

const tty_file = try fs.openFileAbsolute("/dev/tty", .{});
defer tty_file.close();
const tty_fd = tty_file.handle;

Here we are getting unique information about the terminal. For that, we access tty and it will return a number.

var old_settings: linux.termios = undefined;
_ = linux.tcgetattr(tty_fd, &old_settings);

This is responsible to access current settings of termios with tcgetattr(). The value will be stored on old_settings. tty_fd will have the identification of current active terminal, so we avoid using a magic number as a parameter.

var new_settings: linux.termios = old_settings;
new_settings.lflag.ICANON = false;
new_settings.lflag.ECHO = false;

_ = linux.tcsetattr(tty_fd, linux.TCSA.NOW, &new_settings);

Now we are changing the configuration. Here, I’m working with two flags: ICANON and ECHO. The latter one tells the terminal to not print what was pressed. Finally, we save the new configurations with tcsetattr().

while (true) {
    try writer.print("press now: \n", .{});
    const c: u8 = reader.readByte() catch break;

    if (c == '\n') break else try writer.print("pressed {c} \n", .{c});
}

Here we are waiting for each keystroke. Zig has this nifty error handling syntax, where if something happens while waiting for input, I can just break out of the loop. If RETURN is pressed, we also break out of the loop; otherwise we print to the screen.

_ = linux.tcsetattr(handle, linux.TCSA.NOW, &old_settings);

The last step, we reset the terminal settings to the original configurations.

Complete implementation

const reader = @import("std").io.getStdIn().reader();
const writer = @import("std").io.getStdOut().writer();

const linux = @import("std").os.linux;
const fs = @import("std").fs;

pub fn main() !void {
    const tty_file = try fs.openFileAbsolute("/dev/tty", .{});
    defer tty_file.close();
    const tty_fd = tty_file.handle;

    var old_settings: linux.termios = undefined;
    _ = linux.tcgetattr(tty_fd, &old_settings);

    var new_settings: linux.termios = old_settings;
    new_settings.lflag.ICANON = false;
    new_settings.lflag.ECHO = false;

    _ = linux.tcsetattr(tty_fd, linux.TCSA.NOW, &new_settings);

    try writer.print("--- starting input --- \n", .{});

    while (true) {
        try writer.print("press now: \n", .{});
        const c: u8 = reader.readByte() catch break;

        if (c == '\n') break else try writer.print("pressed {c} \n", .{c});
    }

    _ = linux.tcsetattr(tty_fd, linux.TCSA.NOW, &old_settings);

    try writer.print("--- ending --- \n", .{});
}

Special thanks for the people on the Zig Programming Language Discord server that helped me to solve this problem.

Next time (maybe?) I will create a post about what I am doing to create a crappy Rouge knockoff.