Keep the application's focus in step with arrow-key moves in forms

Form.SetFocus moves between items without telling the application, which
kept the item that was left. Opening the help from the Config view then
blurred only that one: the field in use kept drawing its cursor through
the help window, the form still had the focus and took the help's keys
(no scrolling), and closing the help returned to the wrong field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 12:23:00 +02:00
co-authored by Claude Opus 5.5
parent 8ce7bdaaa7
commit c3eca0b68c
4 changed files with 52 additions and 2 deletions
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## Unreleased
- **Help over the Config view:** after moving through the fields with the arrow keys, F9 left the
field in use focused behind the help: its cursor showed through the window, the help did not
scroll (the form took the keys), and closing it returned to the wrong field. Arrow-key moves in
forms now keep the application's focus in step.
## v0.2.0 ## v0.2.0
New: New:
+1
View File
@@ -200,6 +200,7 @@ func New(cfg *config.Config) *App {
return false return false
}) })
a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false) a.app.SetRoot(&toastRoot{Flex: layout, app: a}, true).EnableMouse(false)
syncFocus = func(p tview.Primitive) { a.app.SetFocus(p) }
a.app.SetInputCapture(a.handleKey) a.app.SetInputCapture(a.handleKey)
a.switchTo(0) a.switchTo(0)
a.renderServices() a.renderServices()
+17 -2
View File
@@ -18,6 +18,18 @@ import (
// cancel is called on Esc, except when Esc only closes an open dropdown list // cancel is called on Esc, except when Esc only closes an open dropdown list
// (tview would otherwise treat that as cancelling the whole form). // (tview would otherwise treat that as cancelling the whole form).
func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) { func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
// Form.SetFocus moves the focus between its items without telling the
// application, which then still holds the item that was left. Opening a
// dialog would only blur that one: the item actually in use kept its
// cursor on screen and, because the form still had the focus, took the
// dialog's keys, and closing the dialog returned to the wrong item. So the
// application is told after every move.
move := func(index int) {
f.SetFocus(index)
if syncFocus != nil {
syncFocus(f) // the form hands the focus on to the item at index
}
}
var b browser var b browser
var closingList bool var closingList bool
f.SetCancelFunc(func() { f.SetCancelFunc(func() {
@@ -65,7 +77,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
} }
return nil return nil
} }
f.SetFocus(next) move(next)
case tcell.KeyTab, tcell.KeyBacktab: case tcell.KeyTab, tcell.KeyBacktab:
dir := 1 dir := 1
if ev.Key() == tcell.KeyBacktab { if ev.Key() == tcell.KeyBacktab {
@@ -86,7 +98,7 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
cur, _ := dd.GetCurrentOption() cur, _ := dd.GetCurrentOption()
dd.SetCurrentOption((cur + step + n) % n) dd.SetCurrentOption((cur + step + n) % n)
case button >= 0: case button >= 0:
f.SetFocus(min(max(pos+step, f.GetFormItemCount()), total-1)) move(min(max(pos+step, f.GetFormItemCount()), total-1))
default: default:
return ev return ev
} }
@@ -97,6 +109,9 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
}) })
} }
// syncFocus gives a primitive the application's focus; New sets it.
var syncFocus func(p tview.Primitive)
func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) { func focusedDropDown(f *tview.Form, item int) (*tview.DropDown, bool) {
if item < 0 { if item < 0 {
return nil, false return nil, false
+27
View File
@@ -313,3 +313,30 @@ func TestTestConnectionsButtonSitsBelowTheServices(t *testing.T) {
} }
} }
} }
func TestHelpOverConfigTakesTheFocus(t *testing.T) {
a := newTestApp(t)
a.handleKey(tcell.NewEventKey(tcell.KeyF6, 0, 0))
moveDown := a.settings.form.GetInputCapture()
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
moveDown(tcell.NewEventKey(tcell.KeyDown, 0, 0))
field := a.settings.inputs["radarr.profile"]
if a.app.GetFocus() != field {
t.Fatalf("after ↓↓ the application's focus is %T, not the third field", a.app.GetFocus())
}
a.handleKey(tcell.NewEventKey(tcell.KeyF9, 0, 0))
// The field must not keep the focus behind the help: it would draw its
// cursor through the window, and the form would take the help's keys.
if field.HasFocus() || a.settings.form.HasFocus() {
t.Errorf("behind the help: field focused %v, form focused %v", field.HasFocus(), a.settings.form.HasFocus())
}
if _, ok := a.app.GetFocus().(*tview.TextView); !ok {
t.Errorf("help has no focus: %T", a.app.GetFocus())
}
a.closeModal()
if a.app.GetFocus() != field || !field.HasFocus() {
t.Errorf("closing the help returns to %T, not the field that was in use", a.app.GetFocus())
}
}