detached 4e133f4

feat(cef): capture measured accessibility updates per webview

commit
4e133f4bdf02213bd55d0922bcf3ba5153b4b7cf
author
brice · · 2026-07-29 08:57 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
482b51e
signature
unsigned
7 files · +163 -78
M internal/application/port/webview.go +7 -0
@@ -437,3 +437,10 @@ type PageScrollable interface {
437437 type PageScrollCanceler interface {
438438 CancelPageScroll(ctx context.Context)
439439 }
440+
441+// AccessibilityEnabler is an optional capability for adapters that can enable
442+// browser accessibility tooling once per WebView. Engines that do not support
443+// accessibility leave this unimplemented so callers can type-assert safely.
444+type AccessibilityEnabler interface {
445+ EnableAccessibility()
446+}
@@ -437,3 +437,10 @@ type PageScrollable interface {
437 type PageScrollCanceler interface {437 type PageScrollCanceler interface {
438 CancelPageScroll(ctx context.Context)438 CancelPageScroll(ctx context.Context)
439 }439 }
440+
441+// AccessibilityEnabler is an optional capability for adapters that can enable
442+// browser accessibility tooling once per WebView. Engines that do not support
443+// accessibility leave this unimplemented so callers can type-assert safely.
444+type AccessibilityEnabler interface {
445+ EnableAccessibility()
446+}
M internal/domain/entity/config_types.go +16 -42
@@ -44,10 +44,9 @@ type ActionBinding struct {
4444
4545 // PaneModeConfig holds pane mode shortcut configuration.
4646 type PaneModeConfig struct {
47- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
48- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
49- SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
50- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
47+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
48+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
49+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
5150 }
5251
5352 // keyBindingsFromActions converts an action binding map to a key→action map.
@@ -79,11 +78,13 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
7978 return keyBindingsFromActions(t.Actions)
8079 }
8180
82-// VimModeConfig holds vim scroll mode shortcut configuration.
81+// VimModeConfig holds page scroll mode shortcut configuration.
8382 type VimModeConfig struct {
84- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
85- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
86- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
83+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
84+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
85+ SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
86+ PreloadAccessibility bool `mapstructure:"preload_accessibility" yaml:"preload_accessibility" toml:"preload_accessibility" json:"preload_accessibility"` //nolint:lll // struct tags must stay on one line
87+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
8788 }
8889
8990 // GetKeyBindings returns a map from key string to action name.
@@ -210,42 +211,15 @@ type BrowsingContextConfig struct {
210211 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
211212 type PopupBehaviorConfig = BrowsingContextConfig
212213
213-// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
214-type ExternalLinkBehavior string
215-
216-const (
217- ExternalLinkBehaviorWindowed ExternalLinkBehavior = "windowed"
218- ExternalLinkBehaviorTabbed ExternalLinkBehavior = "tabbed"
219- ExternalLinkBehaviorSplit ExternalLinkBehavior = "split"
220- ExternalLinkBehaviorStacked ExternalLinkBehavior = "stacked"
221-)
222-
223-// ExternalLinkPlacement defines the direction used when splitting for an external URL.
224-type ExternalLinkPlacement string
225-
226-const (
227- ExternalLinkPlacementRight ExternalLinkPlacement = "right"
228- ExternalLinkPlacementLeft ExternalLinkPlacement = "left"
229- ExternalLinkPlacementTop ExternalLinkPlacement = "top"
230- ExternalLinkPlacementBottom ExternalLinkPlacement = "bottom"
231-)
232-
233-// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
234-type ExternalLinksConfig struct {
235- Behavior ExternalLinkBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
236- Placement ExternalLinkPlacement `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
237-}
238-
239214 // WorkspaceConfig holds all workspace layout and behavior settings.
240215 type WorkspaceConfig struct {
241- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
242- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
243- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
244- VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
245- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
246- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
247- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
248- ExternalLinks ExternalLinksConfig `mapstructure:"external_links" yaml:"external_links" toml:"external_links" json:"external_links"`
216+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
217+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
218+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
219+ VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
220+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
221+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
222+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
249223
250224 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
251225 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
@@ -44,10 +44,9 @@ type ActionBinding struct {
44 44
45 // PaneModeConfig holds pane mode shortcut configuration.45 // PaneModeConfig holds pane mode shortcut configuration.
46 type PaneModeConfig struct {46 type PaneModeConfig struct {
47- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line47+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
48- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`48+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
49- SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line49+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
50- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
51 }50 }
52 51
53 // keyBindingsFromActions converts an action binding map to a key→action map.52 // keyBindingsFromActions converts an action binding map to a key→action map.
@@ -79,11 +78,13 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string {
79 return keyBindingsFromActions(t.Actions)78 return keyBindingsFromActions(t.Actions)
80 }79 }
81 80
82-// VimModeConfig holds vim scroll mode shortcut configuration.81+// VimModeConfig holds page scroll mode shortcut configuration.
83 type VimModeConfig struct {82 type VimModeConfig struct {
84- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line83+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
85- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`84+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
86- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`85+ SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
86+ PreloadAccessibility bool `mapstructure:"preload_accessibility" yaml:"preload_accessibility" toml:"preload_accessibility" json:"preload_accessibility"` //nolint:lll // struct tags must stay on one line
87+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
87 }88 }
88 89
89 // GetKeyBindings returns a map from key string to action name.90 // GetKeyBindings returns a map from key string to action name.
@@ -210,42 +211,15 @@ type BrowsingContextConfig struct {
210 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.211 // Deprecated: PopupBehaviorConfig is a compatibility alias for BrowsingContextConfig.
211 type PopupBehaviorConfig = BrowsingContextConfig212 type PopupBehaviorConfig = BrowsingContextConfig
212 213
213-// ExternalLinkBehavior defines how externally opened URLs are placed in the workspace.
214-type ExternalLinkBehavior string
215-
216-const (
217- ExternalLinkBehaviorWindowed ExternalLinkBehavior = "windowed"
218- ExternalLinkBehaviorTabbed ExternalLinkBehavior = "tabbed"
219- ExternalLinkBehaviorSplit ExternalLinkBehavior = "split"
220- ExternalLinkBehaviorStacked ExternalLinkBehavior = "stacked"
221-)
222-
223-// ExternalLinkPlacement defines the direction used when splitting for an external URL.
224-type ExternalLinkPlacement string
225-
226-const (
227- ExternalLinkPlacementRight ExternalLinkPlacement = "right"
228- ExternalLinkPlacementLeft ExternalLinkPlacement = "left"
229- ExternalLinkPlacementTop ExternalLinkPlacement = "top"
230- ExternalLinkPlacementBottom ExternalLinkPlacement = "bottom"
231-)
232-
233-// ExternalLinksConfig controls how externally opened URLs are placed in the workspace.
234-type ExternalLinksConfig struct {
235- Behavior ExternalLinkBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
236- Placement ExternalLinkPlacement `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
237-}
238-
239 // WorkspaceConfig holds all workspace layout and behavior settings.214 // WorkspaceConfig holds all workspace layout and behavior settings.
240 type WorkspaceConfig struct {215 type WorkspaceConfig struct {
241- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`216+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
242- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`217+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
243- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`218+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
244- VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`219+ VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"`
245- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`220+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
246- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`221+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
247- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`222+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
248- ExternalLinks ExternalLinksConfig `mapstructure:"external_links" yaml:"external_links" toml:"external_links" json:"external_links"`
249 223
250 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`224 TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
251 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line225 HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
A internal/infrastructure/cef/accessibility_enable_test.go +103 -0
@@ -0,0 +1,103 @@
1+package cef
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+type accessibilityStateHost struct {
15+ purecef.BrowserHost
16+
17+ mu sync.Mutex
18+ states []purecef.State
19+}
20+
21+func (h *accessibilityStateHost) SetAccessibilityState(state purecef.State) {
22+ h.mu.Lock()
23+ defer h.mu.Unlock()
24+ h.states = append(h.states, state)
25+}
26+
27+func (h *accessibilityStateHost) recordedStates() []purecef.State {
28+ h.mu.Lock()
29+ defer h.mu.Unlock()
30+ out := make([]purecef.State, len(h.states))
31+ copy(out, h.states)
32+ return out
33+}
34+
35+func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) {
36+ t.Parallel()
37+
38+ destroyed := &WebView{}
39+ destroyed.destroyed.Store(true)
40+ host := &accessibilityStateHost{}
41+ destroyed.host = host
42+ destroyed.EnableAccessibility()
43+ assert.Empty(t, host.recordedStates())
44+
45+ noHost := &WebView{}
46+ noHost.EnableAccessibility()
47+ assert.False(t, noHost.a11yEnabled.Load())
48+}
49+
50+func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {
51+ t.Parallel()
52+
53+ host := &accessibilityStateHost{}
54+ wv := &WebView{host: host}
55+
56+ wv.EnableAccessibility()
57+ wv.EnableAccessibility()
58+ wv.EnableAccessibility()
59+
60+ states := host.recordedStates()
61+ require.Len(t, states, 1)
62+ assert.Equal(t, purecef.StateStateEnabled, states[0])
63+ assert.True(t, wv.a11yEnabled.Load())
64+}
65+
66+func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) {
67+ t.Parallel()
68+
69+ hostA := &accessibilityStateHost{}
70+ hostB := &accessibilityStateHost{}
71+ wvA := &WebView{host: hostA}
72+ wvB := &WebView{host: hostB}
73+
74+ wvA.EnableAccessibility()
75+ wvA.EnableAccessibility()
76+ wvB.EnableAccessibility()
77+
78+ require.Len(t, hostA.recordedStates(), 1)
79+ require.Len(t, hostB.recordedStates(), 1)
80+ assert.Equal(t, purecef.StateStateEnabled, hostA.recordedStates()[0])
81+ assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])
82+}
83+
84+func TestWebView_EnableAccessibility_RetriesWhenHostBecomesReady(t *testing.T) {
85+ t.Parallel()
86+
87+ wv := &WebView{}
88+ wv.EnableAccessibility()
89+ assert.False(t, wv.a11yEnabled.Load())
90+
91+ host := &accessibilityStateHost{}
92+ wv.host = host
93+ wv.EnableAccessibility()
94+
95+ require.Len(t, host.recordedStates(), 1)
96+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
97+ assert.True(t, wv.a11yEnabled.Load())
98+}
99+
100+func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {
101+ t.Parallel()
102+ var _ port.AccessibilityEnabler = (*WebView)(nil)
103+}
@@ -0,0 +1,103 @@
1+package cef
2+
3+import (
4+ "sync"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+type accessibilityStateHost struct {
15+ purecef.BrowserHost
16+
17+ mu sync.Mutex
18+ states []purecef.State
19+}
20+
21+func (h *accessibilityStateHost) SetAccessibilityState(state purecef.State) {
22+ h.mu.Lock()
23+ defer h.mu.Unlock()
24+ h.states = append(h.states, state)
25+}
26+
27+func (h *accessibilityStateHost) recordedStates() []purecef.State {
28+ h.mu.Lock()
29+ defer h.mu.Unlock()
30+ out := make([]purecef.State, len(h.states))
31+ copy(out, h.states)
32+ return out
33+}
34+
35+func TestWebView_EnableAccessibility_NoOpWhenDestroyedOrHostNil(t *testing.T) {
36+ t.Parallel()
37+
38+ destroyed := &WebView{}
39+ destroyed.destroyed.Store(true)
40+ host := &accessibilityStateHost{}
41+ destroyed.host = host
42+ destroyed.EnableAccessibility()
43+ assert.Empty(t, host.recordedStates())
44+
45+ noHost := &WebView{}
46+ noHost.EnableAccessibility()
47+ assert.False(t, noHost.a11yEnabled.Load())
48+}
49+
50+func TestWebView_EnableAccessibility_IdempotentOncePerWebView(t *testing.T) {
51+ t.Parallel()
52+
53+ host := &accessibilityStateHost{}
54+ wv := &WebView{host: host}
55+
56+ wv.EnableAccessibility()
57+ wv.EnableAccessibility()
58+ wv.EnableAccessibility()
59+
60+ states := host.recordedStates()
61+ require.Len(t, states, 1)
62+ assert.Equal(t, purecef.StateStateEnabled, states[0])
63+ assert.True(t, wv.a11yEnabled.Load())
64+}
65+
66+func TestWebView_EnableAccessibility_IndependentPerWebView(t *testing.T) {
67+ t.Parallel()
68+
69+ hostA := &accessibilityStateHost{}
70+ hostB := &accessibilityStateHost{}
71+ wvA := &WebView{host: hostA}
72+ wvB := &WebView{host: hostB}
73+
74+ wvA.EnableAccessibility()
75+ wvA.EnableAccessibility()
76+ wvB.EnableAccessibility()
77+
78+ require.Len(t, hostA.recordedStates(), 1)
79+ require.Len(t, hostB.recordedStates(), 1)
80+ assert.Equal(t, purecef.StateStateEnabled, hostA.recordedStates()[0])
81+ assert.Equal(t, purecef.StateStateEnabled, hostB.recordedStates()[0])
82+}
83+
84+func TestWebView_EnableAccessibility_RetriesWhenHostBecomesReady(t *testing.T) {
85+ t.Parallel()
86+
87+ wv := &WebView{}
88+ wv.EnableAccessibility()
89+ assert.False(t, wv.a11yEnabled.Load())
90+
91+ host := &accessibilityStateHost{}
92+ wv.host = host
93+ wv.EnableAccessibility()
94+
95+ require.Len(t, host.recordedStates(), 1)
96+ assert.Equal(t, purecef.StateStateEnabled, host.recordedStates()[0])
97+ assert.True(t, wv.a11yEnabled.Load())
98+}
99+
100+func TestWebView_ImplementsAccessibilityEnabler(t *testing.T) {
101+ t.Parallel()
102+ var _ port.AccessibilityEnabler = (*WebView)(nil)
103+}
M internal/infrastructure/cef/webview.go +22 -0
@@ -37,6 +37,7 @@ var (
3737 _ port.OAuthCallbackCapable = (*WebView)(nil)
3838 _ port.PageScrollable = (*WebView)(nil)
3939 _ port.PageScrollCanceler = (*WebView)(nil)
40+ _ port.AccessibilityEnabler = (*WebView)(nil)
4041 )
4142
4243 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -157,6 +158,7 @@ type WebView struct {
157158 a11yWorker *accessibilityCaptureWorker
158159 a11yCapture *accessibilityCapture
159160 a11yStats accessibilityStats
161+ a11yEnabled atomic.Bool
160162
161163 // beginFrameTick drives CEF external BeginFrame requests while the GTK
162164 // widget is visible. Access is guarded by mu.
@@ -2541,6 +2543,26 @@ func (wv *WebView) CancelPageScroll(_ context.Context) {
25412543 q.commitMu.Unlock()
25422544 }
25432545
2546+// EnableAccessibility turns on CEF accessibility for this WebView exactly once.
2547+// Calls before the browser host exists are no-ops so UI can safely retry.
2548+func (wv *WebView) EnableAccessibility() {
2549+ if wv == nil || wv.destroyed.Load() || wv.a11yEnabled.Load() {
2550+ return
2551+ }
2552+ wv.mu.RLock()
2553+ host := wv.host
2554+ wv.mu.RUnlock()
2555+ if host == nil || !wv.a11yEnabled.CompareAndSwap(false, true) {
2556+ return
2557+ }
2558+ host.SetAccessibilityState(purecef.StateStateEnabled)
2559+ if wv.ctx != nil {
2560+ logging.FromContext(wv.ctx).Debug().
2561+ Uint64("webview_id", uint64(wv.id)).
2562+ Msg("cef: accessibility enabled")
2563+ }
2564+}
2565+
25442566 func (wv *WebView) resetPageScrollQueue() {
25452567 q := &wv.pageScrollQueue
25462568 q.mu.Lock()
@@ -37,6 +37,7 @@ var (
37 _ port.OAuthCallbackCapable = (*WebView)(nil)37 _ port.OAuthCallbackCapable = (*WebView)(nil)
38 _ port.PageScrollable = (*WebView)(nil)38 _ port.PageScrollable = (*WebView)(nil)
39 _ port.PageScrollCanceler = (*WebView)(nil)39 _ port.PageScrollCanceler = (*WebView)(nil)
40+ _ port.AccessibilityEnabler = (*WebView)(nil)
40 )41 )
41 42
42 // errDestroyed is returned when an operation is attempted on a destroyed WebView.43 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -157,6 +158,7 @@ type WebView struct {
157 a11yWorker *accessibilityCaptureWorker158 a11yWorker *accessibilityCaptureWorker
158 a11yCapture *accessibilityCapture159 a11yCapture *accessibilityCapture
159 a11yStats accessibilityStats160 a11yStats accessibilityStats
161+ a11yEnabled atomic.Bool
160 162
161 // beginFrameTick drives CEF external BeginFrame requests while the GTK163 // beginFrameTick drives CEF external BeginFrame requests while the GTK
162 // widget is visible. Access is guarded by mu.164 // widget is visible. Access is guarded by mu.
@@ -2541,6 +2543,26 @@ func (wv *WebView) CancelPageScroll(_ context.Context) {
2541 q.commitMu.Unlock()2543 q.commitMu.Unlock()
2542 }2544 }
2543 2545
2546+// EnableAccessibility turns on CEF accessibility for this WebView exactly once.
2547+// Calls before the browser host exists are no-ops so UI can safely retry.
2548+func (wv *WebView) EnableAccessibility() {
2549+ if wv == nil || wv.destroyed.Load() || wv.a11yEnabled.Load() {
2550+ return
2551+ }
2552+ wv.mu.RLock()
2553+ host := wv.host
2554+ wv.mu.RUnlock()
2555+ if host == nil || !wv.a11yEnabled.CompareAndSwap(false, true) {
2556+ return
2557+ }
2558+ host.SetAccessibilityState(purecef.StateStateEnabled)
2559+ if wv.ctx != nil {
2560+ logging.FromContext(wv.ctx).Debug().
2561+ Uint64("webview_id", uint64(wv.id)).
2562+ Msg("cef: accessibility enabled")
2563+ }
2564+}
2565+
2544 func (wv *WebView) resetPageScrollQueue() {2566 func (wv *WebView) resetPageScrollQueue() {
2545 q := &wv.pageScrollQueue2567 q := &wv.pageScrollQueue
2546 q.mu.Lock()2568 q.mu.Lock()
M internal/infrastructure/config/loader.go +5 -17
@@ -175,6 +175,10 @@ func (m *Manager) transformLegacyConfig() {
175175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176176 }
177177
178+ // Merge newly added default actions into in-memory config so existing persisted
179+ // action maps keep working before the user writes a migrated config to disk.
180+ configMigrator.mergeMissingDefaultActions(rawConfig)
181+
178182 // Apply transformed config back to viper
179183 for key, value := range rawConfig {
180184 m.viper.Set(key, value)
@@ -374,7 +378,6 @@ func ensureDatabasePath(config *Config) error {
374378 }
375379
376380 func normalizeConfig(config *Config) {
377- normalizeExternalLinks(config)
378381 normalizeAppearance(config)
379382 normalizeMedia(config)
380383 normalizeEngineConfig(config)
@@ -387,20 +390,6 @@ func normalizeBrowsingContexts(config *Config) {
387390 config.Workspace.Popups = config.Workspace.BrowsingContexts
388391 }
389392
390-func normalizeExternalLinks(config *Config) {
391- switch config.Workspace.ExternalLinks.Behavior {
392- case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
393- default:
394- config.Workspace.ExternalLinks.Behavior = defaultExternalLinkBehavior
395- }
396-
397- switch config.Workspace.ExternalLinks.Placement {
398- case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
399- default:
400- config.Workspace.ExternalLinks.Placement = defaultExternalLinkPlacement
401- }
402-}
403-
404393 func normalizeEngineConfig(config *Config) {
405394 // Normalize GSK renderer (engine.webkit)
406395 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
@@ -709,6 +698,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
709698 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)
710699 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)
711700 m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)
701+ m.viper.SetDefault("workspace.vim_mode.preload_accessibility", defaults.Workspace.VimMode.PreloadAccessibility)
712702 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)
713703 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
714704 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
@@ -719,8 +709,6 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
719709 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
720710 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
721711 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
722- m.viper.SetDefault("workspace.external_links.behavior", string(defaults.Workspace.ExternalLinks.Behavior))
723- m.viper.SetDefault("workspace.external_links.placement", string(defaults.Workspace.ExternalLinks.Placement))
724712 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
725713 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
726714 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
@@ -175,6 +175,10 @@ func (m *Manager) transformLegacyConfig() {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }176 }
177 177
178+ // Merge newly added default actions into in-memory config so existing persisted
179+ // action maps keep working before the user writes a migrated config to disk.
180+ configMigrator.mergeMissingDefaultActions(rawConfig)
181+
178 // Apply transformed config back to viper182 // Apply transformed config back to viper
179 for key, value := range rawConfig {183 for key, value := range rawConfig {
180 m.viper.Set(key, value)184 m.viper.Set(key, value)
@@ -374,7 +378,6 @@ func ensureDatabasePath(config *Config) error {
374 }378 }
375 379
376 func normalizeConfig(config *Config) {380 func normalizeConfig(config *Config) {
377- normalizeExternalLinks(config)
378 normalizeAppearance(config)381 normalizeAppearance(config)
379 normalizeMedia(config)382 normalizeMedia(config)
380 normalizeEngineConfig(config)383 normalizeEngineConfig(config)
@@ -387,20 +390,6 @@ func normalizeBrowsingContexts(config *Config) {
387 config.Workspace.Popups = config.Workspace.BrowsingContexts390 config.Workspace.Popups = config.Workspace.BrowsingContexts
388 }391 }
389 392
390-func normalizeExternalLinks(config *Config) {
391- switch config.Workspace.ExternalLinks.Behavior {
392- case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
393- default:
394- config.Workspace.ExternalLinks.Behavior = defaultExternalLinkBehavior
395- }
396-
397- switch config.Workspace.ExternalLinks.Placement {
398- case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
399- default:
400- config.Workspace.ExternalLinks.Placement = defaultExternalLinkPlacement
401- }
402-}
403-
404 func normalizeEngineConfig(config *Config) {393 func normalizeEngineConfig(config *Config) {
405 // Normalize GSK renderer (engine.webkit)394 // Normalize GSK renderer (engine.webkit)
406 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {395 switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
@@ -709,6 +698,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
709 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)698 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)
710 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)699 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)
711 m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)700 m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)
701+ m.viper.SetDefault("workspace.vim_mode.preload_accessibility", defaults.Workspace.VimMode.PreloadAccessibility)
712 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)702 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)
713 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)703 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
714 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)704 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
@@ -719,8 +709,6 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
719 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)709 m.viper.SetDefault("workspace.floating_pane.width_pct", defaults.Workspace.FloatingPane.WidthPct)
720 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)710 m.viper.SetDefault("workspace.floating_pane.height_pct", defaults.Workspace.FloatingPane.HeightPct)
721 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)711 m.viper.SetDefault("workspace.floating_pane.profiles", defaults.Workspace.FloatingPane.Profiles)
722- m.viper.SetDefault("workspace.external_links.behavior", string(defaults.Workspace.ExternalLinks.Behavior))
723- m.viper.SetDefault("workspace.external_links.placement", string(defaults.Workspace.ExternalLinks.Placement))
724 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)712 m.viper.SetDefault("workspace.tab_bar_position", defaults.Workspace.TabBarPosition)
725 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)713 m.viper.SetDefault("workspace.hide_tab_bar_when_single_tab", defaults.Workspace.HideTabBarWhenSingleTab)
726 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)714 m.viper.SetDefault("workspace.switch_to_tab_on_move", defaults.Workspace.SwitchToTabOnMove)
M internal/infrastructure/config/schema_provider.go +8 -18
@@ -477,7 +477,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
477477 Key: "workspace.vim_mode.activation_shortcut",
478478 Type: "string",
479479 Default: defaults.Workspace.VimMode.ActivationShortcut,
480- Description: "Shortcut to enter vim scroll mode",
480+ Description: "Shortcut to enter page scroll mode",
481481 Section: SectionWorkspace,
482482 },
483483 {
@@ -496,6 +496,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
496496 Range: ">=0",
497497 Section: SectionWorkspace,
498498 },
499+ {
500+ Key: "workspace.vim_mode.preload_accessibility",
501+ Type: "bool",
502+ Default: fmt.Sprintf("%t", defaults.Workspace.VimMode.PreloadAccessibility),
503+ Description: "Pre-enable browser accessibility when a WebView is shown (default false; enable on Page Mode enter otherwise)",
504+ Section: SectionWorkspace,
505+ },
499506 {
500507 Key: "workspace.vim_mode.actions.<action>",
501508 Type: "object",
@@ -572,23 +579,6 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
572579 Description: "Named floating pane URL profiles with keys, url, and optional desc",
573580 Section: SectionWorkspace,
574581 },
575- // External links
576- {
577- Key: "workspace.external_links.behavior",
578- Type: "string",
579- Default: string(defaults.Workspace.ExternalLinks.Behavior),
580- Description: "How URLs opened from external applications are placed",
581- Values: []string{"windowed", "tabbed", "split", "stacked"},
582- Section: SectionWorkspace,
583- },
584- {
585- Key: "workspace.external_links.placement",
586- Type: "string",
587- Default: string(defaults.Workspace.ExternalLinks.Placement),
588- Description: "Placement direction for split external links",
589- Values: []string{"right", "left", "top", "bottom"},
590- Section: SectionWorkspace,
591- },
592582 // Browsing contexts
593583 {
594584 Key: "workspace.browsing_contexts.behavior",
@@ -477,7 +477,7 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
477 Key: "workspace.vim_mode.activation_shortcut",477 Key: "workspace.vim_mode.activation_shortcut",
478 Type: "string",478 Type: "string",
479 Default: defaults.Workspace.VimMode.ActivationShortcut,479 Default: defaults.Workspace.VimMode.ActivationShortcut,
480- Description: "Shortcut to enter vim scroll mode",480+ Description: "Shortcut to enter page scroll mode",
481 Section: SectionWorkspace,481 Section: SectionWorkspace,
482 },482 },
483 {483 {
@@ -496,6 +496,13 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
496 Range: ">=0",496 Range: ">=0",
497 Section: SectionWorkspace,497 Section: SectionWorkspace,
498 },498 },
499+ {
500+ Key: "workspace.vim_mode.preload_accessibility",
501+ Type: "bool",
502+ Default: fmt.Sprintf("%t", defaults.Workspace.VimMode.PreloadAccessibility),
503+ Description: "Pre-enable browser accessibility when a WebView is shown (default false; enable on Page Mode enter otherwise)",
504+ Section: SectionWorkspace,
505+ },
499 {506 {
500 Key: "workspace.vim_mode.actions.<action>",507 Key: "workspace.vim_mode.actions.<action>",
501 Type: "object",508 Type: "object",
@@ -572,23 +579,6 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
572 Description: "Named floating pane URL profiles with keys, url, and optional desc",579 Description: "Named floating pane URL profiles with keys, url, and optional desc",
573 Section: SectionWorkspace,580 Section: SectionWorkspace,
574 },581 },
575- // External links
576- {
577- Key: "workspace.external_links.behavior",
578- Type: "string",
579- Default: string(defaults.Workspace.ExternalLinks.Behavior),
580- Description: "How URLs opened from external applications are placed",
581- Values: []string{"windowed", "tabbed", "split", "stacked"},
582- Section: SectionWorkspace,
583- },
584- {
585- Key: "workspace.external_links.placement",
586- Type: "string",
587- Default: string(defaults.Workspace.ExternalLinks.Placement),
588- Description: "Placement direction for split external links",
589- Values: []string{"right", "left", "top", "bottom"},
590- Section: SectionWorkspace,
591- },
592 // Browsing contexts582 // Browsing contexts
593 {583 {
594 Key: "workspace.browsing_contexts.behavior",584 Key: "workspace.browsing_contexts.behavior",
M internal/infrastructure/config/schema_provider_test.go +2 -1
@@ -34,7 +34,8 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
3434 // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
3535 // product docs until a dedicated docs task lands them.
3636 var schemaKeysPendingProductDocs = map[string]struct{}{
37- "workspace.page_mode.sequence_timeout_ms": {},
37+ "workspace.page_mode.sequence_timeout_ms": {},
38+ "workspace.page_mode.preload_accessibility": {},
3839 }
3940
4041 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
@@ -34,7 +34,8 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
34 // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from34 // schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
35 // product docs until a dedicated docs task lands them.35 // product docs until a dedicated docs task lands them.
36 var schemaKeysPendingProductDocs = map[string]struct{}{36 var schemaKeysPendingProductDocs = map[string]struct{}{
37- "workspace.page_mode.sequence_timeout_ms": {},37+ "workspace.page_mode.sequence_timeout_ms": {},
38+ "workspace.page_mode.preload_accessibility": {},
38 }39 }
39 40
40 func configurationReferenceKeys(t *testing.T) map[string]struct{} {41 func configurationReferenceKeys(t *testing.T) map[string]struct{} {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close