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