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:
- Only implement features that I believe to be necessary to creating simple but functional TUIs
- Write code that will be easy to read and mantain in the future
- NEVER implement features that I don’t understand
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:
\x1b[ is the escape character, which is place at the beginning of every escape sequence38 specifies that the sequence is setting the foreground color of the text; is used to separate escape sequence parameters5 indicates that the 256-color mode should be used for the foreground color1 indicates the color code to pick from the 256 color palette (red)m is the terminator for the escape sequence
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:
- Text styling (bold, italic, underline, colors)
- Cursor manipulation (position, hide, show)
- Clear screen
- Alternate Screen Support
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! :)