Cover image for Easily create TUI programs with zig-spoon! (project demonstration)
Leon Henrik Plickat
Leon Henrik Plickat

Posted on • Updated on

Easily create TUI programs with zig-spoon! (project demonstration)

Hey, remember me? I am that guy who wrote that one article about raw terminal IO. I am back with more terminal stuff!

But this time it's less scary, because this time I took care of the annoying bits so you can focus on having fun!

In this small article I'd like to introduce zig-spoon, a zig library for creating TUI programs. It is still very much work in progress (which includes the API and documentation, naturally) and does not have everything I want it to have yet. But even in its current ~20%-of-the-finished-thing state, it already takes care of up to (and likely more than) 80% of the work of creating TUI applications - well, at least the UI part.

Here is what is available right now:

  • Simple allocation-free abstraction for managing a terminals IO state
    • Easily enter and leave "raw" / "uncooked" mode
    • Does not force you to use some awkward character double-buffering, just place your UI drawing code into a rendering function, which offers a few orders of magnitude more flexibility and means you can integrate non-standard things, like sixel or other image protocols, without them actually needing direct support in the library
    • Easily integrates with all poll / epoll based event loops - or don't, if you just need something static
  • Highly correct input parser - this is the most painful part of TUIs, so I am glad I can offer you a ready-made solution :)
    • Parsing is fully separate from reading the input; You can store input and parse (way) later, parse input from alternative sources or just skip spoons parser and use your own if you want
    • Additionally there is an input description parser, which parsers strings like "C-b", "Alt-F", "C-M-ยต" into the correct Input struct, which is super helpful both for user configurable keybinds and - since it can be used at comptime - hardcoded keybinds
  • Decent text attribute system
    • Easiely set text colour
    • Completely standalone and can be used without the other parts of the library, so zig-spoon is even useful if you have a non-TUI terminal program that just wants fancy text

While most modern TUI libraries use a widget tree, similar to true graphical toolkits, I instead opted to just plop the user into a "clean canvas", which makes zig-spoon considerably more powerful and - at least in my opinion - useful. That said, if everything goes according to my plan, there will eventually be a purely optionaly character buffer and widget graph system you can user on top of the current design.

While there is still a lot to do, I expect the first release (0.1.0) to happen this year.

Spoon in action!

Now that I have spilled the advertisement blurb, here is some code, so you can get a feeling for how it works. The API is still a bit raw and has a few rough corners.

const std = @import("std");
const mem = std.mem;
const heap = std.heap;
const os = std.os;
const meta = std.meta;

const spoon = @import("spoon");

var term: spoon.Term = undefined;
var loop: bool = true;

const title = "Showing off zig-spoon :)";
var counter: usize = 0;

pub fn main() !void {
    // When init'ing the terminal, you install a render function. This is where
    // you will do all your drawing.
    try term.init(render);
    defer term.deinit();

    // SIGWINCH is send when the terminal size is changed, for silly legacy reasons.
    os.sigaction(os.SIG.WINCH, &os.Sigaction{
        .handler = .{ .handler = handleSigWinch },
        .mask = os.empty_sigset,
        .flags = 0,
    }, null);

    var fds: [1]os.pollfd = undefined;
    fds[0] = .{
        .fd = term.tty.handle,
        .events = os.POLL.IN,
        .revents = undefined,

    try term.uncook();
    defer term.cook() catch {};

    try term.hideCursor();

    // Before entering the main loop, you probably want to render once. Since
    // we never rendered before, we do not know the terminal size, so that needs
    // to be fetched first.
    try term.fetchSize();
    try term.setWindowTitle(title);
    try term.updateContent();

    var buf: [16]u8 = undefined;
    while (loop) {
        _ = try os.poll(&fds, -1);

        const read = try term.readInput(&buf);
        var it = spoon.inputParser(buf[0..read]);
        while (it.next()) |in| {
            if (inputEql(in, "C-c") or inputEql(in, "q")) {
                loop = false;
            } else if (inputEql(in, "space")) {
                counter += 1;

                // Call this function whenever you want to trigger a render.
                try term.updateContent();
            } else if (inputEql(in, "r")) {
                counter = 0;
                try term.updateContent();

fn inputEql(in: spoon.Input, comptime descr: []const u8) bool {
    const bind = comptime spoon.Input.fromDescription(descr) catch @compileError("Bad input descriptor");
    return meta.eql(in, bind);

fn render(_: *spoon.Term, _: usize, columns: usize) !void {
    // This is a super lazy example program, so we clear the entire terminal and
    // draw everything again each time. However it is actually pretty simple to
    // roughly keep track of what has changed since last render and only
    // re-render that.
    try term.clear();

    // In a real application you probably want to check if the terminal is large
    // enough for the contents you plan to draw. However this is an abbreviated
    // example, so whatever.

    try term.moveCursorTo(0, 0);
    try term.setAttribute(.{ .fg = .green, .reverse = true });
    const rest = try term.writeLine(columns, " " ++ title);
    try term.writeByteNTimes(' ', rest);

    try term.moveCursorTo(1, 1);
    try term.setAttribute(.{ .fg = .red, .bold = true });
    _ = try term.writeLine(columns - 1, "Press space to increase the counter, r to reset, Ctrl-C or q to exit.");

    // The way you write things to the terminal is in my opinion the weakest
    // part of the API right now and will probably change quite a bit. However
    // for now you can just grab the writer and use the common write functions
    // you know and love from zigs std.
    try term.setAttribute(.{});
    try term.moveCursorTo(3, 3);
    const writer = term.stdout.writer();
    try writer.print("counter: {}", .{counter});

fn handleSigWinch(_: c_int) callconv(.C) void {
    // The size has changed, so we need to fetch it again and then render.
    term.fetchSize() catch {};
    term.updateContent() catch {};

/// Custom panic handler, so that we can try to cook the terminal on a crash,
/// as otherwise all messages will be mangled.
pub fn panic(msg: []const u8, trace: ?*std.builtin.StackTrace) noreturn {
    // I'll likely add a better version of this to the library itself, so you
    // can just import the panic handler.
    term.cook() catch {};
    std.builtin.default_panic(msg, trace);
Enter fullscreen mode Exit fullscreen mode

showing off the above program in a terminal window

And most importantly: This isn't just a toy project, zig-spoon arose while working on real-world projects (which I will also show-off eventually), so it will be continuously improved and kept up-to-date as long as I use my own tools.

Any feedback is welcome :)

Oh, and before I forget, here are the current caveats:

  • Usage in projects linking libc is a bit wonky right now, see https://github.com/ziglang/zig/issues/10181
  • Integration with the event loop from the std and async / threads in general is untested and probably not that great. I personally tend to just hand-roll my event-loops, so this has kinda been ignored for now. But I want to sort this out before a release.
  • Only the "normal" terminal colours are supported right now, 256 and RGB not yet. It's a trivial patch, but I had other priorities for now.
  • Only supports Linux right now, other UNIXoids will follow.
  • You will likely run into issues if you try it out right now, but if you tell me about them, I'll do my best to adress them in a timely manner. Take a look at the issue tracker to see if we are already aware of the issues you are facing.

Discussion (3)

kristoff profile image
Loris Cro • Edited on

Does not force you to use some awkward character double-buffering

haha, but also fair point, the double buffering / virtual terminal representation doesn't have to be coupled with the rest of the stuff.

Thanks for sharing!

gonzus profile image
Gonzalo Diethelm

Love this. Do you plan to add support for mouse input / scrolling?

lhp profile image
Leon Henrik Plickat Author

Yes, that is planned.