feat(palette): complete selected shortcodes
- commit
- 4b73ee6c9258d9bdcafdb5f877bb29ea3714a62f
- author
- brice · · 2026-07-14 11:29 UTC
- parent
- 5c6654f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +135 -10
M internal/usecase/palette/model.go +73 -10
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package palette | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "unicode" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/usecase/command" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| @@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) { | |||
| 86 | 88 | return m.matches[m.selected].Command, true | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 91 | + | // CompleteSelected replaces the query with the selected command's effective | |
| 92 | + | // code. Required-argument commands retain the typed argument bytes. | |
| 93 | + | func (m *Model) CompleteSelected() bool { | |
| 94 | + | selected, ok := m.Selected() | |
| 95 | + | if !ok { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | query := m.Query() | |
| 100 | + | completed := selected.Code | |
| 101 | + | if selected.Arguments == command.ArgumentsRequired { | |
| 102 | + | token, args, hasSeparator := completionParts(query) | |
| 103 | + | if token == selected.Code && hasSeparator && args != "" { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | completed += " " | |
| 107 | + | if args != "" { | |
| 108 | + | completed += args | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | if completed == query { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | m.input.SetValue(completed) | |
| 116 | + | m.selected = 0 | |
| 117 | + | m.scroll = 0 | |
| 118 | + | m.refresh() | |
| 119 | + | return true | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // completionParts splits the first token at Unicode whitespace and returns | |
| 123 | + | // the argument beginning at its first non-whitespace byte. | |
| 124 | + | func completionParts(query string) (token, args string, hasSeparator bool) { | |
| 125 | + | for i, r := range query { | |
| 126 | + | if !unicode.IsSpace(r) { | |
| 127 | + | continue | |
| 128 | + | } | |
| 129 | + | for j, argumentRune := range query[i:] { | |
| 130 | + | if !unicode.IsSpace(argumentRune) { | |
| 131 | + | return query[:i], query[i+j:], true | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return query[:i], "", true | |
| 135 | + | } | |
| 136 | + | return query, "", false | |
| 137 | + | } | |
| 138 | + | ||
| 89 | 139 | // ArgumentCommand returns the exact argument-taking command being entered. | |
| 90 | 140 | func (m *Model) ArgumentCommand() (command.Command, bool) { | |
| 91 | 141 | if m == nil { | |
| @@ -107,23 +157,36 @@ func (m *Model) Matches() []Match { | |||
| 107 | 157 | } | |
| 108 | 158 | ||
| 109 | 159 | func (m *Model) refresh() { | |
| 110 | - | m.matches = Fuzzy(m.commands, m.input.Value()) | |
| 160 | + | query := m.input.Value() | |
| 161 | + | m.matches = Fuzzy(m.commands, query) | |
| 111 | 162 | // Once an argument-taking token is exact, retain its row while its | |
| 112 | 163 | // arguments make ordinary fuzzy matching inapplicable. | |
| 113 | - | if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok { | |
| 114 | - | found := false | |
| 115 | - | for _, match := range m.matches { | |
| 116 | - | if match.Command.Code == cmd.Code { | |
| 117 | - | found = true | |
| 118 | - | break | |
| 164 | + | if cmd, ok := ArgumentCommand(m.commands, query); ok { | |
| 165 | + | m.prependMatch(cmd) | |
| 166 | + | } else if len(m.matches) == 0 { | |
| 167 | + | // Arguments are not part of static command matching. When they would | |
| 168 | + | // otherwise clear the list, keep required-argument candidates matched by | |
| 169 | + | // the partial first token so Tab can complete them. | |
| 170 | + | token, _, hasSeparator := completionParts(query) | |
| 171 | + | if hasSeparator { | |
| 172 | + | for _, match := range Fuzzy(m.commands, token) { | |
| 173 | + | if match.Command.Arguments == command.ArgumentsRequired { | |
| 174 | + | m.matches = append(m.matches, match) | |
| 175 | + | } | |
| 119 | 176 | } | |
| 120 | 177 | } | |
| 121 | - | if !found { | |
| 122 | - | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |
| 123 | - | } | |
| 124 | 178 | } | |
| 125 | 179 | m.clamp() | |
| 126 | 180 | } | |
| 181 | + | ||
| 182 | + | func (m *Model) prependMatch(cmd command.Command) { | |
| 183 | + | for _, match := range m.matches { | |
| 184 | + | if match.Command.Code == cmd.Code { | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |
| 189 | + | } | |
| 127 | 190 | func (m *Model) clamp() { | |
| 128 | 191 | if len(m.matches) == 0 { | |
| 129 | 192 | m.selected = -1 | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package palette | 1 | package palette | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "unicode" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/usecase/command" | 7 | "github.com/bnema/vev/internal/usecase/command" | ||
| 6 | "github.com/bnema/vev/internal/usecase/ui" | 8 | "github.com/bnema/vev/internal/usecase/ui" | ||
| @@ -86,6 +88,54 @@ func (m *Model) Selected() (command.Command, bool) { | |||||
| 86 | return m.matches[m.selected].Command, true | 88 | return m.matches[m.selected].Command, true | ||
| 87 | } | 89 | } | ||
| 88 | 90 | ||||
| 91 | + | // CompleteSelected replaces the query with the selected command's effective | |||
| 92 | + | // code. Required-argument commands retain the typed argument bytes. | |||
| 93 | + | func (m *Model) CompleteSelected() bool { | |||
| 94 | + | selected, ok := m.Selected() | |||
| 95 | + | if !ok { | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | query := m.Query() | |||
| 100 | + | completed := selected.Code | |||
| 101 | + | if selected.Arguments == command.ArgumentsRequired { | |||
| 102 | + | token, args, hasSeparator := completionParts(query) | |||
| 103 | + | if token == selected.Code && hasSeparator && args != "" { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | completed += " " | |||
| 107 | + | if args != "" { | |||
| 108 | + | completed += args | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | if completed == query { | |||
| 112 | + | return false | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | m.input.SetValue(completed) | |||
| 116 | + | m.selected = 0 | |||
| 117 | + | m.scroll = 0 | |||
| 118 | + | m.refresh() | |||
| 119 | + | return true | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // completionParts splits the first token at Unicode whitespace and returns | |||
| 123 | + | // the argument beginning at its first non-whitespace byte. | |||
| 124 | + | func completionParts(query string) (token, args string, hasSeparator bool) { | |||
| 125 | + | for i, r := range query { | |||
| 126 | + | if !unicode.IsSpace(r) { | |||
| 127 | + | continue | |||
| 128 | + | } | |||
| 129 | + | for j, argumentRune := range query[i:] { | |||
| 130 | + | if !unicode.IsSpace(argumentRune) { | |||
| 131 | + | return query[:i], query[i+j:], true | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return query[:i], "", true | |||
| 135 | + | } | |||
| 136 | + | return query, "", false | |||
| 137 | + | } | |||
| 138 | + | ||||
| 89 | // ArgumentCommand returns the exact argument-taking command being entered. | 139 | // ArgumentCommand returns the exact argument-taking command being entered. | ||
| 90 | func (m *Model) ArgumentCommand() (command.Command, bool) { | 140 | func (m *Model) ArgumentCommand() (command.Command, bool) { | ||
| 91 | if m == nil { | 141 | if m == nil { | ||
| @@ -107,23 +157,36 @@ func (m *Model) Matches() []Match { | |||||
| 107 | } | 157 | } | ||
| 108 | 158 | ||||
| 109 | func (m *Model) refresh() { | 159 | func (m *Model) refresh() { | ||
| 110 | - | m.matches = Fuzzy(m.commands, m.input.Value()) | 160 | + | query := m.input.Value() |
| 161 | + | m.matches = Fuzzy(m.commands, query) | |||
| 111 | // Once an argument-taking token is exact, retain its row while its | 162 | // Once an argument-taking token is exact, retain its row while its | ||
| 112 | // arguments make ordinary fuzzy matching inapplicable. | 163 | // arguments make ordinary fuzzy matching inapplicable. | ||
| 113 | - | if cmd, ok := ArgumentCommand(m.commands, m.input.Value()); ok { | 164 | + | if cmd, ok := ArgumentCommand(m.commands, query); ok { |
| 114 | - | found := false | 165 | + | m.prependMatch(cmd) |
| 115 | - | for _, match := range m.matches { | 166 | + | } else if len(m.matches) == 0 { |
| 116 | - | if match.Command.Code == cmd.Code { | 167 | + | // Arguments are not part of static command matching. When they would |
| 117 | - | found = true | 168 | + | // otherwise clear the list, keep required-argument candidates matched by |
| 118 | - | break | 169 | + | // the partial first token so Tab can complete them. |
| 170 | + | token, _, hasSeparator := completionParts(query) | |||
| 171 | + | if hasSeparator { | |||
| 172 | + | for _, match := range Fuzzy(m.commands, token) { | |||
| 173 | + | if match.Command.Arguments == command.ArgumentsRequired { | |||
| 174 | + | m.matches = append(m.matches, match) | |||
| 175 | + | } | |||
| 119 | } | 176 | } | ||
| 120 | } | 177 | } | ||
| 121 | - | if !found { | |||
| 122 | - | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |||
| 123 | - | } | |||
| 124 | } | 178 | } | ||
| 125 | m.clamp() | 179 | m.clamp() | ||
| 126 | } | 180 | } | ||
| 181 | + | ||||
| 182 | + | func (m *Model) prependMatch(cmd command.Command) { | |||
| 183 | + | for _, match := range m.matches { | |||
| 184 | + | if match.Command.Code == cmd.Code { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | m.matches = append([]Match{{Command: cmd}}, m.matches...) | |||
| 189 | + | } | |||
| 127 | func (m *Model) clamp() { | 190 | func (m *Model) clamp() { | ||
| 128 | if len(m.matches) == 0 { | 191 | if len(m.matches) == 0 { | ||
| 129 | m.selected = -1 | 192 | m.selected = -1 | ||
M internal/usecase/palette/model_test.go +62 -0
| @@ -186,6 +186,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||
| 186 | 186 | } | |
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | + | func TestModelCompleteSelected(t *testing.T) { | |
| 190 | + | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 191 | + | jrs.Arguments = command.ArgumentsRequired | |
| 192 | + | registryFirst := command.Registry()[0] | |
| 193 | + | ||
| 194 | + | tests := []struct { | |
| 195 | + | name string | |
| 196 | + | commands []command.Command | |
| 197 | + | registry bool | |
| 198 | + | query string | |
| 199 | + | down int | |
| 200 | + | want string | |
| 201 | + | wantChange bool | |
| 202 | + | rendered string | |
| 203 | + | }{ | |
| 204 | + | {name: "nil model", want: "", wantChange: false}, | |
| 205 | + | {name: "empty model", commands: nil, want: "", wantChange: false}, | |
| 206 | + | {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code}, | |
| 207 | + | {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true}, | |
| 208 | + | {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true}, | |
| 209 | + | {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true}, | |
| 210 | + | {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true}, | |
| 211 | + | {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true}, | |
| 212 | + | {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true}, | |
| 213 | + | {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true}, | |
| 214 | + | {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false}, | |
| 215 | + | {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false}, | |
| 216 | + | {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true}, | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | for _, tt := range tests { | |
| 220 | + | t.Run(tt.name, func(t *testing.T) { | |
| 221 | + | var m *Model | |
| 222 | + | if tt.name != "nil model" { | |
| 223 | + | if tt.registry { | |
| 224 | + | m = NewRegistry() | |
| 225 | + | } else { | |
| 226 | + | m = New(tt.commands) | |
| 227 | + | } | |
| 228 | + | for _, r := range tt.query { | |
| 229 | + | m.Insert(r) | |
| 230 | + | } | |
| 231 | + | for range tt.down { | |
| 232 | + | m.Down() | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | changed := m.CompleteSelected() | |
| 237 | + | require.Equal(t, tt.wantChange, changed) | |
| 238 | + | if m == nil { | |
| 239 | + | require.Equal(t, tt.want, "") | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | require.Equal(t, tt.want, m.Query()) | |
| 243 | + | if tt.rendered != "" { | |
| 244 | + | frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |
| 245 | + | require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2]) | |
| 246 | + | } | |
| 247 | + | }) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 189 | 251 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | |
| 190 | 252 | jrs := cmd("JRS", "Jump", "Jump to recent session") | |
| 191 | 253 | jrs.Arguments = command.ArgumentsRequired | |
| @@ -186,6 +186,68 @@ func TestRenderUsesConfiguredStyles(t *testing.T) { | |||||
| 186 | } | 186 | } | ||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | + | func TestModelCompleteSelected(t *testing.T) { | |||
| 190 | + | jrs := cmd("JRS", "Jump", "Jump to recent session") | |||
| 191 | + | jrs.Arguments = command.ArgumentsRequired | |||
| 192 | + | registryFirst := command.Registry()[0] | |||
| 193 | + | ||||
| 194 | + | tests := []struct { | |||
| 195 | + | name string | |||
| 196 | + | commands []command.Command | |||
| 197 | + | registry bool | |||
| 198 | + | query string | |||
| 199 | + | down int | |||
| 200 | + | want string | |||
| 201 | + | wantChange bool | |||
| 202 | + | rendered string | |||
| 203 | + | }{ | |||
| 204 | + | {name: "nil model", want: "", wantChange: false}, | |||
| 205 | + | {name: "empty model", commands: nil, want: "", wantChange: false}, | |||
| 206 | + | {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code}, | |||
| 207 | + | {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true}, | |||
| 208 | + | {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true}, | |||
| 209 | + | {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true}, | |||
| 210 | + | {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true}, | |||
| 211 | + | {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true}, | |||
| 212 | + | {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true}, | |||
| 213 | + | {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true}, | |||
| 214 | + | {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false}, | |||
| 215 | + | {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false}, | |||
| 216 | + | {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true}, | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | for _, tt := range tests { | |||
| 220 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 221 | + | var m *Model | |||
| 222 | + | if tt.name != "nil model" { | |||
| 223 | + | if tt.registry { | |||
| 224 | + | m = NewRegistry() | |||
| 225 | + | } else { | |||
| 226 | + | m = New(tt.commands) | |||
| 227 | + | } | |||
| 228 | + | for _, r := range tt.query { | |||
| 229 | + | m.Insert(r) | |||
| 230 | + | } | |||
| 231 | + | for range tt.down { | |||
| 232 | + | m.Down() | |||
| 233 | + | } | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | changed := m.CompleteSelected() | |||
| 237 | + | require.Equal(t, tt.wantChange, changed) | |||
| 238 | + | if m == nil { | |||
| 239 | + | require.Equal(t, tt.want, "") | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | require.Equal(t, tt.want, m.Query()) | |||
| 243 | + | if tt.rendered != "" { | |||
| 244 | + | frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()}) | |||
| 245 | + | require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2]) | |||
| 246 | + | } | |||
| 247 | + | }) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 189 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | 251 | func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) { | ||
| 190 | jrs := cmd("JRS", "Jump", "Jump to recent session") | 252 | jrs := cmd("JRS", "Jump", "Jump to recent session") | ||
| 191 | jrs.Arguments = command.ArgumentsRequired | 253 | jrs.Arguments = command.ArgumentsRequired | ||