Files
arr-tui/internal/ui/form.go
T
nilsandClaude Opus 5.5 c3eca0b68c 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
2026-10-21 12:23:00 +02:00

336 lines
9.7 KiB
Go

package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// arrowNavigation lets a form be driven with the arrow keys:
// - Up/Down move between the fields and buttons,
// - Left/Right change the value of a closed dropdown without opening it
// (and move between the buttons),
//
// Enter or Space still opens a dropdown list. While a list is open it gets all keys.
//
// With hooks, the highlighted entry of an open list is reported while the user
// moves through it, before anything is selected.
//
// cancel is called on Esc, except when Esc only closes an open dropdown list
// (tview would otherwise treat that as cancelling the whole form).
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 closingList bool
f.SetCancelFunc(func() {
if closingList {
closingList = false
return
}
if cancel != nil {
cancel()
}
})
f.SetInputCapture(func(ev *tcell.EventKey) *tcell.EventKey {
closingList = false
item, button := f.GetFocusedItemIndex()
if item < 0 && button < 0 {
return ev
}
dd, _ := focusedDropDown(f, item)
if dd != nil && dd.IsOpen() {
closingList = ev.Key() == tcell.KeyEscape
if hooks != nil {
b.track(dd, ev, hooks)
}
return ev
}
b.dd = nil
if button >= 0 && ev.Key() == tcell.KeyRune && ev.Rune() == ' ' {
return tcell.NewEventKey(tcell.KeyEnter, 0, 0) // Space presses a button too
}
total := f.GetFormItemCount() + f.GetButtonCount()
pos := item
if button >= 0 {
pos = f.GetFormItemCount() + button
}
switch ev.Key() {
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
}
move(next)
case tcell.KeyTab, tcell.KeyBacktab:
dir := 1
if ev.Key() == tcell.KeyBacktab {
dir = -1
}
if hooks != nil && hooks.tab != nil && hooks.tab(dir) {
return nil
}
return ev
case tcell.KeyLeft, tcell.KeyRight:
step := 1
if ev.Key() == tcell.KeyLeft {
step = -1
}
switch {
case dd != nil:
n := dd.GetOptionCount()
cur, _ := dd.GetCurrentOption()
dd.SetCurrentOption((cur + step + n) % n)
case button >= 0:
move(min(max(pos+step, f.GetFormItemCount()), total-1))
default:
return ev
}
default:
return ev
}
return nil
})
}
// 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) {
if item < 0 {
return nil, false
}
dd, ok := f.GetFormItem(item).(*tview.DropDown)
return dd, ok
}
// bindSearchNow keeps a "Search now" checkbox consistent with whether a search
// can find anything. While searchable() is false the box is unchecked, greyed
// out and cannot be ticked (blocked is called instead); it returns to the
// user's earlier choice afterwards. The returned function must be called
// whenever the answer of searchable() may have changed.
//
// tview's SetDisabled is avoided on purpose: on a form item it re-triggers
// the form's focus handling and leaves two fields highlighted.
func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func()) (sync func()) {
want := true
box.SetChangedFunc(func(checked bool) {
switch {
case searchable():
want = checked
case checked:
box.SetChecked(false)
blocked()
}
})
return func() {
if searchable() {
box.SetLabelColor(tview.Styles.SecondaryTextColor)
box.SetChecked(want)
return
}
box.SetLabelColor(named("gray"))
box.SetChecked(false)
}
}
// browseHooks lets a form follow the highlight of an open dropdown list.
type browseHooks struct {
browse func(dd *tview.DropDown, index int) // the highlight moved to entry index
abort func() // the list was closed without selecting
// edge is called when Down is pressed on the last field or Up on the first;
// it returns true if it moved the focus elsewhere.
edge func(down bool) bool
// tab is called for Tab (dir 1) and Shift+Tab (dir -1); true if handled.
tab func(dir int) bool
}
// browser mirrors the highlight of an open dropdown list, which tview does
// not expose: it starts at the current option and follows the navigation keys.
type browser struct {
dd *tview.DropDown
idx int
known bool // false after keys that move the highlight in ways we do not track
}
func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks) {
n := dd.GetOptionCount()
if b.dd != dd {
cur, _ := dd.GetCurrentOption()
b.dd, b.idx, b.known = dd, max(cur, 0), true
}
move := func(to int) {
b.idx = to
if b.known && h.browse != nil {
h.browse(dd, b.idx)
}
}
switch ev.Key() {
case tcell.KeyDown:
move((b.idx + 1) % n)
case tcell.KeyUp:
move((b.idx - 1 + n) % n)
case tcell.KeyHome:
move(0)
case tcell.KeyEnd:
move(n - 1)
case tcell.KeyEscape:
b.dd = nil
if h.abort != nil {
h.abort()
}
case tcell.KeyEnter:
b.dd = nil // the selection is reported by the dropdown itself
default:
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, named("yellow"))
}
// 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)
}
}