907 lines
22 KiB
Go
907 lines
22 KiB
Go
// Package fuzzyfinder provides terminal user interfaces for fuzzy-finding.
|
|
//
|
|
// Note that, all functions are not goroutine-safe.
|
|
package fuzzyfinder
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/ktr0731/go-ansisgr"
|
|
"github.com/ktr0731/go-fuzzyfinder/matching"
|
|
runewidth "github.com/mattn/go-runewidth"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
var (
|
|
// ErrAbort is returned from Find* functions if there are no selections.
|
|
ErrAbort = errors.New("abort")
|
|
errEntered = errors.New("entered")
|
|
)
|
|
|
|
// Finds the minimum value among the arguments
|
|
func min(vars ...int) int {
|
|
min := vars[0]
|
|
|
|
for _, i := range vars {
|
|
if min > i {
|
|
min = i
|
|
}
|
|
}
|
|
|
|
return min
|
|
}
|
|
|
|
type state struct {
|
|
items []string // All item names.
|
|
allMatched []matching.Matched // All items.
|
|
matched []matching.Matched // Matched items against the input.
|
|
|
|
// x is the current index of the prompt line.
|
|
x int
|
|
// cursorX is the position of prompt line.
|
|
// Note that cursorX is the actual width of input runes.
|
|
cursorX int
|
|
|
|
// The current index of filtered items (matched).
|
|
// The initial value is 0.
|
|
y int
|
|
// cursorY is the position of item line.
|
|
// Note that the max size of cursorY depends on max height.
|
|
cursorY int
|
|
|
|
input []rune
|
|
|
|
// selections holds whether a key is selected or not. Each key is
|
|
// an index of an item (Matched.Idx). Each value represents the position
|
|
// which it is selected.
|
|
selection map[int]int
|
|
// selectionIdx holds the next index, which is used to a selection's value.
|
|
selectionIdx int
|
|
}
|
|
|
|
type finder struct {
|
|
term terminal
|
|
stateMu sync.RWMutex
|
|
state state
|
|
drawTimer *time.Timer
|
|
eventCh chan struct{}
|
|
opt *opt
|
|
|
|
termEventsChan <-chan tcell.Event
|
|
}
|
|
|
|
func newFinder() *finder {
|
|
return &finder{}
|
|
}
|
|
|
|
func (f *finder) initFinder(items []string, matched []matching.Matched, opt opt) error {
|
|
if f.term == nil {
|
|
screen, err := tcell.NewScreen()
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to new screen")
|
|
}
|
|
f.term = &termImpl{
|
|
screen: screen,
|
|
}
|
|
if err := f.term.Init(); err != nil {
|
|
return errors.Wrap(err, "failed to initialize screen")
|
|
}
|
|
|
|
eventsChan := make(chan tcell.Event)
|
|
go f.term.ChannelEvents(eventsChan, nil)
|
|
f.termEventsChan = eventsChan
|
|
}
|
|
|
|
f.opt = &opt
|
|
f.state = state{}
|
|
|
|
var cursorPositioned bool
|
|
if opt.multi {
|
|
f.state.selection = map[int]int{}
|
|
f.state.selectionIdx = 1
|
|
|
|
// Apply preselection
|
|
for i := range items {
|
|
if opt.preselected(i) {
|
|
f.state.selection[i] = f.state.selectionIdx
|
|
f.state.selectionIdx++
|
|
}
|
|
}
|
|
} else {
|
|
// In non-multi mode, set the cursor position to the first preselected item
|
|
for i := range items {
|
|
if opt.preselected(i) {
|
|
cursorPositioned = true
|
|
// Find the matched item index
|
|
for j, m := range matched {
|
|
if m.Idx == i {
|
|
f.state.y = j
|
|
f.state.cursorY = min(j, len(matched)-1)
|
|
break
|
|
}
|
|
}
|
|
break // Only use the first preselected item
|
|
}
|
|
}
|
|
}
|
|
|
|
f.state.items = items
|
|
f.state.matched = matched
|
|
f.state.allMatched = matched
|
|
|
|
// If no preselected item is found and beginAtTop is true, set the cursor to the last item
|
|
if !cursorPositioned && opt.beginAtTop {
|
|
f.state.cursorY = len(f.state.matched) - 1
|
|
f.state.y = len(f.state.matched) - 1
|
|
}
|
|
|
|
if !isInTesting() {
|
|
f.drawTimer = time.AfterFunc(0, func() {
|
|
f.stateMu.Lock()
|
|
f._draw()
|
|
f._drawPreview()
|
|
f.stateMu.Unlock()
|
|
f.term.Show()
|
|
})
|
|
f.drawTimer.Stop()
|
|
}
|
|
f.eventCh = make(chan struct{}, 30) // A large value
|
|
|
|
if opt.query != "" {
|
|
f.state.input = []rune(opt.query)
|
|
f.state.cursorX = runewidth.StringWidth(opt.query)
|
|
f.state.x = len(opt.query)
|
|
f.filter()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *finder) updateItems(items []string, matched []matching.Matched) {
|
|
f.stateMu.Lock()
|
|
f.state.items = items
|
|
f.state.matched = matched
|
|
f.state.allMatched = matched
|
|
|
|
// Apply preselection to any new items
|
|
if f.opt.multi {
|
|
for i := 0; i < len(items); i++ {
|
|
// Check if this item is not already in the selection and should be preselected
|
|
if _, exists := f.state.selection[i]; !exists && f.opt.preselected(i) {
|
|
f.state.selection[i] = f.state.selectionIdx
|
|
f.state.selectionIdx++
|
|
}
|
|
}
|
|
}
|
|
|
|
f.stateMu.Unlock()
|
|
f.eventCh <- struct{}{}
|
|
}
|
|
|
|
// _draw is used from draw with a timer.
|
|
func (f *finder) _draw() {
|
|
width, height := f.term.Size()
|
|
f.term.Clear()
|
|
|
|
maxWidth := width
|
|
if f.opt.previewFunc != nil {
|
|
maxWidth = width/2 - 1
|
|
}
|
|
|
|
maxHeight := height
|
|
|
|
// prompt line
|
|
var promptLinePad int
|
|
|
|
for _, r := range f.opt.promptString {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorBlue).
|
|
Background(tcell.ColorDefault)
|
|
|
|
f.term.SetContent(promptLinePad, maxHeight-1, r, nil, style)
|
|
promptLinePad++
|
|
}
|
|
var r rune
|
|
var w int
|
|
for _, r = range f.state.input {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorDefault).
|
|
Background(tcell.ColorDefault).
|
|
Bold(true)
|
|
|
|
// Add a space between '>' and runes.
|
|
f.term.SetContent(promptLinePad+w, maxHeight-1, r, nil, style)
|
|
w += runewidth.RuneWidth(r)
|
|
}
|
|
f.term.ShowCursor(promptLinePad+f.state.cursorX, maxHeight-1)
|
|
|
|
maxHeight--
|
|
|
|
// Header line
|
|
if len(f.opt.header) > 0 {
|
|
w = 0
|
|
for _, r := range runewidth.Truncate(f.opt.header, maxWidth-2, "..") {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorGreen).
|
|
Background(tcell.ColorDefault)
|
|
f.term.SetContent(2+w, maxHeight-1, r, nil, style)
|
|
w += runewidth.RuneWidth(r)
|
|
}
|
|
maxHeight--
|
|
}
|
|
|
|
// Number line
|
|
for i, r := range fmt.Sprintf("%d/%d", len(f.state.matched), len(f.state.items)) {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorYellow).
|
|
Background(tcell.ColorDefault)
|
|
|
|
f.term.SetContent(2+i, maxHeight-1, r, nil, style)
|
|
}
|
|
maxHeight--
|
|
|
|
// Item lines
|
|
itemAreaHeight := maxHeight - 1
|
|
matched := f.state.matched
|
|
offset := f.state.cursorY
|
|
y := f.state.y
|
|
// From the first (the most bottom) item in the item lines to the end.
|
|
matched = matched[y-offset:]
|
|
|
|
for i, m := range matched {
|
|
if i > itemAreaHeight {
|
|
break
|
|
}
|
|
if i == f.state.cursorY {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorRed).
|
|
Background(tcell.ColorBlack)
|
|
|
|
f.term.SetContent(0, maxHeight-1-i, '>', nil, style)
|
|
f.term.SetContent(1, maxHeight-1-i, ' ', nil, style)
|
|
}
|
|
|
|
if f.opt.multi {
|
|
if _, ok := f.state.selection[m.Idx]; ok {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorRed).
|
|
Background(tcell.ColorBlack)
|
|
|
|
f.term.SetContent(1, maxHeight-1-i, '>', nil, style)
|
|
}
|
|
}
|
|
|
|
var posIdx int
|
|
w := 2
|
|
for j, r := range []rune(f.state.items[m.Idx]) {
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorDefault).
|
|
Background(tcell.ColorDefault)
|
|
// Highlight selected strings.
|
|
hasHighlighted := false
|
|
if posIdx < len(f.state.input) {
|
|
from, to := m.Pos[0], m.Pos[1]
|
|
if !(from == -1 && to == -1) && (from <= j && j <= to) {
|
|
if unicode.ToLower(f.state.input[posIdx]) == unicode.ToLower(r) {
|
|
style = tcell.StyleDefault.
|
|
Foreground(tcell.ColorGreen).
|
|
Background(tcell.ColorDefault)
|
|
hasHighlighted = true
|
|
posIdx++
|
|
}
|
|
}
|
|
}
|
|
if i == f.state.cursorY {
|
|
if hasHighlighted {
|
|
style = tcell.StyleDefault.
|
|
Foreground(tcell.ColorDarkCyan).
|
|
Bold(true).
|
|
Background(tcell.ColorBlack)
|
|
} else {
|
|
style = tcell.StyleDefault.
|
|
Foreground(tcell.ColorYellow).
|
|
Bold(true).
|
|
Background(tcell.ColorBlack)
|
|
}
|
|
}
|
|
|
|
rw := runewidth.RuneWidth(r)
|
|
// Shorten item cells.
|
|
if w+rw+2 > maxWidth {
|
|
f.term.SetContent(w, maxHeight-1-i, '.', nil, style)
|
|
f.term.SetContent(w+1, maxHeight-1-i, '.', nil, style)
|
|
break
|
|
} else {
|
|
f.term.SetContent(w, maxHeight-1-i, r, nil, style)
|
|
w += rw
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *finder) _drawPreview() {
|
|
if f.opt.previewFunc == nil {
|
|
return
|
|
}
|
|
|
|
width, height := f.term.Size()
|
|
var idx int
|
|
if len(f.state.matched) == 0 {
|
|
idx = -1
|
|
} else {
|
|
idx = f.state.matched[f.state.y].Idx
|
|
}
|
|
|
|
iter := ansisgr.NewIterator(f.opt.previewFunc(idx, width, height))
|
|
|
|
// top line
|
|
for i := width / 2; i < width; i++ {
|
|
var r rune
|
|
switch {
|
|
case i == width/2:
|
|
r = '┌'
|
|
case i == width-1:
|
|
r = '┐'
|
|
default:
|
|
r = '─'
|
|
}
|
|
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorBlack).
|
|
Background(tcell.ColorDefault)
|
|
|
|
f.term.SetContent(i, 0, r, nil, style)
|
|
}
|
|
// bottom line
|
|
for i := width / 2; i < width; i++ {
|
|
var r rune
|
|
switch {
|
|
case i == width/2:
|
|
r = '└'
|
|
case i == width-1:
|
|
r = '┘'
|
|
default:
|
|
r = '─'
|
|
}
|
|
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorBlack).
|
|
Background(tcell.ColorDefault)
|
|
|
|
f.term.SetContent(i, height-1, r, nil, style)
|
|
}
|
|
// Start with h=1 to exclude each corner rune.
|
|
const vline = '│'
|
|
var wvline = runewidth.RuneWidth(vline)
|
|
for h := 1; h < height-1; h++ {
|
|
// donePreviewLine indicates the preview string of the current line identified by h is already drawn.
|
|
var donePreviewLine bool
|
|
w := width / 2
|
|
for i := width / 2; i < width; i++ {
|
|
switch {
|
|
// Left vertical line.
|
|
case i == width/2:
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorBlack).
|
|
Background(tcell.ColorDefault)
|
|
f.term.SetContent(i, h, vline, nil, style)
|
|
w += wvline
|
|
// Right vertical line.
|
|
case i == width-1:
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorBlack).
|
|
Background(tcell.ColorDefault)
|
|
f.term.SetContent(i, h, vline, nil, style)
|
|
w += wvline
|
|
// Spaces between left and right vertical lines.
|
|
case w == width/2+wvline, w == width-1-wvline:
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorDefault).
|
|
Background(tcell.ColorDefault)
|
|
|
|
f.term.SetContent(w, h, ' ', nil, style)
|
|
w++
|
|
default: // Preview text
|
|
if donePreviewLine {
|
|
continue
|
|
}
|
|
|
|
r, rstyle, ok := iter.Next()
|
|
if !ok || r == '\n' {
|
|
// Consumed all preview characters.
|
|
donePreviewLine = true
|
|
continue
|
|
}
|
|
|
|
rw := runewidth.RuneWidth(r)
|
|
if w+rw > width-1-2 {
|
|
donePreviewLine = true
|
|
|
|
// Discard the rest of the current line.
|
|
consumeIterator(iter, '\n')
|
|
|
|
style := tcell.StyleDefault.
|
|
Foreground(tcell.ColorDefault).
|
|
Background(tcell.ColorDefault)
|
|
|
|
f.term.SetContent(w, h, '.', nil, style)
|
|
f.term.SetContent(w+1, h, '.', nil, style)
|
|
|
|
w += 2
|
|
continue
|
|
}
|
|
|
|
style := tcell.StyleDefault
|
|
if color, ok := rstyle.Foreground(); ok {
|
|
switch color.Mode() {
|
|
case ansisgr.Mode16:
|
|
style = style.Foreground(tcell.PaletteColor(color.Value() - 30))
|
|
case ansisgr.Mode256:
|
|
style = style.Foreground(tcell.PaletteColor(color.Value()))
|
|
case ansisgr.ModeRGB:
|
|
r, g, b := color.RGB()
|
|
style = style.Foreground(tcell.NewRGBColor(int32(r), int32(g), int32(b)))
|
|
}
|
|
}
|
|
if color, valid := rstyle.Background(); valid {
|
|
switch color.Mode() {
|
|
case ansisgr.Mode16:
|
|
style = style.Background(tcell.PaletteColor(color.Value() - 40))
|
|
case ansisgr.Mode256:
|
|
style = style.Background(tcell.PaletteColor(color.Value()))
|
|
case ansisgr.ModeRGB:
|
|
r, g, b := color.RGB()
|
|
style = style.Background(tcell.NewRGBColor(int32(r), int32(g), int32(b)))
|
|
}
|
|
}
|
|
|
|
style = style.
|
|
Bold(rstyle.Bold()).
|
|
Dim(rstyle.Dim()).
|
|
Italic(rstyle.Italic()).
|
|
Underline(rstyle.Underline()).
|
|
Blink(rstyle.Blink()).
|
|
Reverse(rstyle.Reverse()).
|
|
StrikeThrough(rstyle.Strikethrough())
|
|
f.term.SetContent(w, h, r, nil, style)
|
|
w += rw
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *finder) draw(d time.Duration) {
|
|
f.stateMu.RLock()
|
|
defer f.stateMu.RUnlock()
|
|
|
|
if isInTesting() {
|
|
// Don't use goroutine scheduling.
|
|
f._draw()
|
|
f._drawPreview()
|
|
f.term.Show()
|
|
} else {
|
|
f.drawTimer.Reset(d)
|
|
}
|
|
}
|
|
|
|
// readKey reads a key input.
|
|
// It returns ErrAbort if esc, CTRL-C or CTRL-D keys are inputted,
|
|
// errEntered in case of enter key, and a context error when the passed
|
|
// context is cancelled.
|
|
func (f *finder) readKey(ctx context.Context) error {
|
|
f.stateMu.RLock()
|
|
prevInputLen := len(f.state.input)
|
|
f.stateMu.RUnlock()
|
|
defer func() {
|
|
f.stateMu.RLock()
|
|
currentInputLen := len(f.state.input)
|
|
f.stateMu.RUnlock()
|
|
if prevInputLen != currentInputLen {
|
|
f.eventCh <- struct{}{}
|
|
}
|
|
}()
|
|
|
|
var e tcell.Event
|
|
|
|
select {
|
|
case ee := <-f.termEventsChan:
|
|
e = ee
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
|
|
f.stateMu.Lock()
|
|
defer f.stateMu.Unlock()
|
|
|
|
_, screenHeight := f.term.Size()
|
|
matchedLinesCount := len(f.state.matched)
|
|
|
|
// Max number of lines to scroll by using PgUp and PgDn
|
|
var pageScrollBy = screenHeight - 3
|
|
|
|
switch e := e.(type) {
|
|
case *tcell.EventKey:
|
|
switch e.Key() {
|
|
case tcell.KeyEsc, tcell.KeyCtrlC, tcell.KeyCtrlD:
|
|
return ErrAbort
|
|
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
|
if len(f.state.input) == 0 {
|
|
return nil
|
|
}
|
|
if f.state.x == 0 {
|
|
return nil
|
|
}
|
|
x := f.state.x
|
|
f.state.cursorX -= runewidth.RuneWidth(f.state.input[x-1])
|
|
f.state.x--
|
|
f.state.input = append(f.state.input[:x-1], f.state.input[x:]...)
|
|
case tcell.KeyDelete:
|
|
if f.state.x == len(f.state.input) {
|
|
return nil
|
|
}
|
|
x := f.state.x
|
|
|
|
f.state.input = append(f.state.input[:x], f.state.input[x+1:]...)
|
|
case tcell.KeyEnter:
|
|
return errEntered
|
|
case tcell.KeyLeft, tcell.KeyCtrlB:
|
|
if f.state.x > 0 {
|
|
f.state.cursorX -= runewidth.RuneWidth(f.state.input[f.state.x-1])
|
|
f.state.x--
|
|
}
|
|
case tcell.KeyRight, tcell.KeyCtrlF:
|
|
if f.state.x < len(f.state.input) {
|
|
f.state.cursorX += runewidth.RuneWidth(f.state.input[f.state.x])
|
|
f.state.x++
|
|
}
|
|
case tcell.KeyCtrlA, tcell.KeyHome:
|
|
f.state.cursorX = 0
|
|
f.state.x = 0
|
|
case tcell.KeyCtrlE, tcell.KeyEnd:
|
|
f.state.cursorX = runewidth.StringWidth(string(f.state.input))
|
|
f.state.x = len(f.state.input)
|
|
case tcell.KeyCtrlW:
|
|
in := f.state.input[:f.state.x]
|
|
inStr := string(in)
|
|
pos := strings.LastIndex(strings.TrimRightFunc(inStr, unicode.IsSpace), " ")
|
|
if pos == -1 {
|
|
f.state.input = []rune{}
|
|
f.state.cursorX = 0
|
|
f.state.x = 0
|
|
return nil
|
|
}
|
|
pos = utf8.RuneCountInString(inStr[:pos])
|
|
newIn := f.state.input[:pos+1]
|
|
f.state.input = newIn
|
|
f.state.cursorX = runewidth.StringWidth(string(newIn))
|
|
f.state.x = len(newIn)
|
|
case tcell.KeyCtrlU:
|
|
f.state.input = f.state.input[f.state.x:]
|
|
f.state.cursorX = 0
|
|
f.state.x = 0
|
|
case tcell.KeyUp, tcell.KeyCtrlK, tcell.KeyCtrlP:
|
|
if f.state.y+1 < matchedLinesCount {
|
|
f.state.y++
|
|
}
|
|
if f.state.cursorY+1 < min(matchedLinesCount, screenHeight-2) {
|
|
f.state.cursorY++
|
|
}
|
|
case tcell.KeyDown, tcell.KeyCtrlJ, tcell.KeyCtrlN:
|
|
if f.state.y > 0 {
|
|
f.state.y--
|
|
}
|
|
if f.state.cursorY-1 >= 0 {
|
|
f.state.cursorY--
|
|
}
|
|
case tcell.KeyPgUp:
|
|
f.state.y += min(pageScrollBy, matchedLinesCount-1-f.state.y)
|
|
maxCursorY := min(screenHeight-3, matchedLinesCount-1)
|
|
f.state.cursorY += min(pageScrollBy, maxCursorY-f.state.cursorY)
|
|
case tcell.KeyPgDn:
|
|
f.state.y -= min(pageScrollBy, f.state.y)
|
|
f.state.cursorY -= min(pageScrollBy, f.state.cursorY)
|
|
case tcell.KeyTab:
|
|
if !f.opt.multi {
|
|
return nil
|
|
}
|
|
idx := f.state.matched[f.state.y].Idx
|
|
if _, ok := f.state.selection[idx]; ok {
|
|
delete(f.state.selection, idx)
|
|
} else {
|
|
f.state.selection[idx] = f.state.selectionIdx
|
|
f.state.selectionIdx++
|
|
}
|
|
if f.state.y > 0 {
|
|
f.state.y--
|
|
}
|
|
if f.state.cursorY > 0 {
|
|
f.state.cursorY--
|
|
}
|
|
default:
|
|
if e.Rune() != 0 {
|
|
width, _ := f.term.Size()
|
|
maxLineWidth := width - 2 - 1
|
|
if len(f.state.input)+1 > maxLineWidth {
|
|
// Discard inputted rune.
|
|
return nil
|
|
}
|
|
|
|
x := f.state.x
|
|
f.state.input = append(f.state.input[:x], append([]rune{e.Rune()}, f.state.input[x:]...)...)
|
|
f.state.cursorX += runewidth.RuneWidth(e.Rune())
|
|
f.state.x++
|
|
}
|
|
}
|
|
case *tcell.EventResize:
|
|
f.term.Clear()
|
|
|
|
width, height := f.term.Size()
|
|
itemAreaHeight := height - 2 - 1
|
|
if itemAreaHeight >= 0 && f.state.cursorY > itemAreaHeight {
|
|
f.state.cursorY = itemAreaHeight
|
|
}
|
|
|
|
maxLineWidth := width - 2 - 1
|
|
if maxLineWidth < 0 {
|
|
f.state.input = nil
|
|
f.state.cursorX = 0
|
|
f.state.x = 0
|
|
} else if len(f.state.input)+1 > maxLineWidth {
|
|
// Discard inputted rune.
|
|
f.state.input = f.state.input[:maxLineWidth]
|
|
f.state.cursorX = runewidth.StringWidth(string(f.state.input))
|
|
f.state.x = maxLineWidth
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *finder) filter() {
|
|
f.stateMu.RLock()
|
|
if len(f.state.input) == 0 {
|
|
f.stateMu.RUnlock()
|
|
f.stateMu.Lock()
|
|
defer f.stateMu.Unlock()
|
|
f.state.matched = f.state.allMatched
|
|
return
|
|
}
|
|
|
|
// TODO: If input is not delete operation, it is able to
|
|
// reduce total iteration.
|
|
// FindAll may take a lot of time, so it is desired to use RLock to avoid goroutine blocking.
|
|
matchedItems := matching.FindAll(string(f.state.input), f.state.items, matching.WithMode(matching.Mode(f.opt.mode)))
|
|
f.stateMu.RUnlock()
|
|
|
|
f.stateMu.Lock()
|
|
defer f.stateMu.Unlock()
|
|
f.state.matched = matchedItems
|
|
if len(f.state.matched) == 0 {
|
|
f.state.cursorY = 0
|
|
f.state.y = 0
|
|
return
|
|
}
|
|
|
|
// If we are in single-select mode, try to move cursor to the first preselected item
|
|
// that's still in the matched results
|
|
if !f.opt.multi {
|
|
for i, m := range f.state.matched {
|
|
if f.opt.preselected(m.Idx) {
|
|
f.state.y = i
|
|
f.state.cursorY = min(i, len(f.state.matched)-1)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case f.state.cursorY >= len(f.state.matched):
|
|
f.state.cursorY = len(f.state.matched) - 1
|
|
f.state.y = len(f.state.matched) - 1
|
|
case f.state.y >= len(f.state.matched):
|
|
f.state.y = len(f.state.matched) - 1
|
|
}
|
|
}
|
|
|
|
func (f *finder) find(slice interface{}, itemFunc func(i int) string, opts []Option) ([]int, error) {
|
|
if itemFunc == nil {
|
|
return nil, errors.New("itemFunc must not be nil")
|
|
}
|
|
|
|
opt := defaultOption
|
|
for _, o := range opts {
|
|
o(&opt)
|
|
}
|
|
|
|
rv := reflect.ValueOf(slice)
|
|
if opt.hotReload && (rv.Kind() != reflect.Ptr || reflect.Indirect(rv).Kind() != reflect.Slice) {
|
|
return nil, errors.Errorf("the first argument must be a pointer to a slice, but got %T", slice)
|
|
} else if !opt.hotReload && rv.Kind() != reflect.Slice {
|
|
return nil, errors.Errorf("the first argument must be a slice, but got %T", slice)
|
|
}
|
|
|
|
makeItems := func(sliceLen int) ([]string, []matching.Matched) {
|
|
items := make([]string, sliceLen)
|
|
matched := make([]matching.Matched, sliceLen)
|
|
for i := 0; i < sliceLen; i++ {
|
|
items[i] = itemFunc(i)
|
|
matched[i] = matching.Matched{Idx: i} //nolint:exhaustivestruct
|
|
}
|
|
return items, matched
|
|
}
|
|
|
|
var (
|
|
items []string
|
|
matched []matching.Matched
|
|
)
|
|
|
|
var parentContext context.Context
|
|
if opt.context != nil {
|
|
parentContext = opt.context
|
|
} else {
|
|
parentContext = context.Background()
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(parentContext)
|
|
defer cancel()
|
|
|
|
inited := make(chan struct{})
|
|
if opt.hotReload && rv.Kind() == reflect.Ptr {
|
|
opt.hotReloadLock.Lock()
|
|
rvv := reflect.Indirect(rv)
|
|
items, matched = makeItems(rvv.Len())
|
|
opt.hotReloadLock.Unlock()
|
|
|
|
go func() {
|
|
<-inited
|
|
|
|
var prev int
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(30 * time.Millisecond):
|
|
opt.hotReloadLock.Lock()
|
|
curr := rvv.Len()
|
|
if prev != curr {
|
|
items, matched = makeItems(curr)
|
|
f.updateItems(items, matched)
|
|
}
|
|
opt.hotReloadLock.Unlock()
|
|
prev = curr
|
|
}
|
|
}
|
|
}()
|
|
} else {
|
|
items, matched = makeItems(rv.Len())
|
|
}
|
|
|
|
if err := f.initFinder(items, matched, opt); err != nil {
|
|
return nil, errors.Wrap(err, "failed to initialize the fuzzy finder")
|
|
}
|
|
|
|
if !isInTesting() {
|
|
defer f.term.Fini()
|
|
}
|
|
|
|
close(inited)
|
|
|
|
if opt.selectOne && len(f.state.matched) == 1 {
|
|
return []int{f.state.matched[0].Idx}, nil
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-f.eventCh:
|
|
f.filter()
|
|
f.draw(0)
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
f.draw(10 * time.Millisecond)
|
|
|
|
err := f.readKey(ctx)
|
|
// hack for earning time to filter exec
|
|
if isInTesting() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
switch {
|
|
case errors.Is(err, ErrAbort):
|
|
return nil, ErrAbort
|
|
case errors.Is(err, errEntered):
|
|
f.stateMu.RLock()
|
|
defer f.stateMu.RUnlock()
|
|
|
|
if len(f.state.matched) == 0 {
|
|
return nil, ErrAbort
|
|
}
|
|
if f.opt.multi {
|
|
if len(f.state.selection) == 0 {
|
|
return []int{f.state.matched[f.state.y].Idx}, nil
|
|
}
|
|
poss, idxs := make([]int, 0, len(f.state.selection)), make([]int, 0, len(f.state.selection))
|
|
for idx, pos := range f.state.selection {
|
|
idxs = append(idxs, idx)
|
|
poss = append(poss, pos)
|
|
}
|
|
sort.Slice(idxs, func(i, j int) bool {
|
|
return poss[i] < poss[j]
|
|
})
|
|
return idxs, nil
|
|
}
|
|
return []int{f.state.matched[f.state.y].Idx}, nil
|
|
case err != nil:
|
|
return nil, errors.Wrap(err, "failed to read a key")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find displays a UI that provides fuzzy finding against the provided slice.
|
|
// The argument slice must be of a slice type. If not, Find returns
|
|
// an error. itemFunc is called by the length of slice. previewFunc is called
|
|
// when the cursor which points to the currently selected item is changed.
|
|
// If itemFunc is nil, Find returns an error.
|
|
//
|
|
// itemFunc receives an argument i, which is the index of the item currently
|
|
// selected.
|
|
//
|
|
// Find returns ErrAbort if a call to Find is finished with no selection.
|
|
func Find(slice interface{}, itemFunc func(i int) string, opts ...Option) (int, error) {
|
|
f := newFinder()
|
|
return f.Find(slice, itemFunc, opts...)
|
|
}
|
|
|
|
func (f *finder) Find(slice interface{}, itemFunc func(i int) string, opts ...Option) (int, error) {
|
|
res, err := f.find(slice, itemFunc, opts)
|
|
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res[0], err
|
|
}
|
|
|
|
// FindMulti is nearly the same as Find. The only difference from Find is that
|
|
// the user can select multiple items at once, by using the tab key.
|
|
func FindMulti(slice interface{}, itemFunc func(i int) string, opts ...Option) ([]int, error) {
|
|
f := newFinder()
|
|
return f.FindMulti(slice, itemFunc, opts...)
|
|
}
|
|
|
|
func (f *finder) FindMulti(slice interface{}, itemFunc func(i int) string, opts ...Option) ([]int, error) {
|
|
opts = append(opts, withMulti())
|
|
res, err := f.find(slice, itemFunc, opts)
|
|
return res, err
|
|
}
|
|
|
|
func isInTesting() bool {
|
|
return flag.Lookup("test.v") != nil
|
|
}
|
|
|
|
func consumeIterator(iter *ansisgr.Iterator, r rune) {
|
|
for {
|
|
r, _, ok := iter.Next()
|
|
if !ok || r == '\n' {
|
|
return
|
|
}
|
|
}
|
|
}
|