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:
+90
-8
@@ -50,16 +50,19 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
|
||||
pos = f.GetFormItemCount() + button
|
||||
}
|
||||
switch ev.Key() {
|
||||
case tcell.KeyDown:
|
||||
if pos == total-1 && hooks != nil && hooks.edge != nil && hooks.edge(true) {
|
||||
case tcell.KeyDown, tcell.KeyUp:
|
||||
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
|
||||
}
|
||||
f.SetFocus(min(pos+1, total-1))
|
||||
case tcell.KeyUp:
|
||||
if pos == 0 && hooks != nil && hooks.edge != nil && hooks.edge(false) {
|
||||
return nil
|
||||
}
|
||||
f.SetFocus(max(pos-1, 0))
|
||||
f.SetFocus(next)
|
||||
case tcell.KeyTab, tcell.KeyBacktab:
|
||||
dir := 1
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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"},
|
||||
}},
|
||||
{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"},
|
||||
{"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"},
|
||||
|
||||
+42
-45
@@ -102,9 +102,10 @@ type settingsView struct {
|
||||
app *App
|
||||
root *tview.Flex
|
||||
|
||||
radarr, sonarr, sab, ui, actions *tview.Form
|
||||
groups []*tview.Form // keyboard order
|
||||
status *tview.TextView
|
||||
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
|
||||
|
||||
inputs map[string]*tview.InputField // by setting key, e.g. "radarr.url"
|
||||
refresh *tview.InputField
|
||||
@@ -119,21 +120,21 @@ type settingsView struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
v := &settingsView{app: app, inputs: map[string]*tview.InputField{}, overridden: config.Overrides(os.Getenv)}
|
||||
|
||||
section := func(title string) *tview.Form {
|
||||
f := tview.NewForm().SetItemPadding(0)
|
||||
f.SetBorder(true).SetTitle(" " + title + " ")
|
||||
f.SetBorderPadding(0, 0, 1, 1) // one row per field
|
||||
return f
|
||||
}
|
||||
input := func(f *tview.Form, label, key string, width int) {
|
||||
in := tview.NewInputField().SetLabel(label).SetFieldWidth(width)
|
||||
in.SetLabelWidth(24) // the same column in every section
|
||||
// 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('*')
|
||||
}
|
||||
@@ -145,33 +146,33 @@ func newSettingsView(app *App) *settingsView {
|
||||
f.AddFormItem(in)
|
||||
v.inputs[key] = in
|
||||
}
|
||||
service := func(f *tview.Form, name string, withDefaults bool) {
|
||||
input(f, "Address", name+".url", 0)
|
||||
input(f, "API key", name+".apikey", 0)
|
||||
service := func(title, name string, withDefaults bool) {
|
||||
f.AddFormItem(newFormHeading(title))
|
||||
input("Address", name+".url")
|
||||
input("API key", name+".apikey")
|
||||
if withDefaults {
|
||||
input(f, "Default quality profile", name+".profile", 0)
|
||||
input(f, "Default root folder", name+".folder", 0)
|
||||
input("Default quality profile", name+".profile")
|
||||
input("Default root folder", name+".folder")
|
||||
}
|
||||
}
|
||||
v.radarr, v.sonarr, v.sab = section("Radarr"), section("Sonarr"), section("SABnzbd")
|
||||
service(v.radarr, "radarr", true)
|
||||
service(v.sonarr, "sonarr", true)
|
||||
service(v.sab, "sabnzbd", false)
|
||||
service("Radarr", "radarr", true)
|
||||
service("Sonarr", "sonarr", true)
|
||||
service("SABnzbd", "sabnzbd", false)
|
||||
|
||||
v.ui = section("Interface")
|
||||
f.AddFormItem(newFormHeading("Interface"))
|
||||
v.refresh = tview.NewInputField().SetLabel("Downloads refresh (seconds)").SetFieldWidth(6)
|
||||
v.refresh.SetChangedFunc(func(string) { v.edited() })
|
||||
v.ui.AddFormItem(v.refresh)
|
||||
v.ui.AddCheckbox("Posters", true, func(bool) { v.edited() })
|
||||
v.ui.AddDropDown("Poster style", coverStyles, 0, func(string, int) { v.edited() })
|
||||
v.ui.AddCheckbox("Notify about finished downloads", true, func(bool) { v.edited() })
|
||||
v.ui.AddCheckbox("Ring the terminal bell too", false, func(bool) { v.edited() })
|
||||
v.ui.AddCheckbox("Show API keys", false, func(shown bool) { v.maskKeys(!shown) })
|
||||
v.covers = v.ui.GetFormItem(1).(*tview.Checkbox)
|
||||
v.style = v.ui.GetFormItem(2).(*tview.DropDown)
|
||||
v.notify = v.ui.GetFormItem(3).(*tview.Checkbox)
|
||||
v.bell = v.ui.GetFormItem(4).(*tview.Checkbox)
|
||||
v.showKeys = v.ui.GetFormItem(5).(*tview.Checkbox)
|
||||
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.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 ")
|
||||
@@ -184,19 +185,15 @@ func newSettingsView(app *App) *settingsView {
|
||||
v.status.SetBorder(true).SetTitle(" Result ")
|
||||
|
||||
left := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(v.radarr, 6, 0, true).
|
||||
AddItem(v.sonarr, 6, 0, false).
|
||||
AddItem(v.sab, 4, 0, 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)
|
||||
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.radarr, v.sonarr, v.sab, v.ui, v.actions}
|
||||
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)
|
||||
@@ -220,7 +217,7 @@ func newSettingsView(app *App) *settingsView {
|
||||
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) {
|
||||
// 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
|
||||
@@ -231,7 +228,7 @@ func (v *settingsView) focusGroup(i int, last bool) {
|
||||
}
|
||||
}
|
||||
g := v.groups[i]
|
||||
idx := 0
|
||||
idx := firstFocusable(g)
|
||||
if last {
|
||||
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)
|
||||
v := a.settings
|
||||
var focus func(p tview.Primitive)
|
||||
focus = func(p tview.Primitive) { p.Focus(focus) }
|
||||
|
||||
v.focusGroup(3, false) // Interface
|
||||
for _, g := range v.groups {
|
||||
g.Focus(focus)
|
||||
}
|
||||
// Whatever flags were left behind, moving to the actions must leave exactly one section focused.
|
||||
v.focusGroup(4, false)
|
||||
focused := 0
|
||||
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)
|
||||
// Whatever focus flags were left behind, moving to the buttons must leave exactly one group focused.
|
||||
v.focusGroup(0, false)
|
||||
v.form.Focus(focus)
|
||||
v.actions.Focus(focus)
|
||||
v.focusGroup(1, false)
|
||||
if v.form.HasFocus() || !v.actions.HasFocus() {
|
||||
t.Error("only the buttons may have the focus")
|
||||
}
|
||||
|
||||
// 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)) }
|
||||
g := v.radarr
|
||||
g.SetFocus(g.GetFormItemCount() - 1)
|
||||
send(g, tcell.KeyDown)
|
||||
if !v.sonarr.HasFocus() || v.radarr.HasFocus() {
|
||||
t.Error("Down on the last field must move to the next section")
|
||||
focusedItem := func() int { i, _ := v.form.GetFocusedItemIndex(); return i }
|
||||
labelAt := func(i int) string { return v.form.GetFormItem(i).GetLabel() }
|
||||
|
||||
// The list starts on the first real field, not on the "Radarr" heading.
|
||||
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)
|
||||
if !v.radarr.HasFocus() || v.sonarr.HasFocus() {
|
||||
t.Error("Shift+Tab must move back a section")
|
||||
// ↓ walks through the fields of a section and over the next heading into the next section.
|
||||
for _, want := range []string{"API key", "Default quality profile", "Default root folder", "Address"} {
|
||||
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 !v.radarr.HasFocus() {
|
||||
t.Error("Up on the first field of the first section must stay put")
|
||||
if focusedItem() != 6 { // 5 is the Sonarr heading
|
||||
t.Errorf("↓ must skip the section heading, now on item %d", focusedItem())
|
||||
}
|
||||
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