Writing an editor, chapter 1

Enter raw-mode and become a TUI

Return Home

In the setup we created a projects with zig init, so we should now have two files under src/. main.zig is the entry point of our binary and root.zig is the root of our libraries. Go ahead and empty these files of everything except @import and pub fn main statements. The main function in main.zig is the function that the binary will begin execution at. You can also see an import that matches the name of your directory. This is an alias to your root.zig file (check out the b.addModule in build.zig). For our project, we'll use main to do binary relevant stuff (such as reading command-line arguments) and call into the core logic provided by root.zig.

Let's start reading input! Update your main.zig file to something like this.

// Import the standard library, provided by zig.
const std = @import("std");
// Import `root.zig` using it's alias.
const <name> = @import("<name>"); // replace with your alias

pub fn main() !void {
	// Get a constant file instance of the standard input.
    const stdin = std.fs.File.stdin();
	// Create a mutable array of uninitialised data for the reader.
	// to store data as it reads from stdin.
	// A larger buffer may improve performance at the cost of memory.
    var buffer: [128]u8 = undefined;
    // Create a mutable reader that can be used to read data from stdin.
    var reader = stdin.reader(&buffer);

	// Loop forever.
    while (true) {
		// Read a byte (u8) from stdin.
		// Exit the loop if the bytes matches an ASCII "q" or
		// if the read fails.
        if (try reader.interface.takeByte() == 'q') break;
    }
	// Exit the program
}

The main function is now a fallible function that will loop infinitely until you enter a string with "q" on your keyboard. You can also press Ctrl-D or Ctrl-C to send an end of stream or terminate signal respectively, which will cause the program to exit. Try it by running zig build and then run ./zig-out/bin/<name>. As you type, you should see the characters enter in the terminal, and when you press Enter, you will finally send that input to the program. This is the default behaviour of your terminal, which is not really ideal for an editor which should continuously read data and completely control the content printed. What we want is raw mode, which can be enabled by sending some flags to the operating system.

Speaking of operating systems, this series will be focusing on a POSIX (e.g. Linux and Mac) implementation of a terminal API, but you can also refer to the analogous implementations I've made for Windows and WASI. A great boon to Zig is its simplicity of writing cross-platform code. It's as simple as branching based off builtin.os.tag where needed and letting the compiler choose the appropriate branch. I've found this much simpler than using Rust's conditional configuration because it requires no special syntax.

Enable Raw Mode

The following example demonstrates the flags we set in order to enter raw mode. A word of warning though: I consider the following as dangerous to run, I recommend running our program in a fresh terminal that you're okay with killing. It won't do anything particularly risky, but there are a few things to consider here.

  • Your application may be impossible to quit normally. You can use the kill command or enter "q" as we included in our loop.
  • Quitting the application will leave your terminal instance permanently in raw mode.
  • Any terminal history you had is hidden until you print "\x1b[?1049l".
pub fn enableRawMode(stdin: *const std.fs.File, stdout: *const std.fs.File) !void {
	// Get the current state of stdin.
    var termios = try std.posix.tcgetattr(stdin.handle);
    var buffer: [128]u8 = undefined;
    var writer = stdout.writer(&buffer);

    // Enable alternate buffer.
    _ = try writer.interface.write("\x1b[?1049h");
	try writer.flush();
    // Disable printing of input keys.
    termios.lflag.ECHO = false;
    // Disable line-by-line reading; read byte-by-byte.
    termios.lflag.ICANON = false;
    // Disable signals `ctrl-c` and `ctrl-z`.
    termios.lflag.ISIG = false;
    // Disable signals `ctrl-s` and `ctrl-q`.
    termios.iflag.IXON = false;
    // Disable signal `ctrl-v`.
    termios.lflag.IEXTEN = false;
    // Disable mapping `ctrl-m` and `enter` to `\n`; read as `\r` instead.
    termios.iflag.ICRNL = false;
    // Disable post-processing of `\n` to `\r\n`.
    termios.oflag.OPOST = false;
    // Disable signals from break condition.
    termios.iflag.BRKINT = false;
    // Disable unnecessary parity checking.
    termios.iflag.INPCK = false;
    // Disable stripping UTF-8 to ASCII (i.e. stripping ASCII's 8th parity bit).
    termios.iflag.ISTRIP = false;
    // Set character size to 8.
    termios.cflag.CSIZE = .CS8;
    // Set minimum bytes needed before `read` will trigger.
    termios.cc[@intFromEnum(std.posix.V.MIN)] = 0;
    // Set maximum time needed before `read` will trigger.
    termios.cc[@intFromEnum(std.posix.V.TIME)] = 1;

	// Apply the new state to stdin.
    try std.posix.tcsetattr(stdin.handle, .FLUSH, termios);
}

