Compare commits

..

1 commit

Author SHA1 Message Date
Tim Culverhouse
678f84b3a9
panic: move recovery log to separate function
Move the recovery logic to a separate function so users of the library
may call it *without* using the default panic handler.

Reference: https://github.com/rockorager/libvaxis/discussions/146
2025-01-02 07:54:58 -06:00
12 changed files with 73 additions and 389 deletions

172
README.md
View file

@ -9,6 +9,8 @@ It begins with them, but ends with me. Their son, Vaxis
Libvaxis _does not use terminfo_. Support for vt features is detected through
terminal queries.
Contributions are welcome.
Vaxis uses zig `0.13.0`.
## Features
@ -35,173 +37,7 @@ Unix-likes.
[Documentation](https://rockorager.github.io/libvaxis/#vaxis.Vaxis)
The library provides both a low level API suitable for making applications of
any sort as well as a higher level framework. The low level API is suitable for
making applications of any type, providing your own event loop, and gives you
full control over each cell on the screen.
The high level API, called `vxfw` (Vaxis framework), provides a Flutter-like
style of API. The framework provides an application runtime which handles the
event loop, focus management, mouse handling, and more. Several widgets are
provided, and custom widgets are easy to build. This API is most likely what you
want to use for typical TUI applications.
### vxfw (Vaxis framework)
Let's build a simple button counter application. This example can be run using
the command `zig build example -Dexample=counter`. The below application has
full mouse support: the button *and mouse shape* will change style on hover, on
click, and has enough logic to cancel a press if the release does not occur over
the button. Try it! Click the button, move the mouse off the button and release.
All of this logic is baked into the base `Button` widget.
```zig
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
/// Our main application state
const Model = struct {
/// State of the counter
count: u32 = 0,
/// The button. This widget is stateful and must live between frames
button: vxfw.Button,
/// Helper function to return a vxfw.Widget struct
pub fn widget(self: *Model) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = Model.typeErasedEventHandler,
.drawFn = Model.typeErasedDrawFn,
};
}
/// This function will be called from the vxfw runtime.
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *Model = @ptrCast(@alignCast(ptr));
switch (event) {
// The root widget is always sent an init event as the first event. Users of the
// library can also send this event to other widgets they create if they need to do
// some initialization.
.init => return ctx.requestFocus(self.button.widget()),
.key_press => |key| {
if (key.matches('c', .{ .ctrl = true })) {
ctx.quit = true;
return;
}
},
// We can request a specific widget gets focus. In this case, we always want to focus
// our button. Having focus means that key events will be sent up the widget tree to
// the focused widget, and then bubble back down the tree to the root. Users can tell
// the runtime the event was handled and the capture or bubble phase will stop
.focus_in => return ctx.requestFocus(self.button.widget()),
else => {},
}
}
/// This function is called from the vxfw runtime. It will be called on a regular interval, and
/// only when any event handler has marked the redraw flag in EventContext as true. By
/// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events
/// which don't change state (ie mouse motion, unhandled key events, etc)
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
const self: *Model = @ptrCast(@alignCast(ptr));
// The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum
// constraint. The minimum constraint will always be set, even if it is set to 0x0. The
// maximum constraint can have null width and/or height - meaning there is no constraint in
// that direction and the widget should take up as much space as it needs. By calling size()
// on the max, we assert that it has some constrained size. This is *always* the case for
// the root widget - the maximum size will always be the size of the terminal screen.
const max_size = ctx.max.size();
// The DrawContext also contains an arena allocator that can be used for each frame. The
// lifetime of this allocation is until the next time we draw a frame. This is useful for
// temporary allocations such as the one below: we have an integer we want to print as text.
// We can safely allocate this with the ctx arena since we only need it for this frame.
const count_text = try std.fmt.allocPrint(ctx.arena, "{d}", .{self.count});
const text: vxfw.Text = .{ .text = count_text };
// Each widget returns a Surface from it's draw function. A Surface contains the rectangular
// area of the widget, as well as some information about the surface or widget: can we focus
// it? does it handle the mouse?
//
// It DOES NOT contain the location it should be within it's parent. Only the parent can set
// this via a SubSurface. Here, we will return a Surface for the root widget (Model), which
// has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface
// with an offset and a z-index - the offset can be negative. This lets a parent draw a
// child and place it within itself
const text_child: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try text.draw(ctx),
};
const button_child: vxfw.SubSurface = .{
.origin = .{ .row = 2, .col = 0 },
.surface = try self.button.draw(ctx.withConstraints(
ctx.min,
// Here we explicitly set a new maximum size constraint for the Button. A Button will
// expand to fill it's area and must have some hard limit in the maximum constraint
.{ .width = 16, .height = 3 },
)),
};
// We also can use our arena to allocate the slice for our SubSurfaces. This slice only
// needs to live until the next frame, making this safe.
const children = try ctx.arena.alloc(vxfw.SubSurface, 2);
children[0] = text_child;
children[1] = button_child;
return .{
// A Surface must have a size. Our root widget is the size of the screen
.size = max_size,
.widget = self.widget(),
.focusable = false,
// We didn't actually need to draw anything for the root. In this case, we can set
// buffer to a zero length slice. If this slice is *not zero length*, the runtime will
// assert that it's length is equal to the size.width * size.height.
.buffer = &.{},
.children = children,
};
}
/// The onClick callback for our button. This is also called if we press enter while the button
/// has focus
fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void {
const ptr = maybe_ptr orelse return;
const self: *Model = @ptrCast(@alignCast(ptr));
self.count +|= 1;
return ctx.consumeAndRedraw();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var app = try vxfw.App.init(allocator);
defer app.deinit();
// We heap allocate our model because we will require a stable pointer to it in our Button
// widget
const model = try allocator.create(Model);
defer allocator.destroy(model);
// Set the initial state of our button
model.* = .{
.count = 0,
.button = .{
.label = "Click me!",
.onClick = Model.onClick,
.userdata = model,
},
};
try app.run(model.widget(), .{});
}
```
### Low level API
[Starter repo](https://github.com/rockorager/libvaxis-starter)
Vaxis requires three basic primitives to operate:
@ -220,6 +56,8 @@ also handle these query responses and update the Vaxis.caps struct accordingly.
See the `Loop` implementation to see how this is done if writing your own event
loop.
## Example
```zig
const std = @import("std");
const vaxis = @import("vaxis");

View file

@ -29,7 +29,6 @@ pub fn build(b: *std.Build) void {
// Examples
const Example = enum {
cli,
counter,
fuzzy,
image,
main,

View file

@ -62,7 +62,7 @@ pub fn main() !void {
} else {
selected_option.? = selected_option.? -| 1;
}
} else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
} else if (key.matches(vaxis.Key.enter, .{})) {
if (selected_option) |i| {
log.err("enter", .{});
try text_input.insertSliceAtCursor(options[i]);

View file

@ -1,140 +0,0 @@
const std = @import("std");
const vaxis = @import("vaxis");
const vxfw = vaxis.vxfw;
/// Our main application state
const Model = struct {
/// State of the counter
count: u32 = 0,
/// The button. This widget is stateful and must live between frames
button: vxfw.Button,
/// Helper function to return a vxfw.Widget struct
pub fn widget(self: *Model) vxfw.Widget {
return .{
.userdata = self,
.eventHandler = Model.typeErasedEventHandler,
.drawFn = Model.typeErasedDrawFn,
};
}
/// This function will be called from the vxfw runtime.
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
const self: *Model = @ptrCast(@alignCast(ptr));
switch (event) {
// The root widget is always sent an init event as the first event. Users of the
// library can also send this event to other widgets they create if they need to do
// some initialization.
.init => return ctx.requestFocus(self.button.widget()),
.key_press => |key| {
if (key.matches('c', .{ .ctrl = true })) {
ctx.quit = true;
return;
}
},
// We can request a specific widget gets focus. In this case, we always want to focus
// our button. Having focus means that key events will be sent up the widget tree to
// the focused widget, and then bubble back down the tree to the root. Users can tell
// the runtime the event was handled and the capture or bubble phase will stop
.focus_in => return ctx.requestFocus(self.button.widget()),
else => {},
}
}
/// This function is called from the vxfw runtime. It will be called on a regular interval, and
/// only when any event handler has marked the redraw flag in EventContext as true. By
/// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events
/// which don't change state (ie mouse motion, unhandled key events, etc)
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface {
const self: *Model = @ptrCast(@alignCast(ptr));
// The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum
// constraint. The minimum constraint will always be set, even if it is set to 0x0. The
// maximum constraint can have null width and/or height - meaning there is no constraint in
// that direction and the widget should take up as much space as it needs. By calling size()
// on the max, we assert that it has some constrained size. This is *always* the case for
// the root widget - the maximum size will always be the size of the terminal screen.
const max_size = ctx.max.size();
// The DrawContext also contains an arena allocator that can be used for each frame. The
// lifetime of this allocation is until the next time we draw a frame. This is useful for
// temporary allocations such as the one below: we have an integer we want to print as text.
// We can safely allocate this with the ctx arena since we only need it for this frame.
if (self.count > 0) {
self.button.label = try std.fmt.allocPrint(ctx.arena, "Clicks: {d}", .{self.count});
} else {
self.button.label = "Click me!";
}
// Each widget returns a Surface from it's draw function. A Surface contains the rectangular
// area of the widget, as well as some information about the surface or widget: can we focus
// it? does it handle the mouse?
//
// It DOES NOT contain the location it should be within it's parent. Only the parent can set
// this via a SubSurface. Here, we will return a Surface for the root widget (Model), which
// has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface
// with an offset and a z-index - the offset can be negative. This lets a parent draw a
// child and place it within itself
const button_child: vxfw.SubSurface = .{
.origin = .{ .row = 0, .col = 0 },
.surface = try self.button.draw(ctx.withConstraints(
ctx.min,
// Here we explicitly set a new maximum size constraint for the Button. A Button will
// expand to fill it's area and must have some hard limit in the maximum constraint
.{ .width = 16, .height = 3 },
)),
};
// We also can use our arena to allocate the slice for our SubSurfaces. This slice only
// needs to live until the next frame, making this safe.
const children = try ctx.arena.alloc(vxfw.SubSurface, 1);
children[0] = button_child;
return .{
// A Surface must have a size. Our root widget is the size of the screen
.size = max_size,
.widget = self.widget(),
.focusable = false,
// We didn't actually need to draw anything for the root. In this case, we can set
// buffer to a zero length slice. If this slice is *not zero length*, the runtime will
// assert that it's length is equal to the size.width * size.height.
.buffer = &.{},
.children = children,
};
}
/// The onClick callback for our button. This is also called if we press enter while the button
/// has focus
fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void {
const ptr = maybe_ptr orelse return;
const self: *Model = @ptrCast(@alignCast(ptr));
self.count +|= 1;
return ctx.consumeAndRedraw();
}
};
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var app = try vxfw.App.init(allocator);
defer app.deinit();
// We heap allocate our model because we will require a stable pointer to it in our Button
// widget
const model = try allocator.create(Model);
defer allocator.destroy(model);
// Set the initial state of our button
model.* = .{
.count = 0,
.button = .{
.label = "Click me!",
.onClick = Model.onClick,
.userdata = model,
},
};
try app.run(model.widget(), .{});
}

View file

@ -167,12 +167,12 @@ pub fn main() !void {
demo_tbl.sel_rows = try rows_list.toOwnedSlice();
}
// See Row Content
if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) see_content = !see_content;
if (key.matches(vaxis.Key.enter, .{})) see_content = !see_content;
},
.btm => {
if (key.matchesAny(&.{ vaxis.Key.up, 'k' }, .{}) and moving) active = .mid
// Run Command and Clear Command Bar
else if (key.matchExact(vaxis.Key.enter, .{}) or key.matchExact('j', .{ .ctrl = true })) {
else if (key.matchExact(vaxis.Key.enter, .{})) {
const cmd = try cmd_input.toOwnedSlice();
defer alloc.free(cmd);
if (mem.eql(u8, ":q", cmd) or

View file

@ -99,7 +99,7 @@ pub fn main() !void {
try loop.start();
try vx.enterAltScreen(tty.anyWriter());
vx.queueRefresh();
} else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
} else if (key.matches(vaxis.Key.enter, .{})) {
text_input.clearAndFree();
} else {
try text_input.update(.{ .key_press = key });

View file

@ -300,13 +300,14 @@ pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Even
},
.winsize => |winsize| {
vx.state.in_band_resize = true;
if (@hasField(Event, "winsize")) {
return self.postEvent(.{ .winsize = winsize });
}
switch (builtin.os.tag) {
.windows => {},
// Reset the signal handler if we are receiving in_band_resize
else => Tty.resetSignalHandler(),
}
if (@hasField(Event, "winsize")) {
return self.postEvent(.{ .winsize = winsize });
else => self.tty.resetSignalHandler(),
}
},
}

View file

@ -94,8 +94,9 @@ inline fn parseGround(input: []const u8, data: *const grapheme.GraphemeData) !Re
0x00 => .{ .codepoint = '@', .mods = .{ .ctrl = true } },
0x08 => .{ .codepoint = Key.backspace },
0x09 => .{ .codepoint = Key.tab },
0x0A => .{ .codepoint = 'j', .mods = .{ .ctrl = true } },
0x0D => .{ .codepoint = Key.enter },
0x0A,
0x0D,
=> .{ .codepoint = Key.enter },
0x01...0x07,
0x0B...0x0C,
0x0E...0x1A,

View file

@ -59,7 +59,9 @@ pub const PosixTty = struct {
.handler = .{ .handler = PosixTty.handleWinch },
.mask = switch (builtin.os.tag) {
.macos => 0,
else => posix.empty_sigset,
.linux => posix.empty_sigset,
.freebsd => posix.empty_sigset,
else => @compileError("os not supported"),
},
.flags = 0,
};
@ -90,10 +92,12 @@ pub const PosixTty = struct {
if (!handler_installed) return;
handler_installed = false;
var act = posix.Sigaction{
.handler = .{ .handler = posix.SIG.DFL },
.handler = posix.SIG.DFL,
.mask = switch (builtin.os.tag) {
.macos => 0,
else => posix.empty_sigset,
.linux => posix.empty_sigset,
.freebsd => posix.empty_sigset,
else => @compileError("os not supported"),
},
.flags = 0,
};
@ -214,8 +218,8 @@ pub const WindowsTty = struct {
stdout: windows.HANDLE,
initial_codepage: c_uint,
initial_input_mode: CONSOLE_MODE_INPUT,
initial_output_mode: CONSOLE_MODE_OUTPUT,
initial_input_mode: u32,
initial_output_mode: u32,
// a buffer to write key text into
buf: [4]u8 = undefined,
@ -226,35 +230,58 @@ pub const WindowsTty = struct {
const utf8_codepage: c_uint = 65001;
/// The input mode set by init
pub const input_raw_mode: CONSOLE_MODE_INPUT = .{
.WINDOW_INPUT = 1, // resize events
.MOUSE_INPUT = 1,
.EXTENDED_FLAGS = 1, // allow mouse events
const InputMode = struct {
const enable_window_input: u32 = 0x0008; // resize events
const enable_mouse_input: u32 = 0x0010;
const enable_extended_flags: u32 = 0x0080; // allows mouse events
pub fn rawMode() u32 {
return enable_window_input | enable_mouse_input | enable_extended_flags;
}
};
/// The output mode set by init
pub const output_raw_mode: CONSOLE_MODE_OUTPUT = .{
.PROCESSED_OUTPUT = 1, // handle control sequences
.VIRTUAL_TERMINAL_PROCESSING = 1, // handle ANSI sequences
.DISABLE_NEWLINE_AUTO_RETURN = 1, // disable inserting a new line when we write at the last column
.ENABLE_LVB_GRID_WORLDWIDE = 1, // enables reverse video and underline
const OutputMode = struct {
const enable_processed_output: u32 = 0x0001; // handle control sequences
const enable_virtual_terminal_processing: u32 = 0x0004; // handle ANSI sequences
const disable_newline_auto_return: u32 = 0x0008; // disable inserting a new line when we write at the last column
const enable_lvb_grid_worldwide: u32 = 0x0010; // enables reverse video and underline
fn rawMode() u32 {
return enable_processed_output |
enable_virtual_terminal_processing |
disable_newline_auto_return |
enable_lvb_grid_worldwide;
}
};
pub fn init() !Tty {
const stdin = std.io.getStdIn().handle;
const stdout = std.io.getStdOut().handle;
const stdin = try windows.GetStdHandle(windows.STD_INPUT_HANDLE);
const stdout = try windows.GetStdHandle(windows.STD_OUTPUT_HANDLE);
// get initial modes
var initial_input_mode: windows.DWORD = undefined;
var initial_output_mode: windows.DWORD = undefined;
const initial_output_codepage = windows.kernel32.GetConsoleOutputCP();
const initial_input_mode = try getConsoleMode(CONSOLE_MODE_INPUT, stdin);
const initial_output_mode = try getConsoleMode(CONSOLE_MODE_OUTPUT, stdout);
{
if (windows.kernel32.GetConsoleMode(stdin, &initial_input_mode) == 0) {
return windows.unexpectedError(windows.kernel32.GetLastError());
}
if (windows.kernel32.GetConsoleMode(stdout, &initial_output_mode) == 0) {
return windows.unexpectedError(windows.kernel32.GetLastError());
}
}
// set new modes
try setConsoleMode(stdin, input_raw_mode);
try setConsoleMode(stdout, output_raw_mode);
if (windows.kernel32.SetConsoleOutputCP(utf8_codepage) == 0)
return windows.unexpectedError(windows.kernel32.GetLastError());
{
if (windows.kernel32.SetConsoleMode(stdin, InputMode.rawMode()) == 0)
return windows.unexpectedError(windows.kernel32.GetLastError());
if (windows.kernel32.SetConsoleMode(stdout, OutputMode.rawMode()) == 0)
return windows.unexpectedError(windows.kernel32.GetLastError());
if (windows.kernel32.SetConsoleOutputCP(utf8_codepage) == 0)
return windows.unexpectedError(windows.kernel32.GetLastError());
}
const self: Tty = .{
.stdin = stdin,
@ -272,50 +299,12 @@ pub const WindowsTty = struct {
pub fn deinit(self: Tty) void {
_ = windows.kernel32.SetConsoleOutputCP(self.initial_codepage);
setConsoleMode(self.stdin, self.initial_input_mode) catch {};
setConsoleMode(self.stdout, self.initial_output_mode) catch {};
_ = windows.kernel32.SetConsoleMode(self.stdin, self.initial_input_mode);
_ = windows.kernel32.SetConsoleMode(self.stdout, self.initial_output_mode);
windows.CloseHandle(self.stdin);
windows.CloseHandle(self.stdout);
}
pub const CONSOLE_MODE_INPUT = packed struct(u32) {
PROCESSED_INPUT: u1 = 0,
LINE_INPUT: u1 = 0,
ECHO_INPUT: u1 = 0,
WINDOW_INPUT: u1 = 0,
MOUSE_INPUT: u1 = 0,
INSERT_MODE: u1 = 0,
QUICK_EDIT_MODE: u1 = 0,
EXTENDED_FLAGS: u1 = 0,
AUTO_POSITION: u1 = 0,
VIRTUAL_TERMINAL_INPUT: u1 = 0,
_: u22 = 0,
};
pub const CONSOLE_MODE_OUTPUT = packed struct(u32) {
PROCESSED_OUTPUT: u1 = 0,
WRAP_AT_EOL_OUTPUT: u1 = 0,
VIRTUAL_TERMINAL_PROCESSING: u1 = 0,
DISABLE_NEWLINE_AUTO_RETURN: u1 = 0,
ENABLE_LVB_GRID_WORLDWIDE: u1 = 0,
_: u27 = 0,
};
pub fn getConsoleMode(comptime T: type, handle: windows.HANDLE) !T {
var mode: u32 = undefined;
if (windows.kernel32.GetConsoleMode(handle, &mode) == 0) return switch (windows.kernel32.GetLastError()) {
.INVALID_HANDLE => error.InvalidHandle,
else => |e| windows.unexpectedError(e),
};
return @bitCast(mode);
}
pub fn setConsoleMode(handle: windows.HANDLE, mode: anytype) !void {
if (windows.kernel32.SetConsoleMode(handle, @bitCast(mode)) == 0) return switch (windows.kernel32.GetLastError()) {
.INVALID_HANDLE => error.InvalidHandle,
else => |e| windows.unexpectedError(e),
};
}
pub fn opaqueWrite(ptr: *const anyopaque, bytes: []const u8) !usize {
const self: *const Tty = @ptrCast(@alignCast(ptr));
return windows.WriteFile(self.stdout, bytes, null);
@ -799,8 +788,4 @@ pub const TestTty = struct {
pub fn nextEvent(_: *Tty, _: *Parser, _: ?std.mem.Allocator) !Event {
return error.SkipZigTest;
}
pub fn resetSignalHandler() void {
return;
}
};

View file

@ -44,7 +44,7 @@ fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.
pub fn handleEvent(self: *Button, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
switch (event) {
.key_press => |key| {
if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
if (key.matches(vaxis.Key.enter, .{})) {
return self.doClick(ctx);
}
},

View file

@ -7,7 +7,7 @@ const Allocator = std.mem.Allocator;
const Spinner = @This();
const frames: []const []const u8 = &.{ "", "", "", "", "", "", "", "" };
const frames: []const []const u8 = &.{ "", "", "", "", "", "", "", "", "", "" };
const time_lapse: u32 = std.time.ms_per_s / 12; // 12 fps
count: std.atomic.Value(u16) = .{ .raw = 0 },

View file

@ -102,7 +102,7 @@ pub fn handleEvent(self: *TextField, ctx: *vxfw.EventContext, event: vxfw.Event)
} else if (key.matches('d', .{ .alt = true })) {
self.deleteWordAfter();
return self.checkChanged(ctx);
} else if (key.matches(vaxis.Key.enter, .{}) or key.matches('j', .{ .ctrl = true })) {
} else if (key.matches(vaxis.Key.enter, .{})) {
if (self.onSubmit) |onSubmit| {
try onSubmit(self.userdata, ctx, self.previous_val);
return ctx.consumeAndRedraw();