Skip to content

Commit a30639a

Browse files
committed
new tui
Signed-off-by: abzcoding <abzcoding@gmail.com>
1 parent 8c4a288 commit a30639a

6 files changed

Lines changed: 1107 additions & 162 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,16 @@
1010
## Features
1111

1212
- **Parallel downloading** — splits files into chunks and downloads them simultaneously over multiple connections
13-
- **Live TUI** — animated progress bars per-part plus an overall bar, ETA, and per-connection speed readouts, driven by [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Bubbles](https://github.com/charmbracelet/bubbles), and [Harmonica](https://github.com/charmbracelet/harmonica) spring physics
13+
- **Live "carrier" TUI** — refined phosphor-cyan / amber telemetry palette, per-part progress with status glyphs (`⬢ ◉ ◯`), animated overall bar, ETA, per-connection speed readouts, plus a rolling **speed-history sparkline** showing recent throughput trend and peak. Driven by [Bubble Tea](https://github.com/charmbracelet/bubbletea), [Bubbles](https://github.com/charmbracelet/bubbles), [Lip Gloss](https://github.com/charmbracelet/lipgloss), and [Harmonica](https://github.com/charmbracelet/harmonica) spring physics
14+
- **Real cancellation**`q` / `Ctrl-C` actually stops the current download (state saved when resumable); `s` in batch mode skips the current item; `Ctrl-C` (or `q` in batch mode) aborts the entire queue. A live "stopping…/skipping…" overlay shows progress while state is drained safely
1415
- **Beautiful console logging** — colored, structured output via [charmbracelet/log](https://github.com/charmbracelet/log) when running non-interactively
1516
- **GPG signature verification** — pass `--verify` to automatically fetch the `.sig` file and verify it with GPG; result is shown inline in the TUI completion screen
1617
- **Smart re-download prompt** — if the target file already exists, a styled [Huh](https://github.com/charmbracelet/huh) confirmation form asks before overwriting
17-
- **Interrupt & resume**hit Ctrl-C at any point; state is saved to `~/.hget/<task>/state.json` and can be resumed later
18+
- **Interrupt & resume**`q` / `Ctrl-C` at any point persists byte offsets to `~/.hget/<task>/state.json`; resume later with `--resume`
1819
- **State reconstruction** — if the state file is missing, reconstructs progress from existing part files
1920
- **Bandwidth limiting** — cap aggregate download speed with `--rate` (e.g. `5MiB`, `500kB`)
2021
- **Proxy support** — HTTP and SOCKS5 proxies via `--proxy`
21-
- **Batch downloads** — supply a file of URLs (one per line) with `--file`
22+
- **Batch downloads** — supply a file of URLs (one per line) with `--file`; per-item skip + whole-batch abort, distinct `done / skipped / failed / aborted` accounting
2223
- **Server probing** — inspect `Accept-Ranges` and `Content-Length` without downloading via `--probe`
2324
- **TLS skip** — bypass certificate verification with `--skip-tls`
2425

internal/batch/batch.go

Lines changed: 71 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -51,34 +51,34 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
5151
return
5252
}
5353

54-
// ── Palette & styles (mirrors the TUI palette) ────────────────────────────
55-
cPurple := lipgloss.Color("#C77DFF")
56-
cCyan := lipgloss.Color("#00B4D8")
57-
cGreen := lipgloss.Color("#06D6A0")
58-
cYellow := lipgloss.Color("#FFB703")
59-
cRed := lipgloss.Color("#EF233C")
60-
cMuted := lipgloss.Color("#6C757D")
61-
cBorder := lipgloss.Color("#495057")
62-
63-
styleSep := lipgloss.NewStyle().Foreground(cBorder)
64-
styleCounter := lipgloss.NewStyle().Foreground(cPurple).Bold(true)
65-
styleFile := lipgloss.NewStyle().Foreground(cCyan)
66-
styleURL := lipgloss.NewStyle().Foreground(cMuted)
67-
styleDone := lipgloss.NewStyle().Foreground(cGreen).Bold(true)
68-
styleFail := lipgloss.NewStyle().Foreground(cRed).Bold(true)
69-
styleSkip := lipgloss.NewStyle().Foreground(cYellow)
70-
styleAbort := lipgloss.NewStyle().Foreground(cYellow).Bold(true)
71-
stylePending := lipgloss.NewStyle().Foreground(cMuted)
72-
styleActive := lipgloss.NewStyle().Foreground(cCyan).Bold(true)
73-
styleMuted := lipgloss.NewStyle().Foreground(cMuted)
54+
// ── Palette & styles ("carrier" palette — mirrors the TUI) ───────────────
55+
cPhosphor := lipgloss.Color("#73E0FF") // dominant cyan
56+
cAmber := lipgloss.Color("#FFB75A") // sharp accent
57+
cMint := lipgloss.Color("#5EE6A1") // success
58+
cMagenta := lipgloss.Color("#FF5478") // error
59+
cSteel := lipgloss.Color("#5A6B85") // chrome / labels
60+
cSlate := lipgloss.Color("#3A475C") // dim chrome
61+
cFrost := lipgloss.Color("#E8F1F8") // highlight
62+
63+
styleSep := lipgloss.NewStyle().Foreground(cSlate)
64+
styleCounter := lipgloss.NewStyle().Foreground(cAmber).Bold(true)
65+
styleFile := lipgloss.NewStyle().Foreground(cFrost).Bold(true)
66+
styleURL := lipgloss.NewStyle().Foreground(cSteel)
67+
styleDone := lipgloss.NewStyle().Foreground(cMint).Bold(true)
68+
styleFail := lipgloss.NewStyle().Foreground(cMagenta).Bold(true)
69+
styleSkip := lipgloss.NewStyle().Foreground(cAmber)
70+
styleAbort := lipgloss.NewStyle().Foreground(cAmber).Bold(true)
71+
stylePending := lipgloss.NewStyle().Foreground(cSteel)
72+
styleActive := lipgloss.NewStyle().Foreground(cPhosphor).Bold(true)
73+
styleMuted := lipgloss.NewStyle().Foreground(cSteel)
7474
styleBox := lipgloss.NewStyle().
75-
Border(lipgloss.RoundedBorder()).
76-
BorderForeground(cPurple).
77-
Padding(0, 2).
78-
Foreground(cCyan)
75+
Border(lipgloss.NormalBorder(), false, false, false, true).
76+
BorderForeground(cAmber).
77+
Padding(0, 0, 0, 2).
78+
Foreground(cFrost)
7979

8080
const sepW = 68
81-
sep := styleSep.Render(strings.Repeat("", sepW))
81+
sep := styleSep.Render(strings.Repeat("", sepW))
8282

8383
// ── Item status tracking ──────────────────────────────────────────────────
8484
type itemStatus int
@@ -152,46 +152,72 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
152152
fmt.Println(styleBox.Render(hdr))
153153
fmt.Println()
154154

155-
for i, it := range items {
156-
var icon, nameStr, statusStr string
157-
fname := it.file
158-
if len(fname) > 40 {
159-
fname = fname[:39] + "…"
155+
// Fixed cell widths so every row aligns regardless of glyph width.
156+
// Some Unicode glyphs (◯ U+25EF "LARGE CIRCLE", ◉ U+25C9 "FISHEYE")
157+
// are East-Asian Ambiguous and render as 2 cells in most terminals;
158+
// others (⬢ ⤳ ⊘ ◈) render as 1. Width-aware lipgloss styles let
159+
// every column line up on every row.
160+
iconCol := lipgloss.NewStyle().Width(3) // 1-cell glyph + 1 pad → 3
161+
nameCol := lipgloss.NewStyle().Width(40)
162+
163+
// Rune-safe truncation that respects Unicode display width.
164+
truncName := func(s string) string {
165+
if lipgloss.Width(s) <= 39 {
166+
return s
167+
}
168+
out := make([]rune, 0, 40)
169+
width := 0
170+
for _, r := range s {
171+
rw := lipgloss.Width(string(r))
172+
if width+rw > 38 {
173+
break
174+
}
175+
out = append(out, r)
176+
width += rw
160177
}
178+
return string(out) + "…"
179+
}
180+
181+
for i, it := range items {
182+
var glyph, nameStr, statusStr string
183+
fname := truncName(it.file)
161184
switch it.status {
162185
case statusDone:
163-
icon = styleDone.Render("")
186+
glyph = styleDone.Render("")
164187
nameStr = styleDone.Render(fname)
165188
statusStr = styleDone.Render("done")
166189
case statusFailed:
167-
icon = styleFail.Render("")
190+
glyph = styleFail.Render("")
168191
nameStr = styleFail.Render(fname)
169192
statusStr = styleFail.Render("failed")
170193
if it.reason != "" {
171194
statusStr += " " + styleMuted.Render("("+truncateSummary(it.reason, 35)+")")
172195
}
173196
case statusSkipped:
174-
icon = styleSkip.Render("")
197+
glyph = styleSkip.Render("")
175198
nameStr = styleSkip.Render(fname)
176199
statusStr = styleSkip.Render("skipped")
177200
if it.reason != "" {
178201
statusStr += " " + styleMuted.Render("("+truncateSummary(it.reason, 35)+")")
179202
}
180203
case statusAborted:
181-
icon = styleAbort.Render(" ⊘")
204+
glyph = styleAbort.Render("⊘")
182205
nameStr = styleAbort.Render(fname)
183206
statusStr = styleAbort.Render("aborted")
184207
case statusActive:
185-
icon = styleActive.Render("")
208+
glyph = styleActive.Render("")
186209
nameStr = styleActive.Render(fname)
187-
statusStr = styleActive.Render(fmt.Sprintf("downloading [%d/%d]", i+1, len(items)))
210+
statusStr = styleActive.Render(fmt.Sprintf("downloading [%02d/%02d]", i+1, len(items)))
188211
default:
189-
icon = stylePending.Render("")
212+
glyph = stylePending.Render("")
190213
nameStr = stylePending.Render(fname)
191-
statusStr = stylePending.Render("pending")
214+
statusStr = stylePending.Render("queued")
192215
}
193-
padded := nameStr + strings.Repeat(" ", max(0, 42-len(fname)))
194-
fmt.Printf("%s %s %s\n", icon, padded, statusStr)
216+
fmt.Printf(" %s %s %s\n",
217+
iconCol.Render(glyph),
218+
nameCol.Render(nameStr),
219+
statusStr,
220+
)
195221
}
196222
fmt.Println()
197223
fmt.Println(sep)
@@ -217,10 +243,10 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
217243
var itemReason string
218244

219245
fmt.Printf("\n %s %s\n",
220-
styleCounter.Render(fmt.Sprintf("[%d/%d]", i+1, len(items))),
246+
styleCounter.Render(fmt.Sprintf("◉ %02d / %02d", i+1, len(items))),
221247
styleFile.Render(it.file),
222248
)
223-
fmt.Println(styleURL.Render(" " + it.url))
249+
fmt.Println(styleURL.Render(" ╰─ " + it.url))
224250
fmt.Println()
225251

226252
// File-exists check (isatty gate is inside ui.ConfirmRedownload).
@@ -345,11 +371,11 @@ func RunBatchDownloads(ctx context.Context, filePath string, conn int, skiptls b
345371
}
346372
switch {
347373
case aborted > 0 && failed == 0:
348-
fmt.Println(styleAbort.Render(fmt.Sprintf(" ⊘ Aborted — %d/%d completed.", done, len(items))))
374+
fmt.Println(styleAbort.Render(fmt.Sprintf(" ⊘ aborted — %d/%d completed", done, len(items))))
349375
case failed == 0:
350-
fmt.Println(styleDone.Render(fmt.Sprintf(" ✓ All %d downloads complete.", len(items))))
376+
fmt.Println(styleDone.Render(fmt.Sprintf(" ⬢ all %d transfers complete", len(items))))
351377
default:
352-
fmt.Println(styleFail.Render(fmt.Sprintf(" %d/%d failed%s.", failed, len(items),
378+
fmt.Println(styleFail.Render(fmt.Sprintf(" %d/%d failed%s", failed, len(items),
353379
func() string {
354380
if aborted > 0 {
355381
return fmt.Sprintf(", %d aborted", aborted)

0 commit comments

Comments
 (0)