building a terminal manipulation library with zig

Introduction

A few months ago, I gained an interest in terminal-based applications. I tend to like any programs that are open-source and customizable, so it’s honestly a bit surprising it didn’t happen sooner.

While reading about different command line programs, I found a cool GitHub repository named gambatte-terminal: a Game Boy emulator that runs on the terminal.

I’m a fan of retro games, with a soft spot for the Game Boy and the Game Boy Advanced, and I’ve also played around with emulator development before (I built a chip-8 emulator last year). So, I decided I wanted to recreate this project.

Why make my own library

I decided to write this project in Zig. It’s a language that’s pretty well suited for low-level systems programming and I had just begun learning it when I decided to build my emulator. It seemed like a good opportunity to apply what I had learned and build something cool.

Since I wanted to build an emulator capable of running on the terminal, I needed my program to handle a few different things, like handle user input and colored text output. My first thought was to see if someone had already implemented a library for this.

I found a library on GitHub named mibu, which did everything I wanted and more. While reading its source code, I realized that I understood every line. I thought to myself “I think I understand enough about terminal manipulation that I can just write my own library, instead of using a pre-existing one”.

Generally speaking, I believe that, if you can, you should always build your own tools. That way, you end up with software that you fully understand, can easily modify when needed and avoid any bloat or annoyances associated with any features you don’t want or need.

So, I decided to write my own terminal manipulation library. The knowledge I gained from writing a shitty vim clone last year ended up being a lot more useful than I ever expected it to.

Building a library for personal use

Building a library for personal use is different from building a library that you want other people to eventually use.

During the development of Zterm (that’s the name I ended up picking for my library) I wasn’t really worried on making it the most complete or technically correct library. My main objective was simply to build a library that is useful to me, and learn as much as possible during the possible.

At the start of the project, I set up some simple rules for myself:

How it works

Manipulating your terminal is actually fairly simple. Most features in Zterm are implemented using ANSI escape sequences.

ANSI escape sequences are basically just sets of codes that can be used to alter the command lines’s behavior. Here’s a simple example:

pub fn main() void {
    std.debug.print("{s}Hello, red!\n", .{"\x1b[38;5;1m"});    
}

This code prints the text “Hello, red!” in a red color. The code structure is the following:

Here’s a few other examples:

pub fn main() void {
    std.debug.print("{s}Hello, blue!\n", .{"\x1b[38;5;4m"}); 
    
    // clear screen
    std.debug.print("{s}", .{"\x1b[2J"});

    // enable alternate screen
    std.debug.print("{s}", .{"\x1b[?1049h"})
}

While these ANSI codes are extremely useful and versatile, they’re not exactly intuitive to understand or easy to memorize. Zterm abstracts these codes way, making it easier to customize the command line. Like this:

std.debug.print("{s}Hello, world! (italic){s}\n", .{
    zterm.style.italic.set(),
    zterm.style.italic.reset()
});

Using ANSI codes only, I was able to implement the following features:

Another feature of Zterm is the ability to handle/process user input. This can’t really be done via ANSI codes. Instead, it’s done by customizing the command line’s settings.

Normally, a terminal processes user input line-by-line. You write some text, you press enter, and that line is read and handled. However, this isn’t how most programs work. Most programs process each key press individually. In order to do this inside the terminal, we must set it to raw input mode.

Setting the terminal to raw input mode is a bit different depending on your operating system. I had to write different code to support unix systems and to support windows. The process is always conceptually the same. You get a copy of your terminal instance, you change its configuration using terminal flags, and then set this changed copy as you current terminal instance.

Here’s the code used to do this in unix systems:

pub fn enableUnix() ZTermError!std.posix.termios {
    const orig_termios: std.posix.termios = std.posix.tcgetattr(std.posix.STDIN_FILENO) catch return ZTermError.TerminalSetupFailed;

    var raw: std.posix.termios = orig_termios;

    // terminal flags
    raw.lflag.ECHO = false; // echo user input
    raw.lflag.ICANON = false; // read user input byte by byte
    raw.lflag.ISIG = false; // disable SIGINT and SIGSTP signals
    raw.lflag.IEXTEN = false; // disable CTRL-V
    raw.iflag.IXON = false; // disable CTRL-Q and CTRL-S
    raw.iflag.ICRNL = false; // convert carriage returns into new lines
    raw.iflag.BRKINT = false; // disable break condition from sending SIGINT
    raw.iflag.INPCK = false; // parity checking
    raw.iflag.ISTRIP = false; // strips the 8th bit of each byte
    raw.oflag.OPOST = false; // output processing
    raw.cflag.CSIZE = .CS8; // set character size to 8bits per byte

    if (global_config.input_timeout_ms == std.math.maxInt(u8)) {
        raw.cc[@intFromEnum(std.posix.V.MIN)] = 1; // read must read at least one byte before retuning
        raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
    } else {
        raw.cc[@intFromEnum(std.posix.V.MIN)] = 0; // read can return before receving input
        raw.cc[@intFromEnum(std.posix.V.TIME)] = global_config.input_timeout_ms/100;
    }

    std.posix.tcsetattr(std.posix.STDIN_FILENO, std.posix.TCSA.FLUSH, raw) catch 
        return ZTermError.TerminalSetupFailed;
        
    return orig_termios;
}

This code does exactly what I just described. It grabs an instance of the terminal, modifies it (stops the instant printing of user input, disables CTRL commands, sets input handling to byte-by-byte) and, at the end, tells th terminal to use this new configuration.

