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.
@setCold(true);
term.cook() catch {};
std.builtin.default_panic(msg, trace);
}
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.
Top comments (4)
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!
Love this. Do you plan to add support for mouse input / scrolling?
Yes, that is planned.
Mouse support has been added now. You can check out the input-demo example program to see how it works.