pub fn main() !void {
    const stdin = std.fs.File.stdin();
    const stdout = std.fs.File.stdout();
    var buffer: [128]u8 = undefined;
    var reader = stdin.reader(&buffer);

    try enableRawMode(&stdin, &stdout);
    while (true) {
        // Don't break on end of stream; wait for the next character instead.
        const char = reader.interface.takeByte() catch |err| {
            switch (err) {
                std.Io.Reader.Error.EndOfStream => continue,
                else => return err,
            }
        };
        if (char == 'q') break;
    }
}

Now if you type anything except "q", absolutely nothing will happen! Not even Ctrl-C can help you now!!!

Disable Raw Mode

As mentioned earlier, by setting the terminal state, we are changing the behaviour of the terminal beyond the lifetime of our program.

$ # e.g. OPOST remains set
$ echo "Hello\nworld!"
Hello
     world!

It would be rude to leave your user's terminal in a broken state, so before our program exits, we must clean up after ourselves and leave everything as we found it.

var termios: ?std.posix.termios = null;

pub fn enableRawMode(stdin: *const std.fs.File, stdout: *const std.fs.File) !void {
    var newTermios = if (termios) |t| t else try std.posix.tcgetattr(stdin.handle);
    termios = newTermios;
	// ...
}

pub fn disableRawMode(_: *const std.fs.File, stdout: *const std.fs.File) !void {
    var buffer: [128]u8 = undefined;
    var writer = stdout.writer(&buffer);

    // Revert alternate buffer.
    _ = try writer.interface.write("\x1b[?1049l");
    try writer.interface.flush();
	// Restore original state
    if (termios) |originalTermios| {
        try std.posix.tcsetattr(stdin.handle, .FLUSH, originalTermios);
    }

}

pub fn main() !void {
    // ...
    try enableRawMode(&stdin, &stdout);
	// Clean up when `main` exits
    defer disableRawMode(&stdin, &stdout);

    while (true) {
        // ...
    }
}

By using the defer keyword here, we can make sure that the program runs disableRawMode when main exits or returns an error. If you now try and do anything after our program exits, it'll behave as usual.

$ echo "Hello\nworld!"
Hello
world!

Nice, but be aware, there are many ways our program can quit beside the usual control flow of our main function.

  • If our program panics
  • If our program calls std.process.exit
  • If our programs receives a signal from the operating system.

Some of these are out of our control (panics may be caused by unknown bugs; signals can be produced by the user) and some are within (we can simply not call std.process.exit or avoid libraries that do so). And thankfully, for those out of our control, we're able to hook into them and run our cleanup.

pub fn enableRawMode(stdin: *const std.fs.File, stdout: *const std.fs.File) !void {
	// ...
	termios = newTermios;
    defer listenKill();
	// ...
}

/// Cleanup state when the program exits early.
pub fn handlePanic() void {
    disableRawMode(std.fs.File.stdin(), std.fs.File.stdout()) catch {};
}

fn handleKill(_: c_int) callconv(.c) void {
    handlePanic();
    std.process.exit(1);
}
/// Listen to signals from the operating system that may cause the program to exit early.
fn listenKill() void {
    const sa: std.posix.Sigaction = .{
        .handler = .{ .handler = &handleKill },
        .mask = std.posix.sigemptyset(),
        .flags = 0,
    };
    std.posix.sigaction(std.posix.SIG.INT, &sa, null);
    std.posix.sigaction(std.posix.SIG.TERM, &sa, null);
}

/// Receives and handles panic that may cause the program to exit early.
fn listenPanic(msg: []const u8, firstTraceAddr: ?usize) noreturn {
    handlePanic();
    std.debug.simple_panic.call(msg, firstTraceAddr);
}
/// A special export of `main.zig` that will overwrite the default panic method in Zig.
pub const panic = std.debug.FullPanic(listenPanic);

Abstractions

I won't be going deep into other platforms in this series. If you want to support non-POSIX platforms you will need some way for the compiler to know which implementation to pick and drop it in. This is essentially polymorphism, and since Zig supports duck-typing this is achieved by creating types that have the same interface. You can follow my implementations here if you wish, but for now we'll focus on POSIX. The files for each platform have essentially the same structure, but the underlying calls to the operating system are different. We can start my breaking up our work into modules. Our main.zig file will get the parts running, we'll create a term.zig file to expose the type the compile selects for our platform, and we'll create a term/ directory to contain the abstract and concrete types for each platform.

