detached b3d7152

refactor(ui): extract shared text input

commit
b3d7152760d160801739965ca170f2ab4376404b
author
brice · · 2026-07-03 09:17 UTC
parent
9725a6d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +100 -10
M internal/usecase/palette/model.go +7 -10
@@ -9,7 +9,7 @@ import (
99
1010 type Model struct {
1111 commands []command.Command
12- input []rune
12+ input ui.TextInput
1313 matches []Match
1414 selected int
1515 scroll int
@@ -25,15 +25,15 @@ func NewRegistry() *Model { return New(command.Registry()) }
2525
2626 func (m *Model) Insert(r rune) {
2727 if m != nil {
28- m.input = append(m.input, r)
28+ m.input.Insert(r)
2929 m.selected = 0
3030 m.scroll = 0
3131 m.refresh()
3232 }
3333 }
3434 func (m *Model) Backspace() {
35- if m != nil && len(m.input) > 0 {
36- m.input = m.input[:len(m.input)-1]
35+ if m != nil && m.input.Value() != "" {
36+ m.input.Backspace()
3737 m.selected = 0
3838 m.scroll = 0
3939 m.refresh()
@@ -43,7 +43,7 @@ func (m *Model) Query() string {
4343 if m == nil {
4444 return ""
4545 }
46- return string(m.input)
46+ return m.input.Value()
4747 }
4848 func (m *Model) Up() {
4949 if m != nil && m.selected > 0 {
@@ -75,7 +75,7 @@ func (m *Model) Matches() []Match {
7575 return out
7676 }
7777
78-func (m *Model) refresh() { m.matches = Fuzzy(m.commands, string(m.input)); m.clamp() }
78+func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }
7979 func (m *Model) clamp() {
8080 if len(m.matches) == 0 {
8181 m.selected = -1
@@ -103,10 +103,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
103103 }
104104 base := renderer.DefaultStyle()
105105 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- }
106+ ui.DrawInputLine(frame, 0, "> ", m.Query(), base)
110107 if m == nil {
111108 return frame
112109 }
@@ -9,7 +9,7 @@ import (
9 9
10 type Model struct {10 type Model struct {
11 commands []command.Command11 commands []command.Command
12- input []rune12+ input ui.TextInput
13 matches []Match13 matches []Match
14 selected int14 selected int
15 scroll int15 scroll int
@@ -25,15 +25,15 @@ func NewRegistry() *Model { return New(command.Registry()) }
25 25
26 func (m *Model) Insert(r rune) {26 func (m *Model) Insert(r rune) {
27 if m != nil {27 if m != nil {
28- m.input = append(m.input, r)28+ m.input.Insert(r)
29 m.selected = 029 m.selected = 0
30 m.scroll = 030 m.scroll = 0
31 m.refresh()31 m.refresh()
32 }32 }
33 }33 }
34 func (m *Model) Backspace() {34 func (m *Model) Backspace() {
35- if m != nil && len(m.input) > 0 {35+ if m != nil && m.input.Value() != "" {
36- m.input = m.input[:len(m.input)-1]36+ m.input.Backspace()
37 m.selected = 037 m.selected = 0
38 m.scroll = 038 m.scroll = 0
39 m.refresh()39 m.refresh()
@@ -43,7 +43,7 @@ func (m *Model) Query() string {
43 if m == nil {43 if m == nil {
44 return ""44 return ""
45 }45 }
46- return string(m.input)46+ return m.input.Value()
47 }47 }
48 func (m *Model) Up() {48 func (m *Model) Up() {
49 if m != nil && m.selected > 0 {49 if m != nil && m.selected > 0 {
@@ -75,7 +75,7 @@ func (m *Model) Matches() []Match {
75 return out75 return out
76 }76 }
77 77
78-func (m *Model) refresh() { m.matches = Fuzzy(m.commands, string(m.input)); m.clamp() }78+func (m *Model) refresh() { m.matches = Fuzzy(m.commands, m.input.Value()); m.clamp() }
79 func (m *Model) clamp() {79 func (m *Model) clamp() {
80 if len(m.matches) == 0 {80 if len(m.matches) == 0 {
81 m.selected = -181 m.selected = -1
@@ -103,10 +103,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame {
103 }103 }
104 base := renderer.DefaultStyle()104 base := renderer.DefaultStyle()
105 ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base})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)106+ ui.DrawInputLine(frame, 0, "> ", 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 {107 if m == nil {
111 return frame108 return frame
112 }109 }
A internal/usecase/ui/textinput.go +34 -0
@@ -0,0 +1,34 @@
1+package ui
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// TextInput stores editable text as runes for terminal overlay input models.
6+type TextInput struct {
7+ runes []rune
8+}
9+
10+func (t *TextInput) Insert(r rune) { t.runes = append(t.runes, r) }
11+
12+func (t *TextInput) Backspace() {
13+ if len(t.runes) == 0 {
14+ return
15+ }
16+ t.runes = t.runes[:len(t.runes)-1]
17+}
18+
19+func (t *TextInput) Value() string { return string(t.runes) }
20+
21+func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
22+
23+// DrawInputLine draws prefix + value followed by a reverse-video caret cell.
24+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
25+ if y < 0 || y >= f.Height {
26+ return
27+ }
28+ x := DrawText(f, 0, y, f.Width, prefix+value, style)
29+ if x >= 0 && x < f.Width {
30+ caret := style
31+ caret.Inverse = true
32+ f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})
33+ }
34+}
@@ -0,0 +1,34 @@
1+package ui
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// TextInput stores editable text as runes for terminal overlay input models.
6+type TextInput struct {
7+ runes []rune
8+}
9+
10+func (t *TextInput) Insert(r rune) { t.runes = append(t.runes, r) }
11+
12+func (t *TextInput) Backspace() {
13+ if len(t.runes) == 0 {
14+ return
15+ }
16+ t.runes = t.runes[:len(t.runes)-1]
17+}
18+
19+func (t *TextInput) Value() string { return string(t.runes) }
20+
21+func (t *TextInput) SetValue(value string) { t.runes = []rune(value) }
22+
23+// DrawInputLine draws prefix + value followed by a reverse-video caret cell.
24+func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) {
25+ if y < 0 || y >= f.Height {
26+ return
27+ }
28+ x := DrawText(f, 0, y, f.Width, prefix+value, style)
29+ if x >= 0 && x < f.Width {
30+ caret := style
31+ caret.Inverse = true
32+ f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret})
33+ }
34+}
A internal/usecase/ui/textinput_test.go +59 -0
@@ -0,0 +1,59 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTextInputEditsValue(t *testing.T) {
11+ var input TextInput
12+
13+ input.Insert('w')
14+ input.Insert('ø')
15+ input.Insert('k')
16+ require.Equal(t, "wøk", input.Value())
17+
18+ input.Backspace()
19+ require.Equal(t, "wø", input.Value())
20+
21+ input.SetValue("ship")
22+ require.Equal(t, "ship", input.Value())
23+}
24+
25+func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
26+ frame := renderer.NewFrame(6, 1)
27+ style := renderer.DefaultStyle()
28+
29+ DrawInputLine(frame, 0, "> ", "abc", style)
30+
31+ require.Equal(t, '>', frame.At(0, 0).Rune)
32+ require.Equal(t, ' ', frame.At(1, 0).Rune)
33+ require.Equal(t, 'a', frame.At(2, 0).Rune)
34+ require.Equal(t, 'b', frame.At(3, 0).Rune)
35+ require.Equal(t, 'c', frame.At(4, 0).Rune)
36+ require.Equal(t, ' ', frame.At(5, 0).Rune)
37+ require.True(t, frame.At(5, 0).Style.Inverse)
38+}
39+
40+func TestDrawInputLineClipsCaret(t *testing.T) {
41+ frame := renderer.NewFrame(4, 1)
42+ style := renderer.DefaultStyle()
43+
44+ DrawInputLine(frame, 0, "> ", "abcdef", style)
45+
46+ require.Equal(t, '>', frame.At(0, 0).Rune)
47+ require.Equal(t, ' ', frame.At(1, 0).Rune)
48+ require.Equal(t, 'a', frame.At(2, 0).Rune)
49+ require.Equal(t, 'b', frame.At(3, 0).Rune)
50+ require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell")
51+}
52+
53+func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
54+ frame := renderer.NewFrame(4, 1)
55+
56+ DrawInputLine(frame, 2, "> ", "abc", renderer.DefaultStyle())
57+
58+ require.Equal(t, ' ', frame.At(0, 0).Rune)
59+}
@@ -0,0 +1,59 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestTextInputEditsValue(t *testing.T) {
11+ var input TextInput
12+
13+ input.Insert('w')
14+ input.Insert('ø')
15+ input.Insert('k')
16+ require.Equal(t, "wøk", input.Value())
17+
18+ input.Backspace()
19+ require.Equal(t, "wø", input.Value())
20+
21+ input.SetValue("ship")
22+ require.Equal(t, "ship", input.Value())
23+}
24+
25+func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) {
26+ frame := renderer.NewFrame(6, 1)
27+ style := renderer.DefaultStyle()
28+
29+ DrawInputLine(frame, 0, "> ", "abc", style)
30+
31+ require.Equal(t, '>', frame.At(0, 0).Rune)
32+ require.Equal(t, ' ', frame.At(1, 0).Rune)
33+ require.Equal(t, 'a', frame.At(2, 0).Rune)
34+ require.Equal(t, 'b', frame.At(3, 0).Rune)
35+ require.Equal(t, 'c', frame.At(4, 0).Rune)
36+ require.Equal(t, ' ', frame.At(5, 0).Rune)
37+ require.True(t, frame.At(5, 0).Style.Inverse)
38+}
39+
40+func TestDrawInputLineClipsCaret(t *testing.T) {
41+ frame := renderer.NewFrame(4, 1)
42+ style := renderer.DefaultStyle()
43+
44+ DrawInputLine(frame, 0, "> ", "abcdef", style)
45+
46+ require.Equal(t, '>', frame.At(0, 0).Rune)
47+ require.Equal(t, ' ', frame.At(1, 0).Rune)
48+ require.Equal(t, 'a', frame.At(2, 0).Rune)
49+ require.Equal(t, 'b', frame.At(3, 0).Rune)
50+ require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell")
51+}
52+
53+func TestDrawInputLineIgnoresInvalidRow(t *testing.T) {
54+ frame := renderer.NewFrame(4, 1)
55+
56+ DrawInputLine(frame, 2, "> ", "abc", renderer.DefaultStyle())
57+
58+ require.Equal(t, ' ', frame.At(0, 0).Rune)
59+}

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 12

Tags 11

↑↓ moveenter openesc close