Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
364 lines
9.5 KiB
Go
364 lines
9.5 KiB
Go
package ui
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
|
)
|
|
|
|
// Sort modes of the release list; sortLabels[i] names mode i.
|
|
const (
|
|
sortBest = iota
|
|
sortSize
|
|
sortAge
|
|
numSortModes
|
|
)
|
|
|
|
var sortLabels = [numSortModes]string{"best", "size", "age"}
|
|
|
|
// releasesView is the full-screen interactive search result list.
|
|
type releasesView struct {
|
|
app *App
|
|
title string
|
|
grab func(ctx context.Context, r arr.Release) error
|
|
|
|
root *tview.Flex
|
|
header *tview.TextView
|
|
table *tview.Table
|
|
detail *tview.TextView
|
|
cancel context.CancelFunc // aborts the running indexer search
|
|
|
|
all []arr.Release // usenet releases only
|
|
shown []arr.Release
|
|
torrents int // torrent releases left out
|
|
searched bool // the indexer search has finished
|
|
sortMode int
|
|
hideRejected bool
|
|
attr attrFilter
|
|
}
|
|
|
|
// openReleases starts an interactive search and shows the results as they arrive.
|
|
func (a *App) openReleases(title string, fetch func(context.Context) ([]arr.Release, error), grab func(context.Context, arr.Release) error) {
|
|
v := &releasesView{
|
|
app: a,
|
|
title: title,
|
|
grab: grab,
|
|
header: tview.NewTextView().SetDynamicColors(true),
|
|
table: tview.NewTable().SetSelectable(true, false).SetFixed(1, 0),
|
|
detail: tview.NewTextView().SetDynamicColors(true).SetWordWrap(true),
|
|
}
|
|
v.table.SetBorder(true)
|
|
v.root = tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(v.header, 1, 0, false).
|
|
AddItem(v.table, 0, 1, true).
|
|
AddItem(v.detail, 3, 0, false)
|
|
|
|
v.table.SetSelectionChangedFunc(func(int, int) { v.renderDetail() })
|
|
v.table.SetSelectedFunc(func(int, int) { v.grabSelected() })
|
|
v.table.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
|
|
switch {
|
|
case ev.Key() == tcell.KeyEscape:
|
|
v.close()
|
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 'g':
|
|
v.grabSelected()
|
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 's':
|
|
v.sortMode = (v.sortMode + 1) % numSortModes
|
|
v.render()
|
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 'F':
|
|
v.app.openAttrFilter(v.attr, v.filterPool, func(f attrFilter) {
|
|
v.attr = f
|
|
v.render()
|
|
}, nil)
|
|
case ev.Key() == tcell.KeyRune && ev.Rune() == 'f':
|
|
v.hideRejected = !v.hideRejected
|
|
v.render()
|
|
default:
|
|
return ev
|
|
}
|
|
return nil
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
v.cancel = cancel
|
|
v.render() // shows the "searching" placeholder
|
|
a.openFull(v.root, "Enter/g grab · s sort · f hide rejected · F filter · Esc back", v.cancel)
|
|
|
|
go func() {
|
|
releases, err := fetch(ctx)
|
|
if ctx.Err() == context.Canceled {
|
|
return
|
|
}
|
|
a.post(func() {
|
|
if err != nil {
|
|
v.header.SetText("[red]Search failed: " + tview.Escape(err.Error()))
|
|
v.table.Clear()
|
|
return
|
|
}
|
|
v.setReleases(releases)
|
|
})
|
|
}()
|
|
}
|
|
|
|
func (v *releasesView) close() {
|
|
v.app.closeModal() // cancels the running search via the overlay's cleanup
|
|
}
|
|
|
|
func (v *releasesView) setReleases(releases []arr.Release) {
|
|
v.all, v.torrents = nil, 0
|
|
for _, r := range releases {
|
|
// Downloads go to SABnzbd, which can only handle Usenet.
|
|
if r.Protocol == "torrent" {
|
|
v.torrents++
|
|
continue
|
|
}
|
|
v.all = append(v.all, r)
|
|
}
|
|
v.searched = true
|
|
v.render()
|
|
}
|
|
|
|
// render rebuilds the table, keeping the selected release selected.
|
|
func (v *releasesView) render() {
|
|
selGUID := ""
|
|
if r, ok := v.selected(); ok {
|
|
selGUID = r.GUID
|
|
}
|
|
v.shown = visibleReleases(v.all, v.hideRejected, v.sortMode, v.attr)
|
|
|
|
v.table.Clear()
|
|
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
|
|
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
|
|
if c == "Title" {
|
|
cell.SetExpansion(1)
|
|
}
|
|
v.table.SetCell(0, i, cell)
|
|
}
|
|
selRow := 0
|
|
for i, r := range v.shown {
|
|
cells := []string{
|
|
releaseMark(r), tview.Escape(r.Title), tview.Escape(r.QualityName()), formatSize(r.Size),
|
|
formatReleaseAge(r.AgeMinutes), tview.Escape(r.Indexer), fmt.Sprint(r.CustomFormatScore),
|
|
}
|
|
for c, text := range cells {
|
|
cell := tview.NewTableCell(text)
|
|
if c == 1 {
|
|
cell.SetExpansion(1)
|
|
}
|
|
v.table.SetCell(i+1, c, cell)
|
|
}
|
|
if r.GUID == selGUID {
|
|
selRow = i + 1
|
|
}
|
|
}
|
|
switch {
|
|
case !v.searched:
|
|
v.table.SetCell(1, 1, tview.NewTableCell("[gray]Searching indexers… (Esc to cancel)").SetSelectable(false))
|
|
case len(v.shown) == 0:
|
|
msg := "No releases found. Are indexers configured?"
|
|
if len(v.all) > 0 {
|
|
msg = "All releases are hidden by the filters (f, F)"
|
|
}
|
|
v.table.SetCell(1, 1, tview.NewTableCell("[gray]"+msg).SetSelectable(false))
|
|
}
|
|
// With no selectable row tview's Down/Up handling never terminates.
|
|
v.table.SetSelectable(len(v.shown) > 0, false)
|
|
if len(v.shown) > 0 {
|
|
v.table.Select(max(selRow, 1), 0)
|
|
if selRow <= 1 {
|
|
// Otherwise the table can keep a stale scroll offset from the
|
|
// "searching" placeholder and show the middle of the list.
|
|
v.table.ScrollToBeginning()
|
|
}
|
|
} else {
|
|
v.table.Select(0, 0)
|
|
}
|
|
v.renderHeader()
|
|
v.renderDetail()
|
|
}
|
|
|
|
func (v *releasesView) renderHeader() {
|
|
s := "[::b]" + v.title + "[::-]" // callers escape the title
|
|
if v.searched {
|
|
s += fmt.Sprintf(" · %d of %d releases", len(v.shown), len(v.all))
|
|
}
|
|
s += " · sort: " + sortLabels[v.sortMode]
|
|
if v.hideRejected {
|
|
s += " · [yellow]rejected hidden[-]"
|
|
}
|
|
if v.attr.active() {
|
|
s += " · [yellow]" + v.attr.String() + "[-]"
|
|
}
|
|
if v.torrents > 0 {
|
|
s += fmt.Sprintf(" · [gray]%d torrent release%s hidden[-]", v.torrents, plural(v.torrents))
|
|
}
|
|
v.header.SetText(s)
|
|
}
|
|
|
|
func (v *releasesView) renderDetail() {
|
|
r, ok := v.selected()
|
|
if !ok {
|
|
v.detail.SetText("")
|
|
return
|
|
}
|
|
var lines []string
|
|
switch {
|
|
case len(r.Rejections) > 0:
|
|
lines = append(lines, "[red]Rejected:[-] "+tview.Escape(strings.Join(r.Rejections, "; ")))
|
|
case r.Approved:
|
|
lines = append(lines, "[green]Approved[-]")
|
|
}
|
|
var facts []string
|
|
if r.ReleaseGroup != "" {
|
|
facts = append(facts, "group "+tview.Escape(r.ReleaseGroup))
|
|
}
|
|
if l := r.LanguageNames(); l != "" {
|
|
facts = append(facts, tview.Escape(l))
|
|
}
|
|
if len(facts) > 0 {
|
|
lines = append(lines, "[gray]"+strings.Join(facts, " · ")+"[-]")
|
|
}
|
|
v.detail.SetText(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func (v *releasesView) selected() (arr.Release, bool) {
|
|
row, _ := v.table.GetSelection()
|
|
if row < 1 || row > len(v.shown) {
|
|
return arr.Release{}, false
|
|
}
|
|
return v.shown[row-1], true
|
|
}
|
|
|
|
// grabSelected sends the selected release to the download client; rejected
|
|
// releases need an explicit confirmation first.
|
|
func (v *releasesView) grabSelected() {
|
|
r, ok := v.selected()
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(r.Rejections) == 0 && !r.Rejected {
|
|
v.doGrab(r)
|
|
return
|
|
}
|
|
reasons := r.Rejections
|
|
extra := ""
|
|
if len(reasons) > 3 {
|
|
extra = fmt.Sprintf("\n+%d more", len(reasons)-3)
|
|
reasons = reasons[:3]
|
|
}
|
|
v.app.confirm("This release was rejected:\n"+tview.Escape(strings.Join(reasons, "\n"))+extra+"\n\nGrab it anyway?",
|
|
func() { v.doGrab(r) })
|
|
}
|
|
|
|
func (v *releasesView) doGrab(r arr.Release) {
|
|
v.app.SetMessage("Grabbing " + tview.Escape(truncate(r.Title, 60)) + "…")
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
err := v.grab(ctx, r)
|
|
v.app.post(func() {
|
|
if err != nil {
|
|
v.app.SetMessage("[red]Grab failed: " + tview.Escape(err.Error()))
|
|
return
|
|
}
|
|
v.app.SetMessage("[green]Sent to the download client: " + tview.Escape(truncate(r.Title, 60)))
|
|
})
|
|
}()
|
|
}
|
|
|
|
// visibleReleases applies the filter and sort order, returning a new slice.
|
|
func visibleReleases(all []arr.Release, hideRejected bool, mode int, af attrFilter) []arr.Release {
|
|
out := make([]arr.Release, 0, len(all))
|
|
for _, r := range all {
|
|
if hideRejected && isRejected(r) {
|
|
continue
|
|
}
|
|
if af.active() && !af.matchFile(r.Attrs()) {
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
slices.SortStableFunc(out, func(a, b arr.Release) int {
|
|
switch mode {
|
|
case sortSize:
|
|
return cmp.Compare(b.Size, a.Size)
|
|
case sortAge:
|
|
return cmp.Compare(a.AgeMinutes, b.AgeMinutes)
|
|
}
|
|
// best: approved first, then format score, quality, and newest
|
|
return cmp.Or(
|
|
cmp.Compare(boolInt(isRejected(a)), boolInt(isRejected(b))),
|
|
cmp.Compare(b.CustomFormatScore, a.CustomFormatScore),
|
|
cmp.Compare(b.QualityWeight, a.QualityWeight),
|
|
cmp.Compare(a.AgeMinutes, b.AgeMinutes),
|
|
)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func isRejected(r arr.Release) bool { return r.Rejected || len(r.Rejections) > 0 }
|
|
|
|
func boolInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// releaseMark is the status glyph in the first column.
|
|
func releaseMark(r arr.Release) string {
|
|
switch {
|
|
case !isRejected(r):
|
|
return "[green]✓[-]"
|
|
case r.TemporarilyRejected:
|
|
return "[yellow]~[-]"
|
|
}
|
|
return "[red]✗[-]"
|
|
}
|
|
|
|
// formatSize renders a byte count as "1.4 GB".
|
|
func formatSize(b int64) string {
|
|
const k = 1024.0
|
|
f := float64(b)
|
|
switch {
|
|
case f >= k*k*k:
|
|
return fmt.Sprintf("%.1f GB", f/(k*k*k))
|
|
case f >= k*k:
|
|
return fmt.Sprintf("%.0f MB", f/(k*k))
|
|
case f > 0:
|
|
return fmt.Sprintf("%.0f KB", f/k)
|
|
}
|
|
return "-"
|
|
}
|
|
|
|
// formatReleaseAge renders an age given in minutes as "45m", "5h" or "3d".
|
|
func formatReleaseAge(minutes float64) string {
|
|
switch {
|
|
case minutes < 60:
|
|
return fmt.Sprintf("%dm", int(minutes))
|
|
case minutes < 48*60:
|
|
return fmt.Sprintf("%dh", int(minutes/60))
|
|
}
|
|
return fmt.Sprintf("%dd", int(minutes/(24*60)))
|
|
}
|
|
|
|
// filterPool lists the properties of the releases that pass the rejected
|
|
// filter, for the filter dialog.
|
|
func (v *releasesView) filterPool() filterPool {
|
|
var p filterPool
|
|
for _, r := range v.all {
|
|
if v.hideRejected && isRejected(r) {
|
|
continue
|
|
}
|
|
p.files = append(p.files, []arr.FileAttrs{r.Attrs()})
|
|
}
|
|
return p
|
|
}
|