detached fc18cde

fix(vim): cycle inputs and contain reverse focus

commit
fc18cde91fce3a0400fe6e5870897e472616496c
author
brice · · 2026-08-14 13:45 UTC
parent
8c7ae2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
477477 NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error
478478 }
479479
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
482483 // unimplemented.
483484 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)
485493 }
@@ -477,9 +477,17 @@ type SemanticNavigable interface {
477 NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error477 NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error
478 }478 }
479 479
480-// PageInputFocuser is an optional WebView capability for focusing the first480+// PageInputFocuser is an optional WebView capability for moving Vim Mode
481-// eligible page input from Vim Mode. Unsupported engines safely leave it481+// 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 (
66 "github.com/bnema/dumber/internal/application/port"
77 )
88
9-var _ port.PageInputFocuser = (*WebView)(nil)
9+var (
10+ _ port.PageInputFocuser = (*WebView)(nil)
11+ _ port.PageFocusNavigator = (*WebView)(nil)
12+)
1013
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() {
1518 if wv == nil || wv.destroyed.Load() {
1619 return
1720 }
18- wv.RunJavaScript(context.Background(), focusFirstInputScript())
21+ wv.RunJavaScript(context.Background(), focusNextInputScript())
1922 }
2023
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 {
2234 return `(() => {
2335 const candidates = Array.from(document.querySelectorAll("input,textarea,[contenteditable]"));
2436 const hiddenInputTypes = new Set([
@@ -41,7 +53,45 @@ func focusFirstInputScript() string {
4153 }
4254 return element.isContentEditable && element.getAttribute("contenteditable") !== "false";
4355 };
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();
4696 })();`
4797 }
@@ -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 is14+// FocusNextInput moves focus to the next visible, editable page input. When
12-// available. The operation is intentionally fire-and-forget because the DOM15+// 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 return19 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 (
88 "github.com/stretchr/testify/require"
99 )
1010
11-func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) {
11+func TestWebViewFocusNextInputExecutesSafeFocusScript(t *testing.T) {
1212 browser := cefmocks.NewMockBrowser(t)
1313 frame := cefmocks.NewMockFrame(t)
1414 browser.EXPECT().GetMainFrame().Return(frame).Once()
@@ -18,17 +18,43 @@ func TestWebViewFocusFirstInputExecutesSafeFocusScript(t *testing.T) {
1818 "getClientRects",
1919 "HTMLInputElement",
2020 "HTMLTextAreaElement",
21+ "activeIndex",
2122 "target.focus()",
2223 )), "", int32(0)).Once()
2324
24- (&WebView{browser: browser}).FocusFirstInput()
25+ (&WebView{browser: browser}).FocusNextInput()
2526 }
2627
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()
2940
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];")
3250 require.Contains(t, script, "!element.disabled && !element.readOnly")
3351 require.Contains(t, script, `hiddenInputTypes.has(element.type.toLowerCase())`)
3452 }
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 {
320320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322322 "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"},
324324 "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
325325 "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
326326 "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) {
11041104 bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) {
11051105 a.navigateVimSequenceAction(ctx, bw, action, count)
11061106 })
1107+ bw.keyboardHandler.SetOnPageFocusNavigation(func(navigationCtx context.Context, backward bool) bool {
1108+ return a.navigatePageFocus(navigationCtx, bw, backward)
1109+ })
11071110 bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute {
11081111 if bw.sessionManager != nil && bw.sessionManager.IsVisible() {
11091112 return input.RoutePassToWidget
@@ -2163,11 +2166,24 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p
21632166 return paneID, a.contentCoord.GetWebView(paneID)
21642167 }
21652168
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+
21662182 func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) {
21672183 if action == "focus-input" {
21682184 _, wv := a.activeWebViewForBrowserWindow(bw)
21692185 if focuser, ok := wv.(port.PageInputFocuser); ok {
2170- focuser.FocusFirstInput()
2186+ focuser.FocusNextInput()
21712187 }
21722188 return
21732189 }
@@ -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.RoutePassToWidget1112 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 return2188 return
2173 }2189 }
M internal/ui/app_vim_mode_test.go +20 -5
@@ -192,7 +192,8 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
192192
193193 type inputFocusingWebView struct {
194194 *portmocks.MockWebView
195- focusFirstInputCalls int
195+ focusNextInputCalls int
196+ pageFocusNavigationCalls []bool
196197 }
197198
198199 func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebView {
@@ -202,8 +203,12 @@ func newInputFocusingWebView(t *testing.T, id port.WebViewID) *inputFocusingWebV
202203 return &inputFocusingWebView{MockWebView: wv}
203204 }
204205
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)
207212 }
208213
209214 func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) {
@@ -476,14 +481,24 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
476481 }, wv.requests)
477482 }
478483
479-func TestVimMode_SequenceActionFocusesFirstInput(t *testing.T) {
484+func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
480485 app, bw, paneID := newVimModeAccessibilityFixture(t)
481486 wv := newInputFocusingWebView(t, 6)
482487 app.contentCoord.RegisterPopupWebView(paneID, wv)
483488
484489 app.navigateVimSequenceAction(context.Background(), bw, "focus-input", 1)
485490
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)
487502 }
488503
489504 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.MockWebView194 *portmocks.MockWebView
195- focusFirstInputCalls int195+ 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 (
4949 // matching stop event without changing the normal configurable action API.
5050 type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error
5151
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+
5256 const (
5357 vimScrollHoldDelay = 250 * time.Millisecond
5458 vimScrollCadence = 16 * time.Millisecond
@@ -77,8 +81,9 @@ type KeyboardHandler struct {
7781 session *entity.SessionConfig
7882
7983 // Action handler callbacks.
80- onAction ActionHandler
81- onVimScrollLifecycle VimScrollLifecycleHandler
84+ onAction ActionHandler
85+ onVimScrollLifecycle VimScrollLifecycleHandler
86+ onPageFocusNavigation PageFocusNavigationHandler
8287 // Optional routing callback that determines how a key should be handled.
8388 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
8489 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
@@ -163,6 +168,14 @@ func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler)
163168 h.onVimScrollLifecycle = fn
164169 }
165170
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+
166179 // SetVimModeActivationPassthrough sets the callback that can block Vim mode
167180 // activation and let the original key event propagate instead.
168181 func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
@@ -407,11 +420,11 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
407420 }
408421 }
409422
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.
413426 if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) {
414- return false
427+ return h.handlePageFocusNavigation(keyval, modifiers)
415428 }
416429
417430 // Determine routing for this key event
@@ -619,8 +632,19 @@ func normalizeKeyval(keyval uint) uint {
619632 return keyval
620633 }
621634
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+
622646 func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
623- if mode != ModeNormal {
647+ if mode != ModeNormal && mode != ModeVim {
624648 return false
625649 }
626650 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) error50 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.Millisecond57 vimScrollHoldDelay = 250 * time.Millisecond
54 vimScrollCadence = 16 * time.Millisecond58 vimScrollCadence = 16 * time.Millisecond
@@ -77,8 +81,9 @@ type KeyboardHandler struct {
77 session *entity.SessionConfig81 session *entity.SessionConfig
78 82
79 // Action handler callbacks.83 // Action handler callbacks.
80- onAction ActionHandler84+ onAction ActionHandler
81- onVimScrollLifecycle VimScrollLifecycleHandler85+ 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 focused88 // 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 = fn168 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 mode179 // 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 and423+ // 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 can424+ // 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 false427+ return h.handlePageFocusNavigation(keyval, modifiers)
415 }428 }
416 429
417 // Determine routing for this key event430 // Determine routing for this key event
@@ -619,8 +632,19 @@ func normalizeKeyval(keyval uint) uint {
619 return keyval632 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 false648 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) {
942942 }
943943 }
944944
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+
945963 func TestHandleKeyPress_VimModeBlocksGlobalShortcutFallback(t *testing.T) {
946964 ctx := context.Background()
947965 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()

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close