feat(palette): add fuzzy command model
- commit
- 0ede052770b18f2158a6fa3ba13f3e6c4a30f28f
- author
- brice · · 2026-07-03 05:25 UTC
- committer
- brice · · 2026-07-03 07:33 UTC
- parent
- 0d2917c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +408 -0
A internal/usecase/palette/fuzzy.go +97 -0
| @@ -0,0 +1,97 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sort" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type Match struct { | |
| 11 | + | Command command.Command | |
| 12 | + | Positions []int | |
| 13 | + | rank int | |
| 14 | + | span int | |
| 15 | + | first int | |
| 16 | + | order int | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func Fuzzy(commands []command.Command, query string) []Match { | |
| 20 | + | if query == "" { | |
| 21 | + | out := make([]Match, len(commands)) | |
| 22 | + | for i, cmd := range commands { | |
| 23 | + | out[i] = Match{Command: cmd, order: i} | |
| 24 | + | } | |
| 25 | + | return out | |
| 26 | + | } | |
| 27 | + | var out []Match | |
| 28 | + | for i, cmd := range commands { | |
| 29 | + | if m, ok := score(cmd, query, i); ok { | |
| 30 | + | out = append(out, m) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | sort.SliceStable(out, func(i, j int) bool { | |
| 34 | + | a, b := out[i], out[j] | |
| 35 | + | if a.rank != b.rank { | |
| 36 | + | return a.rank < b.rank | |
| 37 | + | } | |
| 38 | + | if a.span != b.span { | |
| 39 | + | return a.span < b.span | |
| 40 | + | } | |
| 41 | + | if a.first != b.first { | |
| 42 | + | return a.first < b.first | |
| 43 | + | } | |
| 44 | + | if a.Command.Code != b.Command.Code { | |
| 45 | + | return a.Command.Code < b.Command.Code | |
| 46 | + | } | |
| 47 | + | return a.order < b.order | |
| 48 | + | }) | |
| 49 | + | return out | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func score(cmd command.Command, query string, order int) (Match, bool) { | |
| 53 | + | code := []rune(cmd.Code) | |
| 54 | + | q := []rune(query) | |
| 55 | + | codeLower := strings.ToLower(cmd.Code) | |
| 56 | + | qLower := strings.ToLower(query) | |
| 57 | + | if codeLower == qLower { | |
| 58 | + | return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true | |
| 59 | + | } | |
| 60 | + | if strings.HasPrefix(codeLower, qLower) { | |
| 61 | + | return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true | |
| 62 | + | } | |
| 63 | + | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | |
| 64 | + | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |
| 65 | + | } | |
| 66 | + | text := strings.ToLower(cmd.Name + " " + cmd.Desc) | |
| 67 | + | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | |
| 68 | + | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |
| 69 | + | } | |
| 70 | + | return Match{}, false | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func rangePositions(n int) []int { | |
| 74 | + | p := make([]int, n) | |
| 75 | + | for i := range p { | |
| 76 | + | p[i] = i | |
| 77 | + | } | |
| 78 | + | return p | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | |
| 82 | + | if len(needle) == 0 { | |
| 83 | + | return nil, true | |
| 84 | + | } | |
| 85 | + | positions := make([]int, 0, len(needle)) | |
| 86 | + | j := 0 | |
| 87 | + | for i, r := range haystack { | |
| 88 | + | if r == needle[j] { | |
| 89 | + | positions = append(positions, i) | |
| 90 | + | j++ | |
| 91 | + | if j == len(needle) { | |
| 92 | + | return positions, true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return nil, false | |
| 97 | + | } | |
| @@ -0,0 +1,97 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sort" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type Match struct { | |||
| 11 | + | Command command.Command | |||
| 12 | + | Positions []int | |||
| 13 | + | rank int | |||
| 14 | + | span int | |||
| 15 | + | first int | |||
| 16 | + | order int | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func Fuzzy(commands []command.Command, query string) []Match { | |||
| 20 | + | if query == "" { | |||
| 21 | + | out := make([]Match, len(commands)) | |||
| 22 | + | for i, cmd := range commands { | |||
| 23 | + | out[i] = Match{Command: cmd, order: i} | |||
| 24 | + | } | |||
| 25 | + | return out | |||
| 26 | + | } | |||
| 27 | + | var out []Match | |||
| 28 | + | for i, cmd := range commands { | |||
| 29 | + | if m, ok := score(cmd, query, i); ok { | |||
| 30 | + | out = append(out, m) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | sort.SliceStable(out, func(i, j int) bool { | |||
| 34 | + | a, b := out[i], out[j] | |||
| 35 | + | if a.rank != b.rank { | |||
| 36 | + | return a.rank < b.rank | |||
| 37 | + | } | |||
| 38 | + | if a.span != b.span { | |||
| 39 | + | return a.span < b.span | |||
| 40 | + | } | |||
| 41 | + | if a.first != b.first { | |||
| 42 | + | return a.first < b.first | |||
| 43 | + | } | |||
| 44 | + | if a.Command.Code != b.Command.Code { | |||
| 45 | + | return a.Command.Code < b.Command.Code | |||
| 46 | + | } | |||
| 47 | + | return a.order < b.order | |||
| 48 | + | }) | |||
| 49 | + | return out | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func score(cmd command.Command, query string, order int) (Match, bool) { | |||
| 53 | + | code := []rune(cmd.Code) | |||
| 54 | + | q := []rune(query) | |||
| 55 | + | codeLower := strings.ToLower(cmd.Code) | |||
| 56 | + | qLower := strings.ToLower(query) | |||
| 57 | + | if codeLower == qLower { | |||
| 58 | + | return Match{Command: cmd, Positions: rangePositions(len(code)), rank: 0, span: len(code), first: 0, order: order}, true | |||
| 59 | + | } | |||
| 60 | + | if strings.HasPrefix(codeLower, qLower) { | |||
| 61 | + | return Match{Command: cmd, Positions: rangePositions(len(q)), rank: 1, span: len(q), first: 0, order: order}, true | |||
| 62 | + | } | |||
| 63 | + | if positions, ok := subsequencePositions([]rune(codeLower), []rune(qLower)); ok { | |||
| 64 | + | return Match{Command: cmd, Positions: positions, rank: 2, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |||
| 65 | + | } | |||
| 66 | + | text := strings.ToLower(cmd.Name + " " + cmd.Desc) | |||
| 67 | + | if positions, ok := subsequencePositions([]rune(text), []rune(qLower)); ok { | |||
| 68 | + | return Match{Command: cmd, rank: 3, span: positions[len(positions)-1] - positions[0] + 1, first: positions[0], order: order}, true | |||
| 69 | + | } | |||
| 70 | + | return Match{}, false | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func rangePositions(n int) []int { | |||
| 74 | + | p := make([]int, n) | |||
| 75 | + | for i := range p { | |||
| 76 | + | p[i] = i | |||
| 77 | + | } | |||
| 78 | + | return p | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func subsequencePositions(haystack, needle []rune) ([]int, bool) { | |||
| 82 | + | if len(needle) == 0 { | |||
| 83 | + | return nil, true | |||
| 84 | + | } | |||
| 85 | + | positions := make([]int, 0, len(needle)) | |||
| 86 | + | j := 0 | |||
| 87 | + | for i, r := range haystack { | |||
| 88 | + | if r == needle[j] { | |||
| 89 | + | positions = append(positions, i) | |||
| 90 | + | j++ | |||
| 91 | + | if j == len(needle) { | |||
| 92 | + | return positions, true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return nil, false | |||
| 97 | + | } | |||
A internal/usecase/palette/fuzzy_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func cmd(code, name, desc string) command.Command { | |
| 11 | + | return command.Command{Code: code, Name: name, Desc: desc} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func codes(matches []Match) []string { | |
| 15 | + | out := make([]string, len(matches)) | |
| 16 | + | for i, match := range matches { | |
| 17 | + | out[i] = match.Command.Code | |
| 18 | + | } | |
| 19 | + | return out | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestFuzzyOrdersScoringTiers(t *testing.T) { | |
| 23 | + | matches := Fuzzy([]command.Command{ | |
| 24 | + | cmd("ZQQ", "alpha beta", "name subsequence"), | |
| 25 | + | cmd("XAB", "unused", "subsequence code"), | |
| 26 | + | cmd("ABX", "unused", "prefix code"), | |
| 27 | + | cmd("AB", "unused", "exact code"), | |
| 28 | + | }, "ab") | |
| 29 | + | ||
| 30 | + | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | |
| 31 | + | require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions") | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | |
| 35 | + | matches := Fuzzy([]command.Command{ | |
| 36 | + | cmd("ZAXQB", "", ""), // span 3, first 1 | |
| 37 | + | cmd("BAXQ", "", ""), // span 3, first 1, code sorts first | |
| 38 | + | cmd("PXAQ", "", ""), // span 2, first 2 | |
| 39 | + | cmd("BAQ", "", ""), // span 2, first 0 | |
| 40 | + | }, "aq") | |
| 41 | + | ||
| 42 | + | require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | |
| 46 | + | matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy") | |
| 47 | + | ||
| 48 | + | require.Len(t, matches, 1) | |
| 49 | + | require.Equal(t, []int{0, 2}, matches[0].Positions) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |
| 53 | + | commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")} | |
| 54 | + | matches := Fuzzy(commands, "") | |
| 55 | + | ||
| 56 | + | require.Equal(t, []string{"B", "A"}, codes(matches)) | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func cmd(code, name, desc string) command.Command { | |||
| 11 | + | return command.Command{Code: code, Name: name, Desc: desc} | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func codes(matches []Match) []string { | |||
| 15 | + | out := make([]string, len(matches)) | |||
| 16 | + | for i, match := range matches { | |||
| 17 | + | out[i] = match.Command.Code | |||
| 18 | + | } | |||
| 19 | + | return out | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestFuzzyOrdersScoringTiers(t *testing.T) { | |||
| 23 | + | matches := Fuzzy([]command.Command{ | |||
| 24 | + | cmd("ZQQ", "alpha beta", "name subsequence"), | |||
| 25 | + | cmd("XAB", "unused", "subsequence code"), | |||
| 26 | + | cmd("ABX", "unused", "prefix code"), | |||
| 27 | + | cmd("AB", "unused", "exact code"), | |||
| 28 | + | }, "ab") | |||
| 29 | + | ||||
| 30 | + | require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches)) | |||
| 31 | + | require.Empty(t, matches[3].Positions, "name/description matches do not expose code highlight positions") | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) { | |||
| 35 | + | matches := Fuzzy([]command.Command{ | |||
| 36 | + | cmd("ZAXQB", "", ""), // span 3, first 1 | |||
| 37 | + | cmd("BAXQ", "", ""), // span 3, first 1, code sorts first | |||
| 38 | + | cmd("PXAQ", "", ""), // span 2, first 2 | |||
| 39 | + | cmd("BAQ", "", ""), // span 2, first 0 | |||
| 40 | + | }, "aq") | |||
| 41 | + | ||||
| 42 | + | require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches)) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) { | |||
| 46 | + | matches := Fuzzy([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}, "cy") | |||
| 47 | + | ||||
| 48 | + | require.Len(t, matches, 1) | |||
| 49 | + | require.Equal(t, []int{0, 2}, matches[0].Positions) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) { | |||
| 53 | + | commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")} | |||
| 54 | + | matches := Fuzzy(commands, "") | |||
| 55 | + | ||||
| 56 | + | require.Equal(t, []string{"B", "A"}, codes(matches)) | |||
| 57 | + | } | |||
A internal/usecase/palette/model.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type Model struct { | |
| 11 | + | commands []command.Command | |
| 12 | + | input []rune | |
| 13 | + | matches []Match | |
| 14 | + | selected int | |
| 15 | + | scroll int | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func New(commands []command.Command) *Model { | |
| 19 | + | m := &Model{commands: append([]command.Command(nil), commands...)} | |
| 20 | + | m.refresh() | |
| 21 | + | return m | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func NewRegistry() *Model { return New(command.Registry()) } | |
| 25 | + | ||
| 26 | + | func (m *Model) Insert(r rune) { | |
| 27 | + | if m != nil { | |
| 28 | + | m.input = append(m.input, r) | |
| 29 | + | m.selected = 0 | |
| 30 | + | m.scroll = 0 | |
| 31 | + | m.refresh() | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | func (m *Model) Backspace() { | |
| 35 | + | if m != nil && len(m.input) > 0 { | |
| 36 | + | m.input = m.input[:len(m.input)-1] | |
| 37 | + | m.selected = 0 | |
| 38 | + | m.scroll = 0 | |
| 39 | + | m.refresh() | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | func (m *Model) Query() string { | |
| 43 | + | if m == nil { | |
| 44 | + | return "" | |
| 45 | + | } | |
| 46 | + | return string(m.input) | |
| 47 | + | } | |
| 48 | + | func (m *Model) Up() { | |
| 49 | + | if m != nil && m.selected > 0 { | |
| 50 | + | m.selected-- | |
| 51 | + | m.clamp() | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | func (m *Model) Down() { | |
| 55 | + | if m != nil && m.selected+1 < len(m.matches) { | |
| 56 | + | m.selected++ | |
| 57 | + | m.clamp() | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | func (m *Model) Selected() (command.Command, bool) { | |
| 61 | + | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | |
| 62 | + | return command.Command{}, false | |
| 63 | + | } | |
| 64 | + | return m.matches[m.selected].Command, true | |
| 65 | + | } | |
| 66 | + | func (m *Model) Matches() []Match { | |
| 67 | + | if m == nil { | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | out := make([]Match, len(m.matches)) | |
| 71 | + | for i, match := range m.matches { | |
| 72 | + | out[i] = match | |
| 73 | + | out[i].Positions = append([]int(nil), match.Positions...) | |
| 74 | + | } | |
| 75 | + | return out | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (m *Model) refresh() { m.matches = Fuzzy(m.commands, string(m.input)); m.clamp() } | |
| 79 | + | func (m *Model) clamp() { | |
| 80 | + | if len(m.matches) == 0 { | |
| 81 | + | m.selected = -1 | |
| 82 | + | m.scroll = 0 | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | if m.selected < 0 { | |
| 86 | + | m.selected = 0 | |
| 87 | + | } | |
| 88 | + | if m.selected >= len(m.matches) { | |
| 89 | + | m.selected = len(m.matches) - 1 | |
| 90 | + | } | |
| 91 | + | if m.scroll < 0 { | |
| 92 | + | m.scroll = 0 | |
| 93 | + | } | |
| 94 | + | if m.scroll > m.selected { | |
| 95 | + | m.scroll = m.selected | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (m *Model) Render(inner domain.Size) renderer.Frame { | |
| 100 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 101 | + | if frame.Width == 0 || frame.Height == 0 { | |
| 102 | + | return frame | |
| 103 | + | } | |
| 104 | + | base := renderer.DefaultStyle() | |
| 105 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 106 | + | x := ui.DrawText(frame, 0, 0, frame.Width, "> "+m.Query(), base) | |
| 107 | + | if x < frame.Width { | |
| 108 | + | frame.Set(x, 0, renderer.Cell{Rune: ' ', Style: renderer.Style{Inverse: true, Foreground: -1, Background: -1}}) | |
| 109 | + | } | |
| 110 | + | if m == nil { | |
| 111 | + | return frame | |
| 112 | + | } | |
| 113 | + | visible := frame.Height - 1 | |
| 114 | + | if visible <= 0 { | |
| 115 | + | return frame | |
| 116 | + | } | |
| 117 | + | if m.selected < m.scroll { | |
| 118 | + | m.scroll = m.selected | |
| 119 | + | } | |
| 120 | + | if m.selected >= m.scroll+visible { | |
| 121 | + | m.scroll = m.selected - visible + 1 | |
| 122 | + | } | |
| 123 | + | codeWidth := 0 | |
| 124 | + | for _, match := range m.matches { | |
| 125 | + | if len([]rune(match.Command.Code)) > codeWidth { | |
| 126 | + | codeWidth = len([]rune(match.Command.Code)) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | for y := 0; y < visible; y++ { | |
| 130 | + | idx := m.scroll + y | |
| 131 | + | if idx >= len(m.matches) { | |
| 132 | + | break | |
| 133 | + | } | |
| 134 | + | match := m.matches[idx] | |
| 135 | + | style := base | |
| 136 | + | if idx == m.selected { | |
| 137 | + | style.Inverse = true | |
| 138 | + | } | |
| 139 | + | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 140 | + | x := 0 | |
| 141 | + | highlight := map[int]bool{} | |
| 142 | + | for _, p := range match.Positions { | |
| 143 | + | highlight[p] = true | |
| 144 | + | } | |
| 145 | + | for i, r := range []rune(match.Command.Code) { | |
| 146 | + | cellStyle := style | |
| 147 | + | if highlight[i] { | |
| 148 | + | cellStyle.Bold = true | |
| 149 | + | cellStyle.Inverse = true | |
| 150 | + | } | |
| 151 | + | if x < frame.Width { | |
| 152 | + | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | |
| 153 | + | } | |
| 154 | + | x++ | |
| 155 | + | } | |
| 156 | + | for x < codeWidth+1 && x < frame.Width { | |
| 157 | + | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | |
| 158 | + | x++ | |
| 159 | + | } | |
| 160 | + | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — "+match.Command.Desc, style) | |
| 161 | + | } | |
| 162 | + | return frame | |
| 163 | + | } | |
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type Model struct { | |||
| 11 | + | commands []command.Command | |||
| 12 | + | input []rune | |||
| 13 | + | matches []Match | |||
| 14 | + | selected int | |||
| 15 | + | scroll int | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func New(commands []command.Command) *Model { | |||
| 19 | + | m := &Model{commands: append([]command.Command(nil), commands...)} | |||
| 20 | + | m.refresh() | |||
| 21 | + | return m | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func NewRegistry() *Model { return New(command.Registry()) } | |||
| 25 | + | ||||
| 26 | + | func (m *Model) Insert(r rune) { | |||
| 27 | + | if m != nil { | |||
| 28 | + | m.input = append(m.input, r) | |||
| 29 | + | m.selected = 0 | |||
| 30 | + | m.scroll = 0 | |||
| 31 | + | m.refresh() | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | func (m *Model) Backspace() { | |||
| 35 | + | if m != nil && len(m.input) > 0 { | |||
| 36 | + | m.input = m.input[:len(m.input)-1] | |||
| 37 | + | m.selected = 0 | |||
| 38 | + | m.scroll = 0 | |||
| 39 | + | m.refresh() | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | func (m *Model) Query() string { | |||
| 43 | + | if m == nil { | |||
| 44 | + | return "" | |||
| 45 | + | } | |||
| 46 | + | return string(m.input) | |||
| 47 | + | } | |||
| 48 | + | func (m *Model) Up() { | |||
| 49 | + | if m != nil && m.selected > 0 { | |||
| 50 | + | m.selected-- | |||
| 51 | + | m.clamp() | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | func (m *Model) Down() { | |||
| 55 | + | if m != nil && m.selected+1 < len(m.matches) { | |||
| 56 | + | m.selected++ | |||
| 57 | + | m.clamp() | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | func (m *Model) Selected() (command.Command, bool) { | |||
| 61 | + | if m == nil || m.selected < 0 || m.selected >= len(m.matches) { | |||
| 62 | + | return command.Command{}, false | |||
| 63 | + | } | |||
| 64 | + | return m.matches[m.selected].Command, true | |||
| 65 | + | } | |||
| 66 | + | func (m *Model) Matches() []Match { | |||
| 67 | + | if m == nil { | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | out := make([]Match, len(m.matches)) | |||
| 71 | + | for i, match := range m.matches { | |||
| 72 | + | out[i] = match | |||
| 73 | + | out[i].Positions = append([]int(nil), match.Positions...) | |||
| 74 | + | } | |||
| 75 | + | return out | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (m *Model) refresh() { m.matches = Fuzzy(m.commands, string(m.input)); m.clamp() } | |||
| 79 | + | func (m *Model) clamp() { | |||
| 80 | + | if len(m.matches) == 0 { | |||
| 81 | + | m.selected = -1 | |||
| 82 | + | m.scroll = 0 | |||
| 83 | + | return | |||
| 84 | + | } | |||
| 85 | + | if m.selected < 0 { | |||
| 86 | + | m.selected = 0 | |||
| 87 | + | } | |||
| 88 | + | if m.selected >= len(m.matches) { | |||
| 89 | + | m.selected = len(m.matches) - 1 | |||
| 90 | + | } | |||
| 91 | + | if m.scroll < 0 { | |||
| 92 | + | m.scroll = 0 | |||
| 93 | + | } | |||
| 94 | + | if m.scroll > m.selected { | |||
| 95 | + | m.scroll = m.selected | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func (m *Model) Render(inner domain.Size) renderer.Frame { | |||
| 100 | + | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |||
| 101 | + | if frame.Width == 0 || frame.Height == 0 { | |||
| 102 | + | return frame | |||
| 103 | + | } | |||
| 104 | + | base := renderer.DefaultStyle() | |||
| 105 | + | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |||
| 106 | + | x := ui.DrawText(frame, 0, 0, frame.Width, "> "+m.Query(), base) | |||
| 107 | + | if x < frame.Width { | |||
| 108 | + | frame.Set(x, 0, renderer.Cell{Rune: ' ', Style: renderer.Style{Inverse: true, Foreground: -1, Background: -1}}) | |||
| 109 | + | } | |||
| 110 | + | if m == nil { | |||
| 111 | + | return frame | |||
| 112 | + | } | |||
| 113 | + | visible := frame.Height - 1 | |||
| 114 | + | if visible <= 0 { | |||
| 115 | + | return frame | |||
| 116 | + | } | |||
| 117 | + | if m.selected < m.scroll { | |||
| 118 | + | m.scroll = m.selected | |||
| 119 | + | } | |||
| 120 | + | if m.selected >= m.scroll+visible { | |||
| 121 | + | m.scroll = m.selected - visible + 1 | |||
| 122 | + | } | |||
| 123 | + | codeWidth := 0 | |||
| 124 | + | for _, match := range m.matches { | |||
| 125 | + | if len([]rune(match.Command.Code)) > codeWidth { | |||
| 126 | + | codeWidth = len([]rune(match.Command.Code)) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | for y := 0; y < visible; y++ { | |||
| 130 | + | idx := m.scroll + y | |||
| 131 | + | if idx >= len(m.matches) { | |||
| 132 | + | break | |||
| 133 | + | } | |||
| 134 | + | match := m.matches[idx] | |||
| 135 | + | style := base | |||
| 136 | + | if idx == m.selected { | |||
| 137 | + | style.Inverse = true | |||
| 138 | + | } | |||
| 139 | + | ui.FillRect(frame, domain.Rect{Y: y + 1, Width: frame.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |||
| 140 | + | x := 0 | |||
| 141 | + | highlight := map[int]bool{} | |||
| 142 | + | for _, p := range match.Positions { | |||
| 143 | + | highlight[p] = true | |||
| 144 | + | } | |||
| 145 | + | for i, r := range []rune(match.Command.Code) { | |||
| 146 | + | cellStyle := style | |||
| 147 | + | if highlight[i] { | |||
| 148 | + | cellStyle.Bold = true | |||
| 149 | + | cellStyle.Inverse = true | |||
| 150 | + | } | |||
| 151 | + | if x < frame.Width { | |||
| 152 | + | frame.Set(x, y+1, renderer.Cell{Rune: r, Style: cellStyle}) | |||
| 153 | + | } | |||
| 154 | + | x++ | |||
| 155 | + | } | |||
| 156 | + | for x < codeWidth+1 && x < frame.Width { | |||
| 157 | + | frame.Set(x, y+1, renderer.Cell{Rune: ' ', Style: style}) | |||
| 158 | + | x++ | |||
| 159 | + | } | |||
| 160 | + | ui.DrawText(frame, x, y+1, frame.Width, match.Command.Name+" — "+match.Command.Desc, style) | |||
| 161 | + | } | |||
| 162 | + | return frame | |||
| 163 | + | } | |||
A internal/usecase/palette/model_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package palette | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |
| 12 | + | m := New([]command.Command{ | |
| 13 | + | cmd("ABC", "Alpha", "first"), | |
| 14 | + | cmd("DEF", "Delta", "second"), | |
| 15 | + | cmd("AXY", "Other", "third"), | |
| 16 | + | }) | |
| 17 | + | ||
| 18 | + | require.Equal(t, "", m.Query()) | |
| 19 | + | selected, ok := m.Selected() | |
| 20 | + | require.True(t, ok) | |
| 21 | + | require.Equal(t, "ABC", selected.Code) | |
| 22 | + | ||
| 23 | + | m.Down() | |
| 24 | + | m.Down() | |
| 25 | + | selected, ok = m.Selected() | |
| 26 | + | require.True(t, ok) | |
| 27 | + | require.Equal(t, "AXY", selected.Code) | |
| 28 | + | ||
| 29 | + | m.Insert('d') | |
| 30 | + | require.Equal(t, "d", m.Query()) | |
| 31 | + | selected, ok = m.Selected() | |
| 32 | + | require.True(t, ok) | |
| 33 | + | require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes") | |
| 34 | + | ||
| 35 | + | m.Up() | |
| 36 | + | selected, ok = m.Selected() | |
| 37 | + | require.True(t, ok) | |
| 38 | + | require.Equal(t, "DEF", selected.Code, "up at first match clamps") | |
| 39 | + | ||
| 40 | + | m.Backspace() | |
| 41 | + | require.Equal(t, "", m.Query()) | |
| 42 | + | require.Len(t, m.Matches(), 3) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestModelNoMatchesClearsSelection(t *testing.T) { | |
| 46 | + | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | |
| 47 | + | m.Insert('z') | |
| 48 | + | ||
| 49 | + | _, ok := m.Selected() | |
| 50 | + | require.False(t, ok) | |
| 51 | + | require.Empty(t, m.Matches()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |
| 55 | + | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | |
| 56 | + | m.Insert('a') | |
| 57 | + | ||
| 58 | + | matches := m.Matches() | |
| 59 | + | require.Len(t, matches, 1) | |
| 60 | + | require.Equal(t, []int{0}, matches[0].Positions) | |
| 61 | + | ||
| 62 | + | matches[0].Positions[0] = 2 | |
| 63 | + | ||
| 64 | + | fresh := m.Matches() | |
| 65 | + | require.Equal(t, []int{0}, fresh[0].Positions) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |
| 69 | + | m := New([]command.Command{ | |
| 70 | + | cmd("CPY", "Copy", "Enter copy mode"), | |
| 71 | + | cmd("CNT", "New", "Create tab"), | |
| 72 | + | }) | |
| 73 | + | m.Insert('c') | |
| 74 | + | m.Insert('y') | |
| 75 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}) | |
| 76 | + | ||
| 77 | + | require.Equal(t, '>', frame.At(0, 0).Rune) | |
| 78 | + | require.Equal(t, ' ', frame.At(1, 0).Rune) | |
| 79 | + | require.Equal(t, 'c', frame.At(2, 0).Rune) | |
| 80 | + | require.Equal(t, 'y', frame.At(3, 0).Rune) | |
| 81 | + | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | |
| 82 | + | ||
| 83 | + | require.Equal(t, 'C', frame.At(0, 1).Rune) | |
| 84 | + | require.Equal(t, 'P', frame.At(1, 1).Rune) | |
| 85 | + | require.Equal(t, 'Y', frame.At(2, 1).Rune) | |
| 86 | + | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | |
| 87 | + | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | |
| 88 | + | require.True(t, frame.At(0, 1).Style.Bold, "matched C is bold") | |
| 89 | + | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | |
| 90 | + | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | |
| 91 | + | } | |
| @@ -0,0 +1,91 @@ | |||||
| 1 | + | package palette | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/command" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) { | |||
| 12 | + | m := New([]command.Command{ | |||
| 13 | + | cmd("ABC", "Alpha", "first"), | |||
| 14 | + | cmd("DEF", "Delta", "second"), | |||
| 15 | + | cmd("AXY", "Other", "third"), | |||
| 16 | + | }) | |||
| 17 | + | ||||
| 18 | + | require.Equal(t, "", m.Query()) | |||
| 19 | + | selected, ok := m.Selected() | |||
| 20 | + | require.True(t, ok) | |||
| 21 | + | require.Equal(t, "ABC", selected.Code) | |||
| 22 | + | ||||
| 23 | + | m.Down() | |||
| 24 | + | m.Down() | |||
| 25 | + | selected, ok = m.Selected() | |||
| 26 | + | require.True(t, ok) | |||
| 27 | + | require.Equal(t, "AXY", selected.Code) | |||
| 28 | + | ||||
| 29 | + | m.Insert('d') | |||
| 30 | + | require.Equal(t, "d", m.Query()) | |||
| 31 | + | selected, ok = m.Selected() | |||
| 32 | + | require.True(t, ok) | |||
| 33 | + | require.Equal(t, "DEF", selected.Code, "selection clamps to only match after query changes") | |||
| 34 | + | ||||
| 35 | + | m.Up() | |||
| 36 | + | selected, ok = m.Selected() | |||
| 37 | + | require.True(t, ok) | |||
| 38 | + | require.Equal(t, "DEF", selected.Code, "up at first match clamps") | |||
| 39 | + | ||||
| 40 | + | m.Backspace() | |||
| 41 | + | require.Equal(t, "", m.Query()) | |||
| 42 | + | require.Len(t, m.Matches(), 3) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestModelNoMatchesClearsSelection(t *testing.T) { | |||
| 46 | + | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | |||
| 47 | + | m.Insert('z') | |||
| 48 | + | ||||
| 49 | + | _, ok := m.Selected() | |||
| 50 | + | require.False(t, ok) | |||
| 51 | + | require.Empty(t, m.Matches()) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestModelMatchesDeepCopiesPositions(t *testing.T) { | |||
| 55 | + | m := New([]command.Command{cmd("ABC", "Alpha", "first")}) | |||
| 56 | + | m.Insert('a') | |||
| 57 | + | ||||
| 58 | + | matches := m.Matches() | |||
| 59 | + | require.Len(t, matches, 1) | |||
| 60 | + | require.Equal(t, []int{0}, matches[0].Positions) | |||
| 61 | + | ||||
| 62 | + | matches[0].Positions[0] = 2 | |||
| 63 | + | ||||
| 64 | + | fresh := m.Matches() | |||
| 65 | + | require.Equal(t, []int{0}, fresh[0].Positions) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestRenderDrawsInputCaretSelectedLineAndCodeHighlight(t *testing.T) { | |||
| 69 | + | m := New([]command.Command{ | |||
| 70 | + | cmd("CPY", "Copy", "Enter copy mode"), | |||
| 71 | + | cmd("CNT", "New", "Create tab"), | |||
| 72 | + | }) | |||
| 73 | + | m.Insert('c') | |||
| 74 | + | m.Insert('y') | |||
| 75 | + | frame := m.Render(domain.Size{Cols: 28, Rows: 3}) | |||
| 76 | + | ||||
| 77 | + | require.Equal(t, '>', frame.At(0, 0).Rune) | |||
| 78 | + | require.Equal(t, ' ', frame.At(1, 0).Rune) | |||
| 79 | + | require.Equal(t, 'c', frame.At(2, 0).Rune) | |||
| 80 | + | require.Equal(t, 'y', frame.At(3, 0).Rune) | |||
| 81 | + | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | |||
| 82 | + | ||||
| 83 | + | require.Equal(t, 'C', frame.At(0, 1).Rune) | |||
| 84 | + | require.Equal(t, 'P', frame.At(1, 1).Rune) | |||
| 85 | + | require.Equal(t, 'Y', frame.At(2, 1).Rune) | |||
| 86 | + | require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse") | |||
| 87 | + | require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse") | |||
| 88 | + | require.True(t, frame.At(0, 1).Style.Bold, "matched C is bold") | |||
| 89 | + | require.False(t, frame.At(1, 1).Style.Bold, "unmatched P is not highlighted") | |||
| 90 | + | require.True(t, frame.At(2, 1).Style.Bold, "matched Y is bold") | |||
| 91 | + | } | |||