a modern tui library written in zig
1# libvaxis
2
3```
4It begins with them, but ends with me. Their son, Vaxis
5```
6
7
8
9Libvaxis _does not use terminfo_. Support for vt features is detected through
10terminal queries.
11
12Vaxis uses zig `0.15.1`.
13
14## Features
15
16libvaxis supports all major platforms: macOS, Windows, Linux/BSD/and other
17Unix-likes.
18
19- RGB
20- [Hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) (OSC 8)
21- Bracketed Paste
22- [Kitty Keyboard Protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
23- [Fancy underlines](https://sw.kovidgoyal.net/kitty/underlines/) (undercurl, etc)
24- Mouse Shapes (OSC 22)
25- System Clipboard (OSC 52)
26- System Notifications (OSC 9)
27- System Notifications (OSC 777)
28- Synchronized Output (Mode 2026)
29- [Unicode Core](https://github.com/contour-terminal/terminal-unicode-core) (Mode 2027)
30- Color Mode Updates (Mode 2031)
31- [In-Band Resize Reports](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) (Mode 2048)
32- Images ([kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/))
33- [Explicit Width](https://github.com/kovidgoyal/kitty/blob/master/docs/text-sizing-protocol.rst) (width modifiers only)
34
35## Usage
36
37[Documentation](https://rockorager.github.io/libvaxis/#vaxis.Vaxis)
38
39The library provides both a low level API suitable for making applications of
40any sort as well as a higher level framework. The low level API is suitable for
41making applications of any type, providing your own event loop, and gives you
42full control over each cell on the screen.
43
44The high level API, called `vxfw` (Vaxis framework), provides a Flutter-like
45style of API. The framework provides an application runtime which handles the
46event loop, focus management, mouse handling, and more. Several widgets are
47provided, and custom widgets are easy to build. This API is most likely what you
48want to use for typical TUI applications.
49
50### Add libvaxis to your project
51
52```console
53zig fetch --save git+https://github.com/rockorager/libvaxis.git
54```
55Add this to your build.zig
56
57```zig
58 const vaxis = b.dependency("vaxis", .{
59 .target = target,
60 .optimize = optimize,
61 });
62
63 exe.root_module.addImport("vaxis", vaxis.module("vaxis"));
64```
65
66or for ZLS support
67
68```zig
69 // create module
70 const exe_mod = b.createModule(.{
71 .root_source_file = b.path("src/main.zig"),
72 .target = target,
73 .optimize = optimize,
74 });
75
76 // add vaxis dependency to module
77 const vaxis = b.dependency("vaxis", .{
78 .target = target,
79 .optimize = optimize,
80 });
81 exe_mod.addImport("vaxis", vaxis.module("vaxis"));
82
83 //create executable
84 const exe = b.addExecutable(.{
85 .name = "project_foo",
86 .root_module = exe_mod,
87 });
88 // install exe below
89```
90
91### vxfw (Vaxis framework)
92
93Let's build a simple button counter application. This example can be run using
94the command `zig build example -Dexample=counter`. The below application has
95full mouse support: the button *and mouse shape* will change style on hover, on
96click, and has enough logic to cancel a press if the release does not occur over
97the button. Try it! Click the button, move the mouse off the button and release.
98All of this logic is baked into the base `Button` widget.
99
100```zig
101const std = @import("std");
102const vaxis = @import("vaxis");
103const vxfw = vaxis.vxfw;
104
105/// Our main application state
106const Model = struct {
107 /// State of the counter
108 count: u32 = 0,
109 /// The button. This widget is stateful and must live between frames
110 button: vxfw.Button,
111
112 /// Helper function to return a vxfw.Widget struct
113 pub fn widget(self: *Model) vxfw.Widget {
114 return .{
115 .userdata = self,
116 .eventHandler = Model.typeErasedEventHandler,
117 .drawFn = Model.typeErasedDrawFn,
118 };
119 }
120
121 /// This function will be called from the vxfw runtime.
122 fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
123 const self: *Model = @ptrCast(@alignCast(ptr));
124 switch (event) {
125 // The root widget is always sent an init event as the first event. Users of the
126 // library can also send this event to other widgets they create if they need to do
127 // some initialization.
128 .init => return ctx.requestFocus(self.button.widget()),
129 .key_press => |key| {
130 if (key.matches('c', .{ .ctrl = true })) {
131 ctx.quit = true;
132 return;
133 }
134 },
135 // We can request a specific widget gets focus. In this case, we always want to focus
136 // our button. Having focus means that key events will be sent up the widget tree to
137 // the focused widget, and then bubble back down the tree to the root. Users can tell
138 // the runtime the event was handled and the capture or bubble phase will stop
139 .focus_in => return ctx.requestFocus(self.button.widget()),
140 else => {},
141 }
142 }
143
144 /// This function is called from the vxfw runtime. It will be called on a regular interval, and
145 /// only when any event handler has marked the redraw flag in EventContext as true. By
146 /// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events
147 /// which don't change state (ie mouse motion, unhandled key events, etc)
148 fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
149 const self: *Model = @ptrCast(@alignCast(ptr));
150 // The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum
151 // constraint. The minimum constraint will always be set, even if it is set to 0x0. The
152 // maximum constraint can have null width and/or height - meaning there is no constraint in
153 // that direction and the widget should take up as much space as it needs. By calling size()
154 // on the max, we assert that it has some constrained size. This is *always* the case for
155 // the root widget - the maximum size will always be the size of the terminal screen.
156 const max_size = ctx.max.size();
157
158 // The DrawContext also contains an arena allocator that can be used for each frame. The
159 // lifetime of this allocation is until the next time we draw a frame. This is useful for
160 // temporary allocations such as the one below: we have an integer we want to print as text.
161 // We can safely allocate this with the ctx arena since we only need it for this frame.
162 const count_text = try std.fmt.allocPrint(ctx.arena, "{d}", .{self.count});
163 const text: vxfw.Text = .{ .text = count_text };
164
165 // Each widget returns a Surface from its draw function. A Surface contains the rectangular
166 // area of the widget, as well as some information about the surface or widget: can we focus
167 // it? does it handle the mouse?
168 //
169 // It DOES NOT contain the location it should be within its parent. Only the parent can set
170 // this via a SubSurface. Here, we will return a Surface for the root widget (Model), which
171 // has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface
172 // with an offset and a z-index - the offset can be negative. This lets a parent draw a
173 // child and place it within itself
174 const text_child: vxfw.SubSurface = .{
175 .origin = .{ .row = 0, .col = 0 },
176 .surface = try text.draw(ctx),
177 };
178
179 const button_child: vxfw.SubSurface = .{
180 .origin = .{ .row = 2, .col = 0 },
181 .surface = try self.button.draw(ctx.withConstraints(
182 ctx.min,
183 // Here we explicitly set a new maximum size constraint for the Button. A Button will
184 // expand to fill its area and must have some hard limit in the maximum constraint
185 .{ .width = 16, .height = 3 },
186 )),
187 };
188
189 // We also can use our arena to allocate the slice for our SubSurfaces. This slice only
190 // needs to live until the next frame, making this safe.
191 const children = try ctx.arena.alloc(vxfw.SubSurface, 2);
192 children[0] = text_child;
193 children[1] = button_child;
194
195 return .{
196 // A Surface must have a size. Our root widget is the size of the screen
197 .size = max_size,
198 .widget = self.widget(),
199 // We didn't actually need to draw anything for the root. In this case, we can set
200 // buffer to a zero length slice. If this slice is *not zero length*, the runtime will
201 // assert that its length is equal to the size.width * size.height.
202 .buffer = &.{},
203 .children = children,
204 };
205 }
206
207 /// The onClick callback for our button. This is also called if we press enter while the button
208 /// has focus
209 fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void {
210 const ptr = maybe_ptr orelse return;
211 const self: *Model = @ptrCast(@alignCast(ptr));
212 self.count +|= 1;
213 return ctx.consumeAndRedraw();
214 }
215};
216
217pub fn main() !void {
218 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
219 defer _ = gpa.deinit();
220
221 const allocator = gpa.allocator();
222
223 var app = try vxfw.App.init(allocator);
224 defer app.deinit();
225
226 // We heap allocate our model because we will require a stable pointer to it in our Button
227 // widget
228 const model = try allocator.create(Model);
229 defer allocator.destroy(model);
230
231 // Set the initial state of our button
232 model.* = .{
233 .count = 0,
234 .button = .{
235 .label = "Click me!",
236 .onClick = Model.onClick,
237 .userdata = model,
238 },
239 };
240
241 try app.run(model.widget(), .{});
242}
243```
244
245### Low level API
246
247Vaxis requires three basic primitives to operate:
248
2491. A TTY instance
2502. An instance of Vaxis
2513. An event loop
252
253The library provides a general purpose posix TTY implementation, as well as a
254multi-threaded event loop implementation. Users of the library are encouraged to
255use the event loop of their choice. The event loop is responsible for reading
256the TTY, passing the read bytes to the vaxis parser, and handling events.
257
258A core feature of Vaxis is its ability to detect features via terminal queries
259instead of relying on a terminfo database. This requires that the event loop
260also handle these query responses and update the Vaxis.caps struct accordingly.
261See the `Loop` implementation to see how this is done if writing your own event
262loop.
263
264```zig
265const std = @import("std");
266const vaxis = @import("vaxis");
267const Cell = vaxis.Cell;
268const TextInput = vaxis.widgets.TextInput;
269const border = vaxis.widgets.border;
270
271// This can contain internal events as well as Vaxis events.
272// Internal events can be posted into the same queue as vaxis events to allow
273// for a single event loop with exhaustive switching. Booya
274const Event = union(enum) {
275 key_press: vaxis.Key,
276 winsize: vaxis.Winsize,
277 focus_in,
278 foo: u8,
279};
280
281pub fn main() !void {
282 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
283 defer {
284 const deinit_status = gpa.deinit();
285 //fail test; can't try in defer as defer is executed after we return
286 if (deinit_status == .leak) {
287 std.log.err("memory leak", .{});
288 }
289 }
290 const alloc = gpa.allocator();
291
292 // Initialize a tty
293 var buffer: [1024]u8 = undefined;
294 var tty = try vaxis.Tty.init(&buffer);
295 defer tty.deinit();
296
297 // Initialize Vaxis
298 var vx = try vaxis.init(alloc, .{});
299 // deinit takes an optional allocator. If your program is exiting, you can
300 // choose to pass a null allocator to save some exit time.
301 defer vx.deinit(alloc, tty.writer());
302
303
304 // The event loop requires an intrusive init. We create an instance with
305 // stable pointers to Vaxis and our TTY, then init the instance. Doing so
306 // installs a signal handler for SIGWINCH on posix TTYs
307 //
308 // This event loop is thread safe. It reads the tty in a separate thread
309 var loop: vaxis.Loop(Event) = .{
310 .tty = &tty,
311 .vaxis = &vx,
312 };
313 try loop.init();
314
315 // Start the read loop. This puts the terminal in raw mode and begins
316 // reading user input
317 try loop.start();
318 defer loop.stop();
319
320 // Optionally enter the alternate screen
321 try vx.enterAltScreen(tty.writer());
322
323 // We'll adjust the color index every keypress for the border
324 var color_idx: u8 = 0;
325
326 // init our text input widget. The text input widget needs an allocator to
327 // store the contents of the input
328 var text_input = TextInput.init(alloc);
329 defer text_input.deinit();
330
331 // Sends queries to terminal to detect certain features. This should always
332 // be called after entering the alt screen, if you are using the alt screen
333 try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s);
334
335 while (true) {
336 // nextEvent blocks until an event is in the queue
337 const event = loop.nextEvent();
338 // exhaustive switching ftw. Vaxis will send events if your Event enum
339 // has the fields for those events (ie "key_press", "winsize")
340 switch (event) {
341 .key_press => |key| {
342 color_idx = switch (color_idx) {
343 255 => 0,
344 else => color_idx + 1,
345 };
346 if (key.matches('c', .{ .ctrl = true })) {
347 break;
348 } else if (key.matches('l', .{ .ctrl = true })) {
349 vx.queueRefresh();
350 } else {
351 try text_input.update(.{ .key_press = key });
352 }
353 },
354
355 // winsize events are sent to the application to ensure that all
356 // resizes occur in the main thread. This lets us avoid expensive
357 // locks on the screen. All applications must handle this event
358 // unless they aren't using a screen (IE only detecting features)
359 //
360 // The allocations are because we keep a copy of each cell to
361 // optimize renders. When resize is called, we allocated two slices:
362 // one for the screen, and one for our buffered screen. Each cell in
363 // the buffered screen contains an ArrayList(u8) to be able to store
364 // the grapheme for that cell. Each cell is initialized with a size
365 // of 1, which is sufficient for all of ASCII. Anything requiring
366 // more than one byte will incur an allocation on the first render
367 // after it is drawn. Thereafter, it will not allocate unless the
368 // screen is resized
369 .winsize => |ws| try vx.resize(alloc, tty.writer(), ws),
370 else => {},
371 }
372
373 // vx.window() returns the root window. This window is the size of the
374 // terminal and can spawn child windows as logical areas. Child windows
375 // cannot draw outside of their bounds
376 const win = vx.window();
377
378 // Clear the entire space because we are drawing in immediate mode.
379 // vaxis double buffers the screen. This new frame will be compared to
380 // the old and only updated cells will be drawn
381 win.clear();
382
383 // Create a style
384 const style: vaxis.Style = .{
385 .fg = .{ .index = color_idx },
386 };
387
388 // Create a bordered child window
389 const child = win.child(.{
390 .x_off = win.width / 2 - 20,
391 .y_off = win.height / 2 - 3,
392 .width = 40 ,
393 .height = 3 ,
394 .border = .{
395 .where = .all,
396 .style = style,
397 },
398 });
399
400 // Draw the text_input in the child window
401 text_input.draw(child);
402
403 // Render the screen. Using a buffered writer will offer much better
404 // performance, but is not required
405 try vx.render(tty.writer());
406 }
407}
408```
409
410## Contributing
411
412Contributions are welcome. Please submit a PR on Github,
413[tangled](https://tangled.sh/@rockorager.dev/libvaxis), or a patch on the
414[mailing list](mailto:~rockorager/libvaxis@lists.sr.ht)
415
416## Community
417
418We use [Github Discussions](https://github.com/rockorager/libvaxis/discussions)
419as the primary location for community support, showcasing what you are working
420on, and discussing library features and usage.
421
422We also have an IRC channel on libera.chat: join us in #vaxis.