Config view: one list with section headings, navigated with the arrow keys
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
for releases or start an automatic search from there.
|
for releases or start an automatic search from there.
|
||||||
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
|
||||||
retry failed downloads, remove from the arr queue and blocklist.
|
retry failed downloads, remove from the arr queue and blocklist.
|
||||||
- **Config (F6)**: edit everything in the config file from inside the program: service addresses
|
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move
|
||||||
and API keys, the add-form defaults, refresh interval, posters, notifications. *Test connections*
|
through with the arrow keys: service addresses and API keys, the add-form defaults, refresh interval, posters, notifications. *Test connections*
|
||||||
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a
|
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a
|
||||||
new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*`
|
new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*`
|
||||||
environment variables are greyed out and are never written to the file.
|
environment variables are greyed out and are never written to the file.
|
||||||
|
|||||||
@@ -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.
|
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.
|
- **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.
|
- **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):** a form for every setting of the config file, in four sections (Radarr, Sonarr, SABnzbd, Interface) plus Test connections / Save / Revert. 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 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).
|
||||||
- **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.
|
- **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)
|
### 4.2 v0.2 (done, M8)
|
||||||
|
|||||||
+90
-8
@@ -50,16 +50,19 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
|||||||
pos = f.GetFormItemCount() + button
|
pos = f.GetFormItemCount() + button
|
||||||
}
|
}
|
||||||
switch ev.Key() {
|
switch ev.Key() {
|
||||||
case tcell.KeyDown:
|
case tcell.KeyDown, tcell.KeyUp:
|
||||||
if pos == total-1 && hooks != nil && hooks.edge != nil && hooks.edge(true) {
|
dir := 1
|
||||||
|
if ev.Key() == tcell.KeyUp {
|
||||||
|
dir = -1
|
||||||
|
}
|
||||||
|
next := neighbour(f, pos, dir)
|
||||||
|
if next == pos { // nothing further in this direction
|
||||||
|
if hooks != nil && hooks.edge != nil && hooks.edge(dir > 0) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
f.SetFocus(min(pos+1, total-1))
|
f.SetFocus(next)
|
||||||
case tcell.KeyUp:
|
|
||||||
if pos == 0 && hooks != nil && hooks.edge != nil && hooks.edge(false) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
f.SetFocus(max(pos-1, 0))
|
|
||||||
case tcell.KeyTab, tcell.KeyBacktab:
|
case tcell.KeyTab, tcell.KeyBacktab:
|
||||||
dir := 1
|
dir := 1
|
||||||
if ev.Key() == tcell.KeyBacktab {
|
if ev.Key() == tcell.KeyBacktab {
|
||||||
@@ -180,3 +183,82 @@ func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks)
|
|||||||
b.known = false // paging, typed prefixes
|
b.known = false // paging, typed prefixes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skipsFocus marks form items that only decorate (section headings): the
|
||||||
|
// arrow keys pass over them.
|
||||||
|
type skipsFocus interface{ skipFocus() bool }
|
||||||
|
|
||||||
|
// neighbour returns the position of the next focusable item or button after
|
||||||
|
// pos in the given direction, or pos itself if there is none.
|
||||||
|
func neighbour(f *tview.Form, pos, dir int) int {
|
||||||
|
total := f.GetFormItemCount() + f.GetButtonCount()
|
||||||
|
for i := pos + dir; i >= 0 && i < total; i += dir {
|
||||||
|
if i < f.GetFormItemCount() {
|
||||||
|
if s, ok := f.GetFormItem(i).(skipsFocus); ok && s.skipFocus() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstFocusable returns the position of the first item or button that can
|
||||||
|
// take the focus.
|
||||||
|
func firstFocusable(f *tview.Form) int {
|
||||||
|
if f.GetFormItemCount() == 0 {
|
||||||
|
return 0 // a form of buttons only
|
||||||
|
}
|
||||||
|
if s, ok := f.GetFormItem(0).(skipsFocus); ok && s.skipFocus() {
|
||||||
|
return neighbour(f, 0, 1)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// formHeading is a section heading inside a form: a bold title above the next
|
||||||
|
// group of fields. It never takes the focus.
|
||||||
|
type formHeading struct {
|
||||||
|
*tview.Box
|
||||||
|
title string
|
||||||
|
finished func(tcell.Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFormHeading(title string) *formHeading {
|
||||||
|
return &formHeading{Box: tview.NewBox(), title: title}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) skipFocus() bool { return true }
|
||||||
|
|
||||||
|
func (h *formHeading) GetLabel() string { return "" }
|
||||||
|
|
||||||
|
func (h *formHeading) SetFormAttributes(int, tcell.Color, tcell.Color, tcell.Color, tcell.Color) tview.FormItem {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) GetFieldWidth() int { return 0 }
|
||||||
|
|
||||||
|
// GetFieldHeight is 2: an empty line above the title separates the sections.
|
||||||
|
func (h *formHeading) GetFieldHeight() int { return 2 }
|
||||||
|
|
||||||
|
func (h *formHeading) SetFinishedFunc(handler func(key tcell.Key)) tview.FormItem {
|
||||||
|
h.finished = handler
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) SetDisabled(bool) tview.FormItem { return h }
|
||||||
|
|
||||||
|
// Focus passes the focus on, like a disabled input field does, so tview's own
|
||||||
|
// Tab handling skips the heading too.
|
||||||
|
func (h *formHeading) Focus(delegate func(p tview.Primitive)) {
|
||||||
|
if h.finished != nil {
|
||||||
|
h.finished(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Box.Focus(delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *formHeading) Draw(screen tcell.Screen) {
|
||||||
|
h.Box.DrawForSubclass(screen, h)
|
||||||
|
x, y, w, _ := h.GetInnerRect()
|
||||||
|
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, tcell.ColorYellow)
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -67,7 +67,8 @@ var helpSections = []helpSection{
|
|||||||
{"r", "refresh now"},
|
{"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{
|
{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{
|
||||||
{"↑↓, Tab", "move between fields / to the next section"},
|
{"↑↓", "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"},
|
{"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"},
|
{"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"},
|
||||||
{"q", "is typed here as text (it does not quit); F9 opens this help"},
|
{"q", "is typed here as text (it does not quit); F9 opens this help"},
|
||||||
|
|||||||
+42
-45
@@ -102,9 +102,10 @@ type settingsView struct {
|
|||||||
app *App
|
app *App
|
||||||
root *tview.Flex
|
root *tview.Flex
|
||||||
|
|
||||||
radarr, sonarr, sab, ui, actions *tview.Form
|
form *tview.Form // every field, one below the other, with section headings
|
||||||
groups []*tview.Form // keyboard order
|
actions *tview.Form // the buttons below it
|
||||||
status *tview.TextView
|
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"
|
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
||||||
refresh *tview.InputField
|
refresh *tview.InputField
|
||||||
@@ -119,21 +120,21 @@ type settingsView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func settingsHints() string {
|
func settingsHints() string {
|
||||||
return "↑↓ move · Tab next section · Space toggles · Enter presses a button"
|
return "↑↓ move through the fields · Space toggles · Enter presses a button · Tab jumps between fields and buttons"
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSettingsView(app *App) *settingsView {
|
func newSettingsView(app *App) *settingsView {
|
||||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
||||||
|
|
||||||
section := func(title string) *tview.Form {
|
// One form with every field below the other: ↑↓ moves through all of them
|
||||||
f := tview.NewForm().SetItemPadding(0)
|
// and the form scrolls to keep the focused field in view.
|
||||||
f.SetBorder(true).SetTitle(" " + title + " ")
|
f := tview.NewForm().SetItemPadding(0)
|
||||||
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
f.SetBorder(true).SetTitle(" Settings ")
|
||||||
return f
|
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
||||||
}
|
v.form = f
|
||||||
input := func(f *tview.Form, label, key string, width int) {
|
|
||||||
in := tview.NewInputField().SetLabel(label).SetFieldWidth(width)
|
input := func(label, key string) {
|
||||||
in.SetLabelWidth(24) // the same column in every section
|
in := tview.NewInputField().SetLabel(label)
|
||||||
if strings.HasSuffix(key, ".apikey") {
|
if strings.HasSuffix(key, ".apikey") {
|
||||||
in.SetMaskCharacter('*')
|
in.SetMaskCharacter('*')
|
||||||
}
|
}
|
||||||
@@ -145,33 +146,33 @@ func newSettingsView(app *App) *settingsView {
|
|||||||
f.AddFormItem(in)
|
f.AddFormItem(in)
|
||||||
v.inputs[key] = in
|
v.inputs[key] = in
|
||||||
}
|
}
|
||||||
service := func(f *tview.Form, name string, withDefaults bool) {
|
service := func(title, name string, withDefaults bool) {
|
||||||
input(f, "Address", name+".url", 0)
|
f.AddFormItem(newFormHeading(title))
|
||||||
input(f, "API key", name+".apikey", 0)
|
input("Address", name+".url")
|
||||||
|
input("API key", name+".apikey")
|
||||||
if withDefaults {
|
if withDefaults {
|
||||||
input(f, "Default quality profile", name+".profile", 0)
|
input("Default quality profile", name+".profile")
|
||||||
input(f, "Default root folder", name+".folder", 0)
|
input("Default root folder", name+".folder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
v.radarr, v.sonarr, v.sab = section("Radarr"), section("Sonarr"), section("SABnzbd")
|
service("Radarr", "radarr", true)
|
||||||
service(v.radarr, "radarr", true)
|
service("Sonarr", "sonarr", true)
|
||||||
service(v.sonarr, "sonarr", true)
|
service("SABnzbd", "sabnzbd", false)
|
||||||
service(v.sab, "sabnzbd", false)
|
|
||||||
|
|
||||||
v.ui = section("Interface")
|
f.AddFormItem(newFormHeading("Interface"))
|
||||||
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
||||||
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
||||||
v.ui.AddFormItem(v.refresh)
|
f.AddFormItem(v.refresh)
|
||||||
v.ui.AddCheckbox("Posters", true, func(bool) { v.edited() })
|
v.covers = tview.NewCheckbox().SetLabel("Posters").SetChangedFunc(func(bool) { v.edited() })
|
||||||
v.ui.AddDropDown("Poster style", coverStyles, 0, func(string, int) { v.edited() })
|
f.AddFormItem(v.covers)
|
||||||
v.ui.AddCheckbox("Notify about finished downloads", true, func(bool) { v.edited() })
|
v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() })
|
||||||
v.ui.AddCheckbox("Ring the terminal bell too", false, func(bool) { v.edited() })
|
f.AddFormItem(v.style)
|
||||||
v.ui.AddCheckbox("Show API keys", false, func(shown bool) { v.maskKeys(!shown) })
|
v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() })
|
||||||
v.covers = v.ui.GetFormItem(1).(*tview.Checkbox)
|
f.AddFormItem(v.notify)
|
||||||
v.style = v.ui.GetFormItem(2).(*tview.DropDown)
|
v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() })
|
||||||
v.notify = v.ui.GetFormItem(3).(*tview.Checkbox)
|
f.AddFormItem(v.bell)
|
||||||
v.bell = v.ui.GetFormItem(4).(*tview.Checkbox)
|
v.showKeys = tview.NewCheckbox().SetLabel("Show API keys").SetChangedFunc(func(shown bool) { v.maskKeys(!shown) })
|
||||||
v.showKeys = v.ui.GetFormItem(5).(*tview.Checkbox)
|
f.AddFormItem(v.showKeys)
|
||||||
|
|
||||||
v.actions = tview.NewForm().SetItemPadding(0)
|
v.actions = tview.NewForm().SetItemPadding(0)
|
||||||
v.actions.SetBorder(true).SetTitle(" Actions ")
|
v.actions.SetBorder(true).SetTitle(" Actions ")
|
||||||
@@ -184,19 +185,15 @@ func newSettingsView(app *App) *settingsView {
|
|||||||
v.status.SetBorder(true).SetTitle(" Result ")
|
v.status.SetBorder(true).SetTitle(" Result ")
|
||||||
|
|
||||||
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||||
AddItem(v.radarr, 6, 0, true).
|
AddItem(v.form, 0, 1, true).
|
||||||
AddItem(v.sonarr, 6, 0, false).
|
AddItem(v.actions, 4, 0, false)
|
||||||
AddItem(v.sab, 4, 0, false)
|
v.root = tview.NewFlex().AddItem(left, 0, 3, true).AddItem(v.status, 0, 2, false)
|
||||||
right := tview.NewFlex().SetDirection(tview.FlexRow).
|
|
||||||
AddItem(v.ui, 8, 0, false).
|
|
||||||
AddItem(v.actions, 4, 0, false).
|
|
||||||
AddItem(v.status, 0, 1, false)
|
|
||||||
v.root = tview.NewFlex().AddItem(left, 0, 1, true).AddItem(right, 0, 1, false)
|
|
||||||
|
|
||||||
v.groups = []*tview.Form{v.radarr, v.sonarr, v.sab, v.ui, v.actions}
|
v.groups = []*tview.Form{v.form, v.actions}
|
||||||
for i, g := range v.groups {
|
for i, g := range v.groups {
|
||||||
i := i
|
i := i
|
||||||
arrowNavigation(g, func() {}, &browseHooks{
|
arrowNavigation(g, func() {}, &browseHooks{
|
||||||
|
// ↓ past the last field reaches the buttons, ↑ from them goes back.
|
||||||
edge: func(down bool) bool {
|
edge: func(down bool) bool {
|
||||||
if down && i+1 < len(v.groups) {
|
if down && i+1 < len(v.groups) {
|
||||||
v.focusGroup(i+1, false)
|
v.focusGroup(i+1, false)
|
||||||
@@ -220,7 +217,7 @@ func newSettingsView(app *App) *settingsView {
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// focusGroup moves the focus to a section, to its first or last field.
|
// focusGroup moves the focus to a group, to its first or last field.
|
||||||
func (v *settingsView) focusGroup(i int, last bool) {
|
func (v *settingsView) focusGroup(i int, last bool) {
|
||||||
// tview only blurs the leaf that had the focus, not the other items of a
|
// 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
|
// form or the form itself; a form that keeps thinking it has focus would
|
||||||
@@ -231,7 +228,7 @@ func (v *settingsView) focusGroup(i int, last bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
g := v.groups[i]
|
g := v.groups[i]
|
||||||
idx := 0
|
idx := firstFocusable(g)
|
||||||
if last {
|
if last {
|
||||||
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
|
idx = g.GetFormItemCount() + g.GetButtonCount() - 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,43 +191,84 @@ func TestSettingsViewWithoutConfigFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSettingsViewFocusMovesBetweenSections(t *testing.T) {
|
func TestSettingsViewIsOneListNavigatedWithArrows(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
v := a.settings
|
v := a.settings
|
||||||
var focus func(p tview.Primitive)
|
var focus func(p tview.Primitive)
|
||||||
focus = func(p tview.Primitive) { p.Focus(focus) }
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
|
|
||||||
v.focusGroup(3, false) // Interface
|
// Whatever focus flags were left behind, moving to the buttons must leave exactly one group focused.
|
||||||
for _, g := range v.groups {
|
v.focusGroup(0, false)
|
||||||
g.Focus(focus)
|
v.form.Focus(focus)
|
||||||
}
|
v.actions.Focus(focus)
|
||||||
// Whatever flags were left behind, moving to the actions must leave exactly one section focused.
|
v.focusGroup(1, false)
|
||||||
v.focusGroup(4, false)
|
if v.form.HasFocus() || !v.actions.HasFocus() {
|
||||||
focused := 0
|
t.Error("only the buttons may have the focus")
|
||||||
for _, g := range v.groups {
|
|
||||||
if g.HasFocus() {
|
|
||||||
focused++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if focused != 1 || !v.actions.HasFocus() {
|
|
||||||
t.Errorf("%d sections claim the focus, want only the actions", focused)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Down on the last field of a section continues in the next one; Tab cycles.
|
|
||||||
v.focusGroup(0, false)
|
|
||||||
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
send := func(g *tview.Form, k tcell.Key) { g.GetInputCapture()(tcell.NewEventKey(k, 0, 0)) }
|
||||||
g := v.radarr
|
focusedItem := func() int { i, _ := v.form.GetFocusedItemIndex(); return i }
|
||||||
g.SetFocus(g.GetFormItemCount() - 1)
|
labelAt := func(i int) string { return v.form.GetFormItem(i).GetLabel() }
|
||||||
send(g, tcell.KeyDown)
|
|
||||||
if !v.sonarr.HasFocus() || v.radarr.HasFocus() {
|
// The list starts on the first real field, not on the "Radarr" heading.
|
||||||
t.Error("Down on the last field must move to the next section")
|
v.focusGroup(0, false)
|
||||||
|
if i := focusedItem(); labelAt(i) != "Address" || i != 1 {
|
||||||
|
t.Fatalf("starts on item %d (%q)", i, labelAt(i))
|
||||||
}
|
}
|
||||||
send(v.sonarr, tcell.KeyBacktab)
|
// ↓ walks through the fields of a section and over the next heading into the next section.
|
||||||
if !v.radarr.HasFocus() || v.sonarr.HasFocus() {
|
for _, want := range []string{"API key", "Default quality profile", "Default root folder", "Address"} {
|
||||||
t.Error("Shift+Tab must move back a section")
|
send(v.form, tcell.KeyDown)
|
||||||
|
if got := labelAt(focusedItem()); got != want {
|
||||||
|
t.Fatalf("after ↓ on %q, want %q", got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
send(v.radarr, tcell.KeyUp) // first section, first field: nothing before it
|
if focusedItem() != 6 { // 5 is the Sonarr heading
|
||||||
if !v.radarr.HasFocus() {
|
t.Errorf("↓ must skip the section heading, now on item %d", focusedItem())
|
||||||
t.Error("Up on the first field of the first section must stay put")
|
}
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
if focusedItem() != 3 && focusedItem() != 4 {
|
||||||
|
t.Errorf("↑ must also skip the heading, now on item %d", focusedItem())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ↓ on the very last field moves to the buttons; ↑ on a button goes back to the last field.
|
||||||
|
v.focusGroup(0, true)
|
||||||
|
if got := labelAt(focusedItem()); got != "Show API keys" {
|
||||||
|
t.Fatalf("last field = %q", got)
|
||||||
|
}
|
||||||
|
send(v.form, tcell.KeyDown)
|
||||||
|
if !v.actions.HasFocus() || v.form.HasFocus() {
|
||||||
|
t.Error("↓ past the last field must reach the buttons")
|
||||||
|
}
|
||||||
|
send(v.actions, tcell.KeyUp)
|
||||||
|
if !v.form.HasFocus() || v.actions.HasFocus() || labelAt(focusedItem()) != "Show API keys" {
|
||||||
|
t.Error("↑ on the buttons must go back to the last field")
|
||||||
|
}
|
||||||
|
// ↑ on the first field has nowhere to go.
|
||||||
|
v.focusGroup(0, false)
|
||||||
|
send(v.form, tcell.KeyUp)
|
||||||
|
if focusedItem() != 1 {
|
||||||
|
t.Errorf("↑ on the first field moved to item %d", focusedItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormHeadingNeverTakesFocus(t *testing.T) {
|
||||||
|
f := tview.NewForm()
|
||||||
|
f.AddFormItem(newFormHeading("Section"))
|
||||||
|
f.AddInputField("One", "", 10, nil, nil)
|
||||||
|
f.AddFormItem(newFormHeading("Other"))
|
||||||
|
f.AddInputField("Two", "", 10, nil, nil)
|
||||||
|
if firstFocusable(f) != 1 {
|
||||||
|
t.Errorf("first focusable = %d", firstFocusable(f))
|
||||||
|
}
|
||||||
|
if neighbour(f, 1, 1) != 3 || neighbour(f, 3, -1) != 1 || neighbour(f, 3, 1) != 3 || neighbour(f, 1, -1) != 1 {
|
||||||
|
t.Error("neighbour must skip headings and stay at the ends")
|
||||||
|
}
|
||||||
|
// tview's own Tab handling passes over a heading as well.
|
||||||
|
var focus func(p tview.Primitive)
|
||||||
|
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||||
|
f.Focus(focus)
|
||||||
|
if i, _ := f.GetFocusedItemIndex(); i == 0 {
|
||||||
|
t.Error("a heading got the focus")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user