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
336 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|