refactor(ui): extract shared text input
- commit
- b3d7152760d160801739965ca170f2ab4376404b
- author
- brice · · 2026-07-03 09:17 UTC
- parent
- 9725a6d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +100 -10
M internal/usecase/palette/model.go +7 -10
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | type Model struct { | |
| 11 | 11 | commands []command.Command | |
| 12 | - | input []rune | |
| 12 | + | input ui.TextInput | |
| 13 | 13 | matches []Match | |
| 14 | 14 | selected int | |
| 15 | 15 | scroll int | |
| @@ -25,15 +25,15 @@ func NewRegistry() *Model { return New(command.Registry()) } | |||
| 25 | 25 | ||
| 26 | 26 | func (m *Model) Insert(r rune) { | |
| 27 | 27 | if m != nil { | |
| 28 | - | m.input = append(m.input, r) | |
| 28 | + | m.input.Insert(r) | |
| 29 | 29 | m.selected = 0 | |
| 30 | 30 | m.scroll = 0 | |
| 31 | 31 | m.refresh() | |
| 32 | 32 | } | |
| 33 | 33 | } | |
| 34 | 34 | 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() | |
| 37 | 37 | m.selected = 0 | |
| 38 | 38 | m.scroll = 0 | |
| 39 | 39 | m.refresh() | |
| @@ -43,7 +43,7 @@ func (m *Model) Query() string { | |||
| 43 | 43 | if m == nil { | |
| 44 | 44 | return "" | |
| 45 | 45 | } | |
| 46 | - | return string(m.input) | |
| 46 | + | return m.input.Value() | |
| 47 | 47 | } | |
| 48 | 48 | func (m *Model) Up() { | |
| 49 | 49 | if m != nil && m.selected > 0 { | |
| @@ -75,7 +75,7 @@ func (m *Model) Matches() []Match { | |||
| 75 | 75 | 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 | 79 | func (m *Model) clamp() { | |
| 80 | 80 | if len(m.matches) == 0 { | |
| 81 | 81 | m.selected = -1 | |
| @@ -103,10 +103,7 @@ func (m *Model) Render(inner domain.Size) renderer.Frame { | |||
| 103 | 103 | } | |
| 104 | 104 | base := renderer.DefaultStyle() | |
| 105 | 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 | - | } | |
| 106 | + | ui.DrawInputLine(frame, 0, "> ", m.Query(), base) | |
| 110 | 107 | if m == nil { | |
| 111 | 108 | return frame | |
| 112 | 109 | } | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | type Model struct { | 10 | type Model struct { | ||
| 11 | commands []command.Command | 11 | commands []command.Command | ||
| 12 | - | input []rune | 12 | + | input ui.TextInput |
| 13 | matches []Match | 13 | matches []Match | ||
| 14 | selected int | 14 | selected int | ||
| 15 | scroll int | 15 | 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 = 0 | 29 | m.selected = 0 | ||
| 30 | m.scroll = 0 | 30 | 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 = 0 | 37 | m.selected = 0 | ||
| 38 | m.scroll = 0 | 38 | 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 out | 75 | 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 = -1 | 81 | 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 frame | 108 | 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 | + | } | |||