// 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 } } }