Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172//! Countdown timer. Ticks down by `interval_ns` until reaching zero. View//! renders remaining time. `timed_out` flips true on the tick that reaches//! zero; that tick also emits a one-shot timeout notification (see//! `timeoutTypeId`) and the tick chain stops there.const std = @import("std");const tea = @import("blacktea");const util = @import("util.zig");
const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;
const Timer = @This();
const default_interval_ns: u64 = std.time.ns_per_s;
var tick_marker: u8 = 0;fn tickTypeId() usize { return @intFromPtr(&tick_marker);}
var timeout_marker: u8 = 0;
/// Type id of the one-shot `tea.Msg.custom` the timer emits when it expires./// Matches bubbles' `Timeouttea.Msg`; the owning model can match on it, e.g./// `msg == .custom and msg.custom.type_id == leaves.Timer.timeoutTypeId()`.pub fn timeoutTypeId() usize { return @intFromPtr(&timeout_marker);}
/// Time remaining until the timer expires. May go negative after the tick/// that crosses zero (Go's `time.Duration` behaves the same way).timeout_ns: i64,interval_ns: u64 = default_interval_ns,running: bool = false,
model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, },},
fn vtableInit(m: *tea.Model) ?tea.Cmd { const self: *Timer = @fieldParentPtr("model", m); return self.start(m.gpa);}
fn vtableUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *Timer = @fieldParentPtr("model", m); return self.update(m.gpa, msg);}
fn vtableView(m: *tea.Model, w: *Writer) Writer.Error!tea.View { const self: *Timer = @fieldParentPtr("model", m); try self.view(w); return .{};}
pub fn timedOut(self: *const Timer) bool { return self.timeout_ns <= 0;}
pub fn isRunning(self: *const Timer) bool { return self.running and !self.timedOut();}
pub fn start(self: *Timer, allocator: Allocator) ?tea.Cmd { if (self.timedOut() or self.running) return null; self.running = true; return self.tick(allocator);}
pub fn stop(self: *Timer) void { self.running = false;}
pub fn toggle(self: *Timer, allocator: Allocator) ?tea.Cmd { if (self.running) { self.stop(); return null; } return self.start(allocator);}
pub fn update(self: *Timer, allocator: Allocator, msg: tea.Msg) ?tea.Cmd { switch (msg) { .custom => |c| { if (c.type_id != tickTypeId()) return null; if (!self.isRunning()) return null; self.timeout_ns -= @as(i64, @intCast(self.interval_ns)); if (self.timedOut()) { // Do not re-arm; notify the owner exactly once. return timeoutCmd(); } return self.tick(allocator); }, else => return null, }}
pub fn view(self: *const Timer, w: *Writer) Writer.Error!void { try util.formatDuration(w, self.timeout_ns);}
fn tick(self: *Timer, allocator: Allocator) ?tea.Cmd { return tea.Cmd.tick(allocator, self.interval_ns, .{ .custom = .{ .type_id = tickTypeId() }, }) catch { self.running = false; return null; };}
/// Runtime emit for the one-shot timeout notification (same shape as/// `tea.Cmd.pure` but the type id is a runtime address, not comptime-known).fn timeoutCmd() tea.Cmd { const Inner = struct { fn run(_: Allocator, _: std.Io, _: ?*anyopaque) ?tea.Msg { return .{ .custom = .{ .type_id = timeoutTypeId() } }; } }; return .{ .run_fn = Inner.run };}
test "timed out timer" { const t: Timer = .{ .timeout_ns = 0 }; try std.testing.expect(t.timedOut()); try std.testing.expect(!t.isRunning());}
test "view renders timeout" { var buf: [32]u8 = undefined; var w: Writer = .fixed(&buf); const t: Timer = .{ .timeout_ns = 10 * std.time.ns_per_s }; try t.view(&w); try std.testing.expectEqualStrings("10s", w.buffered());}
test "stop pauses" { var t: Timer = .{ .timeout_ns = 5 * std.time.ns_per_s }; t.stop(); try std.testing.expect(!t.running); try std.testing.expect(!t.isRunning());}
test "update emits one-shot timeout command" { var t: Timer = .{ .timeout_ns = std.time.ns_per_ms, .interval_ns = std.time.ns_per_s }; t.running = true; var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const cmd = t.update(std.testing.allocator, .{ .custom = .{ .type_id = tickTypeId() } }); try std.testing.expect(t.timedOut()); try std.testing.expect(cmd != null); if (cmd) |c| { const msg = c.run(std.testing.allocator, threaded.io()); try std.testing.expect(msg != null); if (msg) |m| switch (m) { .custom => |cu| try std.testing.expectEqual(timeoutTypeId(), cu.type_id), else => return error.ExpectedTimeoutCustom, }; }}
test "view renders negative remainder" { var buf: [32]u8 = undefined; var w: Writer = .fixed(&buf); const t: Timer = .{ .timeout_ns = -@as(i64, std.time.ns_per_ms) }; try t.view(&w); try std.testing.expectEqualStrings("-1ms", w.buffered());}