fix(input): use bubble phase + IM fallback for dead keys, move accent detection to widget level
Dead-key compose (us intl layout) was broken because the window-level EventControllerKey in capture phase intercepted events before WebKit's GtkIMContext could handle them. Wayland compositors without text-input-v3 (e.g. niri) also need GTK_IM_MODULE=gtk-im-context-simple as fallback. Changes: - Switch window key controller from capture to bubble phase - Set GTK_IM_MODULE=gtk-im-context-simple at startup when unset - Move long-press accent detection to widget level: - Omnibox/find bar: local key controller callbacks - WebView: JS keydown/keyup script + message bridge handlers - Remove now-redundant dead-key bypass state (pendingDeadKey, isDeadKey) - Export KeyvalToRune for use by widget-level accent callbacks
- commit
- b05099ccc710f633a678cb82e64869cc6c900a09
- author
- brice · · 2026-03-11 05:46 UTC
- parent
- 476d2f2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +268 -49
M cmd/dumber/main.go +9 -0
| @@ -73,6 +73,15 @@ func main() { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | func runGUI() int { | |
| 76 | + | // Wayland compositors that lack text-input-v3 (e.g. niri) don't support | |
| 77 | + | // IBus/Fcitx input methods, causing dead-key compose to break. Fall back to | |
| 78 | + | // GTK's built-in simple IM context so dead keys work out of the box, but | |
| 79 | + | // only when the user hasn't explicitly chosen an IM module. | |
| 80 | + | if os.Getenv("GTK_IM_MODULE") == "" { | |
| 81 | + | fmt.Fprintf(os.Stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |
| 82 | + | _ = os.Setenv("GTK_IM_MODULE", "gtk-im-context-simple") | |
| 83 | + | } | |
| 84 | + | ||
| 76 | 85 | runtime.LockOSThread() | |
| 77 | 86 | component.SetSkeletonVersion(version) | |
| 78 | 87 | timer := bootstrap.NewStartupTimer() | |
| @@ -73,6 +73,15 @@ func main() { | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | func runGUI() int { | 75 | func runGUI() int { | ||
| 76 | + | // Wayland compositors that lack text-input-v3 (e.g. niri) don't support | |||
| 77 | + | // IBus/Fcitx input methods, causing dead-key compose to break. Fall back to | |||
| 78 | + | // GTK's built-in simple IM context so dead keys work out of the box, but | |||
| 79 | + | // only when the user hasn't explicitly chosen an IM module. | |||
| 80 | + | if os.Getenv("GTK_IM_MODULE") == "" { | |||
| 81 | + | fmt.Fprintf(os.Stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |||
| 82 | + | _ = os.Setenv("GTK_IM_MODULE", "gtk-im-context-simple") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 76 | runtime.LockOSThread() | 85 | runtime.LockOSThread() | ||
| 77 | component.SetSkeletonVersion(version) | 86 | component.SetSkeletonVersion(version) | ||
| 78 | timer := bootstrap.NewStartupTimer() | 87 | timer := bootstrap.NewStartupTimer() | ||
M internal/infrastructure/webkit/handlers/register.go +53 -0
| @@ -2,6 +2,7 @@ package handlers | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -9,8 +10,16 @@ import ( | |||
| 9 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 12 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | 14 | ) | |
| 13 | 15 | ||
| 16 | + | // AccentKeyHandler is implemented by the InsertAccentUseCase to receive | |
| 17 | + | // key press/release events forwarded from WebView JS via the message bridge. | |
| 18 | + | type AccentKeyHandler interface { | |
| 19 | + | OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool | |
| 20 | + | OnKeyReleased(ctx context.Context, char rune) | |
| 21 | + | } | |
| 22 | + | ||
| 14 | 23 | // Config holds all dependencies for message handlers. | |
| 15 | 24 | type Config struct { | |
| 16 | 25 | HistoryUC *usecase.SearchHistoryUseCase | |
| @@ -18,6 +27,7 @@ type Config struct { | |||
| 18 | 27 | Clipboard port.Clipboard | |
| 19 | 28 | ConfigGetter func() *config.Config | |
| 20 | 29 | OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification) | |
| 30 | + | AccentHandler AccentKeyHandler // Handles accent key press/release events from WebView inputs | |
| 21 | 31 | } | |
| 22 | 32 | ||
| 23 | 33 | // RegisterAll registers all message handlers with the router. | |
| @@ -61,6 +71,49 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 61 | 71 | return nil | |
| 62 | 72 | } | |
| 63 | 73 | ||
| 74 | + | // RegisterAccentHandlers registers accent key press/release handlers with the router. | |
| 75 | + | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | |
| 76 | + | func RegisterAccentHandlers(ctx context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | |
| 77 | + | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | |
| 78 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 79 | + | var p struct { | |
| 80 | + | Char string `json:"char"` | |
| 81 | + | Shift bool `json:"shift"` | |
| 82 | + | } | |
| 83 | + | if err := json.Unmarshal(payload, &p); err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | if len(p.Char) == 1 { | |
| 87 | + | handler.OnKeyPressed(ctx, rune(p.Char[0]), p.Shift) | |
| 88 | + | } | |
| 89 | + | return nil, nil | |
| 90 | + | }, | |
| 91 | + | )); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | if err := router.RegisterHandler("accent_key_release", webkit.MessageHandlerFunc( | |
| 96 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 97 | + | var p struct { | |
| 98 | + | Char string `json:"char"` | |
| 99 | + | } | |
| 100 | + | if err := json.Unmarshal(payload, &p); err != nil { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | if len(p.Char) == 1 { | |
| 104 | + | handler.OnKeyReleased(ctx, rune(p.Char[0])) | |
| 105 | + | } | |
| 106 | + | return nil, nil | |
| 107 | + | }, | |
| 108 | + | )); err != nil { | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | log := logging.FromContext(ctx) | |
| 113 | + | log.Info().Msg("registered accent key handlers") | |
| 114 | + | return nil | |
| 115 | + | } | |
| 116 | + | ||
| 64 | 117 | // createKeybindingsHandler wires the gateway and use cases for keybindings. | |
| 65 | 118 | func createKeybindingsHandler() (*KeybindingsHandler, error) { | |
| 66 | 119 | mgr := config.GetManager() | |
| @@ -2,6 +2,7 @@ package handlers | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -9,8 +10,16 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/infrastructure/config" | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 10 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 11 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 11 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | 12 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | ||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | ) | 14 | ) | ||
| 13 | 15 | ||||
| 16 | + | // AccentKeyHandler is implemented by the InsertAccentUseCase to receive | |||
| 17 | + | // key press/release events forwarded from WebView JS via the message bridge. | |||
| 18 | + | type AccentKeyHandler interface { | |||
| 19 | + | OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool | |||
| 20 | + | OnKeyReleased(ctx context.Context, char rune) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 14 | // Config holds all dependencies for message handlers. | 23 | // Config holds all dependencies for message handlers. | ||
| 15 | type Config struct { | 24 | type Config struct { | ||
| 16 | HistoryUC *usecase.SearchHistoryUseCase | 25 | HistoryUC *usecase.SearchHistoryUseCase | ||
| @@ -18,6 +27,7 @@ type Config struct { | |||||
| 18 | Clipboard port.Clipboard | 27 | Clipboard port.Clipboard | ||
| 19 | ConfigGetter func() *config.Config | 28 | ConfigGetter func() *config.Config | ||
| 20 | OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification) | 29 | OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification) | ||
| 30 | + | AccentHandler AccentKeyHandler // Handles accent key press/release events from WebView inputs | |||
| 21 | } | 31 | } | ||
| 22 | 32 | ||||
| 23 | // RegisterAll registers all message handlers with the router. | 33 | // RegisterAll registers all message handlers with the router. | ||
| @@ -61,6 +71,49 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 61 | return nil | 71 | return nil | ||
| 62 | } | 72 | } | ||
| 63 | 73 | ||||
| 74 | + | // RegisterAccentHandlers registers accent key press/release handlers with the router. | |||
| 75 | + | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | |||
| 76 | + | func RegisterAccentHandlers(ctx context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | |||
| 77 | + | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | |||
| 78 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 79 | + | var p struct { | |||
| 80 | + | Char string `json:"char"` | |||
| 81 | + | Shift bool `json:"shift"` | |||
| 82 | + | } | |||
| 83 | + | if err := json.Unmarshal(payload, &p); err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | if len(p.Char) == 1 { | |||
| 87 | + | handler.OnKeyPressed(ctx, rune(p.Char[0]), p.Shift) | |||
| 88 | + | } | |||
| 89 | + | return nil, nil | |||
| 90 | + | }, | |||
| 91 | + | )); err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if err := router.RegisterHandler("accent_key_release", webkit.MessageHandlerFunc( | |||
| 96 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 97 | + | var p struct { | |||
| 98 | + | Char string `json:"char"` | |||
| 99 | + | } | |||
| 100 | + | if err := json.Unmarshal(payload, &p); err != nil { | |||
| 101 | + | return nil, err | |||
| 102 | + | } | |||
| 103 | + | if len(p.Char) == 1 { | |||
| 104 | + | handler.OnKeyReleased(ctx, rune(p.Char[0])) | |||
| 105 | + | } | |||
| 106 | + | return nil, nil | |||
| 107 | + | }, | |||
| 108 | + | )); err != nil { | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | log := logging.FromContext(ctx) | |||
| 113 | + | log.Info().Msg("registered accent key handlers") | |||
| 114 | + | return nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 64 | // createKeybindingsHandler wires the gateway and use cases for keybindings. | 117 | // createKeybindingsHandler wires the gateway and use cases for keybindings. | ||
| 65 | func createKeybindingsHandler() (*KeybindingsHandler, error) { | 118 | func createKeybindingsHandler() (*KeybindingsHandler, error) { | ||
| 66 | mgr := config.GetManager() | 119 | mgr := config.GetManager() | ||
M internal/infrastructure/webkit/injector.go +52 -0
| @@ -141,6 +141,45 @@ const autoCopySelectionScript = `(function() { | |||
| 141 | 141 | }); | |
| 142 | 142 | })();` | |
| 143 | 143 | ||
| 144 | + | // accentDetectionScript detects keydown/keyup of a-z keys in WebView inputs and | |
| 145 | + | // forwards them to the Go-side InsertAccentUseCase via the message bridge. | |
| 146 | + | // Go handles timing, accent lookup, and picker display — JS only reports events. | |
| 147 | + | const accentDetectionScript = `(function() { | |
| 148 | + | 'use strict'; | |
| 149 | + | ||
| 150 | + | // Track pressed key for release matching | |
| 151 | + | let pressedKey = null; | |
| 152 | + | ||
| 153 | + | document.addEventListener('keydown', function(e) { | |
| 154 | + | // Only handle a-z keys, with optional Shift | |
| 155 | + | // Skip if Ctrl or Alt are held (those are shortcuts) | |
| 156 | + | if (e.ctrlKey || e.altKey || e.metaKey) return; | |
| 157 | + | ||
| 158 | + | // Only single character keys a-z | |
| 159 | + | const key = e.key.toLowerCase(); | |
| 160 | + | if (key.length !== 1 || key < 'a' || key > 'z') return; | |
| 161 | + | ||
| 162 | + | pressedKey = key; | |
| 163 | + | ||
| 164 | + | // Send to Go for accent detection | |
| 165 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 166 | + | type: 'accent_key_press', | |
| 167 | + | payload: { char: key, shift: e.shiftKey } | |
| 168 | + | }); | |
| 169 | + | }, true); // capture phase to see events first | |
| 170 | + | ||
| 171 | + | document.addEventListener('keyup', function(e) { | |
| 172 | + | const key = e.key.toLowerCase(); | |
| 173 | + | if (key === pressedKey) { | |
| 174 | + | pressedKey = null; | |
| 175 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 176 | + | type: 'accent_key_release', | |
| 177 | + | payload: { char: key } | |
| 178 | + | }); | |
| 179 | + | } | |
| 180 | + | }, true); | |
| 181 | + | })();` | |
| 182 | + | ||
| 144 | 183 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | |
| 145 | 184 | // Some pages gate support on window.RTCPeerConnection and report false negatives | |
| 146 | 185 | // when only webkit-prefixed constructors are present. | |
| @@ -333,5 +372,18 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 333 | 372 | log.Debug().Msg("auto-copy selection script injected") | |
| 334 | 373 | } | |
| 335 | 374 | ||
| 375 | + | // 7. Inject accent key detection for all pages (unconditional) | |
| 376 | + | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 377 | + | addScript( | |
| 378 | + | webkit.NewUserScript( | |
| 379 | + | accentDetectionScript, | |
| 380 | + | webkit.UserContentInjectTopFrameValue, | |
| 381 | + | webkit.UserScriptInjectAtDocumentEndValue, | |
| 382 | + | nil, // all pages | |
| 383 | + | nil, | |
| 384 | + | ), | |
| 385 | + | "accent-key-detection", | |
| 386 | + | ) | |
| 387 | + | ||
| 336 | 388 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | |
| 337 | 389 | } | |
| @@ -141,6 +141,45 @@ const autoCopySelectionScript = `(function() { | |||||
| 141 | }); | 141 | }); | ||
| 142 | })();` | 142 | })();` | ||
| 143 | 143 | ||||
| 144 | + | // accentDetectionScript detects keydown/keyup of a-z keys in WebView inputs and | |||
| 145 | + | // forwards them to the Go-side InsertAccentUseCase via the message bridge. | |||
| 146 | + | // Go handles timing, accent lookup, and picker display — JS only reports events. | |||
| 147 | + | const accentDetectionScript = `(function() { | |||
| 148 | + | 'use strict'; | |||
| 149 | + | ||||
| 150 | + | // Track pressed key for release matching | |||
| 151 | + | let pressedKey = null; | |||
| 152 | + | ||||
| 153 | + | document.addEventListener('keydown', function(e) { | |||
| 154 | + | // Only handle a-z keys, with optional Shift | |||
| 155 | + | // Skip if Ctrl or Alt are held (those are shortcuts) | |||
| 156 | + | if (e.ctrlKey || e.altKey || e.metaKey) return; | |||
| 157 | + | ||||
| 158 | + | // Only single character keys a-z | |||
| 159 | + | const key = e.key.toLowerCase(); | |||
| 160 | + | if (key.length !== 1 || key < 'a' || key > 'z') return; | |||
| 161 | + | ||||
| 162 | + | pressedKey = key; | |||
| 163 | + | ||||
| 164 | + | // Send to Go for accent detection | |||
| 165 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 166 | + | type: 'accent_key_press', | |||
| 167 | + | payload: { char: key, shift: e.shiftKey } | |||
| 168 | + | }); | |||
| 169 | + | }, true); // capture phase to see events first | |||
| 170 | + | ||||
| 171 | + | document.addEventListener('keyup', function(e) { | |||
| 172 | + | const key = e.key.toLowerCase(); | |||
| 173 | + | if (key === pressedKey) { | |||
| 174 | + | pressedKey = null; | |||
| 175 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 176 | + | type: 'accent_key_release', | |||
| 177 | + | payload: { char: key } | |||
| 178 | + | }); | |||
| 179 | + | } | |||
| 180 | + | }, true); | |||
| 181 | + | })();` | |||
| 182 | + | ||||
| 144 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | 183 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | ||
| 145 | // Some pages gate support on window.RTCPeerConnection and report false negatives | 184 | // Some pages gate support on window.RTCPeerConnection and report false negatives | ||
| 146 | // when only webkit-prefixed constructors are present. | 185 | // when only webkit-prefixed constructors are present. | ||
| @@ -333,5 +372,18 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 333 | log.Debug().Msg("auto-copy selection script injected") | 372 | log.Debug().Msg("auto-copy selection script injected") | ||
| 334 | } | 373 | } | ||
| 335 | 374 | ||||
| 375 | + | // 7. Inject accent key detection for all pages (unconditional) | |||
| 376 | + | // JS only reports keydown/keyup events; Go handles timing and picker display. | |||
| 377 | + | addScript( | |||
| 378 | + | webkit.NewUserScript( | |||
| 379 | + | accentDetectionScript, | |||
| 380 | + | webkit.UserContentInjectTopFrameValue, | |||
| 381 | + | webkit.UserScriptInjectAtDocumentEndValue, | |||
| 382 | + | nil, // all pages | |||
| 383 | + | nil, | |||
| 384 | + | ), | |||
| 385 | + | "accent-key-detection", | |||
| 386 | + | ) | |||
| 387 | + | ||||
| 336 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | 388 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | ||
| 337 | } | 389 | } | ||
M internal/ui/app.go +61 -2
| @@ -197,6 +197,7 @@ func New(deps *Dependencies) (*App, error) { | |||
| 197 | 197 | FavoritesUC: deps.FavoritesUC, | |
| 198 | 198 | Clipboard: deps.Clipboard, | |
| 199 | 199 | ConfigGetter: config.Get, | |
| 200 | + | // AccentHandler is registered after initAccentPicker in onActivate | |
| 200 | 201 | OnClipboardCopied: func(textLen int) { | |
| 201 | 202 | // Show brief toast on auto-copy (similar to zellij footer notification) | |
| 202 | 203 | // Must schedule on GTK main thread since this is called from WebKit handler | |
| @@ -291,6 +292,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 291 | 292 | a.installCrashReportNotifier(ctx) | |
| 292 | 293 | a.initFocusAndBorderOverlay() | |
| 293 | 294 | a.initAccentPicker(ctx) | |
| 295 | + | a.registerAccentHandlers(ctx) | |
| 294 | 296 | a.initDownloadHandler(ctx) | |
| 295 | 297 | ||
| 296 | 298 | a.initCoordinators(ctx) | |
| @@ -298,7 +300,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 298 | 300 | logging.Trace().Mark("coordinators_init") | |
| 299 | 301 | a.initKeyboardHandler(ctx) | |
| 300 | 302 | a.initOmniboxConfig(ctx) | |
| 301 | - | a.initFindBarConfig() | |
| 303 | + | a.initFindBarConfig(ctx) | |
| 302 | 304 | a.initSessionManager(ctx) | |
| 303 | 305 | a.initTabPicker(ctx) | |
| 304 | 306 | a.initSnapshotService(ctx) | |
| @@ -541,6 +543,21 @@ func (a *App) initAccentPicker(ctx context.Context) { | |||
| 541 | 543 | log.Debug().Msg("accent picker initialized") | |
| 542 | 544 | } | |
| 543 | 545 | ||
| 546 | + | // registerAccentHandlers registers the accent key press/release message handlers | |
| 547 | + | // with the router. Must be called after initAccentPicker so insertAccentUC is non-nil. | |
| 548 | + | func (a *App) registerAccentHandlers(ctx context.Context) { | |
| 549 | + | log := logging.FromContext(ctx) | |
| 550 | + | ||
| 551 | + | if a.router == nil || a.insertAccentUC == nil { | |
| 552 | + | log.Debug().Msg("router or insertAccentUC not available, skipping accent handler registration") | |
| 553 | + | return | |
| 554 | + | } | |
| 555 | + | ||
| 556 | + | if err := handlers.RegisterAccentHandlers(ctx, a.router, a.insertAccentUC); err != nil { | |
| 557 | + | log.Error().Err(err).Msg("failed to register accent handlers") | |
| 558 | + | } | |
| 559 | + | } | |
| 560 | + | ||
| 544 | 561 | func (a *App) initDownloadHandler(ctx context.Context) { | |
| 545 | 562 | log := logging.FromContext(ctx) | |
| 546 | 563 | ||
| @@ -737,12 +754,33 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 737 | 754 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 738 | 755 | } | |
| 739 | 756 | }, | |
| 757 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |
| 758 | + | if a.insertAccentUC == nil { | |
| 759 | + | return false | |
| 760 | + | } | |
| 761 | + | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |
| 762 | + | return false | |
| 763 | + | } | |
| 764 | + | shiftHeld := state&gdk.ShiftMaskValue != 0 | |
| 765 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 766 | + | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |
| 767 | + | } | |
| 768 | + | return false | |
| 769 | + | }, | |
| 770 | + | OnAccentKeyRelease: func(keyval uint) { | |
| 771 | + | if a.insertAccentUC == nil { | |
| 772 | + | return | |
| 773 | + | } | |
| 774 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 775 | + | a.insertAccentUC.OnKeyReleased(ctx, char) | |
| 776 | + | } | |
| 777 | + | }, | |
| 740 | 778 | } | |
| 741 | 779 | a.navCoord.SetOmniboxProvider(a) | |
| 742 | 780 | log.Debug().Msg("omnibox config stored, provider set") | |
| 743 | 781 | } | |
| 744 | 782 | ||
| 745 | - | func (a *App) initFindBarConfig() { | |
| 783 | + | func (a *App) initFindBarConfig(ctx context.Context) { | |
| 746 | 784 | // Store find bar config (find bar is created per-pane via WorkspaceView). | |
| 747 | 785 | a.findBarCfg = component.FindBarConfig{ | |
| 748 | 786 | GetFindController: func(paneID entity.PaneID) port.FindController { | |
| @@ -767,6 +805,27 @@ func (a *App) initFindBarConfig() { | |||
| 767 | 805 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 768 | 806 | } | |
| 769 | 807 | }, | |
| 808 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |
| 809 | + | if a.insertAccentUC == nil { | |
| 810 | + | return false | |
| 811 | + | } | |
| 812 | + | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |
| 813 | + | return false | |
| 814 | + | } | |
| 815 | + | shiftHeld := state&gdk.ShiftMaskValue != 0 | |
| 816 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 817 | + | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |
| 818 | + | } | |
| 819 | + | return false | |
| 820 | + | }, | |
| 821 | + | OnAccentKeyRelease: func(keyval uint) { | |
| 822 | + | if a.insertAccentUC == nil { | |
| 823 | + | return | |
| 824 | + | } | |
| 825 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 826 | + | a.insertAccentUC.OnKeyReleased(ctx, char) | |
| 827 | + | } | |
| 828 | + | }, | |
| 770 | 829 | } | |
| 771 | 830 | } | |
| 772 | 831 | ||
| @@ -197,6 +197,7 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 197 | FavoritesUC: deps.FavoritesUC, | 197 | FavoritesUC: deps.FavoritesUC, | ||
| 198 | Clipboard: deps.Clipboard, | 198 | Clipboard: deps.Clipboard, | ||
| 199 | ConfigGetter: config.Get, | 199 | ConfigGetter: config.Get, | ||
| 200 | + | // AccentHandler is registered after initAccentPicker in onActivate | |||
| 200 | OnClipboardCopied: func(textLen int) { | 201 | OnClipboardCopied: func(textLen int) { | ||
| 201 | // Show brief toast on auto-copy (similar to zellij footer notification) | 202 | // Show brief toast on auto-copy (similar to zellij footer notification) | ||
| 202 | // Must schedule on GTK main thread since this is called from WebKit handler | 203 | // Must schedule on GTK main thread since this is called from WebKit handler | ||
| @@ -291,6 +292,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 291 | a.installCrashReportNotifier(ctx) | 292 | a.installCrashReportNotifier(ctx) | ||
| 292 | a.initFocusAndBorderOverlay() | 293 | a.initFocusAndBorderOverlay() | ||
| 293 | a.initAccentPicker(ctx) | 294 | a.initAccentPicker(ctx) | ||
| 295 | + | a.registerAccentHandlers(ctx) | |||
| 294 | a.initDownloadHandler(ctx) | 296 | a.initDownloadHandler(ctx) | ||
| 295 | 297 | ||||
| 296 | a.initCoordinators(ctx) | 298 | a.initCoordinators(ctx) | ||
| @@ -298,7 +300,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 298 | logging.Trace().Mark("coordinators_init") | 300 | logging.Trace().Mark("coordinators_init") | ||
| 299 | a.initKeyboardHandler(ctx) | 301 | a.initKeyboardHandler(ctx) | ||
| 300 | a.initOmniboxConfig(ctx) | 302 | a.initOmniboxConfig(ctx) | ||
| 301 | - | a.initFindBarConfig() | 303 | + | a.initFindBarConfig(ctx) |
| 302 | a.initSessionManager(ctx) | 304 | a.initSessionManager(ctx) | ||
| 303 | a.initTabPicker(ctx) | 305 | a.initTabPicker(ctx) | ||
| 304 | a.initSnapshotService(ctx) | 306 | a.initSnapshotService(ctx) | ||
| @@ -541,6 +543,21 @@ func (a *App) initAccentPicker(ctx context.Context) { | |||||
| 541 | log.Debug().Msg("accent picker initialized") | 543 | log.Debug().Msg("accent picker initialized") | ||
| 542 | } | 544 | } | ||
| 543 | 545 | ||||
| 546 | + | // registerAccentHandlers registers the accent key press/release message handlers | |||
| 547 | + | // with the router. Must be called after initAccentPicker so insertAccentUC is non-nil. | |||
| 548 | + | func (a *App) registerAccentHandlers(ctx context.Context) { | |||
| 549 | + | log := logging.FromContext(ctx) | |||
| 550 | + | ||||
| 551 | + | if a.router == nil || a.insertAccentUC == nil { | |||
| 552 | + | log.Debug().Msg("router or insertAccentUC not available, skipping accent handler registration") | |||
| 553 | + | return | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | if err := handlers.RegisterAccentHandlers(ctx, a.router, a.insertAccentUC); err != nil { | |||
| 557 | + | log.Error().Err(err).Msg("failed to register accent handlers") | |||
| 558 | + | } | |||
| 559 | + | } | |||
| 560 | + | ||||
| 544 | func (a *App) initDownloadHandler(ctx context.Context) { | 561 | func (a *App) initDownloadHandler(ctx context.Context) { | ||
| 545 | log := logging.FromContext(ctx) | 562 | log := logging.FromContext(ctx) | ||
| 546 | 563 | ||||
| @@ -737,12 +754,33 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 737 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | 754 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | ||
| 738 | } | 755 | } | ||
| 739 | }, | 756 | }, | ||
| 757 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |||
| 758 | + | if a.insertAccentUC == nil { | |||
| 759 | + | return false | |||
| 760 | + | } | |||
| 761 | + | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |||
| 762 | + | return false | |||
| 763 | + | } | |||
| 764 | + | shiftHeld := state&gdk.ShiftMaskValue != 0 | |||
| 765 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |||
| 766 | + | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |||
| 767 | + | } | |||
| 768 | + | return false | |||
| 769 | + | }, | |||
| 770 | + | OnAccentKeyRelease: func(keyval uint) { | |||
| 771 | + | if a.insertAccentUC == nil { | |||
| 772 | + | return | |||
| 773 | + | } | |||
| 774 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |||
| 775 | + | a.insertAccentUC.OnKeyReleased(ctx, char) | |||
| 776 | + | } | |||
| 777 | + | }, | |||
| 740 | } | 778 | } | ||
| 741 | a.navCoord.SetOmniboxProvider(a) | 779 | a.navCoord.SetOmniboxProvider(a) | ||
| 742 | log.Debug().Msg("omnibox config stored, provider set") | 780 | log.Debug().Msg("omnibox config stored, provider set") | ||
| 743 | } | 781 | } | ||
| 744 | 782 | ||||
| 745 | - | func (a *App) initFindBarConfig() { | 783 | + | func (a *App) initFindBarConfig(ctx context.Context) { |
| 746 | // Store find bar config (find bar is created per-pane via WorkspaceView). | 784 | // Store find bar config (find bar is created per-pane via WorkspaceView). | ||
| 747 | a.findBarCfg = component.FindBarConfig{ | 785 | a.findBarCfg = component.FindBarConfig{ | ||
| 748 | GetFindController: func(paneID entity.PaneID) port.FindController { | 786 | GetFindController: func(paneID entity.PaneID) port.FindController { | ||
| @@ -767,6 +805,27 @@ func (a *App) initFindBarConfig() { | |||||
| 767 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | 805 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | ||
| 768 | } | 806 | } | ||
| 769 | }, | 807 | }, | ||
| 808 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |||
| 809 | + | if a.insertAccentUC == nil { | |||
| 810 | + | return false | |||
| 811 | + | } | |||
| 812 | + | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |||
| 813 | + | return false | |||
| 814 | + | } | |||
| 815 | + | shiftHeld := state&gdk.ShiftMaskValue != 0 | |||
| 816 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |||
| 817 | + | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |||
| 818 | + | } | |||
| 819 | + | return false | |||
| 820 | + | }, | |||
| 821 | + | OnAccentKeyRelease: func(keyval uint) { | |||
| 822 | + | if a.insertAccentUC == nil { | |||
| 823 | + | return | |||
| 824 | + | } | |||
| 825 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |||
| 826 | + | a.insertAccentUC.OnKeyReleased(ctx, char) | |||
| 827 | + | } | |||
| 828 | + | }, | |||
| 770 | } | 829 | } | ||
| 771 | } | 830 | } | ||
| 772 | 831 | ||||
M internal/ui/component/find_bar.go +32 -12
| @@ -39,19 +39,23 @@ type FindBar struct { | |||
| 39 | 39 | ||
| 40 | 40 | uc *usecase.FindInPageUseCase | |
| 41 | 41 | ||
| 42 | - | visible bool | |
| 43 | - | mu sync.RWMutex | |
| 44 | - | ctx context.Context | |
| 45 | - | onClose func() | |
| 46 | - | retainedCallbacks []interface{} // Keep callbacks alive for GC | |
| 42 | + | visible bool | |
| 43 | + | mu sync.RWMutex | |
| 44 | + | ctx context.Context | |
| 45 | + | onClose func() | |
| 46 | + | onAccentKeyPress func(keyval uint, state gdk.ModifierType) bool | |
| 47 | + | onAccentKeyRelease func(keyval uint) | |
| 48 | + | retainedCallbacks []interface{} // Keep callbacks alive for GC | |
| 47 | 49 | } | |
| 48 | 50 | ||
| 49 | 51 | // FindBarConfig holds configuration for creating a FindBar. | |
| 50 | 52 | type FindBarConfig struct { | |
| 51 | - | OnClose func() | |
| 52 | - | GetFindController func(paneID entity.PaneID) port.FindController | |
| 53 | - | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 54 | - | OnFocusOut func() // Callback when entry loses focus | |
| 53 | + | OnClose func() | |
| 54 | + | GetFindController func(paneID entity.PaneID) port.FindController | |
| 55 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 56 | + | OnFocusOut func() // Callback when entry loses focus | |
| 57 | + | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |
| 58 | + | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 57 | 61 | // NewFindBar creates a new FindBar component. | |
| @@ -59,9 +63,11 @@ func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar { | |||
| 59 | 63 | log := logging.FromContext(ctx) | |
| 60 | 64 | ||
| 61 | 65 | fb := &FindBar{ | |
| 62 | - | ctx: ctx, | |
| 63 | - | onClose: cfg.OnClose, | |
| 64 | - | uc: usecase.NewFindInPageUseCase(ctx), | |
| 66 | + | ctx: ctx, | |
| 67 | + | onClose: cfg.OnClose, | |
| 68 | + | onAccentKeyPress: cfg.OnAccentKeyPress, | |
| 69 | + | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |
| 70 | + | uc: usecase.NewFindInPageUseCase(ctx), | |
| 65 | 71 | } | |
| 66 | 72 | ||
| 67 | 73 | if err := fb.createWidgets(); err != nil { | |
| @@ -370,9 +376,23 @@ func (fb *FindBar) attachKeyController() { | |||
| 370 | 376 | } | |
| 371 | 377 | return true | |
| 372 | 378 | } | |
| 379 | + | // Try long-press accent detection for text keys | |
| 380 | + | if fb.onAccentKeyPress != nil { | |
| 381 | + | return fb.onAccentKeyPress(keyval, state) | |
| 382 | + | } | |
| 373 | 383 | return false | |
| 374 | 384 | } | |
| 385 | + | fb.retainedCallbacks = append(fb.retainedCallbacks, keyPressedCb) | |
| 375 | 386 | controller.ConnectKeyPressed(&keyPressedCb) | |
| 387 | + | ||
| 388 | + | keyReleasedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { | |
| 389 | + | if fb.onAccentKeyRelease != nil { | |
| 390 | + | fb.onAccentKeyRelease(keyval) | |
| 391 | + | } | |
| 392 | + | } | |
| 393 | + | fb.retainedCallbacks = append(fb.retainedCallbacks, keyReleasedCb) | |
| 394 | + | controller.ConnectKeyReleased(&keyReleasedCb) | |
| 395 | + | ||
| 376 | 396 | fb.outerBox.AddController(&controller.EventController) | |
| 377 | 397 | } | |
| 378 | 398 | ||
| @@ -39,19 +39,23 @@ type FindBar struct { | |||||
| 39 | 39 | ||||
| 40 | uc *usecase.FindInPageUseCase | 40 | uc *usecase.FindInPageUseCase | ||
| 41 | 41 | ||||
| 42 | - | visible bool | 42 | + | visible bool |
| 43 | - | mu sync.RWMutex | 43 | + | mu sync.RWMutex |
| 44 | - | ctx context.Context | 44 | + | ctx context.Context |
| 45 | - | onClose func() | 45 | + | onClose func() |
| 46 | - | retainedCallbacks []interface{} // Keep callbacks alive for GC | 46 | + | onAccentKeyPress func(keyval uint, state gdk.ModifierType) bool |
| 47 | + | onAccentKeyRelease func(keyval uint) | |||
| 48 | + | retainedCallbacks []interface{} // Keep callbacks alive for GC | |||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 49 | // FindBarConfig holds configuration for creating a FindBar. | 51 | // FindBarConfig holds configuration for creating a FindBar. | ||
| 50 | type FindBarConfig struct { | 52 | type FindBarConfig struct { | ||
| 51 | - | OnClose func() | 53 | + | OnClose func() |
| 52 | - | GetFindController func(paneID entity.PaneID) port.FindController | 54 | + | GetFindController func(paneID entity.PaneID) port.FindController |
| 53 | - | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | 55 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) |
| 54 | - | OnFocusOut func() // Callback when entry loses focus | 56 | + | OnFocusOut func() // Callback when entry loses focus |
| 57 | + | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |||
| 58 | + | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 57 | // NewFindBar creates a new FindBar component. | 61 | // NewFindBar creates a new FindBar component. | ||
| @@ -59,9 +63,11 @@ func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar { | |||||
| 59 | log := logging.FromContext(ctx) | 63 | log := logging.FromContext(ctx) | ||
| 60 | 64 | ||||
| 61 | fb := &FindBar{ | 65 | fb := &FindBar{ | ||
| 62 | - | ctx: ctx, | 66 | + | ctx: ctx, |
| 63 | - | onClose: cfg.OnClose, | 67 | + | onClose: cfg.OnClose, |
| 64 | - | uc: usecase.NewFindInPageUseCase(ctx), | 68 | + | onAccentKeyPress: cfg.OnAccentKeyPress, |
| 69 | + | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |||
| 70 | + | uc: usecase.NewFindInPageUseCase(ctx), | |||
| 65 | } | 71 | } | ||
| 66 | 72 | ||||
| 67 | if err := fb.createWidgets(); err != nil { | 73 | if err := fb.createWidgets(); err != nil { | ||
| @@ -370,9 +376,23 @@ func (fb *FindBar) attachKeyController() { | |||||
| 370 | } | 376 | } | ||
| 371 | return true | 377 | return true | ||
| 372 | } | 378 | } | ||
| 379 | + | // Try long-press accent detection for text keys | |||
| 380 | + | if fb.onAccentKeyPress != nil { | |||
| 381 | + | return fb.onAccentKeyPress(keyval, state) | |||
| 382 | + | } | |||
| 373 | return false | 383 | return false | ||
| 374 | } | 384 | } | ||
| 385 | + | fb.retainedCallbacks = append(fb.retainedCallbacks, keyPressedCb) | |||
| 375 | controller.ConnectKeyPressed(&keyPressedCb) | 386 | controller.ConnectKeyPressed(&keyPressedCb) | ||
| 387 | + | ||||
| 388 | + | keyReleasedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { | |||
| 389 | + | if fb.onAccentKeyRelease != nil { | |||
| 390 | + | fb.onAccentKeyRelease(keyval) | |||
| 391 | + | } | |||
| 392 | + | } | |||
| 393 | + | fb.retainedCallbacks = append(fb.retainedCallbacks, keyReleasedCb) | |||
| 394 | + | controller.ConnectKeyReleased(&keyReleasedCb) | |||
| 395 | + | ||||
| 376 | fb.outerBox.AddController(&controller.EventController) | 396 | fb.outerBox.AddController(&controller.EventController) | ||
| 377 | } | 397 | } | ||
| 378 | 398 | ||||
M internal/ui/component/omnibox.go +49 -28
| @@ -111,9 +111,11 @@ type Omnibox struct { | |||
| 111 | 111 | ctx context.Context | |
| 112 | 112 | ||
| 113 | 113 | // Callbacks | |
| 114 | - | onNavigate func(url string) | |
| 115 | - | onClose func() | |
| 116 | - | onToast func(ctx context.Context, message string, level ToastLevel) | |
| 114 | + | onNavigate func(url string) | |
| 115 | + | onClose func() | |
| 116 | + | onToast func(ctx context.Context, message string, level ToastLevel) | |
| 117 | + | onAccentKeyPress func(keyval uint, state gdk.ModifierType) bool | |
| 118 | + | onAccentKeyRelease func(keyval uint) | |
| 117 | 119 | ||
| 118 | 120 | // Debouncing | |
| 119 | 121 | debounceTimer *time.Timer | |
| @@ -144,18 +146,20 @@ type Omnibox struct { | |||
| 144 | 146 | ||
| 145 | 147 | // OmniboxConfig holds configuration for creating an Omnibox. | |
| 146 | 148 | type OmniboxConfig struct { | |
| 147 | - | HistoryUC *usecase.SearchHistoryUseCase | |
| 148 | - | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 149 | - | FaviconAdapter *adapter.FaviconAdapter | |
| 150 | - | CopyURLUC *usecase.CopyURLUseCase | |
| 151 | - | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 152 | - | DefaultSearch string | |
| 153 | - | InitialBehavior string | |
| 154 | - | UIScale float64 // UI scale for favicon sizing | |
| 155 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 156 | - | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 157 | - | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 158 | - | OnFocusOut func() // Callback when entry loses focus | |
| 149 | + | HistoryUC *usecase.SearchHistoryUseCase | |
| 150 | + | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 151 | + | FaviconAdapter *adapter.FaviconAdapter | |
| 152 | + | CopyURLUC *usecase.CopyURLUseCase | |
| 153 | + | ShortcutsUC *usecase.SearchShortcutsUseCase | |
| 154 | + | DefaultSearch string | |
| 155 | + | InitialBehavior string | |
| 156 | + | UIScale float64 // UI scale for favicon sizing | |
| 157 | + | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 158 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 159 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 160 | + | OnFocusOut func() // Callback when entry loses focus | |
| 161 | + | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |
| 162 | + | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |
| 159 | 163 | } | |
| 160 | 164 | ||
| 161 | 165 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -169,18 +173,20 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 169 | 173 | } | |
| 170 | 174 | ||
| 171 | 175 | o := &Omnibox{ | |
| 172 | - | viewMode: ViewModeHistory, | |
| 173 | - | selectedIndex: -1, | |
| 174 | - | historyUC: cfg.HistoryUC, | |
| 175 | - | favoritesUC: cfg.FavoritesUC, | |
| 176 | - | faviconAdapter: cfg.FaviconAdapter, | |
| 177 | - | copyURLUC: cfg.CopyURLUC, | |
| 178 | - | shortcutsUC: cfg.ShortcutsUC, | |
| 179 | - | defaultSearch: cfg.DefaultSearch, | |
| 180 | - | initialBehavior: cfg.InitialBehavior, | |
| 181 | - | onToast: cfg.OnToast, | |
| 182 | - | ctx: ctx, | |
| 183 | - | uiScale: uiScale, | |
| 176 | + | viewMode: ViewModeHistory, | |
| 177 | + | selectedIndex: -1, | |
| 178 | + | historyUC: cfg.HistoryUC, | |
| 179 | + | favoritesUC: cfg.FavoritesUC, | |
| 180 | + | faviconAdapter: cfg.FaviconAdapter, | |
| 181 | + | copyURLUC: cfg.CopyURLUC, | |
| 182 | + | shortcutsUC: cfg.ShortcutsUC, | |
| 183 | + | defaultSearch: cfg.DefaultSearch, | |
| 184 | + | initialBehavior: cfg.InitialBehavior, | |
| 185 | + | onToast: cfg.OnToast, | |
| 186 | + | onAccentKeyPress: cfg.OnAccentKeyPress, | |
| 187 | + | onAccentKeyRelease: cfg.OnAccentKeyRelease, | |
| 188 | + | ctx: ctx, | |
| 189 | + | uiScale: uiScale, | |
| 184 | 190 | } | |
| 185 | 191 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | |
| 186 | 192 | var cb glib.SourceFunc = func(uintptr) bool { | |
| @@ -427,11 +433,26 @@ func (o *Omnibox) setupKeyboardHandling() { | |||
| 427 | 433 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 428 | 434 | ||
| 429 | 435 | keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 430 | - | return o.handleKeyPress(keyval, keycode, state) | |
| 436 | + | if o.handleKeyPress(keyval, keycode, state) { | |
| 437 | + | return true | |
| 438 | + | } | |
| 439 | + | // Try long-press accent detection for text keys | |
| 440 | + | if o.onAccentKeyPress != nil { | |
| 441 | + | return o.onAccentKeyPress(keyval, state) | |
| 442 | + | } | |
| 443 | + | return false | |
| 431 | 444 | } | |
| 432 | 445 | o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb) | |
| 433 | 446 | controller.ConnectKeyPressed(&keyPressedCb) | |
| 434 | 447 | ||
| 448 | + | keyReleasedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { | |
| 449 | + | if o.onAccentKeyRelease != nil { | |
| 450 | + | o.onAccentKeyRelease(keyval) | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | o.retainedCallbacks = append(o.retainedCallbacks, keyReleasedCb) | |
| 454 | + | controller.ConnectKeyReleased(&keyReleasedCb) | |
| 455 | + | ||
| 435 | 456 | o.outerBox.AddController(&controller.EventController) | |
| 436 | 457 | } | |
| 437 | 458 | ||
| @@ -111,9 +111,11 @@ type Omnibox struct { | |||||
| 111 | ctx context.Context | 111 | ctx context.Context | ||
| 112 | 112 | ||||
| 113 | // Callbacks | 113 | // Callbacks | ||
| 114 | - | onNavigate func(url string) | 114 | + | onNavigate func(url string) |
| 115 | - | onClose func() | 115 | + | onClose func() |
| 116 | - | onToast func(ctx context.Context, message string, level ToastLevel) | 116 | + | onToast func(ctx context.Context, message string, level ToastLevel) |
| 117 | + | onAccentKeyPress func(keyval uint, state gdk.ModifierType) bool | |||
| 118 | + | onAccentKeyRelease func(keyval uint) | |||
| 117 | 119 | ||||
| 118 | // Debouncing | 120 | // Debouncing | ||
| 119 | debounceTimer *time.Timer | 121 | debounceTimer *time.Timer | ||
| @@ -144,18 +146,20 @@ type Omnibox struct { | |||||
| 144 | 146 | ||||
| 145 | // OmniboxConfig holds configuration for creating an Omnibox. | 147 | // OmniboxConfig holds configuration for creating an Omnibox. | ||
| 146 | type OmniboxConfig struct { | 148 | type OmniboxConfig struct { | ||
| 147 | - | HistoryUC *usecase.SearchHistoryUseCase | 149 | + | HistoryUC *usecase.SearchHistoryUseCase |
| 148 | - | FavoritesUC *usecase.ManageFavoritesUseCase | 150 | + | FavoritesUC *usecase.ManageFavoritesUseCase |
| 149 | - | FaviconAdapter *adapter.FaviconAdapter | 151 | + | FaviconAdapter *adapter.FaviconAdapter |
| 150 | - | CopyURLUC *usecase.CopyURLUseCase | 152 | + | CopyURLUC *usecase.CopyURLUseCase |
| 151 | - | ShortcutsUC *usecase.SearchShortcutsUseCase | 153 | + | ShortcutsUC *usecase.SearchShortcutsUseCase |
| 152 | - | DefaultSearch string | 154 | + | DefaultSearch string |
| 153 | - | InitialBehavior string | 155 | + | InitialBehavior string |
| 154 | - | UIScale float64 // UI scale for favicon sizing | 156 | + | UIScale float64 // UI scale for favicon sizing |
| 155 | - | OnNavigate func(url string) // Callback when user navigates via omnibox | 157 | + | OnNavigate func(url string) // Callback when user navigates via omnibox |
| 156 | - | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | 158 | + | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification |
| 157 | - | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | 159 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) |
| 158 | - | OnFocusOut func() // Callback when entry loses focus | 160 | + | OnFocusOut func() // Callback when entry loses focus |
| 161 | + | OnAccentKeyPress func(keyval uint, state gdk.ModifierType) bool // Long-press accent detection | |||
| 162 | + | OnAccentKeyRelease func(keyval uint) // Key release for accent cancel | |||
| 159 | } | 163 | } | ||
| 160 | 164 | ||||
| 161 | // NewOmnibox creates a new native GTK4 omnibox widget. | 165 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -169,18 +173,20 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 169 | } | 173 | } | ||
| 170 | 174 | ||||
| 171 | o := &Omnibox{ | 175 | o := &Omnibox{ | ||
| 172 | - | viewMode: ViewModeHistory, | 176 | + | viewMode: ViewModeHistory, |
| 173 | - | selectedIndex: -1, | 177 | + | selectedIndex: -1, |
| 174 | - | historyUC: cfg.HistoryUC, | 178 | + | historyUC: cfg.HistoryUC, |
| 175 | - | favoritesUC: cfg.FavoritesUC, | 179 | + | favoritesUC: cfg.FavoritesUC, |
| 176 | - | faviconAdapter: cfg.FaviconAdapter, | 180 | + | faviconAdapter: cfg.FaviconAdapter, |
| 177 | - | copyURLUC: cfg.CopyURLUC, | 181 | + | copyURLUC: cfg.CopyURLUC, |
| 178 | - | shortcutsUC: cfg.ShortcutsUC, | 182 | + | shortcutsUC: cfg.ShortcutsUC, |
| 179 | - | defaultSearch: cfg.DefaultSearch, | 183 | + | defaultSearch: cfg.DefaultSearch, |
| 180 | - | initialBehavior: cfg.InitialBehavior, | 184 | + | initialBehavior: cfg.InitialBehavior, |
| 181 | - | onToast: cfg.OnToast, | 185 | + | onToast: cfg.OnToast, |
| 182 | - | ctx: ctx, | 186 | + | onAccentKeyPress: cfg.OnAccentKeyPress, |
| 183 | - | uiScale: uiScale, | 187 | + | onAccentKeyRelease: cfg.OnAccentKeyRelease, |
| 188 | + | ctx: ctx, | |||
| 189 | + | uiScale: uiScale, | |||
| 184 | } | 190 | } | ||
| 185 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | 191 | o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) { | ||
| 186 | var cb glib.SourceFunc = func(uintptr) bool { | 192 | var cb glib.SourceFunc = func(uintptr) bool { | ||
| @@ -427,11 +433,26 @@ func (o *Omnibox) setupKeyboardHandling() { | |||||
| 427 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | 433 | controller.SetPropagationPhase(gtk.PhaseCaptureValue) | ||
| 428 | 434 | ||||
| 429 | keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 435 | keyPressedCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 430 | - | return o.handleKeyPress(keyval, keycode, state) | 436 | + | if o.handleKeyPress(keyval, keycode, state) { |
| 437 | + | return true | |||
| 438 | + | } | |||
| 439 | + | // Try long-press accent detection for text keys | |||
| 440 | + | if o.onAccentKeyPress != nil { | |||
| 441 | + | return o.onAccentKeyPress(keyval, state) | |||
| 442 | + | } | |||
| 443 | + | return false | |||
| 431 | } | 444 | } | ||
| 432 | o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb) | 445 | o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb) | ||
| 433 | controller.ConnectKeyPressed(&keyPressedCb) | 446 | controller.ConnectKeyPressed(&keyPressedCb) | ||
| 434 | 447 | ||||
| 448 | + | keyReleasedCb := func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { | |||
| 449 | + | if o.onAccentKeyRelease != nil { | |||
| 450 | + | o.onAccentKeyRelease(keyval) | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | o.retainedCallbacks = append(o.retainedCallbacks, keyReleasedCb) | |||
| 454 | + | controller.ConnectKeyReleased(&keyReleasedCb) | |||
| 455 | + | ||||
| 435 | o.outerBox.AddController(&controller.EventController) | 456 | o.outerBox.AddController(&controller.EventController) | ||
| 436 | } | 457 | } | ||
| 437 | 458 | ||||
M internal/ui/input/keyboard.go +12 -7
| @@ -152,7 +152,10 @@ func (h *KeyboardHandler) Mode() Mode { | |||
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | 154 | // AttachTo attaches the keyboard handler to a GTK window. | |
| 155 | - | // The handler will intercept key events in the capture phase. | |
| 155 | + | // The handler runs in the bubble phase so that WebKit's IM pipeline (including | |
| 156 | + | // dead-key / compose sequences) processes key events first. App-level shortcuts | |
| 157 | + | // are still intercepted because WebKit only consumes events it recognises as | |
| 158 | + | // text input; unhandled events bubble up to this controller. | |
| 156 | 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |
| 157 | 160 | log := logging.FromContext(h.ctx) | |
| 158 | 161 | ||
| @@ -167,8 +170,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 167 | 170 | return | |
| 168 | 171 | } | |
| 169 | 172 | ||
| 170 | - | // Set capture phase to intercept events before WebView gets them | |
| 171 | - | h.controller.SetPropagationPhase(gtk.PhaseCaptureValue) | |
| 173 | + | // Bubble phase: let WebKit IM handle key events first (dead keys, compose | |
| 174 | + | // sequences, input methods). Events not consumed by WebKit bubble up here | |
| 175 | + | // so app shortcuts still work. | |
| 176 | + | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | |
| 172 | 177 | ||
| 173 | 178 | // Connect key pressed handler (retain callback to prevent GC). | |
| 174 | 179 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | |
| @@ -277,7 +282,7 @@ func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval | |||
| 277 | 282 | if modifiers != 0 && modifiers != ModShift { | |
| 278 | 283 | return false | |
| 279 | 284 | } | |
| 280 | - | if char := keyvalToRune(keyval); char != 0 { | |
| 285 | + | if char := KeyvalToRune(keyval); char != 0 { | |
| 281 | 286 | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | |
| 282 | 287 | } | |
| 283 | 288 | return false | |
| @@ -448,14 +453,14 @@ func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |||
| 448 | 453 | } | |
| 449 | 454 | ||
| 450 | 455 | // Convert keyval to rune for the accent handler | |
| 451 | - | if char := keyvalToRune(keyval); char != 0 { | |
| 456 | + | if char := KeyvalToRune(keyval); char != 0 { | |
| 452 | 457 | accentHandler.OnKeyReleased(h.ctx, char) | |
| 453 | 458 | } | |
| 454 | 459 | } | |
| 455 | 460 | ||
| 456 | - | // keyvalToRune converts a GTK keyval to a lowercase rune. | |
| 461 | + | // KeyvalToRune converts a GTK keyval to a lowercase rune. | |
| 457 | 462 | // Returns 0 if the keyval is not a printable character with potential accents. | |
| 458 | - | func keyvalToRune(keyval uint) rune { | |
| 463 | + | func KeyvalToRune(keyval uint) rune { | |
| 459 | 464 | // Handle lowercase a-z | |
| 460 | 465 | if keyval >= uint('a') && keyval <= uint('z') { | |
| 461 | 466 | return rune(keyval) | |
| @@ -152,7 +152,10 @@ func (h *KeyboardHandler) Mode() Mode { | |||||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | // AttachTo attaches the keyboard handler to a GTK window. | 154 | // AttachTo attaches the keyboard handler to a GTK window. | ||
| 155 | - | // The handler will intercept key events in the capture phase. | 155 | + | // The handler runs in the bubble phase so that WebKit's IM pipeline (including |
| 156 | + | // dead-key / compose sequences) processes key events first. App-level shortcuts | |||
| 157 | + | // are still intercepted because WebKit only consumes events it recognises as | |||
| 158 | + | // text input; unhandled events bubble up to this controller. | |||
| 156 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | ||
| 157 | log := logging.FromContext(h.ctx) | 160 | log := logging.FromContext(h.ctx) | ||
| 158 | 161 | ||||
| @@ -167,8 +170,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 167 | return | 170 | return | ||
| 168 | } | 171 | } | ||
| 169 | 172 | ||||
| 170 | - | // Set capture phase to intercept events before WebView gets them | 173 | + | // Bubble phase: let WebKit IM handle key events first (dead keys, compose |
| 171 | - | h.controller.SetPropagationPhase(gtk.PhaseCaptureValue) | 174 | + | // sequences, input methods). Events not consumed by WebKit bubble up here |
| 175 | + | // so app shortcuts still work. | |||
| 176 | + | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | |||
| 172 | 177 | ||||
| 173 | // Connect key pressed handler (retain callback to prevent GC). | 178 | // Connect key pressed handler (retain callback to prevent GC). | ||
| 174 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | 179 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | ||
| @@ -277,7 +282,7 @@ func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval | |||||
| 277 | if modifiers != 0 && modifiers != ModShift { | 282 | if modifiers != 0 && modifiers != ModShift { | ||
| 278 | return false | 283 | return false | ||
| 279 | } | 284 | } | ||
| 280 | - | if char := keyvalToRune(keyval); char != 0 { | 285 | + | if char := KeyvalToRune(keyval); char != 0 { |
| 281 | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | 286 | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | ||
| 282 | } | 287 | } | ||
| 283 | return false | 288 | return false | ||
| @@ -448,14 +453,14 @@ func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |||||
| 448 | } | 453 | } | ||
| 449 | 454 | ||||
| 450 | // Convert keyval to rune for the accent handler | 455 | // Convert keyval to rune for the accent handler | ||
| 451 | - | if char := keyvalToRune(keyval); char != 0 { | 456 | + | if char := KeyvalToRune(keyval); char != 0 { |
| 452 | accentHandler.OnKeyReleased(h.ctx, char) | 457 | accentHandler.OnKeyReleased(h.ctx, char) | ||
| 453 | } | 458 | } | ||
| 454 | } | 459 | } | ||
| 455 | 460 | ||||
| 456 | - | // keyvalToRune converts a GTK keyval to a lowercase rune. | 461 | + | // KeyvalToRune converts a GTK keyval to a lowercase rune. |
| 457 | // Returns 0 if the keyval is not a printable character with potential accents. | 462 | // Returns 0 if the keyval is not a printable character with potential accents. | ||
| 458 | - | func keyvalToRune(keyval uint) rune { | 463 | + | func KeyvalToRune(keyval uint) rune { |
| 459 | // Handle lowercase a-z | 464 | // Handle lowercase a-z | ||
| 460 | if keyval >= uint('a') && keyval <= uint('z') { | 465 | if keyval >= uint('a') && keyval <= uint('z') { | ||
| 461 | return rune(keyval) | 466 | return rune(keyval) | ||