//! Animated spinner driven by a self-chaining tick Cmd. Pick from the //! presets (`Spinner.line`, `Spinner.dot`, etc.) or supply your own frames. //! //! Typical use: embed in a parent, return `spinner.start(gpa)` from the //! parent's init, forward updates that don't belong to you to //! `spinner.update`, and call `spinner.view(w)` from view. const std = @import("std"); const matcha = @import("matcha"); const blush = @import("blush"); const util = @import("util.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Cmd = matcha.Cmd; const Msg = matcha.Msg; const Spinner = @This(); pub const Preset = struct { frames: []const []const u8, fps_ns: u64, pub const line: Preset = .{ .frames = &.{ "|", "/", "-", "\\" }, .fps_ns = std.time.ns_per_s / 10, }; pub const dot: Preset = .{ .frames = &.{ "⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ " }, .fps_ns = std.time.ns_per_s / 10, }; pub const mini_dot: Preset = .{ .frames = &.{ "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }, .fps_ns = std.time.ns_per_s / 12, }; pub const jump: Preset = .{ .frames = &.{ "⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠" }, .fps_ns = std.time.ns_per_s / 10, }; pub const pulse: Preset = .{ .frames = &.{ "█", "▓", "▒", "░" }, .fps_ns = std.time.ns_per_s / 8, }; pub const points: Preset = .{ .frames = &.{ "∙∙∙", "●∙∙", "∙●∙", "∙∙●" }, .fps_ns = std.time.ns_per_s / 7, }; pub const globe: Preset = .{ .frames = &.{ "🌍", "🌎", "🌏" }, .fps_ns = std.time.ns_per_s / 4, }; pub const moon: Preset = .{ .frames = &.{ "🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘" }, .fps_ns = std.time.ns_per_s / 8, }; pub const monkey: Preset = .{ .frames = &.{ "🙈", "🙉", "🙊" }, .fps_ns = std.time.ns_per_s / 3, }; pub const meter: Preset = .{ .frames = &.{ "▱▱▱", "▰▱▱", "▰▰▱", "▰▰▰", "▰▰▱", "▰▱▱", "▱▱▱" }, .fps_ns = std.time.ns_per_s / 7, }; pub const hamburger: Preset = .{ .frames = &.{ "☱", "☲", "☴", "☲" }, .fps_ns = std.time.ns_per_s / 3, }; pub const ellipsis: Preset = .{ .frames = &.{ "", ".", "..", "..." }, .fps_ns = std.time.ns_per_s / 3, }; }; var tick_marker: u8 = 0; fn tickTypeId() usize { return @intFromPtr(&tick_marker); } model: matcha.Model = .{ .vtable = &vtable }, preset: Preset = .line, style: ?blush.Style = null, frame: usize = 0, running: bool = false, const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, }; fn vtableInit(m: *matcha.Model) ?Cmd { const self: *Spinner = @fieldParentPtr("model", m); return self.start(m.gpa); } fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *Spinner = @fieldParentPtr("model", m); return self.update(m.gpa, msg); } fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *Spinner = @fieldParentPtr("model", m); try self.view(w); return .{}; } /// Starts the spinner. No-op if already running. pub fn start(self: *Spinner, allocator: Allocator) ?Cmd { if (self.running) return null; self.running = true; return self.scheduleTick(allocator); } /// Stops the spinner. The next tick that arrives is ignored. pub fn stop(self: *Spinner) void { self.running = false; } pub fn update(self: *Spinner, allocator: Allocator, msg: Msg) ?Cmd { switch (msg) { .custom => |c| { if (c.type_id != tickTypeId()) return null; if (!self.running) return null; self.frame = (self.frame + 1) % self.preset.frames.len; return self.scheduleTick(allocator); }, else => return null, } } pub fn view(self: *const Spinner, w: *Writer) Writer.Error!void { if (self.preset.frames.len == 0) return; const f = self.preset.frames[self.frame % self.preset.frames.len]; try util.writeInline(w, self.style, f); } fn scheduleTick(self: *Spinner, allocator: Allocator) ?Cmd { return Cmd.tick(allocator, self.preset.fps_ns, .{ .custom = .{ .type_id = tickTypeId() }, }) catch { self.running = false; return null; }; } test "start sets running" { var s: Spinner = .{}; var gpa_state: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa_state.deinit(); const gpa = gpa_state.allocator(); const cmd = s.start(gpa); try std.testing.expect(s.running); try std.testing.expect(cmd != null); if (cmd) |c| if (c.free_ctx) |free| if (c.ctx) |ctx| free(gpa, ctx); } test "stop clears running" { var s: Spinner = .{ .running = true }; s.stop(); try std.testing.expect(!s.running); } test "view writes current frame" { var buf: [16]u8 = undefined; var w: Writer = .fixed(&buf); const s: Spinner = .{ .preset = .line, .frame = 0 }; try s.view(&w); try std.testing.expectEqualStrings("|", w.buffered()); }