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![vaxis demo gif](vaxis.gif) 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.