b860a64622
Implement horizontal scrolling for selected lines that are longer than dialog width. The following keys can be used: Ctrl-a, Home jump to beginning of line Ctrl-e, End jump to end of line Left move one character back Right move one character forward Ctrl+b move one word back Ctrl+w move one word forward Signed-off-by: Koni Marti <koni.marti@gmail.com> Acked-by: Robin Jarry <robin@jarry.cc>
299 lines
6.2 KiB
Go
299 lines
6.2 KiB
Go
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"
|
||
"github.com/mattn/go-runewidth"
|
||
)
|
||
|
||
type ListBox struct {
|
||
Scrollable
|
||
title string
|
||
lines []string
|
||
selected string
|
||
cursorPos int
|
||
horizPos 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
|
||
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
|
||
}
|
||
}
|
||
|
||
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
|
||
line := runewidth.Truncate(list[i], w-1, "❯")
|
||
if lb.selected == list[i] && lb.showCursor {
|
||
style = selectedStyle
|
||
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:]
|
||
}
|
||
}
|
||
}
|
||
ctx.Printf(1, y, style, line)
|
||
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() {
|
||
ui.Invalidate()
|
||
}
|
||
|
||
func (lb *ListBox) Event(event tcell.Event) bool {
|
||
if event, ok := event.(*tcell.EventKey); ok {
|
||
switch event.Key() {
|
||
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
|
||
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)
|
||
}
|