feat(vim): focus first page input
- commit
- f52290ef7bac22cb67c4c0af68e8e92dfeee81ca
- author
- brice · · 2026-08-14 13:05 UTC
- parent
- 04133a4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +140 -2
M internal/application/port/webview.go +7 -0
| @@ -476,3 +476,10 @@ type SemanticNavigationRequest struct { | |||
| 476 | 476 | type SemanticNavigable interface { | |
| 477 | 477 | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | |
| 478 | 478 | } | |
| 479 | + | ||
| 480 | + | // PageInputFocuser is an optional WebView capability for focusing the first | |
| 481 | + | // eligible page input from Vim Mode. Unsupported engines safely leave it | |
| 482 | + | // unimplemented. | |
| 483 | + | type PageInputFocuser interface { | |
| 484 | + | FocusFirstInput() | |
| 485 | + | } | |
| @@ -476,3 +476,10 @@ type SemanticNavigationRequest struct { | |||||
| 476 | type SemanticNavigable interface { | 476 | type SemanticNavigable interface { | ||
| 477 | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | 477 | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | ||
| 478 | } | 478 | } | ||
| 479 | + | ||||
| 480 | + | // PageInputFocuser is an optional WebView capability for focusing the first | |||
| 481 | + | // eligible page input from Vim Mode. Unsupported engines safely leave it | |||
| 482 | + | // unimplemented. | |||
| 483 | + | type PageInputFocuser interface { | |||
| 484 | + | FocusFirstInput() | |||
| 485 | + | } | |||
A internal/infrastructure/cef/input_focus.go +47 -0
| @@ -0,0 +1,47 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | var _ port.PageInputFocuser = (*WebView)(nil) | |
| 10 | + | ||
| 11 | + | // FocusFirstInput focuses the first visible, editable page input when one is | |
| 12 | + | // available. The operation is intentionally fire-and-forget because the DOM | |
| 13 | + | // is owned by the renderer and may change after the Vim command is handled. | |
| 14 | + | func (wv *WebView) FocusFirstInput() { | |
| 15 | + | if wv == nil || wv.destroyed.Load() { | |
| 16 | + | return | |
| 17 | + | } | |
| 18 | + | wv.RunJavaScript(context.Background(), focusFirstInputScript()) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func focusFirstInputScript() string { | |
| 22 | + | return `(() => { | |
| 23 | + | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | |
| 24 | + | const hiddenInputTypes = new Set([ | |
| 25 | + | "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit" | |
| 26 | + | ]); | |
| 27 | + | const isVisible = (element) => { | |
| 28 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |
| 29 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |
| 30 | + | if (element.getClientRects().length === 0) return false; | |
| 31 | + | const style = window.getComputedStyle(element); | |
| 32 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |
| 33 | + | }; | |
| 34 | + | const isEligible = (element) => { | |
| 35 | + | if (!isVisible(element)) return false; | |
| 36 | + | if (element instanceof HTMLInputElement) { | |
| 37 | + | return !element.disabled && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase()); | |
| 38 | + | } | |
| 39 | + | if (element instanceof HTMLTextAreaElement) { | |
| 40 | + | return !element.disabled && !element.readOnly; | |
| 41 | + | } | |
| 42 | + | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | |
| 43 | + | }; | |
| 44 | + | const target = candidates.find(isEligible); | |
| 45 | + | if (target) target.focus(); | |
| 46 | + | })();` | |
| 47 | + | } | |
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | var _ port.PageInputFocuser = (*WebView)(nil) | |||
| 10 | + | ||||
| 11 | + | // FocusFirstInput focuses the first visible, editable page input when one is | |||
| 12 | + | // available. The operation is intentionally fire-and-forget because the DOM | |||
| 13 | + | // is owned by the renderer and may change after the Vim command is handled. | |||
| 14 | + | func (wv *WebView) FocusFirstInput() { | |||
| 15 | + | if wv == nil || wv.destroyed.Load() { | |||
| 16 | + | return | |||
| 17 | + | } | |||
| 18 | + | wv.RunJavaScript(context.Background(), focusFirstInputScript()) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func focusFirstInputScript() string { | |||
| 22 | + | return `(() => { | |||
| 23 | + | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | |||
| 24 | + | const hiddenInputTypes = new Set([ | |||
| 25 | + | "button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit" | |||
| 26 | + | ]); | |||
| 27 | + | const isVisible = (element) => { | |||
| 28 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |||
| 29 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |||
| 30 | + | if (element.getClientRects().length === 0) return false; | |||
| 31 | + | const style = window.getComputedStyle(element); | |||
| 32 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |||
| 33 | + | }; | |||
| 34 | + | const isEligible = (element) => { | |||
| 35 | + | if (!isVisible(element)) return false; | |||
| 36 | + | if (element instanceof HTMLInputElement) { | |||
| 37 | + | return !element.disabled && !element.readOnly && !hiddenInputTypes.has(element.type.toLowerCase()); | |||
| 38 | + | } | |||
| 39 | + | if (element instanceof HTMLTextAreaElement) { | |||
| 40 | + | return !element.disabled && !element.readOnly; | |||
| 41 | + | } | |||
| 42 | + | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | |||
| 43 | + | }; | |||
| 44 | + | const target = candidates.find(isEligible); | |||
| 45 | + | if (target) target.focus(); | |||
| 46 | + | })();` | |||
| 47 | + | } | |||
A internal/infrastructure/cef/input_focus_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 7 | + | "github.com/stretchr/testify/mock" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) { | |
| 12 | + | browser := cefmocks.NewMockBrowser(t) | |
| 13 | + | frame := cefmocks.NewMockFrame(t) | |
| 14 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 15 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 16 | + | `document.querySelectorAll("input,textarea,[contenteditable]")`, | |
| 17 | + | `element.closest("[hidden],[inert],[aria-hidden=\"true\"]")`, | |
| 18 | + | "getClientRects", | |
| 19 | + | "HTMLInputElement", | |
| 20 | + | "HTMLTextAreaElement", | |
| 21 | + | "target.focus()", | |
| 22 | + | )), "", int32(0)).Once() | |
| 23 | + | ||
| 24 | + | (&WebView{browser: browser}).FocusFirstInput() | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestFocusFirstInputScriptNoOpsWithoutEligibleTarget(t *testing.T) { | |
| 28 | + | script := focusFirstInputScript() | |
| 29 | + | ||
| 30 | + | require.Contains(t, script, "const target = candidates.find(isEligible);") | |
| 31 | + | require.Contains(t, script, "if (target) target.focus();") | |
| 32 | + | require.Contains(t, script, "!element.disabled && !element.readOnly") | |
| 33 | + | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 7 | + | "github.com/stretchr/testify/mock" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) { | |||
| 12 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 13 | + | frame := cefmocks.NewMockFrame(t) | |||
| 14 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 15 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 16 | + | `document.querySelectorAll("input,textarea,[contenteditable]")`, | |||
| 17 | + | `element.closest("[hidden],[inert],[aria-hidden=\"true\"]")`, | |||
| 18 | + | "getClientRects", | |||
| 19 | + | "HTMLInputElement", | |||
| 20 | + | "HTMLTextAreaElement", | |||
| 21 | + | "target.focus()", | |||
| 22 | + | )), "", int32(0)).Once() | |||
| 23 | + | ||||
| 24 | + | (&WebView{browser: browser}).FocusFirstInput() | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestFocusFirstInputScriptNoOpsWithoutEligibleTarget(t *testing.T) { | |||
| 28 | + | script := focusFirstInputScript() | |||
| 29 | + | ||||
| 30 | + | require.Contains(t, script, "const target = candidates.find(isEligible);") | |||
| 31 | + | require.Contains(t, script, "if (target) target.focus();") | |||
| 32 | + | require.Contains(t, script, "!element.disabled && !element.readOnly") | |||
| 33 | + | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | |||
| 34 | + | } | |||
M internal/infrastructure/config/defaults.go +1 -0
| @@ -320,6 +320,7 @@ func DefaultConfig() *Config { | |||
| 320 | 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | |
| 321 | 321 | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | |
| 322 | 322 | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | |
| 323 | + | "focus-input": {Keys: []string{"gi"}, Desc: "Focus first page input"}, | |
| 323 | 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | |
| 324 | 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | |
| 325 | 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | |
| @@ -320,6 +320,7 @@ func DefaultConfig() *Config { | |||||
| 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | 320 | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"}, | ||
| 321 | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | 321 | "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"}, | ||
| 322 | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | 322 | "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"}, | ||
| 323 | + | "focus-input": {Keys: []string{"gi"}, Desc: "Focus first page input"}, | |||
| 323 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | ||
| 324 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | ||
| 325 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | ||
M internal/infrastructure/config/vim_mode_config_test.go +4 -2
| @@ -22,6 +22,7 @@ var legacyVimModeActions = []string{ | |||
| 22 | 22 | var sequenceVimModeActions = map[string]string{ | |
| 23 | 23 | "heading-next": "]]", | |
| 24 | 24 | "heading-prev": "[[", | |
| 25 | + | "focus-input": "gi", | |
| 25 | 26 | "code-next": "]c", | |
| 26 | 27 | "code-prev": "[c", | |
| 27 | 28 | "table-next": "]t", | |
| @@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) { | |||
| 65 | 66 | vimMode := cfg.Workspace.VimMode | |
| 66 | 67 | ||
| 67 | 68 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | |
| 68 | - | require.Len(t, sequenceVimModeActions, 11) | |
| 69 | + | require.Len(t, sequenceVimModeActions, 12) | |
| 69 | 70 | ||
| 70 | 71 | for action, key := range sequenceVimModeActions { | |
| 71 | 72 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | |
| @@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 118 | 119 | assert.Equal(t, "cancel", bindings["escape"]) | |
| 119 | 120 | assert.Equal(t, "heading-next", bindings["]]"]) | |
| 120 | 121 | assert.Equal(t, "heading-prev", bindings["[["]) | |
| 122 | + | assert.Equal(t, "focus-input", bindings["gi"]) | |
| 121 | 123 | assert.Equal(t, "code-next", bindings["]c"]) | |
| 122 | 124 | assert.Equal(t, "code-prev", bindings["[c"]) | |
| 123 | 125 | assert.Equal(t, "table-next", bindings["]t"]) | |
| @@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||
| 127 | 129 | assert.Equal(t, "yank-section", bindings["yah"]) | |
| 128 | 130 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | |
| 129 | 131 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | |
| 130 | - | assert.Len(t, bindings, 19) | |
| 132 | + | assert.Len(t, bindings, 20) | |
| 131 | 133 | } | |
| 132 | 134 | ||
| 133 | 135 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | |
| @@ -22,6 +22,7 @@ var legacyVimModeActions = []string{ | |||||
| 22 | var sequenceVimModeActions = map[string]string{ | 22 | var sequenceVimModeActions = map[string]string{ | ||
| 23 | "heading-next": "]]", | 23 | "heading-next": "]]", | ||
| 24 | "heading-prev": "[[", | 24 | "heading-prev": "[[", | ||
| 25 | + | "focus-input": "gi", | |||
| 25 | "code-next": "]c", | 26 | "code-next": "]c", | ||
| 26 | "code-prev": "[c", | 27 | "code-prev": "[c", | ||
| 27 | "table-next": "]t", | 28 | "table-next": "]t", | ||
| @@ -65,7 +66,7 @@ func TestVimModeSequenceDefaults(t *testing.T) { | |||||
| 65 | vimMode := cfg.Workspace.VimMode | 66 | vimMode := cfg.Workspace.VimMode | ||
| 66 | 67 | ||||
| 67 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | 68 | assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds) | ||
| 68 | - | require.Len(t, sequenceVimModeActions, 11) | 69 | + | require.Len(t, sequenceVimModeActions, 12) |
| 69 | 70 | ||||
| 70 | for action, key := range sequenceVimModeActions { | 71 | for action, key := range sequenceVimModeActions { | ||
| 71 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | 72 | requireActionBinding(t, vimMode.Actions, action, []string{key}) | ||
| @@ -118,6 +119,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||||
| 118 | assert.Equal(t, "cancel", bindings["escape"]) | 119 | assert.Equal(t, "cancel", bindings["escape"]) | ||
| 119 | assert.Equal(t, "heading-next", bindings["]]"]) | 120 | assert.Equal(t, "heading-next", bindings["]]"]) | ||
| 120 | assert.Equal(t, "heading-prev", bindings["[["]) | 121 | assert.Equal(t, "heading-prev", bindings["[["]) | ||
| 122 | + | assert.Equal(t, "focus-input", bindings["gi"]) | |||
| 121 | assert.Equal(t, "code-next", bindings["]c"]) | 123 | assert.Equal(t, "code-next", bindings["]c"]) | ||
| 122 | assert.Equal(t, "code-prev", bindings["[c"]) | 124 | assert.Equal(t, "code-prev", bindings["[c"]) | ||
| 123 | assert.Equal(t, "table-next", bindings["]t"]) | 125 | assert.Equal(t, "table-next", bindings["]t"]) | ||
| @@ -127,7 +129,7 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) { | |||||
| 127 | assert.Equal(t, "yank-section", bindings["yah"]) | 129 | assert.Equal(t, "yank-section", bindings["yah"]) | ||
| 128 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | 130 | assert.Equal(t, "half-page-down", bindings["<C-d>"]) | ||
| 129 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | 131 | assert.Equal(t, "half-page-up", bindings["<C-u>"]) | ||
| 130 | - | assert.Len(t, bindings, 19) | 132 | + | assert.Len(t, bindings, 20) |
| 131 | } | 133 | } | ||
| 132 | 134 | ||||
| 133 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | 135 | func TestValidateVimMode_NonNegativeTimeout(t *testing.T) { | ||
M internal/ui/app.go +8 -0
| @@ -2164,6 +2164,14 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||
| 2164 | 2164 | } | |
| 2165 | 2165 | ||
| 2166 | 2166 | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |
| 2167 | + | if action == "focus-input" { | |
| 2168 | + | _, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2169 | + | if focuser, ok := wv.(port.PageInputFocuser); ok { | |
| 2170 | + | focuser.FocusFirstInput() | |
| 2171 | + | } | |
| 2172 | + | return | |
| 2173 | + | } | |
| 2174 | + | ||
| 2167 | 2175 | var request port.SemanticNavigationRequest | |
| 2168 | 2176 | switch action { | |
| 2169 | 2177 | case "heading-next": | |
| @@ -2164,6 +2164,14 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||||
| 2164 | } | 2164 | } | ||
| 2165 | 2165 | ||||
| 2166 | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | 2166 | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | ||
| 2167 | + | if action == "focus-input" { | |||
| 2168 | + | _, wv := a.activeWebViewForBrowserWindow(bw) | |||
| 2169 | + | if focuser, ok := wv.(port.PageInputFocuser); ok { | |||
| 2170 | + | focuser.FocusFirstInput() | |||
| 2171 | + | } | |||
| 2172 | + | return | |||
| 2173 | + | } | |||
| 2174 | + | ||||
| 2167 | var request port.SemanticNavigationRequest | 2175 | var request port.SemanticNavigationRequest | ||
| 2168 | switch action { | 2176 | switch action { | ||
| 2169 | case "heading-next": | 2177 | case "heading-next": | ||
M internal/ui/app_vim_mode_test.go +27 -0
| @@ -190,6 +190,22 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||
| 190 | 190 | return nil | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | + | type inputFocusingWebView struct { | |
| 194 | + | *portmocks.MockWebView | |
| 195 | + | focusFirstInputCalls int | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | |
| 199 | + | t.Helper() | |
| 200 | + | wv := portmocks.NewMockWebView(t) | |
| 201 | + | wv.EXPECT().ID().Return(id).Once() | |
| 202 | + | return &inputFocusingWebView{MockWebView: wv} | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (wv *inputFocusingWebView) FocusFirstInput() { | |
| 206 | + | wv.focusFirstInputCalls++ | |
| 207 | + | } | |
| 208 | + | ||
| 193 | 209 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | |
| 194 | 210 | t.Helper() | |
| 195 | 211 | paneID := entity.PaneID("pane-a") | |
| @@ -460,6 +476,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||
| 460 | 476 | }, wv.requests) | |
| 461 | 477 | } | |
| 462 | 478 | ||
| 479 | + | func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) { | |
| 480 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 481 | + | wv := newInputFocusingWebView(t, 6) | |
| 482 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 483 | + | ||
| 484 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |
| 485 | + | ||
| 486 | + | assert.Equal(t, 1, wv.focusFirstInputCalls) | |
| 487 | + | } | |
| 488 | + | ||
| 463 | 489 | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |
| 464 | 490 | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 465 | 491 | wv := portmocks.NewMockWebView(t) | |
| @@ -467,6 +493,7 @@ func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |||
| 467 | 493 | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 468 | 494 | ||
| 469 | 495 | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |
| 496 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |
| 470 | 497 | } | |
| 471 | 498 | ||
| 472 | 499 | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | |
| @@ -190,6 +190,22 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||||
| 190 | return nil | 190 | return nil | ||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | + | type inputFocusingWebView struct { | |||
| 194 | + | *portmocks.MockWebView | |||
| 195 | + | focusFirstInputCalls int | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | |||
| 199 | + | t.Helper() | |||
| 200 | + | wv := portmocks.NewMockWebView(t) | |||
| 201 | + | wv.EXPECT().ID().Return(id).Once() | |||
| 202 | + | return &inputFocusingWebView{MockWebView: wv} | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (wv *inputFocusingWebView) FocusFirstInput() { | |||
| 206 | + | wv.focusFirstInputCalls++ | |||
| 207 | + | } | |||
| 208 | + | ||||
| 193 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | 209 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | ||
| 194 | t.Helper() | 210 | t.Helper() | ||
| 195 | paneID := entity.PaneID("pane-a") | 211 | paneID := entity.PaneID("pane-a") | ||
| @@ -460,6 +476,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||||
| 460 | }, wv.requests) | 476 | }, wv.requests) | ||
| 461 | } | 477 | } | ||
| 462 | 478 | ||||
| 479 | + | func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) { | |||
| 480 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 481 | + | wv := newInputFocusingWebView(t, 6) | |||
| 482 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 483 | + | ||||
| 484 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |||
| 485 | + | ||||
| 486 | + | assert.Equal(t, 1, wv.focusFirstInputCalls) | |||
| 487 | + | } | |||
| 488 | + | ||||
| 463 | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | 489 | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | ||
| 464 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 490 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 465 | wv := portmocks.NewMockWebView(t) | 491 | wv := portmocks.NewMockWebView(t) | ||
| @@ -467,6 +493,7 @@ func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |||||
| 467 | app.contentCoord.RegisterPopupWebView(paneID, wv) | 493 | app.contentCoord.RegisterPopupWebView(paneID, wv) | ||
| 468 | 494 | ||||
| 469 | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | 495 | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | ||
| 496 | + | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |||
| 470 | } | 497 | } | ||
| 471 | 498 | ||||
| 472 | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | 499 | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | ||
M internal/ui/input/vimkeys_dispatch_test.go +12 -0
| @@ -87,6 +87,7 @@ func TestNewVimModeSequenceState_NilTrie(t *testing.T) { | |||
| 87 | 87 | func TestTrieOwnsBinding(t *testing.T) { | |
| 88 | 88 | cases := map[string]bool{ | |
| 89 | 89 | "]]": true, | |
| 90 | + | "gi": true, | |
| 90 | 91 | "yah": true, | |
| 91 | 92 | "gO": true, | |
| 92 | 93 | "<C-d>": true, | |
| @@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 156 | 157 | Actions: map[string]entity.ActionBinding{ | |
| 157 | 158 | "vim-scroll-down": {Keys: []string{"j"}}, | |
| 158 | 159 | "heading-next": {Keys: []string{"]]"}}, | |
| 160 | + | "focus-input": {Keys: []string{"gi"}}, | |
| 159 | 161 | "yank-section": {Keys: []string{"yah"}}, | |
| 160 | 162 | "outline": {Keys: []string{"gO"}}, | |
| 161 | 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | |
| @@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 171 | 173 | ||
| 172 | 174 | wantOwned := map[string]bool{ | |
| 173 | 175 | "]]": true, | |
| 176 | + | "gi": true, | |
| 174 | 177 | "yah": true, | |
| 175 | 178 | "gO": true, | |
| 176 | 179 | "<C-d>": true, | |
| @@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) { | |||
| 194 | 197 | if !ok || !node.Exact || node.Action != "heading-next" { | |
| 195 | 198 | t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok) | |
| 196 | 199 | } | |
| 200 | + | ||
| 201 | + | seq, err = vimkeys.ParseBinding("gi") | |
| 202 | + | if err != nil { | |
| 203 | + | t.Fatalf("ParseBinding(gi) error = %v", err) | |
| 204 | + | } | |
| 205 | + | node, ok = trie.Walk(seq) | |
| 206 | + | if !ok || !node.Exact || node.Action != "focus-input" { | |
| 207 | + | t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok) | |
| 208 | + | } | |
| 197 | 209 | } | |
| 198 | 210 | ||
| 199 | 211 | func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) { | |
| @@ -87,6 +87,7 @@ func TestNewVimModeSequenceState_NilTrie(t *testing.T) { | |||||
| 87 | func TestTrieOwnsBinding(t *testing.T) { | 87 | func TestTrieOwnsBinding(t *testing.T) { | ||
| 88 | cases := map[string]bool{ | 88 | cases := map[string]bool{ | ||
| 89 | "]]": true, | 89 | "]]": true, | ||
| 90 | + | "gi": true, | |||
| 90 | "yah": true, | 91 | "yah": true, | ||
| 91 | "gO": true, | 92 | "gO": true, | ||
| 92 | "<C-d>": true, | 93 | "<C-d>": true, | ||
| @@ -156,6 +157,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 156 | Actions: map[string]entity.ActionBinding{ | 157 | Actions: map[string]entity.ActionBinding{ | ||
| 157 | "vim-scroll-down": {Keys: []string{"j"}}, | 158 | "vim-scroll-down": {Keys: []string{"j"}}, | ||
| 158 | "heading-next": {Keys: []string{"]]"}}, | 159 | "heading-next": {Keys: []string{"]]"}}, | ||
| 160 | + | "focus-input": {Keys: []string{"gi"}}, | |||
| 159 | "yank-section": {Keys: []string{"yah"}}, | 161 | "yank-section": {Keys: []string{"yah"}}, | ||
| 160 | "outline": {Keys: []string{"gO"}}, | 162 | "outline": {Keys: []string{"gO"}}, | ||
| 161 | "half-page-down": {Keys: []string{"<C-d>"}}, | 163 | "half-page-down": {Keys: []string{"<C-d>"}}, | ||
| @@ -171,6 +173,7 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 171 | 173 | ||||
| 172 | wantOwned := map[string]bool{ | 174 | wantOwned := map[string]bool{ | ||
| 173 | "]]": true, | 175 | "]]": true, | ||
| 176 | + | "gi": true, | |||
| 174 | "yah": true, | 177 | "yah": true, | ||
| 175 | "gO": true, | 178 | "gO": true, | ||
| 176 | "<C-d>": true, | 179 | "<C-d>": true, | ||
| @@ -194,6 +197,15 @@ func TestBuildVimModeTrie(t *testing.T) { | |||||
| 194 | if !ok || !node.Exact || node.Action != "heading-next" { | 197 | if !ok || !node.Exact || node.Action != "heading-next" { | ||
| 195 | t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok) | 198 | t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok) | ||
| 196 | } | 199 | } | ||
| 200 | + | ||||
| 201 | + | seq, err = vimkeys.ParseBinding("gi") | |||
| 202 | + | if err != nil { | |||
| 203 | + | t.Fatalf("ParseBinding(gi) error = %v", err) | |||
| 204 | + | } | |||
| 205 | + | node, ok = trie.Walk(seq) | |||
| 206 | + | if !ok || !node.Exact || node.Action != "focus-input" { | |||
| 207 | + | t.Fatalf("Walk(gi) = (%#v, %v), want exact focus-input", node, ok) | |||
| 208 | + | } | |||
| 197 | } | 209 | } | ||
| 198 | 210 | ||||
| 199 | func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) { | 211 | func TestBuildVimModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) { | ||