Files
arr-tui/internal/ui/search.go
T
nilsandClaude Opus 5.5 9b8d59bd48 Trakt lists: right-align the numbers
The grey number of every entry ("1.2k watching", "on 18k lists") stood
right after its title, so they did not line up. The rows are now padded
to the list's width on every draw where it changed, putting the numbers
against the right edge; a title too long for that keeps the number right
after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:20:59 +02:00

481 lines
13 KiB
Go

package ui
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
"git.stinnesbeck.com/nils/arr-tui/internal/config"
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
"git.stinnesbeck.com/nils/arr-tui/internal/trakt"
)
const (
searchDebounce = 300 * time.Millisecond
minAutoSearch = 2 // characters before the debounced search kicks in
)
// searchView is the search / add screen for one backend (Radarr or Sonarr).
type searchView struct {
app *App
be backend
root *tview.Flex
input *tview.InputField
list *tview.List
details *detailPane
// UI-goroutine state.
results []result
notes []string // per result, a Trakt list's number ("1.2k watching"); nil for searches
placeholder string // shown when there are no results
notesWidth int // list width the rows were laid out for
timer *time.Timer
cancel context.CancelFunc
gen int
// Trakt lists, shown while the search box is empty (see discover.go).
lists []trakt.List
listIdx int
browsing bool // the results are a Trakt list
discover map[int]*discovered
loadingList map[int]bool
// Add-form options, loaded in the background.
mu sync.Mutex
profiles []arr.QualityProfile
folders []arr.RootFolder
optsErr error
loaded bool
}
func newSearchView(app *App, be backend) *searchView {
v := &searchView{
app: app,
be: be,
input: tview.NewInputField().SetLabel("Search: "),
list: tview.NewList().ShowSecondaryText(false),
details: newDetailPane(app),
lists: be.lists(),
discover: map[int]*discovered{},
loadingList: map[int]bool{},
}
v.list.SetBorder(true).SetTitle(" " + be.noun() + " ")
left := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(v.input, 1, 0, true).
AddItem(notesList{v.list, v}, 0, 1, false)
v.root = tview.NewFlex().
AddItem(left, 0, 1, true).
AddItem(v.details, 0, 1, false)
v.input.SetChangedFunc(v.onSearchChanged)
v.input.SetDoneFunc(func(key tcell.Key) {
switch key {
case tcell.KeyEnter:
v.search(strings.TrimSpace(v.input.GetText()))
v.app.app.SetFocus(v.list)
case tcell.KeyEscape, tcell.KeyTab, tcell.KeyBacktab:
v.app.app.SetFocus(v.list)
}
})
v.input.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyDown {
v.app.app.SetFocus(v.list)
return nil
}
// While the box is empty a Trakt list is shown, and [ ] switch it (no
// search starts with a bracket).
if ev.Key() == tcell.KeyRune && (ev.Rune() == '[' || ev.Rune() == ']') && v.browsing && v.input.GetText() == "" {
v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()])
return nil
}
return ev
})
v.list.SetChangedFunc(func(i int, _, _ string, _ rune) { v.showDetails(i) })
v.list.SetSelectedFunc(func(i int, _, _ string, _ rune) { v.add(i) })
v.list.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
if ev.Key() == tcell.KeyTab || ev.Key() == tcell.KeyBacktab {
v.app.app.SetFocus(v.input)
return nil
}
if ev.Key() != tcell.KeyRune {
return ev
}
switch ev.Rune() {
case '/':
v.app.app.SetFocus(v.input)
return nil
case '[', ']':
if v.browsing {
v.switchList(map[rune]int{'[': -1, ']': 1}[ev.Rune()])
return nil
}
case 'r':
if v.browsing {
v.reloadList()
return nil
}
case 'a':
v.add(v.list.GetCurrentItem())
return nil
case 'i':
v.interactive(v.list.GetCurrentItem())
return nil
}
return ev
})
app.reconnect = append(app.reconnect, v.reloadOptions)
go v.loadOptions()
return v
}
// defaults returns the add-form defaults of the service, as currently configured.
func (v *searchView) defaults() config.Arr {
if v.be.name() == "Radarr" {
return v.app.cfg.Radarr
}
return v.app.cfg.Sonarr
}
// reloadOptions fetches the profiles and folders again, after the service changed.
func (v *searchView) reloadOptions() {
v.mu.Lock()
v.loaded = false
v.mu.Unlock()
go v.loadOptions()
}
// loadOptions checks the connection and fetches the data the add form needs.
func (v *searchView) loadOptions() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
var (
profiles []arr.QualityProfile
folders []arr.RootFolder
)
c := v.be.arr()
status, err := c.SystemStatus(ctx)
if err == nil {
profiles, err = c.QualityProfiles(ctx)
}
if err == nil {
folders, err = c.RootFolders(ctx)
}
v.mu.Lock()
v.profiles, v.folders, v.optsErr, v.loaded = profiles, folders, err, true
v.mu.Unlock()
v.app.post(func() {
v.app.SetService(v.be.name(), status.Version, err)
if err != nil {
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
}
})
}
func (v *searchView) options() ([]arr.QualityProfile, []arr.RootFolder, error) {
v.mu.Lock()
defer v.mu.Unlock()
if !v.loaded {
return nil, nil, fmt.Errorf("still connecting to %s", v.be.name())
}
return v.profiles, v.folders, v.optsErr
}
// onSearchChanged debounces typing into a search.
func (v *searchView) onSearchChanged(text string) {
if v.timer != nil {
v.timer.Stop()
}
text = strings.TrimSpace(text)
gen := v.invalidate()
if len([]rune(text)) < minAutoSearch {
if text == "" {
v.showList()
}
return
}
ctx, cancel := context.WithCancel(context.Background())
v.cancel = cancel
v.timer = time.AfterFunc(searchDebounce, func() { v.run(ctx, gen, text) })
}
// invalidate cancels any in-flight search and returns the new generation.
func (v *searchView) invalidate() int {
if v.cancel != nil {
v.cancel()
v.cancel = nil
}
v.gen++
return v.gen
}
// search runs a search immediately (UI goroutine).
func (v *searchView) search(text string) {
if v.timer != nil {
v.timer.Stop()
}
gen := v.invalidate()
if text == "" {
v.showList()
return
}
ctx, cancel := context.WithCancel(context.Background())
v.cancel = cancel
go v.run(ctx, gen, text)
}
// run performs the request off the UI goroutine and applies fresh results.
func (v *searchView) run(ctx context.Context, gen int, term string) {
v.app.post(func() {
if gen == v.gen {
v.app.SetMessage("Searching…")
}
})
results, err := v.be.search(ctx, term)
if errors.Is(err, context.Canceled) {
return
}
v.app.post(func() {
if gen != v.gen {
return // superseded by a newer search
}
v.app.SetService(v.be.name(), "", err)
if err != nil {
v.app.SetMessage("[red]Search failed: " + tview.Escape(err.Error()))
return
}
v.app.SetMessage(fmt.Sprintf("%d results for %q", len(results), tview.Escape(term)))
v.setResults(results)
})
}
// setResults replaces the list contents with search results (UI goroutine).
func (v *searchView) setResults(results []result) {
v.results, v.notes, v.placeholder, v.browsing = results, nil, "", false
v.list.SetTitle(" " + v.be.noun() + " ")
v.render(0)
}
// activate shows a Trakt list when the view opens with an empty search.
func (v *searchView) activate() {
if !v.browsing && len(v.results) == 0 && strings.TrimSpace(v.input.GetText()) == "" {
v.showList()
}
}
// render rebuilds the list, selecting index sel.
func (v *searchView) render(sel int) {
v.list.Clear()
_, _, width, _ := v.list.GetInnerRect()
v.notesWidth = width
for i := range v.results {
v.list.AddItem(v.itemText(i, width), "", 0, nil)
}
if len(v.results) == 0 {
if v.placeholder != "" {
v.list.AddItem("[gray]"+v.placeholder+"[-]", "", 0, nil)
}
v.details.Clear()
return
}
sel = min(max(sel, 0), len(v.results)-1)
v.list.SetCurrentItem(sel)
v.showDetails(sel)
}
// itemText is the row of result i in a list width columns wide: the label,
// and a Trakt list's number (if any) in grey against the right edge, so the
// numbers of all rows line up. A label too long for that gets the number
// right after it.
func (v *searchView) itemText(i, width int) string {
label := v.results[i].label()
if i >= len(v.notes) || v.notes[i] == "" {
return label
}
note := tview.Escape(v.notes[i])
gap := width - tview.TaggedStringWidth(label) - tview.TaggedStringWidth(note)
if gap < 2 {
gap = 2
}
return label + strings.Repeat(" ", gap) + "[gray]" + note + "[-]"
}
// notesList draws the results list, first laying the rows out again when the
// width changed (the numbers of a Trakt list sit against the right edge).
type notesList struct {
*tview.List
v *searchView
}
func (n notesList) Draw(screen tcell.Screen) {
v := n.v
if _, _, width, _ := v.list.GetInnerRect(); width != v.notesWidth && len(v.notes) > 0 {
v.notesWidth = width
for i := range v.results {
v.list.SetItemText(i, v.itemText(i, width), "")
}
}
n.List.Draw(screen)
}
func (v *searchView) showDetails(i int) {
if i < 0 || i >= len(v.results) {
v.details.Clear()
return
}
v.details.Show(v.results[i].details(), v.results[i].cover())
}
// interactive opens the release list for the result at index i.
func (v *searchView) interactive(i int) {
if i < 0 || i >= len(v.results) {
return
}
r := v.results[i]
if !r.inLibrary() {
v.app.SetMessage(fmt.Sprintf("[yellow]Add %s to %s first (a), then search for releases", tview.Escape(r.title()), v.be.name()))
return
}
v.be.interactive(v, r)
}
// add opens the add dialog for the result at index i.
func (v *searchView) add(i int) {
if i < 0 || i >= len(v.results) {
return
}
r := v.results[i]
if r.inLibrary() {
v.app.SetMessage(fmt.Sprintf("[yellow]%s is already in %s", tview.Escape(r.title()), v.be.name()))
return
}
profiles, folders, err := v.options()
switch {
case err != nil:
v.app.SetMessage(fmt.Sprintf("[red]%s: %s", v.be.name(), tview.Escape(err.Error())))
return
case len(profiles) == 0 || len(folders) == 0:
v.app.SetMessage(fmt.Sprintf("[red]%s has no quality profiles or root folders configured", v.be.name()))
return
}
profileNames := make([]string, len(profiles))
for i, p := range profiles {
profileNames[i] = p.Name
}
folderPaths := make([]string, len(folders))
for i, f := range folders {
folderPaths[i] = f.Path
}
modes := v.be.monitorModes()
modeNames := make([]string, len(modes))
for i, m := range modes {
modeNames[i] = m.label
}
const (
lProfile = "Quality profile"
lFolder = "Root folder"
lMonitor = "Monitored"
lEpisode = "Monitor"
lSearch = "Search now"
)
form := tview.NewForm().
AddDropDown(lProfile, profileNames, profileIndex(profiles, v.defaults().DefaultQualityProfile), nil).
AddDropDown(lFolder, folderPaths, folderIndex(folders, v.defaults().DefaultRootFolder), nil)
height := 13
if len(modes) > 0 {
form.AddDropDown(lEpisode, modeNames, 0, nil)
height += 2
}
form.AddCheckbox(lMonitor, true, nil).AddCheckbox(lSearch, true, nil)
// Radarr and Sonarr skip unmonitored items in an automatic search, so
// "Search now" only makes sense when something will be monitored.
monitored := form.GetFormItemByLabel(lMonitor).(*tview.Checkbox)
searchNow := form.GetFormItemByLabel(lSearch).(*tview.Checkbox)
searchable := func() bool {
if !monitored.IsChecked() {
return false
}
return len(modes) == 0 || modes[selected(form, lEpisode)].value != sonarr.MonitorNone
}
syncSearch := bindSearchNow(searchNow, searchable, func() {
v.app.Notify(levelWarn, "Search now needs something to be monitored")
})
monitored.SetChangedFunc(func(bool) { syncSearch() })
if len(modes) > 0 {
form.GetFormItemByLabel(lEpisode).(*tview.DropDown).SetSelectedFunc(func(string, int) { syncSearch() })
}
form.AddButton("Add", func() {
choice := addChoice{
profileID: profiles[selected(form, lProfile)].ID,
folder: folders[selected(form, lFolder)].Path,
monitored: monitored.IsChecked(),
searchNow: searchNow.IsChecked() && searchable(),
}
if len(modes) > 0 {
choice.monitor = modes[selected(form, lEpisode)].value
}
v.app.closeModal()
v.doAdd(r, choice)
})
form.AddButton("Cancel", v.app.closeModal)
arrowNavigation(form, v.app.closeModal, nil)
form.SetBorder(true).SetTitle(" Add " + tview.Escape(r.title()) + " ")
v.app.openModal(form, 60, height)
}
func (v *searchView) doAdd(r result, choice addChoice) {
v.app.SetMessage("Adding " + tview.Escape(r.title()) + "…")
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
id, err := v.be.add(ctx, r, choice)
v.app.post(func() {
if err != nil {
v.app.SetMessage("[red]Add failed: " + tview.Escape(err.Error()))
return
}
msg := "[green]Added " + tview.Escape(r.title())
if choice.searchNow {
msg += " and started a search"
}
v.app.SetMessage(msg)
v.markAdded(r.key(), id)
})
}()
}
// markAdded flags the matching result as in the library, keeping the selection.
func (v *searchView) markAdded(key string, id int) {
for i, r := range v.results {
if r.key() == key {
v.results[i] = r.withID(id)
}
}
v.render(v.list.GetCurrentItem())
}
// selected returns the chosen index of the dropdown labelled label.
func selected(form *tview.Form, label string) int {
i, _ := form.GetFormItemByLabel(label).(*tview.DropDown).GetCurrentOption()
return i
}