this repo has no description
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

vxfw: add App runtime

Add the App runtime. App manages the event loop, focus, hit testing,
rendering, and scheduled events.

+498 -1
+491
src/vxfw/App.zig
··· 1 + const std = @import("std"); 2 + const vaxis = @import("vaxis"); 3 + const vxfw = @import("vxfw.zig"); 4 + 5 + const assert = std.debug.assert; 6 + 7 + const Allocator = std.mem.Allocator; 8 + 9 + const EventLoop = vaxis.Loop(vxfw.Event); 10 + const Widget = vxfw.Widget; 11 + 12 + const App = @This(); 13 + 14 + quit_key: vaxis.Key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } }, 15 + 16 + allocator: Allocator, 17 + tty: vaxis.Tty, 18 + vx: vaxis.Vaxis, 19 + timers: std.ArrayList(vxfw.Tick), 20 + wants_focus: ?vxfw.Widget, 21 + 22 + /// Runtime options 23 + pub const Options = struct { 24 + /// Frames per second 25 + framerate: u8 = 60, 26 + }; 27 + 28 + /// Create an application. We require stable pointers to do the set up, so this will create an App 29 + /// object on the heap. Call destroy when the app is complete to reset terminal state and release 30 + /// resources 31 + pub fn init(allocator: Allocator) !App { 32 + return .{ 33 + .allocator = allocator, 34 + .tty = try vaxis.Tty.init(), 35 + .vx = try vaxis.init(allocator, .{ .system_clipboard_allocator = allocator }), 36 + .timers = std.ArrayList(vxfw.Tick).init(allocator), 37 + .wants_focus = null, 38 + }; 39 + } 40 + 41 + pub fn deinit(self: *App) void { 42 + self.timers.deinit(); 43 + self.vx.deinit(self.allocator, self.tty.anyWriter()); 44 + self.tty.deinit(); 45 + } 46 + 47 + pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void { 48 + const tty = &self.tty; 49 + const vx = &self.vx; 50 + 51 + var loop: EventLoop = .{ .tty = tty, .vaxis = vx }; 52 + try loop.start(); 53 + defer loop.stop(); 54 + 55 + // Send the init event 56 + loop.postEvent(.init); 57 + 58 + try vx.enterAltScreen(tty.anyWriter()); 59 + try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); 60 + 61 + { 62 + // This part deserves a comment. loop.init installs a signal handler for the tty. We wait to 63 + // init the loop until we know if we need this handler. We don't need it if the terminal 64 + // supports in-band-resize 65 + if (!vx.state.in_band_resize) try loop.init(); 66 + } 67 + 68 + // HACK: Ghostty is reporting incorrect pixel screen size 69 + vx.caps.sgr_pixels = false; 70 + try vx.setMouseMode(tty.anyWriter(), true); 71 + 72 + // Give DrawContext the unicode data 73 + vxfw.DrawContext.init(&vx.unicode, vx.screen.width_method); 74 + 75 + const framerate: u64 = if (opts.framerate > 0) opts.framerate else 60; 76 + // Calculate tick rate 77 + const tick_ms: u64 = @divFloor(std.time.ms_per_s, framerate); 78 + 79 + // Set up arena and context 80 + var arena = std.heap.ArenaAllocator.init(self.allocator); 81 + defer arena.deinit(); 82 + 83 + var buffered = tty.bufferedWriter(); 84 + 85 + var mouse_handler = MouseHandler.init(widget); 86 + var focus_handler = FocusHandler.init(self.allocator, widget); 87 + focus_handler.intrusiveInit(); 88 + defer focus_handler.deinit(); 89 + 90 + // Timestamp of our next frame 91 + var next_frame_ms: u64 = @intCast(std.time.milliTimestamp()); 92 + 93 + // Create our event context 94 + var ctx: vxfw.EventContext = .{ 95 + .phase = .at_target, 96 + .cmds = vxfw.CommandList.init(self.allocator), 97 + .consume_event = false, 98 + .redraw = false, 99 + .quit = false, 100 + }; 101 + defer ctx.cmds.deinit(); 102 + 103 + while (true) { 104 + const now_ms: u64 = @intCast(std.time.milliTimestamp()); 105 + if (now_ms >= next_frame_ms) { 106 + // Deadline exceeded. Schedule the next frame 107 + next_frame_ms = now_ms + tick_ms; 108 + } else { 109 + // Sleep until the deadline 110 + std.time.sleep((next_frame_ms - now_ms) * std.time.ns_per_ms); 111 + next_frame_ms += tick_ms; 112 + } 113 + 114 + try self.checkTimers(&ctx); 115 + 116 + while (loop.tryEvent()) |event| { 117 + ctx.consume_event = false; 118 + switch (event) { 119 + .key_press => |key| { 120 + try focus_handler.handleEvent(&ctx, event); 121 + try self.handleCommand(&ctx.cmds); 122 + if (!ctx.consume_event) { 123 + if (key.matches(self.quit_key.codepoint, self.quit_key.mods)) { 124 + ctx.quit = true; 125 + } 126 + if (key.matches(vaxis.Key.tab, .{})) { 127 + try focus_handler.focusNext(&ctx); 128 + try self.handleCommand(&ctx.cmds); 129 + } 130 + if (key.matches(vaxis.Key.tab, .{ .shift = true })) { 131 + try focus_handler.focusPrev(&ctx); 132 + try self.handleCommand(&ctx.cmds); 133 + } 134 + } 135 + }, 136 + .focus_out => try mouse_handler.mouseExit(self, &ctx), 137 + .mouse => |mouse| try mouse_handler.handleMouse(self, &ctx, mouse), 138 + .winsize => |ws| { 139 + try vx.resize(self.allocator, buffered.writer().any(), ws); 140 + try buffered.flush(); 141 + ctx.redraw = true; 142 + }, 143 + else => { 144 + try widget.handleEvent(&ctx, event); 145 + try self.handleCommand(&ctx.cmds); 146 + }, 147 + } 148 + } 149 + 150 + // Check if we should quit 151 + if (ctx.quit) return; 152 + 153 + // Check if we need a redraw 154 + if (!ctx.redraw) continue; 155 + ctx.redraw = false; 156 + // Assert that we have handled all commands 157 + assert(ctx.cmds.items.len == 0); 158 + 159 + _ = arena.reset(.retain_capacity); 160 + 161 + const draw_context: vxfw.DrawContext = .{ 162 + .arena = arena.allocator(), 163 + .min = .{ .width = 0, .height = 0 }, 164 + .max = .{ 165 + .width = @intCast(vx.screen.width), 166 + .height = @intCast(vx.screen.height), 167 + }, 168 + }; 169 + const win = vx.window(); 170 + win.clear(); 171 + win.hideCursor(); 172 + win.setCursorShape(.default); 173 + const surface = try widget.draw(draw_context); 174 + 175 + const focused = self.wants_focus orelse focus_handler.focused.widget; 176 + surface.render(win, focused); 177 + try vx.render(buffered.writer().any()); 178 + try buffered.flush(); 179 + 180 + // Store the last frame 181 + mouse_handler.last_frame = surface; 182 + try focus_handler.update(surface, self.wants_focus); 183 + self.wants_focus = null; 184 + } 185 + } 186 + 187 + fn addTick(self: *App, tick: vxfw.Tick) Allocator.Error!void { 188 + try self.timers.append(tick); 189 + std.sort.insertion(vxfw.Tick, self.timers.items, {}, vxfw.Tick.lessThan); 190 + } 191 + 192 + fn handleCommand(self: *App, cmds: *vxfw.CommandList) Allocator.Error!void { 193 + defer cmds.clearRetainingCapacity(); 194 + for (cmds.items) |cmd| { 195 + switch (cmd) { 196 + .tick => |tick| try self.addTick(tick), 197 + .set_mouse_shape => |shape| self.vx.setMouseShape(shape), 198 + .request_focus => |widget| self.wants_focus = widget, 199 + } 200 + } 201 + } 202 + 203 + fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void { 204 + const now_ms = std.time.milliTimestamp(); 205 + 206 + // timers are always sorted descending 207 + while (self.timers.popOrNull()) |tick| { 208 + if (now_ms < tick.deadline_ms) 209 + break; 210 + try tick.widget.handleEvent(ctx, .tick); 211 + try self.handleCommand(&ctx.cmds); 212 + } 213 + } 214 + 215 + const MouseHandler = struct { 216 + last_frame: vxfw.Surface, 217 + maybe_last_handler: ?vxfw.Widget = null, 218 + 219 + fn init(root: Widget) MouseHandler { 220 + return .{ 221 + .last_frame = .{ 222 + .size = .{ .width = 0, .height = 0 }, 223 + .widget = root, 224 + .buffer = &.{}, 225 + .children = &.{}, 226 + }, 227 + .maybe_last_handler = null, 228 + }; 229 + } 230 + 231 + fn handleMouse(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) anyerror!void { 232 + const last_frame = self.last_frame; 233 + 234 + // For mouse events we store the last frame and use that for hit testing 235 + var hits = std.ArrayList(vxfw.HitResult).init(app.allocator); 236 + defer hits.deinit(); 237 + const sub: vxfw.SubSurface = .{ 238 + .origin = .{ .row = 0, .col = 0 }, 239 + .surface = last_frame, 240 + .z_index = 0, 241 + }; 242 + const mouse_point: vxfw.Point = .{ 243 + .row = @intCast(mouse.row), 244 + .col = @intCast(mouse.col), 245 + }; 246 + if (sub.containsPoint(mouse_point)) { 247 + try last_frame.hitTest(&hits, mouse_point); 248 + } 249 + while (hits.popOrNull()) |item| { 250 + var m_local = mouse; 251 + m_local.col = item.local.col; 252 + m_local.row = item.local.row; 253 + try item.widget.handleEvent(ctx, .{ .mouse = m_local }); 254 + try app.handleCommand(&ctx.cmds); 255 + 256 + // If the event wasn't consumed, we keep passing it on 257 + if (!ctx.consume_event) continue; 258 + 259 + if (self.maybe_last_handler) |last_mouse_handler| { 260 + if (!last_mouse_handler.eql(item.widget)) { 261 + try last_mouse_handler.handleEvent(ctx, .mouse_leave); 262 + try app.handleCommand(&ctx.cmds); 263 + } 264 + } 265 + self.maybe_last_handler = item.widget; 266 + return; 267 + } 268 + 269 + // If no one handled the mouse, we assume it exited 270 + return self.mouseExit(app, ctx); 271 + } 272 + 273 + fn mouseExit(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext) anyerror!void { 274 + if (self.maybe_last_handler) |last_handler| { 275 + try last_handler.handleEvent(ctx, .mouse_leave); 276 + try app.handleCommand(&ctx.cmds); 277 + self.maybe_last_handler = null; 278 + } 279 + } 280 + }; 281 + 282 + /// Maintains a tree of focusable nodes. Delivers events to the currently focused node, walking up 283 + /// the tree until the event is handled 284 + const FocusHandler = struct { 285 + arena: std.heap.ArenaAllocator, 286 + 287 + root: Node, 288 + focused: *Node, 289 + maybe_wants_focus: ?vxfw.Widget = null, 290 + 291 + const Node = struct { 292 + widget: Widget, 293 + parent: ?*Node, 294 + children: []*Node, 295 + 296 + fn nextSibling(self: Node) ?*Node { 297 + const parent = self.parent orelse return null; 298 + const idx = for (0..parent.children.len) |i| { 299 + const node = parent.children[i]; 300 + if (self.widget.eql(node.widget)) 301 + break i; 302 + } else unreachable; 303 + 304 + // Return null if last child 305 + if (idx == parent.children.len - 1) 306 + return null 307 + else 308 + return parent.children[idx + 1]; 309 + } 310 + 311 + fn prevSibling(self: Node) ?*Node { 312 + const parent = self.parent orelse return null; 313 + const idx = for (0..parent.children.len) |i| { 314 + const node = parent.children[i]; 315 + if (self.widget.eql(node.widget)) 316 + break i; 317 + } else unreachable; 318 + 319 + // Return null if first child 320 + if (idx == 0) 321 + return null 322 + else 323 + return parent.children[idx - 1]; 324 + } 325 + 326 + fn lastChild(self: Node) ?*Node { 327 + if (self.children.len > 0) 328 + return self.children[self.children.len - 1] 329 + else 330 + return null; 331 + } 332 + 333 + fn firstChild(self: Node) ?*Node { 334 + if (self.children.len > 0) 335 + return self.children[0] 336 + else 337 + return null; 338 + } 339 + 340 + /// returns the next logical node in the tree 341 + fn nextNode(self: *Node) *Node { 342 + // If we have a sibling, we return it's first descendant line 343 + if (self.nextSibling()) |sibling| { 344 + var node = sibling; 345 + while (node.firstChild()) |child| { 346 + node = child; 347 + } 348 + return node; 349 + } 350 + 351 + // If we don't have a sibling, we return our parent 352 + if (self.parent) |parent| return parent; 353 + 354 + // If we don't have a parent, we are the root and we return or first descendant 355 + var node = self; 356 + while (node.firstChild()) |child| { 357 + node = child; 358 + } 359 + return node; 360 + } 361 + 362 + fn prevNode(self: *Node) *Node { 363 + // If we have children, we return the last child descendant 364 + if (self.children.len > 0) { 365 + var node = self; 366 + while (node.lastChild()) |child| { 367 + node = child; 368 + } 369 + return node; 370 + } 371 + 372 + // If we have siblings, we return the last descendant line of the sibling 373 + if (self.prevSibling()) |sibling| { 374 + var node = sibling; 375 + while (node.lastChild()) |child| { 376 + node = child; 377 + } 378 + return node; 379 + } 380 + 381 + // If we don't have a sibling, we return our parent 382 + if (self.parent) |parent| return parent; 383 + 384 + // If we don't have a parent, we are the root and we return our last descendant 385 + var node = self; 386 + while (node.lastChild()) |child| { 387 + node = child; 388 + } 389 + return node; 390 + } 391 + }; 392 + 393 + fn init(allocator: Allocator, root: Widget) FocusHandler { 394 + const node: Node = .{ 395 + .widget = root, 396 + .parent = null, 397 + .children = &.{}, 398 + }; 399 + return .{ 400 + .root = node, 401 + .focused = undefined, 402 + .arena = std.heap.ArenaAllocator.init(allocator), 403 + .maybe_wants_focus = null, 404 + }; 405 + } 406 + 407 + fn intrusiveInit(self: *FocusHandler) void { 408 + self.focused = &self.root; 409 + } 410 + 411 + fn deinit(self: *FocusHandler) void { 412 + self.arena.deinit(); 413 + } 414 + 415 + /// Update the focus list 416 + fn update(self: *FocusHandler, root: vxfw.Surface, maybe_wants_focus: ?vxfw.Widget) Allocator.Error!void { 417 + _ = self.arena.reset(.retain_capacity); 418 + self.maybe_wants_focus = maybe_wants_focus; 419 + 420 + var list = std.ArrayList(*Node).init(self.arena.allocator()); 421 + for (root.children) |child| { 422 + try self.findFocusableChildren(&self.root, &list, child.surface); 423 + } 424 + self.root = .{ 425 + .widget = root.widget, 426 + .children = list.items, 427 + .parent = null, 428 + }; 429 + } 430 + 431 + /// Walks the surface tree, adding all focusable nodes to list 432 + fn findFocusableChildren( 433 + self: *FocusHandler, 434 + parent: *Node, 435 + list: *std.ArrayList(*Node), 436 + surface: vxfw.Surface, 437 + ) Allocator.Error!void { 438 + if (surface.focusable) { 439 + // We are a focusable child of parent. Create a new node, and find our own focusable 440 + // children 441 + const node = try self.arena.allocator().create(Node); 442 + var child_list = std.ArrayList(*Node).init(self.arena.allocator()); 443 + for (surface.children) |child| { 444 + try self.findFocusableChildren(node, &child_list, child.surface); 445 + } 446 + node.* = .{ 447 + .widget = surface.widget, 448 + .parent = parent, 449 + .children = child_list.items, 450 + }; 451 + if (self.maybe_wants_focus) |wants_focus| { 452 + if (wants_focus.eql(surface.widget)) { 453 + self.focused = node; 454 + self.maybe_wants_focus = null; 455 + } 456 + } 457 + try list.append(node); 458 + } else { 459 + for (surface.children) |child| { 460 + try self.findFocusableChildren(parent, list, child.surface); 461 + } 462 + } 463 + } 464 + 465 + fn focusNode(self: *FocusHandler, ctx: *vxfw.EventContext, node: *Node) anyerror!void { 466 + if (self.focused.widget.eql(node.widget)) return; 467 + 468 + try self.focused.widget.handleEvent(ctx, .focus_out); 469 + self.focused = node; 470 + try self.focused.widget.handleEvent(ctx, .focus_in); 471 + } 472 + 473 + /// Focuses the next focusable widget 474 + fn focusNext(self: *FocusHandler, ctx: *vxfw.EventContext) anyerror!void { 475 + return self.focusNode(ctx, self.focused.nextNode()); 476 + } 477 + 478 + /// Focuses the previous focusable widget 479 + fn focusPrev(self: *FocusHandler, ctx: *vxfw.EventContext) anyerror!void { 480 + return self.focusNode(ctx, self.focused.prevNode()); 481 + } 482 + 483 + fn handleEvent(self: *FocusHandler, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 484 + var maybe_node: ?*Node = self.focused; 485 + while (maybe_node) |node| { 486 + try node.widget.handleEvent(ctx, event); 487 + if (ctx.consume_event) return; 488 + maybe_node = node.parent; 489 + } 490 + } 491 + };
+7 -1
src/vxfw/vxfw.zig
··· 8 8 9 9 const Allocator = std.mem.Allocator; 10 10 11 + pub const App = @import("App.zig"); 12 + 11 13 pub const CommandList = std.ArrayList(Command); 12 14 13 15 pub const UserEvent = struct { ··· 421 423 try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 2 })); 422 424 } 423 425 426 + test "refAllDecls" { 427 + std.testing.refAllDecls(@This()); 428 + } 429 + 424 430 test "All widgets have a doctest and refAllDecls test" { 425 431 // This test goes through every file in src/ and checks that it has a doctest (the filename 426 432 // stripped of ".zig" matches a test name) and a test called "refAllDecls". It makes no 427 433 // guarantees about the quality of the test, but it does ensure it exists which at least makes 428 434 // it easy to fail CI early, or spot bad tests vs non-existant tests 429 - const excludes = &[_][]const u8{"vxfw.zig"}; 435 + const excludes = &[_][]const u8{ "vxfw.zig", "App.zig" }; 430 436 431 437 var cwd = try std.fs.cwd().openDir("./src/vxfw", .{ .iterate = true }); 432 438 var iter = cwd.iterate();