add my own session manager
This commit is contained in:
@@ -0,0 +1 @@
|
||||
coverage.txt
|
||||
+21
@@ -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
@@ -0,0 +1,116 @@
|
||||
# go-ansisgr
|
||||
|
||||
[](https://pkg.go.dev/github.com/ktr0731/go-ansisgr)
|
||||
[](https://github.com/ktr0731/go-ansisgr/actions)
|
||||
[](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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
builds:
|
||||
- skip: true
|
||||
+12088
File diff suppressed because it is too large
Load Diff
+21
@@ -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
@@ -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
@@ -0,0 +1,115 @@
|
||||
# go-fuzzyfinder
|
||||
|
||||
[](https://pkg.go.dev/github.com/ktr0731/go-fuzzyfinder)
|
||||
[](https://github.com/ktr0731/go-fuzzyfinder/actions)
|
||||
[](https://codecov.io/gh/ktr0731/go-fuzzyfinder)
|
||||
|
||||
`go-fuzzyfinder` is a Go library that provides fuzzy-finding with an fzf-like terminal user interface.
|
||||
|
||||

|
||||
|
||||
## 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,15 @@
|
||||
package fuzzyfinder
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
type screen tcell.Screen
|
||||
|
||||
type terminal interface {
|
||||
screen
|
||||
}
|
||||
|
||||
type termImpl struct {
|
||||
screen
|
||||
}
|
||||
Reference in New Issue
Block a user