a modern tui library written in zig
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};