vxfw: add App runtime
Add the App runtime. App manages the event loop, focus, hit testing, rendering, and scheduled events.
This commit is contained in:
parent
7cb04494b3
commit
bad7f9cab2
2 changed files with 498 additions and 1 deletions
491
src/vxfw/App.zig
Normal file
491
src/vxfw/App.zig
Normal file
|
@ -0,0 +1,491 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const vxfw = @import("vxfw.zig");
|
||||
|
||||
const assert = std.debug.assert;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const EventLoop = vaxis.Loop(vxfw.Event);
|
||||
const Widget = vxfw.Widget;
|
||||
|
||||
const App = @This();
|
||||
|
||||
quit_key: vaxis.Key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } },
|
||||
|
||||
allocator: Allocator,
|
||||
tty: vaxis.Tty,
|
||||
vx: vaxis.Vaxis,
|
||||
timers: std.ArrayList(vxfw.Tick),
|
||||
wants_focus: ?vxfw.Widget,
|
||||
|
||||
/// Runtime options
|
||||
pub const Options = struct {
|
||||
/// Frames per second
|
||||
framerate: u8 = 60,
|
||||
};
|
||||
|
||||
/// Create an application. We require stable pointers to do the set up, so this will create an App
|
||||
/// object on the heap. Call destroy when the app is complete to reset terminal state and release
|
||||
/// resources
|
||||
pub fn init(allocator: Allocator) !App {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.tty = try vaxis.Tty.init(),
|
||||
.vx = try vaxis.init(allocator, .{ .system_clipboard_allocator = allocator }),
|
||||
.timers = std.ArrayList(vxfw.Tick).init(allocator),
|
||||
.wants_focus = null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
self.timers.deinit();
|
||||
self.vx.deinit(self.allocator, self.tty.anyWriter());
|
||||
self.tty.deinit();
|
||||
}
|
||||
|
||||
pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void {
|
||||
const tty = &self.tty;
|
||||
const vx = &self.vx;
|
||||
|
||||
var loop: EventLoop = .{ .tty = tty, .vaxis = vx };
|
||||
try loop.start();
|
||||
defer loop.stop();
|
||||
|
||||
// Send the init event
|
||||
loop.postEvent(.init);
|
||||
|
||||
try vx.enterAltScreen(tty.anyWriter());
|
||||
try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s);
|
||||
|
||||
{
|
||||
// This part deserves a comment. loop.init installs a signal handler for the tty. We wait to
|
||||
// init the loop until we know if we need this handler. We don't need it if the terminal
|
||||
// supports in-band-resize
|
||||
if (!vx.state.in_band_resize) try loop.init();
|
||||
}
|
||||
|
||||
// HACK: Ghostty is reporting incorrect pixel screen size
|
||||
vx.caps.sgr_pixels = false;
|
||||
try vx.setMouseMode(tty.anyWriter(), true);
|
||||
|
||||
// Give DrawContext the unicode data
|
||||
vxfw.DrawContext.init(&vx.unicode, vx.screen.width_method);
|
||||
|
||||
const framerate: u64 = if (opts.framerate > 0) opts.framerate else 60;
|
||||
// Calculate tick rate
|
||||
const tick_ms: u64 = @divFloor(std.time.ms_per_s, framerate);
|
||||
|
||||
// Set up arena and context
|
||||
var arena = std.heap.ArenaAllocator.init(self.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
var buffered = tty.bufferedWriter();
|
||||
|
||||
var mouse_handler = MouseHandler.init(widget);
|
||||
var focus_handler = FocusHandler.init(self.allocator, widget);
|
||||
focus_handler.intrusiveInit();
|
||||
defer focus_handler.deinit();
|
||||
|
||||
// Timestamp of our next frame
|
||||
var next_frame_ms: u64 = @intCast(std.time.milliTimestamp());
|
||||
|
||||
// Create our event context
|
||||
var ctx: vxfw.EventContext = .{
|
||||
.phase = .at_target,
|
||||
.cmds = vxfw.CommandList.init(self.allocator),
|
||||
.consume_event = false,
|
||||
.redraw = false,
|
||||
.quit = false,
|
||||
};
|
||||
defer ctx.cmds.deinit();
|
||||
|
||||
while (true) {
|
||||
const now_ms: u64 = @intCast(std.time.milliTimestamp());
|
||||
if (now_ms >= next_frame_ms) {
|
||||
// Deadline exceeded. Schedule the next frame
|
||||
next_frame_ms = now_ms + tick_ms;
|
||||
} else {
|
||||
// Sleep until the deadline
|
||||
std.time.sleep((next_frame_ms - now_ms) * std.time.ns_per_ms);
|
||||
next_frame_ms += tick_ms;
|
||||
}
|
||||
|
||||
try self.checkTimers(&ctx);
|
||||
|
||||
while (loop.tryEvent()) |event| {
|
||||
ctx.consume_event = false;
|
||||
switch (event) {
|
||||
.key_press => |key| {
|
||||
try focus_handler.handleEvent(&ctx, event);
|
||||
try self.handleCommand(&ctx.cmds);
|
||||
if (!ctx.consume_event) {
|
||||
if (key.matches(self.quit_key.codepoint, self.quit_key.mods)) {
|
||||
ctx.quit = true;
|
||||
}
|
||||
if (key.matches(vaxis.Key.tab, .{})) {
|
||||
try focus_handler.focusNext(&ctx);
|
||||
try self.handleCommand(&ctx.cmds);
|
||||
}
|
||||
if (key.matches(vaxis.Key.tab, .{ .shift = true })) {
|
||||
try focus_handler.focusPrev(&ctx);
|
||||
try self.handleCommand(&ctx.cmds);
|
||||
}
|
||||
}
|
||||
},
|
||||
.focus_out => try mouse_handler.mouseExit(self, &ctx),
|
||||
.mouse => |mouse| try mouse_handler.handleMouse(self, &ctx, mouse),
|
||||
.winsize => |ws| {
|
||||
try vx.resize(self.allocator, buffered.writer().any(), ws);
|
||||
try buffered.flush();
|
||||
ctx.redraw = true;
|
||||
},
|
||||
else => {
|
||||
try widget.handleEvent(&ctx, event);
|
||||
try self.handleCommand(&ctx.cmds);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should quit
|
||||
if (ctx.quit) return;
|
||||
|
||||
// Check if we need a redraw
|
||||
if (!ctx.redraw) continue;
|
||||
ctx.redraw = false;
|
||||
// Assert that we have handled all commands
|
||||
assert(ctx.cmds.items.len == 0);
|
||||
|
||||
_ = arena.reset(.retain_capacity);
|
||||
|
||||
const draw_context: vxfw.DrawContext = .{
|
||||
.arena = arena.allocator(),
|
||||
.min = .{ .width = 0, .height = 0 },
|
||||
.max = .{
|
||||
.width = @intCast(vx.screen.width),
|
||||
.height = @intCast(vx.screen.height),
|
||||
},
|
||||
};
|
||||
const win = vx.window();
|
||||
win.clear();
|
||||
win.hideCursor();
|
||||
win.setCursorShape(.default);
|
||||
const surface = try widget.draw(draw_context);
|
||||
|
||||
const focused = self.wants_focus orelse focus_handler.focused.widget;
|
||||
surface.render(win, focused);
|
||||
try vx.render(buffered.writer().any());
|
||||
try buffered.flush();
|
||||
|
||||
// Store the last frame
|
||||
mouse_handler.last_frame = surface;
|
||||
try focus_handler.update(surface, self.wants_focus);
|
||||
self.wants_focus = null;
|
||||
}
|
||||
}
|
||||
|
||||
fn addTick(self: *App, tick: vxfw.Tick) Allocator.Error!void {
|
||||
try self.timers.append(tick);
|
||||
std.sort.insertion(vxfw.Tick, self.timers.items, {}, vxfw.Tick.lessThan);
|
||||
}
|
||||
|
||||
fn handleCommand(self: *App, cmds: *vxfw.CommandList) Allocator.Error!void {
|
||||
defer cmds.clearRetainingCapacity();
|
||||
for (cmds.items) |cmd| {
|
||||
switch (cmd) {
|
||||
.tick => |tick| try self.addTick(tick),
|
||||
.set_mouse_shape => |shape| self.vx.setMouseShape(shape),
|
||||
.request_focus => |widget| self.wants_focus = widget,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void {
|
||||
const now_ms = std.time.milliTimestamp();
|
||||
|
||||
// timers are always sorted descending
|
||||
while (self.timers.popOrNull()) |tick| {
|
||||
if (now_ms < tick.deadline_ms)
|
||||
break;
|
||||
try tick.widget.handleEvent(ctx, .tick);
|
||||
try self.handleCommand(&ctx.cmds);
|
||||
}
|
||||
}
|
||||
|
||||
const MouseHandler = struct {
|
||||
last_frame: vxfw.Surface,
|
||||
maybe_last_handler: ?vxfw.Widget = null,
|
||||
|
||||
fn init(root: Widget) MouseHandler {
|
||||
return .{
|
||||
.last_frame = .{
|
||||
.size = .{ .width = 0, .height = 0 },
|
||||
.widget = root,
|
||||
.buffer = &.{},
|
||||
.children = &.{},
|
||||
},
|
||||
.maybe_last_handler = null,
|
||||
};
|
||||
}
|
||||
|
||||
fn handleMouse(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) anyerror!void {
|
||||
const last_frame = self.last_frame;
|
||||
|
||||
// For mouse events we store the last frame and use that for hit testing
|
||||
var hits = std.ArrayList(vxfw.HitResult).init(app.allocator);
|
||||
defer hits.deinit();
|
||||
const sub: vxfw.SubSurface = .{
|
||||
.origin = .{ .row = 0, .col = 0 },
|
||||
.surface = last_frame,
|
||||
.z_index = 0,
|
||||
};
|
||||
const mouse_point: vxfw.Point = .{
|
||||
.row = @intCast(mouse.row),
|
||||
.col = @intCast(mouse.col),
|
||||
};
|
||||
if (sub.containsPoint(mouse_point)) {
|
||||
try last_frame.hitTest(&hits, mouse_point);
|
||||
}
|
||||
while (hits.popOrNull()) |item| {
|
||||
var m_local = mouse;
|
||||
m_local.col = item.local.col;
|
||||
m_local.row = item.local.row;
|
||||
try item.widget.handleEvent(ctx, .{ .mouse = m_local });
|
||||
try app.handleCommand(&ctx.cmds);
|
||||
|
||||
// If the event wasn't consumed, we keep passing it on
|
||||
if (!ctx.consume_event) continue;
|
||||
|
||||
if (self.maybe_last_handler) |last_mouse_handler| {
|
||||
if (!last_mouse_handler.eql(item.widget)) {
|
||||
try last_mouse_handler.handleEvent(ctx, .mouse_leave);
|
||||
try app.handleCommand(&ctx.cmds);
|
||||
}
|
||||
}
|
||||
self.maybe_last_handler = item.widget;
|
||||
return;
|
||||
}
|
||||
|
||||
// If no one handled the mouse, we assume it exited
|
||||
return self.mouseExit(app, ctx);
|
||||
}
|
||||
|
||||
fn mouseExit(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext) anyerror!void {
|
||||
if (self.maybe_last_handler) |last_handler| {
|
||||
try last_handler.handleEvent(ctx, .mouse_leave);
|
||||
try app.handleCommand(&ctx.cmds);
|
||||
self.maybe_last_handler = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Maintains a tree of focusable nodes. Delivers events to the currently focused node, walking up
|
||||
/// the tree until the event is handled
|
||||
const FocusHandler = struct {
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
root: Node,
|
||||
focused: *Node,
|
||||
maybe_wants_focus: ?vxfw.Widget = null,
|
||||
|
||||
const Node = struct {
|
||||
widget: Widget,
|
||||
parent: ?*Node,
|
||||
children: []*Node,
|
||||
|
||||
fn nextSibling(self: Node) ?*Node {
|
||||
const parent = self.parent orelse return null;
|
||||
const idx = for (0..parent.children.len) |i| {
|
||||
const node = parent.children[i];
|
||||
if (self.widget.eql(node.widget))
|
||||
break i;
|
||||
} else unreachable;
|
||||
|
||||
// Return null if last child
|
||||
if (idx == parent.children.len - 1)
|
||||
return null
|
||||
else
|
||||
return parent.children[idx + 1];
|
||||
}
|
||||
|
||||
fn prevSibling(self: Node) ?*Node {
|
||||
const parent = self.parent orelse return null;
|
||||
const idx = for (0..parent.children.len) |i| {
|
||||
const node = parent.children[i];
|
||||
if (self.widget.eql(node.widget))
|
||||
break i;
|
||||
} else unreachable;
|
||||
|
||||
// Return null if first child
|
||||
if (idx == 0)
|
||||
return null
|
||||
else
|
||||
return parent.children[idx - 1];
|
||||
}
|
||||
|
||||
fn lastChild(self: Node) ?*Node {
|
||||
if (self.children.len > 0)
|
||||
return self.children[self.children.len - 1]
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
fn firstChild(self: Node) ?*Node {
|
||||
if (self.children.len > 0)
|
||||
return self.children[0]
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/// returns the next logical node in the tree
|
||||
fn nextNode(self: *Node) *Node {
|
||||
// If we have a sibling, we return it's first descendant line
|
||||
if (self.nextSibling()) |sibling| {
|
||||
var node = sibling;
|
||||
while (node.firstChild()) |child| {
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// If we don't have a sibling, we return our parent
|
||||
if (self.parent) |parent| return parent;
|
||||
|
||||
// If we don't have a parent, we are the root and we return or first descendant
|
||||
var node = self;
|
||||
while (node.firstChild()) |child| {
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
fn prevNode(self: *Node) *Node {
|
||||
// If we have children, we return the last child descendant
|
||||
if (self.children.len > 0) {
|
||||
var node = self;
|
||||
while (node.lastChild()) |child| {
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// If we have siblings, we return the last descendant line of the sibling
|
||||
if (self.prevSibling()) |sibling| {
|
||||
var node = sibling;
|
||||
while (node.lastChild()) |child| {
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// If we don't have a sibling, we return our parent
|
||||
if (self.parent) |parent| return parent;
|
||||
|
||||
// If we don't have a parent, we are the root and we return our last descendant
|
||||
var node = self;
|
||||
while (node.lastChild()) |child| {
|
||||
node = child;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
};
|
||||
|
||||
fn init(allocator: Allocator, root: Widget) FocusHandler {
|
||||
const node: Node = .{
|
||||
.widget = root,
|
||||
.parent = null,
|
||||
.children = &.{},
|
||||
};
|
||||
return .{
|
||||
.root = node,
|
||||
.focused = undefined,
|
||||
.arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.maybe_wants_focus = null,
|
||||
};
|
||||
}
|
||||
|
||||
fn intrusiveInit(self: *FocusHandler) void {
|
||||
self.focused = &self.root;
|
||||
}
|
||||
|
||||
fn deinit(self: *FocusHandler) void {
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
/// Update the focus list
|
||||
fn update(self: *FocusHandler, root: vxfw.Surface, maybe_wants_focus: ?vxfw.Widget) Allocator.Error!void {
|
||||
_ = self.arena.reset(.retain_capacity);
|
||||
self.maybe_wants_focus = maybe_wants_focus;
|
||||
|
||||
var list = std.ArrayList(*Node).init(self.arena.allocator());
|
||||
for (root.children) |child| {
|
||||
try self.findFocusableChildren(&self.root, &list, child.surface);
|
||||
}
|
||||
self.root = .{
|
||||
.widget = root.widget,
|
||||
.children = list.items,
|
||||
.parent = null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Walks the surface tree, adding all focusable nodes to list
|
||||
fn findFocusableChildren(
|
||||
self: *FocusHandler,
|
||||
parent: *Node,
|
||||
list: *std.ArrayList(*Node),
|
||||
surface: vxfw.Surface,
|
||||
) Allocator.Error!void {
|
||||
if (surface.focusable) {
|
||||
// We are a focusable child of parent. Create a new node, and find our own focusable
|
||||
// children
|
||||
const node = try self.arena.allocator().create(Node);
|
||||
var child_list = std.ArrayList(*Node).init(self.arena.allocator());
|
||||
for (surface.children) |child| {
|
||||
try self.findFocusableChildren(node, &child_list, child.surface);
|
||||
}
|
||||
node.* = .{
|
||||
.widget = surface.widget,
|
||||
.parent = parent,
|
||||
.children = child_list.items,
|
||||
};
|
||||
if (self.maybe_wants_focus) |wants_focus| {
|
||||
if (wants_focus.eql(surface.widget)) {
|
||||
self.focused = node;
|
||||
self.maybe_wants_focus = null;
|
||||
}
|
||||
}
|
||||
try list.append(node);
|
||||
} else {
|
||||
for (surface.children) |child| {
|
||||
try self.findFocusableChildren(parent, list, child.surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn focusNode(self: *FocusHandler, ctx: *vxfw.EventContext, node: *Node) anyerror!void {
|
||||
if (self.focused.widget.eql(node.widget)) return;
|
||||
|
||||
try self.focused.widget.handleEvent(ctx, .focus_out);
|
||||
self.focused = node;
|
||||
try self.focused.widget.handleEvent(ctx, .focus_in);
|
||||
}
|
||||
|
||||
/// Focuses the next focusable widget
|
||||
fn focusNext(self: *FocusHandler, ctx: *vxfw.EventContext) anyerror!void {
|
||||
return self.focusNode(ctx, self.focused.nextNode());
|
||||
}
|
||||
|
||||
/// Focuses the previous focusable widget
|
||||
fn focusPrev(self: *FocusHandler, ctx: *vxfw.EventContext) anyerror!void {
|
||||
return self.focusNode(ctx, self.focused.prevNode());
|
||||
}
|
||||
|
||||
fn handleEvent(self: *FocusHandler, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
|
||||
var maybe_node: ?*Node = self.focused;
|
||||
while (maybe_node) |node| {
|
||||
try node.widget.handleEvent(ctx, event);
|
||||
if (ctx.consume_event) return;
|
||||
maybe_node = node.parent;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -8,6 +8,8 @@ const assert = std.debug.assert;
|
|||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const App = @import("App.zig");
|
||||
|
||||
pub const CommandList = std.ArrayList(Command);
|
||||
|
||||
pub const UserEvent = struct {
|
||||
|
@ -421,12 +423,16 @@ test "SubSurface: containsPoint" {
|
|||
try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 2 }));
|
||||
}
|
||||
|
||||
test "refAllDecls" {
|
||||
std.testing.refAllDecls(@This());
|
||||
}
|
||||
|
||||
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"};
|
||||
const excludes = &[_][]const u8{ "vxfw.zig", "App.zig" };
|
||||
|
||||
var cwd = try std.fs.cwd().openDir("./src/vxfw", .{ .iterate = true });
|
||||
var iter = cwd.iterate();
|
||||
|
|
Loading…
Add table
Reference in a new issue