a modern tui library written in zig
at main 604 lines 21 kB view raw
1const std = @import("std"); 2const vaxis = @import("../main.zig"); 3const vxfw = @import("vxfw.zig"); 4 5const assert = std.debug.assert; 6 7const Allocator = std.mem.Allocator; 8 9const EventLoop = vaxis.Loop(vxfw.Event); 10const Widget = vxfw.Widget; 11 12const App = @This(); 13 14allocator: Allocator, 15tty: vaxis.Tty, 16vx: vaxis.Vaxis, 17timers: std.ArrayList(vxfw.Tick), 18wants_focus: ?vxfw.Widget, 19buffer: [1024]u8, 20 21/// Runtime options 22pub const Options = struct { 23 /// Frames per second 24 framerate: u8 = 60, 25}; 26 27/// Create an application. We require stable pointers to do the set up, so this will create an App 28/// object on the heap. Call destroy when the app is complete to reset terminal state and release 29/// resources 30pub fn init(allocator: Allocator) !App { 31 var app: App = .{ 32 .allocator = allocator, 33 .tty = undefined, 34 .vx = try vaxis.init(allocator, .{ 35 .system_clipboard_allocator = allocator, 36 .kitty_keyboard_flags = .{ 37 .report_events = true, 38 }, 39 }), 40 .timers = std.ArrayList(vxfw.Tick){}, 41 .wants_focus = null, 42 .buffer = undefined, 43 }; 44 app.tty = try vaxis.Tty.init(&app.buffer); 45 return app; 46} 47 48pub fn deinit(self: *App) void { 49 self.timers.deinit(self.allocator); 50 self.vx.deinit(self.allocator, self.tty.writer()); 51 self.tty.deinit(); 52} 53 54pub fn run(self: *App, widget: vxfw.Widget, opts: Options) anyerror!void { 55 const tty = &self.tty; 56 const vx = &self.vx; 57 58 var loop: EventLoop = .{ .tty = tty, .vaxis = vx }; 59 try loop.start(); 60 defer loop.stop(); 61 62 // Send the init event 63 loop.postEvent(.init); 64 // Also always initialize the app with a focus event 65 loop.postEvent(.focus_in); 66 67 try vx.enterAltScreen(tty.writer()); 68 try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); 69 try vx.setBracketedPaste(tty.writer(), true); 70 try vx.subscribeToColorSchemeUpdates(tty.writer()); 71 72 { 73 // This part deserves a comment. loop.init installs a signal handler for the tty. We wait to 74 // init the loop until we know if we need this handler. We don't need it if the terminal 75 // supports in-band-resize 76 if (!vx.state.in_band_resize) try loop.init(); 77 } 78 79 // NOTE: We don't use pixel mouse anywhere 80 vx.caps.sgr_pixels = false; 81 try vx.setMouseMode(tty.writer(), true); 82 83 vxfw.DrawContext.init(vx.screen.width_method); 84 85 const framerate: u64 = if (opts.framerate > 0) opts.framerate else 60; 86 // Calculate tick rate 87 const tick_ms: u64 = @divFloor(std.time.ms_per_s, framerate); 88 89 // Set up arena and context 90 var arena = std.heap.ArenaAllocator.init(self.allocator); 91 defer arena.deinit(); 92 93 var mouse_handler = MouseHandler.init(widget); 94 defer mouse_handler.deinit(self.allocator); 95 var focus_handler = FocusHandler.init(self.allocator, widget); 96 try focus_handler.path_to_focused.append(self.allocator, widget); 97 defer focus_handler.deinit(self.allocator); 98 99 // Timestamp of our next frame 100 var next_frame_ms: u64 = @intCast(std.time.milliTimestamp()); 101 102 // Create our event context 103 var ctx: vxfw.EventContext = .{ 104 .alloc = self.allocator, 105 .phase = .capturing, 106 .cmds = vxfw.CommandList{}, 107 .consume_event = false, 108 .redraw = false, 109 .quit = false, 110 }; 111 defer ctx.cmds.deinit(self.allocator); 112 113 while (true) { 114 const now_ms: u64 = @intCast(std.time.milliTimestamp()); 115 if (now_ms >= next_frame_ms) { 116 // Deadline exceeded. Schedule the next frame 117 next_frame_ms = now_ms + tick_ms; 118 } else { 119 // Sleep until the deadline 120 std.Thread.sleep((next_frame_ms - now_ms) * std.time.ns_per_ms); 121 next_frame_ms += tick_ms; 122 } 123 124 try self.checkTimers(&ctx); 125 126 { 127 loop.queue.lock(); 128 defer loop.queue.unlock(); 129 while (loop.queue.drain()) |event| { 130 defer { 131 // Reset our context 132 ctx.consume_event = false; 133 ctx.phase = .capturing; 134 } 135 switch (event) { 136 .key_press => { 137 try focus_handler.handleEvent(&ctx, event); 138 try self.handleCommand(&ctx.cmds); 139 }, 140 .focus_out => { 141 try mouse_handler.mouseExit(self, &ctx); 142 try focus_handler.handleEvent(&ctx, .focus_out); 143 try self.handleCommand(&ctx.cmds); 144 }, 145 .focus_in => { 146 try focus_handler.handleEvent(&ctx, .focus_in); 147 try self.handleCommand(&ctx.cmds); 148 }, 149 .mouse => |mouse| try mouse_handler.handleMouse(self, &ctx, mouse), 150 .winsize => |ws| { 151 try vx.resize(self.allocator, tty.writer(), ws); 152 ctx.redraw = true; 153 }, 154 else => { 155 try focus_handler.handleEvent(&ctx, event); 156 try self.handleCommand(&ctx.cmds); 157 }, 158 } 159 } 160 } 161 162 // If we have a focus change, handle that event before we layout 163 if (self.wants_focus) |wants_focus| { 164 try focus_handler.focusWidget(&ctx, wants_focus); 165 try self.handleCommand(&ctx.cmds); 166 self.wants_focus = null; 167 } 168 169 // Check if we should quit 170 if (ctx.quit) return; 171 172 // Check if we need a redraw 173 if (!ctx.redraw) continue; 174 ctx.redraw = false; 175 // Clear the arena. 176 _ = arena.reset(.free_all); 177 // Assert that we have handled all commands 178 assert(ctx.cmds.items.len == 0); 179 180 const surface: vxfw.Surface = blk: { 181 // Draw the root widget 182 const surface = try self.doLayout(widget, &arena); 183 184 // Check if any hover or mouse effects changed 185 try mouse_handler.updateMouse(self, surface, &ctx); 186 // Our focus may have changed. Handle that here 187 if (self.wants_focus) |wants_focus| { 188 try focus_handler.focusWidget(&ctx, wants_focus); 189 try self.handleCommand(&ctx.cmds); 190 self.wants_focus = null; 191 } 192 193 assert(ctx.cmds.items.len == 0); 194 if (!ctx.redraw) break :blk surface; 195 // If updating the mouse required a redraw, we do the layout again 196 break :blk try self.doLayout(widget, &arena); 197 }; 198 199 // Store the last frame 200 mouse_handler.last_frame = surface; 201 // Update the focus handler list 202 try focus_handler.update(self.allocator, surface); 203 try self.render(surface, focus_handler.focused_widget); 204 } 205} 206 207fn doLayout( 208 self: *App, 209 widget: vxfw.Widget, 210 arena: *std.heap.ArenaAllocator, 211) !vxfw.Surface { 212 const vx = &self.vx; 213 214 const draw_context: vxfw.DrawContext = .{ 215 .arena = arena.allocator(), 216 .min = .{ .width = 0, .height = 0 }, 217 .max = .{ 218 .width = @intCast(vx.screen.width), 219 .height = @intCast(vx.screen.height), 220 }, 221 .cell_size = .{ 222 .width = vx.screen.width_pix / vx.screen.width, 223 .height = vx.screen.height_pix / vx.screen.height, 224 }, 225 }; 226 return widget.draw(draw_context); 227} 228 229fn render( 230 self: *App, 231 surface: vxfw.Surface, 232 focused_widget: vxfw.Widget, 233) !void { 234 const vx = &self.vx; 235 const tty = &self.tty; 236 237 const win = vx.window(); 238 win.clear(); 239 win.hideCursor(); 240 win.setCursorShape(.default); 241 242 const root_win = win.child(.{ 243 .width = surface.size.width, 244 .height = surface.size.height, 245 }); 246 surface.render(root_win, focused_widget); 247 248 try vx.render(tty.writer()); 249} 250 251fn addTick(self: *App, tick: vxfw.Tick) Allocator.Error!void { 252 try self.timers.append(self.allocator, tick); 253 std.sort.insertion(vxfw.Tick, self.timers.items, {}, vxfw.Tick.lessThan); 254} 255 256fn handleCommand(self: *App, cmds: *vxfw.CommandList) Allocator.Error!void { 257 defer cmds.clearRetainingCapacity(); 258 for (cmds.items) |cmd| { 259 switch (cmd) { 260 .tick => |tick| try self.addTick(tick), 261 .set_mouse_shape => |shape| self.vx.setMouseShape(shape), 262 .request_focus => |widget| self.wants_focus = widget, 263 .copy_to_clipboard => |content| { 264 defer self.allocator.free(content); 265 self.vx.copyToSystemClipboard(self.tty.writer(), content, self.allocator) catch |err| { 266 switch (err) { 267 error.OutOfMemory => return Allocator.Error.OutOfMemory, 268 else => std.log.err("copy error: {}", .{err}), 269 } 270 }; 271 }, 272 .set_title => |title| { 273 defer self.allocator.free(title); 274 self.vx.setTitle(self.tty.writer(), title) catch |err| { 275 std.log.err("set_title error: {}", .{err}); 276 }; 277 }, 278 .queue_refresh => self.vx.queueRefresh(), 279 .notify => |notification| { 280 self.vx.notify(self.tty.writer(), notification.title, notification.body) catch |err| { 281 std.log.err("notify error: {}", .{err}); 282 }; 283 const alloc = self.allocator; 284 if (notification.title) |title| { 285 alloc.free(title); 286 } 287 alloc.free(notification.body); 288 }, 289 .query_color => |kind| { 290 self.vx.queryColor(self.tty.writer(), kind) catch |err| { 291 std.log.err("queryColor error: {}", .{err}); 292 }; 293 }, 294 } 295 } 296} 297 298fn checkTimers(self: *App, ctx: *vxfw.EventContext) anyerror!void { 299 const now_ms = std.time.milliTimestamp(); 300 301 // timers are always sorted descending 302 while (self.timers.pop()) |tick| { 303 if (now_ms < tick.deadline_ms) { 304 // re-add the timer 305 try self.timers.append(self.allocator, tick); 306 break; 307 } 308 try tick.widget.handleEvent(ctx, .tick); 309 } 310 try self.handleCommand(&ctx.cmds); 311} 312 313const MouseHandler = struct { 314 last_frame: vxfw.Surface, 315 last_hit_list: []vxfw.HitResult, 316 mouse: ?vaxis.Mouse, 317 318 fn init(root: Widget) MouseHandler { 319 return .{ 320 .last_frame = .{ 321 .size = .{ .width = 0, .height = 0 }, 322 .widget = root, 323 .buffer = &.{}, 324 .children = &.{}, 325 }, 326 .last_hit_list = &.{}, 327 .mouse = null, 328 }; 329 } 330 331 fn deinit(self: MouseHandler, gpa: Allocator) void { 332 gpa.free(self.last_hit_list); 333 } 334 335 fn updateMouse( 336 self: *MouseHandler, 337 app: *App, 338 surface: vxfw.Surface, 339 ctx: *vxfw.EventContext, 340 ) anyerror!void { 341 const mouse = self.mouse orelse return; 342 // For mouse events we store the last frame and use that for hit testing 343 const last_frame = surface; 344 345 var hits = std.ArrayList(vxfw.HitResult){}; 346 defer hits.deinit(app.allocator); 347 const sub: vxfw.SubSurface = .{ 348 .origin = .{ .row = 0, .col = 0 }, 349 .surface = last_frame, 350 .z_index = 0, 351 }; 352 const mouse_point: vxfw.Point = .{ 353 .row = @intCast(mouse.row), 354 .col = @intCast(mouse.col), 355 }; 356 if (sub.containsPoint(mouse_point)) { 357 try last_frame.hitTest(app.allocator, &hits, mouse_point); 358 } 359 360 // We store the hit list from the last mouse event to determine mouse_enter and mouse_leave 361 // events. If list a is the previous hit list, and list b is the current hit list: 362 // - Widgets in a but not in b get a mouse_leave event 363 // - Widgets in b but not in a get a mouse_enter event 364 // - Widgets in both receive nothing 365 const a = self.last_hit_list; 366 const b = hits.items; 367 368 // Find widgets in a but not b 369 for (a) |a_item| { 370 const a_widget = a_item.widget; 371 for (b) |b_item| { 372 const b_widget = b_item.widget; 373 if (a_widget.eql(b_widget)) break; 374 } else { 375 // a_item is not in b 376 try a_widget.handleEvent(ctx, .mouse_leave); 377 try app.handleCommand(&ctx.cmds); 378 } 379 } 380 381 // Widgets in b but not in a 382 for (b) |b_item| { 383 const b_widget = b_item.widget; 384 for (a) |a_item| { 385 const a_widget = a_item.widget; 386 if (b_widget.eql(a_widget)) break; 387 } else { 388 // b_item is not in a. 389 try b_widget.handleEvent(ctx, .mouse_enter); 390 try app.handleCommand(&ctx.cmds); 391 } 392 } 393 394 // Store a copy of this hit list for next frame 395 app.allocator.free(self.last_hit_list); 396 self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items); 397 } 398 399 fn handleMouse(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext, mouse: vaxis.Mouse) anyerror!void { 400 // For mouse events we store the last frame and use that for hit testing 401 const last_frame = self.last_frame; 402 self.mouse = mouse; 403 404 var hits = std.ArrayList(vxfw.HitResult){}; 405 defer hits.deinit(app.allocator); 406 const sub: vxfw.SubSurface = .{ 407 .origin = .{ .row = 0, .col = 0 }, 408 .surface = last_frame, 409 .z_index = 0, 410 }; 411 const mouse_point: vxfw.Point = .{ 412 .row = @intCast(mouse.row), 413 .col = @intCast(mouse.col), 414 }; 415 if (sub.containsPoint(mouse_point)) { 416 try last_frame.hitTest(app.allocator, &hits, mouse_point); 417 } 418 419 // Handle mouse_enter and mouse_leave events 420 { 421 // We store the hit list from the last mouse event to determine mouse_enter and mouse_leave 422 // events. If list a is the previous hit list, and list b is the current hit list: 423 // - Widgets in a but not in b get a mouse_leave event 424 // - Widgets in b but not in a get a mouse_enter event 425 // - Widgets in both receive nothing 426 const a = self.last_hit_list; 427 const b = hits.items; 428 429 // Find widgets in a but not b 430 for (a) |a_item| { 431 const a_widget = a_item.widget; 432 for (b) |b_item| { 433 const b_widget = b_item.widget; 434 if (a_widget.eql(b_widget)) break; 435 } else { 436 // a_item is not in b 437 try a_widget.handleEvent(ctx, .mouse_leave); 438 try app.handleCommand(&ctx.cmds); 439 } 440 } 441 442 // Widgets in b but not in a 443 for (b) |b_item| { 444 const b_widget = b_item.widget; 445 for (a) |a_item| { 446 const a_widget = a_item.widget; 447 if (b_widget.eql(a_widget)) break; 448 } else { 449 // b_item is not in a. 450 try b_widget.handleEvent(ctx, .mouse_enter); 451 try app.handleCommand(&ctx.cmds); 452 } 453 } 454 455 // Store a copy of this hit list for next frame 456 app.allocator.free(self.last_hit_list); 457 self.last_hit_list = try app.allocator.dupe(vxfw.HitResult, hits.items); 458 } 459 460 const target = hits.pop() orelse return; 461 462 // capturing phase 463 ctx.phase = .capturing; 464 for (hits.items) |item| { 465 var m_local = mouse; 466 m_local.col = @intCast(item.local.col); 467 m_local.row = @intCast(item.local.row); 468 try item.widget.captureEvent(ctx, .{ .mouse = m_local }); 469 try app.handleCommand(&ctx.cmds); 470 471 if (ctx.consume_event) return; 472 } 473 474 // target phase 475 ctx.phase = .at_target; 476 { 477 var m_local = mouse; 478 m_local.col = @intCast(target.local.col); 479 m_local.row = @intCast(target.local.row); 480 try target.widget.handleEvent(ctx, .{ .mouse = m_local }); 481 try app.handleCommand(&ctx.cmds); 482 483 if (ctx.consume_event) return; 484 } 485 486 // Bubbling phase 487 ctx.phase = .bubbling; 488 while (hits.pop()) |item| { 489 var m_local = mouse; 490 m_local.col = @intCast(item.local.col); 491 m_local.row = @intCast(item.local.row); 492 try item.widget.handleEvent(ctx, .{ .mouse = m_local }); 493 try app.handleCommand(&ctx.cmds); 494 495 if (ctx.consume_event) return; 496 } 497 } 498 499 /// sends .mouse_leave to all of the widgets from the last_hit_list 500 fn mouseExit(self: *MouseHandler, app: *App, ctx: *vxfw.EventContext) anyerror!void { 501 for (self.last_hit_list) |item| { 502 try item.widget.handleEvent(ctx, .mouse_leave); 503 try app.handleCommand(&ctx.cmds); 504 } 505 } 506}; 507 508/// Maintains a tree of focusable nodes. Delivers events to the currently focused node, walking up 509/// the tree until the event is handled 510const FocusHandler = struct { 511 root: Widget, 512 focused_widget: vxfw.Widget, 513 path_to_focused: std.ArrayList(Widget), 514 515 fn init(_: Allocator, root: Widget) FocusHandler { 516 return .{ 517 .root = root, 518 .focused_widget = root, 519 .path_to_focused = std.ArrayList(Widget){}, 520 }; 521 } 522 523 fn deinit(self: *FocusHandler, allocator: Allocator) void { 524 self.path_to_focused.deinit(allocator); 525 } 526 527 /// Update the focus list 528 fn update(self: *FocusHandler, allocator: Allocator, surface: vxfw.Surface) Allocator.Error!void { 529 // clear path 530 self.path_to_focused.clearAndFree(allocator); 531 532 // Find the path to the focused widget. This builds a list that has the first element as the 533 // focused widget, and walks backward to the root. It's possible our focused widget is *not* 534 // in this tree. If this is the case, we refocus to the root widget 535 _ = try self.childHasFocus(allocator, surface); 536 537 if (!self.root.eql(surface.widget)) { 538 // If the root of surface is not the initial widget, we append the initial widget 539 try self.path_to_focused.append(allocator, self.root); 540 } 541 542 // reverse path_to_focused so that it is root first 543 std.mem.reverse(Widget, self.path_to_focused.items); 544 } 545 546 /// Returns true if a child of surface is the focused widget 547 fn childHasFocus( 548 self: *FocusHandler, 549 allocator: Allocator, 550 surface: vxfw.Surface, 551 ) Allocator.Error!bool { 552 // Check if we are the focused widget 553 if (self.focused_widget.eql(surface.widget)) { 554 try self.path_to_focused.append(allocator, surface.widget); 555 return true; 556 } 557 for (surface.children) |child| { 558 // Add child to list if it is the focused widget or one of it's own children is 559 if (try self.childHasFocus(allocator, child.surface)) { 560 try self.path_to_focused.append(allocator, surface.widget); 561 return true; 562 } 563 } 564 return false; 565 } 566 567 fn focusWidget(self: *FocusHandler, ctx: *vxfw.EventContext, widget: vxfw.Widget) anyerror!void { 568 // Focusing a widget requires it to have an event handler 569 assert(widget.eventHandler != null); 570 if (self.focused_widget.eql(widget)) return; 571 572 ctx.phase = .at_target; 573 try self.focused_widget.handleEvent(ctx, .focus_out); 574 self.focused_widget = widget; 575 try self.focused_widget.handleEvent(ctx, .focus_in); 576 } 577 578 fn handleEvent(self: *FocusHandler, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { 579 const path = self.path_to_focused.items; 580 assert(path.len > 0); 581 582 // Capturing phase. We send capture events from the root to the target (inclusive of target) 583 ctx.phase = .capturing; 584 for (path) |widget| { 585 try widget.captureEvent(ctx, event); 586 if (ctx.consume_event) return; 587 } 588 589 // Target phase. This is only sent to the target 590 ctx.phase = .at_target; 591 const target = self.path_to_focused.getLast(); 592 try target.handleEvent(ctx, event); 593 if (ctx.consume_event) return; 594 595 // Bubbling phase. Bubbling phase moves from target (exclusive) to the root 596 ctx.phase = .bubbling; 597 const target_idx = path.len - 1; 598 var iter = std.mem.reverseIterator(path[0..target_idx]); 599 while (iter.next()) |widget| { 600 try widget.handleEvent(ctx, event); 601 if (ctx.consume_event) return; 602 } 603 } 604};