Add the Config view: edit and save the settings, applied without a restart

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:31:13 +02:00
co-authored by Claude Sonnet 5.5
parent 0ccd02b3c5
commit a4ec9ad71e
25 changed files with 1168 additions and 54 deletions
+70 -22
View File
@@ -25,6 +25,9 @@ type view struct {
onShow, onHide func()
// hints lists the keys that work in this view (shown in the status bar).
hints func() string
// captureKeys makes the global single-key shortcuts (q, ?) stand back
// while this view is shown, because its fields take typed text.
captureKeys bool
}
// App is the top-level arr-tui application.
@@ -46,8 +49,18 @@ type App struct {
state map[string]serviceState // keyed by service name
probes []probe
screen tcell.Screen // remembered at the first draw, for the bell
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
@@ -111,8 +124,10 @@ func New(cfg *config.Config) *App {
a.gfx = newKittyGraphics(a.writeTerminal)
}
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)
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
@@ -127,6 +142,8 @@ func New(cfg *config.Config) *App {
}},
{"SABnzbd", sabClient.Version},
}
settings := newSettingsView(a)
a.settings = settings
calendar := newCalendarView(a, radarrClient, sonarrClient)
library := newLibraryView(a, radarrClient, sonarrClient)
downloads := newDownloadsView(a, sabClient,
@@ -135,13 +152,15 @@ func New(cfg *config.Config) *App {
{name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem},
},
time.Duration(cfg.UI.RefreshSeconds)*time.Second)
a.downloads = downloads
a.views = []view{
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}, cfg.Radarr).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}, cfg.Sonarr).root, hints: searchHints},
{key: '1', name: "Movies", body: newSearchView(a, radarrBackend{radarrClient}).root, hints: searchHints},
{key: '2', name: "Series", body: newSearchView(a, sonarrBackend{sonarrClient}).root, hints: searchHints},
{key: '3', name: "Library", body: library.root, onShow: library.activate, 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: "Log", body: a.logs.root, onShow: a.logs.onShow, hints: logHints},
{key: '7', name: "Config", body: settings.root, onShow: settings.activate, hints: settingsHints, captureKeys: true},
}
for i, v := range a.views {
a.pages.AddPage(v.name, v.body, true, i == 0)
@@ -236,6 +255,7 @@ func (a *App) setNarrow(narrow bool) {
a.narrow = narrow
a.topBar.ResizeItem(a.services, servicesWidth(narrow), 0)
a.renderServices()
a.renderTabs()
}
func (a *App) renderServices() {
@@ -295,27 +315,31 @@ func (a *App) Run() error {
}
}()
go a.watchServices(stop)
if a.notifyDone {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.watchDownloads(ctx, a.sab, a.bell)
}
a.setNotifyDownloads(a.notifyDone)
defer a.setNotifyDownloads(false)
return a.app.Run()
}
// 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 {
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) })
}()
}
a.probeAll()
select {
case <-tick.C:
case <-stop:
@@ -324,15 +348,34 @@ func (a *App) watchServices(stop <-chan struct{}) {
}
}
// 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 {
if h := a.views[a.active].hints; h != nil {
hints = h() + " · "
v := a.views[a.active]
var parts []string
if v.hints != nil {
parts = append(parts, v.hints())
}
hints += "? help · F1-F6 · q quit"
if v.captureKeys { // q and ? are text there
parts = append(parts, "F9 help · F1-F7 views")
} else {
parts = append(parts, "? help · F1-F7 · q quit")
}
hints = strings.Join(parts, " · ")
}
a.status.SetText("[gray]" + hints)
}
@@ -419,7 +462,7 @@ func (a *App) onlyFullOverlays() bool {
// 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 {
if len(a.overlays) > 0 || a.views[a.active].captureKeys {
return true
}
_, isInput := a.app.GetFocus().(*tview.InputField)
@@ -483,9 +526,14 @@ func (a *App) switchTo(i int) {
func (a *App) renderTabs() {
var s string
for i, v := range a.views {
if i == a.active {
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)
} else {
default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
}
}