this repo has no description
1const std = @import("std");
2const vaxis = @import("../main.zig");
3const uucode = @import("uucode");
4
5const testing = std.testing;
6
7const assert = std.debug.assert;
8
9const Allocator = std.mem.Allocator;
10
11pub const App = @import("App.zig");
12
13// Widgets
14pub const Border = @import("Border.zig");
15pub const Button = @import("Button.zig");
16pub const Center = @import("Center.zig");
17pub const FlexColumn = @import("FlexColumn.zig");
18pub const FlexRow = @import("FlexRow.zig");
19pub const ListView = @import("ListView.zig");
20pub const Padding = @import("Padding.zig");
21pub const RichText = @import("RichText.zig");
22pub const ScrollView = @import("ScrollView.zig");
23pub const ScrollBars = @import("ScrollBars.zig");
24pub const SizedBox = @import("SizedBox.zig");
25pub const SplitView = @import("SplitView.zig");
26pub const Spinner = @import("Spinner.zig");
27pub const Text = @import("Text.zig");
28pub const TextField = @import("TextField.zig");
29
30pub const CommandList = std.ArrayList(Command);
31
32pub const UserEvent = struct {
33 name: []const u8,
34 data: ?*const anyopaque = null,
35};
36
37pub const Event = union(enum) {
38 key_press: vaxis.Key,
39 key_release: vaxis.Key,
40 mouse: vaxis.Mouse,
41 focus_in, // window has gained focus
42 focus_out, // window has lost focus
43 paste_start, // bracketed paste start
44 paste_end, // bracketed paste end
45 paste: []const u8, // osc 52 paste, caller must free
46 color_report: vaxis.Color.Report, // osc 4, 10, 11, 12 response
47 color_scheme: vaxis.Color.Scheme, // light / dark OS theme changes
48 winsize: vaxis.Winsize, // the window size has changed. This event is always sent when the loop is started
49 app: UserEvent, // A custom event from the app
50 tick, // An event from a Tick command
51 init, // sent when the application starts
52 mouse_leave, // The mouse has left the widget
53 mouse_enter, // The mouse has entered the widget
54};
55
56pub const Tick = struct {
57 deadline: std.Io.Timestamp,
58 widget: Widget,
59
60 pub fn lessThan(_: void, lhs: Tick, rhs: Tick) bool {
61 return lhs.deadline.nanoseconds > rhs.deadline.nanoseconds;
62 }
63
64 pub fn in(io: std.Io, ms: u32, widget: Widget) Command {
65 const now: std.Io.Timestamp = .now(io, .awake);
66 const deadline = now.addDuration(.fromMilliseconds(ms));
67 return .{
68 .tick = .{
69 .deadline = deadline,
70 .widget = widget,
71 },
72 };
73 }
74};
75
76pub const Command = union(enum) {
77 /// Callback the event with a tick event at the specified deadlline
78 tick: Tick,
79 /// Change the mouse shape. This also has an implicit redraw
80 set_mouse_shape: vaxis.Mouse.Shape,
81 /// Request that this widget receives focus
82 request_focus: Widget,
83
84 /// Try to copy the provided text to the host clipboard. Uses OSC 52. Silently fails if terminal
85 /// doesn't support OSC 52
86 copy_to_clipboard: []const u8,
87
88 /// Set the title of the terminal
89 set_title: []const u8,
90
91 /// Queue a refresh of the entire screen. Implicitly sets redraw
92 queue_refresh,
93
94 /// Send a system notification
95 notify: struct {
96 title: ?[]const u8,
97 body: []const u8,
98 },
99
100 query_color: vaxis.Cell.Color.Kind,
101};
102
103pub const EventContext = struct {
104 phase: Phase = .at_target,
105 io: std.Io,
106 alloc: Allocator,
107 cmds: CommandList,
108
109 /// The event was handled, do not pass it on
110 consume_event: bool = false,
111 /// Tells the event loop to redraw the UI
112 redraw: bool = true,
113 /// Quit the application
114 quit: bool = false,
115
116 pub const Phase = enum {
117 capturing,
118 at_target,
119 bubbling,
120 };
121
122 pub fn addCmd(self: *EventContext, cmd: Command) Allocator.Error!void {
123 try self.cmds.append(self.alloc, cmd);
124 }
125
126 pub fn tick(self: *EventContext, ms: u32, widget: Widget) Allocator.Error!void {
127 try self.addCmd(Tick.in(self.io, ms, widget));
128 }
129
130 pub fn consumeAndRedraw(self: *EventContext) void {
131 self.consume_event = true;
132 self.redraw = true;
133 }
134
135 pub fn consumeEvent(self: *EventContext) void {
136 self.consume_event = true;
137 }
138
139 pub fn setMouseShape(self: *EventContext, shape: vaxis.Mouse.Shape) Allocator.Error!void {
140 try self.addCmd(.{ .set_mouse_shape = shape });
141 self.redraw = true;
142 }
143
144 pub fn requestFocus(self: *EventContext, widget: Widget) Allocator.Error!void {
145 try self.addCmd(.{ .request_focus = widget });
146 }
147
148 /// Copy content to clipboard.
149 /// content is duplicated using self.alloc.
150 /// Caller retains ownership of their copy of content.
151 pub fn copyToClipboard(self: *EventContext, content: []const u8) Allocator.Error!void {
152 try self.addCmd(.{ .copy_to_clipboard = try self.alloc.dupe(u8, content) });
153 }
154
155 /// Set window title.
156 /// title is duplicated using self.alloc.
157 /// Caller retains ownership of their copy of title.
158 pub fn setTitle(self: *EventContext, title: []const u8) Allocator.Error!void {
159 try self.addCmd(.{ .set_title = try self.alloc.dupe(u8, title) });
160 }
161
162 pub fn queueRefresh(self: *EventContext) Allocator.Error!void {
163 try self.addCmd(.queue_refresh);
164 self.redraw = true;
165 }
166
167 /// Send a system notification. This function dupes title and body using it's own allocator.
168 /// They will be freed once the notification has been sent
169 pub fn sendNotification(
170 self: *EventContext,
171 maybe_title: ?[]const u8,
172 body: []const u8,
173 ) Allocator.Error!void {
174 const alloc = self.alloc;
175 if (maybe_title) |title| {
176 return self.addCmd(.{ .notify = .{
177 .title = try alloc.dupe(u8, title),
178 .body = try alloc.dupe(u8, body),
179 } });
180 }
181 return self.addCmd(.{ .notify = .{
182 .title = null,
183 .body = try alloc.dupe(u8, body),
184 } });
185 }
186
187 pub fn queryColor(self: *EventContext, kind: vaxis.Cell.Color.Kind) Allocator.Error!void {
188 try self.addCmd(.{ .query_color = kind });
189 }
190};
191
192pub const DrawContext = struct {
193 // Allocator backed by an arena. Widgets do not need to free their own resources, they will be
194 // freed after rendering
195 arena: std.mem.Allocator,
196 // Constraints
197 min: Size,
198 max: MaxSize,
199
200 // Size of a single cell, in pixels
201 cell_size: Size,
202
203 // Unicode stuff
204 var width_method: vaxis.gwidth.Method = .unicode;
205
206 pub fn init(method: vaxis.gwidth.Method) void {
207 DrawContext.width_method = method;
208 }
209
210 pub fn stringWidth(_: DrawContext, str: []const u8) usize {
211 return vaxis.gwidth.gwidth(
212 str,
213 DrawContext.width_method,
214 );
215 }
216
217 pub fn graphemeIterator(_: DrawContext, str: []const u8) vaxis.unicode.GraphemeIterator {
218 return vaxis.unicode.graphemeIterator(str);
219 }
220
221 pub fn withConstraints(self: DrawContext, min: Size, max: MaxSize) DrawContext {
222 return .{
223 .arena = self.arena,
224 .min = min,
225 .max = max,
226 .cell_size = self.cell_size,
227 };
228 }
229};
230
231pub const Size = struct {
232 width: u16 = 0,
233 height: u16 = 0,
234};
235
236pub const MaxSize = struct {
237 width: ?u16 = null,
238 height: ?u16 = null,
239
240 /// Returns true if the row would fall outside of this height. A null height value is infinite
241 /// and always returns false
242 pub fn outsideHeight(self: MaxSize, row: u16) bool {
243 const max = self.height orelse return false;
244 return row >= max;
245 }
246
247 /// Returns true if the col would fall outside of this width. A null width value is infinite
248 /// and always returns false
249 pub fn outsideWidth(self: MaxSize, col: u16) bool {
250 const max = self.width orelse return false;
251 return col >= max;
252 }
253
254 /// Asserts that neither height nor width are null
255 pub fn size(self: MaxSize) Size {
256 assert(self.width != null);
257 assert(self.height != null);
258 return .{
259 .width = self.width.?,
260 .height = self.height.?,
261 };
262 }
263
264 pub fn fromSize(other: Size) MaxSize {
265 return .{
266 .width = other.width,
267 .height = other.height,
268 };
269 }
270};
271
272/// The Widget interface
273pub const Widget = struct {
274 userdata: *anyopaque,
275 captureHandler: ?*const fn (userdata: *anyopaque, ctx: *EventContext, event: Event) anyerror!void = null,
276 eventHandler: ?*const fn (userdata: *anyopaque, ctx: *EventContext, event: Event) anyerror!void = null,
277 drawFn: *const fn (userdata: *anyopaque, ctx: DrawContext) Allocator.Error!Surface,
278
279 pub fn captureEvent(self: Widget, ctx: *EventContext, event: Event) anyerror!void {
280 if (self.captureHandler) |handle| {
281 return handle(self.userdata, ctx, event);
282 }
283 }
284
285 pub fn handleEvent(self: Widget, ctx: *EventContext, event: Event) anyerror!void {
286 if (self.eventHandler) |handle| {
287 return handle(self.userdata, ctx, event);
288 }
289 }
290
291 pub fn draw(self: Widget, ctx: DrawContext) Allocator.Error!Surface {
292 return self.drawFn(self.userdata, ctx);
293 }
294
295 /// Returns true if the Widgets point to the same widget instance. To be considered the same,
296 /// the userdata and drawFn fields must point to the same values in both widgets
297 pub fn eql(self: Widget, other: Widget) bool {
298 return @intFromPtr(self.userdata) == @intFromPtr(other.userdata) and
299 @intFromPtr(self.drawFn) == @intFromPtr(other.drawFn);
300 }
301};
302
303pub const FlexItem = struct {
304 widget: Widget,
305 /// A value of zero means the child will have it's inherent size. Any value greater than zero
306 /// and the remaining space will be proportioned to each item
307 flex: u8 = 1,
308
309 pub fn init(child: Widget, flex: u8) FlexItem {
310 return .{ .widget = child, .flex = flex };
311 }
312};
313
314pub const Point = struct {
315 row: u16,
316 col: u16,
317};
318
319pub const RelativePoint = struct {
320 row: i17,
321 col: i17,
322};
323
324/// Result of a hit test
325pub const HitResult = struct {
326 local: Point,
327 widget: Widget,
328};
329
330pub const CursorState = struct {
331 /// Local coordinates
332 row: u16,
333 /// Local coordinates
334 col: u16,
335 shape: vaxis.Cell.CursorShape = .default,
336};
337
338pub const Surface = struct {
339 /// Size of this surface
340 size: Size,
341 /// The widget this surface belongs to
342 widget: Widget,
343
344 /// Cursor state
345 cursor: ?CursorState = null,
346
347 /// Contents of this surface. Must be len == 0 or len == size.width * size.height
348 buffer: []vaxis.Cell,
349
350 children: []SubSurface,
351
352 pub fn empty(widget: Widget) Surface {
353 return .{
354 .size = .{},
355 .widget = widget,
356 .buffer = &.{},
357 .children = &.{},
358 };
359 }
360
361 /// Creates a slice of vaxis.Cell's equal to size.width * size.height
362 pub fn createBuffer(allocator: Allocator, size: Size) Allocator.Error![]vaxis.Cell {
363 const buffer = try allocator.alloc(vaxis.Cell, size.width * size.height);
364 @memset(buffer, .{ .default = true });
365 return buffer;
366 }
367
368 pub fn init(allocator: Allocator, widget: Widget, size: Size) Allocator.Error!Surface {
369 return .{
370 .size = size,
371 .widget = widget,
372 .buffer = try Surface.createBuffer(allocator, size),
373 .children = &.{},
374 };
375 }
376
377 pub fn initWithChildren(
378 allocator: Allocator,
379 widget: Widget,
380 size: Size,
381 children: []SubSurface,
382 ) Allocator.Error!Surface {
383 return .{
384 .size = size,
385 .widget = widget,
386 .buffer = try Surface.createBuffer(allocator, size),
387 .children = children,
388 };
389 }
390
391 pub fn writeCell(self: Surface, col: u16, row: u16, cell: vaxis.Cell) void {
392 if (self.size.width <= col) return;
393 if (self.size.height <= row) return;
394 const i = (row * self.size.width) + col;
395 assert(i < self.buffer.len);
396 self.buffer[i] = cell;
397 }
398
399 pub fn readCell(self: Surface, col: usize, row: usize) vaxis.Cell {
400 assert(col < self.size.width and row < self.size.height);
401 const i = (row * self.size.width) + col;
402 assert(i < self.buffer.len);
403 return self.buffer[i];
404 }
405
406 /// Creates a new surface of the same width, with the buffer trimmed to a given height
407 pub fn trimHeight(self: Surface, height: u16) Surface {
408 assert(height <= self.size.height);
409 return .{
410 .size = .{ .width = self.size.width, .height = height },
411 .widget = self.widget,
412 .buffer = self.buffer[0 .. self.size.width * height],
413 .children = self.children,
414 };
415 }
416
417 /// Walks the Surface tree to produce a list of all widgets that intersect Point. Point will
418 /// always be translated to local Surface coordinates. Asserts that this Surface does contain Point
419 pub fn hitTest(self: Surface, allocator: Allocator, list: *std.ArrayList(HitResult), point: Point) Allocator.Error!void {
420 assert(point.col < self.size.width and point.row < self.size.height);
421 // Add this widget to the hit list if it has an event or capture handler
422 if (self.widget.eventHandler != null or self.widget.captureHandler != null)
423 try list.append(allocator, .{ .local = point, .widget = self.widget });
424 for (self.children) |child| {
425 if (!child.containsPoint(point)) continue;
426 const child_point: Point = .{
427 .row = @intCast(point.row - child.origin.row),
428 .col = @intCast(point.col - child.origin.col),
429 };
430 try child.surface.hitTest(allocator, list, child_point);
431 }
432 }
433
434 /// Copies all cells from Surface to Window
435 pub fn render(self: Surface, win: vaxis.Window, focused: Widget) void {
436 // render self first
437 if (self.buffer.len > 0) {
438 assert(self.buffer.len == self.size.width * self.size.height);
439 for (self.buffer, 0..) |cell, i| {
440 const row = i / self.size.width;
441 const col = i % self.size.width;
442 win.writeCell(@intCast(col), @intCast(row), cell);
443 }
444 }
445
446 if (self.cursor) |cursor| {
447 if (self.widget.eql(focused)) {
448 win.showCursor(cursor.col, cursor.row);
449 win.setCursorShape(cursor.shape);
450 }
451 }
452
453 // Sort children by z-index
454 std.mem.sort(SubSurface, self.children, {}, SubSurface.lessThan);
455
456 // for each child, we make a window and render to it
457 for (self.children) |child| {
458 const child_win = win.child(.{
459 .x_off = @intCast(child.origin.col),
460 .y_off = @intCast(child.origin.row),
461 .width = @intCast(child.surface.size.width),
462 .height = @intCast(child.surface.size.height),
463 });
464 child.surface.render(child_win, focused);
465 }
466 }
467
468 /// Returns true if the surface satisfies a set of constraints
469 pub fn satisfiesConstraints(self: Surface, min: Size, max: Size) bool {
470 return self.size.width < max.width and
471 self.size.width > min.width and
472 self.size.height < max.height and
473 self.size.height > min.height;
474 }
475};
476
477pub const SubSurface = struct {
478 /// Origin relative to parent
479 origin: RelativePoint,
480 /// This surface
481 surface: Surface,
482 /// z-index relative to siblings
483 z_index: u8 = 0,
484
485 pub fn lessThan(_: void, lhs: SubSurface, rhs: SubSurface) bool {
486 return lhs.z_index < rhs.z_index;
487 }
488
489 /// Returns true if this SubSurface contains Point. Point must be in parent local units
490 pub fn containsPoint(self: SubSurface, point: Point) bool {
491 return point.col >= self.origin.col and
492 point.row >= self.origin.row and
493 point.col < (self.origin.col + self.surface.size.width) and
494 point.row < (self.origin.row + self.surface.size.height);
495 }
496};
497
498test {
499 std.testing.refAllDecls(@This());
500}
501
502test "SubSurface: containsPoint" {
503 const surf: SubSurface = .{
504 .origin = .{ .row = 2, .col = 2 },
505 .surface = .{
506 .size = .{ .width = 10, .height = 10 },
507 .widget = undefined,
508 .children = &.{},
509 .buffer = &.{},
510 },
511 .z_index = 0,
512 };
513
514 try testing.expect(surf.containsPoint(.{ .row = 2, .col = 2 }));
515 try testing.expect(surf.containsPoint(.{ .row = 3, .col = 3 }));
516 try testing.expect(surf.containsPoint(.{ .row = 11, .col = 11 }));
517
518 try testing.expect(!surf.containsPoint(.{ .row = 1, .col = 1 }));
519 try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 12 }));
520 try testing.expect(!surf.containsPoint(.{ .row = 2, .col = 12 }));
521 try testing.expect(!surf.containsPoint(.{ .row = 12, .col = 2 }));
522}
523
524test "refAllDecls" {
525 std.testing.refAllDecls(@This());
526}
527
528test "Surface: satisfiesConstraints" {
529 const surf: Surface = .{
530 .size = .{ .width = 10, .height = 10 },
531 .widget = undefined,
532 .children = &.{},
533 .buffer = &.{},
534 };
535
536 try testing.expect(surf.satisfiesConstraints(.{ .width = 1, .height = 1 }, .{ .width = 20, .height = 20 }));
537 try testing.expect(!surf.satisfiesConstraints(.{ .width = 10, .height = 10 }, .{ .width = 20, .height = 20 }));
538 try testing.expect(!surf.satisfiesConstraints(.{ .width = 1, .height = 1 }, .{ .width = 10, .height = 10 }));
539}
540
541test "All widgets have a doctest and refAllDecls test" {
542 const io = std.testing.io;
543 // This test goes through every file in src/ and checks that it has a doctest (the filename
544 // stripped of ".zig" matches a test name) and a test called "refAllDecls". It makes no
545 // guarantees about the quality of the test, but it does ensure it exists which at least makes
546 // it easy to fail CI early, or spot bad tests vs non-existant tests
547 const excludes = &[_][]const u8{ "vxfw.zig", "App.zig" };
548
549 var cwd = try std.Io.Dir.cwd().openDir(io, "./src/vxfw", .{ .iterate = true });
550 var iter = cwd.iterate();
551 defer cwd.close(io);
552 outer: while (try iter.next(io)) |file| {
553 if (file.kind != .file) continue;
554 for (excludes) |ex| if (std.mem.eql(u8, ex, file.name)) continue :outer;
555
556 const container_name = if (std.mem.lastIndexOf(u8, file.name, ".zig")) |idx|
557 file.name[0..idx]
558 else
559 continue;
560 const data = try cwd.readFileAllocOptions(io, file.name, std.testing.allocator, .limited(10_000_000), .of(u8), 0x00);
561 defer std.testing.allocator.free(data);
562 var ast = try std.zig.Ast.parse(std.testing.allocator, data, .zig);
563 defer ast.deinit(std.testing.allocator);
564
565 var has_doctest: bool = false;
566 var has_refAllDecls: bool = false;
567 for (ast.rootDecls()) |root_decl| {
568 const decl = ast.nodes.get(@intFromEnum(root_decl));
569 switch (decl.tag) {
570 .test_decl => {
571 const test_name = ast.tokenSlice(decl.main_token + 1);
572 if (std.mem.eql(u8, "\"refAllDecls\"", test_name))
573 has_refAllDecls = true
574 else if (std.mem.eql(u8, container_name, test_name))
575 has_doctest = true;
576 },
577 else => continue,
578 }
579 }
580 if (!has_doctest) {
581 std.log.err("file {s} has no doctest", .{file.name});
582 return error.TestExpectedDoctest;
583 }
584 if (!has_refAllDecls) {
585 std.log.err("file {s} has no 'refAllDecls' test", .{file.name});
586 return error.TestExpectedRefAllDecls;
587 }
588 }
589}