Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169//! 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());}