From bad7f9cab2a6c191a47fbe44fcda15537e83174f Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Wed, 30 Oct 2024 14:21:49 -0500 Subject: [PATCH] vxfw: add App runtime Add the App runtime. App manages the event loop, focus, hit testing, rendering, and scheduled events. --- src/vxfw/App.zig | 491 ++++++++++++++++++++++++++++++++++++++++++++++ src/vxfw/vxfw.zig | 8 +- 2 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/vxfw/App.zig diff --git a/src/vxfw/App.zig b/src/vxfw/App.zig new file mode 100644 index 0000000..1fcfb0c --- /dev/null +++ b/src/vxfw/App.zig @@ -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; + } + } +}; diff --git a/src/vxfw/vxfw.zig b/src/vxfw/vxfw.zig index 8cc4685..98ece73 100644 --- a/src/vxfw/vxfw.zig +++ b/src/vxfw/vxfw.zig @@ -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();