Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
450 lines
14 KiB
Go
450 lines
14 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rivo/tview"
|
|
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/config"
|
|
"git.stinnesbeck.com/nils/arr-tui/internal/sabnzbd"
|
|
)
|
|
|
|
// settingsValues are the settings as shown in the Config view.
|
|
type settingsValues struct {
|
|
radarrURL, radarrKey, radarrProfile, radarrFolder string
|
|
sonarrURL, sonarrKey, sonarrProfile, sonarrFolder string
|
|
sabURL, sabKey string
|
|
refresh string // seconds, as typed
|
|
covers bool
|
|
coverStyle string
|
|
notify, bell bool
|
|
theme string
|
|
}
|
|
|
|
var (
|
|
coverStyles = []string{"auto", "kitty", "sextant", "quadrant", "cells"}
|
|
themes = []string{"auto", "dark", "light"}
|
|
)
|
|
|
|
// settingsFrom reads the settings in use.
|
|
func settingsFrom(c *config.Config) settingsValues {
|
|
style, th := c.UI.CoverStyle, c.UI.Theme
|
|
if style == "" {
|
|
style = "auto"
|
|
}
|
|
if th == "" {
|
|
th = "auto"
|
|
}
|
|
return settingsValues{
|
|
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
|
|
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
|
|
sonarrURL: c.Sonarr.URL, sonarrKey: c.Sonarr.APIKey,
|
|
sonarrProfile: c.Sonarr.DefaultQualityProfile, sonarrFolder: c.Sonarr.DefaultRootFolder,
|
|
sabURL: c.SABnzbd.URL, sabKey: c.SABnzbd.APIKey,
|
|
refresh: strconv.Itoa(c.UI.RefreshSeconds),
|
|
covers: c.UI.ShowCovers(), coverStyle: style,
|
|
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
|
|
theme: th,
|
|
}
|
|
}
|
|
|
|
// applySettings returns raw (the config file's contents) with the edited
|
|
// values. Settings the environment overrides are left as the file has them, so
|
|
// a value that only came from the environment is never written to disk. The
|
|
// result is checked the way the program checks its configuration.
|
|
func applySettings(raw config.Config, v settingsValues, overridden map[string]string) (config.Config, error) {
|
|
set := func(key string, dst *string, value string) {
|
|
if _, env := overridden[key]; !env {
|
|
*dst = strings.TrimSpace(value)
|
|
}
|
|
}
|
|
set("radarr.url", &raw.Radarr.URL, v.radarrURL)
|
|
set("radarr.apikey", &raw.Radarr.APIKey, v.radarrKey)
|
|
set("sonarr.url", &raw.Sonarr.URL, v.sonarrURL)
|
|
set("sonarr.apikey", &raw.Sonarr.APIKey, v.sonarrKey)
|
|
set("sabnzbd.url", &raw.SABnzbd.URL, v.sabURL)
|
|
set("sabnzbd.apikey", &raw.SABnzbd.APIKey, v.sabKey)
|
|
raw.Radarr.DefaultQualityProfile = strings.TrimSpace(v.radarrProfile)
|
|
raw.Radarr.DefaultRootFolder = strings.TrimSpace(v.radarrFolder)
|
|
raw.Sonarr.DefaultQualityProfile = strings.TrimSpace(v.sonarrProfile)
|
|
raw.Sonarr.DefaultRootFolder = strings.TrimSpace(v.sonarrFolder)
|
|
|
|
n, err := strconv.Atoi(strings.TrimSpace(v.refresh))
|
|
if err != nil || n < 1 || n > 3600 {
|
|
return raw, fmt.Errorf("refresh interval: %q is not a number of seconds between 1 and 3600", v.refresh)
|
|
}
|
|
raw.UI.RefreshSeconds = n
|
|
// Defaults stay out of the file unless the file already named them.
|
|
raw.UI.Covers = boolSetting(raw.UI.Covers, v.covers, true)
|
|
raw.UI.NotifyDownloads = boolSetting(raw.UI.NotifyDownloads, v.notify, true)
|
|
raw.UI.Bell = v.bell
|
|
raw.UI.CoverStyle = v.coverStyle
|
|
if v.coverStyle == "auto" {
|
|
raw.UI.CoverStyle = ""
|
|
}
|
|
raw.UI.Theme = v.theme
|
|
if v.theme == "auto" {
|
|
raw.UI.Theme = ""
|
|
}
|
|
|
|
if _, err := raw.Effective(os.Getenv); err != nil {
|
|
return raw, err
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// boolSetting keeps an optional boolean out of the file while it equals its
|
|
// default and was not in the file before.
|
|
func boolSetting(old *bool, value, def bool) *bool {
|
|
if old == nil && value == def {
|
|
return nil
|
|
}
|
|
return &value
|
|
}
|
|
|
|
// settingsView edits the configuration file's settings. Everything except
|
|
// the theme applies right after saving; the theme at the next start.
|
|
type settingsView struct {
|
|
app *App
|
|
root *tview.Flex
|
|
|
|
form *tview.Form // every field, one below the other, with section headings
|
|
testItem *formAction // the Test button, below the service settings
|
|
actions *tview.Form // the buttons below it
|
|
groups []*tview.Form // keyboard order: the fields, then the buttons
|
|
status *tview.TextView
|
|
|
|
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
|
refresh *tview.InputField
|
|
covers *tview.Checkbox
|
|
style *tview.DropDown
|
|
theme *tview.DropDown
|
|
notify *tview.Checkbox
|
|
bell *tview.Checkbox
|
|
showKeys *tview.Checkbox
|
|
overridden map[string]string
|
|
dirty bool
|
|
filling bool // set while the fields are filled, so that does not count as an edit
|
|
}
|
|
|
|
func settingsHints() string {
|
|
return "↑↓ move through the fields · Space toggles or presses a button · Tab jumps between fields and Save/Revert"
|
|
}
|
|
|
|
func newSettingsView(app *App) *settingsView {
|
|
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
|
|
|
// One form with every field below the other: ↑↓ moves through all of them
|
|
// and the form scrolls to keep the focused field in view.
|
|
f := tview.NewForm().SetItemPadding(0)
|
|
f.SetBorder(true).SetTitle(" Settings ")
|
|
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
|
v.form = f
|
|
|
|
input := func(label, key string) {
|
|
in := tview.NewInputField().SetLabel(label)
|
|
if strings.HasSuffix(key, ".apikey") {
|
|
in.SetMaskCharacter('*')
|
|
}
|
|
if _, ok := v.overridden[key]; ok {
|
|
in.SetLabel(label + " (env)")
|
|
in.SetDisabled(true)
|
|
}
|
|
in.SetChangedFunc(func(string) { v.edited() })
|
|
f.AddFormItem(in)
|
|
v.inputs[key] = in
|
|
}
|
|
service := func(title, name string, withDefaults bool) {
|
|
f.AddFormItem(newFormHeading(title))
|
|
input("Address", name+".url")
|
|
input("API key", name+".apikey")
|
|
if withDefaults {
|
|
input("Default quality profile", name+".profile")
|
|
input("Default root folder", name+".folder")
|
|
}
|
|
}
|
|
service("Radarr", "radarr", true)
|
|
service("Sonarr", "sonarr", true)
|
|
service("SABnzbd", "sabnzbd", false)
|
|
// Right below the connection settings it checks.
|
|
v.testItem = newFormAction("Test", v.test)
|
|
f.AddFormItem(v.testItem)
|
|
|
|
f.AddFormItem(newFormHeading("Interface"))
|
|
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
|
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
|
f.AddFormItem(v.refresh)
|
|
v.covers = tview.NewCheckbox().SetLabel("Posters").SetChangedFunc(func(bool) { v.edited() })
|
|
f.AddFormItem(v.covers)
|
|
v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() })
|
|
f.AddFormItem(v.style)
|
|
v.theme = tview.NewDropDown().SetLabel("Theme (at the next start)").SetOptions(themes, func(string, int) { v.edited() })
|
|
f.AddFormItem(v.theme)
|
|
v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() })
|
|
f.AddFormItem(v.notify)
|
|
v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() })
|
|
f.AddFormItem(v.bell)
|
|
v.showKeys = tview.NewCheckbox().SetLabel("Show API keys").SetChangedFunc(func(shown bool) { v.maskKeys(!shown) })
|
|
f.AddFormItem(v.showKeys)
|
|
|
|
v.actions = tview.NewForm().SetItemPadding(0)
|
|
v.actions.SetBorder(true).SetTitle(" Actions ")
|
|
v.actions.SetBorderPadding(0, 0, 1, 1)
|
|
v.actions.SetButtonsAlign(tview.AlignCenter)
|
|
v.actions.AddButton("Save", v.save)
|
|
v.actions.AddButton("Revert", v.revert)
|
|
|
|
v.status = tview.NewTextView().SetDynamicColors(true).SetWordWrap(true)
|
|
v.status.SetBorder(true).SetTitle(" Result ")
|
|
|
|
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
AddItem(v.form, 0, 1, true).
|
|
AddItem(v.actions, 4, 0, false)
|
|
v.root = tview.NewFlex().AddItem(left, 0, 3, true).AddItem(v.status, 0, 2, false)
|
|
|
|
v.groups = []*tview.Form{v.form, v.actions}
|
|
for i, g := range v.groups {
|
|
i := i
|
|
arrowNavigation(g, func() {}, &browseHooks{
|
|
// ↓ past the last field reaches the buttons, ↑ from them goes back.
|
|
edge: func(down bool) bool {
|
|
if down && i+1 < len(v.groups) {
|
|
v.focusGroup(i+1, false)
|
|
return true
|
|
}
|
|
if !down && i > 0 {
|
|
v.focusGroup(i-1, true)
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
tab: func(dir int) bool {
|
|
v.focusGroup((i+dir+len(v.groups))%len(v.groups), false)
|
|
return true
|
|
},
|
|
})
|
|
}
|
|
v.fill(settingsFrom(app.cfg))
|
|
v.maskKeys(true)
|
|
v.describe()
|
|
return v
|
|
}
|
|
|
|
// focusGroup moves the focus to a group, to its first or last field.
|
|
func (v *settingsView) focusGroup(i int, last bool) {
|
|
// tview only blurs the leaf that had the focus, not the other items of a
|
|
// form or the form itself; a form that keeps thinking it has focus would
|
|
// take the key presses.
|
|
for j, other := range v.groups {
|
|
if j != i {
|
|
blurForm(other)
|
|
}
|
|
}
|
|
g := v.groups[i]
|
|
idx := firstFocusable(g)
|
|
if last {
|
|
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
|
|
}
|
|
g.SetFocus(idx)
|
|
v.app.app.SetFocus(g)
|
|
}
|
|
|
|
// blurForm takes the focus away from a form and everything in it.
|
|
func blurForm(f *tview.Form) {
|
|
for i := 0; i < f.GetFormItemCount(); i++ {
|
|
f.GetFormItem(i).Blur()
|
|
}
|
|
for i := 0; i < f.GetButtonCount(); i++ {
|
|
f.GetButton(i).Blur()
|
|
}
|
|
f.Blur()
|
|
}
|
|
|
|
func (v *settingsView) edited() {
|
|
if !v.filling {
|
|
v.dirty = true
|
|
}
|
|
}
|
|
|
|
// maskKeys hides or shows the API keys.
|
|
func (v *settingsView) maskKeys(hide bool) {
|
|
for key, in := range v.inputs {
|
|
if strings.HasSuffix(key, ".apikey") {
|
|
if hide {
|
|
in.SetMaskCharacter('*')
|
|
} else {
|
|
in.SetMaskCharacter(0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// fill puts the values into the fields.
|
|
func (v *settingsView) fill(s settingsValues) {
|
|
v.filling = true
|
|
defer func() { v.filling = false }()
|
|
set := map[string]string{
|
|
"radarr.url": s.radarrURL, "radarr.apikey": s.radarrKey, "radarr.profile": s.radarrProfile, "radarr.folder": s.radarrFolder,
|
|
"sonarr.url": s.sonarrURL, "sonarr.apikey": s.sonarrKey, "sonarr.profile": s.sonarrProfile, "sonarr.folder": s.sonarrFolder,
|
|
"sabnzbd.url": s.sabURL, "sabnzbd.apikey": s.sabKey,
|
|
}
|
|
for key, value := range set {
|
|
v.inputs[key].SetText(value)
|
|
}
|
|
v.refresh.SetText(s.refresh)
|
|
v.covers.SetChecked(s.covers)
|
|
for i, name := range coverStyles {
|
|
if name == s.coverStyle {
|
|
v.style.SetCurrentOption(i)
|
|
}
|
|
}
|
|
for i, name := range themes {
|
|
if name == s.theme {
|
|
v.theme.SetCurrentOption(i)
|
|
}
|
|
}
|
|
v.notify.SetChecked(s.notify)
|
|
v.bell.SetChecked(s.bell)
|
|
v.dirty = false
|
|
}
|
|
|
|
// read collects the values from the fields.
|
|
func (v *settingsView) read() settingsValues {
|
|
text := func(key string) string { return v.inputs[key].GetText() }
|
|
i, _ := v.style.GetCurrentOption()
|
|
style := "auto"
|
|
if i >= 0 && i < len(coverStyles) {
|
|
style = coverStyles[i]
|
|
}
|
|
th := "auto"
|
|
if i, _ := v.theme.GetCurrentOption(); i >= 0 && i < len(themes) {
|
|
th = themes[i]
|
|
}
|
|
return settingsValues{
|
|
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
|
|
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
|
|
sonarrURL: text("sonarr.url"), sonarrKey: text("sonarr.apikey"),
|
|
sonarrProfile: text("sonarr.profile"), sonarrFolder: text("sonarr.folder"),
|
|
sabURL: text("sabnzbd.url"), sabKey: text("sabnzbd.apikey"),
|
|
refresh: v.refresh.GetText(),
|
|
covers: v.covers.IsChecked(), coverStyle: style,
|
|
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
|
|
theme: th,
|
|
}
|
|
}
|
|
|
|
// describe writes the standing information into the result box.
|
|
func (v *settingsView) describe() {
|
|
var b strings.Builder
|
|
if v.app.cfgPath == "" {
|
|
b.WriteString("[yellow]No config file is known, changes cannot be saved.[-]\n")
|
|
} else {
|
|
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
|
|
}
|
|
b.WriteString("Changes take effect when you save; only the theme waits for the next start.\n")
|
|
if len(v.overridden) > 0 {
|
|
var names []string
|
|
for _, env := range v.overridden {
|
|
names = append(names, env)
|
|
}
|
|
b.WriteString("[gray]Greyed-out fields come from the environment (" + strings.Join(names, ", ") + ") and are not written to the file.[-]\n")
|
|
}
|
|
v.status.SetText(b.String())
|
|
}
|
|
|
|
// activate refreshes the fields when the view is shown, unless they hold unsaved edits.
|
|
func (v *settingsView) activate() {
|
|
if !v.dirty {
|
|
v.fill(settingsFrom(v.app.cfg))
|
|
}
|
|
v.focusGroup(0, false)
|
|
}
|
|
|
|
// report appends a line to the result box.
|
|
func (v *settingsView) report(line string) {
|
|
fmt.Fprintln(v.status, line)
|
|
v.status.ScrollToEnd()
|
|
}
|
|
|
|
func (v *settingsView) revert() {
|
|
v.fill(settingsFrom(v.app.cfg))
|
|
v.describe()
|
|
v.report("[green]Reverted to the saved settings.[-]")
|
|
}
|
|
|
|
// test checks the three services with the values in the fields, without saving.
|
|
func (v *settingsView) test() {
|
|
s := v.read()
|
|
v.describe()
|
|
v.report("Testing the connections…")
|
|
type probeFn func(ctx context.Context) (string, error)
|
|
tests := []struct {
|
|
name string
|
|
run probeFn
|
|
}{
|
|
{"Radarr", func(ctx context.Context) (string, error) {
|
|
st, err := arr.NewClient(strings.TrimSpace(s.radarrURL), strings.TrimSpace(s.radarrKey)).SystemStatus(ctx)
|
|
return st.Version, err
|
|
}},
|
|
{"Sonarr", func(ctx context.Context) (string, error) {
|
|
st, err := arr.NewClient(strings.TrimSpace(s.sonarrURL), strings.TrimSpace(s.sonarrKey)).SystemStatus(ctx)
|
|
return st.Version, err
|
|
}},
|
|
{"SABnzbd", func(ctx context.Context) (string, error) {
|
|
return sabnzbd.NewClient(strings.TrimSpace(s.sabURL), strings.TrimSpace(s.sabKey)).Version(ctx)
|
|
}},
|
|
}
|
|
for _, t := range tests {
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
version, err := t.run(ctx)
|
|
v.app.post(func() {
|
|
if err != nil {
|
|
v.report("[red]✗ " + t.name + ": " + tview.Escape(err.Error()) + "[-]")
|
|
return
|
|
}
|
|
v.report("[green]✓ " + t.name + " " + tview.Escape(version) + " answers[-]")
|
|
})
|
|
}()
|
|
}
|
|
}
|
|
|
|
// save writes the config file and applies the settings.
|
|
func (v *settingsView) save() {
|
|
v.describe()
|
|
if v.app.cfgPath == "" || v.app.cfgFile == nil {
|
|
v.report("[red]There is no config file to save to.[-]")
|
|
return
|
|
}
|
|
raw, err := applySettings(*v.app.cfgFile, v.read(), v.overridden)
|
|
if err != nil {
|
|
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
|
return
|
|
}
|
|
if err := config.Save(v.app.cfgPath, &raw); err != nil {
|
|
v.report("[red]Not saved: " + tview.Escape(err.Error()) + "[-]")
|
|
return
|
|
}
|
|
effective, err := raw.Effective(os.Getenv)
|
|
if err != nil { // cannot happen: applySettings validated it
|
|
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
|
|
return
|
|
}
|
|
themeChanged := effective.UI.Theme != v.app.cfg.UI.Theme
|
|
v.app.cfgFile = &raw
|
|
v.app.applyConfig(effective)
|
|
v.dirty = false
|
|
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
|
|
if themeChanged {
|
|
v.report("The new theme is used from the next start.")
|
|
}
|
|
v.app.Notify(levelSuccess, "Settings saved")
|
|
}
|