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
+328
View File
@@ -0,0 +1,328 @@
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
Warning Excluding main source file src/main.d from test.
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/ui.d(98,43): Error: function `core.thread.osthread.Thread.join(bool rethrow = true)` is not callable using argument types `(Duration)`
() @trusted { _thread.join(dur!("msecs")(2000)); }();
^
src/tofu/ui.d(98,43): cannot pass argument `dur(2000L)` of type `Duration` to parameter `bool rethrow = true`
Error /usr/bin/dmd failed with exit code 1.
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
Warning Excluding main source file src/main.d from test.
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [tofu-test-application]
src/tofu/commands/info.d(325,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("neovim", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(325,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(335,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("nonexistent", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(335,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(356,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("neovim", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(356,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(371,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("neovim", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(371,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(386,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("ripgrep", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(386,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(397,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("firefox", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(397,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(412,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("firefox", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(412,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/info.d(425,34): Error: function `infoCommandWithIndex` is not callable using argument types `(string, ParsedArgs, Config, PackageIndex[] function(Config _) @safe)`
int ec = infoCommandWithIndex("ripgrep", flags, cfg, &fakeIndex);
^
src/tofu/commands/info.d(425,34): cannot pass argument `& fakeIndex` of type `PackageIndex[] function(Config _) @safe` to parameter `scope PackageIndex[] delegate(Config) @safe fetchFn`
src/tofu/commands/info.d(179,19): `tofu.commands.info.infoCommandWithIndex(string pkgName, ParsedArgs flags, Config cfg, scope PackageIndex[] delegate(Config) @safe fetchFn)` declared here
package @safe int infoCommandWithIndex(
^
src/tofu/commands/install.d(112,27): Error: taking the address of local variable `entry` is not allowed in a `@safe` function
foundEntry = &entry;
^
src/tofu/commands/install.d(231,34): Error: `@safe` function `tofu.commands.install.installCommand` cannot call `@system` function `std.stdio.readln!string.readln`
response = readln().strip();
^
/usr/include/dlang/dmd/std/stdio.d(5242,20): which calls `makeGlobal`
@property ref File makeGlobal(StdFileHandle _iob)()
^
/usr/include/dlang/dmd/std/stdio.d(5244,25): and using `__gshared` instead of `shared` makes it fail to infer `@safe`
__gshared File.Impl impl;
^
/usr/include/dlang/dmd/std/stdio.d(4499,3): `std.stdio.readln!string.readln` is declared here
S readln(S = string)(dchar terminator = '\n')
^
src/tofu/commands/install.d(363,10): Error: `write` matches conflicting symbols:
write(path, content);
^
/usr/include/dlang/dmd/std/file.d(741,6): function `std.file.write!string.write`
void write(R)(R name, const void[] buffer)
^
/usr/include/dlang/dmd/std/stdio.d(4091,6): function `std.stdio.write!(string, string).write`
void write(T...)(T args)
^
src/tofu/commands/install.d(371,12): Error: module `std.string` import `octal` not found
import std.string : toStringz, octal;
^
src/tofu/commands/install.d(390,10): Error: `write` matches conflicting symbols:
write(path, script);
^
/usr/include/dlang/dmd/std/file.d(741,6): function `std.file.write!string.write`
void write(R)(R name, const void[] buffer)
^
/usr/include/dlang/dmd/std/stdio.d(4091,6): function `std.stdio.write!(string, string).write`
void write(T...)(T args)
^
src/tofu/commands/install.d(401,12): Error: module `std.string` import `octal` not found
import std.string : toStringz, octal;
^
src/tofu/commands/install.d(405,10): Error: `write` matches conflicting symbols:
write(path, script);
^
/usr/include/dlang/dmd/std/file.d(741,6): function `std.file.write!string.write`
void write(R)(R name, const void[] buffer)
^
/usr/include/dlang/dmd/std/stdio.d(4091,6): function `std.stdio.write!(string, string).write`
void write(T...)(T args)
^
src/tofu/commands/remove.d(49,9): Error: `@safe` function `tofu.commands.remove.removeCommand` cannot call `@system` function `std.stdio.makeGlobal!"core.stdc.stdio.stdout".makeGlobal`
stdout.flush();
^
/usr/include/dlang/dmd/std/stdio.d(5244,25): and using `__gshared` instead of `shared` makes it fail to infer `@safe`
__gshared File.Impl impl;
^
/usr/include/dlang/dmd/std/stdio.d(5242,20): `std.stdio.makeGlobal!"core.stdc.stdio.stdout".makeGlobal` is declared here
@property ref File makeGlobal(StdFileHandle _iob)()
^
Error /usr/bin/dmd failed with exit code 1.
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Generating test runner configuration 'tofu-test-application' for 'application' (executable).
Warning Excluding main source file src/main.d from test.
Starting Performing "unittest" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [tofu-test-application]
Linking tofu-test-application
Finished To force a rebuild of up-to-date targets, run again with --force
Running tofu-test-application
zeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building hello...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasedonezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depB...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building target...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phasezeta-makepkg: building depA...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for depA: build failed for depA:
zeta-makepkg: building depA...
zeta-makepkg: configure phase
zeta-makepkg: build phase
zeta-makepkg: install phase
zeta-makepkg: building pkg...zeta-makepkg: configure phasezeta-makepkg: build phasezeta-makepkg: install phaseerror build failed for noexist: recipe not found at /tmp/tofu-test-build-buildall-norecipe-232842/nonexistent.recipe
warn corrupted cache for pkg: Found 'h' when expecting 'r'. (Line 1:2)
Warning: malformed TOML config at /tmp/tofu-test-config-bad-232842.toml: Invalid table key declaration (2:0)
Warning: invalid TOFU_DEFAULT_JOBS 'not-a-number', using default 1
warn removing stale lock (PID 99999999 not alive)
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/package.lua HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:40] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:40] "GET /recipes/hello/scripts/build.sh HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:41] "GET /recipes/hello/hello.recipe HTTP/1.1" 200 -
127.0.0.1 - - [08/Aug/2026 18:26:41] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:41] "GET /recipes/hello/package.lua HTTP/1.1" 404 -
127.0.0.1 - - [08/Aug/2026 18:26:41] code 404, message File not found
127.0.0.1 - - [08/Aug/2026 18:26:41] "GET /recipes/hello/build.sh HTTP/1.1" 404 -
warn skipping index entry with empty name
warn skipping index entry with empty name
warn skipping index entry 'badpool': invalid pool 'bad_pool_value'
 . checking binary firefox ...
- binary firefox-2.1.0 satisfies firefox>=2.0
 . checking binary libfoo ...
- binary libfoo-1.9 does not satisfy libfoo>=2.0
 . checking binary noexist ...
 . checking binary brokenpkg ...
 . checking binary badpkg ...
 . checking binary testpkg ...
- binary testpkg-2.1.0 satisfies testpkg
==> ──── building depA (1/3) ────
 ok depA
==> ──── building depB (2/3) ────
 ok depB
==> ──── building target (3/3) ────
 ok target
==> ──── building depA (1/3) ────
- nothing to build
==> ──── building pkg (1/1) ────
- pkg already built, skipping
==> ──── building pkg (1/1) ────
 ok pkg
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
 . downloading build.sh
 ok recipe hello downloaded
==> fetching recipe hello
 . downloading hello.recipe
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
- no package.lua for hello (optional manifest)
 . downloading build.sh
- no build.sh for hello (optional build script)
 . custom build system: fetching scripts/build.sh
 ok recipe hello downloaded
==> fetching recipe hello
 . downloading hello.recipe
 . downloading package.lua
- no package.lua for hello (optional manifest)
 . downloading build.sh
- no build.sh for hello (optional build script)
 ok recipe hello downloaded
==> fetching recipe hello
 . downloading hello.recipe
 . fetching index http://127.0.0.1:38591//index.lua
 ok index loaded: 3 packages
 . fetching index http://127.0.0.1:52363//index.lua
 . fetching index http://127.0.0.1:42821//index.lua
 . fetching index http://127.0.0.1:43871//index.lua
 . fetching index http://127.0.0.1:44243//index.lua
 ok index loaded: 0 packages
 . fetching index http://127.0.0.1:41801//index.lua
 ok index loaded: 1 packages
 . fetching index http://127.0.0.1:48459//index.lua
 ok index loaded: 2 packages
 . fetching index http://127.0.0.1:57985//index.lua
 . fetching index http://127.0.0.1:55199//index.lua
 ok index loaded: 1 packages
installing package...error: build failedreason: missing dependencytestpkg-2.1 is already installed -- use -ReProvide to reinstall - already installed — skipping
 ok installed A with 2 dependencies
- nothing to install
install failed: conflict detectedtestpkg-2.1 is already installed -- use -ReProvide to reinstallwarn corrupted installed state at /tmp/tofu-test-state-corrupt-232842/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
warn corrupted installed state at /tmp/tofu-test-state-corrupt-232842/installed.json: Found 'h' when expecting 'r'. (Line 1:2)
19 modules passed unittests
- already installed — skipping
 ok installed A with 0 dependencies
- binary libfoo-2.1 satisfies libfoo>=2.0
- libfoo: binary 1.9 too old, building from recipe
- libfoo: binary 1.9 too old, building from recipe
- binary libbar-3.0 satisfies libbar (unconstrained)
- libbaz: no binary available, building from recipe
- binary libfoo-1.5 satisfies libfoo>=1.0
- libfoo: binary 1.5 too old, building from recipe
- binary parentA-1.5 satisfies parentA (unconstrained)
- binary parentB-1.5 satisfies parentB (unconstrained)
==> generating build plan
- + C (recipe)
- + A (recipe)
 ok build plan: 2 packages
==> generating build plan
- recipe libfoo not in cache, re-fetching
- + libfoo (recipe)
 ok build plan: 1 packages
==> generating build plan
==> generating build plan
- nothing to build (all binary)
==> generating build plan
- + mypkg (recipe)
 ok build plan: 1 packages
==> generating build plan
- + libfoo (recipe)
- + mypkg (recipe)
 ok build plan: 2 packages
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [application]
src/tofu/commands/remove.d(11,8): Error: module `tofu.install` import `runRemove` not found
import tofu.install : runRemove, InstallException;
^
Error /usr/bin/dmd failed with exit code 1.
Warning
Warning ## Warning for package tofu ##
Warning
Warning The following compiler flags have been specified in the package description
Warning file. They are handled by DUB and direct use in packages is discouraged.
Warning Alternatively, you can set the DFLAGS environment variable to pass custom flags
Warning to the compiler, or use one of the suggestions below:
Warning
Warning warningsAsErrors: Use "buildRequirements" to control the warning level
Warning
Starting Performing "debug" build using /usr/bin/dmd for x86_64.
Up-to-date toml 1.0.0: target for configuration [library] is up to date.
Building tofu ~main: building configuration [application]
src/main.d(25,8): Error: unable to read module `info`
import tofu.commands.info : infoCommand;
^
src/main.d(25,8): Expected 'tofu/commands/info.d' or 'tofu/commands/info/package.d' in one of the following import paths:
import path[0] = src/
import path[1] = ../../.dub/packages/toml/1.0.0/toml/src/
import path[2] = /usr/include/dlang/dmd
Error /usr/bin/dmd failed with exit code 1.
+54
View File
@@ -1029,3 +1029,57 @@ The sandbox test (test 2) creates a sentinel file, serves an index containing `o
- `dub test` passes — all 18 modules, including search.d's 7 unittests.
- `dub run -- -Ss neovim` fails on lock file creation (`~/.cache/tofu` directory doesn't exist) — pre-existing issue, expected without initialized environment.
- Evidence logged to `.omo/evidence/task-20-tofu-core.log`.
---
## Task 25 — `tofu.ui` (spinner, progress output, summary table)
### Architecture
- Module `tofu.ui` — file `src/tofu/ui.d`, standalone module with no external dependencies beyond `tofu.log` and `core.thread`.
- Three public APIs: `Spinner` class, `buildSeparator`, `summaryTable`.
- `startSpinner(string label)` factory returns `Spinner` handle — creates and starts animation.
### Spinner — thread-based animation
- **TTY detection**: `isatty(1)` from `core.sys.posix.unistd` — when stdout is NOT a terminal, spinner prints `"<label>..."` once and becomes a no-op on `stop()`.
- **Animation**: background `core.thread.Thread` loops every 100 ms, writing `\r <frame> <label>` with frames `/`, `-`, `\`, `-` (same as ZETA `spinner.lua`).
- **Stop**: sets `shared bool _running = false`, calls `Thread.join()` (blocks until thread exits — thread checks flag every 100 ms so this is prompt), then clears the animation line (`\r` + spaces + `\r`), then prints ` ok <label>` via `logOk`.
- **`@safe` / `@trusted` split**: `start()` and `stop()` are `@safe` public; `threadFn()` is `@trusted` (calls stdout.writef/flush). Thread constructor + start/join are wrapped in `@trusted` blocks (Thread APIs are `@system` in DMD 2.112).
### Thread.join() — no Duration overload
- D's `core.thread.Thread.join()` takes `bool rethrow = true`, NOT a `Duration`. The plan spec says "join with timeout" but this API doesn't exist in Phobos.
- No timeout needed in practice: the thread exits within one 100 ms cycle after `_running` is cleared — join returns promptly.
- The Lua reference uses `kill $pid` (signal-based), not join — the thread model is fundamentally different.
### buildSeparator
- Prints `──── building <name> (<i>/<n>) ────` (U+2500 box-drawing chars, 4 per side).
- Duplicates build.d's inline separator (line 247: `logStep("──── building %s (%d/%d) ────", ...)`) — documented duplication, do NOT modify build.d.
- Uses plain `stdout.writeln` (no color/logStep) so test can assert exact output.
### summaryTable
- Struct `InstallSummary { string name; string ver; string status; }`.
- Fixed-width columns: `PACKAGE` (20 chars), `VERSION` (16 chars), `STATUS` (variable). Format: `%-20s %-16s %s`.
- Empty array → no output.
### stdout capture in tests
- `captureStdout()` — swaps global `stdout` to temp `File(name, "w+")`, runs dg, flushes, closes file, reads back with `std.file.readText`.
- Must call `file.close()` before reading — `readText` opens the file independently and can't read while another handle exists.
- Pattern adapted from `log.d`'s `capture()` but reads full multi-line content (log.d's version only calls `readln` for single-line output).
### Test cases (4/4 pass)
1. Spinner with stdout redirected → no `\r` in captured output, label present ✓
2. buildSeparator exact: UTF-8 `\xe2\x94\x80` (U+2500) chars with "building foo (1/3)" ✓
3. summaryTable: 3 items → header PACKAGE/VERSION/STATUS + all 3 rows with correct statuses ✓
4. Spinner plain-path: non-TTY mode prints label, stop() is no-op (no " ok " in output) ✓
### `canFind` usage in tests
- Free function form: `canFind(haystack, needle)` — avoids D's UFCS auto-decoding issue on `string` (where `output.canFind("PACKAGE")` tries to find a `string` element in a `dchar` range).
### Pre-existing breakage
- `src/tofu/commands/{install,remove,info}.d` from parallel tasks 20-24 have compile errors (function/delegate mismatches, import issues, `@safe` violations).
- These block `dub build` and `dub test` — excluded via `.skip` rename for verification. Not caused by task 25.
- `ui.d` compiles standalone: `dmd -c -o- -unittest -Isrc src/tofu/ui.d` passes clean.
### Build verified
- `dub test` (with broken siblings excluded) — 19 modules pass unittests, including ui.d's 4 test blocks.
- `dub build` (with broken siblings excluded) — blocked by `main.d` importing `tofu.commands.info` (skipped). ui.d itself compiles clean.
- Evidence logged to `.omo/evidence/task-25-tofu-core.log`.
+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 ~ "`");
}