vxfw: introduce the vaxis framework

This was previously being developed at github.com/rockorager/vtk. I
really liked how it came together, and am moving it into the vaxis repo
piece by piece.
This commit is contained in:
Tim Culverhouse 2024-10-30 14:14:00 -05:00
parent 96f29a5fea
commit 7cb04494b3
2 changed files with 473 additions and 0 deletions

View file

@ -30,6 +30,8 @@ pub const grapheme = @import("grapheme");
pub const Event = @import("event.zig").Event;
pub const Unicode = @import("Unicode.zig");
pub const vxfw = @import("vxfw/vxfw.zig");
pub const Tty = tty.Tty;
/// The size of the terminal screen

471
src/vxfw/vxfw.zig Normal file
View file

@ -0,0 +1,471 @@
const std = @import("std");
const vaxis = @import("../main.zig");
const grapheme = vaxis.grapheme;
const testing = std.testing;
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
pub const CommandList = std.ArrayList(Command);
pub const UserEvent = struct {
name: []const u8,
data: ?*const anyopaque = null,
};
pub const Event = union(enum) {
key_press: vaxis.Key,
key_release: vaxis.Key,
mouse: vaxis.Mouse,
focus_in, // window has gained focus
focus_out, // window has lost focus
paste_start, // bracketed paste start
paste_end, // bracketed paste end
paste: []const u8, // osc 52 paste, caller must free
color_report: vaxis.Color.Report, // osc 4, 10, 11, 12 response
color_scheme: vaxis.Color.Scheme, // light / dark OS theme changes
winsize: vaxis.Winsize, // the window size has changed. This event is always sent when the loop is started
app: UserEvent, // A custom event from the app
tick, // An event from a Tick command
init, // sent when the application starts
mouse_leave, // The mouse has left the widget
};
pub const Tick = struct {
deadline_ms: i64,
widget: Widget,
pub fn lessThan(_: void, lhs: Tick, rhs: Tick) bool {
return lhs.deadline_ms > rhs.deadline_ms;
}
pub fn in(ms: u32, widget: Widget) Command {
const now = std.time.milliTimestamp();
return .{ .tick = .{
.deadline_ms = now + ms,
.widget = widget,
} };
}
};
pub const Command = union(enum) {
/// Callback the event with a tick event at the specified deadlline
tick: Tick,
/// Change the mouse shape. This also has an implicit redraw
set_mouse_shape: vaxis.Mouse.Shape,
/// Request that this widget receives focus
request_focus: Widget,
};
pub const EventContext = struct {
phase: Phase = .at_target,
cmds: CommandList,
/// The event was handled, do not pass it on
consume_event: bool = false,
/// Tells the event loop to redraw the UI
redraw: bool = true,
/// Quit the application
quit: bool = false,
pub const Phase = enum {
// TODO: Capturing phase
// capturing,
at_target,
bubbling,
};
pub fn addCmd(self: *EventContext, cmd: Command) Allocator.Error!void {
try self.cmds.append(cmd);
}
pub fn tick(self: *EventContext, ms: u32, widget: Widget) Allocator.Error!void {
try self.addCmd(Tick.in(ms, widget));
}
pub fn consumeAndRedraw(self: *EventContext) void {
self.consume_event = true;
self.redraw = true;
}
pub fn consumeEvent(self: *EventContext) void {
self.consume_event = true;
}
pub fn setMouseShape(self: *EventContext, shape: vaxis.Mouse.Shape) Allocator.Error!void {
try self.addCmd(.{ .set_mouse_shape = shape });
self.redraw = true;
}
pub fn requestFocus(self: *EventContext, widget: Widget) Allocator.Error!void {
try self.addCmd(.{ .request_focus = widget });
}
};
pub const DrawContext = struct {
// Allocator backed by an arena. Widgets do not need to free their own resources, they will be
// freed after rendering
arena: std.mem.Allocator,
// Constraints
min: Size,
max: MaxSize,
// Unicode stuff
var unicode: ?*const vaxis.Unicode = null;
var width_method: vaxis.gwidth.Method = .unicode;
pub fn init(ucd: *const vaxis.Unicode, method: vaxis.gwidth.Method) void {
DrawContext.unicode = ucd;
DrawContext.width_method = method;
}
pub fn stringWidth(_: DrawContext, str: []const u8) usize {
assert(DrawContext.unicode != null); // DrawContext not initialized
return vaxis.gwidth.gwidth(
str,
DrawContext.width_method,
&DrawContext.unicode.?.width_data,
);
}
pub fn graphemeIterator(_: DrawContext, str: []const u8) grapheme.Iterator {
assert(DrawContext.unicode != null); // DrawContext not initialized
return DrawContext.unicode.?.graphemeIterator(str);
}
pub fn withConstraints(self: DrawContext, min: Size, max: MaxSize) DrawContext {
return .{
.arena = self.arena,
.min = min,
.max = max,
};
}
};
pub const Size = struct {
width: u16 = 0,
height: u16 = 0,
};
pub const MaxSize = struct {
width: ?u16 = null,
height: ?u16 = null,
/// Returns true if the row would fall outside of this height. A null height value is infinite
/// and always returns false
pub fn outsideHeight(self: MaxSize, row: u16) bool {
const max = self.height orelse return false;
return row >= max;
}
/// Returns true if the col would fall outside of this width. A null width value is infinite
/// and always returns false
pub fn outsideWidth(self: MaxSize, col: u16) bool {
const max = self.width orelse return false;
return col >= max;
}
/// Asserts that neither height nor width are null
pub fn size(self: MaxSize) Size {
assert(self.width != null);
assert(self.height != null);
return .{
.width = self.width.?,
.height = self.height.?,
};
}
};
/// The Widget interface
pub const Widget = struct {
userdata: *anyopaque,
eventHandler: *const fn (userdata: *anyopaque, ctx: *EventContext, event: Event) anyerror!void,
drawFn: *const fn (userdata: *anyopaque, ctx: DrawContext) Allocator.Error!Surface,
pub fn handleEvent(self: Widget, ctx: *EventContext, event: Event) anyerror!void {
return self.eventHandler(self.userdata, ctx, event);
}
pub fn draw(self: Widget, ctx: DrawContext) Allocator.Error!Surface {
return self.drawFn(self.userdata, ctx);
}
/// Returns true if the Widgets point to the same widget instance
pub fn eql(self: Widget, other: Widget) bool {
return @intFromPtr(self.userdata) == @intFromPtr(other.userdata) and
@intFromPtr(self.eventHandler) == @intFromPtr(other.eventHandler) and
@intFromPtr(self.drawFn) == @intFromPtr(other.drawFn);
}
};
pub const FlexItem = struct {
widget: Widget,
/// A value of zero means the child will have it's inherent size. Any value greater than zero
/// and the remaining space will be proportioned to each item
flex: u8 = 1,
pub fn init(child: Widget, flex: u8) FlexItem {
return .{ .widget = child, .flex = flex };
}
};
pub const Point = struct {
row: u16,
col: u16,
};
pub const RelativePoint = struct {
row: i17,
col: i17,
};
/// Result of a hit test
pub const HitResult = struct {
local: Point,
widget: Widget,
};
pub const CursorState = struct {
/// Local coordinates
row: u16,
/// Local coordinates
col: u16,
shape: vaxis.Cell.CursorShape = .default,
};
pub const Surface = struct {
/// Size of this surface
size: Size,
/// The widget this surface belongs to
widget: Widget,
/// If this widget / Surface is focusable
focusable: bool = false,
/// If this widget can handle mouse events
handles_mouse: bool = false,
/// Cursor state
cursor: ?CursorState = null,
/// Contents of this surface. Must be len == 0 or len == size.width * size.height
buffer: []vaxis.Cell,
children: []SubSurface,
/// Creates a slice of vaxis.Cell's equal to size.width * size.height
pub fn createBuffer(allocator: Allocator, size: Size) Allocator.Error![]vaxis.Cell {
const buffer = try allocator.alloc(vaxis.Cell, size.width * size.height);
@memset(buffer, .{ .default = true });
return buffer;
}
pub fn init(allocator: Allocator, widget: Widget, size: Size) Allocator.Error!Surface {
return .{
.size = size,
.widget = widget,
.buffer = try Surface.createBuffer(allocator, size),
.children = &.{},
};
}
pub fn initWithChildren(
allocator: Allocator,
widget: Widget,
size: Size,
children: []SubSurface,
) Allocator.Error!Surface {
return .{
.size = size,
.widget = widget,
.buffer = try Surface.createBuffer(allocator, size),
.children = children,
};
}
pub fn writeCell(self: Surface, col: u16, row: u16, cell: vaxis.Cell) void {
if (self.size.width <= col) return;
if (self.size.height <= row) return;
const i = (row * self.size.width) + col;
assert(i < self.buffer.len);
self.buffer[i] = cell;
}
pub fn readCell(self: Surface, col: usize, row: usize) vaxis.Cell {
assert(col < self.size.width and row < self.size.height);
const i = (row * self.size.width) + col;
assert(i < self.buffer.len);
return self.buffer[i];
}
/// Creates a new surface of the same width, with the buffer trimmed to a given height
pub fn trimHeight(self: Surface, height: u16) Surface {
assert(height <= self.size.height);
return .{
.size = .{ .width = self.size.width, .height = height },
.widget = self.widget,
.buffer = self.buffer[0 .. self.size.width * height],
.children = self.children,
.focusable = self.focusable,
.handles_mouse = self.handles_mouse,
};
}
/// Walks the Surface tree to produce a list of all widgets that intersect Point. Point will
/// always be translated to local Surface coordinates. Asserts that this Surface does contain Point
pub fn hitTest(self: Surface, list: *std.ArrayList(HitResult), point: Point) Allocator.Error!void {
assert(point.col < self.size.width and point.row < self.size.height);
if (self.handles_mouse)
try list.append(.{ .local = point, .widget = self.widget });
for (self.children) |child| {
if (!child.containsPoint(point)) continue;
const child_point: Point = .{
.row = @intCast(point.row - child.origin.row),
.col = @intCast(point.col - child.origin.col),
};
try child.surface.hitTest(list, child_point);
}
}
/// Copies all cells from Surface to Window
pub fn render(self: Surface, win: vaxis.Window, focused: Widget) void {
// render self first
if (self.buffer.len > 0) {
assert(self.buffer.len == self.size.width * self.size.height);
for (self.buffer, 0..) |cell, i| {
const row = i / self.size.width;
const col = i % self.size.width;
win.writeCell(@intCast(col), @intCast(row), cell);
}
}
if (self.cursor) |cursor| {
if (self.widget.eql(focused)) {
win.showCursor(cursor.col, cursor.row);
win.setCursorShape(cursor.shape);
}
}
// Sort children by z-index
std.mem.sort(SubSurface, self.children, {}, SubSurface.lessThan);
// for each child, we make a window and render to it
for (self.children) |child| {
const child_win = win.child(.{
.x_off = @intCast(child.origin.col),
.y_off = @intCast(child.origin.row),
.width = @intCast(child.surface.size.width),
.height = @intCast(child.surface.size.height),
});
child.surface.render(child_win, focused);
}
}
/// Returns true if the surface satisfies a set of constraints
pub fn satisfiesConstraints(self: Surface, min: Size, max: Size) bool {
return self.size.width < min.width and
self.size.width > max.width and
self.size.height < min.height and
self.size.height > max.height;
}
};
pub const SubSurface = struct {
/// Origin relative to parent
origin: RelativePoint,
/// This surface
surface: Surface,
/// z-index relative to siblings
z_index: u8 = 0,
pub fn lessThan(_: void, lhs: SubSurface, rhs: SubSurface) bool {
return lhs.z_index < rhs.z_index;
}
/// Returns true if this SubSurface contains Point. Point must be in parent local units
pub fn containsPoint(self: SubSurface, point: Point) bool {
return point.col >= self.origin.col and
point.row >= self.origin.row and
point.col < (self.origin.col + self.surface.size.width) and
point.row < (self.origin.row + self.surface.size.height);
}
};
/// A noop event handler for widgets which don't require any event handling
pub fn noopEventHandler(_: *anyopaque, _: *EventContext, _: Event) anyerror!void {}
test {
std.testing.refAllDecls(@This());
}
test "SubSurface: containsPoint" {
const surf: SubSurface = .{
.origin = .{ .row = 2, .col = 2 },
.surface = .{
.size = .{ .width = 10, .height = 10 },
.widget = undefined,
.children = &.{},
.buffer = &.{},
},
.z_index = 0,
};
try testing.expect(surf.containsPoint(.{ .row = 2, .col = 2 }));
try testing.expect(surf.containsPoint(.{ .row = 3, .col = 3 }));
try testing.expect(surf.containsPoint(.{ .row = 11, .col = 11 }));
try testing.expect(!surf.containsPoint(.{ .row = 1, .col = 1 }));
try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 12 }));
try testing.expect(!surf.containsPoint(.{ .row = 2, .col = 12 }));
try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 2 }));
}
test "All widgets have a doctest and refAllDecls test" {
// This test goes through every file in src/ and checks that it has a doctest (the filename
// stripped of ".zig" matches a test name) and a test called "refAllDecls". It makes no
// guarantees about the quality of the test, but it does ensure it exists which at least makes
// it easy to fail CI early, or spot bad tests vs non-existant tests
const excludes = &[_][]const u8{"vxfw.zig"};
var cwd = try std.fs.cwd().openDir("./src/vxfw", .{ .iterate = true });
var iter = cwd.iterate();
defer cwd.close();
outer: while (try iter.next()) |file| {
if (file.kind != .file) continue;
for (excludes) |ex| if (std.mem.eql(u8, ex, file.name)) continue :outer;
const container_name = if (std.mem.lastIndexOf(u8, file.name, ".zig")) |idx|
file.name[0..idx]
else
continue;
const data = try cwd.readFileAllocOptions(std.testing.allocator, file.name, 10_000_000, null, @alignOf(u8), 0x00);
defer std.testing.allocator.free(data);
var ast = try std.zig.Ast.parse(std.testing.allocator, data, .zig);
defer ast.deinit(std.testing.allocator);
var has_doctest: bool = false;
var has_refAllDecls: bool = false;
for (ast.rootDecls()) |root_decl| {
const decl = ast.nodes.get(root_decl);
switch (decl.tag) {
.test_decl => {
const test_name = ast.tokenSlice(decl.data.lhs);
if (std.mem.eql(u8, "\"refAllDecls\"", test_name))
has_refAllDecls = true
else if (std.mem.eql(u8, container_name, test_name))
has_doctest = true;
},
else => continue,
}
}
if (!has_doctest) {
std.log.err("file {s} has no doctest", .{file.name});
return error.TestExpectedDoctest;
}
if (!has_refAllDecls) {
std.log.err("file {s} has no 'refAllDecls' test", .{file.name});
return error.TestExpectedRefAllDecls;
}
}
}