359 lines
10 KiB
Go
359 lines
10 KiB
Go
// Package ui contains the tview user interface.
|
|
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
|
)
|
|
|
|
type view struct {
|
|
key rune
|
|
name string
|
|
body tview.Primitive
|
|
// Optional hooks, called when the view becomes visible / hidden.
|
|
onShow, onHide func()
|
|
// hints lists the keys that work in this view (shown in the status bar).
|
|
hints func() string
|
|
}
|
|
|
|
// App is the top-level arr-tui application.
|
|
type App struct {
|
|
app *tview.Application
|
|
tabs *tview.TextView
|
|
pages *tview.Pages
|
|
status *tview.TextView
|
|
views []view
|
|
active int
|
|
|
|
toasts []toast
|
|
logs *logView
|
|
overlays []overlay // dialogs and full-screen views stacked above the pages
|
|
|
|
services *tview.TextView
|
|
state map[string]serviceState // keyed by service name
|
|
}
|
|
|
|
// serviceState is the last known connection state of a backend service.
|
|
type serviceState struct {
|
|
known bool
|
|
version string
|
|
err error
|
|
}
|
|
|
|
// serviceNames fixes the order of the connection indicators.
|
|
var serviceNames = []string{"Radarr", "Sonarr", "SABnzbd"}
|
|
|
|
// overlay is a page stacked above the main views; focus is restored on close.
|
|
// hints replaces the view's key hints in the status bar while it is open.
|
|
type overlay struct {
|
|
page string
|
|
focus tview.Primitive
|
|
hints string
|
|
full bool // full-screen view (can be left with F1-F5), as opposed to a dialog
|
|
close func() // optional cleanup when the overlay is removed
|
|
}
|
|
|
|
// New builds the application shell: a tab bar, one page per view and a status bar.
|
|
func New(cfg *config.Config) *App {
|
|
a := &App{
|
|
app: tview.NewApplication(),
|
|
tabs: tview.NewTextView().SetDynamicColors(true),
|
|
pages: tview.NewPages(),
|
|
status: tview.NewTextView().SetDynamicColors(true),
|
|
|
|
services: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignRight),
|
|
state: map[string]serviceState{},
|
|
}
|
|
|
|
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
|
|
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
|
|
a.logs = newLogView(a)
|
|
library := newLibraryView(a, radarrClient, sonarrClient)
|
|
downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey),
|
|
[]arrSource{
|
|
{name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem},
|
|
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
|
|
},
|
|
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
|
|
a.views = []view{
|
|
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
|
|
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
|
|
{key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
|
|
{key: '4', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
|
|
{key: '5', name: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
|
|
}
|
|
for i, v := range a.views {
|
|
a.pages.AddPage(v.name, v.body, true, i == 0)
|
|
}
|
|
|
|
layout := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(tview.NewFlex().
|
|
AddItem(a.tabs, 0, 1, false).
|
|
AddItem(a.services, 25, 0, false), 1, 0, false).
|
|
AddItem(a.pages, 0, 1, true).
|
|
AddItem(a.status, 1, 0, false)
|
|
|
|
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
|
|
a.app.SetInputCapture(a.handleKey)
|
|
a.switchTo(0)
|
|
a.renderServices()
|
|
return a
|
|
}
|
|
|
|
// SetService records the outcome of a request to a service and updates the
|
|
// connection indicators. UI goroutine only. A nil err means reachable.
|
|
func (a *App) SetService(name, version string, err error) {
|
|
prev := a.state[name]
|
|
if err == nil && version == "" {
|
|
version = prev.version
|
|
}
|
|
a.state[name] = serviceState{known: true, version: version, err: err}
|
|
// Log connection changes only, so background polling does not flood the log.
|
|
switch {
|
|
case err != nil && (!prev.known || prev.err == nil):
|
|
a.logs.add(levelError, name+" unreachable: "+err.Error())
|
|
case err == nil && (!prev.known || prev.err != nil):
|
|
a.logs.add(levelSuccess, strings.TrimSpace(name+" "+version)+" connected")
|
|
}
|
|
a.renderServices()
|
|
}
|
|
|
|
func (a *App) renderServices() {
|
|
var s string
|
|
for _, n := range serviceNames {
|
|
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
|
|
st := a.state[n]
|
|
switch {
|
|
case !st.known:
|
|
s += "[gray]○ " + short + "[-] "
|
|
case st.err != nil:
|
|
s += "[red]● " + short + "[-] "
|
|
default:
|
|
s += "[green]●[-] " + short + " "
|
|
}
|
|
}
|
|
a.services.SetText(s)
|
|
}
|
|
|
|
// serviceSummary describes every service for the help screen.
|
|
func (a *App) serviceSummary() string {
|
|
var b strings.Builder
|
|
for _, n := range serviceNames {
|
|
st := a.state[n]
|
|
switch {
|
|
case !st.known:
|
|
fmt.Fprintf(&b, "[gray]○ %s: not contacted yet[-]\n", n)
|
|
case st.err != nil:
|
|
fmt.Fprintf(&b, "[red]● %s: %s[-]\n", n, tview.Escape(st.err.Error()))
|
|
case st.version != "":
|
|
fmt.Fprintf(&b, "[green]●[-] %s %s: connected\n", n, tview.Escape(st.version))
|
|
default:
|
|
fmt.Fprintf(&b, "[green]●[-] %s: connected\n", n)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// post runs f on the UI goroutine and redraws. Safe to call from any goroutine.
|
|
func (a *App) post(f func()) {
|
|
a.app.QueueUpdateDraw(f)
|
|
}
|
|
|
|
// Run starts the event loop and blocks until the application quits.
|
|
func (a *App) Run() error {
|
|
return a.app.Run()
|
|
}
|
|
|
|
func (a *App) renderStatus() {
|
|
var hints string
|
|
if n := len(a.overlays); n > 0 {
|
|
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
|
|
} else {
|
|
if h := a.views[a.active].hints; h != nil {
|
|
hints = h() + " · "
|
|
}
|
|
hints += "? help · F1-F5 · q quit"
|
|
}
|
|
a.status.SetText("[gray]" + hints)
|
|
}
|
|
|
|
// pushOverlay stacks p above everything and focuses focus.
|
|
func (a *App) pushOverlay(p, focus tview.Primitive, hints string, full bool, onClose func()) {
|
|
name := fmt.Sprintf("overlay-%d", len(a.overlays))
|
|
a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus(), hints: hints, full: full, close: onClose})
|
|
a.pages.AddPage(name, p, true, true)
|
|
a.app.SetFocus(focus)
|
|
a.renderStatus()
|
|
}
|
|
|
|
// openModal shows p centered above the current view.
|
|
func (a *App) openModal(p tview.Primitive, width, height int) {
|
|
box := tview.NewFlex().
|
|
AddItem(nil, 0, 1, false).
|
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(nil, 0, 1, false).
|
|
AddItem(p, height, 0, true).
|
|
AddItem(nil, 0, 1, false), width, 0, true).
|
|
AddItem(nil, 0, 1, false)
|
|
a.pushOverlay(box, p, "↑↓ move · ←→ change · Enter select · Esc cancel", false, nil)
|
|
}
|
|
|
|
// openSidePanel shows p at the top right so most of the view behind it stays
|
|
// visible, for dialogs whose changes are applied live.
|
|
func (a *App) openSidePanel(p tview.Primitive, width, height int, hints string, onClose func()) {
|
|
box := tview.NewFlex().
|
|
AddItem(nil, 0, 1, false).
|
|
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(p, height, 0, true).
|
|
AddItem(nil, 0, 1, false), width, 0, true)
|
|
a.pushOverlay(box, p, hints, false, onClose)
|
|
}
|
|
|
|
// openFull shows p above the whole view area (the tab bar and status bar stay visible).
|
|
func (a *App) openFull(p tview.Primitive, hints string, onClose func()) {
|
|
a.pushOverlay(p, p, hints, true, onClose)
|
|
}
|
|
|
|
// closeModal removes the topmost overlay and restores the previous focus.
|
|
func (a *App) closeModal() {
|
|
if len(a.overlays) == 0 {
|
|
return
|
|
}
|
|
top := a.overlays[len(a.overlays)-1]
|
|
a.overlays = a.overlays[:len(a.overlays)-1]
|
|
a.pages.RemovePage(top.page)
|
|
if top.close != nil {
|
|
top.close()
|
|
}
|
|
if top.focus != nil {
|
|
a.app.SetFocus(top.focus)
|
|
}
|
|
a.renderStatus()
|
|
}
|
|
|
|
// confirm asks a yes/no question; yes runs only if the user picks Delete/Yes.
|
|
func (a *App) confirm(text string, yes func()) {
|
|
lines := strings.Count(text, "\n") + 1 + len([]rune(text))/44
|
|
m := tview.NewModal().
|
|
SetText(text).
|
|
AddButtons([]string{"Cancel", "Confirm"}). // Cancel first so a stray Enter is safe
|
|
SetDoneFunc(func(i int, _ string) {
|
|
a.closeModal()
|
|
if i == 1 {
|
|
yes()
|
|
}
|
|
})
|
|
a.openModal(m, 60, 7+min(lines, 10))
|
|
}
|
|
|
|
// onlyFullOverlays is true when no dialog is open (full-screen views may be).
|
|
func (a *App) onlyFullOverlays() bool {
|
|
for _, o := range a.overlays {
|
|
if !o.full {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// typing reports whether keystrokes belong to a text field or dialog and must
|
|
// not trigger global shortcuts.
|
|
func (a *App) typing() bool {
|
|
if len(a.overlays) > 0 {
|
|
return true
|
|
}
|
|
_, isInput := a.app.GetFocus().(*tview.InputField)
|
|
return isInput
|
|
}
|
|
|
|
func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey {
|
|
if ev.Key() == tcell.KeyCtrlC {
|
|
a.app.Stop()
|
|
return nil
|
|
}
|
|
// Shortcuts that also work while typing in a search box. Dialogs block them;
|
|
// full-screen views (release list, episodes) are closed by a view switch.
|
|
if a.onlyFullOverlays() {
|
|
if ev.Key() == tcell.KeyF9 && len(a.overlays) == 0 {
|
|
a.showHelp()
|
|
return nil
|
|
}
|
|
if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) {
|
|
for len(a.overlays) > 0 {
|
|
a.closeModal()
|
|
}
|
|
a.switchTo(i)
|
|
return nil
|
|
}
|
|
}
|
|
if a.typing() {
|
|
return ev
|
|
}
|
|
switch ev.Key() {
|
|
case tcell.KeyRune:
|
|
if ev.Rune() == 'q' {
|
|
a.app.Stop()
|
|
return nil
|
|
}
|
|
for i, v := range a.views {
|
|
if ev.Rune() == v.key {
|
|
a.switchTo(i)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
return ev
|
|
}
|
|
|
|
// viewShortcut returns the view index for F1-F4, or -1.
|
|
func (a *App) viewShortcut(ev *tcell.EventKey) int {
|
|
if ev.Key() >= tcell.KeyF1 && ev.Key() <= tcell.KeyF12 {
|
|
return int(ev.Key() - tcell.KeyF1)
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (a *App) switchTo(i int) {
|
|
if hide := a.views[a.active].onHide; hide != nil && i != a.active {
|
|
hide()
|
|
}
|
|
if show := a.views[i].onShow; show != nil && i != a.active {
|
|
show()
|
|
}
|
|
a.active = i
|
|
a.pages.SwitchToPage(a.views[i].name)
|
|
a.renderTabs()
|
|
a.renderStatus()
|
|
}
|
|
|
|
func (a *App) renderTabs() {
|
|
var s string
|
|
for i, v := range a.views {
|
|
if i == a.active {
|
|
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
|
|
} else {
|
|
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
|
}
|
|
}
|
|
a.tabs.SetText(s)
|
|
}
|
|
|
|
func searchHints() string {
|
|
return "/ search · Tab list/search · Enter add · i releases"
|
|
}
|
|
|
|
func libraryHints() string {
|
|
return "Tab list · f status · F file props · / text · t monitor · s search"
|
|
}
|
|
|
|
func logHints() string { return "↑↓ scroll · G follow · c clear" }
|