Files
arr-tui/internal/ui/app.go
T
nilsandClaude Sonnet 5.5 51fb0556b0 Add movie search and add flow (M2)
Movies view with debounced Radarr lookup, detail pane, and an add dialog
(quality profile, root folder, monitored, search now). Global shortcuts
are suspended while typing or in a dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:42:26 +02:00

177 lines
3.9 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"
)
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)
}