Files
arr-tui/internal/ui/app.go
T
2026-10-20 23:37:17 +02:00

556 lines
16 KiB
Go

// Package ui contains the tview user interface.
package ui
import (
"context"
"fmt"
"os"
"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
// captureKeys makes the global single-key shortcuts (q, ?) stand back
// while this view is shown, because its fields take typed text.
captureKeys bool
}
// 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
topBar *tview.Flex // tab bar and connection indicators
narrow bool // terminal too narrow for full service names
state map[string]serviceState // keyed by service name
probes []probe
screen tcell.Screen // remembered at the first draw, for the bell
cfg *config.Config // the settings in use (file, environment and defaults combined)
cfgPath string // the config file, empty if unknown
cfgFile *config.Config // the file's contents as written, for saving
radarr *radarr.Client
sonarr *sonarr.Client
sab *sabnzbd.Client
downloads *downloadsView
settings *settingsView
panes []*detailPane // every detail pane, to restyle posters when settings change
reconnect []func() // run after the service addresses or keys changed
watchStop context.CancelFunc
notifyDone bool // announce finished downloads
bell bool
covers bool // poster thumbnails enabled
coverStyle coverStyle
gfx *kittyGraphics // real-image posters, nil when not used
cellAspect float64 // height of a terminal cell divided by its width
coverCache *coverCache
}
// probe checks that one service answers; it returns its version.
type probe struct {
name string
check func(ctx context.Context) (string, error)
}
// heartbeat is how often the connection indicators are refreshed in the
// background, so a service shows up as connected without visiting its view.
const heartbeat = 30 * time.Second
// 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-F7), 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{},
notifyDone: cfg.UI.ShouldNotifyDownloads(),
bell: cfg.UI.Bell,
covers: cfg.UI.ShowCovers(),
cellAspect: 2,
coverCache: newCoverCache(),
}
blocks, graphics := resolveCovers(cfg.UI.CoverStyle, os.Getenv)
a.coverStyle = blocks
if graphics {
a.gfx = newKittyGraphics(a.writeTerminal)
}
a.cfg = cfg
radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)
sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)
a.radarr, a.sonarr = radarrClient, sonarrClient
a.logs = newLogView(a)
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
a.probes = []probe{
{"Radarr", func(ctx context.Context) (string, error) {
st, err := radarrClient.SystemStatus(ctx)
return st.Version, err
}},
{"Sonarr", func(ctx context.Context) (string, error) {
st, err := sonarrClient.SystemStatus(ctx)
return st.Version, err
}},
{"SABnzbd", sabClient.Version},
}
settings := newSettingsView(a)
a.settings = settings
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient)
downloads := newDownloadsView(a, sabClient,
[]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.downloads = downloads
a.views = []view{
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, hints: libraryHints},
{key: '4', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate, hints: downloads.hints},
{key: '5', name: "Calendar", body: calendar.root, onShow: calendar.activate, hints: calendarHints},
{key: '6', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints, captureKeys: true},
{key: '7', 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)
}
a.topBar = tview.NewFlex().
AddItem(a.tabs, 0, 1, false).
AddItem(a.services, servicesWidth(false), 0, false)
layout := tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(a.topBar, 1, 0, false).
AddItem(a.pages, 0, 1, true).
AddItem(a.status, 1, 0, false)
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
a.screen = s
a.cellAspect = cellAspectOf(s)
return false
})
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
a.app.SetInputCapture(a.handleKey)
a.switchTo(0)
a.renderServices()
return a
}
// writeTerminal sends raw bytes (graphics commands) to the terminal through the
// screen's own connection. UI goroutine only, so it stays in step with drawing.
func (a *App) writeTerminal(b []byte) error {
if a.screen == nil {
return fmt.Errorf("no screen yet")
}
tty, ok := a.screen.Tty()
if !ok {
return fmt.Errorf("the screen has no terminal")
}
_, err := tty.Write(b)
return err
}
// cellAspectOf measures the height of a terminal cell divided by its width in
// pixels; 2 when the terminal does not report pixel sizes.
func cellAspectOf(s tcell.Screen) float64 {
if tty, ok := s.Tty(); ok {
if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 && ws.PixelWidth > 0 && ws.PixelHeight > 0 {
return (float64(ws.PixelHeight) / float64(ws.Height)) / (float64(ws.PixelWidth) / float64(ws.Width))
}
}
return 2
}
// coverMaxHeight is how tall the stored poster copies are: real images keep
// more detail than block characters need.
func (a *App) coverMaxHeight() int {
if a.gfx != nil {
return graphicsCoverHeight
}
return storedCoverHeight
}
// 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()
}
// servicesWidth is the width of the connection indicators.
func servicesWidth(narrow bool) int {
if narrow {
return 14
}
return 25
}
// setNarrow switches the indicators between full names and initials; called
// from the draw handler when the terminal width crosses the threshold.
func (a *App) setNarrow(narrow bool) {
if narrow == a.narrow {
return
}
a.narrow = narrow
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
a.renderServices()
a.renderTabs()
}
func (a *App) renderServices() {
var s string
for _, n := range serviceNames {
short := strings.TrimSuffix(n, "nzbd") // SABnzbd -> SAB, keeps the bar narrow
if a.narrow {
short = n[:1]
if n == "SABnzbd" {
short = "SAB"
}
}
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 {
stop := make(chan struct{})
defer close(stop)
defer func() {
if a.gfx != nil {
a.gfx.deleteAll()
}
}()
go a.watchServices(stop)
a.setNotifyDownloads(a.notifyDone)
defer a.setNotifyDownloads(false)
return a.app.Run()
}
// setNotifyDownloads starts or stops the background watch for finished downloads.
func (a *App) setNotifyDownloads(on bool) {
a.notifyDone = on
if a.watchStop != nil {
a.watchStop()
a.watchStop = nil
}
if on {
ctx, cancel := context.WithCancel(context.Background())
a.watchStop = cancel
go a.watchDownloads(ctx, a.sab)
}
}
// watchServices checks all services now and then every heartbeat until stop is closed.
func (a *App) watchServices(stop <-chan struct{}) {
tick := time.NewTicker(heartbeat)
defer tick.Stop()
for {
a.probeAll()
select {
case <-tick.C:
case <-stop:
return
}
}
}
// probeAll checks every service in the background and updates the indicators.
func (a *App) probeAll() {
for _, p := range a.probes {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
version, err := p.check(ctx)
a.post(func() { a.SetService(p.name, version, err) })
}()
}
}
func (a *App) renderStatus() {
var hints string
if n := len(a.overlays); n > 0 {
hints = a.overlays[n-1].hints + " · Ctrl+C quit"
} else {
v := a.views[a.active]
var parts []string
if v.hints != nil {
parts = append(parts, v.hints())
}
if v.captureKeys { // q and ? are text there
parts = append(parts, "F9 help · F1-F7 views")
} else {
parts = append(parts, "F9 help · F1-F7 · q quit")
}
hints = strings.Join(parts, " · ")
}
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 || a.views[a.active].captureKeys {
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
}
}
return ev
}
// viewShortcut returns the view index for F1-F7, 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 {
switch {
case a.narrow && i == a.active:
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
case a.narrow:
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
case i == a.active:
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
default:
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 props · / text · t monitor · s search · d remove"
}
func calendarHints() string {
return "[ ] week · t today · u unmonitored · Enter releases · s search · r reload"
}
func logHints() string { return "↑↓ scroll · G follow · c clear" }