// Package ui contains the tview user interface. package ui import ( "fmt" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "git.stinnesbeck.com/nils/arr-tui/internal/config" ) const modalPage = "modal" type view struct { key rune name string body tview.Primitive } // 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 modalOpen bool modalFocus 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), } a.views = []view{ {'1', "Movies", newMoviesView(a, cfg.Radarr).root}, {'2', "Series", placeholder("Series search (M3)")}, {'3', "Downloads", placeholder("Downloads (M4/M5)")}, {'4', "Library", 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(a.tabs, 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() return a } // 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]q quit · Tab/1-4 views" if a.message == "" { a.status.SetText(hints) return } a.status.SetText(a.message + " [gray]│ " + hints) } // openModal shows p centered over the current view and remembers the focus. func (a *App) openModal(p tview.Primitive, width, height int) { a.modalFocus = a.app.GetFocus() a.modalOpen = true 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.pages.AddPage(modalPage, box, true, true) a.app.SetFocus(p) } func (a *App) closeModal() { a.pages.RemovePage(modalPage) a.modalOpen = false if a.modalFocus != nil { a.app.SetFocus(a.modalFocus) a.modalFocus = nil } } // typing reports whether keystrokes belong to a text field or dialog and must // not trigger global shortcuts. func (a *App) typing() bool { if a.modalOpen { 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 } if a.typing() { return ev } switch ev.Key() { case tcell.KeyTab: a.switchTo((a.active + 1) % len(a.views)) return nil case tcell.KeyBacktab: a.switchTo((a.active + len(a.views) - 1) % len(a.views)) return nil 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 } func (a *App) switchTo(i int) { 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) }