feat(ui): add spinner, progress output, and summary table
This commit is contained in:
+275
@@ -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 ~ "`");
|
||||
}
|
||||
Reference in New Issue
Block a user