add my own session manager

This commit is contained in:
2026-01-24 11:17:24 +01:00
parent f12acdaf4f
commit eddff54729
599 changed files with 292374 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
coverage.txt
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 ktr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+116
View File
@@ -0,0 +1,116 @@
# go-ansisgr
[![PkgGoDev](https://pkg.go.dev/badge/github.com/ktr0731/go-ansisgr)](https://pkg.go.dev/github.com/ktr0731/go-ansisgr)
[![GitHub Actions](https://github.com/ktr0731/go-ansisgr/workflows/main/badge.svg)](https://github.com/ktr0731/go-ansisgr/actions)
[![codecov](https://codecov.io/gh/ktr0731/go-ansisgr/branch/master/graph/badge.svg?token=6IHRfCBs7K)](https://codecov.io/gh/ktr0731/go-ansisgr)
`go-ansisgr` provides a SGR (Select Graphic Rendition, a part of ANSI Escape Sequence) parser.
- 16 colors, 256 colors and RGB colors support
- All attributes support
## Installation
``` bash
go get github.com/ktr0731/go-fuzzyfinder
```
## Usage
`ansisgr.NewIterator` is the only entry-point API. This function returns an iteratorw which consumes the passed string.
``` go
in := "a\x1b[1;31mb"
iter := ansisgr.NewIterator(in)
for {
r, style, ok := iter.Next()
if !ok {
break
}
// do something.
}
```
`r` is a rune, and `style` is the foreground/background color and attributes `r` has.
``` go
if color, ok := style.Foreground(); ok {
// Foreground color is specified.
}
if color, ok := style.Background(); ok {
// Background color is specified.
}
```
The attribute method reports whether `r` has the attribute.
``` go
style.Bold()
style.Italic()
```
## go-ansisgr with gdamore/tcell
`go-ansisgr` is useful when you construct a rich terminal user interface by using `gdamore/tcell` or others. Although `gdamore/tcell` and other libraries provides SGR functionality from their API, they doesn't support "raw" strings which contain ANSI Escape Sequence. Therefore, `go-ansisgr` translates these strings and makes it easy to use the TUI library's functionality.
For example, the following code displays colored string powered by `gdamore/tcell`.
```go
func main() {
screen, err := tcell.NewScreen()
if err != nil {
log.Fatal(err)
}
defer screen.Fini()
if err := screen.Init(); err != nil {
log.Fatal(err)
}
in := "\x1b[38;2;100;200;200mhello, \x1b[0;1;30;48;5;245mworld!"
iter := ansisgr.NewIterator(in)
for i := 0; ; i++ {
r, rstyle, ok := iter.Next()
if !ok {
break
}
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())
screen.SetContent(i, 0, r, nil, style)
}
screen.Show()
time.Sleep(3 * time.Second)
}
```
+296
View File
@@ -0,0 +1,296 @@
// go-ansisgr provides an SGR (Select Graphic Rendition, a part of ANSI Escape Sequence) parser.
package ansisgr
import (
"unicode"
)
const (
attrReset = 0
attrBold = 1 << iota
attrDim
attrItalic
attrUnderline
attrBlink
attrReverse
attrInvisible
attrStrikethrough
)
const (
colorValid = 1 << (24 + iota)
colorIs16
colorIs256
colorIsRGB
)
// Style represents a set of attributes, foreground color, and background color.
type Style struct {
attr int
foreground int
background int
}
// Mode represents a color syntax.
type Mode int
const (
// ModeNone is used to when color is not specified.
ModeNone Mode = iota
// Mode16 represents 16 colors.
Mode16
// Mode256 represents 256 colors.
Mode256
// ModeRGB represents RGB colors (a.k.a. True Color).
ModeRGB
)
// Color represents color value specified by SGR.
type Color struct{ v int }
// Mode returns the color's Mode.
func (c Color) Mode() Mode {
switch {
case c.v&colorIs16 == colorIs16:
return Mode16
case c.v&colorIs256 == colorIs256:
return Mode256
case c.v&colorIsRGB == colorIsRGB:
return ModeRGB
}
return ModeNone
}
// RGB returns red, green, and blue color values. It assumes that c.Mode() == ModeRGB.
func (c Color) RGB() (int, int, int) {
return 0xff0000 & c.v >> 16, 0x00ff00 & c.v >> 8, 0x0000ff & c.v
}
// Value returns the color value represented by int.
// For example, '\x1b[31m' is 31, '\x1b[38;5;116m' is 116, and '\x1b[38;2;10;20;30' is 660510 (0x0a141e).
func (c Color) Value() int { return c.v & 0xffffff }
// Foreground returns the foreground color. the second return value indicates whether the color is valid or not.
func (s *Style) Foreground() (Color, bool) {
return Color{v: s.foreground}, s.foreground&colorValid == colorValid
}
// Background returns the background color. the second return value indicates whether the color is valid or not.
func (s *Style) Background() (Color, bool) {
return Color{v: s.background}, s.background&colorValid == colorValid
}
// Bold indicates whether bold is enabled.
func (s *Style) Bold() bool { return s.attr&attrBold == attrBold }
// Dim indicates whether dim (faint) is enabled.
func (s *Style) Dim() bool { return s.attr&attrDim == attrDim }
// Italic indicates whether italic is enabled.
func (s *Style) Italic() bool { return s.attr&attrItalic == attrItalic }
// Underline indicates whether underline is enabled.
func (s *Style) Underline() bool { return s.attr&attrUnderline == attrUnderline }
// Blink indicates whether blink is enabled.
func (s *Style) Blink() bool { return s.attr&attrBlink == attrBlink }
// Reverse indicates whether reverse (hidden) is enabled.
func (s *Style) Reverse() bool { return s.attr&attrReverse == attrReverse }
// Invisible indicates whether invisible is enabled.
func (s *Style) Invisible() bool { return s.attr&attrInvisible == attrInvisible }
// Strikethrough indicates whether strikethrough is enabled.
func (s *Style) Strikethrough() bool { return s.attr&attrStrikethrough == attrStrikethrough }
// Iterator is an iterator over a parsed string.
type Iterator struct {
runes []rune
i int
style Style
}
// NewIterator returns a new Iterator.
func NewIterator(s string) *Iterator {
return &Iterator{i: -1, runes: []rune(s)}
}
// Next returns a next rune and its style. the third return value indicates there is a next value.
func (a *Iterator) Next() (rune, Style, bool) {
for a.next() {
r := a.runes[a.i]
if r != 0x1b {
return r, a.style, true
}
if ok := a.next(); ok && a.runes[a.i] != '[' {
continue
}
args := a.consumeSequence()
LOOP:
for len(args) != 0 {
if args[0] == 38 {
offset, consumed := consumeAs256OrRGB(args)
args = args[offset:]
if consumed.valid {
a.style.foreground = consumed.v
continue
}
} else if args[0] == 48 {
offset, consumed := consumeAs256OrRGB(args)
args = args[offset:]
if consumed.valid {
a.style.background = consumed.v
continue
}
}
for _, arg := range args {
switch arg {
case 38, 48:
if len(args) != 1 {
continue LOOP
}
// Ignore when the arg is the last element.
case 0:
a.style.attr = attrReset
a.style.foreground = 0 | colorIs16
a.style.background = 0 | colorIs16
case 39:
a.style.foreground = 0 | colorIs16
case 49:
a.style.background = 0 | colorIs16
case 1:
a.style.attr |= attrBold
case 2:
a.style.attr |= attrDim
case 3:
a.style.attr |= attrItalic
case 4:
a.style.attr |= attrUnderline
case 5:
a.style.attr |= attrBlink
case 7:
a.style.attr |= attrReverse
case 8:
a.style.attr |= attrInvisible
case 9:
a.style.attr |= attrStrikethrough
case 22:
if a.style.attr&attrBold == attrBold {
a.style.attr ^= attrBold
}
if a.style.attr&attrDim == attrDim {
a.style.attr ^= attrDim
}
case 23:
a.style.attr ^= attrItalic
case 24:
a.style.attr ^= attrUnderline
case 25:
a.style.attr ^= attrBlink
case 27:
a.style.attr ^= attrReverse
case 28:
a.style.attr ^= attrInvisible
case 29:
a.style.attr ^= attrStrikethrough
default:
switch {
case (arg >= 30 && arg <= 37):
a.style.foreground = arg | colorIs16 | colorValid
case arg >= 40 && arg <= 47:
a.style.background = arg | colorIs16 | colorValid
case arg >= 90 && arg <= 97:
a.style.foreground = arg | colorIs16 | colorValid
case arg >= 100 && arg <= 107:
a.style.background = arg | colorIs16 | colorValid
}
}
args = args[1:]
}
}
}
return 0, Style{}, false
}
func (a *Iterator) next() bool {
a.i++
return a.i < len(a.runes)
}
func (a *Iterator) consumeSequence() []int {
var args []int
var val int
for a.next() {
switch a.runes[a.i] {
case 'm':
return append(args, val)
case ';':
args = append(args, val)
val = 0
default:
if !unicode.IsDigit(a.runes[a.i]) {
return nil // Broken sequence, ignore
}
val = val*10 + int(a.runes[a.i]-'0')
}
}
return nil
}
type consumedValue struct {
v int
valid bool
}
func consumeAs256OrRGB(args []int) (offset int, v consumedValue) {
if len(args) < 2 || (args[0] != 38 && args[0] != 48) {
return 0, v
}
switch args[1] {
case 5: // 256
if l := len(args); l < 3 {
return l, v
}
if args[2] > 255 {
return 3, v
}
return 3, consumedValue{
v: args[2] | colorValid | colorIs256,
valid: true,
}
case 2: // RGB
if l := len(args); l < 5 {
return l, v
}
var val int
for i := 0; i < 3; i++ {
if args[i+2] > 255 {
return i + 2 + 1, v
}
val |= args[i+2] << (8 * (2 - i))
}
return 5, consumedValue{
v: val | colorValid | colorIsRGB,
valid: true,
}
}
return 2, v
}
+19
View File
@@ -0,0 +1,19 @@
### https://raw.github.com/github/gitignore/38b189d3b9ea5a14f7177db78d13e2e1cc0e0092/Go.gitignore
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
coverage.txt
fuzz.out
_tools
.idea
+2
View File
@@ -0,0 +1,2 @@
builds:
- skip: true
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019-2021 ktr0731
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+44
View File
@@ -0,0 +1,44 @@
SHELL := /bin/bash
export GOBIN := $(PWD)/_tools
export PATH := $(GOBIN):$(PATH)
export GO111MODULE := on
.PHONY: generate
generate:
go generate ./...
.PHONY: tools
tools:
@cat tools/tools.go | grep -E '^\s*_\s.*' | awk '{ print $$2 }' | xargs go install
.PHONY: build
build:
go build ./...
.PHONY: test
test: format unit-test credits
.PHONY: format
format:
go mod tidy
.PHONY: credits
credits:
gocredits -skip-missing . > CREDITS
.PHONY: unit-test
unit-test: lint
go test -race ./...
.PHONY: lint
lint:
go vet ./...
.PHONY: coverage
coverage:
DEBUG=true go test -coverpkg ./... -covermode=atomic -coverprofile=coverage.txt -race $(shell go list ./...)
.PHONY: coverage-web
coverage-web: coverage
go tool cover -html=coverage.txt
+115
View File
@@ -0,0 +1,115 @@
# go-fuzzyfinder
[![PkgGoDev](https://pkg.go.dev/badge/github.com/ktr0731/go-fuzzyfinder)](https://pkg.go.dev/github.com/ktr0731/go-fuzzyfinder)
[![GitHub Actions](https://github.com/ktr0731/go-fuzzyfinder/workflows/main/badge.svg)](https://github.com/ktr0731/go-fuzzyfinder/actions)
[![codecov](https://codecov.io/gh/ktr0731/go-fuzzyfinder/branch/master/graph/badge.svg?token=RvpSTKDJGO)](https://codecov.io/gh/ktr0731/go-fuzzyfinder)
`go-fuzzyfinder` is a Go library that provides fuzzy-finding with an fzf-like terminal user interface.
![](https://user-images.githubusercontent.com/12953836/52424222-e1edc900-2b3c-11e9-8158-8e193844252a.png)
## Installation
``` bash
$ go get github.com/ktr0731/go-fuzzyfinder
```
## Usage
`go-fuzzyfinder` provides two functions, `Find` and `FindMulti`.
`FindMulti` can select multiple lines. It is similar to `fzf -m`.
This is [an example](//github.com/ktr0731/go-fuzzyfinder/blob/master/example/track/main.go) of `FindMulti`.
``` go
type Track struct {
Name string
AlbumName string
Artist string
}
var tracks = []Track{
{"foo", "album1", "artist1"},
{"bar", "album1", "artist1"},
{"foo", "album2", "artist1"},
{"baz", "album2", "artist2"},
{"baz", "album3", "artist2"},
}
func main() {
idx, err := fuzzyfinder.FindMulti(
tracks,
func(i int) string {
return tracks[i].Name
},
fuzzyfinder.WithPreviewWindow(func(i, w, h int) string {
if i == -1 {
return ""
}
return fmt.Sprintf("Track: %s (%s)\nAlbum: %s",
tracks[i].Name,
tracks[i].Artist,
tracks[i].AlbumName)
}))
if err != nil {
log.Fatal(err)
}
fmt.Printf("selected: %v\n", idx)
}
```
The execution result prints selected item's indexes.
### Preselecting items
You can preselect items using the `WithPreselected` option. It works in both `Find` and `FindMulti`.
``` go
// Single selection mode
// The cursor will be positioned on the first item that matches the predicate
idx, err := fuzzyfinder.Find(
tracks,
func(i int) string {
return tracks[i].Name
},
fuzzyfinder.WithPreselected(func(i int) bool {
return tracks[i].Name == "bar"
}),
)
// Multi selection mode
// All items that match the predicate will be selected initially
idxs, err := fuzzyfinder.FindMulti(
tracks,
func(i int) string {
return tracks[i].Name
},
fuzzyfinder.WithPreselected(func(i int) bool {
return tracks[i].Artist == "artist2"
}),
)
```
## Motivation
Fuzzy-finder command-line tools such that
[fzf](https://github.com/junegunn/fzf), [fzy](https://github.com/jhawthorn/fzy), or [skim](https://github.com/lotabout/skim)
are very powerful to find out specified lines interactively.
However, there are limits to deal with fuzzy-finder's features in several cases.
First, it is hard to distinguish between two or more entities that have the same text.
In the example of ktr0731/itunes-cli, it is possible to conflict tracks such that same track names, but different artists.
To avoid such conflicts, we have to display the artist names with each track name.
It seems like the problem has been solved, but it still has the problem.
It is possible to conflict in case of same track names, same artists, but other albums, which each track belongs to.
This problem is difficult to solve because pipes and filters are row-based mechanisms, there are no ways to hold references that point list entities.
The second issue occurs in the case of incorporating a fuzzy-finder as one of the main features in a command-line tool such that [enhancd](https://github.com/b4b4r07/enhancd) or [itunes-cli](https://github.com/ktr0731/itunes-cli).
Usually, these tools require that it has been installed one fuzzy-finder as a precondition.
In addition, to deal with the fuzzy-finder, an environment variable configuration such that `export TOOL_NAME_FINDER=fzf` is required.
It is a bother and complicated.
`go-fuzzyfinder` resolves above issues.
Dealing with the first issue, `go-fuzzyfinder` provides the preview-window feature (See an example in [Usage](#usage)).
Also, by using `go-fuzzyfinder`, built tools don't require any fuzzy-finders.
## See Also
- [Fuzzy-finder as a Go library](https://medium.com/@ktr0731/fuzzy-finder-as-a-go-library-590b7458200f)
- [(Japanese) fzf ライクな fuzzy-finder を提供する Go ライブラリを書いた](https://syfm.hatenablog.com/entry/2019/02/09/120000)
+32
View File
@@ -0,0 +1,32 @@
codecov:
notify:
require_ci_to_pass: yes
coverage:
precision: 2
round: down
range: "70...100"
status:
changes: no
patch: no
project:
default:
target: 85
parsers:
gcov:
branch_detection:
conditional: yes
loop: yes
method: no
macro: no
comment:
layout: "header, diff"
behavior: default
require_changes: no
ignore:
- tcell.go
- mock.go
+906
View File
@@ -0,0 +1,906 @@
// 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
}
}
}
+102
View File
@@ -0,0 +1,102 @@
// Package matching provides matching features that find appropriate strings
// by using a passed input string.
package matching
import (
"sort"
"strings"
"unicode"
"github.com/ktr0731/go-fuzzyfinder/scoring"
)
// Matched represents a result of FindAll.
type Matched struct {
// Idx is the index of an item of the original slice which was used to
// search matched strings.
Idx int
// Pos is the range of matched position.
// [2]int represents an open interval of a position.
Pos [2]int
// score is the value that indicates how it similar to the input string.
// The bigger score, the more similar it is.
score int
}
// Option represents available matching options.
type Option func(*opt)
type Mode int
const (
ModeSmart Mode = iota
ModeCaseSensitive
ModeCaseInsensitive
)
// opt represents available options and its default values.
type opt struct {
mode Mode
}
// WithMode specifies a matching mode. The default mode is ModeSmart.
func WithMode(m Mode) Option {
return func(o *opt) {
o.mode = m
}
}
// FindAll tries to find out sub-strings from slice that match the passed argument in.
// The returned slice is sorted by similarity scores in descending order.
func FindAll(in string, slice []string, opts ...Option) []Matched {
var opt opt
for _, o := range opts {
o(&opt)
}
m := match(in, slice, opt)
sort.Slice(m, func(i, j int) bool {
if m[i].score == m[j].score {
return m[i].Idx > m[j].Idx
}
return m[i].score > m[j].score
})
return m
}
// match iterates each string of slice for check whether it is matched to the input string.
func match(input string, slice []string, opt opt) (res []Matched) {
if opt.mode == ModeSmart {
// Find an upper-case rune
n := strings.IndexFunc(input, unicode.IsUpper)
if n == -1 {
opt.mode = ModeCaseInsensitive
input = strings.ToLower(input)
} else {
opt.mode = ModeCaseSensitive
}
}
in := []rune(input)
for idxOfSlice, s := range slice {
var idx int
if opt.mode == ModeCaseInsensitive {
s = strings.ToLower(s)
}
LINE_MATCHING:
for _, r := range s {
if r == in[idx] {
idx++
if idx == len(in) {
score, pos := scoring.Calculate(s, input)
res = append(res, Matched{
Idx: idxOfSlice,
Pos: pos,
score: score,
})
break LINE_MATCHING
}
}
}
}
return res
}
+404
View File
@@ -0,0 +1,404 @@
package fuzzyfinder
import (
"bytes"
"fmt"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
runewidth "github.com/mattn/go-runewidth"
"github.com/nsf/termbox-go"
)
type cell struct {
ch rune
bg, fg termbox.Attribute
}
type simScreen tcell.SimulationScreen
// TerminalMock is a mocked terminal for testing.
// Most users should use it by calling UseMockedTerminal.
type TerminalMock struct {
simScreen
sizeMu sync.RWMutex
width, height int
eventsMu sync.Mutex
events []termbox.Event
cellsMu sync.RWMutex
cells []*cell
resultMu sync.RWMutex
result string
sleepDuration time.Duration
v2 bool
}
// SetSize changes the pseudo-size of the window.
// Note that SetSize resets added cells.
func (m *TerminalMock) SetSize(w, h int) {
if m.v2 {
m.simScreen.SetSize(w, h)
return
}
m.sizeMu.Lock()
defer m.sizeMu.Unlock()
m.cellsMu.Lock()
defer m.cellsMu.Unlock()
m.width = w
m.height = h
m.cells = make([]*cell, w*h)
}
// Deprecated: Use SetEventsV2
// SetEvents sets all events, which are fetched by pollEvent.
// A user of this must set the EscKey event at the end.
func (m *TerminalMock) SetEvents(events ...termbox.Event) {
m.eventsMu.Lock()
defer m.eventsMu.Unlock()
m.events = events
}
// SetEventsV2 sets all events, which are fetched by pollEvent.
// A user of this must set the EscKey event at the end.
func (m *TerminalMock) SetEventsV2(events ...tcell.Event) {
for _, event := range events {
switch event := event.(type) {
case *tcell.EventKey:
ek := event
m.simScreen.InjectKey(ek.Key(), ek.Rune(), ek.Modifiers())
case *tcell.EventResize:
er := event
w, h := er.Size()
m.simScreen.SetSize(w, h)
}
}
}
// GetResult returns a flushed string that is displayed to the actual terminal.
// It contains all escape sequences such that ANSI escape code.
func (m *TerminalMock) GetResult() string {
if !m.v2 {
m.resultMu.RLock()
defer m.resultMu.RUnlock()
return m.result
}
var s string
// set cursor for snapshot test
setCursor := func() {
cursorX, cursorY, _ := m.simScreen.GetCursor()
mainc, _, _, _ := m.simScreen.GetContent(cursorX, cursorY)
if mainc == ' ' {
m.simScreen.SetContent(cursorX, cursorY, '\u2588', nil, tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorDefault))
} else {
m.simScreen.SetContent(cursorX, cursorY, mainc, nil, tcell.StyleDefault.Background(tcell.ColorWhite))
}
m.simScreen.Show()
}
setCursor()
m.resultMu.Lock()
cells, width, height := m.simScreen.GetContents()
for h := 0; h < height; h++ {
prevFg, prevBg := tcell.ColorDefault, tcell.ColorDefault
for w := 0; w < width; w++ {
cell := cells[h*width+w]
fg, bg, attr := cell.Style.Decompose()
if fg != prevFg || bg != prevBg {
prevFg, prevBg = fg, bg
s += "\x1b\x5b\x6d" // Reset previous color.
v := parseAttrV2(fg, bg, attr)
s += v
}
s += string(cell.Runes)
rw := runewidth.RuneWidth(cell.Runes[0])
if rw != 0 {
w += rw - 1
}
}
s += "\n"
}
s += "\x1b\x5b\x6d" // Reset previous color.
m.resultMu.Unlock()
return s
}
func (m *TerminalMock) init() error {
return nil
}
func (m *TerminalMock) size() (width int, height int) {
m.sizeMu.RLock()
defer m.sizeMu.RUnlock()
return m.width, m.height
}
func (m *TerminalMock) clear(fg termbox.Attribute, bg termbox.Attribute) error {
// TODO
return nil
}
func (m *TerminalMock) setCell(x int, y int, ch rune, fg termbox.Attribute, bg termbox.Attribute) {
m.sizeMu.RLock()
defer m.sizeMu.RUnlock()
m.cellsMu.Lock()
defer m.cellsMu.Unlock()
if x < 0 || x >= m.width {
return
}
if y < 0 || y >= m.height {
return
}
m.cells[y*m.width+x] = &cell{ch: ch, fg: fg, bg: bg}
}
func (m *TerminalMock) setCursor(x int, y int) {
m.sizeMu.RLock()
defer m.sizeMu.RUnlock()
m.cellsMu.Lock()
defer m.cellsMu.Unlock()
if x < 0 || x >= m.width {
return
}
if y < 0 || y >= m.height {
return
}
i := y*m.width + x
if m.cells[i] == nil {
m.cells[y*m.width+x] = &cell{ch: '\u2588', fg: termbox.ColorWhite, bg: termbox.ColorDefault}
} else {
// Cursor on a rune.
m.cells[y*m.width+x].bg = termbox.ColorWhite
}
}
func (m *TerminalMock) pollEvent() termbox.Event {
m.eventsMu.Lock()
defer m.eventsMu.Unlock()
if len(m.events) == 0 {
panic("pollEvent called with empty events. have you set expected events by SetEvents?")
}
e := m.events[0]
m.events = m.events[1:]
// Wait a moment for goroutine scheduling.
time.Sleep(m.sleepDuration)
return e
}
// flush displays all items with formatted layout.
func (m *TerminalMock) flush() {
m.cellsMu.RLock()
var s string
for j := 0; j < m.height; j++ {
prevFg, prevBg := termbox.ColorDefault, termbox.ColorDefault
for i := 0; i < m.width; i++ {
c := m.cells[j*m.width+i]
if c == nil {
s += " "
prevFg, prevBg = termbox.ColorDefault, termbox.ColorDefault
continue
} else {
var fgReset bool
if c.fg != prevFg {
s += "\x1b\x5b\x6d" // Reset previous color.
s += parseAttr(c.fg, true)
prevFg = c.fg
prevBg = termbox.ColorDefault
fgReset = true
}
if c.bg != prevBg {
if !fgReset {
s += "\x1b\x5b\x6d" // Reset previous color.
prevFg = termbox.ColorDefault
}
s += parseAttr(c.bg, false)
prevBg = c.bg
}
s += string(c.ch)
rw := runewidth.RuneWidth(c.ch)
if rw != 0 {
i += rw - 1
}
}
}
s += "\n"
}
s += "\x1b\x5b\x6d" // Reset previous color.
m.cellsMu.RUnlock()
m.cellsMu.Lock()
m.cells = make([]*cell, m.width*m.height)
m.cellsMu.Unlock()
m.resultMu.Lock()
defer m.resultMu.Unlock()
m.result = s
}
func (m *TerminalMock) close() {}
// UseMockedTerminal switches the terminal, which is used from
// this package to a mocked one.
func UseMockedTerminal() *TerminalMock {
f := newFinder()
return f.UseMockedTerminal()
}
// UseMockedTerminalV2 switches the terminal, which is used from
// this package to a mocked one.
func UseMockedTerminalV2() *TerminalMock {
f := newFinder()
return f.UseMockedTerminalV2()
}
func (f *finder) UseMockedTerminal() *TerminalMock {
m := &TerminalMock{}
f.term = m
return m
}
func (f *finder) UseMockedTerminalV2() *TerminalMock {
screen := tcell.NewSimulationScreen("UTF-8")
if err := screen.Init(); err != nil {
panic(err)
}
m := &TerminalMock{
simScreen: screen,
v2: true,
}
f.term = m
return m
}
// parseAttr parses an attribute of termbox
// as an escape sequence.
// parseAttr doesn't support output modes othar than color256 in termbox-go.
func parseAttr(attr termbox.Attribute, isFg bool) string {
var buf bytes.Buffer
buf.WriteString("\x1b[")
if attr >= termbox.AttrReverse {
buf.WriteString("7;")
attr -= termbox.AttrReverse
}
if attr >= termbox.AttrUnderline {
buf.WriteString("4;")
attr -= termbox.AttrUnderline
}
if attr >= termbox.AttrBold {
buf.WriteString("1;")
attr -= termbox.AttrBold
}
if attr > termbox.ColorWhite {
panic(fmt.Sprintf("invalid color code: %d", attr))
}
if attr == termbox.ColorDefault {
if isFg {
buf.WriteString("39")
} else {
buf.WriteString("49")
}
} else {
color := int(attr) - 1
if isFg {
fmt.Fprintf(&buf, "38;5;%d", color)
} else {
fmt.Fprintf(&buf, "48;5;%d", color)
}
}
buf.WriteString("m")
return buf.String()
}
// parseAttrV2 parses color and attribute for testing.
func parseAttrV2(fg, bg tcell.Color, attr tcell.AttrMask) string {
if attr == tcell.AttrInvalid {
panic("invalid attribute")
}
var params []string
if attr&tcell.AttrBold == tcell.AttrBold {
params = append(params, "1")
attr ^= tcell.AttrBold
}
if attr&tcell.AttrBlink == tcell.AttrBlink {
params = append(params, "5")
attr ^= tcell.AttrBlink
}
if attr&tcell.AttrReverse == tcell.AttrReverse {
params = append(params, "7")
attr ^= tcell.AttrReverse
}
if attr&tcell.AttrUnderline == tcell.AttrUnderline {
params = append(params, "4")
attr ^= tcell.AttrUnderline
}
if attr&tcell.AttrDim == tcell.AttrDim {
params = append(params, "2")
attr ^= tcell.AttrDim
}
if attr&tcell.AttrItalic == tcell.AttrItalic {
params = append(params, "3")
attr ^= tcell.AttrItalic
}
if attr&tcell.AttrStrikeThrough == tcell.AttrStrikeThrough {
params = append(params, "9")
attr ^= tcell.AttrStrikeThrough
}
switch {
case fg == 0: // Ignore.
case fg == tcell.ColorDefault:
params = append(params, "39")
case fg > tcell.Color255:
r, g, b := fg.RGB()
params = append(params, "38", "2", fmt.Sprint(r), fmt.Sprint(g), fmt.Sprint(b))
default:
params = append(params, "38", "5", fmt.Sprint(fg-tcell.ColorValid))
}
switch {
case bg == 0: // Ignore.
case bg == tcell.ColorDefault:
params = append(params, "49")
case bg > tcell.Color255:
r, g, b := bg.RGB()
params = append(params, "48", "2", fmt.Sprint(r), fmt.Sprint(g), fmt.Sprint(b))
default:
params = append(params, "48", "5", fmt.Sprint(bg-tcell.ColorValid))
}
return fmt.Sprintf("\x1b[%sm", strings.Join(params, ";"))
}
func toAnsi3bit(color tcell.Color) int {
colors := []tcell.Color{
tcell.ColorBlack, tcell.ColorRed, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorBlue, tcell.ColorDarkMagenta, tcell.ColorDarkCyan, tcell.ColorWhite,
}
for i, c := range colors {
if c == color {
return i
}
}
return 0
}
+162
View File
@@ -0,0 +1,162 @@
package fuzzyfinder
import (
"context"
"sync"
)
type opt struct {
mode mode
previewFunc func(i, width, height int) string
multi bool
hotReload bool
hotReloadLock sync.Locker
promptString string
header string
beginAtTop bool
context context.Context
query string
selectOne bool
preselected func(i int) bool
}
type mode int
const (
// ModeSmart enables a smart matching. It is the default matching mode.
// At the beginning, matching mode is ModeCaseInsensitive, but it switches
// over to ModeCaseSensitive if an upper case character is inputted.
ModeSmart mode = iota
// ModeCaseSensitive enables a case-sensitive matching.
ModeCaseSensitive
// ModeCaseInsensitive enables a case-insensitive matching.
ModeCaseInsensitive
)
var defaultOption = opt{
promptString: "> ",
hotReloadLock: &sync.Mutex{}, // this won't resolve the race condition but avoid nil panic
preselected: func(i int) bool { return false },
}
// Option represents available fuzzy-finding options.
type Option func(*opt)
// WithMode specifies a matching mode. The default mode is ModeSmart.
func WithMode(m mode) Option {
return func(o *opt) {
o.mode = m
}
}
// WithPreviewWindow enables to display a preview for the selected item.
// The argument f receives i, width and height. i is the same as Find's one.
// width and height are the size of the terminal so that you can use these to adjust
// a preview content. Note that width and height are calculated as a rune-based length.
//
// If there is no selected item, previewFunc passes -1 to previewFunc.
//
// If f is nil, the preview feature is disabled.
func WithPreviewWindow(f func(i, width, height int) string) Option {
return func(o *opt) {
o.previewFunc = f
}
}
// WithHotReload reloads the passed slice automatically when some entries are appended.
// The caller must pass a pointer of the slice instead of the slice itself.
//
// Deprecated: use WithHotReloadLock instead.
func WithHotReload() Option {
return func(o *opt) {
o.hotReload = true
}
}
// WithHotReloadLock reloads the passed slice automatically when some entries are appended.
// The caller must pass a pointer of the slice instead of the slice itself.
// The caller must pass a RLock which is used to synchronize access to the slice.
// The caller MUST NOT lock in the itemFunc passed to Find / FindMulti because it will be locked by the fuzzyfinder.
// If used together with WithPreviewWindow, the caller MUST use the RLock only in the previewFunc passed to WithPreviewWindow.
func WithHotReloadLock(lock sync.Locker) Option {
return func(o *opt) {
o.hotReload = true
o.hotReloadLock = lock
}
}
type cursorPosition int
const (
CursorPositionBottom cursorPosition = iota
CursorPositionTop
)
// WithCursorPosition sets the initial position of the cursor
//
// If Find is called with WithCursorPosition and WithPreselected, the cursor will be positioned at the first preselected item.
func WithCursorPosition(position cursorPosition) Option {
return func(o *opt) {
switch position {
case CursorPositionTop:
o.beginAtTop = true
case CursorPositionBottom:
o.beginAtTop = false
}
}
}
// WithPromptString changes the prompt string. The default value is "> ".
func WithPromptString(s string) Option {
return func(o *opt) {
o.promptString = s
}
}
// withMulti enables to select multiple items by tab key.
func withMulti() Option {
return func(o *opt) {
o.multi = true
}
}
// WithHeader enables to set the header.
func WithHeader(s string) Option {
return func(o *opt) {
o.header = s
}
}
// WithContext enables closing the fuzzy finder from parent.
func WithContext(ctx context.Context) Option {
return func(o *opt) {
o.context = ctx
}
}
// WithQuery enables to set the initial query.
func WithQuery(s string) Option {
return func(o *opt) {
o.query = s
}
}
// WithQuery enables to set the initial query.
func WithSelectOne() Option {
return func(o *opt) {
o.selectOne = true
}
}
// WithPreselected enables to specify which items should be preselected.
// The argument f is a function that returns true for items that should be preselected.
// i is the same index value passed to itemFunc in Find or FindMulti.
// This option is effective in both Find and FindMulti, but in Find mode only
// the first preselected item will be considered.
//
// If Find is called with WithCursorPosition and WithPreselected, the cursor will be positioned at the first preselected item.
func WithPreselected(f func(i int) bool) Option {
return func(o *opt) {
o.preselected = f
}
}
+27
View File
@@ -0,0 +1,27 @@
// Package scoring provides APIs that calculates similarity scores between two strings.
package scoring
// Calculate calculates a similarity score between s1 and s2.
// The length of s1 must be greater or equal than the length of s2.
func Calculate(s1, s2 string) (int, [2]int) {
if len(s1) < len(s2) {
panic("len(s1) must be greater than or equal to len(s2)")
}
return smithWaterman([]rune(s1), []rune(s2))
}
// max returns the biggest number from passed args.
// If the number of args is 0, it always returns 0.
func max(n ...int32) (min int32) {
if len(n) == 0 {
return 0
}
min = n[0]
for _, a := range n[1:] {
if a > min {
min = a
}
}
return
}
+170
View File
@@ -0,0 +1,170 @@
package scoring
import (
"fmt"
"os"
"unicode"
)
// smithWaterman calculates a simularity score between s1 and s2
// by smith-waterman algorithm. smith-waterman algorithm is one of
// local alignment algorithms and it uses dynamic programming.
//
// In this smith-waterman algorithm, we use the affine gap penalty.
// Please see https://en.wikipedia.org/wiki/Gap_penalty#Affine for additional
// information about the affine gap penalty.
//
// We calculate the gap penalty by the Gotoh's algorithm, which optimizes
// the calculation from O(M^2N) to O(MN).
// Please see ftp://150.128.97.71/pub/Bioinformatica/gotoh1982.pdf for more details.
func smithWaterman(s1, s2 []rune) (int, [2]int) {
if len(s1) == 0 {
// If the length of s1 is 0, also the length of s2 is 0.
return 0, [2]int{-1, -1}
}
const (
openGap int32 = 5 // Gap opening penalty.
extGap int32 = 1 // Gap extension penalty.
matchScore int32 = 5
mismatchScore int32 = 1
firstCharBonus int32 = 3 // The first char of s1 is equal to s2's one.
)
// The scoring matrix.
H := make([][]int32, len(s1)+1)
// A matrix that calculates gap penalties for s2 until each position (i, j).
// Note that, we don't need a matrix for s1 because s1 contains all runes
// of s2 so that s1 is not inserted gaps.
D := make([][]int32, len(s1)+1)
for i := 0; i <= len(s1); i++ {
H[i] = make([]int32, len(s2)+1)
D[i] = make([]int32, len(s2)+1)
}
for i := 0; i <= len(s1); i++ {
D[i][0] = -openGap - int32(i)*extGap
}
// Calculate bonuses for each rune of s1.
bonus := make([]int32, len(s1))
bonus[0] = firstCharBonus
prevCh := s1[0]
prevIsDelimiter := isDelimiter(prevCh)
for i, r := range s1[1:] {
isDelimiter := isDelimiter(r)
if prevIsDelimiter && !isDelimiter {
bonus[i] = firstCharBonus
}
prevIsDelimiter = isDelimiter
}
var maxScore int32
var maxI int
var maxJ int
for i := 1; i <= len(s1); i++ {
for j := 1; j <= len(s2); j++ {
var score int32
if s1[i-1] != s2[j-1] {
score = H[i-1][j-1] - mismatchScore
} else {
score = H[i-1][j-1] + matchScore + bonus[i-1]
}
H[i][j] += max(D[i-1][j], score, 0)
D[i][j] = max(H[i-1][j]-openGap, D[i-1][j]-extGap)
// Update the max score.
// Don't pick a position that is less than the length of s2.
if H[i][j] > maxScore && i >= j {
maxScore = H[i][j]
maxI = i - 1
maxJ = j - 1
}
}
}
if isDebug() {
fmt.Printf("max score = %d (%d, %d)\n\n", maxScore, maxI, maxJ)
printSlice := func(m [][]int32) {
fmt.Printf("%4c ", '|')
for i := 0; i < len(s2); i++ {
fmt.Printf("%3c ", s2[i])
}
fmt.Printf("\n-------------------------\n")
fmt.Print(" | ")
for i := 0; i <= len(s1); i++ {
if i != 0 {
fmt.Printf("%3c| ", s1[i-1])
}
for j := 0; j <= len(s2); j++ {
fmt.Printf("%3d ", m[i][j])
}
fmt.Println()
}
fmt.Println()
}
printSlice(H)
printSlice(D)
}
// Determine the matched position.
var from, to int
cnt := 1
// maxJ is the last index of s2.
// If maxJ is equal to the length of s2, it means there are no matched runes after maxJ.
if maxJ == len(s2)-1 {
to = maxI
} else {
j := maxJ + 1
for i := maxI + 1; i < len(s1); i++ {
if unicode.ToLower(s1[i]) == unicode.ToLower(s2[j]) {
cnt++
j++
if j == len(s2) {
to = i + 1
break
}
}
}
}
for i := maxI - 1; i > 0; i-- {
if cnt == len(s2) {
from = i + 1
break
}
if unicode.ToLower(s1[i]) == unicode.ToLower(s2[len(s2)-1-cnt]) {
cnt++
}
}
// We adjust scores by the weight per one rune.
return int(float32(maxScore) * (float32(maxScore) / float32(len(s1)))), [2]int{from, to}
}
func isDebug() bool {
return os.Getenv("DEBUG") != ""
}
var delimiterRunes = map[rune]interface{}{
'(': nil,
'[': nil,
'{': nil,
'/': nil,
'-': nil,
'_': nil,
'.': nil,
}
func isDelimiter(r rune) bool {
if _, ok := delimiterRunes[r]; ok {
return true
}
return unicode.IsSpace(r)
}
+15
View File
@@ -0,0 +1,15 @@
package fuzzyfinder
import (
"github.com/gdamore/tcell/v2"
)
type screen tcell.Screen
type terminal interface {
screen
}
type termImpl struct {
screen
}