// Package ui contains the tview user interface. package ui import ( "context" "errors" "fmt" "os" "strings" "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/radarr" "git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd" "git.stinnesbeck.com/nils/arr-tui/internal/sonarr" "git.stinnesbeck.com/nils/arr-tui/internal/trakt" ) 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 { cellW, cellH int // size of a terminal cell in pixels, 0 if unknown app *tview.Application theme theme // the colors in use trakt *trakt.Client // trending, most watched and anticipated lists, casts people *peopleCache // casts loaded from Trakt disk *diskCache // posters, casts and lookups kept between runs lookups *lookupStore // Radarr/Sonarr lookups of Trakt list titles // Called (on the UI goroutine) a moment after a release was grabbed. afterGrab []func() // The Movies and Series views, to refresh their Trakt lists. searches []*searchView themed *themedScreen // draws with them; nil until Run 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 root tview.Primitive // what was pushed 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 { // Ask the terminal before the screen takes it over, and set the colors // before any widget copies them. th, themeNote := chooseTheme(cfg.UI.Theme, terminalAnswers()) useTokens() 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) coverNote := "Posters: " + blocks.name + " block characters" if graphics { coverNote = "Posters: real images (the terminal confirmed the Kitty graphics protocol)" } else if cfg.UI.CoverStyle == "kitty" || kittyTerminal(os.Getenv) { coverNote += " (the terminal did not confirm the Kitty graphics protocol)" } 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) if a.covers { a.logs.add(levelInfo, coverNote) } a.theme = th a.logs.add(levelInfo, themeNote) 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) a.afterGrab = append(a.afterGrab, library.loadQueue) 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.trakt = trakt.NewClient(cfg.Trakt.ClientID, "arr-tui") a.disk = newDiskCache() a.people = newPeopleCache() a.loadSavedPeople() if u := os.Getenv("ARR_TUI_TRAKT_URL"); u != "" { a.trakt.SetBaseURL(u) // another Trakt API, for development (dev/screenshots) } a.lookups = newLookupStore(a.disk) movies := newSearchView(a, radarrBackend{radarrClient, a.lookups}) series := newSearchView(a, sonarrBackend{sonarrClient, a.lookups}) a.searches = []*searchView{movies, series} a.views = []view{ {key: '1', name: "Movies", body: movies.root, onShow: movies.activate, hints: searchHints}, {key: '2', name: "Series", body: series.root, onShow: series.activate, hints: searchHints}, {key: '3', name: "Library", body: library.root, onShow: library.activate, onHide: library.deactivate, 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}, {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.cellW, a.cellH = cellPixelsOf(s) a.noteWindow(s) return false }) a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) } a.app.SetInputCapture(a.handleKey) a.switchTo(0) movies.activate() // switchTo does not call onShow for the view already active if !cfg.AnyConfigured() { // First start (or nothing set up): go where that is done. a.switchTo(a.viewIndex("Config")) a.Notify(levelInfo, "Welcome! Enter the addresses and API keys of your services, then Save") } 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) k.cellPixels = func() (int, int) { return a.cellW, a.cellH } switch v := os.Getenv("ARR_TUI_DEBUG_GFX"); { case v == "": case strings.Contains(v, "/"): // A path: also append to that file, so the lines can be read without // leaving the view that shows the problem. f, err := os.OpenFile(v, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { a.logs.add(levelWarn, "ARR_TUI_DEBUG_GFX: "+err.Error()) k.debug = func(msg string) { a.logs.add(levelInfo, msg) } break } k.debug = func(msg string) { a.logs.add(levelInfo, msg) fmt.Fprintf(f, "%s %s\n", time.Now().Format("15:04:05.000"), msg) } default: 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. // cellPixelsOf returns the size of a terminal cell in pixels, 0, 0 when the // terminal does not say. func cellPixelsOf(s tcell.Screen) (w, h int) { if tty, ok := s.Tty(); ok { if ws, err := tty.WindowSize(); err == nil && ws.Width > 0 && ws.Height > 0 { return ws.PixelWidth / ws.Width, ws.PixelHeight / ws.Height } } return 0, 0 } 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 notConfigured(err): if !prev.known || !notConfigured(prev.err) { a.logs.add(levelInfo, name+" is not configured; set it up in the Config view (F6)") } case err != nil && (!prev.known || prev.err == nil || notConfigured(prev.err)): 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, notConfigured(st.err): 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 notConfigured(st.err): fmt.Fprintf(&b, "[gray]○ %s: not configured (Config view, F6)[-]\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() } // viewIndex returns the position of the view with the given name. func (a *App) viewIndex(name string) int { for i, v := range a.views { if v.name == name { return i } } return 0 } // notConfigured reports whether err says a service is not set up. func notConfigured(err error) bool { return errors.Is(err, arr.ErrNotConfigured) || errors.Is(err, sabnzbd.ErrNotConfigured) } // 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() } if a.people.dirty { // casts fetched in the last seconds _ = a.disk.savePeople(a.peopleSnapshot()) } _ = a.lookups.save() }() go a.disk.prunePosters() // The screen draws through the theme (see themedScreen). s, err := tcell.NewScreen() if err != nil { return err } a.themed = newThemedScreen(s, a.theme) a.app.SetScreen(a.themed) // initializes it if a.themed.initErr != nil { return a.themed.initErr } a.themed.DisableMouse() a.themed.DisablePaste() go a.watchServices(stop) a.setNotifyDownloads(a.notifyDone) defer a.setNotifyDownloads(false) return a.app.Run() } // setTheme switches the colors while the program runs. func (a *App) setTheme(t theme, note string) { a.theme = t if a.themed != nil { a.themed.setTheme(t) } a.logs.add(levelInfo, note) } // 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()) } parts = append(parts, "F9 help · F1-F7 · Ctrl+C 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, root: p, 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) } // closeIfTop closes the topmost overlay if it is p (and not something opened // on top of it since). func (a *App) closeIfTop(p tview.Primitive) { if n := len(a.overlays); n > 0 && a.overlays[n-1].root == p { a.closeModal() } } // 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 } 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 } } 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 · [ ] lists · 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" }