// 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() } // 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 message string 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. type overlay struct { page string focus tview.Primitive } // 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) 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}, {key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root}, {key: '3', name: "Downloads", body: downloads.root, onShow: downloads.activate, onHide: downloads.deactivate}, {key: '4', name: "Library", body: placeholder("Library (v0.2)")}, } 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, 34, 0, false), 1, 0, false). AddItem(a.pages, 0, 1, true). AddItem(a.status, 1, 0, false) a.app.SetRoot(layout, true).EnableMouse(false) a.app.SetInputCapture(a.handleKey) a.switchTo(0) a.renderStatus() 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} a.renderServices() } func (a *App) renderServices() { var s string for _, n := range serviceNames { st := a.state[n] switch { case !st.known: s += "[gray]○ " + n + "[-] " case st.err != nil: s += "[red]● " + n + "[-] " default: s += "[green]●[-] " + n + " " } } 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() } // SetMessage shows a message in the status bar. UI goroutine only; use // post from other goroutines. func (a *App) SetMessage(text string) { a.message = text a.renderStatus() } // 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() { hints := "[gray]?/F9 help · q quit · F1-F4 views · Tab panels · / search" if len(a.overlays) > 0 { hints = "[gray]Esc back · Ctrl+C quit" } if a.message == "" { a.status.SetText(hints) return } a.status.SetText(a.message + " [gray]│ " + hints) } // pushOverlay stacks p above everything and focuses focus. func (a *App) pushOverlay(p, focus tview.Primitive) { name := fmt.Sprintf("overlay-%d", len(a.overlays)) a.overlays = append(a.overlays, overlay{page: name, focus: a.app.GetFocus()}) 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) } // openFull shows p above the whole view area (the tab bar and status bar stay visible). func (a *App) openFull(p tview.Primitive) { a.pushOverlay(p, p) } // 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.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)) } // 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. Not while a dialog is open. if len(a.overlays) == 0 { if ev.Key() == tcell.KeyF9 { a.showHelp() return nil } if i := a.viewShortcut(ev); i >= 0 && i < len(a.views) { 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() } 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 placeholder(text string) tview.Primitive { return tview.NewTextView().SetText(text).SetTextAlign(tview.AlignCenter) }