Config view: Test connections below the service settings; hide a stale cursor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@ Notes:
|
||||
Posters are JPEG/PNG, decoded and downscaled in the process. Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory (the terminal's copies are deleted when the cache is dropped and on exit), failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
||||
- **Calendar view (F5):** a two-week window (starting today, moved a week at a time with `[`/`]`, `t` returns to today) with the library's movie releases and airing episodes, grouped by day. Radarr `GET /calendar` supplies the cinema, digital and physical release dates (taken as written: midnight UTC means that calendar day) and Sonarr `GET /calendar?includeSeries=true` the episodes with their air time (shown in local time). Unmonitored items are included with `u`. A movie appears once per release date in the window. Each entry shows type, title, episode or release kind and status (downloaded / missing / upcoming); the detail pane shows poster and plot; `Enter`/`i` starts the release search and `s` an automatic search.
|
||||
- **Download notifications:** a background poller (independent of the visible view) reads SABnzbd's history every 15 s and announces downloads that reach `Completed` or `Failed` as a popup and log entry; the first poll only records the current state. `ui.notify_downloads` (default true) and `ui.bell` (default false, rings the terminal bell) control it. Connection errors are not reported here, the indicators and the log already do that.
|
||||
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Test connections / Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test connections` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live. `q` is typed text in this view (`F9` opens the help).
|
||||
- **Config view (F6):** one list with a field for every setting of the config file, one below the other under the headings Radarr, Sonarr, SABnzbd and Interface (a single scrolling form; ↑↓ passes over the headings and ↓ after the last field reaches the Save / Revert buttons, so no Tab is needed; Tab jumps between the fields and the buttons). The `Test connections` button is part of the list, right below the SABnzbd settings; `Save` and `Revert` are in a bar below the list. The fields start from the settings in use; API keys are masked (a checkbox shows them). `Test connections` builds temporary clients from the typed values and reports each service. `Save` validates like startup does (URLs, non-empty keys, refresh 1–3600 s), then writes the file from the file's own contents plus the edits (`config.LoadFile`), so values that only come from `ARR_TUI_*` environment variables are left as the file has them and are shown greyed out; the write is atomic (temp file + rename) and keeps the file's permissions (`0600` when new); defaults such as `covers: true` stay out of the file unless it already named them. Everything then applies without a restart: the Radarr/Sonarr/SABnzbd clients are re-pointed (`Reconfigure`, guarded by a mutex), the connection indicators, add-form defaults and options are refreshed, the library and calendar reload, the Downloads interval, notifications, bell and poster style are switched live. `q` is typed text in this view (`F9` opens the help).
|
||||
- **Notifications and log (F7):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
||||
|
||||
### 4.2 v0.2 (done, M8)
|
||||
|
||||
@@ -175,6 +175,10 @@ func New(cfg *config.Config) *App {
|
||||
AddItem(a.status, 1, 0, false)
|
||||
|
||||
a.app.SetBeforeDrawFunc(func(s tcell.Screen) bool {
|
||||
// A focused text field shows the cursor again while it is drawn. Hiding it
|
||||
// first covers focus moves that bypass tview (arrow keys in forms), which
|
||||
// would leave the cursor behind in the field that was left.
|
||||
s.HideCursor()
|
||||
a.screen = s
|
||||
a.cellAspect = cellAspectOf(s)
|
||||
return false
|
||||
|
||||
@@ -262,3 +262,56 @@ func (h *formHeading) Draw(screen tcell.Screen) {
|
||||
x, y, w, _ := h.GetInnerRect()
|
||||
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, tcell.ColorYellow)
|
||||
}
|
||||
|
||||
// formAction is a button inside a form's list of fields, so it can sit between
|
||||
// sections instead of at the end. Enter or Space presses it.
|
||||
type formAction struct {
|
||||
*tview.Button
|
||||
labelWidth int
|
||||
finished func(tcell.Key)
|
||||
}
|
||||
|
||||
func newFormAction(label string, pressed func()) *formAction {
|
||||
a := &formAction{Button: tview.NewButton(label)}
|
||||
a.SetSelectedFunc(pressed)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *formAction) GetLabel() string { return "" } // the button text is its own label
|
||||
|
||||
func (a *formAction) SetFormAttributes(labelWidth int, _, _, _, _ tcell.Color) tview.FormItem {
|
||||
a.labelWidth = labelWidth
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *formAction) GetFieldWidth() int { return tview.TaggedStringWidth(a.GetLabel()) + 4 }
|
||||
|
||||
// GetFieldHeight is 2: an empty line above the button.
|
||||
func (a *formAction) GetFieldHeight() int { return 2 }
|
||||
|
||||
func (a *formAction) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
|
||||
a.SetExitFunc(handler) // Tab, Shift+Tab and Esc
|
||||
a.finished = handler
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *formAction) SetDisabled(disabled bool) tview.FormItem {
|
||||
a.Button.SetDisabled(disabled)
|
||||
return a
|
||||
}
|
||||
|
||||
// SetRect places the button under the field column, one line down, as wide as its text.
|
||||
func (a *formAction) SetRect(x, y, width, height int) {
|
||||
w := min(max(width-a.labelWidth, 0), tview.TaggedStringWidth(a.Button.GetLabel())+4)
|
||||
a.Button.SetRect(x+a.labelWidth, y+1, w, 1)
|
||||
}
|
||||
|
||||
func (a *formAction) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
inner := a.Button.InputHandler()
|
||||
return func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||
if event.Key() == tcell.KeyRune && event.Rune() == ' ' {
|
||||
event = tcell.NewEventKey(tcell.KeyEnter, 0, 0)
|
||||
}
|
||||
inner(event, setFocus)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -67,10 +67,11 @@ var helpSections = []helpSection{
|
||||
{"r", "refresh now"},
|
||||
}},
|
||||
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, notifications.", items: []helpItem{
|
||||
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches the buttons"},
|
||||
{"Tab", "jump between the fields and the buttons"},
|
||||
{"Space", "toggle a checkbox"},
|
||||
{"Enter", "press a button: Test connections checks the three services with the values typed in; Save writes the file and applies everything at once; Revert discards edits"},
|
||||
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
|
||||
{"Tab", "jump between the fields and Save / Revert"},
|
||||
{"Space", "toggle a checkbox; press a button"},
|
||||
{"Test connections", "(below the SABnzbd settings) checks the three services with the values typed in"},
|
||||
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
|
||||
{"q", "is typed here as text (it does not quit); F9 opens this help"},
|
||||
}},
|
||||
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
|
||||
|
||||
@@ -102,10 +102,11 @@ type settingsView struct {
|
||||
app *App
|
||||
root *tview.Flex
|
||||
|
||||
form *tview.Form // every field, one below the other, with section headings
|
||||
actions *tview.Form // the buttons below it
|
||||
groups []*tview.Form // keyboard order: the fields, then the buttons
|
||||
status *tview.TextView
|
||||
form *tview.Form // every field, one below the other, with section headings
|
||||
testItem *formAction // the Test connections 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
|
||||
@@ -120,7 +121,7 @@ type settingsView struct {
|
||||
}
|
||||
|
||||
func settingsHints() string {
|
||||
return "↑↓ move through the fields · Space toggles · Enter presses a button · Tab jumps between fields and buttons"
|
||||
return "↑↓ move through the fields · Space toggles or presses a button · Tab jumps between fields and Save/Revert"
|
||||
}
|
||||
|
||||
func newSettingsView(app *App) *settingsView {
|
||||
@@ -158,6 +159,9 @@ func newSettingsView(app *App) *settingsView {
|
||||
service("Radarr", "radarr", true)
|
||||
service("Sonarr", "sonarr", true)
|
||||
service("SABnzbd", "sabnzbd", false)
|
||||
// Right below the connection settings it checks.
|
||||
v.testItem = newFormAction("Test connections", v.test)
|
||||
f.AddFormItem(v.testItem)
|
||||
|
||||
f.AddFormItem(newFormHeading("Interface"))
|
||||
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
||||
@@ -177,7 +181,6 @@ func newSettingsView(app *App) *settingsView {
|
||||
v.actions = tview.NewForm().SetItemPadding(0)
|
||||
v.actions.SetBorder(true).SetTitle(" Actions ")
|
||||
v.actions.SetBorderPadding(0, 0, 1, 1)
|
||||
v.actions.AddButton("Test connections", v.test)
|
||||
v.actions.AddButton("Save", v.save)
|
||||
v.actions.AddButton("Revert", v.revert)
|
||||
|
||||
|
||||
@@ -272,3 +272,43 @@ func TestFormHeadingNeverTakesFocus(t *testing.T) {
|
||||
t.Error("a heading got the focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
v := a.settings
|
||||
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||
focusedItem := func() tview.FormItem { i, _ := v.form.GetFocusedItemIndex(); return v.form.GetFormItem(i) }
|
||||
|
||||
// It comes right after the SABnzbd API key and before the Interface section.
|
||||
v.focusGroup(0, false)
|
||||
for i := 0; i < 10; i++ { // Radarr 4, Sonarr 4, SABnzbd 2 fields
|
||||
send(v.form, tcell.KeyDown)
|
||||
}
|
||||
if focusedItem() != tview.FormItem(v.testItem) {
|
||||
t.Fatalf("↓ after the SABnzbd API key reached %T %q, want the Test connections button", focusedItem(), focusedItem().GetLabel())
|
||||
}
|
||||
send(v.form, tcell.KeyDown)
|
||||
if got := focusedItem().GetLabel(); got != "Downloads refresh (seconds)" {
|
||||
t.Errorf("after the button comes %q", got)
|
||||
}
|
||||
send(v.form, tcell.KeyUp)
|
||||
if focusedItem() != tview.FormItem(v.testItem) {
|
||||
t.Error("↑ must come back to the button")
|
||||
}
|
||||
|
||||
// Save and Revert stay below everything; Test connections is not among them.
|
||||
if v.actions.GetButtonCount() != 2 || v.actions.GetButton(0).GetLabel() != "Save" || v.actions.GetButton(1).GetLabel() != "Revert" {
|
||||
t.Errorf("actions bar has %d buttons", v.actions.GetButtonCount())
|
||||
}
|
||||
|
||||
// Space and Enter press it.
|
||||
var focus func(p tview.Primitive)
|
||||
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||
for _, ev := range []*tcell.EventKey{tcell.NewEventKey(tcell.KeyRune, ' ', 0), tcell.NewEventKey(tcell.KeyEnter, 0, 0)} {
|
||||
v.describe()
|
||||
v.testItem.InputHandler()(ev, focus)
|
||||
if !strings.Contains(v.status.GetText(false), "Testing the connections") {
|
||||
t.Errorf("key %v did not start the test: %q", ev.Name(), v.status.GetText(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user