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
+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`.