widgets: add list box with filter/scrollbar
Implement a scrollable text box that displays a list of strings which can be filtered. The widget is closed by pressing ESC or ENTER. If ENTER is pressed and an entry has been selected, this text will be passed to the closing callback. Signed-off-by: Koni Marti <koni.marti@gmail.com> Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
parent
00e908e2ae
commit
e31dbe9f31
1 changed files with 239 additions and 0 deletions
239
widgets/listbox.go
Normal file
239
widgets/listbox.go
Normal file
|
@ -0,0 +1,239 @@
|
||||||
|
package widgets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.sr.ht/~rjarry/aerc/config"
|
||||||
|
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||||
|
"git.sr.ht/~rjarry/aerc/logging"
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ListBox struct {
|
||||||
|
Scrollable
|
||||||
|
ui.Invalidatable
|
||||||
|
title string
|
||||||
|
lines []string
|
||||||
|
selected string
|
||||||
|
cursorPos int
|
||||||
|
jump int
|
||||||
|
showCursor bool
|
||||||
|
showFilter bool
|
||||||
|
filterMutex sync.Mutex
|
||||||
|
filter *ui.TextInput
|
||||||
|
uiConfig *config.UIConfig
|
||||||
|
cb func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListBox(title string, lines []string, uiConfig *config.UIConfig, cb func(string)) *ListBox {
|
||||||
|
lb := &ListBox{
|
||||||
|
title: title,
|
||||||
|
lines: lines,
|
||||||
|
cursorPos: -1,
|
||||||
|
jump: -1,
|
||||||
|
uiConfig: uiConfig,
|
||||||
|
cb: cb,
|
||||||
|
filter: ui.NewTextInput("", uiConfig),
|
||||||
|
}
|
||||||
|
lb.filter.OnChange(func(ti *ui.TextInput) {
|
||||||
|
var show bool
|
||||||
|
if ti.String() == "" {
|
||||||
|
show = false
|
||||||
|
} else {
|
||||||
|
show = true
|
||||||
|
}
|
||||||
|
lb.setShowFilterField(show)
|
||||||
|
lb.filter.Focus(show)
|
||||||
|
lb.Invalidate()
|
||||||
|
})
|
||||||
|
lb.dedup()
|
||||||
|
return lb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) dedup() {
|
||||||
|
dedupped := make([]string, 0, len(lb.lines))
|
||||||
|
dedup := make(map[string]struct{})
|
||||||
|
for _, line := range lb.lines {
|
||||||
|
if _, dup := dedup[line]; dup {
|
||||||
|
logging.Warnf("ignore duplicate: %s", line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dedup[line] = struct{}{}
|
||||||
|
dedupped = append(dedupped, line)
|
||||||
|
}
|
||||||
|
lb.lines = dedupped
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) setShowFilterField(b bool) {
|
||||||
|
lb.filterMutex.Lock()
|
||||||
|
defer lb.filterMutex.Unlock()
|
||||||
|
lb.showFilter = b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) showFilterField() bool {
|
||||||
|
lb.filterMutex.Lock()
|
||||||
|
defer lb.filterMutex.Unlock()
|
||||||
|
return lb.showFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Draw(ctx *ui.Context) {
|
||||||
|
defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
titleStyle := lb.uiConfig.GetStyle(config.STYLE_TITLE)
|
||||||
|
w, h := ctx.Width(), ctx.Height()
|
||||||
|
ctx.Fill(0, 0, w, h, ' ', defaultStyle)
|
||||||
|
ctx.Fill(0, 0, w, 1, ' ', titleStyle)
|
||||||
|
ctx.Printf(0, 0, titleStyle, "%s", lb.title)
|
||||||
|
|
||||||
|
y := 0
|
||||||
|
if lb.showFilterField() {
|
||||||
|
y = 1
|
||||||
|
x := ctx.Printf(0, y, defaultStyle, "Filter: ")
|
||||||
|
lb.filter.Draw(ctx.Subcontext(x, y, w-x, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
lb.drawBox(ctx.Subcontext(0, y+1, w, h-(y+1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) moveCursor(delta int) {
|
||||||
|
list := lb.filtered()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lb.cursorPos += delta
|
||||||
|
if lb.cursorPos < 0 {
|
||||||
|
lb.cursorPos = 0
|
||||||
|
}
|
||||||
|
if lb.cursorPos >= len(list) {
|
||||||
|
lb.cursorPos = len(list) - 1
|
||||||
|
}
|
||||||
|
lb.selected = list[lb.cursorPos]
|
||||||
|
lb.showCursor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) filtered() []string {
|
||||||
|
list := []string{}
|
||||||
|
filterTerm := lb.filter.String()
|
||||||
|
for _, line := range lb.lines {
|
||||||
|
if strings.Contains(line, filterTerm) {
|
||||||
|
list = append(list, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) drawBox(ctx *ui.Context) {
|
||||||
|
defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
||||||
|
selectedStyle := lb.uiConfig.GetComposedStyleSelected(config.STYLE_MSGLIST_DEFAULT, nil)
|
||||||
|
|
||||||
|
w, h := ctx.Width(), ctx.Height()
|
||||||
|
lb.jump = h
|
||||||
|
list := lb.filtered()
|
||||||
|
|
||||||
|
lb.UpdateScroller(ctx.Height(), len(list))
|
||||||
|
scroll := 0
|
||||||
|
lb.cursorPos = -1
|
||||||
|
for i := 0; i < len(list); i++ {
|
||||||
|
if lb.selected == list[i] {
|
||||||
|
scroll = i
|
||||||
|
lb.cursorPos = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lb.EnsureScroll(scroll)
|
||||||
|
|
||||||
|
needScrollbar := lb.NeedScrollbar()
|
||||||
|
if needScrollbar {
|
||||||
|
w -= 1
|
||||||
|
if w < 0 {
|
||||||
|
w = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lb.lines == nil || len(list) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
y := 0
|
||||||
|
for i := lb.Scroll(); i < len(list) && y < h; i++ {
|
||||||
|
style := defaultStyle
|
||||||
|
if lb.selected == list[i] && lb.showCursor {
|
||||||
|
style = selectedStyle
|
||||||
|
}
|
||||||
|
ctx.Printf(1, y, style, list[i])
|
||||||
|
y += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if needScrollbar {
|
||||||
|
scrollBarCtx := ctx.Subcontext(w, 0, 1, ctx.Height())
|
||||||
|
lb.drawScrollbar(scrollBarCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) drawScrollbar(ctx *ui.Context) {
|
||||||
|
gutterStyle := tcell.StyleDefault
|
||||||
|
pillStyle := tcell.StyleDefault.Reverse(true)
|
||||||
|
|
||||||
|
// gutter
|
||||||
|
h := ctx.Height()
|
||||||
|
ctx.Fill(0, 0, 1, h, ' ', gutterStyle)
|
||||||
|
|
||||||
|
// pill
|
||||||
|
pillSize := int(math.Ceil(float64(h) * lb.PercentVisible()))
|
||||||
|
pillOffset := int(math.Floor(float64(h) * lb.PercentScrolled()))
|
||||||
|
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Invalidate() {
|
||||||
|
lb.DoInvalidate(lb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Event(event tcell.Event) bool {
|
||||||
|
if event, ok := event.(*tcell.EventKey); ok {
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyCtrlP, tcell.KeyUp:
|
||||||
|
lb.moveCursor(-1)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case tcell.KeyCtrlN, tcell.KeyDown:
|
||||||
|
lb.moveCursor(+1)
|
||||||
|
lb.Invalidate()
|
||||||
|
return true
|
||||||
|
case tcell.KeyPgUp:
|
||||||
|
if lb.jump >= 0 {
|
||||||
|
lb.moveCursor(-lb.jump)
|
||||||
|
lb.Invalidate()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case tcell.KeyPgDn:
|
||||||
|
if lb.jump >= 0 {
|
||||||
|
lb.moveCursor(+lb.jump)
|
||||||
|
lb.Invalidate()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case tcell.KeyEnter:
|
||||||
|
return lb.quit(lb.selected)
|
||||||
|
case tcell.KeyEsc:
|
||||||
|
return lb.quit("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lb.filter != nil {
|
||||||
|
handled := lb.filter.Event(event)
|
||||||
|
lb.Invalidate()
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) quit(s string) bool {
|
||||||
|
lb.filter.Focus(false)
|
||||||
|
if lb.cb != nil {
|
||||||
|
lb.cb(s)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lb *ListBox) Focus(f bool) {
|
||||||
|
lb.filter.Focus(f)
|
||||||
|
}
|
Loading…
Reference in a new issue