Sonarr client with lookup, add series (monitor modes) and search. Refactor the movie view into a generic search view driven by a backend interface so Movies and Series share one implementation. Add F1-F4 / Alt+1-4 view switching that works while typing. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
201 lines
4.8 KiB
Go
201 lines
4.8 KiB
Go
// 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"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/radarr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sonarr"
|
|
)
|
|
|
|
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", newSearchView(a, radarrBackend{radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey)}, cfg.Radarr).root},
|
|
{'2', "Series", newSearchView(a, sonarrBackend{sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey)}, cfg.Sonarr).root},
|
|
{'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/F1-F4 views · / search"
|
|
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
|
|
}
|
|
// View switching that also works while typing in a search box. Not while a dialog is open.
|
|
if !a.modalOpen {
|
|
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.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
|
|
}
|
|
|
|
// viewShortcut returns the view index for F1-F4 or Alt+1-4, 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)
|
|
}
|
|
if ev.Key() == tcell.KeyRune && ev.Modifiers()&tcell.ModAlt != 0 {
|
|
for i, v := range a.views {
|
|
if ev.Rune() == v.key {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
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)
|
|
}
|