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:
parent
96f29a5fea
commit
7cb04494b3
2 changed files with 473 additions and 0 deletions
|
@ -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
471
src/vxfw/vxfw.zig
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue