2022-08-08 22:04:03 +02:00
|
|
|
|
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"
|
2022-10-20 23:55:27 +02:00
|
|
|
|
"github.com/mattn/go-runewidth"
|
2022-08-08 22:04:03 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type ListBox struct {
|
|
|
|
|
Scrollable
|
|
|
|
|
title string
|
|
|
|
|
lines []string
|
|
|
|
|
selected string
|
|
|
|
|
cursorPos int
|
2022-10-20 23:55:27 +02:00
|
|
|
|
horizPos int
|
2022-08-08 22:04:03 +02:00
|
|
|
|
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
|
2022-10-20 23:55:27 +02:00
|
|
|
|
lb.horizPos = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (lb *ListBox) moveHorizontal(delta int) {
|
|
|
|
|
lb.horizPos += delta
|
|
|
|
|
if lb.horizPos > len(lb.selected) {
|
|
|
|
|
lb.horizPos = len(lb.selected)
|
|
|
|
|
}
|
|
|
|
|
if lb.horizPos < 0 {
|
|
|
|
|
lb.horizPos = 0
|
|
|
|
|
}
|
2022-08-08 22:04:03 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2022-10-20 23:55:27 +02:00
|
|
|
|
line := runewidth.Truncate(list[i], w-1, "❯")
|
2022-08-08 22:04:03 +02:00
|
|
|
|
if lb.selected == list[i] && lb.showCursor {
|
|
|
|
|
style = selectedStyle
|
2022-10-20 23:55:27 +02:00
|
|
|
|
if len(list[i]) > w {
|
|
|
|
|
if len(list[i])-lb.horizPos < w {
|
|
|
|
|
lb.horizPos = len(list[i]) - w + 1
|
|
|
|
|
}
|
|
|
|
|
rest := list[i][lb.horizPos:]
|
|
|
|
|
line = runewidth.Truncate(rest,
|
|
|
|
|
w-1, "❯")
|
|
|
|
|
if lb.horizPos > 0 && len(line) > 0 {
|
|
|
|
|
line = "❮" + line[1:]
|
|
|
|
|
}
|
|
|
|
|
}
|
2022-08-08 22:04:03 +02:00
|
|
|
|
}
|
2022-10-20 23:55:27 +02:00
|
|
|
|
ctx.Printf(1, y, style, line)
|
2022-08-08 22:04:03 +02:00
|
|
|
|
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() {
|
2022-10-07 18:00:31 +02:00
|
|
|
|
ui.Invalidate()
|
2022-08-08 22:04:03 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (lb *ListBox) Event(event tcell.Event) bool {
|
|
|
|
|
if event, ok := event.(*tcell.EventKey); ok {
|
|
|
|
|
switch event.Key() {
|
2022-10-20 23:55:27 +02:00
|
|
|
|
case tcell.KeyLeft:
|
|
|
|
|
lb.moveHorizontal(-1)
|
|
|
|
|
lb.Invalidate()
|
|
|
|
|
return true
|
|
|
|
|
case tcell.KeyRight:
|
|
|
|
|
lb.moveHorizontal(+1)
|
|
|
|
|
lb.Invalidate()
|
|
|
|
|
return true
|
|
|
|
|
case tcell.KeyCtrlB:
|
|
|
|
|
line := lb.selected[:lb.horizPos]
|
|
|
|
|
fds := strings.Fields(line)
|
|
|
|
|
if len(fds) > 1 {
|
|
|
|
|
lb.moveHorizontal(
|
|
|
|
|
strings.LastIndex(line,
|
|
|
|
|
fds[len(fds)-1]) - lb.horizPos - 1)
|
|
|
|
|
} else {
|
|
|
|
|
lb.horizPos = 0
|
|
|
|
|
}
|
|
|
|
|
lb.Invalidate()
|
|
|
|
|
return true
|
|
|
|
|
case tcell.KeyCtrlW:
|
|
|
|
|
line := lb.selected[lb.horizPos+1:]
|
|
|
|
|
fds := strings.Fields(line)
|
|
|
|
|
if len(fds) > 1 {
|
|
|
|
|
lb.moveHorizontal(strings.Index(line, fds[1]))
|
|
|
|
|
}
|
|
|
|
|
lb.Invalidate()
|
|
|
|
|
return true
|
|
|
|
|
case tcell.KeyCtrlA, tcell.KeyHome:
|
|
|
|
|
lb.horizPos = 0
|
|
|
|
|
lb.Invalidate()
|
|
|
|
|
return true
|
|
|
|
|
case tcell.KeyCtrlE, tcell.KeyEnd:
|
|
|
|
|
lb.horizPos = len(lb.selected)
|
|
|
|
|
lb.Invalidate()
|
|
|
|
|
return true
|
2022-08-08 22:04:03 +02:00
|
|
|
|
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)
|
|
|
|
|
}
|