//! 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()); }