Files
arr-tui/internal/ui/app.go
T

615 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 shortcut (q) stands 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
lastWindow [3]int // columns, rows and cell shape at the last draw
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 = a.newGraphics()
}
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 focused text field shows the cursor again while it is drawn. Hiding it
// first covers focus moves that bypass tview (arrow keys in forms), which
// would leave the cursor behind in the field that was left.
s.HideCursor()
a.screen = s
a.cellAspect = cellAspectOf(s)
a.noteWindow(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
}
// newGraphics creates the real-image drawer. With ARR_TUI_DEBUG_GFX set it also
// writes what it does (window size, cell shape, every upload) to the Log, which
// helps to find out why a poster is drawn wrongly in some terminal.
func (a *App) newGraphics() *kittyGraphics {
k := newKittyGraphics(a.writeTerminal)
if os.Getenv("ARR_TUI_DEBUG_GFX") != "" {
k.debug = func(msg string) { a.logs.add(levelInfo, msg) }
}
return k
}
// noteWindow notices when the terminal's size or cell size changed. The
// real-image posters are then uploaded and placed again from scratch instead of
// relying on the terminal to rescale what is there.
func (a *App) noteWindow(s tcell.Screen) {
w, h := s.Size()
win := [3]int{w, h, int(a.cellAspect * 1000)}
if win == a.lastWindow {
return
}
first := a.lastWindow == [3]int{}
a.lastWindow = win
if !first && a.gfx != nil {
a.gfx.deleteAll()
a.gfx.hold() // blocks while the size is changing, real images when it has settled
time.AfterFunc(settleDelay+30*time.Millisecond, func() { a.post(func() {}) })
}
if a.gfx != nil && a.gfx.debug != nil {
a.gfx.debug(fmt.Sprintf("window: %d×%d cells, cell shape %.3f", w, h, a.cellAspect))
}
}
// 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 {
return cellAspectFromWindow(ws.Width, ws.Height, ws.PixelWidth, ws.PixelHeight)
}
}
return 2
}
// cellAspectFromWindow derives the cell shape from the window size in cells
// and pixels. The pixel size usually includes some padding around the cell
// grid, which would skew a plain division (more with a small grid, and it
// changes while the window is resized); cell sizes are whole pixels, so the
// division is rounded down, which drops that padding.
func cellAspectFromWindow(cols, rows, pixelW, pixelH int) float64 {
if cols <= 0 || rows <= 0 || pixelW <= 0 || pixelH <= 0 {
return 2
}
cw, ch := pixelW/cols, pixelH/rows
if cw <= 0 || ch <= 0 {
return 2
}
return float64(ch) / float64(cw)
}
// 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 is 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) {
a.openModalHints(p, width, height, "↑↓ move · ←→ change · Enter select · Esc cancel")
}
// openModalHints is openModal with its own status bar hints.
func (a *App) openModalHints(p tview.Primitive, width, height int, hints string) {
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, hints, 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" }