Once the terminal is in raw mode, handling user input still has some problems left to solve. Handling inputs such as integers or characters is easy, but handling multiple byte inputs is a bit more tricky, so I also wrote abstractions for that.

I basically just wrote one method that reads user input, and a method that processes user input and returns an easy to understand structure to the user. I’m not sure if this is the best possible implementation, but it’s definetly “good enough”, which is what I was going for with this library. I don’t need the best code, I just need something that works well enough for me to build cross-platform terminal programs.

fn getNextInputUnix() ZTermError!input {
    var c: [32]u8 = undefined;
    c[0] = 0;
        
    const bytes_read = std.posix.read(std.posix.STDIN_FILENO, &c) catch |err| switch (err) {
        error.WouldBlock => 0, // timeout occurred
        else => return ZTermError.InputReadFailed,
    };

    return parseInput(c[0..bytes_read]);
}

fn parseInput(buffer: []const u8) input {
    var ret: input = .{
        .value = if (buffer.len > 0) buffer[0] else 0,
        .key = .NONE,
        .mouse = .{
            .button = .NONE,
            .column = 0,
            .row = 0,
            .shift = false,
            .ctrl = false,
            .meta = false,
            .motion = false
        }
    };

    if (buffer.len == 0) return ret;

    if (buffer.len == 1) {
        const c = buffer[0];
        if (std.ascii.isPrint(c)) {
            ret.key = .PRINTABLE;
            if (std.ascii.isAlphanumeric(c)) {
                ret.key = .ALPHANUM;
            }
        }

        if (c >= 1 and c <= 26) ret.key = @enumFromInt(c);

        switch (c) {
            std.ascii.control_code.cr => ret.key = .ENTER,
            std.ascii.control_code.ht => ret.key = .TAB,
            std.ascii.control_code.bs => ret.key = .BACKSPACE,
            std.ascii.control_code.del => ret.key = .DELETE,
            else => {},
        }
    } else if (buffer.len >= 3 and buffer[0] == '\x1b' and buffer[1] == '[') {
        if (buffer.len == 3) {
            switch (buffer[2]) {
                'A' => ret.key = .ARROW_UP,
                'B' => ret.key = .ARROW_DOWN,
                'C' => ret.key = .ARROW_RIGHT,
                'D' => ret.key = .ARROW_LEFT,
                'H' => ret.key = .HOME,
                'F' => ret.key = .END,
                else => {},
            }
        } else if (buffer.len == 4 and buffer[3] == '~') {
            switch (buffer[2]) {
                '1' => ret.key = .HOME,
                '3' => ret.key = .DELETE,
                '4' => ret.key = .END,
                '5' => ret.key = .PAGE_UP,
                '6' => ret.key = .PAGE_DOWN,
                '7' => ret.key = .HOME,
                '8' => ret.key = .END,
                else => {},
            }
        } else if (buffer.len >= 6 and buffer[2] == '<') {
            ret.key = .MOUSE;
                
            const mouse_data = buffer[3..buffer.len-1];
            const last_char = buffer[buffer.len-1];

            var iter = std.mem.splitAny(u8, mouse_data, ";");
            const B_str = iter.next() orelse return ret;
            const C_str = iter.next() orelse return ret;
            const R_str = iter.next() orelse return ret;

            const B = std.fmt.parseInt(u32, B_str, 10) catch return ret;
            const C = std.fmt.parseInt(u32, C_str, 10) catch return ret;
            const R = std.fmt.parseInt(u32, R_str, 10) catch return ret;

            ret.mouse.column = C;
            ret.mouse.row = R;
            ret.mouse.shift = (B & 4) != 0;
            ret.mouse.meta = (B & 8) != 0;
            ret.mouse.ctrl = (B & 16) != 0;

            if (last_char == 'M') {
                if (B & 32 != 0) {
                    ret.mouse.motion = true;
                    ret.mouse.button = switch (B & 3) {
                        0 => .LEFT,
                        1 => .MIDDLE,
                        2 => .RIGHT,
                        else => .NONE,
                    };
                } else if (B >= 64) {
                    ret.mouse.motion = false;
                    if (B == 64) ret.mouse.button = .SCROLL_UP
                    else if (B == 65) ret.mouse.button = .SCROLL_DOWN
                    else ret.mouse.button = .NONE;
                } else {
                    ret.mouse.motion = false;
                    ret.mouse.button = switch (B & 3) {
                        0 => .LEFT,
                        1 => .MIDDLE,
                        2 => .RIGHT,
                        else => .NONE,
                    };
                }
            } else if (last_char == 'm') {
                ret.mouse.motion = false;
                ret.mouse.button = .RELEASE;
            }
        }
    }

    return ret;
}

ANSI escape sequences, enabling/disabling of terminal raw input mode and the processing of user input are pretty much the core of Zterm. If you understand these concepts, it should be easy enough to create your own library.

Final thoughts and future work

Zterm was the first library I wrote. Honestly, I feel pretty acomplished at having built something I’ll actually use in the future. The github repository for Zterm has about 15 start currently, which means there’s 15 people who think what I built is somewhat interesting. Feels good, man.

My future plans for Zterm are basically just fixing any bugs I find or adding any features I might find necessary in the future.

This is the end of my very first blog post. I hope it was a nice read. Any feedback or criticism is appreciated! :)