feat(ui): add spinner, progress output, and summary table

This commit is contained in:
2026-08-08 18:27:52 -04:00
parent de77fb1733
commit 066700b540
3 changed files with 657 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
/// Progress output and terminal UI helpers for tofu — spinner, build
/// separators, and summary table.
///
/// Spinner:
/// Uses a background thread to animate `/ - \ |` with `\r` carriage return
/// on stdout. Only renders when stdout is a terminal; when redirected,
/// prints a single plain-text `<label>...` line once. `stop()` clears the
/// animation line and emits ` ok <label>` via `logOk`.
///
/// buildSeparator:
/// Prints `──── building <name> (<i>/<n>) ────` (U+2500 box-drawing).
/// This duplicates build.d's inline separator for use by other callers;
/// do **not** modify build.d.
///
/// summaryTable:
/// Fixed-width tabular summary of install results — columns PACKAGE
/// (20 chars), VERSION (16 chars), STATUS (variable).
module tofu.ui;
import core.sys.posix.unistd : isatty;
import core.thread : Thread;
import core.time : dur;
import std.format : format;
import std.stdio : stdout;
import tofu.log : logOk;
// ─── Spinner ────────────────────────────────────────────────────────────────
/// Animated progress spinner. Only animates when stdout is a terminal;
/// otherwise prints a single plain-text line.
class Spinner
{
private:
Thread _thread;
shared bool _running;
string _label;
bool _isTTY;
/// Check whether stdout (fd 1) is a terminal.
static private bool isStdoutTTY() @trusted
{
return isatty(1) != 0;
}
/// Background thread function: animate `/ - \ -` every 100 ms.
private void threadFn() @trusted
{
immutable string[4] frames = ["/", "-", "\\", "-"];
size_t i = 0;
while (_running)
{
stdout.writef("\r %s %s", frames[i % 4], _label);
stdout.flush();
i++;
Thread.sleep(dur!("msecs")(100));
}
}
public:
/// Create a spinner for `label` (does **not** start animation).
this(string label) @safe
{
_label = label;
_isTTY = isStdoutTTY();
}
/// Start the spinner. On a TTY launches the background animation
/// thread; on non-TTY output prints a single `<label>...` line.
void start() @safe
{
if (!_isTTY)
{
() @trusted { stdout.writeln(_label ~ "..."); }();
return;
}
_running = true;
() @trusted {
_thread = new Thread(&threadFn);
_thread.start();
}();
}
/// Stop the spinner. On a TTY signals the background thread, joins
/// it (the thread checks `_running` every 100 ms so join returns
/// promptly), clears the animation line, and prints
/// ` ok <label>` via `logOk`. Non-TTY is a no-op.
void stop() @safe
{
if (!_isTTY)
return;
// Signal the worker thread to exit.
_running = false;
if (_thread !is null)
{
try
{
() @trusted { _thread.join(); }();
}
catch (Exception)
{
// Thread join failure (e.g. double-join) — harmless.
}
_thread = null;
}
// Clear the animation line: \r + enough spaces + \r.
() @trusted {
stdout.write("\r");
// Overwrite the widest possible spinner line (" \ " + label).
foreach (_; 0 .. _label.length + 10)
stdout.write(" ");
stdout.write("\r");
stdout.flush();
}();
logOk("%s", _label);
}
}
/// Convenience factory: create a `Spinner` and immediately call `start()`.
/// Returns the handle for later `stop()`.
Spinner startSpinner(string label) @safe
{
auto s = new Spinner(label);
s.start();
return s;
}
// ─── buildSeparator ─────────────────────────────────────────────────────────
/// Print a box-drawing separator for build progress.
///
/// Output: `──── building <name> (<index>/<total>) ────`
/// (U+2500 characters). This duplicates build.d's inline separator;
/// kept here as a standalone helper for future callers.
void buildSeparator(string name, int index, int total) @safe
{
auto line = format("──── building %s (%d/%d) ────", name, index, total);
() @trusted { stdout.writeln(line); }();
}
// ─── summaryTable ───────────────────────────────────────────────────────────
/// Package install summary entry.
struct InstallSummary
{
string name;
string ver;
string status;
}
/// Print a fixed-width summary table to stdout.
///
/// Columns: `PACKAGE` (20 chars, left-aligned), `VERSION` (16 chars,
/// left-aligned), `STATUS` (variable width). Empty array → no output.
void summaryTable(InstallSummary[] items) @safe
{
if (items.length == 0)
return;
() @trusted {
import std.stdio : writefln;
writefln("%-20s %-16s %s", "PACKAGE", "VERSION", "STATUS");
foreach (item; items)
writefln("%-20s %-16s %s", item.name, item.ver, item.status);
}();
}
// ─── Unittests ──────────────────────────────────────────────────────────────
version (unittest)
{
import std.algorithm.searching : canFind;
import std.file : exists, readText, remove, tempDir;
import std.path : buildPath;
import std.stdio : File;
import std.string : indexOf;
/// Swap stdout to a temp file, run `dg`, restore, return full file
/// contents. The file is cleaned up on scope exit.
private string captureStdout(void delegate() dg) @trusted
{
auto fname = buildPath(tempDir, "tofu-ui-capture.tmp");
scope (exit)
{
if (exists(fname))
remove(fname);
}
auto file = File(fname, "w+");
auto saved = stdout;
stdout = file;
scope (exit) stdout = saved;
scope (failure) stdout = saved;
dg();
stdout.flush();
file.close(); // release handle before read
return readText(fname);
}
}
// (1) Spinner with stdout redirected → no control characters leak.
@safe unittest
{
auto output = captureStdout({
auto s = startSpinner("downloading foo");
s.stop();
});
// Redirected output must contain NO carriage-return characters.
assert(output.indexOf("\r") < 0,
"redirected output must contain no \\r, got: `" ~ output ~ "`");
// The plain-text label was printed.
assert(canFind(output, "downloading foo"),
"expected 'downloading foo' in output, got: `" ~ output ~ "`");
}
// (2) buildSeparator output exact.
@safe unittest
{
auto output = captureStdout({
buildSeparator("foo", 1, 3);
});
assert(canFind(output, "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80 "
~ "building foo (1/3) "
~ "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80"),
"expected separator line, got: `" ~ output ~ "`");
}
// (3) summaryTable: 3 items → header + aligned rows.
@safe unittest
{
auto output = captureStdout({
summaryTable([
InstallSummary("libfoo", "1.2.3", "installed"),
InstallSummary("libbar", "0.9.0", "skipped"),
InstallSummary("libbaz", "2.0.0", "failed"),
]);
});
// Header present.
assert(canFind(output, "PACKAGE"), "expected PACKAGE header");
assert(canFind(output, "VERSION"), "expected VERSION header");
assert(canFind(output, "STATUS"), "expected STATUS header");
// All three rows present.
assert(canFind(output, "libfoo"), "expected libfoo row");
assert(canFind(output, "libbar"), "expected libbar row");
assert(canFind(output, "libbaz"), "expected libbaz row");
assert(canFind(output, "installed"), "expected installed status");
assert(canFind(output, "skipped"), "expected skipped status");
assert(canFind(output, "failed"), "expected failed status");
}
// (4) Spinner start/stop plain-path (non-TTY) — no hang, no ok line.
@safe unittest
{
auto output = captureStdout({
auto s = startSpinner("test operation");
s.stop();
});
// Non-TTY mode prints the plain label.
assert(canFind(output, "test operation"),
"expected label in plain output, got: `" ~ output ~ "`");
// Non-TTY stop() returns early — logOk is NOT called.
assert(output.indexOf(" ok ") < 0,
"non-TTY stop must not print 'ok' line, got: `" ~ output ~ "`");
}