mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-07-15 15:43:50 +02:00

Following up on the great discussion on IRC and in patch #59448, improve the usability of the ":menu" command, namely: - Add the possibility to override the dialog title, that defaults to the actual command, for instance "-cf -a ...". - Select the first matching row, so that the user can simply type enter if it actually matches what they're looking for. In passing, also fix a bug whereby if the user had selected a matching row and then updates the filter so that that row does *not* match anymore (e.g. the filter was box, matching INBOX, and is updated to boxnono), the box would still remember that row's value (and give it if the user typed Enter, even though nothing was matching the filter). Make "<C-g> = :menu -d -t "Open folder" :cf<Enter>" behave exactly like Sublime or VS Code, and improve aerc usability a lot for mailboxes with many folders. Signed-off-by: Simon Martin <simon@nasilyan.com> Acked-by: Robin Jarry <robin@jarry.cc>
338 lines
7.3 KiB
Go
338 lines
7.3 KiB
Go
package app
|
||
|
||
import (
|
||
"math"
|
||
"strings"
|
||
"sync"
|
||
|
||
"git.sr.ht/~rjarry/aerc/config"
|
||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||
"git.sr.ht/~rockorager/vaxis"
|
||
"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
|
||
textFilter func([]string, string) []string
|
||
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,
|
||
textFilter: nil,
|
||
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) SetTextFilter(fn func([]string, string) []string) *ListBox {
|
||
lb.textFilter = fn
|
||
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 {
|
||
log.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 (%d/%d): ",
|
||
len(lb.filtered()), len(lb.lines))
|
||
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 {
|
||
term := lb.filter.String()
|
||
|
||
if lb.textFilter != nil {
|
||
return lb.textFilter(lb.lines, term)
|
||
}
|
||
|
||
list := make([]string, 0, len(lb.lines))
|
||
for _, line := range lb.lines {
|
||
if strings.Contains(line, term) {
|
||
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
|
||
}
|
||
}
|
||
if len(list) > 0 {
|
||
if lb.cursorPos == -1 {
|
||
// The list is not empty and we did not find the selection, if any,
|
||
// so clear it.
|
||
lb.selected = ""
|
||
// Select the fist matching item to avoid the user needing to
|
||
// explicitly select it in case it matches what they want.
|
||
lb.moveCursor(0)
|
||
}
|
||
} else {
|
||
// The list is now empty, the selection, if any, needs to be cleared.
|
||
lb.selected = ""
|
||
}
|
||
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, "%s", line)
|
||
y += 1
|
||
}
|
||
|
||
if needScrollbar {
|
||
scrollBarCtx := ctx.Subcontext(w, 0, 1, ctx.Height())
|
||
lb.drawScrollbar(scrollBarCtx)
|
||
}
|
||
}
|
||
|
||
func (lb *ListBox) drawScrollbar(ctx *ui.Context) {
|
||
gutterStyle := vaxis.Style{}
|
||
pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse}
|
||
|
||
// 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 vaxis.Event) bool {
|
||
showFilter := lb.showFilterField()
|
||
if key, ok := event.(vaxis.Key); ok {
|
||
switch {
|
||
case key.Matches(vaxis.KeyLeft):
|
||
if showFilter {
|
||
break
|
||
}
|
||
lb.moveHorizontal(-1)
|
||
lb.Invalidate()
|
||
return true
|
||
case key.Matches(vaxis.KeyRight):
|
||
if showFilter {
|
||
break
|
||
}
|
||
lb.moveHorizontal(+1)
|
||
lb.Invalidate()
|
||
return true
|
||
case key.Matches('b', vaxis.ModCtrl):
|
||
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 key.Matches('w', vaxis.ModCtrl):
|
||
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 key.Matches('a', vaxis.ModCtrl), key.Matches(vaxis.KeyHome):
|
||
if showFilter {
|
||
break
|
||
}
|
||
lb.horizPos = 0
|
||
lb.Invalidate()
|
||
return true
|
||
case key.Matches('e', vaxis.ModCtrl), key.Matches(vaxis.KeyEnd):
|
||
if showFilter {
|
||
break
|
||
}
|
||
lb.horizPos = len(lb.selected)
|
||
lb.Invalidate()
|
||
return true
|
||
case key.Matches('p', vaxis.ModCtrl), key.Matches(vaxis.KeyUp):
|
||
lb.moveCursor(-1)
|
||
lb.Invalidate()
|
||
return true
|
||
case key.Matches('n', vaxis.ModCtrl), key.Matches(vaxis.KeyDown):
|
||
lb.moveCursor(+1)
|
||
lb.Invalidate()
|
||
return true
|
||
case key.Matches(vaxis.KeyPgUp):
|
||
if lb.jump >= 0 {
|
||
lb.moveCursor(-lb.jump)
|
||
lb.Invalidate()
|
||
}
|
||
return true
|
||
case key.Matches(vaxis.KeyPgDown):
|
||
if lb.jump >= 0 {
|
||
lb.moveCursor(+lb.jump)
|
||
lb.Invalidate()
|
||
}
|
||
return true
|
||
case key.Matches(vaxis.KeyEnter):
|
||
return lb.quit(lb.selected)
|
||
case key.Matches(vaxis.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)
|
||
}
|