fix(vim): clear heading highlight on mode exit
- commit
- 3a00c387324a08655382373f02ea5200753038ba
- author
- brice · · 2026-08-17 06:52 UTC
- parent
- 4733629
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +221 -18
M internal/application/port/webview.go +6 -0
| @@ -465,6 +465,12 @@ type SemanticNavigable interface { | |||
| 465 | 465 | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | |
| 466 | 466 | } | |
| 467 | 467 | ||
| 468 | + | // SemanticNavigationHighlightClearer is an optional WebView capability for | |
| 469 | + | // removing the visual target left by semantic navigation. | |
| 470 | + | type SemanticNavigationHighlightClearer interface { | |
| 471 | + | ClearSemanticNavigationHighlight(ctx context.Context) error | |
| 472 | + | } | |
| 473 | + | ||
| 468 | 474 | // PageInputFocuser is an optional WebView capability for moving Vim Mode | |
| 469 | 475 | // input focus to the next eligible page input. With no active eligible input, | |
| 470 | 476 | // implementations focus the first one. Unsupported engines safely leave it | |
| @@ -465,6 +465,12 @@ type SemanticNavigable interface { | |||||
| 465 | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | 465 | NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error | ||
| 466 | } | 466 | } | ||
| 467 | 467 | ||||
| 468 | + | // SemanticNavigationHighlightClearer is an optional WebView capability for | |||
| 469 | + | // removing the visual target left by semantic navigation. | |||
| 470 | + | type SemanticNavigationHighlightClearer interface { | |||
| 471 | + | ClearSemanticNavigationHighlight(ctx context.Context) error | |||
| 472 | + | } | |||
| 473 | + | ||||
| 468 | // PageInputFocuser is an optional WebView capability for moving Vim Mode | 474 | // PageInputFocuser is an optional WebView capability for moving Vim Mode | ||
| 469 | // input focus to the next eligible page input. With no active eligible input, | 475 | // input focus to the next eligible page input. With no active eligible input, | ||
| 470 | // implementations focus the first one. Unsupported engines safely leave it | 476 | // implementations focus the first one. Unsupported engines safely leave it | ||
M internal/application/usecase/vim_navigation.go +13 -0
| @@ -65,6 +65,19 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio | |||
| 65 | 65 | return navigator.NavigateSemantic(ctx, request) | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | + | // ClearSemanticNavigationHighlight removes the visual target left by semantic | |
| 69 | + | // navigation when the active Vim Mode session ends. | |
| 70 | + | func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error { | |
| 71 | + | if wv == nil { | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | clearer, ok := wv.(port.SemanticNavigationHighlightClearer) | |
| 75 | + | if !ok { | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | return clearer.ClearSemanticNavigationHighlight(ctx) | |
| 79 | + | } | |
| 80 | + | ||
| 68 | 81 | // NavigatePageFocus keeps focus traversal inside a page when the active page | |
| 69 | 82 | // control would otherwise hand the host window a Tab event. | |
| 70 | 83 | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | |
| @@ -65,6 +65,19 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio | |||||
| 65 | return navigator.NavigateSemantic(ctx, request) | 65 | return navigator.NavigateSemantic(ctx, request) | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | + | // ClearSemanticNavigationHighlight removes the visual target left by semantic | |||
| 69 | + | // navigation when the active Vim Mode session ends. | |||
| 70 | + | func (*VimNavigationUseCase) ClearSemanticNavigationHighlight(ctx context.Context, wv port.WebView) error { | |||
| 71 | + | if wv == nil { | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | clearer, ok := wv.(port.SemanticNavigationHighlightClearer) | |||
| 75 | + | if !ok { | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | return clearer.ClearSemanticNavigationHighlight(ctx) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 68 | // NavigatePageFocus keeps focus traversal inside a page when the active page | 81 | // NavigatePageFocus keeps focus traversal inside a page when the active page | ||
| 69 | // control would otherwise hand the host window a Tab event. | 82 | // control would otherwise hand the host window a Tab event. | ||
| 70 | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | 83 | func (*VimNavigationUseCase) NavigatePageFocus(wv port.WebView, backward bool) bool { | ||
M internal/application/usecase/vim_navigation_test.go +16 -3
| @@ -12,9 +12,10 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | type vimNavigationWebView struct { | |
| 14 | 14 | *portmocks.MockWebView | |
| 15 | - | focusNextInputCalls int | |
| 16 | - | pageFocusBackward []bool | |
| 17 | - | semanticRequests []dto.SemanticNavigationRequest | |
| 15 | + | focusNextInputCalls int | |
| 16 | + | pageFocusBackward []bool | |
| 17 | + | semanticRequests []dto.SemanticNavigationRequest | |
| 18 | + | clearNavigationHighlightCalls int | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | func (wv *vimNavigationWebView) FocusNextInput() { | |
| @@ -30,6 +31,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto. | |||
| 30 | 31 | return nil | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 34 | + | func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error { | |
| 35 | + | wv.clearNavigationHighlightCalls++ | |
| 36 | + | return nil | |
| 37 | + | } | |
| 38 | + | ||
| 33 | 39 | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |
| 34 | 40 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 35 | 41 | uc := NewVimNavigationUseCase() | |
| @@ -47,6 +53,13 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||
| 47 | 53 | }, wv.semanticRequests[0]) | |
| 48 | 54 | } | |
| 49 | 55 | ||
| 56 | + | func TestVimNavigationUseCaseClearSemanticNavigationHighlight(t *testing.T) { | |
| 57 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 58 | + | ||
| 59 | + | require.NoError(t, NewVimNavigationUseCase().ClearSemanticNavigationHighlight(context.Background(), wv)) | |
| 60 | + | assert.Equal(t, 1, wv.clearNavigationHighlightCalls) | |
| 61 | + | } | |
| 62 | + | ||
| 50 | 63 | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | |
| 51 | 64 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |
| 52 | 65 | ||
| @@ -12,9 +12,10 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | type vimNavigationWebView struct { | 13 | type vimNavigationWebView struct { | ||
| 14 | *portmocks.MockWebView | 14 | *portmocks.MockWebView | ||
| 15 | - | focusNextInputCalls int | 15 | + | focusNextInputCalls int |
| 16 | - | pageFocusBackward []bool | 16 | + | pageFocusBackward []bool |
| 17 | - | semanticRequests []dto.SemanticNavigationRequest | 17 | + | semanticRequests []dto.SemanticNavigationRequest |
| 18 | + | clearNavigationHighlightCalls int | |||
| 18 | } | 19 | } | ||
| 19 | 20 | ||||
| 20 | func (wv *vimNavigationWebView) FocusNextInput() { | 21 | func (wv *vimNavigationWebView) FocusNextInput() { | ||
| @@ -30,6 +31,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto. | |||||
| 30 | return nil | 31 | return nil | ||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 34 | + | func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error { | |||
| 35 | + | wv.clearNavigationHighlightCalls++ | |||
| 36 | + | return nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 33 | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | 39 | func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | ||
| 34 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | 40 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | ||
| 35 | uc := NewVimNavigationUseCase() | 41 | uc := NewVimNavigationUseCase() | ||
| @@ -47,6 +53,13 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) { | |||||
| 47 | }, wv.semanticRequests[0]) | 53 | }, wv.semanticRequests[0]) | ||
| 48 | } | 54 | } | ||
| 49 | 55 | ||||
| 56 | + | func TestVimNavigationUseCaseClearSemanticNavigationHighlight(t *testing.T) { | |||
| 57 | + | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | |||
| 58 | + | ||||
| 59 | + | require.NoError(t, NewVimNavigationUseCase().ClearSemanticNavigationHighlight(context.Background(), wv)) | |||
| 60 | + | assert.Equal(t, 1, wv.clearNavigationHighlightCalls) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 50 | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | 63 | func TestVimNavigationUseCaseNavigatePageFocus(t *testing.T) { | ||
| 51 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | 64 | wv := &vimNavigationWebView{MockWebView: portmocks.NewMockWebView(t)} | ||
| 52 | 65 | ||||
M internal/infrastructure/cef/semantic_navigation.go +74 -10
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | ||
| 8 | 10 | "github.com/bnema/dumber/internal/application/dto" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -26,17 +28,64 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav | |||
| 26 | 28 | if count < 1 { | |
| 27 | 29 | count = 1 | |
| 28 | 30 | } | |
| 29 | - | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor)) | |
| 31 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace())) | |
| 30 | 32 | return nil | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 33 | - | func headingNavigationScript(direction, count int, highlightColor string) string { | |
| 35 | + | // ClearSemanticNavigationHighlight removes the visual target left by the most | |
| 36 | + | // recent Vim semantic navigation in this WebView. | |
| 37 | + | func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error { | |
| 38 | + | if wv == nil || wv.destroyed.Load() { | |
| 39 | + | return errDestroyed | |
| 40 | + | } | |
| 41 | + | return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace())) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (wv *WebView) vimHeadingHighlightNamespace() string { | |
| 45 | + | return fmt.Sprintf("dumber-vim-heading-%p", wv) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // scheduleJavaScript makes CEF UI-thread scheduling failures observable to | |
| 49 | + | // callers that need to guarantee a transient UI state is cleared. | |
| 50 | + | func (wv *WebView) scheduleJavaScript(script string) error { | |
| 51 | + | if wv == nil || wv.destroyed.Load() { | |
| 52 | + | return errDestroyed | |
| 53 | + | } | |
| 54 | + | wv.mu.RLock() | |
| 55 | + | browser := wv.browser | |
| 56 | + | wv.mu.RUnlock() | |
| 57 | + | if browser == nil { | |
| 58 | + | return fmt.Errorf("schedule JavaScript: browser unavailable") | |
| 59 | + | } | |
| 60 | + | if wv.engine == nil { | |
| 61 | + | frame := browser.GetMainFrame() | |
| 62 | + | if frame == nil { | |
| 63 | + | return fmt.Errorf("schedule JavaScript: main frame unavailable") | |
| 64 | + | } | |
| 65 | + | frame.ExecuteJavaScript(script, "", 0) | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 70 | + | wv.executeJavaScriptNow(script) | |
| 71 | + | })) | |
| 72 | + | if task == nil { | |
| 73 | + | return fmt.Errorf("schedule JavaScript: create CEF task") | |
| 74 | + | } | |
| 75 | + | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |
| 76 | + | return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result) | |
| 77 | + | } | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func headingNavigationScript(direction, count int, highlightColor, namespace string) string { | |
| 34 | 82 | if highlightColor == "" { | |
| 35 | 83 | highlightColor = "#fbbf24" | |
| 36 | 84 | } | |
| 37 | 85 | return `(() => { | |
| 38 | - | const stateKey = "__dumberVimHeadingTarget"; | |
| 39 | - | const className = "dumber-vim-heading-target"; | |
| 86 | + | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | |
| 87 | + | const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `; | |
| 88 | + | const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `; | |
| 40 | 89 | const highlightColor = ` + strconv.Quote(highlightColor) + `; | |
| 41 | 90 | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |
| 42 | 91 | .filter((heading) => { | |
| @@ -65,23 +114,38 @@ func headingNavigationScript(direction, count int, highlightColor string) string | |||
| 65 | 114 | if (next < 0 || next >= headings.length) return; | |
| 66 | 115 | const target = headings[next]; | |
| 67 | 116 | ||
| 68 | - | const previous = document.querySelector("." + className); | |
| 69 | - | if (previous && previous !== target) previous.classList.remove(className); | |
| 70 | - | let style = document.getElementById("dumber-vim-heading-target-style"); | |
| 117 | + | const previous = document.querySelector("[" + targetAttribute + "]"); | |
| 118 | + | if (previous && previous !== target) previous.removeAttribute(targetAttribute); | |
| 119 | + | let style = document.querySelector("style[" + styleAttribute + "]"); | |
| 71 | 120 | if (!style) { | |
| 72 | 121 | // This visual cue is best-effort: strict page CSP can reject a DOM style | |
| 73 | 122 | // element, but target selection and scrolling must stay independent of it. | |
| 74 | 123 | style = document.createElement("style"); | |
| 75 | - | style.id = "dumber-vim-heading-target-style"; | |
| 124 | + | style.setAttribute(styleAttribute, ""); | |
| 76 | 125 | document.documentElement.appendChild(style); | |
| 77 | 126 | } | |
| 78 | 127 | // Refresh the outline on every navigation so an in-session accent change is | |
| 79 | 128 | // reflected even when the target style element already exists. | |
| 80 | - | style.textContent = "." + className + | |
| 129 | + | style.textContent = "[" + targetAttribute + "]" + | |
| 81 | 130 | " { outline: 3px solid " + highlightColor + | |
| 82 | 131 | " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |
| 83 | 132 | window[stateKey] = target; | |
| 84 | - | target.classList.add(className); | |
| 133 | + | target.setAttribute(targetAttribute, ""); | |
| 85 | 134 | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | |
| 86 | 135 | })();` | |
| 87 | 136 | } | |
| 137 | + | ||
| 138 | + | func clearHeadingNavigationHighlightScript(namespace string) string { | |
| 139 | + | return `(() => { | |
| 140 | + | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | |
| 141 | + | const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `; | |
| 142 | + | const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `; | |
| 143 | + | document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => { | |
| 144 | + | target.removeAttribute(targetAttribute); | |
| 145 | + | }); | |
| 146 | + | document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => { | |
| 147 | + | style.remove(); | |
| 148 | + | }); | |
| 149 | + | delete window[stateKey]; | |
| 150 | + | })();` | |
| 151 | + | } | |
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "strconv" | 6 | "strconv" | ||
| 7 | 7 | ||||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 9 | + | ||||
| 8 | "github.com/bnema/dumber/internal/application/dto" | 10 | "github.com/bnema/dumber/internal/application/dto" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -26,17 +28,64 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav | |||||
| 26 | if count < 1 { | 28 | if count < 1 { | ||
| 27 | count = 1 | 29 | count = 1 | ||
| 28 | } | 30 | } | ||
| 29 | - | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor)) | 31 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace())) |
| 30 | return nil | 32 | return nil | ||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 33 | - | func headingNavigationScript(direction, count int, highlightColor string) string { | 35 | + | // ClearSemanticNavigationHighlight removes the visual target left by the most |
| 36 | + | // recent Vim semantic navigation in this WebView. | |||
| 37 | + | func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error { | |||
| 38 | + | if wv == nil || wv.destroyed.Load() { | |||
| 39 | + | return errDestroyed | |||
| 40 | + | } | |||
| 41 | + | return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace())) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (wv *WebView) vimHeadingHighlightNamespace() string { | |||
| 45 | + | return fmt.Sprintf("dumber-vim-heading-%p", wv) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // scheduleJavaScript makes CEF UI-thread scheduling failures observable to | |||
| 49 | + | // callers that need to guarantee a transient UI state is cleared. | |||
| 50 | + | func (wv *WebView) scheduleJavaScript(script string) error { | |||
| 51 | + | if wv == nil || wv.destroyed.Load() { | |||
| 52 | + | return errDestroyed | |||
| 53 | + | } | |||
| 54 | + | wv.mu.RLock() | |||
| 55 | + | browser := wv.browser | |||
| 56 | + | wv.mu.RUnlock() | |||
| 57 | + | if browser == nil { | |||
| 58 | + | return fmt.Errorf("schedule JavaScript: browser unavailable") | |||
| 59 | + | } | |||
| 60 | + | if wv.engine == nil { | |||
| 61 | + | frame := browser.GetMainFrame() | |||
| 62 | + | if frame == nil { | |||
| 63 | + | return fmt.Errorf("schedule JavaScript: main frame unavailable") | |||
| 64 | + | } | |||
| 65 | + | frame.ExecuteJavaScript(script, "", 0) | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | task := cefNewTask(cefTaskFunc(func() { | |||
| 70 | + | wv.executeJavaScriptNow(script) | |||
| 71 | + | })) | |||
| 72 | + | if task == nil { | |||
| 73 | + | return fmt.Errorf("schedule JavaScript: create CEF task") | |||
| 74 | + | } | |||
| 75 | + | if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 { | |||
| 76 | + | return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result) | |||
| 77 | + | } | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func headingNavigationScript(direction, count int, highlightColor, namespace string) string { | |||
| 34 | if highlightColor == "" { | 82 | if highlightColor == "" { | ||
| 35 | highlightColor = "#fbbf24" | 83 | highlightColor = "#fbbf24" | ||
| 36 | } | 84 | } | ||
| 37 | return `(() => { | 85 | return `(() => { | ||
| 38 | - | const stateKey = "__dumberVimHeadingTarget"; | 86 | + | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; |
| 39 | - | const className = "dumber-vim-heading-target"; | 87 | + | const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `; |
| 88 | + | const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `; | |||
| 40 | const highlightColor = ` + strconv.Quote(highlightColor) + `; | 89 | const highlightColor = ` + strconv.Quote(highlightColor) + `; | ||
| 41 | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | 90 | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | ||
| 42 | .filter((heading) => { | 91 | .filter((heading) => { | ||
| @@ -65,23 +114,38 @@ func headingNavigationScript(direction, count int, highlightColor string) string | |||||
| 65 | if (next < 0 || next >= headings.length) return; | 114 | if (next < 0 || next >= headings.length) return; | ||
| 66 | const target = headings[next]; | 115 | const target = headings[next]; | ||
| 67 | 116 | ||||
| 68 | - | const previous = document.querySelector("." + className); | 117 | + | const previous = document.querySelector("[" + targetAttribute + "]"); |
| 69 | - | if (previous && previous !== target) previous.classList.remove(className); | 118 | + | if (previous && previous !== target) previous.removeAttribute(targetAttribute); |
| 70 | - | let style = document.getElementById("dumber-vim-heading-target-style"); | 119 | + | let style = document.querySelector("style[" + styleAttribute + "]"); |
| 71 | if (!style) { | 120 | if (!style) { | ||
| 72 | // This visual cue is best-effort: strict page CSP can reject a DOM style | 121 | // This visual cue is best-effort: strict page CSP can reject a DOM style | ||
| 73 | // element, but target selection and scrolling must stay independent of it. | 122 | // element, but target selection and scrolling must stay independent of it. | ||
| 74 | style = document.createElement("style"); | 123 | style = document.createElement("style"); | ||
| 75 | - | style.id = "dumber-vim-heading-target-style"; | 124 | + | style.setAttribute(styleAttribute, ""); |
| 76 | document.documentElement.appendChild(style); | 125 | document.documentElement.appendChild(style); | ||
| 77 | } | 126 | } | ||
| 78 | // Refresh the outline on every navigation so an in-session accent change is | 127 | // Refresh the outline on every navigation so an in-session accent change is | ||
| 79 | // reflected even when the target style element already exists. | 128 | // reflected even when the target style element already exists. | ||
| 80 | - | style.textContent = "." + className + | 129 | + | style.textContent = "[" + targetAttribute + "]" + |
| 81 | " { outline: 3px solid " + highlightColor + | 130 | " { outline: 3px solid " + highlightColor + | ||
| 82 | " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | 131 | " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | ||
| 83 | window[stateKey] = target; | 132 | window[stateKey] = target; | ||
| 84 | - | target.classList.add(className); | 133 | + | target.setAttribute(targetAttribute, ""); |
| 85 | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | 134 | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | ||
| 86 | })();` | 135 | })();` | ||
| 87 | } | 136 | } | ||
| 137 | + | ||||
| 138 | + | func clearHeadingNavigationHighlightScript(namespace string) string { | |||
| 139 | + | return `(() => { | |||
| 140 | + | const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `; | |||
| 141 | + | const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `; | |||
| 142 | + | const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `; | |||
| 143 | + | document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => { | |||
| 144 | + | target.removeAttribute(targetAttribute); | |||
| 145 | + | }); | |||
| 146 | + | document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => { | |||
| 147 | + | style.remove(); | |||
| 148 | + | }); | |||
| 149 | + | delete window[stateKey]; | |||
| 150 | + | })();` | |||
| 151 | + | } | |||
M internal/infrastructure/cef/semantic_navigation_test.go +32 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | 8 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| 9 | 10 | "github.com/stretchr/testify/mock" | |
| @@ -26,8 +27,9 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||
| 26 | 27 | "scrollIntoView", | |
| 27 | 28 | "window[stateKey] = target", | |
| 28 | 29 | "const highlightColor = \"#22c55e\"", | |
| 29 | - | "let style = document.getElementById(\"dumber-vim-heading-target-style\")", | |
| 30 | - | "style.textContent = \".\" + className", | |
| 30 | + | "const targetAttribute = \"data-dumber-vim-heading-", | |
| 31 | + | "let style = document.querySelector(\"style[\" + styleAttribute + \"]\")", | |
| 32 | + | "style.textContent = \"[\" + targetAttribute + \"]\"", | |
| 31 | 33 | "1 * 3", | |
| 32 | 34 | )), "", int32(0)).Once() | |
| 33 | 35 | ||
| @@ -41,9 +43,35 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||
| 41 | 43 | require.NoError(t, err) | |
| 42 | 44 | } | |
| 43 | 45 | ||
| 46 | + | func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) { | |
| 47 | + | browser := cefmocks.NewMockBrowser(t) | |
| 48 | + | frame := cefmocks.NewMockFrame(t) | |
| 49 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 50 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |
| 51 | + | "const targetAttribute = \"data-dumber-vim-heading-", | |
| 52 | + | "document.querySelectorAll(\"[\" + targetAttribute + \"]\")", | |
| 53 | + | "target.removeAttribute(targetAttribute)", | |
| 54 | + | "style[\" + styleAttribute + \"]", | |
| 55 | + | "delete window[stateKey]", | |
| 56 | + | )), "", int32(0)).Once() | |
| 57 | + | ||
| 58 | + | wv := &WebView{browser: browser} | |
| 59 | + | require.NoError(t, wv.ClearSemanticNavigationHighlight(context.Background())) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestWebViewClearSemanticNavigationHighlightReportsSchedulingFailure(t *testing.T) { | |
| 63 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |
| 64 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |
| 65 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |
| 66 | + | cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 } | |
| 67 | + | ||
| 68 | + | wv := &WebView{engine: &Engine{}, browser: cefmocks.NewMockBrowser(t)} | |
| 69 | + | require.Error(t, wv.ClearSemanticNavigationHighlight(context.Background())) | |
| 70 | + | } | |
| 71 | + | ||
| 44 | 72 | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | |
| 45 | - | forward := headingNavigationScript(1, 1, "") | |
| 46 | - | backward := headingNavigationScript(-1, 1, "#4ade80") | |
| 73 | + | forward := headingNavigationScript(1, 1, "", "test") | |
| 74 | + | backward := headingNavigationScript(-1, 1, "#4ade80", "test") | |
| 47 | 75 | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | |
| 48 | 76 | assert.Contains(t, forward, "if (index < -1) index = -1;") | |
| 49 | 77 | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 7 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | 8 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | ||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/mock" | 10 | "github.com/stretchr/testify/mock" | ||
| @@ -26,8 +27,9 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||||
| 26 | "scrollIntoView", | 27 | "scrollIntoView", | ||
| 27 | "window[stateKey] = target", | 28 | "window[stateKey] = target", | ||
| 28 | "const highlightColor = \"#22c55e\"", | 29 | "const highlightColor = \"#22c55e\"", | ||
| 29 | - | "let style = document.getElementById(\"dumber-vim-heading-target-style\")", | 30 | + | "const targetAttribute = \"data-dumber-vim-heading-", |
| 30 | - | "style.textContent = \".\" + className", | 31 | + | "let style = document.querySelector(\"style[\" + styleAttribute + \"]\")", |
| 32 | + | "style.textContent = \"[\" + targetAttribute + \"]\"", | |||
| 31 | "1 * 3", | 33 | "1 * 3", | ||
| 32 | )), "", int32(0)).Once() | 34 | )), "", int32(0)).Once() | ||
| 33 | 35 | ||||
| @@ -41,9 +43,35 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||||
| 41 | require.NoError(t, err) | 43 | require.NoError(t, err) | ||
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| 46 | + | func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) { | |||
| 47 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 48 | + | frame := cefmocks.NewMockFrame(t) | |||
| 49 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 50 | + | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, | |||
| 51 | + | "const targetAttribute = \"data-dumber-vim-heading-", | |||
| 52 | + | "document.querySelectorAll(\"[\" + targetAttribute + \"]\")", | |||
| 53 | + | "target.removeAttribute(targetAttribute)", | |||
| 54 | + | "style[\" + styleAttribute + \"]", | |||
| 55 | + | "delete window[stateKey]", | |||
| 56 | + | )), "", int32(0)).Once() | |||
| 57 | + | ||||
| 58 | + | wv := &WebView{browser: browser} | |||
| 59 | + | require.NoError(t, wv.ClearSemanticNavigationHighlight(context.Background())) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestWebViewClearSemanticNavigationHighlightReportsSchedulingFailure(t *testing.T) { | |||
| 63 | + | oldNewTask, oldPostTask := cefNewTask, cefPostTask | |||
| 64 | + | defer func() { cefNewTask, cefPostTask = oldNewTask, oldPostTask }() | |||
| 65 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 66 | + | cefPostTask = func(purecef.ThreadID, purecef.Task) int32 { return 0 } | |||
| 67 | + | ||||
| 68 | + | wv := &WebView{engine: &Engine{}, browser: cefmocks.NewMockBrowser(t)} | |||
| 69 | + | require.Error(t, wv.ClearSemanticNavigationHighlight(context.Background())) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 44 | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | 72 | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | ||
| 45 | - | forward := headingNavigationScript(1, 1, "") | 73 | + | forward := headingNavigationScript(1, 1, "", "test") |
| 46 | - | backward := headingNavigationScript(-1, 1, "#4ade80") | 74 | + | backward := headingNavigationScript(-1, 1, "#4ade80", "test") |
| 47 | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | 75 | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | ||
| 48 | assert.Contains(t, forward, "if (index < -1) index = -1;") | 76 | assert.Contains(t, forward, "if (index < -1) index = -1;") | ||
| 49 | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | 77 | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | ||
M internal/ui/app.go +25 -0
| @@ -2201,9 +2201,31 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2201 | 2201 | Err(err). | |
| 2202 | 2202 | Str("action", action). | |
| 2203 | 2203 | Msg("vim navigation unavailable") | |
| 2204 | + | return | |
| 2205 | + | } | |
| 2206 | + | if bw != nil && (action == "heading-next" || action == "heading-prev") { | |
| 2207 | + | if _, ok := wv.(port.SemanticNavigable); ok { | |
| 2208 | + | bw.vimNavigationHighlightedWebViews = append(bw.vimNavigationHighlightedWebViews, wv) | |
| 2209 | + | } | |
| 2204 | 2210 | } | |
| 2205 | 2211 | } | |
| 2206 | 2212 | ||
| 2213 | + | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | |
| 2214 | + | if bw == nil { | |
| 2215 | + | return | |
| 2216 | + | } | |
| 2217 | + | navigationUC := a.vimNavigationUseCase() | |
| 2218 | + | if navigationUC == nil { | |
| 2219 | + | return | |
| 2220 | + | } | |
| 2221 | + | for _, wv := range bw.vimNavigationHighlightedWebViews { | |
| 2222 | + | if err := navigationUC.ClearSemanticNavigationHighlight(ctx, wv); err != nil { | |
| 2223 | + | logging.FromContext(ctx).Debug().Err(err).Msg("failed to clear vim navigation highlight") | |
| 2224 | + | } | |
| 2225 | + | } | |
| 2226 | + | bw.vimNavigationHighlightedWebViews = nil | |
| 2227 | + | } | |
| 2228 | + | ||
| 2207 | 2229 | func (a *App) vimNavigationHighlightColor() string { | |
| 2208 | 2230 | if a == nil || a.deps == nil || a.deps.Theme == nil { | |
| 2209 | 2231 | return "" | |
| @@ -3940,6 +3962,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||
| 3940 | 3962 | if to == input.ModeVim && from != input.ModeVim { | |
| 3941 | 3963 | a.enableAccessibilityForVimMode(ctx, bw) | |
| 3942 | 3964 | } | |
| 3965 | + | if from == input.ModeVim && to != input.ModeVim { | |
| 3966 | + | a.clearVimNavigationHighlight(ctx, bw) | |
| 3967 | + | } | |
| 3943 | 3968 | ||
| 3944 | 3969 | // Handle pane-local Vim Mode visual ownership. | |
| 3945 | 3970 | // Entering Vim Mode accents the active pane; leaving removes the accent. | |
| @@ -2201,9 +2201,31 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2201 | Err(err). | 2201 | Err(err). | ||
| 2202 | Str("action", action). | 2202 | Str("action", action). | ||
| 2203 | Msg("vim navigation unavailable") | 2203 | Msg("vim navigation unavailable") | ||
| 2204 | + | return | |||
| 2205 | + | } | |||
| 2206 | + | if bw != nil && (action == "heading-next" || action == "heading-prev") { | |||
| 2207 | + | if _, ok := wv.(port.SemanticNavigable); ok { | |||
| 2208 | + | bw.vimNavigationHighlightedWebViews = append(bw.vimNavigationHighlightedWebViews, wv) | |||
| 2209 | + | } | |||
| 2204 | } | 2210 | } | ||
| 2205 | } | 2211 | } | ||
| 2206 | 2212 | ||||
| 2213 | + | func (a *App) clearVimNavigationHighlight(ctx context.Context, bw *browserWindow) { | |||
| 2214 | + | if bw == nil { | |||
| 2215 | + | return | |||
| 2216 | + | } | |||
| 2217 | + | navigationUC := a.vimNavigationUseCase() | |||
| 2218 | + | if navigationUC == nil { | |||
| 2219 | + | return | |||
| 2220 | + | } | |||
| 2221 | + | for _, wv := range bw.vimNavigationHighlightedWebViews { | |||
| 2222 | + | if err := navigationUC.ClearSemanticNavigationHighlight(ctx, wv); err != nil { | |||
| 2223 | + | logging.FromContext(ctx).Debug().Err(err).Msg("failed to clear vim navigation highlight") | |||
| 2224 | + | } | |||
| 2225 | + | } | |||
| 2226 | + | bw.vimNavigationHighlightedWebViews = nil | |||
| 2227 | + | } | |||
| 2228 | + | ||||
| 2207 | func (a *App) vimNavigationHighlightColor() string { | 2229 | func (a *App) vimNavigationHighlightColor() string { | ||
| 2208 | if a == nil || a.deps == nil || a.deps.Theme == nil { | 2230 | if a == nil || a.deps == nil || a.deps.Theme == nil { | ||
| 2209 | return "" | 2231 | return "" | ||
| @@ -3940,6 +3962,9 @@ func (a *App) handleModeChange(ctx context.Context, bw *browserWindow, from, to | |||||
| 3940 | if to == input.ModeVim && from != input.ModeVim { | 3962 | if to == input.ModeVim && from != input.ModeVim { | ||
| 3941 | a.enableAccessibilityForVimMode(ctx, bw) | 3963 | a.enableAccessibilityForVimMode(ctx, bw) | ||
| 3942 | } | 3964 | } | ||
| 3965 | + | if from == input.ModeVim && to != input.ModeVim { | |||
| 3966 | + | a.clearVimNavigationHighlight(ctx, bw) | |||
| 3967 | + | } | |||
| 3943 | 3968 | ||||
| 3944 | // Handle pane-local Vim Mode visual ownership. | 3969 | // Handle pane-local Vim Mode visual ownership. | ||
| 3945 | // Entering Vim Mode accents the active pane; leaving removes the accent. | 3970 | // Entering Vim Mode accents the active pane; leaving removes the accent. | ||
M internal/ui/app_vim_mode_test.go +51 -1
| @@ -176,7 +176,8 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||
| 176 | 176 | ||
| 177 | 177 | type semanticNavigatingWebView struct { | |
| 178 | 178 | *portmocks.MockWebView | |
| 179 | - | requests []dto.SemanticNavigationRequest | |
| 179 | + | requests []dto.SemanticNavigationRequest | |
| 180 | + | clearNavigationHighlightCalls int | |
| 180 | 181 | } | |
| 181 | 182 | ||
| 182 | 183 | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | |
| @@ -191,6 +192,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||
| 191 | 192 | return nil | |
| 192 | 193 | } | |
| 193 | 194 | ||
| 195 | + | func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error { | |
| 196 | + | wv.clearNavigationHighlightCalls++ | |
| 197 | + | return nil | |
| 198 | + | } | |
| 199 | + | ||
| 194 | 200 | type inputFocusingWebView struct { | |
| 195 | 201 | *portmocks.MockWebView | |
| 196 | 202 | focusNextInputCalls int | |
| @@ -517,6 +523,50 @@ func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | |||
| 517 | 523 | assert.Equal(t, 1, wv.focusNextInputCalls) | |
| 518 | 524 | } | |
| 519 | 525 | ||
| 526 | + | func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) { | |
| 527 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 528 | + | wv := newSemanticNavigatingWebView(t, 8) | |
| 529 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |
| 530 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |
| 531 | + | ||
| 532 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal) | |
| 533 | + | ||
| 534 | + | assert.Equal(t, 1, wv.clearNavigationHighlightCalls) | |
| 535 | + | assert.Empty(t, bw.vimNavigationHighlightedWebViews) | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) { | |
| 539 | + | f := newSingleWindowVimModeFixture(t) | |
| 540 | + | f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil) | |
| 541 | + | oldWebView := newSemanticNavigatingWebView(t, 9) | |
| 542 | + | newWebView := newSemanticNavigatingWebView(t, 10) | |
| 543 | + | f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView) | |
| 544 | + | f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView) | |
| 545 | + | ||
| 546 | + | f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1) | |
| 547 | + | f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b" | |
| 548 | + | f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal) | |
| 549 | + | ||
| 550 | + | assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls) | |
| 551 | + | assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls) | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) { | |
| 555 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 556 | + | oldWebView := newSemanticNavigatingWebView(t, 11) | |
| 557 | + | app.contentCoord.RegisterPopupWebView(paneID, oldWebView) | |
| 558 | + | otherPaneID := entity.PaneID("pane-b") | |
| 559 | + | otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID)) | |
| 560 | + | bw.tabs.Add(otherTab) | |
| 561 | + | bw.tabs.SetActive("tab-1") | |
| 562 | + | ||
| 563 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |
| 564 | + | bw.tabs.SetActive(otherTab.ID) | |
| 565 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal) | |
| 566 | + | ||
| 567 | + | assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls) | |
| 568 | + | } | |
| 569 | + | ||
| 520 | 570 | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | |
| 521 | 571 | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 522 | 572 | wv := newInputFocusingWebView(t, 7) | |
| @@ -176,7 +176,8 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() { | |||||
| 176 | 176 | ||||
| 177 | type semanticNavigatingWebView struct { | 177 | type semanticNavigatingWebView struct { | ||
| 178 | *portmocks.MockWebView | 178 | *portmocks.MockWebView | ||
| 179 | - | requests []dto.SemanticNavigationRequest | 179 | + | requests []dto.SemanticNavigationRequest |
| 180 | + | clearNavigationHighlightCalls int | |||
| 180 | } | 181 | } | ||
| 181 | 182 | ||||
| 182 | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | 183 | func newSemanticNavigatingWebView(t *testing.T, id port.WebViewID) *semanticNavigatingWebView { | ||
| @@ -191,6 +192,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request | |||||
| 191 | return nil | 192 | return nil | ||
| 192 | } | 193 | } | ||
| 193 | 194 | ||||
| 195 | + | func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error { | |||
| 196 | + | wv.clearNavigationHighlightCalls++ | |||
| 197 | + | return nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 194 | type inputFocusingWebView struct { | 200 | type inputFocusingWebView struct { | ||
| 195 | *portmocks.MockWebView | 201 | *portmocks.MockWebView | ||
| 196 | focusNextInputCalls int | 202 | focusNextInputCalls int | ||
| @@ -517,6 +523,50 @@ func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) { | |||||
| 517 | assert.Equal(t, 1, wv.focusNextInputCalls) | 523 | assert.Equal(t, 1, wv.focusNextInputCalls) | ||
| 518 | } | 524 | } | ||
| 519 | 525 | ||||
| 526 | + | func TestVimMode_Leave_ClearsNavigationHighlight(t *testing.T) { | |||
| 527 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 528 | + | wv := newSemanticNavigatingWebView(t, 8) | |||
| 529 | + | app.contentCoord.RegisterPopupWebView(paneID, wv) | |||
| 530 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |||
| 531 | + | ||||
| 532 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal) | |||
| 533 | + | ||||
| 534 | + | assert.Equal(t, 1, wv.clearNavigationHighlightCalls) | |||
| 535 | + | assert.Empty(t, bw.vimNavigationHighlightedWebViews) | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | func TestVimMode_Leave_ClearsNavigationHighlightAfterPaneSwitch(t *testing.T) { | |||
| 539 | + | f := newSingleWindowVimModeFixture(t) | |||
| 540 | + | f.app.contentCoord = contentcoord.NewCoordinator(context.Background(), nil, nil, nil, nil, nil, nil, nil) | |||
| 541 | + | oldWebView := newSemanticNavigatingWebView(t, 9) | |||
| 542 | + | newWebView := newSemanticNavigatingWebView(t, 10) | |||
| 543 | + | f.app.contentCoord.RegisterPopupWebView("pane-a", oldWebView) | |||
| 544 | + | f.app.contentCoord.RegisterPopupWebView("pane-b", newWebView) | |||
| 545 | + | ||||
| 546 | + | f.app.navigateVimSequenceAction(context.Background(), f.bw1, "heading-next", 1) | |||
| 547 | + | f.bw1.tabs.ActiveTab().Workspace.ActivePaneID = "pane-b" | |||
| 548 | + | f.app.handleModeChange(context.Background(), f.bw1, input.ModeVim, input.ModeNormal) | |||
| 549 | + | ||||
| 550 | + | assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls) | |||
| 551 | + | assert.Equal(t, 0, newWebView.clearNavigationHighlightCalls) | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func TestVimMode_Leave_ClearsNavigationHighlightAfterTabSwitch(t *testing.T) { | |||
| 555 | + | app, bw, paneID := newVimModeAccessibilityFixture(t) | |||
| 556 | + | oldWebView := newSemanticNavigatingWebView(t, 11) | |||
| 557 | + | app.contentCoord.RegisterPopupWebView(paneID, oldWebView) | |||
| 558 | + | otherPaneID := entity.PaneID("pane-b") | |||
| 559 | + | otherTab := entity.NewTab("tab-2", "ws-2", entity.NewPane(otherPaneID)) | |||
| 560 | + | bw.tabs.Add(otherTab) | |||
| 561 | + | bw.tabs.SetActive("tab-1") | |||
| 562 | + | ||||
| 563 | + | app.navigateVimSequenceAction(context.Background(), bw, "heading-next", 1) | |||
| 564 | + | bw.tabs.SetActive(otherTab.ID) | |||
| 565 | + | app.handleModeChange(context.Background(), bw, input.ModeVim, input.ModeNormal) | |||
| 566 | + | ||||
| 567 | + | assert.Equal(t, 1, oldWebView.clearNavigationHighlightCalls) | |||
| 568 | + | } | |||
| 569 | + | ||||
| 520 | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | 570 | func TestVimMode_NativePageFocusNavigationUsesActiveEditableWebView(t *testing.T) { | ||
| 521 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 571 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 522 | wv := newInputFocusingWebView(t, 7) | 572 | wv := newInputFocusingWebView(t, 7) | ||
M internal/ui/browser_window.go +4 -0
| @@ -56,6 +56,9 @@ type browserWindow struct { | |||
| 56 | 56 | ||
| 57 | 57 | // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner. | |
| 58 | 58 | vimModePaneID entity.PaneID | |
| 59 | + | // vimNavigationHighlightedWebViews holds every WebView with a semantic Vim | |
| 60 | + | // navigation target that must be cleared when this window leaves Vim Mode. | |
| 61 | + | vimNavigationHighlightedWebViews []port.WebView | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | func (bw *browserWindow) detachInputForDestroy() { | |
| @@ -113,6 +116,7 @@ func (bw *browserWindow) clearShellState() { | |||
| 113 | 116 | bw.historySidebarReloader = nil | |
| 114 | 117 | bw.activeSidebarKind = nativeSidebarNone | |
| 115 | 118 | bw.vimModePaneID = "" | |
| 119 | + | bw.vimNavigationHighlightedWebViews = nil | |
| 116 | 120 | } | |
| 117 | 121 | ||
| 118 | 122 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | |
| @@ -56,6 +56,9 @@ type browserWindow struct { | |||||
| 56 | 56 | ||||
| 57 | // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner. | 57 | // vimModePaneID tracks this window's pane-local Vim Mode accent/pulse owner. | ||
| 58 | vimModePaneID entity.PaneID | 58 | vimModePaneID entity.PaneID | ||
| 59 | + | // vimNavigationHighlightedWebViews holds every WebView with a semantic Vim | |||
| 60 | + | // navigation target that must be cleared when this window leaves Vim Mode. | |||
| 61 | + | vimNavigationHighlightedWebViews []port.WebView | |||
| 59 | } | 62 | } | ||
| 60 | 63 | ||||
| 61 | func (bw *browserWindow) detachInputForDestroy() { | 64 | func (bw *browserWindow) detachInputForDestroy() { | ||
| @@ -113,6 +116,7 @@ func (bw *browserWindow) clearShellState() { | |||||
| 113 | bw.historySidebarReloader = nil | 116 | bw.historySidebarReloader = nil | ||
| 114 | bw.activeSidebarKind = nativeSidebarNone | 117 | bw.activeSidebarKind = nativeSidebarNone | ||
| 115 | bw.vimModePaneID = "" | 118 | bw.vimModePaneID = "" | ||
| 119 | + | bw.vimNavigationHighlightedWebViews = nil | |||
| 116 | } | 120 | } | ||
| 117 | 121 | ||||
| 118 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | 122 | func (bw *browserWindow) initChrome(ctx context.Context, a *App) { | ||