detached 0ede052

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close