$ tree src
src/
├── main.zig
├── root.zig
├── term
│   ├── abstract.zig
│   ├── posix.zig
│   ├── wasi.zig
│   └── windows.zig
└── term.zig

In Zig, conditional compilation is as easy as creating a conditional block against some const variable known at compile time. builtin.os is such an example, and we can use it include code in our program to select operating system it's compiled for.

/// term.zig
const builtin = @import("builtin");

pub const Term = switch (builtin.os.tag) {
    .linux, .macos, .ios, .tvos, .watchos, .visionos, .freebsd, .netbsd, .dragonfly, .openbsd, .serenity, .haiku, .solaris, .illumos => @import("term/posix.zig"),
    else => |tag| @compileError(@tagName(tag) ++ " not supported."),
};

Now Zig, as you may know, doesn't support classes, but the truth is we don't really need them. At the end of the day, classes are a language feature that lets you define an interface that must be equivalent for sub-classes and provides some encapsulation for its invariants. Sometimes languages that support these features can cause the project to become complicated, as they can become harder to opt out of. Zig on the other hand is limited in language features, but it provides us with tools to emulate concepts like traits or classes. What I'm aiming to build here is something like an abstract class, which contains the logic, data, and methods common between all sub-classes. We can write up the abstract struct which will define the base interface that would be useful for our program, and create a concrete struct that will implement the base interface.

/// abstract.zig
const std = @import("std");

stdin: std.fs.File,
stdout: std.fs.File,
reader: std.fs.File.Reader,
writer: std.fs.File.Writer,

const Term = @This();

pub fn init(buffer: []u8) Term {
    const stdin = std.fs.File.stdin();
    const stdout = std.fs.File.stdout();
    return .{ .stdin = stdin, .stdout = stdout, .reader = stdin.reader(buffer), .writer = stdout.writer(buffer) };
}

pub fn enableRawMode(_: *Term) !void {
    @compileError("abstract method enableRawMode called");
}

pub fn disableRawMode(_: *Term) !void {
    @compileError("abstract method disableRawMode called");
}

pub fn takeByte(self: *Term) std.Io.Reader.Error!u8 {
    return self.reader.interface.takeByte();
}

And then our concrete struct can use what the abstract struct provides and otherwise provide an implementation. I trust you to take the work we've done so far and implement the methods for enableRawMode and disableRawMode.

/// posix.zig
const std = @import("std");

const AbstractTerm = @import("abstract.zig");

const Term = @This();

super: AbstractTerm,
termios: std.posix.termios,

pub fn init(buffer: []u8) !Term {
    const term: AbstractTerm = .init(buffer);
    const termios = try std.posix.tcgetattr(term.stdin.handle);

    return .{ .super = term, .termios = termios };
}

pub fn takeByte(self: *Term) std.Io.Reader.Error!u8 {
    return self.super.takeByte();
}

comptime {
    const decls = @typeInfo(AbstractTerm).@"struct".decls;
    for (decls) |decl| {
        if (std.meta.hasMethod(AbstractTerm, decl.name) and !std.meta.hasMethod(Term, decl.name)) {
            @compileLog(decl.name);
            @compileError("Posix struct missing abstract method");
        }
    }
}

That comptime block is a special block of code that will be executed while Zig compiles. We can use this to enforce the rules of inheritance so that we don't accidentally miss a method.

With this set up, we can update main.zig to focus on orchestrating the binary's functionality without any concern for which platform is being used.

/// main.zig
const Term = @import("term.zig").Term;

pub fn main() !void {
    const stdin = std.fs.File.stdin();
    const stdout = std.fs.File.stdout();
    var buffer: [128]u8 = undefined;
	var term: Term = .init(&buffer);
    try term.enableRawMode();
    defer term.disableRawMode() catch {};

    while (true) {
        const char = term.takeByte() catch |err| {
            switch (err) {
                std.Io.Reader.Error.EndOfStream => continue,
                else => return err,
            }
        };
        if (char == 'q') break;
    }
}

Now we have a terminal program that is effectively a TUI. It doesn't exactly do much, but unlike normal terminal programs we'll be able to take in user input while having full control over output displayed to the user. In the next chapter, we'll extend our terminal interface to read from the input have our program react to it by drawing to the output.