this repo has no description
1const std = @import("std");
2const vaxis = @import("../main.zig");
3
4const vxfw = @import("vxfw.zig");
5
6const Allocator = std.mem.Allocator;
7
8const Spinner = @This();
9
10const frames: []const []const u8 = &.{ "⣶", "⣧", "⣏", "⡟", "⠿", "⢻", "⣹", "⣼" };
11const time_lapse: u32 = std.time.ms_per_s / 12; // 12 fps
12
13io: std.Io,
14count: std.atomic.Value(u16) = .{ .raw = 0 },
15style: vaxis.Style = .{},
16/// The frame index
17frame: u4 = 0,
18
19/// Turns true when we start the spinner. Only turns false during a draw if the count == 0. This
20/// ensures we draw one more time past the spinner stopping to clear the state
21was_spinning: std.atomic.Value(bool) = .{ .raw = false },
22
23/// Start, or add one, to the spinner counter. Thread safe.
24pub fn start(self: *Spinner) ?vxfw.Command {
25 self.was_spinning.store(true, .unordered);
26 const count = self.count.fetchAdd(1, .monotonic);
27 if (count == 0) {
28 return vxfw.Tick.in(self.io, time_lapse, self.widget());
29 }
30 return null;
31}
32
33/// Reduce one from the spinner counter. The spinner will stop when it reaches 0. Thread safe
34pub fn stop(self: *Spinner) void {
35 self.count.store(self.count.load(.unordered) -| 1, .unordered);
36}
37
38pub fn wasSpinning(self: *Spinner) bool {
39 return self.was_spinning.load(.unordered);
40}
41
42pub fn widget(self: *Spinner) vxfw.Widget {
43 return .{
44 .userdata = self,
45 .eventHandler = typeErasedEventHandler,
46 .drawFn = typeErasedDrawFn,
47 };
48}
49
50fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
51 const self: *Spinner = @ptrCast(@alignCast(ptr));
52 return self.handleEvent(ctx, event);
53}
54
55pub fn handleEvent(self: *Spinner, ctx: *vxfw.EventContext, event: vxfw.Event) Allocator.Error!void {
56 switch (event) {
57 .tick => {
58 const count = self.count.load(.unordered);
59
60 if (count == 0) {
61 if (self.wasSpinning()) {
62 ctx.redraw = true;
63 self.was_spinning.store(false, .unordered);
64 }
65 return;
66 }
67 // Update frame
68 self.frame += 1;
69 if (self.frame >= frames.len) self.frame = 0;
70
71 // Update rearm
72 try ctx.tick(time_lapse, self.widget());
73 },
74 else => {},
75 }
76}
77
78fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
79 const self: *Spinner = @ptrCast(@alignCast(ptr));
80 return self.draw(ctx);
81}
82
83pub fn draw(self: *Spinner, ctx: vxfw.DrawContext) Allocator.Error!vxfw.Surface {
84 const size: vxfw.Size = .{
85 .width = @max(1, ctx.min.width),
86 .height = @max(1, ctx.min.height),
87 };
88
89 const surface = try vxfw.Surface.init(ctx.arena, self.widget(), size);
90 @memset(surface.buffer, .{ .style = self.style });
91
92 if (self.count.load(.unordered) == 0) return surface;
93
94 surface.writeCell(0, 0, .{
95 .char = .{
96 .grapheme = frames[self.frame],
97 .width = 1,
98 },
99 .style = self.style,
100 });
101 return surface;
102}
103
104test Spinner {
105 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
106 defer arena.deinit();
107 // Create a spinner
108 var spinner: Spinner = .{ .io = std.testing.io };
109 // Get our widget interface
110 const spinner_widget = spinner.widget();
111
112 // Start the spinner. This (maybe) returns a Tick command to schedule the next frame. If the
113 // spinner is already running, no command is returned. Calling start is thread safe. The
114 // returned command can be added to an EventContext to schedule the frame
115 const maybe_cmd = spinner.start();
116 try std.testing.expect(maybe_cmd != null);
117 try std.testing.expect(maybe_cmd.? == .tick);
118 try std.testing.expectEqual(1, spinner.count.load(.unordered));
119
120 // If we call start again, we won't get another command but our counter will go up
121 const maybe_cmd2 = spinner.start();
122 try std.testing.expect(maybe_cmd2 == null);
123 try std.testing.expectEqual(2, spinner.count.load(.unordered));
124
125 // We are about to deliver the tick to the widget. We need an EventContext (the engine will
126 // provide this)
127 var ctx: vxfw.EventContext = .{
128 .io = std.testing.io,
129 .alloc = arena.allocator(),
130 .cmds = .empty,
131 };
132
133 // The event loop handles the tick event and calls us back with a .tick event. If we should keep
134 // running, we will add a new tick event
135 try spinner_widget.handleEvent(&ctx, .tick);
136
137 // Receiving a .tick advances the frame
138 try std.testing.expectEqual(1, spinner.frame);
139
140 // Simulate a draw
141 const surface = try spinner_widget.draw(.{
142 .arena = arena.allocator(),
143 .min = .{},
144 .max = .{},
145 .cell_size = .{ .width = 10, .height = 20 },
146 });
147
148 // Spinner will try to be 1x1
149 try std.testing.expectEqual(1, surface.size.width);
150 try std.testing.expectEqual(1, surface.size.height);
151
152 // Stopping the spinner decrements our counter
153 spinner.stop();
154 try std.testing.expectEqual(1, spinner.count.load(.unordered));
155 spinner.stop();
156 try std.testing.expectEqual(0, spinner.count.load(.unordered));
157}
158
159test "refAllDecls" {
160 std.testing.refAllDecls(@This());
161}