Colors no longer come from the terminal's palette, whose color 0 many color themes set to a grey that is not their background. A theme sets tview's default styles and the color names used in texts, all chosen from entries 16-255 of the 256-color table so terminals without 24-bit color show them exactly. "auto" picks light or dark from the background color the terminal reports. Settable in the Config view, applied at the next start (widgets copy their colors when created). Replaces using the terminal's own background and text colors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
318 lines
8.9 KiB
Go
318 lines
8.9 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
|
|
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)
|
|
}
|
|
}
|