termancy Guide Components Commands API

stopwatch

Time counting up, started, stopped, and reset

termancy tui add stopwatch

No recording here. zig build site-gifs records one from docs/tapes/stopwatch.tape when there is one; termancy ui demo stopwatch runs it, if it has a demo.

A stopwatch: time counting up while it runs, started, stopped, and reset by the model that embeds it (run alone, it starts at once; space stops and starts it, r resets it).

var watch: Stopwatch = .{};
// in start: return watch.start();
// in update: return watch.update(msg);
try watch.view(w); // 1m02s

Time is counted from its own ticks, so a test drives it with a virtual clock.

stopwatch.zig

//! A stopwatch: time counting up while it runs, started, stopped, and reset by the model
//! that embeds it (run alone, it starts at once; space stops and starts it, r resets it).
//!
//! ```zig
//! var watch: Stopwatch = .{};
//! // in start: return watch.start();
//! // in update: return watch.update(msg);
//! try watch.view(w); // 1m02s
//! ```
//!
//! Time is counted from its own ticks, so a test drives it with a virtual clock.

const std = @import("std");
const tui = @import("tui");
const Writer = std.Io.Writer;
const Duration = std.Io.Duration;

const Stopwatch = @This();

/// The time counted so far.
elapsed: Duration = .zero,
running: bool = false,
/// How often it ticks, and so how fine its count is.
interval: Duration = .fromSeconds(1),
tick_id: u32 = 0x5357_4154, // "SWAT"
/// Run alone: space starts and stops, r resets, q quits.
keys: bool = true,
style: tui.Style = .{},

/// Starts counting; returns the first tick.
pub fn start(s: *Stopwatch) tui.Cmd {
    if (s.running) return .none;
    s.running = true;
    return .tick(s.interval, s.tick_id);
}

/// Stops counting; the count stays.
pub fn stop(s: *Stopwatch) void {
    s.running = false;
}

/// Starts or stops.
pub fn toggle(s: *Stopwatch) tui.Cmd {
    if (s.running) {
        s.stop();
        return .none;
    }
    return s.start();
}

/// Back to zero, running or not.
pub fn reset(s: *Stopwatch) void {
    s.elapsed = .zero;
}

pub fn update(s: *Stopwatch, msg: tui.Msg) tui.Cmd {
    switch (msg) {
        .tick => |id| if (id == s.tick_id and s.running) {
            s.elapsed = .fromNanoseconds(s.elapsed.nanoseconds + s.interval.nanoseconds);
            return .tick(s.interval, s.tick_id);
        },
        .key => |key| if (s.keys) {
            if (key.matches("space")) return s.toggle();
            if (key.matches("r")) s.reset();
            if (key.matches("q") or key.matches("esc")) return .quit;
        },
        else => {},
    }
    return .none;
}

pub fn view(s: *const Stopwatch, w: *Writer) Writer.Error!void {
    var buffer: [32]u8 = undefined;
    var text: Writer = .fixed(&buffer);
    writeDuration(&text, s.elapsed) catch {};
    try s.style.paint(w, text.buffered());
}

/// Writes `duration` as people read it: `1h02m03s`, `2m05s`, `7s`, `0.25s`.
pub const writeDuration = tui.term.live.writeElapsed;

const testing = std.testing;

test "it counts while running, from its own ticks" {
    var d: tui.testing.Driver(Stopwatch) = try .init(testing.allocator, .{}, .{});
    defer d.deinit();
    try d.expectFrame("0s");
    try d.advance(.fromSeconds(65));
    try d.expectFrame("1m05s");
    try d.press("space");
    try d.advance(.fromSeconds(10));
    try d.expectFrame("1m05s");
    try d.press("r");
    try d.expectFrame("0s");
}

test writeDuration {
    var buffer: [32]u8 = undefined;
    var w: Writer = .fixed(&buffer);
    for ([_]i64{ 3_723_000, 125_000, 7_000, 250 }, 0..) |ms, i| {
        if (i > 0) try w.writeByte(' ');
        try writeDuration(&w, .fromMilliseconds(ms));
    }
    try testing.expectEqualStrings("1h02m03s 2m05s 7s 0.25s", w.buffered());
}