feat(vim): navigate live headings
- commit
- a11aecaec91d849fdcfda79517038cc7d7b1a236
- author
- brice · · 2026-08-14 10:37 UTC
- parent
- 0a69a74
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +257 -0
M internal/application/port/webview.go +31 -0
| @@ -444,3 +444,34 @@ type PageScrollCanceler interface { | |||
| 444 | 444 | type AccessibilityEnabler interface { | |
| 445 | 445 | EnableAccessibility() | |
| 446 | 446 | } | |
| 447 | + | ||
| 448 | + | // SemanticNavigationTarget identifies a visible document target that Vim Mode | |
| 449 | + | // can navigate without coupling the application layer to a browser engine. | |
| 450 | + | type SemanticNavigationTarget int | |
| 451 | + | ||
| 452 | + | const ( | |
| 453 | + | SemanticNavigationTargetHeading SemanticNavigationTarget = iota | |
| 454 | + | ) | |
| 455 | + | ||
| 456 | + | // SemanticNavigationDirection identifies the strict document-order direction | |
| 457 | + | // for an optional semantic navigation request. | |
| 458 | + | type SemanticNavigationDirection int | |
| 459 | + | ||
| 460 | + | const ( | |
| 461 | + | SemanticNavigationBackward SemanticNavigationDirection = -1 | |
| 462 | + | SemanticNavigationForward SemanticNavigationDirection = 1 | |
| 463 | + | ) | |
| 464 | + | ||
| 465 | + | // SemanticNavigationRequest describes one Vim structural movement. Count uses | |
| 466 | + | // Vim semantics: adapters treat values less than one as one. | |
| 467 | + | type SemanticNavigationRequest struct { | |
| 468 | + | Target SemanticNavigationTarget | |
| 469 | + | Direction SemanticNavigationDirection | |
| 470 | + | Count int | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // SemanticNavigable is an optional WebView capability for visible structural | |
| 474 | + | // navigation. Unsupported engines safely leave it unimplemented. | |
| 475 | + | type SemanticNavigable interface { | |
| 476 | + | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | |
| 477 | + | } | |
| @@ -444,3 +444,34 @@ type PageScrollCanceler interface { | |||||
| 444 | type AccessibilityEnabler interface { | 444 | type AccessibilityEnabler interface { | ||
| 445 | EnableAccessibility() | 445 | EnableAccessibility() | ||
| 446 | } | 446 | } | ||
| 447 | + | ||||
| 448 | + | // SemanticNavigationTarget identifies a visible document target that Vim Mode | |||
| 449 | + | // can navigate without coupling the application layer to a browser engine. | |||
| 450 | + | type SemanticNavigationTarget int | |||
| 451 | + | ||||
| 452 | + | const ( | |||
| 453 | + | SemanticNavigationTargetHeading SemanticNavigationTarget = iota | |||
| 454 | + | ) | |||
| 455 | + | ||||
| 456 | + | // SemanticNavigationDirection identifies the strict document-order direction | |||
| 457 | + | // for an optional semantic navigation request. | |||
| 458 | + | type SemanticNavigationDirection int | |||
| 459 | + | ||||
| 460 | + | const ( | |||
| 461 | + | SemanticNavigationBackward SemanticNavigationDirection = -1 | |||
| 462 | + | SemanticNavigationForward SemanticNavigationDirection = 1 | |||
| 463 | + | ) | |||
| 464 | + | ||||
| 465 | + | // SemanticNavigationRequest describes one Vim structural movement. Count uses | |||
| 466 | + | // Vim semantics: adapters treat values less than one as one. | |||
| 467 | + | type SemanticNavigationRequest struct { | |||
| 468 | + | Target SemanticNavigationTarget | |||
| 469 | + | Direction SemanticNavigationDirection | |||
| 470 | + | Count int | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | // SemanticNavigable is an optional WebView capability for visible structural | |||
| 474 | + | // navigation. Unsupported engines safely leave it unimplemented. | |||
| 475 | + | type SemanticNavigable interface { | |||
| 476 | + | NavigateSemantic(ctx context.Context, request SemanticNavigationRequest) error | |||
| 477 | + | } | |||
A internal/infrastructure/cef/semantic_navigation.go +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NavigateSemantic performs the initial live Vim structural navigation path. | |
| 12 | + | // It deliberately uses only stable HTML semantics and a static script: CEF's | |
| 13 | + | // raw accessibility payload is not yet a documented application contract. | |
| 14 | + | func (wv *WebView) NavigateSemantic(ctx context.Context, request port.SemanticNavigationRequest) error { | |
| 15 | + | if wv == nil || wv.destroyed.Load() { | |
| 16 | + | return errDestroyed | |
| 17 | + | } | |
| 18 | + | if request.Target != port.SemanticNavigationTargetHeading { | |
| 19 | + | return fmt.Errorf("unsupported semantic navigation target: %d", request.Target) | |
| 20 | + | } | |
| 21 | + | if request.Direction != port.SemanticNavigationForward && request.Direction != port.SemanticNavigationBackward { | |
| 22 | + | return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | count := request.Count | |
| 26 | + | if count < 1 { | |
| 27 | + | count = 1 | |
| 28 | + | } | |
| 29 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count)) | |
| 30 | + | return nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func headingNavigationScript(direction, count int) string { | |
| 34 | + | return `(() => { | |
| 35 | + | const stateKey = "__dumberVimHeadingTarget"; | |
| 36 | + | const className = "dumber-vim-heading-target"; | |
| 37 | + | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |
| 38 | + | .filter((heading) => heading.getClientRects().length > 0); | |
| 39 | + | if (headings.length === 0) return; | |
| 40 | + | ||
| 41 | + | const prior = window[stateKey]; | |
| 42 | + | let index = headings.indexOf(prior); | |
| 43 | + | if (index < 0) { | |
| 44 | + | const firstAtOrAfterViewportTop = headings.findIndex( | |
| 45 | + | (heading) => heading.getBoundingClientRect().top >= 0, | |
| 46 | + | ); | |
| 47 | + | if (` + strconv.Itoa(direction) + ` > 0) { | |
| 48 | + | index = firstAtOrAfterViewportTop - 1; | |
| 49 | + | } else { | |
| 50 | + | index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length; | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `); | |
| 55 | + | if (next < 0 || next >= headings.length) return; | |
| 56 | + | const target = headings[next]; | |
| 57 | + | ||
| 58 | + | const previous = document.querySelector("." + className); | |
| 59 | + | if (previous && previous !== target) previous.classList.remove(className); | |
| 60 | + | if (!document.getElementById("dumber-vim-heading-target-style")) { | |
| 61 | + | // This visual cue is best-effort: strict page CSP can reject a DOM style | |
| 62 | + | // element, but target selection and scrolling must stay independent of it. | |
| 63 | + | const style = document.createElement("style"); | |
| 64 | + | style.id = "dumber-vim-heading-target-style"; | |
| 65 | + | style.textContent = "." + className + " { outline: 3px solid #fbbf24 !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |
| 66 | + | document.documentElement.appendChild(style); | |
| 67 | + | } | |
| 68 | + | window[stateKey] = target; | |
| 69 | + | target.classList.add(className); | |
| 70 | + | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | |
| 71 | + | })();` | |
| 72 | + | } | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // NavigateSemantic performs the initial live Vim structural navigation path. | |||
| 12 | + | // It deliberately uses only stable HTML semantics and a static script: CEF's | |||
| 13 | + | // raw accessibility payload is not yet a documented application contract. | |||
| 14 | + | func (wv *WebView) NavigateSemantic(ctx context.Context, request port.SemanticNavigationRequest) error { | |||
| 15 | + | if wv == nil || wv.destroyed.Load() { | |||
| 16 | + | return errDestroyed | |||
| 17 | + | } | |||
| 18 | + | if request.Target != port.SemanticNavigationTargetHeading { | |||
| 19 | + | return fmt.Errorf("unsupported semantic navigation target: %d", request.Target) | |||
| 20 | + | } | |||
| 21 | + | if request.Direction != port.SemanticNavigationForward && request.Direction != port.SemanticNavigationBackward { | |||
| 22 | + | return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | count := request.Count | |||
| 26 | + | if count < 1 { | |||
| 27 | + | count = 1 | |||
| 28 | + | } | |||
| 29 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count)) | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func headingNavigationScript(direction, count int) string { | |||
| 34 | + | return `(() => { | |||
| 35 | + | const stateKey = "__dumberVimHeadingTarget"; | |||
| 36 | + | const className = "dumber-vim-heading-target"; | |||
| 37 | + | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |||
| 38 | + | .filter((heading) => heading.getClientRects().length > 0); | |||
| 39 | + | if (headings.length === 0) return; | |||
| 40 | + | ||||
| 41 | + | const prior = window[stateKey]; | |||
| 42 | + | let index = headings.indexOf(prior); | |||
| 43 | + | if (index < 0) { | |||
| 44 | + | const firstAtOrAfterViewportTop = headings.findIndex( | |||
| 45 | + | (heading) => heading.getBoundingClientRect().top >= 0, | |||
| 46 | + | ); | |||
| 47 | + | if (` + strconv.Itoa(direction) + ` > 0) { | |||
| 48 | + | index = firstAtOrAfterViewportTop - 1; | |||
| 49 | + | } else { | |||
| 50 | + | index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length; | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `); | |||
| 55 | + | if (next < 0 || next >= headings.length) return; | |||
| 56 | + | const target = headings[next]; | |||
| 57 | + | ||||
| 58 | + | const previous = document.querySelector("." + className); | |||
| 59 | + | if (previous && previous !== target) previous.classList.remove(className); | |||
| 60 | + | if (!document.getElementById("dumber-vim-heading-target-style")) { | |||
| 61 | + | // This visual cue is best-effort: strict page CSP can reject a DOM style | |||
| 62 | + | // element, but target selection and scrolling must stay independent of it. | |||
| 63 | + | const style = document.createElement("style"); | |||
| 64 | + | style.id = "dumber-vim-heading-target-style"; | |||
| 65 | + | style.textContent = "." + className + " { outline: 3px solid #fbbf24 !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |||
| 66 | + | document.documentElement.appendChild(style); | |||
| 67 | + | } | |||
| 68 | + | window[stateKey] = target; | |||
| 69 | + | target.classList.add(className); | |||
| 70 | + | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | |||
| 71 | + | })();` | |||
| 72 | + | } | |||
A internal/infrastructure/cef/semantic_navigation_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) { | |
| 16 | + | browser := cefmocks.NewMockBrowser(t) | |
| 17 | + | frame := cefmocks.NewMockFrame(t) | |
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 19 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 20 | + | "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")", | |
| 21 | + | "firstAtOrAfterViewportTop", | |
| 22 | + | "if (next < 0 || next >= headings.length) return;", | |
| 23 | + | "previous && previous !== target", | |
| 24 | + | "scrollIntoView", | |
| 25 | + | "window[stateKey] = target", | |
| 26 | + | "1 * 3", | |
| 27 | + | )), "", int32(0)).Once() | |
| 28 | + | ||
| 29 | + | wv := &WebView{browser: browser} | |
| 30 | + | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |
| 31 | + | Target: port.SemanticNavigationTargetHeading, | |
| 32 | + | Direction: port.SemanticNavigationForward, | |
| 33 | + | Count: 3, | |
| 34 | + | }) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | |
| 39 | + | forward := headingNavigationScript(1, 1) | |
| 40 | + | backward := headingNavigationScript(-1, 1) | |
| 41 | + | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | |
| 42 | + | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | |
| 43 | + | assert.Contains(t, backward, "-1 * 1") | |
| 44 | + | assert.NotContains(t, backward, "window.innerHeight") | |
| 45 | + | ||
| 46 | + | wv := &WebView{} | |
| 47 | + | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |
| 48 | + | Target: port.SemanticNavigationTargetHeading, | |
| 49 | + | Direction: port.SemanticNavigationForward, | |
| 50 | + | Count: 0, | |
| 51 | + | }) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | ||
| 54 | + | err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |
| 55 | + | Target: port.SemanticNavigationTargetHeading + 1, | |
| 56 | + | Direction: port.SemanticNavigationForward, | |
| 57 | + | }) | |
| 58 | + | require.Error(t, err) | |
| 59 | + | ||
| 60 | + | err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |
| 61 | + | Target: port.SemanticNavigationTargetHeading, | |
| 62 | + | Direction: 0, | |
| 63 | + | }) | |
| 64 | + | require.Error(t, err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func mockStringContaining(t *testing.T, fragments ...string) func(string) bool { | |
| 68 | + | t.Helper() | |
| 69 | + | return func(script string) bool { | |
| 70 | + | for _, fragment := range fragments { | |
| 71 | + | if !assert.Contains(t, script, fragment) { | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return true | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testing.T) { | |||
| 16 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 17 | + | frame := cefmocks.NewMockFrame(t) | |||
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 19 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 20 | + | "document.querySelectorAll(\"h1,h2,h3,h4,h5,h6\")", | |||
| 21 | + | "firstAtOrAfterViewportTop", | |||
| 22 | + | "if (next < 0 || next >= headings.length) return;", | |||
| 23 | + | "previous && previous !== target", | |||
| 24 | + | "scrollIntoView", | |||
| 25 | + | "window[stateKey] = target", | |||
| 26 | + | "1 * 3", | |||
| 27 | + | )), "", int32(0)).Once() | |||
| 28 | + | ||||
| 29 | + | wv := &WebView{browser: browser} | |||
| 30 | + | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |||
| 31 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 32 | + | Direction: port.SemanticNavigationForward, | |||
| 33 | + | Count: 3, | |||
| 34 | + | }) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | |||
| 39 | + | forward := headingNavigationScript(1, 1) | |||
| 40 | + | backward := headingNavigationScript(-1, 1) | |||
| 41 | + | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | |||
| 42 | + | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | |||
| 43 | + | assert.Contains(t, backward, "-1 * 1") | |||
| 44 | + | assert.NotContains(t, backward, "window.innerHeight") | |||
| 45 | + | ||||
| 46 | + | wv := &WebView{} | |||
| 47 | + | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |||
| 48 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 49 | + | Direction: port.SemanticNavigationForward, | |||
| 50 | + | Count: 0, | |||
| 51 | + | }) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | ||||
| 54 | + | err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |||
| 55 | + | Target: port.SemanticNavigationTargetHeading + 1, | |||
| 56 | + | Direction: port.SemanticNavigationForward, | |||
| 57 | + | }) | |||
| 58 | + | require.Error(t, err) | |||
| 59 | + | ||||
| 60 | + | err = wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |||
| 61 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 62 | + | Direction: 0, | |||
| 63 | + | }) | |||
| 64 | + | require.Error(t, err) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func mockStringContaining(t *testing.T, fragments ...string) func(string) bool { | |||
| 68 | + | t.Helper() | |||
| 69 | + | return func(script string) bool { | |||
| 70 | + | for _, fragment := range fragments { | |||
| 71 | + | if !assert.Contains(t, script, fragment) { | |||
| 72 | + | return false | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return true | |||
| 76 | + | } | |||
| 77 | + | } | |||
M internal/ui/app.go +36 -0
| @@ -1101,6 +1101,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1101 | 1101 | a.handleModeChange(ctx, bw, from, to) | |
| 1102 | 1102 | }) | |
| 1103 | 1103 | a.bindVimModeSequenceToaster(ctx, bw) | |
| 1104 | + | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | |
| 1105 | + | a.navigateVimSequenceAction(ctx, bw, action, count) | |
| 1106 | + | }) | |
| 1104 | 1107 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1105 | 1108 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 1106 | 1109 | return input.RoutePassToWidget | |
| @@ -2160,6 +2163,39 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||
| 2160 | 2163 | return paneID, a.contentCoord.GetWebView(paneID) | |
| 2161 | 2164 | } | |
| 2162 | 2165 | ||
| 2166 | + | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |
| 2167 | + | var request port.SemanticNavigationRequest | |
| 2168 | + | switch action { | |
| 2169 | + | case "heading-next": | |
| 2170 | + | request = port.SemanticNavigationRequest{ | |
| 2171 | + | Target: port.SemanticNavigationTargetHeading, | |
| 2172 | + | Direction: port.SemanticNavigationForward, | |
| 2173 | + | Count: count, | |
| 2174 | + | } | |
| 2175 | + | case "heading-prev": | |
| 2176 | + | request = port.SemanticNavigationRequest{ | |
| 2177 | + | Target: port.SemanticNavigationTargetHeading, | |
| 2178 | + | Direction: port.SemanticNavigationBackward, | |
| 2179 | + | Count: count, | |
| 2180 | + | } | |
| 2181 | + | default: | |
| 2182 | + | return | |
| 2183 | + | } | |
| 2184 | + | ||
| 2185 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2186 | + | navigator, ok := wv.(port.SemanticNavigable) | |
| 2187 | + | if !ok { | |
| 2188 | + | return | |
| 2189 | + | } | |
| 2190 | + | if err := navigator.NavigateSemantic(ctx, request); err != nil { | |
| 2191 | + | logging.FromContext(ctx).Debug(). | |
| 2192 | + | Err(err). | |
| 2193 | + | Str("action", action). | |
| 2194 | + | Str("pane_id", string(paneID)). | |
| 2195 | + | Msg("vim semantic navigation unavailable") | |
| 2196 | + | } | |
| 2197 | + | } | |
| 2198 | + | ||
| 2163 | 2199 | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | |
| 2164 | 2200 | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2165 | 2201 | if wv == nil { | |
| @@ -1101,6 +1101,9 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 1101 | a.handleModeChange(ctx, bw, from, to) | 1101 | a.handleModeChange(ctx, bw, from, to) | ||
| 1102 | }) | 1102 | }) | ||
| 1103 | a.bindVimModeSequenceToaster(ctx, bw) | 1103 | a.bindVimModeSequenceToaster(ctx, bw) | ||
| 1104 | + | bw.keyboardHandler.SetOnSequenceAction(func(action string, count int) { | |||
| 1105 | + | a.navigateVimSequenceAction(ctx, bw, action, count) | |||
| 1106 | + | }) | |||
| 1104 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | 1107 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | ||
| 1105 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | 1108 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | ||
| 1106 | return input.RoutePassToWidget | 1109 | return input.RoutePassToWidget | ||
| @@ -2160,6 +2163,39 @@ func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, p | |||||
| 2160 | return paneID, a.contentCoord.GetWebView(paneID) | 2163 | return paneID, a.contentCoord.GetWebView(paneID) | ||
| 2161 | } | 2164 | } | ||
| 2162 | 2165 | ||||
| 2166 | + | func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, action string, count int) { | |||
| 2167 | + | var request port.SemanticNavigationRequest | |||
| 2168 | + | switch action { | |||
| 2169 | + | case "heading-next": | |||
| 2170 | + | request = port.SemanticNavigationRequest{ | |||
| 2171 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 2172 | + | Direction: port.SemanticNavigationForward, | |||
| 2173 | + | Count: count, | |||
| 2174 | + | } | |||
| 2175 | + | case "heading-prev": | |||
| 2176 | + | request = port.SemanticNavigationRequest{ | |||
| 2177 | + | Target: port.SemanticNavigationTargetHeading, | |||
| 2178 | + | Direction: port.SemanticNavigationBackward, | |||
| 2179 | + | Count: count, | |||
| 2180 | + | } | |||
| 2181 | + | default: | |||
| 2182 | + | return | |||
| 2183 | + | } | |||
| 2184 | + | ||||
| 2185 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |||
| 2186 | + | navigator, ok := wv.(port.SemanticNavigable) | |||
| 2187 | + | if !ok { | |||
| 2188 | + | return | |||
| 2189 | + | } | |||
| 2190 | + | if err := navigator.NavigateSemantic(ctx, request); err != nil { | |||
| 2191 | + | logging.FromContext(ctx).Debug(). | |||
| 2192 | + | Err(err). | |||
| 2193 | + | Str("action", action). | |||
| 2194 | + | Str("pane_id", string(paneID)). | |||
| 2195 | + | Msg("vim semantic navigation unavailable") | |||
| 2196 | + | } | |||
| 2197 | + | } | |||
| 2198 | + | ||||
| 2163 | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | 2199 | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | ||
| 2164 | paneID, wv := a.activeWebViewForBrowserWindow(bw) | 2200 | paneID, wv := a.activeWebViewForBrowserWindow(bw) | ||
| 2165 | if wv == nil { | 2201 | if wv == nil { | ||
M internal/ui/app_vim_mode_test.go +41 -0
| @@ -173,6 +173,23 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||
| 173 | 173 | wv.enableAccessibilityCalls++ | |
| 174 | 174 | } | |
| 175 | 175 | ||
| 176 | + | type semanticNavigatingWebView struct { | |
| 177 | + | *portmocks.MockWebView | |
| 178 | + | requests []port.SemanticNavigationRequest | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | |
| 182 | + | t.Helper() | |
| 183 | + | wv := portmocks.NewMockWebView(t) | |
| 184 | + | wv.EXPECT().ID().Return(id).Once() | |
| 185 | + | return &semanticNavigatingWebView{MockWebView: wv} | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error { | |
| 189 | + | wv.requests = append(wv.requests, request) | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | ||
| 176 | 193 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | |
| 177 | 194 | t.Helper() | |
| 178 | 195 | paneID := entity.PaneID("pane-a") | |
| @@ -416,6 +433,30 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) | |||
| 416 | 433 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 417 | 434 | } | |
| 418 | 435 | ||
| 436 | + | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |
| 437 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 438 | + | wv := newSemanticNavigatingWebView(t, 4) | |
| 439 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 440 | + | ||
| 441 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3) | |
| 442 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0) | |
| 443 | + | app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1) | |
| 444 | + | ||
| 445 | + | assert.Equal(t, []port.SemanticNavigationRequest{ | |
| 446 | + | {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationForward, Count: 3}, | |
| 447 | + | {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationBackward, Count: 0}, | |
| 448 | + | }, wv.requests) | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |
| 452 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 453 | + | wv := portmocks.NewMockWebView(t) | |
| 454 | + | wv.EXPECT().ID().Return(port.WebViewID(5)).Once() | |
| 455 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 456 | + | ||
| 457 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |
| 458 | + | } | |
| 459 | + | ||
| 419 | 460 | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | |
| 420 | 461 | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 421 | 462 | wv := newAccessibilityEnablingWebView(t, 3) | |
| @@ -173,6 +173,23 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||||
| 173 | wv.enableAccessibilityCalls++ | 173 | wv.enableAccessibilityCalls++ | ||
| 174 | } | 174 | } | ||
| 175 | 175 | ||||
| 176 | + | type semanticNavigatingWebView struct { | |||
| 177 | + | *portmocks.MockWebView | |||
| 178 | + | requests []port.SemanticNavigationRequest | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | |||
| 182 | + | t.Helper() | |||
| 183 | + | wv := portmocks.NewMockWebView(t) | |||
| 184 | + | wv.EXPECT().ID().Return(id).Once() | |||
| 185 | + | return &semanticNavigatingWebView{MockWebView: wv} | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request port.SemanticNavigationRequest) error { | |||
| 189 | + | wv.requests = append(wv.requests, request) | |||
| 190 | + | return nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 176 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | 193 | func newVimModeAccessibilityFixture(t *testing.T) (*App, *browserWindow, entity.PaneID) { | ||
| 177 | t.Helper() | 194 | t.Helper() | ||
| 178 | paneID := entity.PaneID("pane-a") | 195 | paneID := entity.PaneID("pane-a") | ||
| @@ -416,6 +433,30 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) | |||||
| 416 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | 433 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | ||
| 417 | } | 434 | } | ||
| 418 | 435 | ||||
| 436 | + | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |||
| 437 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 438 | + | wv := newSemanticNavigatingWebView(t, 4) | |||
| 439 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 440 | + | ||||
| 441 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 3) | |||
| 442 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-prev", 0) | |||
| 443 | + | app.navigateVimSequenceAction(context.Background(), bw, "code-next", 1) | |||
| 444 | + | ||||
| 445 | + | assert.Equal(t, []port.SemanticNavigationRequest{ | |||
| 446 | + | {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationForward, Count: 3}, | |||
| 447 | + | {Target: port.SemanticNavigationTargetHeading, Direction: port.SemanticNavigationBackward, Count: 0}, | |||
| 448 | + | }, wv.requests) | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | func TestVimMode_SequenceActionNoopsForUnsupportedActiveWebView(t *testing.T) { | |||
| 452 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 453 | + | wv := portmocks.NewMockWebView(t) | |||
| 454 | + | wv.EXPECT().ID().Return(port.WebViewID(5)).Once() | |||
| 455 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 456 | + | ||||
| 457 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |||
| 458 | + | } | |||
| 459 | + | ||||
| 419 | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | 460 | func TestVimMode_AccessibilityOnlyEnabledOnEnteringVimMode(t *testing.T) { | ||
| 420 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 461 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 421 | wv := newAccessibilityEnablingWebView(t, 3) | 462 | wv := newAccessibilityEnablingWebView(t, 3) | ||