fix(vim): follow theme accent for headings
- commit
- 40007580f55bd08de0aa4bb8e61f6442a2807d58
- author
- brice · · 2026-08-14 12:09 UTC
- parent
- a11aeca
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +54 -19
M internal/application/port/webview.go +4 -3
| @@ -465,9 +465,10 @@ const ( | |||
| 465 | 465 | // SemanticNavigationRequest describes one Vim structural movement. Count uses | |
| 466 | 466 | // Vim semantics: adapters treat values less than one as one. | |
| 467 | 467 | type SemanticNavigationRequest struct { | |
| 468 | - | Target SemanticNavigationTarget | |
| 469 | - | Direction SemanticNavigationDirection | |
| 470 | - | Count int | |
| 468 | + | Target SemanticNavigationTarget | |
| 469 | + | Direction SemanticNavigationDirection | |
| 470 | + | Count int | |
| 471 | + | HighlightColor string | |
| 471 | 472 | } | |
| 472 | 473 | ||
| 473 | 474 | // SemanticNavigable is an optional WebView capability for visible structural | |
| @@ -465,9 +465,10 @@ const ( | |||||
| 465 | // SemanticNavigationRequest describes one Vim structural movement. Count uses | 465 | // SemanticNavigationRequest describes one Vim structural movement. Count uses | ||
| 466 | // Vim semantics: adapters treat values less than one as one. | 466 | // Vim semantics: adapters treat values less than one as one. | ||
| 467 | type SemanticNavigationRequest struct { | 467 | type SemanticNavigationRequest struct { | ||
| 468 | - | Target SemanticNavigationTarget | 468 | + | Target SemanticNavigationTarget |
| 469 | - | Direction SemanticNavigationDirection | 469 | + | Direction SemanticNavigationDirection |
| 470 | - | Count int | 470 | + | Count int |
| 471 | + | HighlightColor string | |||
| 471 | } | 472 | } | ||
| 472 | 473 | ||||
| 473 | // SemanticNavigable is an optional WebView capability for visible structural | 474 | // SemanticNavigable is an optional WebView capability for visible structural | ||
M internal/infrastructure/cef/semantic_navigation.go +12 -5
| @@ -26,14 +26,18 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request port.SemanticNa | |||
| 26 | 26 | if count < 1 { | |
| 27 | 27 | count = 1 | |
| 28 | 28 | } | |
| 29 | - | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count)) | |
| 29 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor)) | |
| 30 | 30 | return nil | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | - | func headingNavigationScript(direction, count int) string { | |
| 33 | + | func headingNavigationScript(direction, count int, highlightColor string) string { | |
| 34 | + | if highlightColor == "" { | |
| 35 | + | highlightColor = "#fbbf24" | |
| 36 | + | } | |
| 34 | 37 | return `(() => { | |
| 35 | 38 | const stateKey = "__dumberVimHeadingTarget"; | |
| 36 | 39 | const className = "dumber-vim-heading-target"; | |
| 40 | + | const highlightColor = ` + strconv.Quote(highlightColor) + `; | |
| 37 | 41 | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | |
| 38 | 42 | .filter((heading) => heading.getClientRects().length > 0); | |
| 39 | 43 | if (headings.length === 0) return; | |
| @@ -57,14 +61,17 @@ func headingNavigationScript(direction, count int) string { | |||
| 57 | 61 | ||
| 58 | 62 | const previous = document.querySelector("." + className); | |
| 59 | 63 | if (previous && previous !== target) previous.classList.remove(className); | |
| 60 | - | if (!document.getElementById("dumber-vim-heading-target-style")) { | |
| 64 | + | let style = document.getElementById("dumber-vim-heading-target-style"); | |
| 65 | + | if (!style) { | |
| 61 | 66 | // This visual cue is best-effort: strict page CSP can reject a DOM style | |
| 62 | 67 | // element, but target selection and scrolling must stay independent of it. | |
| 63 | - | const style = document.createElement("style"); | |
| 68 | + | style = document.createElement("style"); | |
| 64 | 69 | 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 | 70 | document.documentElement.appendChild(style); | |
| 67 | 71 | } | |
| 72 | + | // Refresh the outline on every navigation so an in-session accent change is | |
| 73 | + | // reflected even when the target style element already exists. | |
| 74 | + | style.textContent = "." + className + " { outline: 3px solid " + highlightColor + " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |
| 68 | 75 | window[stateKey] = target; | |
| 69 | 76 | target.classList.add(className); | |
| 70 | 77 | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | |
| @@ -26,14 +26,18 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request port.SemanticNa | |||||
| 26 | if count < 1 { | 26 | if count < 1 { | ||
| 27 | count = 1 | 27 | count = 1 | ||
| 28 | } | 28 | } | ||
| 29 | - | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count)) | 29 | + | wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor)) |
| 30 | return nil | 30 | return nil | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | - | func headingNavigationScript(direction, count int) string { | 33 | + | func headingNavigationScript(direction, count int, highlightColor string) string { |
| 34 | + | if highlightColor == "" { | |||
| 35 | + | highlightColor = "#fbbf24" | |||
| 36 | + | } | |||
| 34 | return `(() => { | 37 | return `(() => { | ||
| 35 | const stateKey = "__dumberVimHeadingTarget"; | 38 | const stateKey = "__dumberVimHeadingTarget"; | ||
| 36 | const className = "dumber-vim-heading-target"; | 39 | const className = "dumber-vim-heading-target"; | ||
| 40 | + | const highlightColor = ` + strconv.Quote(highlightColor) + `; | |||
| 37 | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | 41 | const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")) | ||
| 38 | .filter((heading) => heading.getClientRects().length > 0); | 42 | .filter((heading) => heading.getClientRects().length > 0); | ||
| 39 | if (headings.length === 0) return; | 43 | if (headings.length === 0) return; | ||
| @@ -57,14 +61,17 @@ func headingNavigationScript(direction, count int) string { | |||||
| 57 | 61 | ||||
| 58 | const previous = document.querySelector("." + className); | 62 | const previous = document.querySelector("." + className); | ||
| 59 | if (previous && previous !== target) previous.classList.remove(className); | 63 | if (previous && previous !== target) previous.classList.remove(className); | ||
| 60 | - | if (!document.getElementById("dumber-vim-heading-target-style")) { | 64 | + | let style = document.getElementById("dumber-vim-heading-target-style"); |
| 65 | + | if (!style) { | |||
| 61 | // This visual cue is best-effort: strict page CSP can reject a DOM style | 66 | // 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. | 67 | // element, but target selection and scrolling must stay independent of it. | ||
| 63 | - | const style = document.createElement("style"); | 68 | + | style = document.createElement("style"); |
| 64 | style.id = "dumber-vim-heading-target-style"; | 69 | 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); | 70 | document.documentElement.appendChild(style); | ||
| 67 | } | 71 | } | ||
| 72 | + | // Refresh the outline on every navigation so an in-session accent change is | |||
| 73 | + | // reflected even when the target style element already exists. | |||
| 74 | + | style.textContent = "." + className + " { outline: 3px solid " + highlightColor + " !important; outline-offset: 5px !important; border-radius: 3px !important; }"; | |||
| 68 | window[stateKey] = target; | 75 | window[stateKey] = target; | ||
| 69 | target.classList.add(className); | 76 | target.classList.add(className); | ||
| 70 | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | 77 | target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" }); | ||
M internal/infrastructure/cef/semantic_navigation_test.go +11 -5
| @@ -23,25 +23,31 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||
| 23 | 23 | "previous && previous !== target", | |
| 24 | 24 | "scrollIntoView", | |
| 25 | 25 | "window[stateKey] = target", | |
| 26 | + | "const highlightColor = \"#22c55e\"", | |
| 27 | + | "let style = document.getElementById(\"dumber-vim-heading-target-style\")", | |
| 28 | + | "style.textContent = \".\" + className", | |
| 26 | 29 | "1 * 3", | |
| 27 | 30 | )), "", int32(0)).Once() | |
| 28 | 31 | ||
| 29 | 32 | wv := &WebView{browser: browser} | |
| 30 | 33 | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |
| 31 | - | Target: port.SemanticNavigationTargetHeading, | |
| 32 | - | Direction: port.SemanticNavigationForward, | |
| 33 | - | Count: 3, | |
| 34 | + | Target: port.SemanticNavigationTargetHeading, | |
| 35 | + | Direction: port.SemanticNavigationForward, | |
| 36 | + | Count: 3, | |
| 37 | + | HighlightColor: "#22c55e", | |
| 34 | 38 | }) | |
| 35 | 39 | require.NoError(t, err) | |
| 36 | 40 | } | |
| 37 | 41 | ||
| 38 | 42 | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | |
| 39 | - | forward := headingNavigationScript(1, 1) | |
| 40 | - | backward := headingNavigationScript(-1, 1) | |
| 43 | + | forward := headingNavigationScript(1, 1, "") | |
| 44 | + | backward := headingNavigationScript(-1, 1, "#4ade80") | |
| 41 | 45 | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | |
| 42 | 46 | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | |
| 43 | 47 | assert.Contains(t, backward, "-1 * 1") | |
| 44 | 48 | assert.NotContains(t, backward, "window.innerHeight") | |
| 49 | + | assert.Contains(t, forward, "const highlightColor = \"#fbbf24\"") | |
| 50 | + | assert.Contains(t, backward, "const highlightColor = \"#4ade80\"") | |
| 45 | 51 | ||
| 46 | 52 | wv := &WebView{} | |
| 47 | 53 | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | |
| @@ -23,25 +23,31 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin | |||||
| 23 | "previous && previous !== target", | 23 | "previous && previous !== target", | ||
| 24 | "scrollIntoView", | 24 | "scrollIntoView", | ||
| 25 | "window[stateKey] = target", | 25 | "window[stateKey] = target", | ||
| 26 | + | "const highlightColor = \"#22c55e\"", | |||
| 27 | + | "let style = document.getElementById(\"dumber-vim-heading-target-style\")", | |||
| 28 | + | "style.textContent = \".\" + className", | |||
| 26 | "1 * 3", | 29 | "1 * 3", | ||
| 27 | )), "", int32(0)).Once() | 30 | )), "", int32(0)).Once() | ||
| 28 | 31 | ||||
| 29 | wv := &WebView{browser: browser} | 32 | wv := &WebView{browser: browser} | ||
| 30 | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | 33 | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | ||
| 31 | - | Target: port.SemanticNavigationTargetHeading, | 34 | + | Target: port.SemanticNavigationTargetHeading, |
| 32 | - | Direction: port.SemanticNavigationForward, | 35 | + | Direction: port.SemanticNavigationForward, |
| 33 | - | Count: 3, | 36 | + | Count: 3, |
| 37 | + | HighlightColor: "#22c55e", | |||
| 34 | }) | 38 | }) | ||
| 35 | require.NoError(t, err) | 39 | require.NoError(t, err) | ||
| 36 | } | 40 | } | ||
| 37 | 41 | ||||
| 38 | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | 42 | func TestWebViewNavigateSemantic_NormalizesCountAndRejectsUnsupportedRequests(t *testing.T) { | ||
| 39 | - | forward := headingNavigationScript(1, 1) | 43 | + | forward := headingNavigationScript(1, 1, "") |
| 40 | - | backward := headingNavigationScript(-1, 1) | 44 | + | backward := headingNavigationScript(-1, 1, "#4ade80") |
| 41 | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | 45 | assert.Contains(t, forward, "index = firstAtOrAfterViewportTop - 1") | ||
| 42 | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | 46 | assert.Contains(t, backward, "index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length") | ||
| 43 | assert.Contains(t, backward, "-1 * 1") | 47 | assert.Contains(t, backward, "-1 * 1") | ||
| 44 | assert.NotContains(t, backward, "window.innerHeight") | 48 | assert.NotContains(t, backward, "window.innerHeight") | ||
| 49 | + | assert.Contains(t, forward, "const highlightColor = \"#fbbf24\"") | |||
| 50 | + | assert.Contains(t, backward, "const highlightColor = \"#4ade80\"") | |||
| 45 | 51 | ||||
| 46 | wv := &WebView{} | 52 | wv := &WebView{} | ||
| 47 | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | 53 | err := wv.NavigateSemantic(context.Background(), port.SemanticNavigationRequest{ | ||
M internal/ui/app.go +15 -6
| @@ -2168,15 +2168,17 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2168 | 2168 | switch action { | |
| 2169 | 2169 | case "heading-next": | |
| 2170 | 2170 | request = port.SemanticNavigationRequest{ | |
| 2171 | - | Target: port.SemanticNavigationTargetHeading, | |
| 2172 | - | Direction: port.SemanticNavigationForward, | |
| 2173 | - | Count: count, | |
| 2171 | + | Target: port.SemanticNavigationTargetHeading, | |
| 2172 | + | Direction: port.SemanticNavigationForward, | |
| 2173 | + | Count: count, | |
| 2174 | + | HighlightColor: a.vimNavigationHighlightColor(), | |
| 2174 | 2175 | } | |
| 2175 | 2176 | case "heading-prev": | |
| 2176 | 2177 | request = port.SemanticNavigationRequest{ | |
| 2177 | - | Target: port.SemanticNavigationTargetHeading, | |
| 2178 | - | Direction: port.SemanticNavigationBackward, | |
| 2179 | - | Count: count, | |
| 2178 | + | Target: port.SemanticNavigationTargetHeading, | |
| 2179 | + | Direction: port.SemanticNavigationBackward, | |
| 2180 | + | Count: count, | |
| 2181 | + | HighlightColor: a.vimNavigationHighlightColor(), | |
| 2180 | 2182 | } | |
| 2181 | 2183 | default: | |
| 2182 | 2184 | return | |
| @@ -2196,6 +2198,13 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||
| 2196 | 2198 | } | |
| 2197 | 2199 | } | |
| 2198 | 2200 | ||
| 2201 | + | func (a *App) vimNavigationHighlightColor() string { | |
| 2202 | + | if a == nil || a.deps == nil || a.deps.Theme == nil { | |
| 2203 | + | return "" | |
| 2204 | + | } | |
| 2205 | + | return a.deps.Theme.GetCurrentPalette().Accent | |
| 2206 | + | } | |
| 2207 | + | ||
| 2199 | 2208 | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | |
| 2200 | 2209 | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2201 | 2210 | if wv == nil { | |
| @@ -2168,15 +2168,17 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2168 | switch action { | 2168 | switch action { | ||
| 2169 | case "heading-next": | 2169 | case "heading-next": | ||
| 2170 | request = port.SemanticNavigationRequest{ | 2170 | request = port.SemanticNavigationRequest{ | ||
| 2171 | - | Target: port.SemanticNavigationTargetHeading, | 2171 | + | Target: port.SemanticNavigationTargetHeading, |
| 2172 | - | Direction: port.SemanticNavigationForward, | 2172 | + | Direction: port.SemanticNavigationForward, |
| 2173 | - | Count: count, | 2173 | + | Count: count, |
| 2174 | + | HighlightColor: a.vimNavigationHighlightColor(), | |||
| 2174 | } | 2175 | } | ||
| 2175 | case "heading-prev": | 2176 | case "heading-prev": | ||
| 2176 | request = port.SemanticNavigationRequest{ | 2177 | request = port.SemanticNavigationRequest{ | ||
| 2177 | - | Target: port.SemanticNavigationTargetHeading, | 2178 | + | Target: port.SemanticNavigationTargetHeading, |
| 2178 | - | Direction: port.SemanticNavigationBackward, | 2179 | + | Direction: port.SemanticNavigationBackward, |
| 2179 | - | Count: count, | 2180 | + | Count: count, |
| 2181 | + | HighlightColor: a.vimNavigationHighlightColor(), | |||
| 2180 | } | 2182 | } | ||
| 2181 | default: | 2183 | default: | ||
| 2182 | return | 2184 | return | ||
| @@ -2196,6 +2198,13 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow, | |||||
| 2196 | } | 2198 | } | ||
| 2197 | } | 2199 | } | ||
| 2198 | 2200 | ||||
| 2201 | + | func (a *App) vimNavigationHighlightColor() string { | |||
| 2202 | + | if a == nil || a.deps == nil || a.deps.Theme == nil { | |||
| 2203 | + | return "" | |||
| 2204 | + | } | |||
| 2205 | + | return a.deps.Theme.GetCurrentPalette().Accent | |||
| 2206 | + | } | |||
| 2207 | + | ||||
| 2199 | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | 2208 | func (a *App) enableAccessibilityForVimMode(ctx context.Context, bw *browserWindow) { | ||
| 2200 | paneID, wv := a.activeWebViewForBrowserWindow(bw) | 2209 | paneID, wv := a.activeWebViewForBrowserWindow(bw) | ||
| 2201 | if wv == nil { | 2210 | if wv == nil { | ||
M internal/ui/app_vim_mode_test.go +12 -0
| @@ -433,6 +433,18 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) | |||
| 433 | 433 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | |
| 434 | 434 | } | |
| 435 | 435 | ||
| 436 | + | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | |
| 437 | + | resolved := entity.ResolvedTheme{ | |
| 438 | + | PrefersDark: true, | |
| 439 | + | DarkPalette: entity.ColorPalette{ | |
| 440 | + | Accent: "#22c55e", | |
| 441 | + | }, | |
| 442 | + | } | |
| 443 | + | app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}} | |
| 444 | + | ||
| 445 | + | assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor()) | |
| 446 | + | } | |
| 447 | + | ||
| 436 | 448 | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | |
| 437 | 449 | app, bw, paneID := newVimModeAccessibilityFixture(t) | |
| 438 | 450 | wv := newSemanticNavigatingWebView(t, 4) | |
| @@ -433,6 +433,18 @@ func TestVimMode_Enter_AccessibilityNoopsWhenActiveWebViewMissing(t *testing.T) | |||||
| 433 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | 433 | app.handleModeChange(context.Background(), bw, input.ModeNormal, input.ModeVim) | ||
| 434 | } | 434 | } | ||
| 435 | 435 | ||||
| 436 | + | func TestVimNavigationHighlightColorUsesCurrentThemeAccent(t *testing.T) { | |||
| 437 | + | resolved := entity.ResolvedTheme{ | |||
| 438 | + | PrefersDark: true, | |||
| 439 | + | DarkPalette: entity.ColorPalette{ | |||
| 440 | + | Accent: "#22c55e", | |||
| 441 | + | }, | |||
| 442 | + | } | |||
| 443 | + | app := &App{deps: &Dependencies{Theme: theme.NewManager(context.Background(), resolved)}} | |||
| 444 | + | ||||
| 445 | + | assert.Equal(t, "#22c55e", app.vimNavigationHighlightColor()) | |||
| 446 | + | } | |||
| 447 | + | ||||
| 436 | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | 448 | func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) { | ||
| 437 | app, bw, paneID := newVimModeAccessibilityFixture(t) | 449 | app, bw, paneID := newVimModeAccessibilityFixture(t) | ||
| 438 | wv := newSemanticNavigatingWebView(t, 4) | 450 | wv := newSemanticNavigatingWebView(t, 4) | ||