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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 12 | 12 | 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 | 21 | // FocusedInputProvider tracks which text input target currently has focus. | |
| 16 | 22 | // This allows the accent picker to insert text into the correct input field. | |
| 17 | 23 | type FocusedInputProvider interface { | |
| @@ -12,6 +12,12 @@ type TextInputTarget interface { | |||||
| 12 | DeleteBeforeCursor(ctx context.Context, n int) error | 12 | 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 | |||
| 249 | 249 | if err := target.InsertText(ctx, string(accent)); err != nil { | |
| 250 | 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 | 258 | // onPickerCanceled is called when the user cancels the picker (Escape). | |
| @@ -256,6 +260,7 @@ func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) { | |||
| 256 | 260 | log := logging.FromContext(ctx) | |
| 257 | 261 | ||
| 258 | 262 | uc.mu.Lock() | |
| 263 | + | target := uc.targetInput | |
| 259 | 264 | uc.pickerVisible = false | |
| 260 | 265 | uc.pressedChar = 0 | |
| 261 | 266 | uc.repeatCount = 0 | |
| @@ -264,6 +269,10 @@ func (uc *InsertAccentUseCase) onPickerCanceled(ctx context.Context) { | |||
| 264 | 269 | ||
| 265 | 270 | uc.accentPicker.Hide() | |
| 266 | 271 | ||
| 272 | + | if focusable, ok := target.(port.Focusable); ok { | |
| 273 | + | focusable.Focus(ctx) | |
| 274 | + | } | |
| 275 | + | ||
| 267 | 276 | log.Debug().Msg("accent picker canceled") | |
| 268 | 277 | } | |
| 269 | 278 | ||
| @@ -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 = false | 264 | uc.pickerVisible = false | ||
| 260 | uc.pressedChar = 0 | 265 | uc.pressedChar = 0 | ||
| 261 | uc.repeatCount = 0 | 266 | 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 ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 10 | 11 | "github.com/stretchr/testify/assert" | |
| 11 | 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 | 24 | func TestInsertAccentUseCase_OnKeyPressed_NoAccents(t *testing.T) { | |
| 15 | 25 | ctx := context.Background() | |
| 16 | 26 | focusProvider := mocks.NewMockFocusedInputProvider(t) | |
| @@ -217,3 +227,38 @@ func TestInsertAccentUseCase_PickerVisibleBlocksNewLongPress(t *testing.T) { | |||
| 217 | 227 | ||
| 218 | 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 | + | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "time" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 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 | 12 | // deleteBeforeCursorJS is the JavaScript template to delete n characters before cursor. | |
| 17 | 13 | // It handles both input/textarea elements and contenteditable elements. | |
| 18 | 14 | const deleteBeforeCursorJS = ` | |
| 19 | 15 | (function() { | |
| 20 | 16 | const n = %d; | |
| 21 | - | const el = document.activeElement; | |
| 17 | + | const el = window.__dumber_lastEditableEl || document.activeElement; | |
| 22 | 18 | if (!el) return; | |
| 23 | 19 | ||
| 24 | 20 | // Handle input and textarea elements | |
| @@ -38,7 +34,6 @@ const deleteBeforeCursorJS = ` | |||
| 38 | 34 | if (el.isContentEditable) { | |
| 39 | 35 | const sel = window.getSelection(); | |
| 40 | 36 | if (!sel.rangeCount) return; | |
| 41 | - | const range = sel.getRangeAt(0); | |
| 42 | 37 | for (let i = 0; i < n; i++) { | |
| 43 | 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. | |
| 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. | |
| 56 | 75 | type WebViewTarget struct { | |
| 57 | - | webView *webkit.WebView | |
| 58 | - | clipboard port.Clipboard | |
| 76 | + | webView *webkit.WebView | |
| 59 | 77 | } | |
| 60 | 78 | ||
| 61 | - | // Compile-time interface check. | |
| 62 | 79 | var _ port.TextInputTarget = (*WebViewTarget)(nil) | |
| 80 | + | var _ port.Focusable = (*WebViewTarget)(nil) | |
| 63 | 81 | ||
| 64 | 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 | 84 | return &WebViewTarget{ | |
| 67 | - | webView: webView, | |
| 68 | - | clipboard: clipboard, | |
| 85 | + | webView: webView, | |
| 69 | 86 | } | |
| 70 | 87 | } | |
| 71 | 88 | ||
| 72 | 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 | 90 | func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |
| 75 | 91 | log := logging.FromContext(ctx) | |
| 76 | 92 | ||
| @@ -79,37 +95,12 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||
| 79 | 95 | return nil | |
| 80 | 96 | } | |
| 81 | 97 | ||
| 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) | |
| 93 | 100 | ||
| 94 | 101 | log.Debug(). | |
| 95 | 102 | 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") | |
| 113 | 104 | ||
| 114 | 105 | return nil | |
| 115 | 106 | } | |
| @@ -135,3 +126,19 @@ func (t *WebViewTarget) DeleteBeforeCursor(ctx context.Context, n int) error { | |||
| 135 | 126 | log.Debug().Int("n", n).Msg("deleted characters before cursor in WebView") | |
| 136 | 127 | 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 | + | } | |
| @@ -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 elements | 20 | // 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 clipboard | 47 | + | // after GTK focus moves to the accent picker. |
| 53 | - | // 2. Write text to clipboard | 48 | + | const insertTextJS = ` |
| 54 | - | // 3. Execute paste command | 49 | + | (function() { |
| 55 | - | // 4. Restore original clipboard after a short delay | 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. | |||
| 56 | type WebViewTarget struct { | 75 | type WebViewTarget struct { | ||
| 57 | - | webView *webkit.WebView | 76 | + | 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 nil | 95 | return nil | ||
| 80 | } | 96 | } | ||
| 81 | 97 | ||||
| 82 | - | // Save current clipboard contents | 98 | + | 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 nil | 105 | 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 nil | 127 | 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() { | |||
| 193 | 193 | }); | |
| 194 | 194 | } | |
| 195 | 195 | }, true); | |
| 196 | + | ||
| 197 | + | document.addEventListener('focusin', function(e) { | |
| 198 | + | if (isEditableTarget(e.target)) { | |
| 199 | + | window.__dumber_lastEditableEl = e.target; | |
| 200 | + | } | |
| 201 | + | }, true); | |
| 196 | 202 | })();`, strings.Join(keys, ",")) | |
| 197 | 203 | } | |
| 198 | 204 | ||
| @@ -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) { | |||
| 17 | 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 | + | } | |
| @@ -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 { | |||
| 2116 | 2116 | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | |
| 2117 | 2117 | // Used by the accent picker to insert accented characters into web content. | |
| 2118 | 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 | 2120 | return nil | |
| 2121 | 2121 | } | |
| 2122 | 2122 | ||
| @@ -2126,7 +2126,7 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |||
| 2126 | 2126 | } | |
| 2127 | 2127 | ||
| 2128 | 2128 | // 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 | 2132 | // 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 nil | 2120 | 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 target | 2128 | // 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. | ||