Config view: one list with section headings, navigated with the arrow keys

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 23:53:00 +02:00
co-authored by Claude Sonnet 5.5
parent ff9e59a310
commit 56bd80f7bb
6 changed files with 206 additions and 85 deletions
+90 -8
View File
@@ -50,16 +50,19 @@ func arrowNavigation(f *tview.Form, cancel func(), hooks *browseHooks) {
pos = f.GetFormItemCount() + button
}
switch ev.Key() {
case tcell.KeyDown:
if pos == total-1 && hooks != nil && hooks.edge != nil && hooks.edge(true) {
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(min(pos+1, total-1))
case tcell.KeyUp:
if pos == 0 && hooks != nil && hooks.edge != nil && hooks.edge(false) {
return nil
}
f.SetFocus(max(pos-1, 0))
f.SetFocus(next)
case tcell.KeyTab, tcell.KeyBacktab:
dir := 1
if ev.Key() == tcell.KeyBacktab {
@@ -180,3 +183,82 @@ func (b *browser) track(dd *tview.DropDown, ev *tcell.EventKey, h *browseHooks)
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, tcell.ColorYellow)
}