Only the Test button turned Space into Enter; Save, Revert and the buttons of the dialogs ignored it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
321 lines
9.0 KiB
Go
321 lines
9.0 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) {
|
|
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
|
|
}
|
|
f.SetFocus(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:
|
|
f.SetFocus(min(max(pos+step, f.GetFormItemCount()), total-1))
|
|
default:
|
|
return ev
|
|
}
|
|
default:
|
|
return ev
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|