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 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) } }