fix(vim): cycle inputs and contain reverse focus
- commit
- fc18cde91fce3a0400fe6e5870897e472616496c
- author
- brice · · 2026-08-14 13:45 UTC
- parent
- 8c7ae2b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +189 -32
M internal/application/port/webview.go +11 -3
| @@ -477,9 +477,17 @@ type SemanticNavigable interface { | |||
| 477 | 477 | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | |
| 478 | 478 | } | |
| 479 | 479 | ||
| 480 | - | // PageInputFocuser is an optional WebView capability for focusing the first | |
| 481 | - | // eligible page input from Vim Mode. Unsupported engines safely leave it | |
| 480 | + | // PageInputFocuser is an optional WebView capability for moving Vim Mode | |
| 481 | + | // input focus to the next eligible page input. With no active eligible input, | |
| 482 | + | // implementations focus the first one. Unsupported engines safely leave it | |
| 482 | 483 | // unimplemented. | |
| 483 | 484 | type PageInputFocuser interface { | |
| 484 | - | FocusFirstInput() | |
| 485 | + | FocusNextInput() | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // PageFocusNavigator is an optional WebView capability for keeping native-like | |
| 489 | + | // focus traversal inside the active page when the host window would otherwise | |
| 490 | + | // move focus outside the WebView. | |
| 491 | + | type PageFocusNavigator interface { | |
| 492 | + | NavigatePageFocus(backward bool) | |
| 485 | 493 | } | |
| @@ -477,9 +477,17 @@ type SemanticNavigable interface { | |||||
| 477 | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | 477 | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | ||
| 478 | } | 478 | } | ||
| 479 | 479 | ||||
| 480 | - | // PageInputFocuser is an optional WebView capability for focusing the first | 480 | + | // PageInputFocuser is an optional WebView capability for moving Vim Mode |
| 481 | - | // eligible page input from Vim Mode. Unsupported engines safely leave it | 481 | + | // input focus to the next eligible page input. With no active eligible input, |
| 482 | + | // implementations focus the first one. Unsupported engines safely leave it | |||
| 482 | // unimplemented. | 483 | // unimplemented. | ||
| 483 | type PageInputFocuser interface { | 484 | type PageInputFocuser interface { | ||
| 484 | - | FocusFirstInput() | 485 | + | FocusNextInput() |
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | // PageFocusNavigator is an optional WebView capability for keeping native-like | |||
| 489 | + | // focus traversal inside the active page when the host window would otherwise | |||
| 490 | + | // move focus outside the WebView. | |||
| 491 | + | type PageFocusNavigator interface { | |||
| 492 | + | NavigatePageFocus(backward bool) | |||
| 485 | } | 493 | } | ||
M internal/infrastructure/cef/input_focus.go +59 -9
| @@ -6,19 +6,31 @@ import ( | |||
| 6 | 6 | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | var _ port.PageInputFocuser = (*WebView)(nil) | |
| 9 | + | var ( | |
| 10 | + | _ port.PageInputFocuser = (*WebView)(nil) | |
| 11 | + | _ port.PageFocusNavigator = (*WebView)(nil) | |
| 12 | + | ) | |
| 10 | 13 | ||
| 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() { | |
| 14 | + | // FocusNextInput moves focus to the next visible, editable page input. When | |
| 15 | + | // no eligible input is active it focuses the first one; movement wraps at the | |
| 16 | + | // end so repeated gi commands continue to cycle through the page inputs. | |
| 17 | + | func (wv *WebView) FocusNextInput() { | |
| 15 | 18 | if wv == nil || wv.destroyed.Load() { | |
| 16 | 19 | return | |
| 17 | 20 | } | |
| 18 | - | wv.RunJavaScript(context.Background(), focusFirstInputScript()) | |
| 21 | + | wv.RunJavaScript(context.Background(), focusNextInputScript()) | |
| 19 | 22 | } | |
| 20 | 23 | ||
| 21 | - | func focusFirstInputScript() string { | |
| 24 | + | // NavigatePageFocus keeps reverse and forward focus traversal inside the live | |
| 25 | + | // page when GTK would otherwise move focus out of the WebView. | |
| 26 | + | func (wv *WebView) NavigatePageFocus(backward bool) { | |
| 27 | + | if wv == nil || wv.destroyed.Load() { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | wv.RunJavaScript(context.Background(), pageFocusNavigationScript(backward)) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func focusNextInputScript() string { | |
| 22 | 34 | return `(() => { | |
| 23 | 35 | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | |
| 24 | 36 | const hiddenInputTypes = new Set([ | |
| @@ -41,7 +53,45 @@ func focusFirstInputScript() string { | |||
| 41 | 53 | } | |
| 42 | 54 | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | |
| 43 | 55 | }; | |
| 44 | - | const target = candidates.find(isEligible); | |
| 45 | - | if (target) target.focus(); | |
| 56 | + | const eligible = candidates.filter(isEligible); | |
| 57 | + | if (eligible.length === 0) return; | |
| 58 | + | const active = document.activeElement; | |
| 59 | + | const activeIndex = eligible.findIndex((element) => element === active || element.contains(active)); | |
| 60 | + | const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length]; | |
| 61 | + | target.focus(); | |
| 62 | + | })();` | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func pageFocusNavigationScript(backward bool) string { | |
| 66 | + | direction := "1" | |
| 67 | + | if backward { | |
| 68 | + | direction = "-1" | |
| 69 | + | } | |
| 70 | + | return `(() => { | |
| 71 | + | const direction = ` + direction + `; | |
| 72 | + | const selector = [ | |
| 73 | + | "a[href]", "area[href]", "button:not([disabled])", | |
| 74 | + | "input:not([disabled]):not([type=\"hidden\"])", "select:not([disabled])", | |
| 75 | + | "textarea:not([disabled])", "[contenteditable]:not([contenteditable=\"false\"])", | |
| 76 | + | "[tabindex]:not([tabindex=\"-1\"])" | |
| 77 | + | ].join(","); | |
| 78 | + | const isVisible = (element) => { | |
| 79 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |
| 80 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |
| 81 | + | if (element.getClientRects().length === 0) return false; | |
| 82 | + | const style = window.getComputedStyle(element); | |
| 83 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |
| 84 | + | }; | |
| 85 | + | const candidates = Array.from(document.querySelectorAll(selector)).filter((element) => { | |
| 86 | + | return isVisible(element) && !element.disabled && element.tabIndex >= 0; | |
| 87 | + | }); | |
| 88 | + | if (candidates.length === 0) return; | |
| 89 | + | const active = document.activeElement; | |
| 90 | + | const activeIndex = candidates.findIndex((element) => element === active || element.contains(active)); | |
| 91 | + | const targetIndex = activeIndex < 0 | |
| 92 | + | ? (direction < 0 ? candidates.length - 1 : 0) | |
| 93 | + | : activeIndex + direction; | |
| 94 | + | if (targetIndex < 0 || targetIndex >= candidates.length) return; | |
| 95 | + | candidates[targetIndex].focus(); | |
| 46 | 96 | })();` | |
| 47 | 97 | } | |
| @@ -6,19 +6,31 @@ import ( | |||||
| 6 | "github.com/bnema/dumber/internal/application/port" | 6 | "github.com/bnema/dumber/internal/application/port" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | var _ port.PageInputFocuser = (*WebView)(nil) | 9 | + | var ( |
| 10 | + | _ port.PageInputFocuser = (*WebView)(nil) | |||
| 11 | + | _ port.PageFocusNavigator = (*WebView)(nil) | |||
| 12 | + | ) | |||
| 10 | 13 | ||||
| 11 | - | // FocusFirstInput focuses the first visible, editable page input when one is | 14 | + | // FocusNextInput moves focus to the next visible, editable page input. When |
| 12 | - | // available. The operation is intentionally fire-and-forget because the DOM | 15 | + | // no eligible input is active it focuses the first one; movement wraps at the |
| 13 | - | // is owned by the renderer and may change after the Vim command is handled. | 16 | + | // end so repeated gi commands continue to cycle through the page inputs. |
| 14 | - | func (wv *WebView) FocusFirstInput() { | 17 | + | func (wv *WebView) FocusNextInput() { |
| 15 | if wv == nil || wv.destroyed.Load() { | 18 | if wv == nil || wv.destroyed.Load() { | ||
| 16 | return | 19 | return | ||
| 17 | } | 20 | } | ||
| 18 | - | wv.RunJavaScript(context.Background(), focusFirstInputScript()) | 21 | + | wv.RunJavaScript(context.Background(), focusNextInputScript()) |
| 19 | } | 22 | } | ||
| 20 | 23 | ||||
| 21 | - | func focusFirstInputScript() string { | 24 | + | // NavigatePageFocus keeps reverse and forward focus traversal inside the live |
| 25 | + | // page when GTK would otherwise move focus out of the WebView. | |||
| 26 | + | func (wv *WebView) NavigatePageFocus(backward bool) { | |||
| 27 | + | if wv == nil || wv.destroyed.Load() { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | wv.RunJavaScript(context.Background(), pageFocusNavigationScript(backward)) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func focusNextInputScript() string { | |||
| 22 | return `(() => { | 34 | return `(() => { | ||
| 23 | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | 35 | const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]")); | ||
| 24 | const hiddenInputTypes = new Set([ | 36 | const hiddenInputTypes = new Set([ | ||
| @@ -41,7 +53,45 @@ func focusFirstInputScript() string { | |||||
| 41 | } | 53 | } | ||
| 42 | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | 54 | return element.isContentEditable && element.getAttribute("contenteditable") !== "false"; | ||
| 43 | }; | 55 | }; | ||
| 44 | - | const target = candidates.find(isEligible); | 56 | + | const eligible = candidates.filter(isEligible); |
| 45 | - | if (target) target.focus(); | 57 | + | if (eligible.length === 0) return; |
| 58 | + | const active = document.activeElement; | |||
| 59 | + | const activeIndex = eligible.findIndex((element) => element === active || element.contains(active)); | |||
| 60 | + | const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length]; | |||
| 61 | + | target.focus(); | |||
| 62 | + | })();` | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func pageFocusNavigationScript(backward bool) string { | |||
| 66 | + | direction := "1" | |||
| 67 | + | if backward { | |||
| 68 | + | direction = "-1" | |||
| 69 | + | } | |||
| 70 | + | return `(() => { | |||
| 71 | + | const direction = ` + direction + `; | |||
| 72 | + | const selector = [ | |||
| 73 | + | "a[href]", "area[href]", "button:not([disabled])", | |||
| 74 | + | "input:not([disabled]):not([type=\"hidden\"])", "select:not([disabled])", | |||
| 75 | + | "textarea:not([disabled])", "[contenteditable]:not([contenteditable=\"false\"])", | |||
| 76 | + | "[tabindex]:not([tabindex=\"-1\"])" | |||
| 77 | + | ].join(","); | |||
| 78 | + | const isVisible = (element) => { | |||
| 79 | + | if (!(element instanceof HTMLElement) || !element.isConnected) return false; | |||
| 80 | + | if (element.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false; | |||
| 81 | + | if (element.getClientRects().length === 0) return false; | |||
| 82 | + | const style = window.getComputedStyle(element); | |||
| 83 | + | return style.display !== "none" && style.visibility !== "hidden" && style.visibility !== "collapse"; | |||
| 84 | + | }; | |||
| 85 | + | const candidates = Array.from(document.querySelectorAll(selector)).filter((element) => { | |||
| 86 | + | return isVisible(element) && !element.disabled && element.tabIndex >= 0; | |||
| 87 | + | }); | |||
| 88 | + | if (candidates.length === 0) return; | |||
| 89 | + | const active = document.activeElement; | |||
| 90 | + | const activeIndex = candidates.findIndex((element) => element === active || element.contains(active)); | |||
| 91 | + | const targetIndex = activeIndex < 0 | |||
| 92 | + | ? (direction < 0 ? candidates.length - 1 : 0) | |||
| 93 | + | : activeIndex + direction; | |||
| 94 | + | if (targetIndex < 0 || targetIndex >= candidates.length) return; | |||
| 95 | + | candidates[targetIndex].focus(); | |||
| 46 | })();` | 96 | })();` | ||
| 47 | } | 97 | } | ||
M internal/infrastructure/cef/input_focus_test.go +32 -6
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) { | |
| 11 | + | func TestWebViewFocusNextInputExecutesSafeFocusScript(t *testing.T) { | |
| 12 | 12 | browser := cefmocks.NewMockBrowser(t) | |
| 13 | 13 | frame := cefmocks.NewMockFrame(t) | |
| 14 | 14 | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| @@ -18,17 +18,43 @@ func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) { | |||
| 18 | 18 | "getClientRects", | |
| 19 | 19 | "HTMLInputElement", | |
| 20 | 20 | "HTMLTextAreaElement", | |
| 21 | + | "activeIndex", | |
| 21 | 22 | "target.focus()", | |
| 22 | 23 | )), "", int32(0)).Once() | |
| 23 | 24 | ||
| 24 | - | (&WebView{browser: browser}).FocusFirstInput() | |
| 25 | + | (&WebView{browser: browser}).FocusNextInput() | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | - | func TestFocusFirstInputScriptNoOpsWithoutEligibleTarget(t *testing.T) { | |
| 28 | - | script := focusFirstInputScript() | |
| 28 | + | func TestWebViewNavigatePageFocusExecutesDirectionalScript(t *testing.T) { | |
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |
| 30 | + | frame := cefmocks.NewMockFrame(t) | |
| 31 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 32 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 33 | + | "const direction = -1;", | |
| 34 | + | "a[href]", | |
| 35 | + | "textarea:not([disabled])", | |
| 36 | + | "document.activeElement", | |
| 37 | + | "targetIndex", | |
| 38 | + | "candidates[targetIndex].focus()", | |
| 39 | + | )), "", int32(0)).Once() | |
| 29 | 40 | ||
| 30 | - | require.Contains(t, script, "const target = candidates.find(isEligible);") | |
| 31 | - | require.Contains(t, script, "if (target) target.focus();") | |
| 41 | + | (&WebView{browser: browser}).NavigatePageFocus(true) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestFocusNextInputScriptWrapsAndNoOpsWithoutEligibleTarget(t *testing.T) { | |
| 45 | + | script := focusNextInputScript() | |
| 46 | + | ||
| 47 | + | require.Contains(t, script, "const eligible = candidates.filter(isEligible);") | |
| 48 | + | require.Contains(t, script, "if (eligible.length === 0) return;") | |
| 49 | + | require.Contains(t, script, "const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length];") | |
| 32 | 50 | require.Contains(t, script, "!element.disabled && !element.readOnly") | |
| 33 | 51 | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | |
| 34 | 52 | } | |
| 53 | + | ||
| 54 | + | func TestPageFocusNavigationScriptStopsAtPageBoundaries(t *testing.T) { | |
| 55 | + | script := pageFocusNavigationScript(true) | |
| 56 | + | ||
| 57 | + | require.Contains(t, script, "const direction = -1;") | |
| 58 | + | require.Contains(t, script, "if (targetIndex < 0 || targetIndex >= candidates.length) return;") | |
| 59 | + | require.Contains(t, script, "candidates[targetIndex].focus();") | |
| 60 | + | } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) { | 11 | + | func TestWebViewFocusNextInputExecutesSafeFocusScript(t *testing.T) { |
| 12 | browser := cefmocks.NewMockBrowser(t) | 12 | browser := cefmocks.NewMockBrowser(t) | ||
| 13 | frame := cefmocks.NewMockFrame(t) | 13 | frame := cefmocks.NewMockFrame(t) | ||
| 14 | browser.EXPECT().GetMainFrame().Return(frame).Once() | 14 | browser.EXPECT().GetMainFrame().Return(frame).Once() | ||
| @@ -18,17 +18,43 @@ func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) { | |||||
| 18 | "getClientRects", | 18 | "getClientRects", | ||
| 19 | "HTMLInputElement", | 19 | "HTMLInputElement", | ||
| 20 | "HTMLTextAreaElement", | 20 | "HTMLTextAreaElement", | ||
| 21 | + | "activeIndex", | |||
| 21 | "target.focus()", | 22 | "target.focus()", | ||
| 22 | )), "", int32(0)).Once() | 23 | )), "", int32(0)).Once() | ||
| 23 | 24 | ||||
| 24 | - | (&WebView{browser: browser}).FocusFirstInput() | 25 | + | (&WebView{browser: browser}).FocusNextInput() |
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | - | func TestFocusFirstInputScriptNoOpsWithoutEligibleTarget(t *testing.T) { | 28 | + | func TestWebViewNavigatePageFocusExecutesDirectionalScript(t *testing.T) { |
| 28 | - | script := focusFirstInputScript() | 29 | + | browser := cefmocks.NewMockBrowser(t) |
| 30 | + | frame := cefmocks.NewMockFrame(t) | |||
| 31 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 32 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 33 | + | "const direction = -1;", | |||
| 34 | + | "a[href]", | |||
| 35 | + | "textarea:not([disabled])", | |||
| 36 | + | "document.activeElement", | |||
| 37 | + | "targetIndex", | |||
| 38 | + | "candidates[targetIndex].focus()", | |||
| 39 | + | )), "", int32(0)).Once() | |||
| 29 | 40 | ||||
| 30 | - | require.Contains(t, script, "const target = candidates.find(isEligible);") | 41 | + | (&WebView{browser: browser}).NavigatePageFocus(true) |
| 31 | - | require.Contains(t, script, "if (target) target.focus();") | 42 | + | } |
| 43 | + | ||||
| 44 | + | func TestFocusNextInputScriptWrapsAndNoOpsWithoutEligibleTarget(t *testing.T) { | |||
| 45 | + | script := focusNextInputScript() | |||
| 46 | + | ||||
| 47 | + | require.Contains(t, script, "const eligible = candidates.filter(isEligible);") | |||
| 48 | + | require.Contains(t, script, "if (eligible.length === 0) return;") | |||
| 49 | + | require.Contains(t, script, "const target = eligible[(activeIndex + 1 + eligible.length) % eligible.length];") | |||
| 32 | require.Contains(t, script, "!element.disabled && !element.readOnly") | 50 | require.Contains(t, script, "!element.disabled && !element.readOnly") | ||
| 33 | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | 51 | require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`) | ||
| 34 | } | 52 | } | ||
| 53 | + | ||||
| 54 | + | func TestPageFocusNavigationScriptStopsAtPageBoundaries(t *testing.T) { | |||
| 55 | + | script := pageFocusNavigationScript(true) | |||
| 56 | + | ||||
| 57 | + | require.Contains(t, script, "const direction = -1;") | |||
| 58 | + | require.Contains(t, script, "if (targetIndex < 0 || targetIndex >= candidates.length) return;") | |||
| 59 | + | require.Contains(t, script, "candidates[targetIndex].focus();") | |||
| 60 | + | } | |||
M internal/infrastructure/config/defaults.go +1 -1
| @@ -320,7 +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 | + | "focus-input": {Keys: []string{"gi"}, Desc: "Focus next page input"}, | |
| 324 | 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | |
| 325 | 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | |
| 326 | 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | |
| @@ -320,7 +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 | + | "focus-input": {Keys: []string{"gi"}, Desc: "Focus next page input"}, |
| 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | 324 | "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"}, | ||
| 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | 325 | "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"}, | ||
| 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | 326 | "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"}, | ||
M internal/ui/app.go +17 -1
| @@ -1104,6 +1104,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1104 | 1104 | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | |
| 1105 | 1105 | a.navigateVimSequenceAction(ctx, bw, action, count) | |
| 1106 | 1106 | }) | |
| 1107 | + | bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool { | |
| 1108 | + | return a.navigatePageFocus(navigationCtx, bw, backward) | |
| 1109 | + | }) | |
| 1107 | 1110 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1108 | 1111 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 1109 | 1112 | return input.RoutePassToWidget | |
| @@ -2163,11 +2166,24 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||
| 2163 | 2166 | return paneID, a.contentCoord.GetWebView(paneID) | |
| 2164 | 2167 | } | |
| 2165 | 2168 | ||
| 2169 | + | func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward bool) bool { | |
| 2170 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2171 | + | if paneID == "" || !a.pageEditableFocused(paneID) { | |
| 2172 | + | return false | |
| 2173 | + | } | |
| 2174 | + | navigator, ok := wv.(port.PageFocusNavigator) | |
| 2175 | + | if !ok { | |
| 2176 | + | return false | |
| 2177 | + | } | |
| 2178 | + | navigator.NavigatePageFocus(backward) | |
| 2179 | + | return true | |
| 2180 | + | } | |
| 2181 | + | ||
| 2166 | 2182 | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |
| 2167 | 2183 | if action == "focus-input" { | |
| 2168 | 2184 | _, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2169 | 2185 | if focuser, ok := wv.(port.PageInputFocuser); ok { | |
| 2170 | - | focuser.FocusFirstInput() | |
| 2186 | + | focuser.FocusNextInput() | |
| 2171 | 2187 | } | |
| 2172 | 2188 | return | |
| 2173 | 2189 | } | |
| @@ -1104,6 +1104,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 1104 | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | 1104 | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | ||
| 1105 | a.navigateVimSequenceAction(ctx, bw, action, count) | 1105 | a.navigateVimSequenceAction(ctx, bw, action, count) | ||
| 1106 | }) | 1106 | }) | ||
| 1107 | + | bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool { | |||
| 1108 | + | return a.navigatePageFocus(navigationCtx, bw, backward) | |||
| 1109 | + | }) | |||
| 1107 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | 1110 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | ||
| 1108 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | 1111 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | ||
| 1109 | return input.RoutePassToWidget | 1112 | return input.RoutePassToWidget | ||
| @@ -2163,11 +2166,24 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||||
| 2163 | return paneID, a.contentCoord.GetWebView(paneID) | 2166 | return paneID, a.contentCoord.GetWebView(paneID) | ||
| 2164 | } | 2167 | } | ||
| 2165 | 2168 | ||||
| 2169 | + | func (a *App) navigatePageFocus(_ context.Context, bw *browserWindow, backward bool) bool { | |||
| 2170 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |||
| 2171 | + | if paneID == "" || !a.pageEditableFocused(paneID) { | |||
| 2172 | + | return false | |||
| 2173 | + | } | |||
| 2174 | + | navigator, ok := wv.(port.PageFocusNavigator) | |||
| 2175 | + | if !ok { | |||
| 2176 | + | return false | |||
| 2177 | + | } | |||
| 2178 | + | navigator.NavigatePageFocus(backward) | |||
| 2179 | + | return true | |||
| 2180 | + | } | |||
| 2181 | + | ||||
| 2166 | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | 2182 | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | ||
| 2167 | if action == "focus-input" { | 2183 | if action == "focus-input" { | ||
| 2168 | _, wv := a.activeWebViewForBrowserWindow(bw) | 2184 | _, wv := a.activeWebViewForBrowserWindow(bw) | ||
| 2169 | if focuser, ok := wv.(port.PageInputFocuser); ok { | 2185 | if focuser, ok := wv.(port.PageInputFocuser); ok { | ||
| 2170 | - | focuser.FocusFirstInput() | 2186 | + | focuser.FocusNextInput() |
| 2171 | } | 2187 | } | ||
| 2172 | return | 2188 | return | ||
| 2173 | } | 2189 | } | ||
M internal/ui/app_vim_mode_test.go +20 -5
| @@ -192,7 +192,8 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||
| 192 | 192 | ||
| 193 | 193 | type inputFocusingWebView struct { | |
| 194 | 194 | *portmocks.MockWebView | |
| 195 | - | focusFirstInputCalls int | |
| 195 | + | focusNextInputCalls int | |
| 196 | + | pageFocusNavigationCalls []bool | |
| 196 | 197 | } | |
| 197 | 198 | ||
| 198 | 199 | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | |
| @@ -202,8 +203,12 @@ func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebV | |||
| 202 | 203 | return &inputFocusingWebView{MockWebView: wv} | |
| 203 | 204 | } | |
| 204 | 205 | ||
| 205 | - | func (wv *inputFocusingWebView) FocusFirstInput() { | |
| 206 | - | wv.focusFirstInputCalls++ | |
| 206 | + | func (wv *inputFocusingWebView) FocusNextInput() { | |
| 207 | + | wv.focusNextInputCalls++ | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) { | |
| 211 | + | wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward) | |
| 207 | 212 | } | |
| 208 | 213 | ||
| 209 | 214 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | |
| @@ -476,14 +481,24 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||
| 476 | 481 | }, wv.requests) | |
| 477 | 482 | } | |
| 478 | 483 | ||
| 479 | - | func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) { | |
| 484 | + | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | |
| 480 | 485 | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 481 | 486 | wv := newInputFocusingWebView(t, 6) | |
| 482 | 487 | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 483 | 488 | ||
| 484 | 489 | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | |
| 485 | 490 | ||
| 486 | - | assert.Equal(t, 1, wv.focusFirstInputCalls) | |
| 491 | + | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | |
| 495 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 496 | + | wv := newInputFocusingWebView(t, 7) | |
| 497 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 498 | + | app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true} | |
| 499 | + | ||
| 500 | + | assert.True(t, app.navigatePageFocus(context.Background(), bw, true)) | |
| 501 | + | assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls) | |
| 487 | 502 | } | |
| 488 | 503 | ||
| 489 | 504 | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |
| @@ -192,7 +192,8 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||||
| 192 | 192 | ||||
| 193 | type inputFocusingWebView struct { | 193 | type inputFocusingWebView struct { | ||
| 194 | *portmocks.MockWebView | 194 | *portmocks.MockWebView | ||
| 195 | - | focusFirstInputCalls int | 195 | + | focusNextInputCalls int |
| 196 | + | pageFocusNavigationCalls []bool | |||
| 196 | } | 197 | } | ||
| 197 | 198 | ||||
| 198 | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | 199 | func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView { | ||
| @@ -202,8 +203,12 @@ func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebV | |||||
| 202 | return &inputFocusingWebView{MockWebView: wv} | 203 | return &inputFocusingWebView{MockWebView: wv} | ||
| 203 | } | 204 | } | ||
| 204 | 205 | ||||
| 205 | - | func (wv *inputFocusingWebView) FocusFirstInput() { | 206 | + | func (wv *inputFocusingWebView) FocusNextInput() { |
| 206 | - | wv.focusFirstInputCalls++ | 207 | + | wv.focusNextInputCalls++ |
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (wv *inputFocusingWebView) NavigatePageFocus(backward bool) { | |||
| 211 | + | wv.pageFocusNavigationCalls = append(wv.pageFocusNavigationCalls, backward) | |||
| 207 | } | 212 | } | ||
| 208 | 213 | ||||
| 209 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | 214 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | ||
| @@ -476,14 +481,24 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||||
| 476 | }, wv.requests) | 481 | }, wv.requests) | ||
| 477 | } | 482 | } | ||
| 478 | 483 | ||||
| 479 | - | func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) { | 484 | + | func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { |
| 480 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 485 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 481 | wv := newInputFocusingWebView(t, 6) | 486 | wv := newInputFocusingWebView(t, 6) | ||
| 482 | app.contentCoord.RegisterPopupWebView(paneID, wv) | 487 | app.contentCoord.RegisterPopupWebView(paneID, wv) | ||
| 483 | 488 | ||||
| 484 | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | 489 | app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1) | ||
| 485 | 490 | ||||
| 486 | - | assert.Equal(t, 1, wv.focusFirstInputCalls) | 491 | + | assert.Equal(t, 1, wv.focusNextInputCalls) |
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | |||
| 495 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 496 | + | wv := newInputFocusingWebView(t, 7) | |||
| 497 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 498 | + | app.pageEditableFocusByPane = map[entity.PaneID]bool{paneID: true} | |||
| 499 | + | ||||
| 500 | + | assert.True(t, app.navigatePageFocus(context.Background(), bw, true)) | |||
| 501 | + | assert.Equal(t, []bool{true}, wv.pageFocusNavigationCalls) | |||
| 487 | } | 502 | } | ||
| 488 | 503 | ||||
| 489 | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | 504 | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | ||
M internal/ui/input/keyboard.go +31 -7
| @@ -49,6 +49,10 @@ const ( | |||
| 49 | 49 | // matching stop event without changing the normal configurable action API. | |
| 50 | 50 | type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error | |
| 51 | 51 | ||
| 52 | + | // PageFocusNavigationHandler handles native-like focus traversal inside the | |
| 53 | + | // focused page. It returns true when the event was handled by the page. | |
| 54 | + | type PageFocusNavigationHandler func(ctx context.Context, backward bool) bool | |
| 55 | + | ||
| 52 | 56 | const ( | |
| 53 | 57 | vimScrollHoldDelay = 250 * time.Millisecond | |
| 54 | 58 | vimScrollCadence = 16 * time.Millisecond | |
| @@ -77,8 +81,9 @@ type KeyboardHandler struct { | |||
| 77 | 81 | session *entity.SessionConfig | |
| 78 | 82 | ||
| 79 | 83 | // Action handler callbacks. | |
| 80 | - | onAction ActionHandler | |
| 81 | - | onVimScrollLifecycle VimScrollLifecycleHandler | |
| 84 | + | onAction ActionHandler | |
| 85 | + | onVimScrollLifecycle VimScrollLifecycleHandler | |
| 86 | + | onPageFocusNavigation PageFocusNavigationHandler | |
| 82 | 87 | // Optional routing callback that determines how a key should be handled. | |
| 83 | 88 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | |
| 84 | 89 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | |
| @@ -163,6 +168,14 @@ func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) | |||
| 163 | 168 | h.onVimScrollLifecycle = fn | |
| 164 | 169 | } | |
| 165 | 170 | ||
| 171 | + | // SetOnPageFocusNavigation sets the optional callback for keeping page focus | |
| 172 | + | // traversal inside the active WebView. | |
| 173 | + | func (h *KeyboardHandler) SetOnPageFocusNavigation(fn PageFocusNavigationHandler) { | |
| 174 | + | h.mu.Lock() | |
| 175 | + | defer h.mu.Unlock() | |
| 176 | + | h.onPageFocusNavigation = fn | |
| 177 | + | } | |
| 178 | + | ||
| 166 | 179 | // SetVimModeActivationPassthrough sets the callback that can block Vim mode | |
| 167 | 180 | // activation and let the original key event propagate instead. | |
| 168 | 181 | func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | |
| @@ -407,11 +420,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 407 | 420 | } | |
| 408 | 421 | } | |
| 409 | 422 | ||
| 410 | - | // Page focus traversal remains native in normal mode. Keep Tab and | |
| 411 | - | // Shift+Tab out of the shortcut lookup so a focused CEF page control can | |
| 412 | - | // handle both directions consistently. | |
| 423 | + | // Page focus traversal must stay out of shortcut lookup. The callback keeps | |
| 424 | + | // traversal inside CEF when the active page control needs it; otherwise the | |
| 425 | + | // event passes through to the native widget. | |
| 413 | 426 | if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) { | |
| 414 | - | return false | |
| 427 | + | return h.handlePageFocusNavigation(keyval, modifiers) | |
| 415 | 428 | } | |
| 416 | 429 | ||
| 417 | 430 | // Determine routing for this key event | |
| @@ -619,8 +632,19 @@ func normalizeKeyval(keyval uint) uint { | |||
| 619 | 632 | return keyval | |
| 620 | 633 | } | |
| 621 | 634 | ||
| 635 | + | func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool { | |
| 636 | + | h.mu.RLock() | |
| 637 | + | handler := h.onPageFocusNavigation | |
| 638 | + | h.mu.RUnlock() | |
| 639 | + | if handler == nil { | |
| 640 | + | return false | |
| 641 | + | } | |
| 642 | + | backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift | |
| 643 | + | return handler(h.ctx, backward) | |
| 644 | + | } | |
| 645 | + | ||
| 622 | 646 | func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | |
| 623 | - | if mode != ModeNormal { | |
| 647 | + | if mode != ModeNormal && mode != ModeVim { | |
| 624 | 648 | return false | |
| 625 | 649 | } | |
| 626 | 650 | switch keyval { | |
| @@ -49,6 +49,10 @@ const ( | |||||
| 49 | // matching stop event without changing the normal configurable action API. | 49 | // matching stop event without changing the normal configurable action API. | ||
| 50 | type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error | 50 | type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error | ||
| 51 | 51 | ||||
| 52 | + | // PageFocusNavigationHandler handles native-like focus traversal inside the | |||
| 53 | + | // focused page. It returns true when the event was handled by the page. | |||
| 54 | + | type PageFocusNavigationHandler func(ctx context.Context, backward bool) bool | |||
| 55 | + | ||||
| 52 | const ( | 56 | const ( | ||
| 53 | vimScrollHoldDelay = 250 * time.Millisecond | 57 | vimScrollHoldDelay = 250 * time.Millisecond | ||
| 54 | vimScrollCadence = 16 * time.Millisecond | 58 | vimScrollCadence = 16 * time.Millisecond | ||
| @@ -77,8 +81,9 @@ type KeyboardHandler struct { | |||||
| 77 | session *entity.SessionConfig | 81 | session *entity.SessionConfig | ||
| 78 | 82 | ||||
| 79 | // Action handler callbacks. | 83 | // Action handler callbacks. | ||
| 80 | - | onAction ActionHandler | 84 | + | onAction ActionHandler |
| 81 | - | onVimScrollLifecycle VimScrollLifecycleHandler | 85 | + | onVimScrollLifecycle VimScrollLifecycleHandler |
| 86 | + | onPageFocusNavigation PageFocusNavigationHandler | |||
| 82 | // Optional routing callback that determines how a key should be handled. | 87 | // Optional routing callback that determines how a key should be handled. | ||
| 83 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | 88 | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | ||
| 84 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | 89 | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | ||
| @@ -163,6 +168,14 @@ func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) | |||||
| 163 | h.onVimScrollLifecycle = fn | 168 | h.onVimScrollLifecycle = fn | ||
| 164 | } | 169 | } | ||
| 165 | 170 | ||||
| 171 | + | // SetOnPageFocusNavigation sets the optional callback for keeping page focus | |||
| 172 | + | // traversal inside the active WebView. | |||
| 173 | + | func (h *KeyboardHandler) SetOnPageFocusNavigation(fn PageFocusNavigationHandler) { | |||
| 174 | + | h.mu.Lock() | |||
| 175 | + | defer h.mu.Unlock() | |||
| 176 | + | h.onPageFocusNavigation = fn | |||
| 177 | + | } | |||
| 178 | + | ||||
| 166 | // SetVimModeActivationPassthrough sets the callback that can block Vim mode | 179 | // SetVimModeActivationPassthrough sets the callback that can block Vim mode | ||
| 167 | // activation and let the original key event propagate instead. | 180 | // activation and let the original key event propagate instead. | ||
| 168 | func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | 181 | func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { | ||
| @@ -407,11 +420,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 407 | } | 420 | } | ||
| 408 | } | 421 | } | ||
| 409 | 422 | ||||
| 410 | - | // Page focus traversal remains native in normal mode. Keep Tab and | 423 | + | // Page focus traversal must stay out of shortcut lookup. The callback keeps |
| 411 | - | // Shift+Tab out of the shortcut lookup so a focused CEF page control can | 424 | + | // traversal inside CEF when the active page control needs it; otherwise the |
| 412 | - | // handle both directions consistently. | 425 | + | // event passes through to the native widget. |
| 413 | if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) { | 426 | if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) { | ||
| 414 | - | return false | 427 | + | return h.handlePageFocusNavigation(keyval, modifiers) |
| 415 | } | 428 | } | ||
| 416 | 429 | ||||
| 417 | // Determine routing for this key event | 430 | // Determine routing for this key event | ||
| @@ -619,8 +632,19 @@ func normalizeKeyval(keyval uint) uint { | |||||
| 619 | return keyval | 632 | return keyval | ||
| 620 | } | 633 | } | ||
| 621 | 634 | ||||
| 635 | + | func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool { | |||
| 636 | + | h.mu.RLock() | |||
| 637 | + | handler := h.onPageFocusNavigation | |||
| 638 | + | h.mu.RUnlock() | |||
| 639 | + | if handler == nil { | |||
| 640 | + | return false | |||
| 641 | + | } | |||
| 642 | + | backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift | |||
| 643 | + | return handler(h.ctx, backward) | |||
| 644 | + | } | |||
| 645 | + | ||||
| 622 | func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | 646 | func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool { | ||
| 623 | - | if mode != ModeNormal { | 647 | + | if mode != ModeNormal && mode != ModeVim { |
| 624 | return false | 648 | return false | ||
| 625 | } | 649 | } | ||
| 626 | switch keyval { | 650 | switch keyval { | ||
M internal/ui/input/keyboard_test.go +18 -0
| @@ -942,6 +942,24 @@ func TestHandleKeyPress_NormalModeTabKeysPassThroughNatively(t *testing.T) { | |||
| 942 | 942 | } | |
| 943 | 943 | } | |
| 944 | 944 | ||
| 945 | + | func TestHandleKeyPress_PageFocusNavigationHandlerConsumesReverseTab(t *testing.T) { | |
| 946 | + | ctx := context.Background() | |
| 947 | + | workspace := newTestWorkspace() | |
| 948 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |
| 949 | + | var gotBackward bool | |
| 950 | + | h.SetOnPageFocusNavigation(func(_ context.Context, backward bool) bool { | |
| 951 | + | gotBackward = backward | |
| 952 | + | return true | |
| 953 | + | }) | |
| 954 | + | ||
| 955 | + | if !h.handleKeyPress(uint(gdk.KEY_ISO_Left_Tab), 0, gdk.ShiftMaskValue) { | |
| 956 | + | t.Fatal("handled page focus navigation should consume reverse tab") | |
| 957 | + | } | |
| 958 | + | if !gotBackward { | |
| 959 | + | t.Fatal("reverse tab should call page focus navigation backward") | |
| 960 | + | } | |
| 961 | + | } | |
| 962 | + | ||
| 945 | 963 | func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) { | |
| 946 | 964 | ctx := context.Background() | |
| 947 | 965 | workspace := newTestWorkspace() | |
| @@ -942,6 +942,24 @@ func TestHandleKeyPress_NormalModeTabKeysPassThroughNatively(t *testing.T) { | |||||
| 942 | } | 942 | } | ||
| 943 | } | 943 | } | ||
| 944 | 944 | ||||
| 945 | + | func TestHandleKeyPress_PageFocusNavigationHandlerConsumesReverseTab(t *testing.T) { | |||
| 946 | + | ctx := context.Background() | |||
| 947 | + | workspace := newTestWorkspace() | |||
| 948 | + | h := NewKeyboardHandler(ctx, workspace, newTestSession()) | |||
| 949 | + | var gotBackward bool | |||
| 950 | + | h.SetOnPageFocusNavigation(func(_ context.Context, backward bool) bool { | |||
| 951 | + | gotBackward = backward | |||
| 952 | + | return true | |||
| 953 | + | }) | |||
| 954 | + | ||||
| 955 | + | if !h.handleKeyPress(uint(gdk.KEY_ISO_Left_Tab), 0, gdk.ShiftMaskValue) { | |||
| 956 | + | t.Fatal("handled page focus navigation should consume reverse tab") | |||
| 957 | + | } | |||
| 958 | + | if !gotBackward { | |||
| 959 | + | t.Fatal("reverse tab should call page focus navigation backward") | |||
| 960 | + | } | |||
| 961 | + | } | |||
| 962 | + | ||||
| 945 | func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) { | 963 | func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) { | ||
| 946 | ctx := context.Background() | 964 | ctx := context.Background() | ||
| 947 | workspace := newTestWorkspace() | 965 | workspace := newTestWorkspace() | ||