detached ea0fc29

fix: WebView accent picker double-char and lost focus

Track last focused editable element via injected JS (focusin listener
sets window.__dumber_lastEditableEl) so delete/insert target the correct
element even after the accent picker steals GTK focus.

Replace clipboard-paste insertion with direct JS insertion into the
tracked element. Add Focus() method to WebViewTarget and call it from
InsertAccentUseCase after picker closes (selection or cancel).
commit
ea0fc29203aef2062c1f754eb868edef206fedaa
author
brice · · 2026-03-13 08:19 UTC
committer
brice · · 2026-03-13 08:36 UTC
parent
47070ab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +135 -49
M internal/application/port/text_input.go +6 -0
@@ -12,6 +12,12 @@ type TextInputTarget interface {
1212 DeleteBeforeCursor(ctx context.Context, n int) error
1313 }
1414
15+// Focusable is optionally implemented by text input targets that can restore
16+// focus after the accent picker closes.
17+type Focusable interface {
18+ Focus(ctx context.Context)
19+}
20+
1521 // FocusedInputProvider tracks which text input target currently has focus.
1622 // This allows the accent picker to insert text into the correct input field.
1723 type FocusedInputProvider interface {
@@ -12,6 +12,12 @@ type TextInputTarget interface {
12 DeleteBeforeCursor(ctx context.Context, n int) error12 DeleteBeforeCursor(ctx context.Context, n int) error
13 }13 }
14 14
15+// Focusable is optionally implemented by text input targets that can restore
16+// focus after the accent picker closes.
17+type Focusable interface {
18+ Focus(ctx context.Context)
19+}
20+
15 // FocusedInputProvider tracks which text input target currently has focus.21 // FocusedInputProvider tracks which text input target currently has focus.
16 // This allows the accent picker to insert text into the correct input field.22 // This allows the accent picker to insert text into the correct input field.
17 type FocusedInputProvider interface {23 type FocusedInputProvider interface {
M internal/application/usecase/insert_accent.go +9 -0
@@ -249,6 +249,10 @@ func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune
249249 if err := target.InsertText(ctx, string(accent)); err != nil {
250250 log.Error().Err(err).Msg("failed to insert accent")
251251 }
252+
253+ if focusable, ok := target.(port.Focusable); ok {
254+ focusable.Focus(ctx)
255+ }
252256 }
253257
254258 // onPickerCanceled is called when the user cancels the picker (Escape).
@@ -256,6 +260,7 @@ func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
256260 log := logging.FromContext(ctx)
257261
258262 uc.mu.Lock()
263+ target := uc.targetInput
259264 uc.pickerVisible = false
260265 uc.pressedChar = 0
261266 uc.repeatCount = 0
@@ -264,6 +269,10 @@ func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
264269
265270 uc.accentPicker.Hide()
266271
272+ if focusable, ok := target.(port.Focusable); ok {
273+ focusable.Focus(ctx)
274+ }
275+
267276 log.Debug().Msg("accent picker canceled")
268277 }
269278
@@ -249,6 +249,10 @@ func (uc *InsertAccentUseCase) onAccentSelected(ctx context.Context, accent rune
249 if err := target.InsertText(ctx, string(accent)); err != nil {249 if err := target.InsertText(ctx, string(accent)); err != nil {
250 log.Error().Err(err).Msg("failed to insert accent")250 log.Error().Err(err).Msg("failed to insert accent")
251 }251 }
252+
253+ if focusable, ok := target.(port.Focusable); ok {
254+ focusable.Focus(ctx)
255+ }
252 }256 }
253 257
254 // onPickerCanceled is called when the user cancels the picker (Escape).258 // onPickerCanceled is called when the user cancels the picker (Escape).
@@ -256,6 +260,7 @@ func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
256 log := logging.FromContext(ctx)260 log := logging.FromContext(ctx)
257 261
258 uc.mu.Lock()262 uc.mu.Lock()
263+ target := uc.targetInput
259 uc.pickerVisible = false264 uc.pickerVisible = false
260 uc.pressedChar = 0265 uc.pressedChar = 0
261 uc.repeatCount = 0266 uc.repeatCount = 0
@@ -264,6 +269,10 @@ func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) {
264 269
265 uc.accentPicker.Hide()270 uc.accentPicker.Hide()
266 271
272+ if focusable, ok := target.(port.Focusable); ok {
273+ focusable.Focus(ctx)
274+ }
275+
267 log.Debug().Msg("accent picker canceled")276 log.Debug().Msg("accent picker canceled")
268 }277 }
269 278
M internal/application/usecase/insert_accent_test.go +45 -0
@@ -6,11 +6,21 @@ import (
66 "testing"
77 "time"
88
9+ "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/application/port/mocks"
1011 "github.com/stretchr/testify/assert"
1112 "github.com/stretchr/testify/mock"
1213 )
1314
15+type focusableTextInputTarget struct {
16+ port.TextInputTarget
17+ focusCalls int
18+}
19+
20+func (t *focusableTextInputTarget) Focus(_ context.Context) {
21+ t.focusCalls++
22+}
23+
1424 func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
1525 ctx := context.Background()
1626 focusProvider := mocks.NewMockFocusedInputProvider(t)
@@ -217,3 +227,38 @@ func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
217227
218228 uc.Cancel(ctx)
219229 }
230+
231+func TestInsertAccentUseCase_OnAccentSelected_RestoresFocusForFocusableTarget(t *testing.T) {
232+ ctx := context.Background()
233+ focusProvider := mocks.NewMockFocusedInputProvider(t)
234+ accentPicker := mocks.NewMockAccentPickerUI(t)
235+ textInput := mocks.NewMockTextInputTarget(t)
236+ target := &focusableTextInputTarget{TextInputTarget: textInput}
237+
238+ focusProvider.EXPECT().GetFocusedInput().Return(target).Once()
239+ textInput.EXPECT().InsertText(ctx, "à").Return(nil).Once()
240+ accentPicker.EXPECT().Hide().Once()
241+
242+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
243+ uc.OnKeyPressed(ctx, 'a', false)
244+ uc.onAccentSelected(ctx, 'à')
245+
246+ assert.Equal(t, 1, target.focusCalls, "focus should be restored after accent insertion")
247+}
248+
249+func TestInsertAccentUseCase_OnPickerCanceled_RestoresFocusForFocusableTarget(t *testing.T) {
250+ ctx := context.Background()
251+ focusProvider := mocks.NewMockFocusedInputProvider(t)
252+ accentPicker := mocks.NewMockAccentPickerUI(t)
253+ textInput := mocks.NewMockTextInputTarget(t)
254+ target := &focusableTextInputTarget{TextInputTarget: textInput}
255+
256+ focusProvider.EXPECT().GetFocusedInput().Return(target).Once()
257+ accentPicker.EXPECT().Hide().Once()
258+
259+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
260+ uc.OnKeyPressed(ctx, 'a', false)
261+ uc.onPickerCanceled(ctx)
262+
263+ assert.Equal(t, 1, target.focusCalls, "focus should be restored after picker cancel")
264+}
@@ -6,11 +6,21 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/port/mocks"10 "github.com/bnema/dumber/internal/application/port/mocks"
10 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"12 "github.com/stretchr/testify/mock"
12 )13 )
13 14
15+type focusableTextInputTarget struct {
16+ port.TextInputTarget
17+ focusCalls int
18+}
19+
20+func (t *focusableTextInputTarget) Focus(_ context.Context) {
21+ t.focusCalls++
22+}
23+
14 func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {24 func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) {
15 ctx := context.Background()25 ctx := context.Background()
16 focusProvider := mocks.NewMockFocusedInputProvider(t)26 focusProvider := mocks.NewMockFocusedInputProvider(t)
@@ -217,3 +227,38 @@ func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) {
217 227
218 uc.Cancel(ctx)228 uc.Cancel(ctx)
219 }229 }
230+
231+func TestInsertAccentUseCase_OnAccentSelected_RestoresFocusForFocusableTarget(t *testing.T) {
232+ ctx := context.Background()
233+ focusProvider := mocks.NewMockFocusedInputProvider(t)
234+ accentPicker := mocks.NewMockAccentPickerUI(t)
235+ textInput := mocks.NewMockTextInputTarget(t)
236+ target := &focusableTextInputTarget{TextInputTarget: textInput}
237+
238+ focusProvider.EXPECT().GetFocusedInput().Return(target).Once()
239+ textInput.EXPECT().InsertText(ctx, "à").Return(nil).Once()
240+ accentPicker.EXPECT().Hide().Once()
241+
242+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
243+ uc.OnKeyPressed(ctx, 'a', false)
244+ uc.onAccentSelected(ctx, 'à')
245+
246+ assert.Equal(t, 1, target.focusCalls, "focus should be restored after accent insertion")
247+}
248+
249+func TestInsertAccentUseCase_OnPickerCanceled_RestoresFocusForFocusableTarget(t *testing.T) {
250+ ctx := context.Background()
251+ focusProvider := mocks.NewMockFocusedInputProvider(t)
252+ accentPicker := mocks.NewMockAccentPickerUI(t)
253+ textInput := mocks.NewMockTextInputTarget(t)
254+ target := &focusableTextInputTarget{TextInputTarget: textInput}
255+
256+ focusProvider.EXPECT().GetFocusedInput().Return(target).Once()
257+ accentPicker.EXPECT().Hide().Once()
258+
259+ uc := NewInsertAccentUseCase(focusProvider, accentPicker, func(fn func()) { fn() })
260+ uc.OnKeyPressed(ctx, 'a', false)
261+ uc.onPickerCanceled(ctx)
262+
263+ assert.Equal(t, 1, target.focusCalls, "focus should be restored after picker cancel")
264+}
M internal/infrastructure/textinput/webview_target.go +54 -47
@@ -3,22 +3,18 @@ package textinput
33 import (
44 "context"
55 "fmt"
6- "time"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/logging"
109 "github.com/bnema/puregotk-webkit/webkit"
1110 )
1211
13-// clipboardRestoreDelay is the delay before restoring the clipboard after paste.
14-const clipboardRestoreDelay = 50 * time.Millisecond
15-
1612 // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
1713 // It handles both input/textarea elements and contenteditable elements.
1814 const deleteBeforeCursorJS = `
1915 (function() {
2016 const n = %d;
21- const el = document.activeElement;
17+ const el = window.__dumber_lastEditableEl || document.activeElement;
2218 if (!el) return;
2319
2420 // Handle input and textarea elements
@@ -38,7 +34,6 @@ const deleteBeforeCursorJS = `
3834 if (el.isContentEditable) {
3935 const sel = window.getSelection();
4036 if (!sel.rangeCount) return;
41- const range = sel.getRangeAt(0);
4237 for (let i = 0; i < n; i++) {
4338 sel.modify('extend', 'backward', 'character');
4439 }
@@ -47,30 +42,51 @@ const deleteBeforeCursorJS = `
4742 })();
4843 `
4944
50-// WebViewTarget implements TextInputTarget for WebView text fields.
51-// It uses the clipboard-paste pattern to insert text:
52-// 1. Save current clipboard
53-// 2. Write text to clipboard
54-// 3. Execute paste command
55-// 4. Restore original clipboard after a short delay
45+// insertTextJS inserts text at the cursor position of the last focused editable element.
46+// Uses the tracked element from the accent detection script so insertion still works
47+// after GTK focus moves to the accent picker.
48+const insertTextJS = `
49+(function() {
50+ const text = %q;
51+ const el = window.__dumber_lastEditableEl || document.activeElement;
52+ if (!el) return;
53+
54+ el.focus();
55+
56+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
57+ const start = el.selectionStart;
58+ const end = el.selectionEnd;
59+ el.value = el.value.slice(0, start) + text + el.value.slice(end);
60+ el.selectionStart = el.selectionEnd = start + text.length;
61+ el.dispatchEvent(new Event('input', { bubbles: true }));
62+ return;
63+ }
64+
65+ // execCommand is deprecated but remains the most reliable way to insert
66+ // text into contenteditable elements across WebKit/GTK. The modern
67+ // InputEvent('insertText') alternative has inconsistent support.
68+ if (el.isContentEditable) {
69+ document.execCommand('insertText', false, text);
70+ }
71+})();
72+`
73+
74+// WebViewTarget implements text input and focus restoration for WebView fields.
5675 type WebViewTarget struct {
57- webView *webkit.WebView
58- clipboard port.Clipboard
76+ webView *webkit.WebView
5977 }
6078
61-// Compile-time interface check.
6279 var _ port.TextInputTarget = (*WebViewTarget)(nil)
80+var _ port.Focusable = (*WebViewTarget)(nil)
6381
6482 // NewWebViewTarget creates a new WebView target.
65-func NewWebViewTarget(webView *webkit.WebView, clipboard port.Clipboard) *WebViewTarget {
83+func NewWebViewTarget(webView *webkit.WebView) *WebViewTarget {
6684 return &WebViewTarget{
67- webView: webView,
68- clipboard: clipboard,
85+ webView: webView,
6986 }
7087 }
7188
7289 // InsertText inserts text at the current cursor position in the WebView.
73-// Uses clipboard-paste pattern since WebView doesn't expose direct text insertion.
7490 func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
7591 log := logging.FromContext(ctx)
7692
@@ -79,37 +95,12 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
7995 return nil
8096 }
8197
82- // Save current clipboard contents
83- originalClipboard, _ := t.clipboard.ReadText(ctx)
84-
85- // Write the accent to clipboard
86- if err := t.clipboard.WriteText(ctx, text); err != nil {
87- log.Error().Err(err).Msg("failed to write text to clipboard")
88- return err
89- }
90-
91- // Execute paste command
92- t.webView.ExecuteEditingCommand(webkit.EDITING_COMMAND_PASTE)
98+ script := fmt.Sprintf(insertTextJS, text)
99+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
93100
94101 log.Debug().
95102 Str("text", text).
96- Msg("inserted text into WebView via clipboard paste")
97-
98- // Restore original clipboard after a short delay.
99- // This allows the paste operation to complete first.
100- // Derive a detached context so the restore runs even if ctx is canceled,
101- // but still carries its values/logger.
102- detachedCtx := context.WithoutCancel(ctx)
103- go func() {
104- time.Sleep(clipboardRestoreDelay)
105- restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay)
106- defer cancelRestore()
107- if originalClipboard != "" {
108- if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil {
109- log.Debug().Err(err).Msg("failed to restore clipboard")
110- }
111- }
112- }()
103+ Msg("inserted text into WebView via JS")
113104
114105 return nil
115106 }
@@ -135,3 +126,19 @@ func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
135126 log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
136127 return nil
137128 }
129+
130+// Focus restores GTK focus to the WebView widget and refocuses the last
131+// editable element inside the page.
132+func (t *WebViewTarget) Focus(_ context.Context) {
133+ if t.webView == nil {
134+ return
135+ }
136+
137+ t.webView.GrabFocus()
138+ t.webView.EvaluateJavascript(`
139+ (function() {
140+ var el = window.__dumber_lastEditableEl;
141+ if (el) el.focus();
142+ })();
143+ `, -1, nil, nil, nil, nil, 0)
144+}
@@ -3,22 +3,18 @@ package textinput
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "time"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk-webkit/webkit"9 "github.com/bnema/puregotk-webkit/webkit"
11 )10 )
12 11
13-// clipboardRestoreDelay is the delay before restoring the clipboard after paste.
14-const clipboardRestoreDelay = 50 * time.Millisecond
15-
16 // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.12 // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor.
17 // It handles both input/textarea elements and contenteditable elements.13 // It handles both input/textarea elements and contenteditable elements.
18 const deleteBeforeCursorJS = `14 const deleteBeforeCursorJS = `
19 (function() {15 (function() {
20 const n = %d;16 const n = %d;
21- const el = document.activeElement;17+ const el = window.__dumber_lastEditableEl || document.activeElement;
22 if (!el) return;18 if (!el) return;
23 19
24 // Handle input and textarea elements20 // Handle input and textarea elements
@@ -38,7 +34,6 @@ const deleteBeforeCursorJS = `
38 if (el.isContentEditable) {34 if (el.isContentEditable) {
39 const sel = window.getSelection();35 const sel = window.getSelection();
40 if (!sel.rangeCount) return;36 if (!sel.rangeCount) return;
41- const range = sel.getRangeAt(0);
42 for (let i = 0; i < n; i++) {37 for (let i = 0; i < n; i++) {
43 sel.modify('extend', 'backward', 'character');38 sel.modify('extend', 'backward', 'character');
44 }39 }
@@ -47,30 +42,51 @@ const deleteBeforeCursorJS = `
47 })();42 })();
48 `43 `
49 44
50-// WebViewTarget implements TextInputTarget for WebView text fields.45+// insertTextJS inserts text at the cursor position of the last focused editable element.
51-// It uses the clipboard-paste pattern to insert text:46+// Uses the tracked element from the accent detection script so insertion still works
52-// 1. Save current clipboard47+// after GTK focus moves to the accent picker.
53-// 2. Write text to clipboard48+const insertTextJS = `
54-// 3. Execute paste command49+(function() {
55-// 4. Restore original clipboard after a short delay50+ const text = %q;
51+ const el = window.__dumber_lastEditableEl || document.activeElement;
52+ if (!el) return;
53+
54+ el.focus();
55+
56+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
57+ const start = el.selectionStart;
58+ const end = el.selectionEnd;
59+ el.value = el.value.slice(0, start) + text + el.value.slice(end);
60+ el.selectionStart = el.selectionEnd = start + text.length;
61+ el.dispatchEvent(new Event('input', { bubbles: true }));
62+ return;
63+ }
64+
65+ // execCommand is deprecated but remains the most reliable way to insert
66+ // text into contenteditable elements across WebKit/GTK. The modern
67+ // InputEvent('insertText') alternative has inconsistent support.
68+ if (el.isContentEditable) {
69+ document.execCommand('insertText', false, text);
70+ }
71+})();
72+`
73+
74+// WebViewTarget implements text input and focus restoration for WebView fields.
56 type WebViewTarget struct {75 type WebViewTarget struct {
57- webView *webkit.WebView76+ webView *webkit.WebView
58- clipboard port.Clipboard
59 }77 }
60 78
61-// Compile-time interface check.
62 var _ port.TextInputTarget = (*WebViewTarget)(nil)79 var _ port.TextInputTarget = (*WebViewTarget)(nil)
80+var _ port.Focusable = (*WebViewTarget)(nil)
63 81
64 // NewWebViewTarget creates a new WebView target.82 // NewWebViewTarget creates a new WebView target.
65-func NewWebViewTarget(webView *webkit.WebView, clipboard port.Clipboard) *WebViewTarget {83+func NewWebViewTarget(webView *webkit.WebView) *WebViewTarget {
66 return &WebViewTarget{84 return &WebViewTarget{
67- webView: webView,85+ webView: webView,
68- clipboard: clipboard,
69 }86 }
70 }87 }
71 88
72 // InsertText inserts text at the current cursor position in the WebView.89 // InsertText inserts text at the current cursor position in the WebView.
73-// Uses clipboard-paste pattern since WebView doesn't expose direct text insertion.
74 func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {90 func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
75 log := logging.FromContext(ctx)91 log := logging.FromContext(ctx)
76 92
@@ -79,37 +95,12 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error {
79 return nil95 return nil
80 }96 }
81 97
82- // Save current clipboard contents98+ script := fmt.Sprintf(insertTextJS, text)
83- originalClipboard, _ := t.clipboard.ReadText(ctx)99+ t.webView.EvaluateJavascript(script, -1, nil, nil, nil, nil, 0)
84-
85- // Write the accent to clipboard
86- if err := t.clipboard.WriteText(ctx, text); err != nil {
87- log.Error().Err(err).Msg("failed to write text to clipboard")
88- return err
89- }
90-
91- // Execute paste command
92- t.webView.ExecuteEditingCommand(webkit.EDITING_COMMAND_PASTE)
93 100
94 log.Debug().101 log.Debug().
95 Str("text", text).102 Str("text", text).
96- Msg("inserted text into WebView via clipboard paste")103+ Msg("inserted text into WebView via JS")
97-
98- // Restore original clipboard after a short delay.
99- // This allows the paste operation to complete first.
100- // Derive a detached context so the restore runs even if ctx is canceled,
101- // but still carries its values/logger.
102- detachedCtx := context.WithoutCancel(ctx)
103- go func() {
104- time.Sleep(clipboardRestoreDelay)
105- restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay)
106- defer cancelRestore()
107- if originalClipboard != "" {
108- if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil {
109- log.Debug().Err(err).Msg("failed to restore clipboard")
110- }
111- }
112- }()
113 104
114 return nil105 return nil
115 }106 }
@@ -135,3 +126,19 @@ func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error {
135 log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")126 log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView")
136 return nil127 return nil
137 }128 }
129+
130+// Focus restores GTK focus to the WebView widget and refocuses the last
131+// editable element inside the page.
132+func (t *WebViewTarget) Focus(_ context.Context) {
133+ if t.webView == nil {
134+ return
135+ }
136+
137+ t.webView.GrabFocus()
138+ t.webView.EvaluateJavascript(`
139+ (function() {
140+ var el = window.__dumber_lastEditableEl;
141+ if (el) el.focus();
142+ })();
143+ `, -1, nil, nil, nil, nil, 0)
144+}
M internal/infrastructure/webkit/injector.go +6 -0
@@ -193,6 +193,12 @@ func init() {
193193 });
194194 }
195195 }, true);
196+
197+ document.addEventListener('focusin', function(e) {
198+ if (isEditableTarget(e.target)) {
199+ window.__dumber_lastEditableEl = e.target;
200+ }
201+ }, true);
196202 })();`, strings.Join(keys, ","))
197203 }
198204
@@ -193,6 +193,12 @@ func init() {
193 });193 });
194 }194 }
195 }, true);195 }, true);
196+
197+ document.addEventListener('focusin', function(e) {
198+ if (isEditableTarget(e.target)) {
199+ window.__dumber_lastEditableEl = e.target;
200+ }
201+ }, true);
196 })();`, strings.Join(keys, ","))202 })();`, strings.Join(keys, ","))
197 }203 }
198 204
M internal/infrastructure/webkit/injector_accent_test.go +13 -0
@@ -17,3 +17,16 @@ func TestAccentDetectionScriptInjectionMode(t *testing.T) {
1717 "accent detection script must inject into all frames, not just the top frame",
1818 )
1919 }
20+
21+func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
22+ assert.Contains(t,
23+ accentDetectionScript,
24+ "window.__dumber_lastEditableEl = e.target",
25+ "accent detection script must track the last focused editable element",
26+ )
27+ assert.Contains(t,
28+ accentDetectionScript,
29+ "document.addEventListener('focusin'",
30+ "accent detection script must register a focusin listener",
31+ )
32+}
@@ -17,3 +17,16 @@ func TestAccentDetectionScriptInjectionMode(t *testing.T) {
17 "accent detection script must inject into all frames, not just the top frame",17 "accent detection script must inject into all frames, not just the top frame",
18 )18 )
19 }19 }
20+
21+func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
22+ assert.Contains(t,
23+ accentDetectionScript,
24+ "window.__dumber_lastEditableEl = e.target",
25+ "accent detection script must track the last focused editable element",
26+ )
27+ assert.Contains(t,
28+ accentDetectionScript,
29+ "document.addEventListener('focusin'",
30+ "accent detection script must register a focusin listener",
31+ )
32+}
M internal/ui/app.go +2 -2
@@ -2116,7 +2116,7 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
21162116 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
21172117 // Used by the accent picker to insert accented characters into web content.
21182118 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2119- if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {
2119+ if a.contentCoord == nil {
21202120 return nil
21212121 }
21222122
@@ -2126,7 +2126,7 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget {
21262126 }
21272127
21282128 // Get the underlying webkit.WebView for the text input target
2129- return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)
2129+ return textinput.NewWebViewTarget(wv.Widget())
21302130 }
21312131
21322132 // attachPopupToTab attaches a popup WebView to a newly created tab.
@@ -2116,7 +2116,7 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
2116 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.2116 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
2117 // Used by the accent picker to insert accented characters into web content.2117 // Used by the accent picker to insert accented characters into web content.
2118 func (a *App) getActiveWebViewTarget() port.TextInputTarget {2118 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2119- if a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil {2119+ if a.contentCoord == nil {
2120 return nil2120 return nil
2121 }2121 }
2122 2122
@@ -2126,7 +2126,7 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2126 }2126 }
2127 2127
2128 // Get the underlying webkit.WebView for the text input target2128 // Get the underlying webkit.WebView for the text input target
2129- return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard)2129+ return textinput.NewWebViewTarget(wv.Widget())
2130 }2130 }
2131 2131
2132 // attachPopupToTab attaches a popup WebView to a newly created tab.2132 // attachPopupToTab attaches a popup WebView to a newly created tab.

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