Merge pull request #187 from bnema/refactor/keyboard-routing-dead-keys
refactor(input): unified keyboard routing with native dead key support
24 files · +1019 -120
M .golangci.yml +8 -0
| @@ -149,6 +149,14 @@ linters: | |||
| 149 | 149 | - text: "G304:" | |
| 150 | 150 | linters: | |
| 151 | 151 | - gosec | |
| 152 | + | - path: internal/bootstrap/session\.go | |
| 153 | + | text: "G703:" | |
| 154 | + | linters: | |
| 155 | + | - gosec | |
| 156 | + | - path: internal/infrastructure/updater/github\.go | |
| 157 | + | text: "G704:" | |
| 158 | + | linters: | |
| 159 | + | - gosec | |
| 152 | 160 | # GTK callbacks require unused parameters | |
| 153 | 161 | - path: internal/ui/ | |
| 154 | 162 | text: "unused-parameter" | |
| @@ -149,6 +149,14 @@ linters: | |||||
| 149 | - text: "G304:" | 149 | - text: "G304:" | ||
| 150 | linters: | 150 | linters: | ||
| 151 | - gosec | 151 | - gosec | ||
| 152 | + | - path: internal/bootstrap/session\.go | |||
| 153 | + | text: "G703:" | |||
| 154 | + | linters: | |||
| 155 | + | - gosec | |||
| 156 | + | - path: internal/infrastructure/updater/github\.go | |||
| 157 | + | text: "G704:" | |||
| 158 | + | linters: | |||
| 159 | + | - gosec | |||
| 152 | # GTK callbacks require unused parameters | 160 | # GTK callbacks require unused parameters | ||
| 153 | - path: internal/ui/ | 161 | - path: internal/ui/ | ||
| 154 | text: "unused-parameter" | 162 | text: "unused-parameter" | ||
M Makefile +1 -4
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | |
| 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -71,9 +71,6 @@ test: ## Run tests | |||
| 71 | 71 | @echo "Running tests..." | |
| 72 | 72 | go test -v ./... | |
| 73 | 73 | ||
| 74 | - | check-docs: ## Verify README/docs match code | |
| 75 | - | @go run ./dev/check-docs.go --fail-on-error | |
| 76 | - | ||
| 77 | 74 | test-race: ## Run tests with race detection | |
| 78 | 75 | @echo "Running tests with race detection..." | |
| 79 | 76 | go test -race -v ./... | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego | 3 | + | .PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -71,9 +71,6 @@ test: ## Run tests | |||||
| 71 | @echo "Running tests..." | 71 | @echo "Running tests..." | ||
| 72 | go test -v ./... | 72 | go test -v ./... | ||
| 73 | 73 | ||||
| 74 | - | check-docs: ## Verify README/docs match code | |||
| 75 | - | @go run ./dev/check-docs.go --fail-on-error | |||
| 76 | - | ||||
| 77 | test-race: ## Run tests with race detection | 74 | test-race: ## Run tests with race detection | ||
| 78 | @echo "Running tests with race detection..." | 75 | @echo "Running tests with race detection..." | ||
| 79 | go test -race -v ./... | 76 | go test -race -v ./... | ||
M cmd/dumber/main.go +2 -0
| @@ -73,6 +73,8 @@ func main() { | |||
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | 75 | func runGUI() int { | |
| 76 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 77 | + | ||
| 76 | 78 | runtime.LockOSThread() | |
| 77 | 79 | component.SetSkeletonVersion(version) | |
| 78 | 80 | timer := bootstrap.NewStartupTimer() | |
| @@ -73,6 +73,8 @@ func main() { | |||||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | func runGUI() int { | 75 | func runGUI() int { | ||
| 76 | + | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |||
| 77 | + | ||||
| 76 | runtime.LockOSThread() | 78 | runtime.LockOSThread() | ||
| 77 | component.SetSkeletonVersion(version) | 79 | component.SetSkeletonVersion(version) | ||
| 78 | timer := bootstrap.NewStartupTimer() | 80 | timer := bootstrap.NewStartupTimer() | ||
M internal/application/usecase/insert_accent.go +4 -1
| @@ -53,8 +53,11 @@ func NewInsertAccentUseCase( | |||
| 53 | 53 | ||
| 54 | 54 | // OnKeyPressed handles a key press event. | |
| 55 | 55 | // Returns true if the use case is handling this key and the caller should suppress it. | |
| 56 | - | // Note: Due to WebKit's input handling, key suppression may not be fully effective. | |
| 56 | + | // Note: Key suppression may not be fully effective for all widget types. | |
| 57 | 57 | // When the picker shows, we delete any repeated characters that slipped through. | |
| 58 | + | // The accent picker is available for both GTK Entry widgets and WebView inputs | |
| 59 | + | // as an accessibility feature: users with motor disabilities or without a | |
| 60 | + | // dead-key keyboard layout can long-press to access accented characters. | |
| 58 | 61 | func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool { | |
| 59 | 62 | log := logging.FromContext(ctx) | |
| 60 | 63 | ||
| @@ -53,8 +53,11 @@ func NewInsertAccentUseCase( | |||||
| 53 | 53 | ||||
| 54 | // OnKeyPressed handles a key press event. | 54 | // OnKeyPressed handles a key press event. | ||
| 55 | // Returns true if the use case is handling this key and the caller should suppress it. | 55 | // Returns true if the use case is handling this key and the caller should suppress it. | ||
| 56 | - | // Note: Due to WebKit's input handling, key suppression may not be fully effective. | 56 | + | // Note: Key suppression may not be fully effective for all widget types. |
| 57 | // When the picker shows, we delete any repeated characters that slipped through. | 57 | // When the picker shows, we delete any repeated characters that slipped through. | ||
| 58 | + | // The accent picker is available for both GTK Entry widgets and WebView inputs | |||
| 59 | + | // as an accessibility feature: users with motor disabilities or without a | |||
| 60 | + | // dead-key keyboard layout can long-press to access accented characters. | |||
| 58 | func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool { | 61 | func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool { | ||
| 59 | log := logging.FromContext(ctx) | 62 | log := logging.FromContext(ctx) | ||
| 60 | 63 | ||||
A internal/bootstrap/runtime_env.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // ApplyGTKIMModuleFallback sets GTK_IM_MODULE to "gtk-im-context-simple" when: | |
| 10 | + | // - GTK_IM_MODULE is unset, AND | |
| 11 | + | // - the session appears to be Wayland (XDG_SESSION_TYPE == "wayland" OR WAYLAND_DISPLAY != ""). | |
| 12 | + | // | |
| 13 | + | // This ensures dead-key compose works on Wayland compositors that lack text-input-v3 | |
| 14 | + | // (e.g. niri), without overriding an explicit user choice. | |
| 15 | + | // | |
| 16 | + | // The getter and setter parameters allow injection for testing; pass os.Getenv and | |
| 17 | + | // os.Setenv for production use. | |
| 18 | + | func ApplyGTKIMModuleFallback(stderr io.Writer, getter func(string) string, setter func(string, string) error) { | |
| 19 | + | if getter("GTK_IM_MODULE") != "" { | |
| 20 | + | return | |
| 21 | + | } | |
| 22 | + | isWayland := getter("XDG_SESSION_TYPE") == "wayland" || getter("WAYLAND_DISPLAY") != "" | |
| 23 | + | if !isWayland { | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | _, _ = fmt.Fprintf(stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |
| 27 | + | if err := setter("GTK_IM_MODULE", "gtk-im-context-simple"); err != nil { | |
| 28 | + | _, _ = fmt.Fprintf(stderr, "dumber: failed to set GTK_IM_MODULE: %v\n", err) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // ApplyGTKIMModuleFallbackDefault calls ApplyGTKIMModuleFallback with os.Getenv and os.Setenv. | |
| 33 | + | func ApplyGTKIMModuleFallbackDefault(stderr io.Writer) { | |
| 34 | + | ApplyGTKIMModuleFallback(stderr, os.Getenv, os.Setenv) | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ApplyGTKIMModuleFallback sets GTK_IM_MODULE to "gtk-im-context-simple" when: | |||
| 10 | + | // - GTK_IM_MODULE is unset, AND | |||
| 11 | + | // - the session appears to be Wayland (XDG_SESSION_TYPE == "wayland" OR WAYLAND_DISPLAY != ""). | |||
| 12 | + | // | |||
| 13 | + | // This ensures dead-key compose works on Wayland compositors that lack text-input-v3 | |||
| 14 | + | // (e.g. niri), without overriding an explicit user choice. | |||
| 15 | + | // | |||
| 16 | + | // The getter and setter parameters allow injection for testing; pass os.Getenv and | |||
| 17 | + | // os.Setenv for production use. | |||
| 18 | + | func ApplyGTKIMModuleFallback(stderr io.Writer, getter func(string) string, setter func(string, string) error) { | |||
| 19 | + | if getter("GTK_IM_MODULE") != "" { | |||
| 20 | + | return | |||
| 21 | + | } | |||
| 22 | + | isWayland := getter("XDG_SESSION_TYPE") == "wayland" || getter("WAYLAND_DISPLAY") != "" | |||
| 23 | + | if !isWayland { | |||
| 24 | + | return | |||
| 25 | + | } | |||
| 26 | + | _, _ = fmt.Fprintf(stderr, "dumber: GTK_IM_MODULE unset, defaulting to gtk-im-context-simple for dead-key support\n") | |||
| 27 | + | if err := setter("GTK_IM_MODULE", "gtk-im-context-simple"); err != nil { | |||
| 28 | + | _, _ = fmt.Fprintf(stderr, "dumber: failed to set GTK_IM_MODULE: %v\n", err) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // ApplyGTKIMModuleFallbackDefault calls ApplyGTKIMModuleFallback with os.Getenv and os.Setenv. | |||
| 33 | + | func ApplyGTKIMModuleFallbackDefault(stderr io.Writer) { | |||
| 34 | + | ApplyGTKIMModuleFallback(stderr, os.Getenv, os.Setenv) | |||
| 35 | + | } | |||
A internal/bootstrap/runtime_env_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package bootstrap | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestApplyGTKIMModuleFallback(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | gtkIMModule string | |
| 14 | + | xdgSessionType string | |
| 15 | + | waylandDisplay string | |
| 16 | + | wantEnvSet bool | |
| 17 | + | wantMessageSent bool | |
| 18 | + | }{ | |
| 19 | + | { | |
| 20 | + | name: "GTK_IM_MODULE already set => no override, no message", | |
| 21 | + | gtkIMModule: "ibus", | |
| 22 | + | xdgSessionType: "wayland", | |
| 23 | + | waylandDisplay: "", | |
| 24 | + | wantEnvSet: false, | |
| 25 | + | wantMessageSent: false, | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "non-Wayland with GTK_IM_MODULE unset => no override, no message", | |
| 29 | + | gtkIMModule: "", | |
| 30 | + | xdgSessionType: "x11", | |
| 31 | + | waylandDisplay: "", | |
| 32 | + | wantEnvSet: false, | |
| 33 | + | wantMessageSent: false, | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: "Wayland via XDG_SESSION_TYPE with GTK_IM_MODULE unset => override + message", | |
| 37 | + | gtkIMModule: "", | |
| 38 | + | xdgSessionType: "wayland", | |
| 39 | + | waylandDisplay: "", | |
| 40 | + | wantEnvSet: true, | |
| 41 | + | wantMessageSent: true, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "Wayland via WAYLAND_DISPLAY with GTK_IM_MODULE unset => override + message", | |
| 45 | + | gtkIMModule: "", | |
| 46 | + | xdgSessionType: "", | |
| 47 | + | waylandDisplay: "wayland-0", | |
| 48 | + | wantEnvSet: true, | |
| 49 | + | wantMessageSent: true, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for _, tt := range tests { | |
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |
| 55 | + | var stderr bytes.Buffer | |
| 56 | + | env := map[string]string{ | |
| 57 | + | "GTK_IM_MODULE": tt.gtkIMModule, | |
| 58 | + | "XDG_SESSION_TYPE": tt.xdgSessionType, | |
| 59 | + | "WAYLAND_DISPLAY": tt.waylandDisplay, | |
| 60 | + | } | |
| 61 | + | getter := func(key string) string { return env[key] } | |
| 62 | + | var setKey, setValue string | |
| 63 | + | setter := func(key, val string) error { | |
| 64 | + | setKey = key | |
| 65 | + | setValue = val | |
| 66 | + | return nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | ApplyGTKIMModuleFallback(&stderr, getter, setter) | |
| 70 | + | ||
| 71 | + | msg := stderr.String() | |
| 72 | + | if tt.wantMessageSent { | |
| 73 | + | assert.Contains(t, msg, "GTK_IM_MODULE unset", "expected stderr message") | |
| 74 | + | assert.Equal(t, "GTK_IM_MODULE", setKey) | |
| 75 | + | assert.Equal(t, "gtk-im-context-simple", setValue) | |
| 76 | + | } else { | |
| 77 | + | assert.Empty(t, msg, "expected no stderr message") | |
| 78 | + | assert.Empty(t, setKey, "expected no env var set") | |
| 79 | + | } | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package bootstrap | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestApplyGTKIMModuleFallback(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | gtkIMModule string | |||
| 14 | + | xdgSessionType string | |||
| 15 | + | waylandDisplay string | |||
| 16 | + | wantEnvSet bool | |||
| 17 | + | wantMessageSent bool | |||
| 18 | + | }{ | |||
| 19 | + | { | |||
| 20 | + | name: "GTK_IM_MODULE already set => no override, no message", | |||
| 21 | + | gtkIMModule: "ibus", | |||
| 22 | + | xdgSessionType: "wayland", | |||
| 23 | + | waylandDisplay: "", | |||
| 24 | + | wantEnvSet: false, | |||
| 25 | + | wantMessageSent: false, | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "non-Wayland with GTK_IM_MODULE unset => no override, no message", | |||
| 29 | + | gtkIMModule: "", | |||
| 30 | + | xdgSessionType: "x11", | |||
| 31 | + | waylandDisplay: "", | |||
| 32 | + | wantEnvSet: false, | |||
| 33 | + | wantMessageSent: false, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "Wayland via XDG_SESSION_TYPE with GTK_IM_MODULE unset => override + message", | |||
| 37 | + | gtkIMModule: "", | |||
| 38 | + | xdgSessionType: "wayland", | |||
| 39 | + | waylandDisplay: "", | |||
| 40 | + | wantEnvSet: true, | |||
| 41 | + | wantMessageSent: true, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "Wayland via WAYLAND_DISPLAY with GTK_IM_MODULE unset => override + message", | |||
| 45 | + | gtkIMModule: "", | |||
| 46 | + | xdgSessionType: "", | |||
| 47 | + | waylandDisplay: "wayland-0", | |||
| 48 | + | wantEnvSet: true, | |||
| 49 | + | wantMessageSent: true, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for _, tt := range tests { | |||
| 54 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 55 | + | var stderr bytes.Buffer | |||
| 56 | + | env := map[string]string{ | |||
| 57 | + | "GTK_IM_MODULE": tt.gtkIMModule, | |||
| 58 | + | "XDG_SESSION_TYPE": tt.xdgSessionType, | |||
| 59 | + | "WAYLAND_DISPLAY": tt.waylandDisplay, | |||
| 60 | + | } | |||
| 61 | + | getter := func(key string) string { return env[key] } | |||
| 62 | + | var setKey, setValue string | |||
| 63 | + | setter := func(key, val string) error { | |||
| 64 | + | setKey = key | |||
| 65 | + | setValue = val | |||
| 66 | + | return nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | ApplyGTKIMModuleFallback(&stderr, getter, setter) | |||
| 70 | + | ||||
| 71 | + | msg := stderr.String() | |||
| 72 | + | if tt.wantMessageSent { | |||
| 73 | + | assert.Contains(t, msg, "GTK_IM_MODULE unset", "expected stderr message") | |||
| 74 | + | assert.Equal(t, "GTK_IM_MODULE", setKey) | |||
| 75 | + | assert.Equal(t, "gtk-im-context-simple", setValue) | |||
| 76 | + | } else { | |||
| 77 | + | assert.Empty(t, msg, "expected no stderr message") | |||
| 78 | + | assert.Empty(t, setKey, "expected no env var set") | |||
| 79 | + | } | |||
| 80 | + | }) | |||
| 81 | + | } | |||
| 82 | + | } | |||
M internal/bootstrap/session.go +5 -8
| @@ -571,7 +571,8 @@ func markerValue(raw []byte, key string) string { | |||
| 571 | 571 | ||
| 572 | 572 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | |
| 573 | 573 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | |
| 574 | - | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | |
| 574 | + | // Uses context.WithoutCancel(startupCtx) so the background work inherits request | |
| 575 | + | // values and the logger from the startup context while detaching from its cancellation. | |
| 575 | 576 | func runSessionCleanupAsync( | |
| 576 | 577 | startupCtx context.Context, | |
| 577 | 578 | sessionUC *usecase.ManageSessionUseCase, | |
| @@ -580,14 +581,10 @@ func runSessionCleanupAsync( | |||
| 580 | 581 | lockDir string, | |
| 581 | 582 | log *zerolog.Logger, | |
| 582 | 583 | ) { | |
| 583 | - | // Silence unused parameter warning - startupCtx is intentionally unused. | |
| 584 | - | _ = startupCtx | |
| 585 | - | ||
| 586 | 584 | go func() { | |
| 587 | - | // Use a detached background context instead of the startup context: | |
| 588 | - | // session cleanup is critical and must run to completion even if the | |
| 589 | - | // startup context is canceled or times out. | |
| 590 | - | bgCtx := context.Background() | |
| 585 | + | // Detach cancellation from the startup context so cleanup runs to completion | |
| 586 | + | // even if the startup context is canceled, while still carrying its values/logger. | |
| 587 | + | bgCtx := context.WithoutCancel(startupCtx) | |
| 591 | 588 | ||
| 592 | 589 | // End stale active sessions (orphaned from crashed processes) | |
| 593 | 590 | if lockDir != "" { | |
| @@ -571,7 +571,8 @@ func markerValue(raw []byte, key string) string { | |||||
| 571 | 571 | ||||
| 572 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | 572 | // runSessionCleanupAsync performs stale session cleanup and old session pruning | ||
| 573 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | 573 | // in a background goroutine. This avoids blocking startup for non-critical tasks. | ||
| 574 | - | // Uses context.Background() to ensure cleanup completes even if startup context is canceled. | 574 | + | // Uses context.WithoutCancel(startupCtx) so the background work inherits request |
| 575 | + | // values and the logger from the startup context while detaching from its cancellation. | |||
| 575 | func runSessionCleanupAsync( | 576 | func runSessionCleanupAsync( | ||
| 576 | startupCtx context.Context, | 577 | startupCtx context.Context, | ||
| 577 | sessionUC *usecase.ManageSessionUseCase, | 578 | sessionUC *usecase.ManageSessionUseCase, | ||
| @@ -580,14 +581,10 @@ func runSessionCleanupAsync( | |||||
| 580 | lockDir string, | 581 | lockDir string, | ||
| 581 | log *zerolog.Logger, | 582 | log *zerolog.Logger, | ||
| 582 | ) { | 583 | ) { | ||
| 583 | - | // Silence unused parameter warning - startupCtx is intentionally unused. | |||
| 584 | - | _ = startupCtx | |||
| 585 | - | ||||
| 586 | go func() { | 584 | go func() { | ||
| 587 | - | // Use a detached background context instead of the startup context: | 585 | + | // Detach cancellation from the startup context so cleanup runs to completion |
| 588 | - | // session cleanup is critical and must run to completion even if the | 586 | + | // even if the startup context is canceled, while still carrying its values/logger. |
| 589 | - | // startup context is canceled or times out. | 587 | + | bgCtx := context.WithoutCancel(startupCtx) |
| 590 | - | bgCtx := context.Background() | |||
| 591 | 588 | ||||
| 592 | // End stale active sessions (orphaned from crashed processes) | 589 | // End stale active sessions (orphaned from crashed processes) | ||
| 593 | if lockDir != "" { | 590 | if lockDir != "" { | ||
M internal/cli/model/sessions.go +2 -2
| @@ -602,11 +602,11 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |||
| 602 | 602 | tabName = "Tab" | |
| 603 | 603 | } | |
| 604 | 604 | ||
| 605 | - | b.WriteString(fmt.Sprintf(" %s%s %s\n", | |
| 605 | + | fmt.Fprintf(&b, " %s%s %s\n", | |
| 606 | 606 | treeStyle.Render(branch), | |
| 607 | 607 | leafStyle.Render(tabIcon), | |
| 608 | 608 | t.Normal.Render(tabName), | |
| 609 | - | )) | |
| 609 | + | ) | |
| 610 | 610 | ||
| 611 | 611 | // Render panes under this tab | |
| 612 | 612 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | |
| @@ -602,11 +602,11 @@ func (m SessionsModel) renderSessionDetails(info entity.SessionInfo) string { | |||||
| 602 | tabName = "Tab" | 602 | tabName = "Tab" | ||
| 603 | } | 603 | } | ||
| 604 | 604 | ||||
| 605 | - | b.WriteString(fmt.Sprintf(" %s%s %s\n", | 605 | + | fmt.Fprintf(&b, " %s%s %s\n", |
| 606 | treeStyle.Render(branch), | 606 | treeStyle.Render(branch), | ||
| 607 | leafStyle.Render(tabIcon), | 607 | leafStyle.Render(tabIcon), | ||
| 608 | t.Normal.Render(tabName), | 608 | t.Normal.Render(tabName), | ||
| 609 | - | )) | 609 | + | ) |
| 610 | 610 | ||||
| 611 | // Render panes under this tab | 611 | // Render panes under this tab | ||
| 612 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | 612 | m.renderPaneTree(&b, &tab.Workspace, isLastTab, t, treeStyle, leafStyle) | ||
M internal/cli/styles/config.go +12 -12
| @@ -54,16 +54,16 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||
| 54 | 54 | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | |
| 55 | 55 | ||
| 56 | 56 | var sb strings.Builder | |
| 57 | - | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | |
| 57 | + | fmt.Fprintf(&sb, "\n Missing settings (%d):\n", len(keys)) | |
| 58 | 58 | ||
| 59 | 59 | for _, key := range keys { | |
| 60 | - | sb.WriteString(fmt.Sprintf( | |
| 60 | + | fmt.Fprintf(&sb, | |
| 61 | 61 | " %s %s\n Type: %s | Default: %s\n", | |
| 62 | 62 | iconStyle.Render(IconCursor), | |
| 63 | 63 | keyStyle.Render(key.Key), | |
| 64 | 64 | typeStyle.Render(key.Type), | |
| 65 | 65 | valueStyle.Render(key.DefaultValue), | |
| 66 | - | )) | |
| 66 | + | ) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | 69 | return sb.String() | |
| @@ -98,42 +98,42 @@ func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |||
| 98 | 98 | ||
| 99 | 99 | // Show renamed keys first (most important for user to understand) | |
| 100 | 100 | if len(renamed) > 0 { | |
| 101 | - | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | |
| 101 | + | fmt.Fprintf(&sb, "\n Renamed settings (%d):\n", len(renamed)) | |
| 102 | 102 | for _, c := range renamed { | |
| 103 | - | sb.WriteString(fmt.Sprintf( | |
| 103 | + | fmt.Fprintf(&sb, | |
| 104 | 104 | " %s %s %s %s\n Value: %s\n", | |
| 105 | 105 | renameStyle.Render("~"), | |
| 106 | 106 | valueStyle.Render(c.OldKey), | |
| 107 | 107 | renameStyle.Render("→"), | |
| 108 | 108 | keyStyle.Render(c.NewKey), | |
| 109 | 109 | valueStyle.Render(c.OldValue), | |
| 110 | - | )) | |
| 110 | + | ) | |
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | // Show new settings | |
| 115 | 115 | if len(added) > 0 { | |
| 116 | - | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | |
| 116 | + | fmt.Fprintf(&sb, "\n New settings (%d):\n", len(added)) | |
| 117 | 117 | for _, c := range added { | |
| 118 | - | sb.WriteString(fmt.Sprintf( | |
| 118 | + | fmt.Fprintf(&sb, | |
| 119 | 119 | " %s %s\n Default: %s\n", | |
| 120 | 120 | addStyle.Render("+"), | |
| 121 | 121 | keyStyle.Render(c.NewKey), | |
| 122 | 122 | valueStyle.Render(c.NewValue), | |
| 123 | - | )) | |
| 123 | + | ) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | 127 | // Show deprecated settings (will be removed) | |
| 128 | 128 | if len(removed) > 0 { | |
| 129 | - | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | |
| 129 | + | fmt.Fprintf(&sb, "\n Deprecated settings (%d):\n", len(removed)) | |
| 130 | 130 | for _, c := range removed { | |
| 131 | - | sb.WriteString(fmt.Sprintf( | |
| 131 | + | fmt.Fprintf(&sb, | |
| 132 | 132 | " %s %s %s\n", | |
| 133 | 133 | removeStyle.Render("-"), | |
| 134 | 134 | valueStyle.Render(c.OldKey), | |
| 135 | 135 | removeStyle.Render("(will be removed)"), | |
| 136 | - | )) | |
| 136 | + | ) | |
| 137 | 137 | } | |
| 138 | 138 | } | |
| 139 | 139 | ||
| @@ -54,16 +54,16 @@ func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||||
| 54 | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | 54 | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | ||
| 55 | 55 | ||||
| 56 | var sb strings.Builder | 56 | var sb strings.Builder | ||
| 57 | - | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | 57 | + | fmt.Fprintf(&sb, "\n Missing settings (%d):\n", len(keys)) |
| 58 | 58 | ||||
| 59 | for _, key := range keys { | 59 | for _, key := range keys { | ||
| 60 | - | sb.WriteString(fmt.Sprintf( | 60 | + | fmt.Fprintf(&sb, |
| 61 | " %s %s\n Type: %s | Default: %s\n", | 61 | " %s %s\n Type: %s | Default: %s\n", | ||
| 62 | iconStyle.Render(IconCursor), | 62 | iconStyle.Render(IconCursor), | ||
| 63 | keyStyle.Render(key.Key), | 63 | keyStyle.Render(key.Key), | ||
| 64 | typeStyle.Render(key.Type), | 64 | typeStyle.Render(key.Type), | ||
| 65 | valueStyle.Render(key.DefaultValue), | 65 | valueStyle.Render(key.DefaultValue), | ||
| 66 | - | )) | 66 | + | ) |
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | return sb.String() | 69 | return sb.String() | ||
| @@ -98,42 +98,42 @@ func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string { | |||||
| 98 | 98 | ||||
| 99 | // Show renamed keys first (most important for user to understand) | 99 | // Show renamed keys first (most important for user to understand) | ||
| 100 | if len(renamed) > 0 { | 100 | if len(renamed) > 0 { | ||
| 101 | - | sb.WriteString(fmt.Sprintf("\n Renamed settings (%d):\n", len(renamed))) | 101 | + | fmt.Fprintf(&sb, "\n Renamed settings (%d):\n", len(renamed)) |
| 102 | for _, c := range renamed { | 102 | for _, c := range renamed { | ||
| 103 | - | sb.WriteString(fmt.Sprintf( | 103 | + | fmt.Fprintf(&sb, |
| 104 | " %s %s %s %s\n Value: %s\n", | 104 | " %s %s %s %s\n Value: %s\n", | ||
| 105 | renameStyle.Render("~"), | 105 | renameStyle.Render("~"), | ||
| 106 | valueStyle.Render(c.OldKey), | 106 | valueStyle.Render(c.OldKey), | ||
| 107 | renameStyle.Render("→"), | 107 | renameStyle.Render("→"), | ||
| 108 | keyStyle.Render(c.NewKey), | 108 | keyStyle.Render(c.NewKey), | ||
| 109 | valueStyle.Render(c.OldValue), | 109 | valueStyle.Render(c.OldValue), | ||
| 110 | - | )) | 110 | + | ) |
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | // Show new settings | 114 | // Show new settings | ||
| 115 | if len(added) > 0 { | 115 | if len(added) > 0 { | ||
| 116 | - | sb.WriteString(fmt.Sprintf("\n New settings (%d):\n", len(added))) | 116 | + | fmt.Fprintf(&sb, "\n New settings (%d):\n", len(added)) |
| 117 | for _, c := range added { | 117 | for _, c := range added { | ||
| 118 | - | sb.WriteString(fmt.Sprintf( | 118 | + | fmt.Fprintf(&sb, |
| 119 | " %s %s\n Default: %s\n", | 119 | " %s %s\n Default: %s\n", | ||
| 120 | addStyle.Render("+"), | 120 | addStyle.Render("+"), | ||
| 121 | keyStyle.Render(c.NewKey), | 121 | keyStyle.Render(c.NewKey), | ||
| 122 | valueStyle.Render(c.NewValue), | 122 | valueStyle.Render(c.NewValue), | ||
| 123 | - | )) | 123 | + | ) |
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | // Show deprecated settings (will be removed) | 127 | // Show deprecated settings (will be removed) | ||
| 128 | if len(removed) > 0 { | 128 | if len(removed) > 0 { | ||
| 129 | - | sb.WriteString(fmt.Sprintf("\n Deprecated settings (%d):\n", len(removed))) | 129 | + | fmt.Fprintf(&sb, "\n Deprecated settings (%d):\n", len(removed)) |
| 130 | for _, c := range removed { | 130 | for _, c := range removed { | ||
| 131 | - | sb.WriteString(fmt.Sprintf( | 131 | + | fmt.Fprintf(&sb, |
| 132 | " %s %s %s\n", | 132 | " %s %s %s\n", | ||
| 133 | removeStyle.Render("-"), | 133 | removeStyle.Render("-"), | ||
| 134 | valueStyle.Render(c.OldKey), | 134 | valueStyle.Render(c.OldKey), | ||
| 135 | removeStyle.Render("(will be removed)"), | 135 | removeStyle.Render("(will be removed)"), | ||
| 136 | - | )) | 136 | + | ) |
| 137 | } | 137 | } | ||
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
M internal/infrastructure/config/diff_formatter.go +7 -5
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | const noChangesMsg = "No changes detected." | |
| 11 | + | ||
| 10 | 12 | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | |
| 11 | 13 | type ConfigDiffFormatter struct{} | |
| 12 | 14 | ||
| @@ -18,7 +20,7 @@ func NewDiffFormatter() *ConfigDiffFormatter { | |||
| 18 | 20 | // FormatChangesAsDiff returns changes formatted as a diff for display. | |
| 19 | 21 | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | |
| 20 | 22 | if len(changes) == 0 { | |
| 21 | - | return "No changes detected." | |
| 23 | + | return noChangesMsg | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | 26 | var sb strings.Builder | |
| @@ -27,12 +29,12 @@ func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string | |||
| 27 | 29 | for _, change := range changes { | |
| 28 | 30 | switch change.Type { | |
| 29 | 31 | case port.KeyChangeAdded: | |
| 30 | - | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | |
| 32 | + | fmt.Fprintf(&sb, " + %s = %s\n", change.NewKey, change.NewValue) | |
| 31 | 33 | case port.KeyChangeRemoved: | |
| 32 | - | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | |
| 34 | + | fmt.Fprintf(&sb, " - %s = %s (deprecated)\n", change.OldKey, change.OldValue) | |
| 33 | 35 | case port.KeyChangeRenamed: | |
| 34 | - | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | |
| 35 | - | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | |
| 36 | + | fmt.Fprintf(&sb, " ~ %s -> %s\n", change.OldKey, change.NewKey) | |
| 37 | + | fmt.Fprintf(&sb, " (value: %s)\n", change.OldValue) | |
| 36 | 38 | } | |
| 37 | 39 | } | |
| 38 | 40 | ||
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | const noChangesMsg = "No changes detected." | |||
| 11 | + | ||||
| 10 | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | 12 | // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes. | ||
| 11 | type ConfigDiffFormatter struct{} | 13 | type ConfigDiffFormatter struct{} | ||
| 12 | 14 | ||||
| @@ -18,7 +20,7 @@ func NewDiffFormatter() *ConfigDiffFormatter { | |||||
| 18 | // FormatChangesAsDiff returns changes formatted as a diff for display. | 20 | // FormatChangesAsDiff returns changes formatted as a diff for display. | ||
| 19 | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | 21 | func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string { | ||
| 20 | if len(changes) == 0 { | 22 | if len(changes) == 0 { | ||
| 21 | - | return "No changes detected." | 23 | + | return noChangesMsg |
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 24 | var sb strings.Builder | 26 | var sb strings.Builder | ||
| @@ -27,12 +29,12 @@ func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string | |||||
| 27 | for _, change := range changes { | 29 | for _, change := range changes { | ||
| 28 | switch change.Type { | 30 | switch change.Type { | ||
| 29 | case port.KeyChangeAdded: | 31 | case port.KeyChangeAdded: | ||
| 30 | - | sb.WriteString(fmt.Sprintf(" + %s = %s\n", change.NewKey, change.NewValue)) | 32 | + | fmt.Fprintf(&sb, " + %s = %s\n", change.NewKey, change.NewValue) |
| 31 | case port.KeyChangeRemoved: | 33 | case port.KeyChangeRemoved: | ||
| 32 | - | sb.WriteString(fmt.Sprintf(" - %s = %s (deprecated)\n", change.OldKey, change.OldValue)) | 34 | + | fmt.Fprintf(&sb, " - %s = %s (deprecated)\n", change.OldKey, change.OldValue) |
| 33 | case port.KeyChangeRenamed: | 35 | case port.KeyChangeRenamed: | ||
| 34 | - | sb.WriteString(fmt.Sprintf(" ~ %s -> %s\n", change.OldKey, change.NewKey)) | 36 | + | fmt.Fprintf(&sb, " ~ %s -> %s\n", change.OldKey, change.NewKey) |
| 35 | - | sb.WriteString(fmt.Sprintf(" (value: %s)\n", change.OldValue)) | 37 | + | fmt.Fprintf(&sb, " (value: %s)\n", change.OldValue) |
| 36 | } | 38 | } | ||
| 37 | } | 39 | } | ||
| 38 | 40 | ||||
A internal/infrastructure/config/diff_formatter_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestConfigDiffFormatter_FormatChangesAsDiff(t *testing.T) { | |
| 10 | + | f := NewDiffFormatter() | |
| 11 | + | ||
| 12 | + | t.Run("no changes", func(t *testing.T) { | |
| 13 | + | got := f.FormatChangesAsDiff(nil) | |
| 14 | + | want := noChangesMsg | |
| 15 | + | if got != want { | |
| 16 | + | t.Errorf("got %q, want %q", got, want) | |
| 17 | + | } | |
| 18 | + | }) | |
| 19 | + | ||
| 20 | + | t.Run("empty slice", func(t *testing.T) { | |
| 21 | + | got := f.FormatChangesAsDiff([]port.KeyChange{}) | |
| 22 | + | want := noChangesMsg | |
| 23 | + | if got != want { | |
| 24 | + | t.Errorf("got %q, want %q", got, want) | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | ||
| 28 | + | t.Run("added change", func(t *testing.T) { | |
| 29 | + | changes := []port.KeyChange{ | |
| 30 | + | {Type: port.KeyChangeAdded, NewKey: "update.enable", NewValue: "true"}, | |
| 31 | + | } | |
| 32 | + | got := f.FormatChangesAsDiff(changes) | |
| 33 | + | want := "Config migration changes:\n\n + update.enable = true\n" | |
| 34 | + | if got != want { | |
| 35 | + | t.Errorf("got %q, want %q", got, want) | |
| 36 | + | } | |
| 37 | + | }) | |
| 38 | + | ||
| 39 | + | t.Run("removed change", func(t *testing.T) { | |
| 40 | + | changes := []port.KeyChange{ | |
| 41 | + | {Type: port.KeyChangeRemoved, OldKey: "old.key", OldValue: "42"}, | |
| 42 | + | } | |
| 43 | + | got := f.FormatChangesAsDiff(changes) | |
| 44 | + | want := "Config migration changes:\n\n - old.key = 42 (deprecated)\n" | |
| 45 | + | if got != want { | |
| 46 | + | t.Errorf("got %q, want %q", got, want) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | ||
| 50 | + | t.Run("renamed change", func(t *testing.T) { | |
| 51 | + | changes := []port.KeyChange{ | |
| 52 | + | {Type: port.KeyChangeRenamed, OldKey: "old.name", NewKey: "new.name", OldValue: "hello"}, | |
| 53 | + | } | |
| 54 | + | got := f.FormatChangesAsDiff(changes) | |
| 55 | + | want := "Config migration changes:\n\n ~ old.name -> new.name\n (value: hello)\n" | |
| 56 | + | if got != want { | |
| 57 | + | t.Errorf("got %q, want %q", got, want) | |
| 58 | + | } | |
| 59 | + | }) | |
| 60 | + | ||
| 61 | + | t.Run("all change types combined", func(t *testing.T) { | |
| 62 | + | changes := []port.KeyChange{ | |
| 63 | + | {Type: port.KeyChangeAdded, NewKey: "feature.flag", NewValue: "false"}, | |
| 64 | + | {Type: port.KeyChangeRemoved, OldKey: "legacy.opt", OldValue: "1"}, | |
| 65 | + | {Type: port.KeyChangeRenamed, OldKey: "foo.bar", NewKey: "foo.baz", OldValue: "qux"}, | |
| 66 | + | } | |
| 67 | + | got := f.FormatChangesAsDiff(changes) | |
| 68 | + | want := "Config migration changes:\n\n" + | |
| 69 | + | " + feature.flag = false\n" + | |
| 70 | + | " - legacy.opt = 1 (deprecated)\n" + | |
| 71 | + | " ~ foo.bar -> foo.baz\n" + | |
| 72 | + | " (value: qux)\n" | |
| 73 | + | if got != want { | |
| 74 | + | t.Errorf("got %q, want %q", got, want) | |
| 75 | + | } | |
| 76 | + | }) | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestConfigDiffFormatter_FormatChangesAsDiff(t *testing.T) { | |||
| 10 | + | f := NewDiffFormatter() | |||
| 11 | + | ||||
| 12 | + | t.Run("no changes", func(t *testing.T) { | |||
| 13 | + | got := f.FormatChangesAsDiff(nil) | |||
| 14 | + | want := noChangesMsg | |||
| 15 | + | if got != want { | |||
| 16 | + | t.Errorf("got %q, want %q", got, want) | |||
| 17 | + | } | |||
| 18 | + | }) | |||
| 19 | + | ||||
| 20 | + | t.Run("empty slice", func(t *testing.T) { | |||
| 21 | + | got := f.FormatChangesAsDiff([]port.KeyChange{}) | |||
| 22 | + | want := noChangesMsg | |||
| 23 | + | if got != want { | |||
| 24 | + | t.Errorf("got %q, want %q", got, want) | |||
| 25 | + | } | |||
| 26 | + | }) | |||
| 27 | + | ||||
| 28 | + | t.Run("added change", func(t *testing.T) { | |||
| 29 | + | changes := []port.KeyChange{ | |||
| 30 | + | {Type: port.KeyChangeAdded, NewKey: "update.enable", NewValue: "true"}, | |||
| 31 | + | } | |||
| 32 | + | got := f.FormatChangesAsDiff(changes) | |||
| 33 | + | want := "Config migration changes:\n\n + update.enable = true\n" | |||
| 34 | + | if got != want { | |||
| 35 | + | t.Errorf("got %q, want %q", got, want) | |||
| 36 | + | } | |||
| 37 | + | }) | |||
| 38 | + | ||||
| 39 | + | t.Run("removed change", func(t *testing.T) { | |||
| 40 | + | changes := []port.KeyChange{ | |||
| 41 | + | {Type: port.KeyChangeRemoved, OldKey: "old.key", OldValue: "42"}, | |||
| 42 | + | } | |||
| 43 | + | got := f.FormatChangesAsDiff(changes) | |||
| 44 | + | want := "Config migration changes:\n\n - old.key = 42 (deprecated)\n" | |||
| 45 | + | if got != want { | |||
| 46 | + | t.Errorf("got %q, want %q", got, want) | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | ||||
| 50 | + | t.Run("renamed change", func(t *testing.T) { | |||
| 51 | + | changes := []port.KeyChange{ | |||
| 52 | + | {Type: port.KeyChangeRenamed, OldKey: "old.name", NewKey: "new.name", OldValue: "hello"}, | |||
| 53 | + | } | |||
| 54 | + | got := f.FormatChangesAsDiff(changes) | |||
| 55 | + | want := "Config migration changes:\n\n ~ old.name -> new.name\n (value: hello)\n" | |||
| 56 | + | if got != want { | |||
| 57 | + | t.Errorf("got %q, want %q", got, want) | |||
| 58 | + | } | |||
| 59 | + | }) | |||
| 60 | + | ||||
| 61 | + | t.Run("all change types combined", func(t *testing.T) { | |||
| 62 | + | changes := []port.KeyChange{ | |||
| 63 | + | {Type: port.KeyChangeAdded, NewKey: "feature.flag", NewValue: "false"}, | |||
| 64 | + | {Type: port.KeyChangeRemoved, OldKey: "legacy.opt", OldValue: "1"}, | |||
| 65 | + | {Type: port.KeyChangeRenamed, OldKey: "foo.bar", NewKey: "foo.baz", OldValue: "qux"}, | |||
| 66 | + | } | |||
| 67 | + | got := f.FormatChangesAsDiff(changes) | |||
| 68 | + | want := "Config migration changes:\n\n" + | |||
| 69 | + | " + feature.flag = false\n" + | |||
| 70 | + | " - legacy.opt = 1 (deprecated)\n" + | |||
| 71 | + | " ~ foo.bar -> foo.baz\n" + | |||
| 72 | + | " (value: qux)\n" | |||
| 73 | + | if got != want { | |||
| 74 | + | t.Errorf("got %q, want %q", got, want) | |||
| 75 | + | } | |||
| 76 | + | }) | |||
| 77 | + | } | |||
M internal/infrastructure/textinput/webview_target.go +8 -4
| @@ -95,13 +95,17 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||
| 95 | 95 | Str("text", text). | |
| 96 | 96 | Msg("inserted text into WebView via clipboard paste") | |
| 97 | 97 | ||
| 98 | - | // Restore original clipboard after a short delay | |
| 99 | - | // This allows the paste operation to complete first | |
| 100 | - | // Use context.Background() since this cleanup should complete regardless of ctx cancellation | |
| 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) | |
| 101 | 103 | go func() { | |
| 102 | 104 | time.Sleep(clipboardRestoreDelay) | |
| 105 | + | restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay) | |
| 106 | + | defer cancelRestore() | |
| 103 | 107 | if originalClipboard != "" { | |
| 104 | - | if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil { | |
| 108 | + | if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil { | |
| 105 | 109 | log.Debug().Err(err).Msg("failed to restore clipboard") | |
| 106 | 110 | } | |
| 107 | 111 | } | |
| @@ -95,13 +95,17 @@ func (t *WebViewTarget) InsertText(ctx context.Context, text string) error { | |||||
| 95 | Str("text", text). | 95 | Str("text", text). | ||
| 96 | Msg("inserted text into WebView via clipboard paste") | 96 | Msg("inserted text into WebView via clipboard paste") | ||
| 97 | 97 | ||||
| 98 | - | // Restore original clipboard after a short delay | 98 | + | // Restore original clipboard after a short delay. |
| 99 | - | // This allows the paste operation to complete first | 99 | + | // This allows the paste operation to complete first. |
| 100 | - | // Use context.Background() since this cleanup should complete regardless of ctx cancellation | 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) | |||
| 101 | go func() { | 103 | go func() { | ||
| 102 | time.Sleep(clipboardRestoreDelay) | 104 | time.Sleep(clipboardRestoreDelay) | ||
| 105 | + | restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay) | |||
| 106 | + | defer cancelRestore() | |||
| 103 | if originalClipboard != "" { | 107 | if originalClipboard != "" { | ||
| 104 | - | if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil { | 108 | + | if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil { |
| 105 | log.Debug().Err(err).Msg("failed to restore clipboard") | 109 | log.Debug().Err(err).Msg("failed to restore clipboard") | ||
| 106 | } | 110 | } | ||
| 107 | } | 111 | } | ||
M internal/infrastructure/updater/applier.go +31 -2
| @@ -160,11 +160,40 @@ func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error { | |||
| 160 | 160 | return fmt.Errorf("failed to read new binary: %w", err) | |
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | - | // Write to staging location. | |
| 163 | + | // Write to staging location atomically via temp file. | |
| 164 | 164 | stagedPath := a.stagedBinaryPath() | |
| 165 | - | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | |
| 165 | + | dir := filepath.Dir(stagedPath) | |
| 166 | + | tmpFile, err := os.CreateTemp(dir, ".staged-update-*") | |
| 167 | + | if err != nil { | |
| 168 | + | return fmt.Errorf("failed to create temp file for staged binary: %w", err) | |
| 169 | + | } | |
| 170 | + | tmpPath := tmpFile.Name() | |
| 171 | + | // Ensure temp file is cleaned up on any failure. | |
| 172 | + | cleanup := func() { _ = os.Remove(tmpPath) } | |
| 173 | + | ||
| 174 | + | if _, err := tmpFile.Write(newBinary); err != nil { | |
| 175 | + | _ = tmpFile.Close() | |
| 176 | + | cleanup() | |
| 166 | 177 | return fmt.Errorf("failed to write staged binary: %w", err) | |
| 167 | 178 | } | |
| 179 | + | if err := tmpFile.Chmod(applierExecPerm); err != nil { | |
| 180 | + | _ = tmpFile.Close() | |
| 181 | + | cleanup() | |
| 182 | + | return fmt.Errorf("failed to chmod staged binary: %w", err) | |
| 183 | + | } | |
| 184 | + | if err := tmpFile.Sync(); err != nil { | |
| 185 | + | _ = tmpFile.Close() | |
| 186 | + | cleanup() | |
| 187 | + | return fmt.Errorf("failed to sync staged binary: %w", err) | |
| 188 | + | } | |
| 189 | + | if err := tmpFile.Close(); err != nil { | |
| 190 | + | cleanup() | |
| 191 | + | return fmt.Errorf("failed to close staged binary temp file: %w", err) | |
| 192 | + | } | |
| 193 | + | if err := os.Rename(tmpPath, stagedPath); err != nil { | |
| 194 | + | cleanup() | |
| 195 | + | return fmt.Errorf("failed to rename staged binary into place: %w", err) | |
| 196 | + | } | |
| 168 | 197 | ||
| 169 | 198 | log.Info(). | |
| 170 | 199 | Str("staged_path", stagedPath). | |
| @@ -160,11 +160,40 @@ func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error { | |||||
| 160 | return fmt.Errorf("failed to read new binary: %w", err) | 160 | return fmt.Errorf("failed to read new binary: %w", err) | ||
| 161 | } | 161 | } | ||
| 162 | 162 | ||||
| 163 | - | // Write to staging location. | 163 | + | // Write to staging location atomically via temp file. |
| 164 | stagedPath := a.stagedBinaryPath() | 164 | stagedPath := a.stagedBinaryPath() | ||
| 165 | - | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | 165 | + | dir := filepath.Dir(stagedPath) |
| 166 | + | tmpFile, err := os.CreateTemp(dir, ".staged-update-*") | |||
| 167 | + | if err != nil { | |||
| 168 | + | return fmt.Errorf("failed to create temp file for staged binary: %w", err) | |||
| 169 | + | } | |||
| 170 | + | tmpPath := tmpFile.Name() | |||
| 171 | + | // Ensure temp file is cleaned up on any failure. | |||
| 172 | + | cleanup := func() { _ = os.Remove(tmpPath) } | |||
| 173 | + | ||||
| 174 | + | if _, err := tmpFile.Write(newBinary); err != nil { | |||
| 175 | + | _ = tmpFile.Close() | |||
| 176 | + | cleanup() | |||
| 166 | return fmt.Errorf("failed to write staged binary: %w", err) | 177 | return fmt.Errorf("failed to write staged binary: %w", err) | ||
| 167 | } | 178 | } | ||
| 179 | + | if err := tmpFile.Chmod(applierExecPerm); err != nil { | |||
| 180 | + | _ = tmpFile.Close() | |||
| 181 | + | cleanup() | |||
| 182 | + | return fmt.Errorf("failed to chmod staged binary: %w", err) | |||
| 183 | + | } | |||
| 184 | + | if err := tmpFile.Sync(); err != nil { | |||
| 185 | + | _ = tmpFile.Close() | |||
| 186 | + | cleanup() | |||
| 187 | + | return fmt.Errorf("failed to sync staged binary: %w", err) | |||
| 188 | + | } | |||
| 189 | + | if err := tmpFile.Close(); err != nil { | |||
| 190 | + | cleanup() | |||
| 191 | + | return fmt.Errorf("failed to close staged binary temp file: %w", err) | |||
| 192 | + | } | |||
| 193 | + | if err := os.Rename(tmpPath, stagedPath); err != nil { | |||
| 194 | + | cleanup() | |||
| 195 | + | return fmt.Errorf("failed to rename staged binary into place: %w", err) | |||
| 196 | + | } | |||
| 168 | 197 | ||||
| 169 | log.Info(). | 198 | log.Info(). | ||
| 170 | Str("staged_path", stagedPath). | 199 | Str("staged_path", stagedPath). | ||
M internal/infrastructure/webkit/handlers/register.go +57 -0
| @@ -2,7 +2,9 @@ package handlers | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 7 | + | "unicode/utf8" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| @@ -11,6 +13,13 @@ import ( | |||
| 11 | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | |
| 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 | |
| @@ -61,6 +70,54 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 61 | 70 | return nil | |
| 62 | 71 | } | |
| 63 | 72 | ||
| 73 | + | // RegisterAccentHandlers registers accent key press/release handlers with the router. | |
| 74 | + | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | |
| 75 | + | func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | |
| 76 | + | if router == nil { | |
| 77 | + | return fmt.Errorf("RegisterAccentHandlers: router must not be nil") | |
| 78 | + | } | |
| 79 | + | if handler == nil { | |
| 80 | + | return fmt.Errorf("RegisterAccentHandlers: handler must not be nil") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | |
| 84 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 85 | + | var p struct { | |
| 86 | + | Char string `json:"char"` | |
| 87 | + | Shift bool `json:"shift"` | |
| 88 | + | } | |
| 89 | + | if err := json.Unmarshal(payload, &p); err != nil { | |
| 90 | + | return nil, err | |
| 91 | + | } | |
| 92 | + | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 93 | + | handler.OnKeyPressed(ctx, r, p.Shift) | |
| 94 | + | } | |
| 95 | + | return nil, nil | |
| 96 | + | }, | |
| 97 | + | )); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | if err := router.RegisterHandler("accent_key_release", webkit.MessageHandlerFunc( | |
| 102 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 103 | + | var p struct { | |
| 104 | + | Char string `json:"char"` | |
| 105 | + | } | |
| 106 | + | if err := json.Unmarshal(payload, &p); err != nil { | |
| 107 | + | return nil, err | |
| 108 | + | } | |
| 109 | + | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 110 | + | handler.OnKeyReleased(ctx, r) | |
| 111 | + | } | |
| 112 | + | return nil, nil | |
| 113 | + | }, | |
| 114 | + | )); err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | ||
| 64 | 121 | // createKeybindingsHandler wires the gateway and use cases for keybindings. | |
| 65 | 122 | func createKeybindingsHandler() (*KeybindingsHandler, error) { | |
| 66 | 123 | mgr := config.GetManager() | |
| @@ -2,7 +2,9 @@ package handlers | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "fmt" | 6 | "fmt" | ||
| 7 | + | "unicode/utf8" | |||
| 6 | 8 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| @@ -11,6 +13,13 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | ||
| 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 | ||
| @@ -61,6 +70,54 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||||
| 61 | return nil | 70 | return nil | ||
| 62 | } | 71 | } | ||
| 63 | 72 | ||||
| 73 | + | // RegisterAccentHandlers registers accent key press/release handlers with the router. | |||
| 74 | + | // Must be called after the AccentKeyHandler is initialized (i.e., after initAccentPicker). | |||
| 75 | + | func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, handler AccentKeyHandler) error { | |||
| 76 | + | if router == nil { | |||
| 77 | + | return fmt.Errorf("RegisterAccentHandlers: router must not be nil") | |||
| 78 | + | } | |||
| 79 | + | if handler == nil { | |||
| 80 | + | return fmt.Errorf("RegisterAccentHandlers: handler must not be nil") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if err := router.RegisterHandler("accent_key_press", webkit.MessageHandlerFunc( | |||
| 84 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 85 | + | var p struct { | |||
| 86 | + | Char string `json:"char"` | |||
| 87 | + | Shift bool `json:"shift"` | |||
| 88 | + | } | |||
| 89 | + | if err := json.Unmarshal(payload, &p); err != nil { | |||
| 90 | + | return nil, err | |||
| 91 | + | } | |||
| 92 | + | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |||
| 93 | + | handler.OnKeyPressed(ctx, r, p.Shift) | |||
| 94 | + | } | |||
| 95 | + | return nil, nil | |||
| 96 | + | }, | |||
| 97 | + | )); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | if err := router.RegisterHandler("accent_key_release", webkit.MessageHandlerFunc( | |||
| 102 | + | func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |||
| 103 | + | var p struct { | |||
| 104 | + | Char string `json:"char"` | |||
| 105 | + | } | |||
| 106 | + | if err := json.Unmarshal(payload, &p); err != nil { | |||
| 107 | + | return nil, err | |||
| 108 | + | } | |||
| 109 | + | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |||
| 110 | + | handler.OnKeyReleased(ctx, r) | |||
| 111 | + | } | |||
| 112 | + | return nil, nil | |||
| 113 | + | }, | |||
| 114 | + | )); err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | return nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 64 | // createKeybindingsHandler wires the gateway and use cases for keybindings. | 121 | // createKeybindingsHandler wires the gateway and use cases for keybindings. | ||
| 65 | func createKeybindingsHandler() (*KeybindingsHandler, error) { | 122 | func createKeybindingsHandler() (*KeybindingsHandler, error) { | ||
| 66 | mgr := config.GetManager() | 123 | mgr := config.GetManager() | ||
A internal/infrastructure/webkit/handlers/register_test.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestRegisterAccentHandlers_NilRouter(t *testing.T) { | |
| 13 | + | ctx := context.Background() | |
| 14 | + | handler := &stubAccentHandler{} | |
| 15 | + | err := RegisterAccentHandlers(ctx, nil, handler) | |
| 16 | + | require.Error(t, err) | |
| 17 | + | assert.Contains(t, err.Error(), "router") | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestRegisterAccentHandlers_NilHandler(t *testing.T) { | |
| 21 | + | ctx := context.Background() | |
| 22 | + | router := webkit.NewMessageRouter(ctx) | |
| 23 | + | err := RegisterAccentHandlers(ctx, router, nil) | |
| 24 | + | require.Error(t, err) | |
| 25 | + | assert.Contains(t, err.Error(), "handler") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestRegisterAccentHandlers_Success(t *testing.T) { | |
| 29 | + | ctx := context.Background() | |
| 30 | + | router := webkit.NewMessageRouter(ctx) | |
| 31 | + | handler := &stubAccentHandler{} | |
| 32 | + | err := RegisterAccentHandlers(ctx, router, handler) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // stubAccentHandler satisfies AccentKeyHandler for tests. | |
| 37 | + | type stubAccentHandler struct{} | |
| 38 | + | ||
| 39 | + | func (*stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { return false } | |
| 40 | + | func (*stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {} | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestRegisterAccentHandlers_NilRouter(t *testing.T) { | |||
| 13 | + | ctx := context.Background() | |||
| 14 | + | handler := &stubAccentHandler{} | |||
| 15 | + | err := RegisterAccentHandlers(ctx, nil, handler) | |||
| 16 | + | require.Error(t, err) | |||
| 17 | + | assert.Contains(t, err.Error(), "router") | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestRegisterAccentHandlers_NilHandler(t *testing.T) { | |||
| 21 | + | ctx := context.Background() | |||
| 22 | + | router := webkit.NewMessageRouter(ctx) | |||
| 23 | + | err := RegisterAccentHandlers(ctx, router, nil) | |||
| 24 | + | require.Error(t, err) | |||
| 25 | + | assert.Contains(t, err.Error(), "handler") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestRegisterAccentHandlers_Success(t *testing.T) { | |||
| 29 | + | ctx := context.Background() | |||
| 30 | + | router := webkit.NewMessageRouter(ctx) | |||
| 31 | + | handler := &stubAccentHandler{} | |||
| 32 | + | err := RegisterAccentHandlers(ctx, router, handler) | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // stubAccentHandler satisfies AccentKeyHandler for tests. | |||
| 37 | + | type stubAccentHandler struct{} | |||
| 38 | + | ||||
| 39 | + | func (*stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { return false } | |||
| 40 | + | func (*stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {} | |||
M internal/infrastructure/webkit/injector.go +71 -0
| @@ -141,6 +141,64 @@ 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 | + | // Check if the event target is an editable element (input, textarea, contenteditable) | |
| 154 | + | function isEditableTarget(target) { | |
| 155 | + | const el = target instanceof Element ? target : null; | |
| 156 | + | if (!el) return false; | |
| 157 | + | if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true; | |
| 158 | + | if (el.closest('textarea')) return true; | |
| 159 | + | const input = el.closest('input'); | |
| 160 | + | if (!input) return false; | |
| 161 | + | // Exclude non-text input types | |
| 162 | + | return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(input.type); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | document.addEventListener('keydown', function(e) { | |
| 166 | + | if (!isEditableTarget(e.target)) return; | |
| 167 | + | ||
| 168 | + | // Only handle a-z keys, with optional Shift | |
| 169 | + | // Skip if Ctrl or Alt are held (those are shortcuts) | |
| 170 | + | if (e.ctrlKey || e.altKey || e.metaKey) return; | |
| 171 | + | ||
| 172 | + | // Only single character keys a-z | |
| 173 | + | const key = e.key.toLowerCase(); | |
| 174 | + | if (key.length !== 1 || key < 'a' || key > 'z') return; | |
| 175 | + | ||
| 176 | + | pressedKey = key; | |
| 177 | + | ||
| 178 | + | // Send to Go for accent detection | |
| 179 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 180 | + | type: 'accent_key_press', | |
| 181 | + | payload: { char: key, shift: e.shiftKey } | |
| 182 | + | }); | |
| 183 | + | }, true); // capture phase to see events first | |
| 184 | + | ||
| 185 | + | document.addEventListener('keyup', function(e) { | |
| 186 | + | if (!isEditableTarget(e.target)) return; | |
| 187 | + | const key = e.key.toLowerCase(); | |
| 188 | + | if (key === pressedKey) { | |
| 189 | + | pressedKey = null; | |
| 190 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 191 | + | type: 'accent_key_release', | |
| 192 | + | payload: { char: key } | |
| 193 | + | }); | |
| 194 | + | } | |
| 195 | + | }, true); | |
| 196 | + | })();` | |
| 197 | + | ||
| 198 | + | // accentDetectionInjectionMode controls which frames receive the accent detection script. | |
| 199 | + | // Set to AllFrames so the script runs in iframes as well as the top-level document. | |
| 200 | + | const accentDetectionInjectionMode = webkit.UserContentInjectAllFramesValue | |
| 201 | + | ||
| 144 | 202 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | |
| 145 | 203 | // Some pages gate support on window.RTCPeerConnection and report false negatives | |
| 146 | 204 | // when only webkit-prefixed constructors are present. | |
| @@ -333,5 +391,18 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 333 | 391 | log.Debug().Msg("auto-copy selection script injected") | |
| 334 | 392 | } | |
| 335 | 393 | ||
| 394 | + | // 7. Inject accent key detection for all pages and all frames (unconditional). | |
| 395 | + | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 396 | + | addScript( | |
| 397 | + | webkit.NewUserScript( | |
| 398 | + | accentDetectionScript, | |
| 399 | + | accentDetectionInjectionMode, | |
| 400 | + | webkit.UserScriptInjectAtDocumentEndValue, | |
| 401 | + | nil, // all pages | |
| 402 | + | nil, | |
| 403 | + | ), | |
| 404 | + | "accent-key-detection", | |
| 405 | + | ) | |
| 406 | + | ||
| 336 | 407 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | |
| 337 | 408 | } | |
| @@ -141,6 +141,64 @@ 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 | + | // Check if the event target is an editable element (input, textarea, contenteditable) | |||
| 154 | + | function isEditableTarget(target) { | |||
| 155 | + | const el = target instanceof Element ? target : null; | |||
| 156 | + | if (!el) return false; | |||
| 157 | + | if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true; | |||
| 158 | + | if (el.closest('textarea')) return true; | |||
| 159 | + | const input = el.closest('input'); | |||
| 160 | + | if (!input) return false; | |||
| 161 | + | // Exclude non-text input types | |||
| 162 | + | return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(input.type); | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | document.addEventListener('keydown', function(e) { | |||
| 166 | + | if (!isEditableTarget(e.target)) return; | |||
| 167 | + | ||||
| 168 | + | // Only handle a-z keys, with optional Shift | |||
| 169 | + | // Skip if Ctrl or Alt are held (those are shortcuts) | |||
| 170 | + | if (e.ctrlKey || e.altKey || e.metaKey) return; | |||
| 171 | + | ||||
| 172 | + | // Only single character keys a-z | |||
| 173 | + | const key = e.key.toLowerCase(); | |||
| 174 | + | if (key.length !== 1 || key < 'a' || key > 'z') return; | |||
| 175 | + | ||||
| 176 | + | pressedKey = key; | |||
| 177 | + | ||||
| 178 | + | // Send to Go for accent detection | |||
| 179 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 180 | + | type: 'accent_key_press', | |||
| 181 | + | payload: { char: key, shift: e.shiftKey } | |||
| 182 | + | }); | |||
| 183 | + | }, true); // capture phase to see events first | |||
| 184 | + | ||||
| 185 | + | document.addEventListener('keyup', function(e) { | |||
| 186 | + | if (!isEditableTarget(e.target)) return; | |||
| 187 | + | const key = e.key.toLowerCase(); | |||
| 188 | + | if (key === pressedKey) { | |||
| 189 | + | pressedKey = null; | |||
| 190 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 191 | + | type: 'accent_key_release', | |||
| 192 | + | payload: { char: key } | |||
| 193 | + | }); | |||
| 194 | + | } | |||
| 195 | + | }, true); | |||
| 196 | + | })();` | |||
| 197 | + | ||||
| 198 | + | // accentDetectionInjectionMode controls which frames receive the accent detection script. | |||
| 199 | + | // Set to AllFrames so the script runs in iframes as well as the top-level document. | |||
| 200 | + | const accentDetectionInjectionMode = webkit.UserContentInjectAllFramesValue | |||
| 201 | + | ||||
| 144 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | 202 | // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names. | ||
| 145 | // Some pages gate support on window.RTCPeerConnection and report false negatives | 203 | // Some pages gate support on window.RTCPeerConnection and report false negatives | ||
| 146 | // when only webkit-prefixed constructors are present. | 204 | // when only webkit-prefixed constructors are present. | ||
| @@ -333,5 +391,18 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 333 | log.Debug().Msg("auto-copy selection script injected") | 391 | log.Debug().Msg("auto-copy selection script injected") | ||
| 334 | } | 392 | } | ||
| 335 | 393 | ||||
| 394 | + | // 7. Inject accent key detection for all pages and all frames (unconditional). | |||
| 395 | + | // JS only reports keydown/keyup events; Go handles timing and picker display. | |||
| 396 | + | addScript( | |||
| 397 | + | webkit.NewUserScript( | |||
| 398 | + | accentDetectionScript, | |||
| 399 | + | accentDetectionInjectionMode, | |||
| 400 | + | webkit.UserScriptInjectAtDocumentEndValue, | |||
| 401 | + | nil, // all pages | |||
| 402 | + | nil, | |||
| 403 | + | ), | |||
| 404 | + | "accent-key-detection", | |||
| 405 | + | ) | |||
| 406 | + | ||||
| 336 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | 407 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | ||
| 337 | } | 408 | } | ||
A internal/infrastructure/webkit/injector_accent_test.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // TestAccentDetectionScriptInjectionMode verifies that the accent detection | |
| 11 | + | // script is configured for all-frames injection (not top-frame only). | |
| 12 | + | // This ensures the script runs in iframes as well as the top-level document. | |
| 13 | + | func TestAccentDetectionScriptInjectionMode(t *testing.T) { | |
| 14 | + | assert.Equal(t, | |
| 15 | + | webkit.UserContentInjectAllFramesValue, | |
| 16 | + | accentDetectionInjectionMode, | |
| 17 | + | "accent detection script must inject into all frames, not just the top frame", | |
| 18 | + | ) | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // TestAccentDetectionScriptInjectionMode verifies that the accent detection | |||
| 11 | + | // script is configured for all-frames injection (not top-frame only). | |||
| 12 | + | // This ensures the script runs in iframes as well as the top-level document. | |||
| 13 | + | func TestAccentDetectionScriptInjectionMode(t *testing.T) { | |||
| 14 | + | assert.Equal(t, | |||
| 15 | + | webkit.UserContentInjectAllFramesValue, | |||
| 16 | + | accentDetectionInjectionMode, | |||
| 17 | + | "accent detection script must inject into all frames, not just the top frame", | |||
| 18 | + | ) | |||
| 19 | + | } | |||
M internal/ui/app.go +99 -11
| @@ -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,23 @@ 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 | + | } else { | |
| 559 | + | log.Info().Msg("registered accent key handlers") | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | ||
| 544 | 563 | func (a *App) initDownloadHandler(ctx context.Context) { | |
| 545 | 564 | log := logging.FromContext(ctx) | |
| 546 | 565 | ||
| @@ -631,23 +650,53 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 631 | 650 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | |
| 632 | 651 | a.handleModeChange(ctx, from, to) | |
| 633 | 652 | }) | |
| 634 | - | a.keyboardHandler.SetShouldBypassInput(func(modifiers input.Modifier) bool { | |
| 635 | - | // Bypass keyboard handler when modals are visible | |
| 653 | + | // Wire key routing policy. | |
| 654 | + | // Determines how each key event should be handled based on UI state: | |
| 655 | + | // - Overlays visible (session manager, tab picker): pass all keys to widget | |
| 656 | + | // - Omnibox visible: accent detection for text keys, shortcuts for modified keys | |
| 657 | + | // - WebView focused: pass text/dead keys to WebKit IM for native compose, | |
| 658 | + | // intercept shortcut-modified keys (Ctrl+, Alt+) | |
| 659 | + | // - Default: handle through shortcut system | |
| 660 | + | a.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 661 | + | // Overlays take full keyboard control | |
| 636 | 662 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | |
| 637 | - | return true | |
| 663 | + | return input.RoutePassToWidget | |
| 638 | 664 | } | |
| 639 | 665 | if a.tabPicker != nil && a.tabPicker.IsVisible() { | |
| 640 | - | return true | |
| 666 | + | return input.RoutePassToWidget | |
| 641 | 667 | } | |
| 668 | + | ||
| 642 | 669 | wsView := a.activeWorkspaceView() | |
| 643 | 670 | if wsView == nil { | |
| 644 | - | return false | |
| 671 | + | return input.RouteHandleShortcuts | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | // Check actual focus rather than visibility: find bar can remain | |
| 675 | + | // visible after focus returns to the WebView, so visibility alone | |
| 676 | + | // would misroute keys. | |
| 677 | + | if a.accentFocusProvider != nil { | |
| 678 | + | if _, ok := a.accentFocusProvider.GetFocusedInput().(*textinput.GTKEntryTarget); ok { | |
| 679 | + | // GTK Entry focused (omnibox or find bar): Alt-modified keys go to | |
| 680 | + | // shortcuts (pane navigation), other keys pass through to the widget's | |
| 681 | + | // own capture-phase key controller (which handles accent detection) | |
| 682 | + | if kc.Modifiers&input.ModAlt != 0 { | |
| 683 | + | return input.RouteHandleShortcuts | |
| 684 | + | } | |
| 685 | + | return input.RoutePassToWidget | |
| 686 | + | } | |
| 645 | 687 | } | |
| 646 | - | if wsView.IsOmniboxVisible() { | |
| 647 | - | // Let Alt-modified keys through for pane navigation | |
| 648 | - | return modifiers&input.ModAlt == 0 | |
| 688 | + | ||
| 689 | + | // WebView focused: text/dead keys pass through for native IM compose, | |
| 690 | + | // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system | |
| 691 | + | if input.IsShortcutModified(kc.Modifiers) { | |
| 692 | + | return input.RouteHandleShortcuts | |
| 649 | 693 | } | |
| 650 | - | return false | |
| 694 | + | if input.IsTextInputKey(kc.Keyval) { | |
| 695 | + | return input.RoutePassToWidget | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system | |
| 699 | + | return input.RouteHandleShortcuts | |
| 651 | 700 | }) | |
| 652 | 701 | // Wire accent handler for dead keys support | |
| 653 | 702 | if a.insertAccentUC != nil { | |
| @@ -668,6 +717,33 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 668 | 717 | ) | |
| 669 | 718 | } | |
| 670 | 719 | ||
| 720 | + | // handleAccentKeyPress handles accent key press events for GTK entry widgets | |
| 721 | + | // (omnibox and find bar). Returns true if the key was consumed. | |
| 722 | + | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | |
| 723 | + | if a.insertAccentUC == nil { | |
| 724 | + | return false | |
| 725 | + | } | |
| 726 | + | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |
| 727 | + | return false | |
| 728 | + | } | |
| 729 | + | shiftHeld := state&gdk.ShiftMaskValue != 0 | |
| 730 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 731 | + | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |
| 732 | + | } | |
| 733 | + | return false | |
| 734 | + | } | |
| 735 | + | ||
| 736 | + | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | |
| 737 | + | // (omnibox and find bar). | |
| 738 | + | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |
| 739 | + | if a.insertAccentUC == nil { | |
| 740 | + | return | |
| 741 | + | } | |
| 742 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |
| 743 | + | a.insertAccentUC.OnKeyReleased(ctx, char) | |
| 744 | + | } | |
| 745 | + | } | |
| 746 | + | ||
| 671 | 747 | func (a *App) initOmniboxConfig(ctx context.Context) { | |
| 672 | 748 | if a.deps == nil || a.deps.Config == nil { | |
| 673 | 749 | return | |
| @@ -712,12 +788,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 712 | 788 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 713 | 789 | } | |
| 714 | 790 | }, | |
| 791 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |
| 792 | + | return a.handleAccentKeyPress(ctx, keyval, state) | |
| 793 | + | }, | |
| 794 | + | OnAccentKeyRelease: func(keyval uint) { | |
| 795 | + | a.handleAccentKeyRelease(ctx, keyval) | |
| 796 | + | }, | |
| 715 | 797 | } | |
| 716 | 798 | a.navCoord.SetOmniboxProvider(a) | |
| 717 | 799 | log.Debug().Msg("omnibox config stored, provider set") | |
| 718 | 800 | } | |
| 719 | 801 | ||
| 720 | - | func (a *App) initFindBarConfig() { | |
| 802 | + | func (a *App) initFindBarConfig(ctx context.Context) { | |
| 721 | 803 | // Store find bar config (find bar is created per-pane via WorkspaceView). | |
| 722 | 804 | a.findBarCfg = component.FindBarConfig{ | |
| 723 | 805 | GetFindController: func(paneID entity.PaneID) port.FindController { | |
| @@ -742,6 +824,12 @@ func (a *App) initFindBarConfig() { | |||
| 742 | 824 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 743 | 825 | } | |
| 744 | 826 | }, | |
| 827 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |
| 828 | + | return a.handleAccentKeyPress(ctx, keyval, state) | |
| 829 | + | }, | |
| 830 | + | OnAccentKeyRelease: func(keyval uint) { | |
| 831 | + | a.handleAccentKeyRelease(ctx, keyval) | |
| 832 | + | }, | |
| 745 | 833 | } | |
| 746 | 834 | } | |
| 747 | 835 | ||
| @@ -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,23 @@ 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 | + | } else { | |||
| 559 | + | log.Info().Msg("registered accent key handlers") | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | ||||
| 544 | func (a *App) initDownloadHandler(ctx context.Context) { | 563 | func (a *App) initDownloadHandler(ctx context.Context) { | ||
| 545 | log := logging.FromContext(ctx) | 564 | log := logging.FromContext(ctx) | ||
| 546 | 565 | ||||
| @@ -631,23 +650,53 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 631 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | 650 | a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) { | ||
| 632 | a.handleModeChange(ctx, from, to) | 651 | a.handleModeChange(ctx, from, to) | ||
| 633 | }) | 652 | }) | ||
| 634 | - | a.keyboardHandler.SetShouldBypassInput(func(modifiers input.Modifier) bool { | 653 | + | // Wire key routing policy. |
| 635 | - | // Bypass keyboard handler when modals are visible | 654 | + | // Determines how each key event should be handled based on UI state: |
| 655 | + | // - Overlays visible (session manager, tab picker): pass all keys to widget | |||
| 656 | + | // - Omnibox visible: accent detection for text keys, shortcuts for modified keys | |||
| 657 | + | // - WebView focused: pass text/dead keys to WebKit IM for native compose, | |||
| 658 | + | // intercept shortcut-modified keys (Ctrl+, Alt+) | |||
| 659 | + | // - Default: handle through shortcut system | |||
| 660 | + | a.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |||
| 661 | + | // Overlays take full keyboard control | |||
| 636 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | 662 | if a.sessionManager != nil && a.sessionManager.IsVisible() { | ||
| 637 | - | return true | 663 | + | return input.RoutePassToWidget |
| 638 | } | 664 | } | ||
| 639 | if a.tabPicker != nil && a.tabPicker.IsVisible() { | 665 | if a.tabPicker != nil && a.tabPicker.IsVisible() { | ||
| 640 | - | return true | 666 | + | return input.RoutePassToWidget |
| 641 | } | 667 | } | ||
| 668 | + | ||||
| 642 | wsView := a.activeWorkspaceView() | 669 | wsView := a.activeWorkspaceView() | ||
| 643 | if wsView == nil { | 670 | if wsView == nil { | ||
| 644 | - | return false | 671 | + | return input.RouteHandleShortcuts |
| 672 | + | } | |||
| 673 | + | ||||
| 674 | + | // Check actual focus rather than visibility: find bar can remain | |||
| 675 | + | // visible after focus returns to the WebView, so visibility alone | |||
| 676 | + | // would misroute keys. | |||
| 677 | + | if a.accentFocusProvider != nil { | |||
| 678 | + | if _, ok := a.accentFocusProvider.GetFocusedInput().(*textinput.GTKEntryTarget); ok { | |||
| 679 | + | // GTK Entry focused (omnibox or find bar): Alt-modified keys go to | |||
| 680 | + | // shortcuts (pane navigation), other keys pass through to the widget's | |||
| 681 | + | // own capture-phase key controller (which handles accent detection) | |||
| 682 | + | if kc.Modifiers&input.ModAlt != 0 { | |||
| 683 | + | return input.RouteHandleShortcuts | |||
| 684 | + | } | |||
| 685 | + | return input.RoutePassToWidget | |||
| 686 | + | } | |||
| 645 | } | 687 | } | ||
| 646 | - | if wsView.IsOmniboxVisible() { | 688 | + | |
| 647 | - | // Let Alt-modified keys through for pane navigation | 689 | + | // WebView focused: text/dead keys pass through for native IM compose, |
| 648 | - | return modifiers&input.ModAlt == 0 | 690 | + | // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system |
| 691 | + | if input.IsShortcutModified(kc.Modifiers) { | |||
| 692 | + | return input.RouteHandleShortcuts | |||
| 649 | } | 693 | } | ||
| 650 | - | return false | 694 | + | if input.IsTextInputKey(kc.Keyval) { |
| 695 | + | return input.RoutePassToWidget | |||
| 696 | + | } | |||
| 697 | + | ||||
| 698 | + | // Non-text, non-shortcut keys (F-keys, arrows, etc.): shortcut system | |||
| 699 | + | return input.RouteHandleShortcuts | |||
| 651 | }) | 700 | }) | ||
| 652 | // Wire accent handler for dead keys support | 701 | // Wire accent handler for dead keys support | ||
| 653 | if a.insertAccentUC != nil { | 702 | if a.insertAccentUC != nil { | ||
| @@ -668,6 +717,33 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 668 | ) | 717 | ) | ||
| 669 | } | 718 | } | ||
| 670 | 719 | ||||
| 720 | + | // handleAccentKeyPress handles accent key press events for GTK entry widgets | |||
| 721 | + | // (omnibox and find bar). Returns true if the key was consumed. | |||
| 722 | + | func (a *App) handleAccentKeyPress(ctx context.Context, keyval uint, state gdk.ModifierType) bool { | |||
| 723 | + | if a.insertAccentUC == nil { | |||
| 724 | + | return false | |||
| 725 | + | } | |||
| 726 | + | if state&(gdk.ControlMaskValue|gdk.AltMaskValue) != 0 { | |||
| 727 | + | return false | |||
| 728 | + | } | |||
| 729 | + | shiftHeld := state&gdk.ShiftMaskValue != 0 | |||
| 730 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |||
| 731 | + | return a.insertAccentUC.OnKeyPressed(ctx, char, shiftHeld) | |||
| 732 | + | } | |||
| 733 | + | return false | |||
| 734 | + | } | |||
| 735 | + | ||||
| 736 | + | // handleAccentKeyRelease handles accent key release events for GTK entry widgets | |||
| 737 | + | // (omnibox and find bar). | |||
| 738 | + | func (a *App) handleAccentKeyRelease(ctx context.Context, keyval uint) { | |||
| 739 | + | if a.insertAccentUC == nil { | |||
| 740 | + | return | |||
| 741 | + | } | |||
| 742 | + | if char := input.KeyvalToRune(keyval); char != 0 { | |||
| 743 | + | a.insertAccentUC.OnKeyReleased(ctx, char) | |||
| 744 | + | } | |||
| 745 | + | } | |||
| 746 | + | ||||
| 671 | func (a *App) initOmniboxConfig(ctx context.Context) { | 747 | func (a *App) initOmniboxConfig(ctx context.Context) { | ||
| 672 | if a.deps == nil || a.deps.Config == nil { | 748 | if a.deps == nil || a.deps.Config == nil { | ||
| 673 | return | 749 | return | ||
| @@ -712,12 +788,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 712 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | 788 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | ||
| 713 | } | 789 | } | ||
| 714 | }, | 790 | }, | ||
| 791 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |||
| 792 | + | return a.handleAccentKeyPress(ctx, keyval, state) | |||
| 793 | + | }, | |||
| 794 | + | OnAccentKeyRelease: func(keyval uint) { | |||
| 795 | + | a.handleAccentKeyRelease(ctx, keyval) | |||
| 796 | + | }, | |||
| 715 | } | 797 | } | ||
| 716 | a.navCoord.SetOmniboxProvider(a) | 798 | a.navCoord.SetOmniboxProvider(a) | ||
| 717 | log.Debug().Msg("omnibox config stored, provider set") | 799 | log.Debug().Msg("omnibox config stored, provider set") | ||
| 718 | } | 800 | } | ||
| 719 | 801 | ||||
| 720 | - | func (a *App) initFindBarConfig() { | 802 | + | func (a *App) initFindBarConfig(ctx context.Context) { |
| 721 | // Store find bar config (find bar is created per-pane via WorkspaceView). | 803 | // Store find bar config (find bar is created per-pane via WorkspaceView). | ||
| 722 | a.findBarCfg = component.FindBarConfig{ | 804 | a.findBarCfg = component.FindBarConfig{ | ||
| 723 | GetFindController: func(paneID entity.PaneID) port.FindController { | 805 | GetFindController: func(paneID entity.PaneID) port.FindController { | ||
| @@ -742,6 +824,12 @@ func (a *App) initFindBarConfig() { | |||||
| 742 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | 824 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | ||
| 743 | } | 825 | } | ||
| 744 | }, | 826 | }, | ||
| 827 | + | OnAccentKeyPress: func(keyval uint, state gdk.ModifierType) bool { | |||
| 828 | + | return a.handleAccentKeyPress(ctx, keyval, state) | |||
| 829 | + | }, | |||
| 830 | + | OnAccentKeyRelease: func(keyval uint) { | |||
| 831 | + | a.handleAccentKeyRelease(ctx, keyval) | |||
| 832 | + | }, | |||
| 745 | } | 833 | } | ||
| 746 | } | 834 | } | ||
| 747 | 835 | ||||
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/coordinator/navigation.go +1 -1
| @@ -85,8 +85,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error | |||
| 85 | 85 | // Track original URL for cross-domain redirect favicon caching | |
| 86 | 86 | c.contentCoord.SetNavigationOrigin(activePaneID, url) | |
| 87 | 87 | // Pre-load cached favicon asynchronously (don't block navigation start) | |
| 88 | - | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |
| 89 | 88 | go func() { | |
| 89 | + | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |
| 90 | 90 | defer cancelPreload() | |
| 91 | 91 | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | |
| 92 | 92 | }() | |
| @@ -85,8 +85,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error | |||||
| 85 | // Track original URL for cross-domain redirect favicon caching | 85 | // Track original URL for cross-domain redirect favicon caching | ||
| 86 | c.contentCoord.SetNavigationOrigin(activePaneID, url) | 86 | c.contentCoord.SetNavigationOrigin(activePaneID, url) | ||
| 87 | // Pre-load cached favicon asynchronously (don't block navigation start) | 87 | // Pre-load cached favicon asynchronously (don't block navigation start) | ||
| 88 | - | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |||
| 89 | go func() { | 88 | go func() { | ||
| 89 | + | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |||
| 90 | defer cancelPreload() | 90 | defer cancelPreload() | ||
| 91 | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | 91 | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | ||
| 92 | }() | 92 | }() | ||
M internal/ui/input/keyboard.go +143 -28
| @@ -1,5 +1,23 @@ | |||
| 1 | 1 | package input | |
| 2 | 2 | ||
| 3 | + | // Manual Testing: Dead Key / Compose Support | |
| 4 | + | // | |
| 5 | + | // To verify native dead key support works correctly: | |
| 6 | + | // | |
| 7 | + | // 1. Set keyboard layout to "US International with dead keys" | |
| 8 | + | // 2. Open dumber, navigate to a page with a text input (e.g. Google search) | |
| 9 | + | // 3. Click into the text input field | |
| 10 | + | // 4. Type: ' then e -- should produce e with acute accent | |
| 11 | + | // 5. Type: " then u -- should produce u with diaeresis | |
| 12 | + | // 6. Type: ` then a -- should produce a with grave accent | |
| 13 | + | // 7. Type: ~ then n -- should produce n with tilde | |
| 14 | + | // 8. Verify Ctrl+L still opens omnibox | |
| 15 | + | // 9. Verify Alt+1-9 still switches tabs | |
| 16 | + | // 10. Verify modal modes (Ctrl+T for tab mode) still work | |
| 17 | + | // 11. In omnibox, verify long-press accent picker still works (hold 'e' for 400ms) | |
| 18 | + | // 12. Verify Escape closes omnibox / exits modal modes | |
| 19 | + | // 13. Verify session manager overlay captures all keyboard input | |
| 20 | + | ||
| 3 | 21 | import ( | |
| 4 | 22 | "context" | |
| 5 | 23 | "sync" | |
| @@ -40,8 +58,10 @@ type KeyboardHandler struct { | |||
| 40 | 58 | ||
| 41 | 59 | // Action handler callback | |
| 42 | 60 | onAction ActionHandler | |
| 43 | - | // Optional bypass check (e.g., omnibox visible) | |
| 44 | - | shouldBypass func(modifiers Modifier) bool | |
| 61 | + | // Optional routing callback that determines how a key should be handled. | |
| 62 | + | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused | |
| 63 | + | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | |
| 64 | + | routeKey func(KeyContext) KeyRoute | |
| 45 | 65 | // Optional accent handler for dead keys support | |
| 46 | 66 | accentHandler AccentHandler | |
| 47 | 67 | // Optional escape hook for app-level overlays | |
| @@ -99,12 +119,16 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||
| 99 | 119 | h.modal.SetOnModeChange(fn) | |
| 100 | 120 | } | |
| 101 | 121 | ||
| 102 | - | // SetShouldBypassInput sets a hook to bypass keyboard handling entirely. | |
| 103 | - | // When true, events propagate to focused widgets instead. | |
| 104 | - | func (h *KeyboardHandler) SetShouldBypassInput(fn func(modifiers Modifier) bool) { | |
| 122 | + | // SetRouteKey sets the callback that determines how each key event should be routed. | |
| 123 | + | // The callback receives key context and returns a KeyRoute indicating whether the | |
| 124 | + | // key should be handled by the shortcut system, passed to the focused widget | |
| 125 | + | // (for IM/compose support), or routed through accent detection. | |
| 126 | + | // | |
| 127 | + | // If not set, all keys are routed through the shortcut system (RouteHandleShortcuts). | |
| 128 | + | func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) { | |
| 105 | 129 | h.mu.Lock() | |
| 106 | 130 | defer h.mu.Unlock() | |
| 107 | - | h.shouldBypass = fn | |
| 131 | + | h.routeKey = fn | |
| 108 | 132 | } | |
| 109 | 133 | ||
| 110 | 134 | // SetAccentHandler sets the handler for long-press accent detection. | |
| @@ -128,7 +152,10 @@ func (h *KeyboardHandler) Mode() Mode { | |||
| 128 | 152 | } | |
| 129 | 153 | ||
| 130 | 154 | // AttachTo attaches the keyboard handler to a GTK window. | |
| 131 | - | // 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 recognizes as | |
| 158 | + | // text input; unhandled events bubble up to this controller. | |
| 132 | 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |
| 133 | 160 | log := logging.FromContext(h.ctx) | |
| 134 | 161 | ||
| @@ -143,8 +170,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 143 | 170 | return | |
| 144 | 171 | } | |
| 145 | 172 | ||
| 146 | - | // Set capture phase to intercept events before WebView gets them | |
| 147 | - | 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) | |
| 148 | 177 | ||
| 149 | 178 | // Connect key pressed handler (retain callback to prevent GC). | |
| 150 | 179 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | |
| @@ -176,42 +205,61 @@ func (h *KeyboardHandler) Detach() { | |||
| 176 | 205 | ||
| 177 | 206 | // handleKeyPress processes a key press event. | |
| 178 | 207 | // Returns true if the event was handled and should not propagate further. | |
| 179 | - | // Parameters: | |
| 180 | - | // - keyval: the translated key value (depends on keyboard layout) | |
| 181 | - | // - keycode: the hardware keycode (physical key position, layout-independent) | |
| 182 | - | // - state: modifier keys state | |
| 183 | 208 | func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 184 | 209 | log := logging.FromContext(h.ctx) | |
| 185 | 210 | ||
| 186 | 211 | h.mu.RLock() | |
| 187 | - | shouldBypass := h.shouldBypass | |
| 212 | + | routeKey := h.routeKey | |
| 188 | 213 | accentHandler := h.accentHandler | |
| 189 | 214 | onEscape := h.onEscape | |
| 190 | 215 | h.mu.RUnlock() | |
| 191 | 216 | ||
| 192 | - | // Check if accent picker is visible - it takes priority | |
| 217 | + | // Accent picker takes absolute priority when visible -- it has its own key controller | |
| 193 | 218 | if accentHandler != nil && accentHandler.IsPickerVisible() { | |
| 194 | - | return false // Let the accent picker handle the key via its own controller | |
| 219 | + | return false | |
| 195 | 220 | } | |
| 196 | 221 | ||
| 197 | 222 | modifiers := Modifier(state) & modifierMask | |
| 223 | + | ||
| 224 | + | // Escape in normal mode: check app-level escape hook first | |
| 198 | 225 | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | |
| 199 | 226 | if onEscape != nil && onEscape(h.ctx) { | |
| 200 | 227 | return true | |
| 201 | 228 | } | |
| 202 | 229 | } | |
| 203 | 230 | ||
| 204 | - | // Check accent detection first - works in both normal and bypass mode | |
| 205 | - | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | |
| 206 | - | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |
| 207 | - | return true | |
| 231 | + | // Determine routing for this key event | |
| 232 | + | route := RouteHandleShortcuts // default: process through shortcut system | |
| 233 | + | if routeKey != nil && h.modal.Mode() == ModeNormal { | |
| 234 | + | route = routeKey(KeyContext{ | |
| 235 | + | Keyval: keyval, | |
| 236 | + | Keycode: keycode, | |
| 237 | + | Modifiers: modifiers, | |
| 238 | + | }) | |
| 208 | 239 | } | |
| 209 | 240 | ||
| 210 | - | if shouldBypass != nil && shouldBypass(modifiers) { | |
| 211 | - | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | |
| 241 | + | switch route { | |
| 242 | + | case RoutePassToWidget: | |
| 243 | + | // Let the focused widget handle this key directly (WebView IM, etc.) | |
| 244 | + | // No accent detection here -- WebView has its own JS-based accent detection. | |
| 245 | + | log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget") | |
| 246 | + | return false | |
| 247 | + | ||
| 248 | + | case RouteAccentDetection: | |
| 249 | + | // Try long-press accent detection for GTK Entry widgets (omnibox, find bar). | |
| 250 | + | // If the accent handler doesn't consume the key, pass it to the widget. | |
| 251 | + | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |
| 252 | + | return true | |
| 253 | + | } | |
| 254 | + | log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget") | |
| 212 | 255 | return false | |
| 256 | + | ||
| 257 | + | case RouteHandleShortcuts: | |
| 258 | + | // Fall through to shortcut processing below | |
| 213 | 259 | } | |
| 214 | 260 | ||
| 261 | + | // --- Shortcut processing path --- | |
| 262 | + | ||
| 215 | 263 | // Normalize uppercase letters for consistent binding lookup | |
| 216 | 264 | keyval = normalizeKeyval(keyval) | |
| 217 | 265 | ||
| @@ -227,16 +275,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 227 | 275 | } | |
| 228 | 276 | ||
| 229 | 277 | // tryAccentDetection starts long-press detection for accent-eligible keys. | |
| 230 | - | // Returns true if the key should be suppressed (blocked from reaching the WebView). | |
| 278 | + | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as | |
| 279 | + | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; | |
| 280 | + | // WebView long-press accent behavior is handled entirely by the injected JS bridge | |
| 281 | + | // (accentDetectionScript) and the Go-side accent key detection script, not here. | |
| 282 | + | // Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it). | |
| 231 | 283 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | |
| 232 | - | if h.modal.Mode() != ModeNormal || accentHandler == nil { | |
| 284 | + | if accentHandler == nil { | |
| 233 | 285 | return false | |
| 234 | 286 | } | |
| 235 | 287 | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | |
| 236 | 288 | if modifiers != 0 && modifiers != ModShift { | |
| 237 | 289 | return false | |
| 238 | 290 | } | |
| 239 | - | if char := keyvalToRune(keyval); char != 0 { | |
| 291 | + | if char := KeyvalToRune(keyval); char != 0 { | |
| 240 | 292 | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | |
| 241 | 293 | } | |
| 242 | 294 | return false | |
| @@ -407,14 +459,14 @@ func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |||
| 407 | 459 | } | |
| 408 | 460 | ||
| 409 | 461 | // Convert keyval to rune for the accent handler | |
| 410 | - | if char := keyvalToRune(keyval); char != 0 { | |
| 462 | + | if char := KeyvalToRune(keyval); char != 0 { | |
| 411 | 463 | accentHandler.OnKeyReleased(h.ctx, char) | |
| 412 | 464 | } | |
| 413 | 465 | } | |
| 414 | 466 | ||
| 415 | - | // keyvalToRune converts a GTK keyval to a lowercase rune. | |
| 467 | + | // KeyvalToRune converts a GTK keyval to a lowercase rune. | |
| 416 | 468 | // Returns 0 if the keyval is not a printable character with potential accents. | |
| 417 | - | func keyvalToRune(keyval uint) rune { | |
| 469 | + | func KeyvalToRune(keyval uint) rune { | |
| 418 | 470 | // Handle lowercase a-z | |
| 419 | 471 | if keyval >= uint('a') && keyval <= uint('z') { | |
| 420 | 472 | return rune(keyval) | |
| @@ -425,3 +477,66 @@ func keyvalToRune(keyval uint) rune { | |||
| 425 | 477 | } | |
| 426 | 478 | return 0 | |
| 427 | 479 | } | |
| 480 | + | ||
| 481 | + | // KeyRoute determines how a key event should be routed. | |
| 482 | + | type KeyRoute int | |
| 483 | + | ||
| 484 | + | const ( | |
| 485 | + | // RouteHandleShortcuts means the key should be processed by the shortcut system. | |
| 486 | + | // This is the default route -- app-level shortcuts, modal mode keys, etc. | |
| 487 | + | RouteHandleShortcuts KeyRoute = iota | |
| 488 | + | ||
| 489 | + | // RoutePassToWidget means the key should propagate to the focused widget. | |
| 490 | + | // Used when overlays (session manager, tab picker) are visible, or when a | |
| 491 | + | // WebView should receive text input for native IM/compose/dead-key processing. | |
| 492 | + | // Long-press accent detection still runs first for accessibility: users with | |
| 493 | + | // motor disabilities may not be able to execute rapid compose sequences. | |
| 494 | + | RoutePassToWidget | |
| 495 | + | ||
| 496 | + | // RouteAccentDetection means the key should go through long-press accent | |
| 497 | + | // detection, then pass to the focused widget. Used for GTK Entry widgets | |
| 498 | + | // (omnibox, find bar) and as a universal accessibility fallback. | |
| 499 | + | RouteAccentDetection | |
| 500 | + | ) | |
| 501 | + | ||
| 502 | + | // KeyContext provides key event information for routing decisions. | |
| 503 | + | type KeyContext struct { | |
| 504 | + | Keyval uint // Translated key value | |
| 505 | + | Keycode uint // Hardware keycode | |
| 506 | + | Modifiers Modifier // Active modifiers (masked) | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | // IsShortcutModified returns true if the modifiers indicate a shortcut combination | |
| 510 | + | // (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only). | |
| 511 | + | // Dead keys and compose sequences only use no-modifier or Shift, so this correctly | |
| 512 | + | // identifies keys that should be intercepted vs passed through to the IM pipeline. | |
| 513 | + | func IsShortcutModified(modifiers Modifier) bool { | |
| 514 | + | return modifiers&(ModCtrl|ModAlt) != 0 | |
| 515 | + | } | |
| 516 | + | ||
| 517 | + | // IsTextInputKey returns true if the keyval represents a text input key | |
| 518 | + | // (printable character or dead key) as opposed to a function/navigation key. | |
| 519 | + | // This is used to determine whether a key should be passed through to the | |
| 520 | + | // WebKit IM pipeline for compose/dead-key processing. | |
| 521 | + | func IsTextInputKey(keyval uint) bool { | |
| 522 | + | // Dead keys used by compose sequences (US International, etc.) | |
| 523 | + | // GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c) | |
| 524 | + | if keyval >= 0xfe50 && keyval <= 0xfe8c { | |
| 525 | + | return true | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | // Printable ASCII range (space through tilde) | |
| 529 | + | if keyval >= 0x020 && keyval <= 0x07e { | |
| 530 | + | return true | |
| 531 | + | } | |
| 532 | + | // Latin-1 supplement (non-breaking space through ÿ) | |
| 533 | + | if keyval >= 0x0a0 && keyval <= 0x0ff { | |
| 534 | + | return true | |
| 535 | + | } | |
| 536 | + | // Extended Latin and other Unicode characters mapped in GDK keyval space | |
| 537 | + | if keyval >= 0x100 && keyval <= 0x20ff { | |
| 538 | + | return true | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | return false | |
| 542 | + | } | |
| @@ -1,5 +1,23 @@ | |||||
| 1 | package input | 1 | package input | ||
| 2 | 2 | ||||
| 3 | + | // Manual Testing: Dead Key / Compose Support | |||
| 4 | + | // | |||
| 5 | + | // To verify native dead key support works correctly: | |||
| 6 | + | // | |||
| 7 | + | // 1. Set keyboard layout to "US International with dead keys" | |||
| 8 | + | // 2. Open dumber, navigate to a page with a text input (e.g. Google search) | |||
| 9 | + | // 3. Click into the text input field | |||
| 10 | + | // 4. Type: ' then e -- should produce e with acute accent | |||
| 11 | + | // 5. Type: " then u -- should produce u with diaeresis | |||
| 12 | + | // 6. Type: ` then a -- should produce a with grave accent | |||
| 13 | + | // 7. Type: ~ then n -- should produce n with tilde | |||
| 14 | + | // 8. Verify Ctrl+L still opens omnibox | |||
| 15 | + | // 9. Verify Alt+1-9 still switches tabs | |||
| 16 | + | // 10. Verify modal modes (Ctrl+T for tab mode) still work | |||
| 17 | + | // 11. In omnibox, verify long-press accent picker still works (hold 'e' for 400ms) | |||
| 18 | + | // 12. Verify Escape closes omnibox / exits modal modes | |||
| 19 | + | // 13. Verify session manager overlay captures all keyboard input | |||
| 20 | + | ||||
| 3 | import ( | 21 | import ( | ||
| 4 | "context" | 22 | "context" | ||
| 5 | "sync" | 23 | "sync" | ||
| @@ -40,8 +58,10 @@ type KeyboardHandler struct { | |||||
| 40 | 58 | ||||
| 41 | // Action handler callback | 59 | // Action handler callback | ||
| 42 | onAction ActionHandler | 60 | onAction ActionHandler | ||
| 43 | - | // Optional bypass check (e.g., omnibox visible) | 61 | + | // Optional routing callback that determines how a key should be handled. |
| 44 | - | shouldBypass func(modifiers Modifier) bool | 62 | + | // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused |
| 63 | + | // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). | |||
| 64 | + | routeKey func(KeyContext) KeyRoute | |||
| 45 | // Optional accent handler for dead keys support | 65 | // Optional accent handler for dead keys support | ||
| 46 | accentHandler AccentHandler | 66 | accentHandler AccentHandler | ||
| 47 | // Optional escape hook for app-level overlays | 67 | // Optional escape hook for app-level overlays | ||
| @@ -99,12 +119,16 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { | |||||
| 99 | h.modal.SetOnModeChange(fn) | 119 | h.modal.SetOnModeChange(fn) | ||
| 100 | } | 120 | } | ||
| 101 | 121 | ||||
| 102 | - | // SetShouldBypassInput sets a hook to bypass keyboard handling entirely. | 122 | + | // SetRouteKey sets the callback that determines how each key event should be routed. |
| 103 | - | // When true, events propagate to focused widgets instead. | 123 | + | // The callback receives key context and returns a KeyRoute indicating whether the |
| 104 | - | func (h *KeyboardHandler) SetShouldBypassInput(fn func(modifiers Modifier) bool) { | 124 | + | // key should be handled by the shortcut system, passed to the focused widget |
| 125 | + | // (for IM/compose support), or routed through accent detection. | |||
| 126 | + | // | |||
| 127 | + | // If not set, all keys are routed through the shortcut system (RouteHandleShortcuts). | |||
| 128 | + | func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) { | |||
| 105 | h.mu.Lock() | 129 | h.mu.Lock() | ||
| 106 | defer h.mu.Unlock() | 130 | defer h.mu.Unlock() | ||
| 107 | - | h.shouldBypass = fn | 131 | + | h.routeKey = fn |
| 108 | } | 132 | } | ||
| 109 | 133 | ||||
| 110 | // SetAccentHandler sets the handler for long-press accent detection. | 134 | // SetAccentHandler sets the handler for long-press accent detection. | ||
| @@ -128,7 +152,10 @@ func (h *KeyboardHandler) Mode() Mode { | |||||
| 128 | } | 152 | } | ||
| 129 | 153 | ||||
| 130 | // AttachTo attaches the keyboard handler to a GTK window. | 154 | // AttachTo attaches the keyboard handler to a GTK window. | ||
| 131 | - | // 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 recognizes as | |||
| 158 | + | // text input; unhandled events bubble up to this controller. | |||
| 132 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | 159 | func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | ||
| 133 | log := logging.FromContext(h.ctx) | 160 | log := logging.FromContext(h.ctx) | ||
| 134 | 161 | ||||
| @@ -143,8 +170,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 143 | return | 170 | return | ||
| 144 | } | 171 | } | ||
| 145 | 172 | ||||
| 146 | - | // Set capture phase to intercept events before WebView gets them | 173 | + | // Bubble phase: let WebKit IM handle key events first (dead keys, compose |
| 147 | - | 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) | |||
| 148 | 177 | ||||
| 149 | // Connect key pressed handler (retain callback to prevent GC). | 178 | // Connect key pressed handler (retain callback to prevent GC). | ||
| 150 | // 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) | ||
| @@ -176,42 +205,61 @@ func (h *KeyboardHandler) Detach() { | |||||
| 176 | 205 | ||||
| 177 | // handleKeyPress processes a key press event. | 206 | // handleKeyPress processes a key press event. | ||
| 178 | // Returns true if the event was handled and should not propagate further. | 207 | // Returns true if the event was handled and should not propagate further. | ||
| 179 | - | // Parameters: | |||
| 180 | - | // - keyval: the translated key value (depends on keyboard layout) | |||
| 181 | - | // - keycode: the hardware keycode (physical key position, layout-independent) | |||
| 182 | - | // - state: modifier keys state | |||
| 183 | func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 208 | func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 184 | log := logging.FromContext(h.ctx) | 209 | log := logging.FromContext(h.ctx) | ||
| 185 | 210 | ||||
| 186 | h.mu.RLock() | 211 | h.mu.RLock() | ||
| 187 | - | shouldBypass := h.shouldBypass | 212 | + | routeKey := h.routeKey |
| 188 | accentHandler := h.accentHandler | 213 | accentHandler := h.accentHandler | ||
| 189 | onEscape := h.onEscape | 214 | onEscape := h.onEscape | ||
| 190 | h.mu.RUnlock() | 215 | h.mu.RUnlock() | ||
| 191 | 216 | ||||
| 192 | - | // Check if accent picker is visible - it takes priority | 217 | + | // Accent picker takes absolute priority when visible -- it has its own key controller |
| 193 | if accentHandler != nil && accentHandler.IsPickerVisible() { | 218 | if accentHandler != nil && accentHandler.IsPickerVisible() { | ||
| 194 | - | return false // Let the accent picker handle the key via its own controller | 219 | + | return false |
| 195 | } | 220 | } | ||
| 196 | 221 | ||||
| 197 | modifiers := Modifier(state) & modifierMask | 222 | modifiers := Modifier(state) & modifierMask | ||
| 223 | + | ||||
| 224 | + | // Escape in normal mode: check app-level escape hook first | |||
| 198 | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | 225 | if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { | ||
| 199 | if onEscape != nil && onEscape(h.ctx) { | 226 | if onEscape != nil && onEscape(h.ctx) { | ||
| 200 | return true | 227 | return true | ||
| 201 | } | 228 | } | ||
| 202 | } | 229 | } | ||
| 203 | 230 | ||||
| 204 | - | // Check accent detection first - works in both normal and bypass mode | 231 | + | // Determine routing for this key event |
| 205 | - | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | 232 | + | route := RouteHandleShortcuts // default: process through shortcut system |
| 206 | - | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | 233 | + | if routeKey != nil && h.modal.Mode() == ModeNormal { |
| 207 | - | return true | 234 | + | route = routeKey(KeyContext{ |
| 235 | + | Keyval: keyval, | |||
| 236 | + | Keycode: keycode, | |||
| 237 | + | Modifiers: modifiers, | |||
| 238 | + | }) | |||
| 208 | } | 239 | } | ||
| 209 | 240 | ||||
| 210 | - | if shouldBypass != nil && shouldBypass(modifiers) { | 241 | + | switch route { |
| 211 | - | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | 242 | + | case RoutePassToWidget: |
| 243 | + | // Let the focused widget handle this key directly (WebView IM, etc.) | |||
| 244 | + | // No accent detection here -- WebView has its own JS-based accent detection. | |||
| 245 | + | log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget") | |||
| 246 | + | return false | |||
| 247 | + | ||||
| 248 | + | case RouteAccentDetection: | |||
| 249 | + | // Try long-press accent detection for GTK Entry widgets (omnibox, find bar). | |||
| 250 | + | // If the accent handler doesn't consume the key, pass it to the widget. | |||
| 251 | + | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |||
| 252 | + | return true | |||
| 253 | + | } | |||
| 254 | + | log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget") | |||
| 212 | return false | 255 | return false | ||
| 256 | + | ||||
| 257 | + | case RouteHandleShortcuts: | |||
| 258 | + | // Fall through to shortcut processing below | |||
| 213 | } | 259 | } | ||
| 214 | 260 | ||||
| 261 | + | // --- Shortcut processing path --- | |||
| 262 | + | ||||
| 215 | // Normalize uppercase letters for consistent binding lookup | 263 | // Normalize uppercase letters for consistent binding lookup | ||
| 216 | keyval = normalizeKeyval(keyval) | 264 | keyval = normalizeKeyval(keyval) | ||
| 217 | 265 | ||||
| @@ -227,16 +275,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 227 | } | 275 | } | ||
| 228 | 276 | ||||
| 229 | // tryAccentDetection starts long-press detection for accent-eligible keys. | 277 | // tryAccentDetection starts long-press detection for accent-eligible keys. | ||
| 230 | - | // Returns true if the key should be suppressed (blocked from reaching the WebView). | 278 | + | // It is used only for the RouteAccentDetection route (GTK Entry widgets such as |
| 279 | + | // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; | |||
| 280 | + | // WebView long-press accent behavior is handled entirely by the injected JS bridge | |||
| 281 | + | // (accentDetectionScript) and the Go-side accent key detection script, not here. | |||
| 282 | + | // Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it). | |||
| 231 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | 283 | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | ||
| 232 | - | if h.modal.Mode() != ModeNormal || accentHandler == nil { | 284 | + | if accentHandler == nil { |
| 233 | return false | 285 | return false | ||
| 234 | } | 286 | } | ||
| 235 | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | 287 | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | ||
| 236 | if modifiers != 0 && modifiers != ModShift { | 288 | if modifiers != 0 && modifiers != ModShift { | ||
| 237 | return false | 289 | return false | ||
| 238 | } | 290 | } | ||
| 239 | - | if char := keyvalToRune(keyval); char != 0 { | 291 | + | if char := KeyvalToRune(keyval); char != 0 { |
| 240 | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | 292 | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | ||
| 241 | } | 293 | } | ||
| 242 | return false | 294 | return false | ||
| @@ -407,14 +459,14 @@ func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |||||
| 407 | } | 459 | } | ||
| 408 | 460 | ||||
| 409 | // Convert keyval to rune for the accent handler | 461 | // Convert keyval to rune for the accent handler | ||
| 410 | - | if char := keyvalToRune(keyval); char != 0 { | 462 | + | if char := KeyvalToRune(keyval); char != 0 { |
| 411 | accentHandler.OnKeyReleased(h.ctx, char) | 463 | accentHandler.OnKeyReleased(h.ctx, char) | ||
| 412 | } | 464 | } | ||
| 413 | } | 465 | } | ||
| 414 | 466 | ||||
| 415 | - | // keyvalToRune converts a GTK keyval to a lowercase rune. | 467 | + | // KeyvalToRune converts a GTK keyval to a lowercase rune. |
| 416 | // Returns 0 if the keyval is not a printable character with potential accents. | 468 | // Returns 0 if the keyval is not a printable character with potential accents. | ||
| 417 | - | func keyvalToRune(keyval uint) rune { | 469 | + | func KeyvalToRune(keyval uint) rune { |
| 418 | // Handle lowercase a-z | 470 | // Handle lowercase a-z | ||
| 419 | if keyval >= uint('a') && keyval <= uint('z') { | 471 | if keyval >= uint('a') && keyval <= uint('z') { | ||
| 420 | return rune(keyval) | 472 | return rune(keyval) | ||
| @@ -425,3 +477,66 @@ func keyvalToRune(keyval uint) rune { | |||||
| 425 | } | 477 | } | ||
| 426 | return 0 | 478 | return 0 | ||
| 427 | } | 479 | } | ||
| 480 | + | ||||
| 481 | + | // KeyRoute determines how a key event should be routed. | |||
| 482 | + | type KeyRoute int | |||
| 483 | + | ||||
| 484 | + | const ( | |||
| 485 | + | // RouteHandleShortcuts means the key should be processed by the shortcut system. | |||
| 486 | + | // This is the default route -- app-level shortcuts, modal mode keys, etc. | |||
| 487 | + | RouteHandleShortcuts KeyRoute = iota | |||
| 488 | + | ||||
| 489 | + | // RoutePassToWidget means the key should propagate to the focused widget. | |||
| 490 | + | // Used when overlays (session manager, tab picker) are visible, or when a | |||
| 491 | + | // WebView should receive text input for native IM/compose/dead-key processing. | |||
| 492 | + | // Long-press accent detection still runs first for accessibility: users with | |||
| 493 | + | // motor disabilities may not be able to execute rapid compose sequences. | |||
| 494 | + | RoutePassToWidget | |||
| 495 | + | ||||
| 496 | + | // RouteAccentDetection means the key should go through long-press accent | |||
| 497 | + | // detection, then pass to the focused widget. Used for GTK Entry widgets | |||
| 498 | + | // (omnibox, find bar) and as a universal accessibility fallback. | |||
| 499 | + | RouteAccentDetection | |||
| 500 | + | ) | |||
| 501 | + | ||||
| 502 | + | // KeyContext provides key event information for routing decisions. | |||
| 503 | + | type KeyContext struct { | |||
| 504 | + | Keyval uint // Translated key value | |||
| 505 | + | Keycode uint // Hardware keycode | |||
| 506 | + | Modifiers Modifier // Active modifiers (masked) | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | // IsShortcutModified returns true if the modifiers indicate a shortcut combination | |||
| 510 | + | // (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only). | |||
| 511 | + | // Dead keys and compose sequences only use no-modifier or Shift, so this correctly | |||
| 512 | + | // identifies keys that should be intercepted vs passed through to the IM pipeline. | |||
| 513 | + | func IsShortcutModified(modifiers Modifier) bool { | |||
| 514 | + | return modifiers&(ModCtrl|ModAlt) != 0 | |||
| 515 | + | } | |||
| 516 | + | ||||
| 517 | + | // IsTextInputKey returns true if the keyval represents a text input key | |||
| 518 | + | // (printable character or dead key) as opposed to a function/navigation key. | |||
| 519 | + | // This is used to determine whether a key should be passed through to the | |||
| 520 | + | // WebKit IM pipeline for compose/dead-key processing. | |||
| 521 | + | func IsTextInputKey(keyval uint) bool { | |||
| 522 | + | // Dead keys used by compose sequences (US International, etc.) | |||
| 523 | + | // GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c) | |||
| 524 | + | if keyval >= 0xfe50 && keyval <= 0xfe8c { | |||
| 525 | + | return true | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | // Printable ASCII range (space through tilde) | |||
| 529 | + | if keyval >= 0x020 && keyval <= 0x07e { | |||
| 530 | + | return true | |||
| 531 | + | } | |||
| 532 | + | // Latin-1 supplement (non-breaking space through ÿ) | |||
| 533 | + | if keyval >= 0x0a0 && keyval <= 0x0ff { | |||
| 534 | + | return true | |||
| 535 | + | } | |||
| 536 | + | // Extended Latin and other Unicode characters mapped in GDK keyval space | |||
| 537 | + | if keyval >= 0x100 && keyval <= 0x20ff { | |||
| 538 | + | return true | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | return false | |||
| 542 | + | } | |||
A internal/ui/input/keyboard_test.go +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // newTestConfig creates a minimal Config with activation shortcuts set. | |
| 13 | + | // Used by tests that construct a KeyboardHandler. | |
| 14 | + | func newTestConfig() *config.Config { | |
| 15 | + | cfg := &config.Config{} | |
| 16 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |
| 17 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |
| 18 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |
| 19 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |
| 20 | + | return cfg | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // stubAccentHandler is a test stub for the AccentHandler interface. | |
| 24 | + | type stubAccentHandler struct { | |
| 25 | + | onKeyPressedResult bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (s *stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { | |
| 29 | + | return s.onKeyPressedResult | |
| 30 | + | } | |
| 31 | + | func (s *stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {} | |
| 32 | + | func (s *stubAccentHandler) IsPickerVisible() bool { return false } | |
| 33 | + | func (s *stubAccentHandler) Cancel(_ context.Context) {} | |
| 34 | + | ||
| 35 | + | func TestIsShortcutModified(t *testing.T) { | |
| 36 | + | tests := []struct { | |
| 37 | + | name string | |
| 38 | + | mods Modifier | |
| 39 | + | expected bool | |
| 40 | + | }{ | |
| 41 | + | {"no modifier", ModNone, false}, | |
| 42 | + | {"shift only", ModShift, false}, | |
| 43 | + | {"ctrl", ModCtrl, true}, | |
| 44 | + | {"alt", ModAlt, true}, | |
| 45 | + | {"ctrl+shift", ModCtrl | ModShift, true}, | |
| 46 | + | {"alt+shift", ModAlt | ModShift, true}, | |
| 47 | + | {"ctrl+alt", ModCtrl | ModAlt, true}, | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |
| 52 | + | assert.Equal(t, tt.expected, IsShortcutModified(tt.mods)) | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestIsTextInputKey(t *testing.T) { | |
| 58 | + | tests := []struct { | |
| 59 | + | name string | |
| 60 | + | keyval uint | |
| 61 | + | expected bool | |
| 62 | + | }{ | |
| 63 | + | // Printable characters | |
| 64 | + | {"lowercase a", uint('a'), true}, | |
| 65 | + | {"lowercase z", uint('z'), true}, | |
| 66 | + | {"uppercase A", uint('A'), true}, | |
| 67 | + | {"digit 1", uint('1'), true}, | |
| 68 | + | {"space", 0x020, true}, | |
| 69 | + | {"period", uint('.'), true}, | |
| 70 | + | {"slash", uint('/'), true}, | |
| 71 | + | {"apostrophe", 0x027, true}, | |
| 72 | + | {"grave accent", 0x060, true}, | |
| 73 | + | {"tilde", 0x07e, true}, | |
| 74 | + | {"quotedbl", 0x022, true}, | |
| 75 | + | {"caret", 0x05e, true}, | |
| 76 | + | ||
| 77 | + | // Dead keys (used by US International layout) | |
| 78 | + | {"dead acute", 0xfe51, true}, | |
| 79 | + | {"dead grave", 0xfe50, true}, | |
| 80 | + | {"dead circumflex", 0xfe52, true}, | |
| 81 | + | {"dead tilde", 0xfe53, true}, | |
| 82 | + | {"dead diaeresis", 0xfe57, true}, | |
| 83 | + | ||
| 84 | + | // Dead key upper-bound boundary cases | |
| 85 | + | {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true}, | |
| 86 | + | {"0xfe8d - one past highest dead key", 0xfe8d, false}, | |
| 87 | + | ||
| 88 | + | // Non-text keys (should NOT be treated as text) | |
| 89 | + | {"escape", 0xff1b, false}, | |
| 90 | + | {"return", 0xff0d, false}, | |
| 91 | + | {"tab", 0xff09, false}, | |
| 92 | + | {"F1", 0xffbe, false}, | |
| 93 | + | {"F12", 0xffc9, false}, | |
| 94 | + | {"left arrow", 0xff51, false}, | |
| 95 | + | {"right arrow", 0xff53, false}, | |
| 96 | + | {"backspace", 0xff08, false}, | |
| 97 | + | {"delete", 0xffff, false}, | |
| 98 | + | {"home", 0xff50, false}, | |
| 99 | + | {"end", 0xff57, false}, | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | for _, tt := range tests { | |
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |
| 104 | + | assert.Equal(t, tt.expected, IsTextInputKey(tt.keyval), "keyval 0x%x", tt.keyval) | |
| 105 | + | }) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |
| 110 | + | ctx := context.Background() | |
| 111 | + | cfg := newTestConfig() | |
| 112 | + | ||
| 113 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 114 | + | ||
| 115 | + | // Route all non-shortcut keys to widget (simulates WebView text input) | |
| 116 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 117 | + | return RoutePassToWidget | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | tests := []struct { | |
| 121 | + | name string | |
| 122 | + | keyval uint | |
| 123 | + | state gdk.ModifierType | |
| 124 | + | }{ | |
| 125 | + | {"plain 'e'", uint('e'), 0}, | |
| 126 | + | {"plain 'a'", uint('a'), 0}, | |
| 127 | + | {"shift 'E'", uint('E'), gdk.ShiftMaskValue}, | |
| 128 | + | {"space", 0x020, 0}, | |
| 129 | + | {"digit 1", uint('1'), 0}, | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | for _, tt := range tests { | |
| 133 | + | t.Run(tt.name, func(t *testing.T) { | |
| 134 | + | result := h.handleKeyPress(tt.keyval, 0, tt.state) | |
| 135 | + | assert.False(t, result, "RoutePassToWidget should let key propagate (return false)") | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |
| 141 | + | ctx := context.Background() | |
| 142 | + | cfg := newTestConfig() | |
| 143 | + | ||
| 144 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 145 | + | ||
| 146 | + | // Set a stub accent handler that always returns false (simulates "no accents for this key") | |
| 147 | + | h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false}) | |
| 148 | + | ||
| 149 | + | // Route to accent detection | |
| 150 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 151 | + | return RouteAccentDetection | |
| 152 | + | }) | |
| 153 | + | ||
| 154 | + | // 'x' has no accents -> accent handler returns false -> key falls through | |
| 155 | + | result := h.handleKeyPress(uint('x'), 0, 0) | |
| 156 | + | assert.False(t, result, "'x' has no accents, should pass through") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |
| 160 | + | ctx := context.Background() | |
| 161 | + | cfg := newTestConfig() | |
| 162 | + | ||
| 163 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 164 | + | ||
| 165 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |
| 166 | + | return nil | |
| 167 | + | }) | |
| 168 | + | ||
| 169 | + | // Route text keys to widget, shortcuts to handler | |
| 170 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 171 | + | if IsShortcutModified(kc.Modifiers) { | |
| 172 | + | return RouteHandleShortcuts | |
| 173 | + | } | |
| 174 | + | return RoutePassToWidget | |
| 175 | + | }) | |
| 176 | + | ||
| 177 | + | // Ctrl+L = open omnibox (registered global shortcut) | |
| 178 | + | result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) | |
| 179 | + | assert.True(t, result, "Ctrl+L should be consumed by shortcut handler") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |
| 183 | + | ctx := context.Background() | |
| 184 | + | cfg := newTestConfig() | |
| 185 | + | ||
| 186 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 187 | + | h.EnterTabMode() | |
| 188 | + | ||
| 189 | + | // Even if routeKey says pass to widget, modal mode ignores it | |
| 190 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 191 | + | return RoutePassToWidget | |
| 192 | + | }) | |
| 193 | + | ||
| 194 | + | // In modal mode, unknown keys are consumed (return true) | |
| 195 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |
| 196 | + | assert.True(t, result, "modal mode should consume unknown keys regardless of route") | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func TestKeyboardHandler_SetRouteKey(t *testing.T) { | |
| 200 | + | ctx := context.Background() | |
| 201 | + | cfg := &config.Config{} | |
| 202 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 203 | + | ||
| 204 | + | // Default: no callback set | |
| 205 | + | h.mu.RLock() | |
| 206 | + | fn := h.routeKey | |
| 207 | + | h.mu.RUnlock() | |
| 208 | + | assert.Nil(t, fn, "routeKey should be nil by default") | |
| 209 | + | ||
| 210 | + | // Set callback | |
| 211 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| 212 | + | return RoutePassToWidget | |
| 213 | + | }) | |
| 214 | + | ||
| 215 | + | h.mu.RLock() | |
| 216 | + | fn = h.routeKey | |
| 217 | + | h.mu.RUnlock() | |
| 218 | + | assert.NotNil(t, fn, "routeKey should be set") | |
| 219 | + | assert.Equal(t, RoutePassToWidget, fn(KeyContext{})) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |
| 223 | + | ctx := context.Background() | |
| 224 | + | cfg := newTestConfig() | |
| 225 | + | ||
| 226 | + | h := NewKeyboardHandler(ctx, cfg) | |
| 227 | + | ||
| 228 | + | // No routeKey set -- should default to shortcut handling | |
| 229 | + | // 'z' is not a registered shortcut in ModeNormal, so returns false | |
| 230 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |
| 231 | + | assert.False(t, result, "unregistered key in ModeNormal returns false") | |
| 232 | + | } | |
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 8 | + | "github.com/jwijenbergh/puregotk/v4/gdk" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // newTestConfig creates a minimal Config with activation shortcuts set. | |||
| 13 | + | // Used by tests that construct a KeyboardHandler. | |||
| 14 | + | func newTestConfig() *config.Config { | |||
| 15 | + | cfg := &config.Config{} | |||
| 16 | + | cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t" | |||
| 17 | + | cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p" | |||
| 18 | + | cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r" | |||
| 19 | + | cfg.Session.SessionMode.ActivationShortcut = "ctrl+s" | |||
| 20 | + | return cfg | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // stubAccentHandler is a test stub for the AccentHandler interface. | |||
| 24 | + | type stubAccentHandler struct { | |||
| 25 | + | onKeyPressedResult bool | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (s *stubAccentHandler) OnKeyPressed(_ context.Context, _ rune, _ bool) bool { | |||
| 29 | + | return s.onKeyPressedResult | |||
| 30 | + | } | |||
| 31 | + | func (s *stubAccentHandler) OnKeyReleased(_ context.Context, _ rune) {} | |||
| 32 | + | func (s *stubAccentHandler) IsPickerVisible() bool { return false } | |||
| 33 | + | func (s *stubAccentHandler) Cancel(_ context.Context) {} | |||
| 34 | + | ||||
| 35 | + | func TestIsShortcutModified(t *testing.T) { | |||
| 36 | + | tests := []struct { | |||
| 37 | + | name string | |||
| 38 | + | mods Modifier | |||
| 39 | + | expected bool | |||
| 40 | + | }{ | |||
| 41 | + | {"no modifier", ModNone, false}, | |||
| 42 | + | {"shift only", ModShift, false}, | |||
| 43 | + | {"ctrl", ModCtrl, true}, | |||
| 44 | + | {"alt", ModAlt, true}, | |||
| 45 | + | {"ctrl+shift", ModCtrl | ModShift, true}, | |||
| 46 | + | {"alt+shift", ModAlt | ModShift, true}, | |||
| 47 | + | {"ctrl+alt", ModCtrl | ModAlt, true}, | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | for _, tt := range tests { | |||
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 52 | + | assert.Equal(t, tt.expected, IsShortcutModified(tt.mods)) | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestIsTextInputKey(t *testing.T) { | |||
| 58 | + | tests := []struct { | |||
| 59 | + | name string | |||
| 60 | + | keyval uint | |||
| 61 | + | expected bool | |||
| 62 | + | }{ | |||
| 63 | + | // Printable characters | |||
| 64 | + | {"lowercase a", uint('a'), true}, | |||
| 65 | + | {"lowercase z", uint('z'), true}, | |||
| 66 | + | {"uppercase A", uint('A'), true}, | |||
| 67 | + | {"digit 1", uint('1'), true}, | |||
| 68 | + | {"space", 0x020, true}, | |||
| 69 | + | {"period", uint('.'), true}, | |||
| 70 | + | {"slash", uint('/'), true}, | |||
| 71 | + | {"apostrophe", 0x027, true}, | |||
| 72 | + | {"grave accent", 0x060, true}, | |||
| 73 | + | {"tilde", 0x07e, true}, | |||
| 74 | + | {"quotedbl", 0x022, true}, | |||
| 75 | + | {"caret", 0x05e, true}, | |||
| 76 | + | ||||
| 77 | + | // Dead keys (used by US International layout) | |||
| 78 | + | {"dead acute", 0xfe51, true}, | |||
| 79 | + | {"dead grave", 0xfe50, true}, | |||
| 80 | + | {"dead circumflex", 0xfe52, true}, | |||
| 81 | + | {"dead tilde", 0xfe53, true}, | |||
| 82 | + | {"dead diaeresis", 0xfe57, true}, | |||
| 83 | + | ||||
| 84 | + | // Dead key upper-bound boundary cases | |||
| 85 | + | {"dead_greek (0xfe8c) - highest dead key", 0xfe8c, true}, | |||
| 86 | + | {"0xfe8d - one past highest dead key", 0xfe8d, false}, | |||
| 87 | + | ||||
| 88 | + | // Non-text keys (should NOT be treated as text) | |||
| 89 | + | {"escape", 0xff1b, false}, | |||
| 90 | + | {"return", 0xff0d, false}, | |||
| 91 | + | {"tab", 0xff09, false}, | |||
| 92 | + | {"F1", 0xffbe, false}, | |||
| 93 | + | {"F12", 0xffc9, false}, | |||
| 94 | + | {"left arrow", 0xff51, false}, | |||
| 95 | + | {"right arrow", 0xff53, false}, | |||
| 96 | + | {"backspace", 0xff08, false}, | |||
| 97 | + | {"delete", 0xffff, false}, | |||
| 98 | + | {"home", 0xff50, false}, | |||
| 99 | + | {"end", 0xff57, false}, | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | for _, tt := range tests { | |||
| 103 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 104 | + | assert.Equal(t, tt.expected, IsTextInputKey(tt.keyval), "keyval 0x%x", tt.keyval) | |||
| 105 | + | }) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |||
| 110 | + | ctx := context.Background() | |||
| 111 | + | cfg := newTestConfig() | |||
| 112 | + | ||||
| 113 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 114 | + | ||||
| 115 | + | // Route all non-shortcut keys to widget (simulates WebView text input) | |||
| 116 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 117 | + | return RoutePassToWidget | |||
| 118 | + | }) | |||
| 119 | + | ||||
| 120 | + | tests := []struct { | |||
| 121 | + | name string | |||
| 122 | + | keyval uint | |||
| 123 | + | state gdk.ModifierType | |||
| 124 | + | }{ | |||
| 125 | + | {"plain 'e'", uint('e'), 0}, | |||
| 126 | + | {"plain 'a'", uint('a'), 0}, | |||
| 127 | + | {"shift 'E'", uint('E'), gdk.ShiftMaskValue}, | |||
| 128 | + | {"space", 0x020, 0}, | |||
| 129 | + | {"digit 1", uint('1'), 0}, | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | for _, tt := range tests { | |||
| 133 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 134 | + | result := h.handleKeyPress(tt.keyval, 0, tt.state) | |||
| 135 | + | assert.False(t, result, "RoutePassToWidget should let key propagate (return false)") | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |||
| 141 | + | ctx := context.Background() | |||
| 142 | + | cfg := newTestConfig() | |||
| 143 | + | ||||
| 144 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 145 | + | ||||
| 146 | + | // Set a stub accent handler that always returns false (simulates "no accents for this key") | |||
| 147 | + | h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false}) | |||
| 148 | + | ||||
| 149 | + | // Route to accent detection | |||
| 150 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 151 | + | return RouteAccentDetection | |||
| 152 | + | }) | |||
| 153 | + | ||||
| 154 | + | // 'x' has no accents -> accent handler returns false -> key falls through | |||
| 155 | + | result := h.handleKeyPress(uint('x'), 0, 0) | |||
| 156 | + | assert.False(t, result, "'x' has no accents, should pass through") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |||
| 160 | + | ctx := context.Background() | |||
| 161 | + | cfg := newTestConfig() | |||
| 162 | + | ||||
| 163 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 164 | + | ||||
| 165 | + | h.SetOnAction(func(ctx context.Context, action Action) error { | |||
| 166 | + | return nil | |||
| 167 | + | }) | |||
| 168 | + | ||||
| 169 | + | // Route text keys to widget, shortcuts to handler | |||
| 170 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 171 | + | if IsShortcutModified(kc.Modifiers) { | |||
| 172 | + | return RouteHandleShortcuts | |||
| 173 | + | } | |||
| 174 | + | return RoutePassToWidget | |||
| 175 | + | }) | |||
| 176 | + | ||||
| 177 | + | // Ctrl+L = open omnibox (registered global shortcut) | |||
| 178 | + | result := h.handleKeyPress(uint('l'), 0, gdk.ControlMaskValue) | |||
| 179 | + | assert.True(t, result, "Ctrl+L should be consumed by shortcut handler") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |||
| 183 | + | ctx := context.Background() | |||
| 184 | + | cfg := newTestConfig() | |||
| 185 | + | ||||
| 186 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 187 | + | h.EnterTabMode() | |||
| 188 | + | ||||
| 189 | + | // Even if routeKey says pass to widget, modal mode ignores it | |||
| 190 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 191 | + | return RoutePassToWidget | |||
| 192 | + | }) | |||
| 193 | + | ||||
| 194 | + | // In modal mode, unknown keys are consumed (return true) | |||
| 195 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |||
| 196 | + | assert.True(t, result, "modal mode should consume unknown keys regardless of route") | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestKeyboardHandler_SetRouteKey(t *testing.T) { | |||
| 200 | + | ctx := context.Background() | |||
| 201 | + | cfg := &config.Config{} | |||
| 202 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 203 | + | ||||
| 204 | + | // Default: no callback set | |||
| 205 | + | h.mu.RLock() | |||
| 206 | + | fn := h.routeKey | |||
| 207 | + | h.mu.RUnlock() | |||
| 208 | + | assert.Nil(t, fn, "routeKey should be nil by default") | |||
| 209 | + | ||||
| 210 | + | // Set callback | |||
| 211 | + | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |||
| 212 | + | return RoutePassToWidget | |||
| 213 | + | }) | |||
| 214 | + | ||||
| 215 | + | h.mu.RLock() | |||
| 216 | + | fn = h.routeKey | |||
| 217 | + | h.mu.RUnlock() | |||
| 218 | + | assert.NotNil(t, fn, "routeKey should be set") | |||
| 219 | + | assert.Equal(t, RoutePassToWidget, fn(KeyContext{})) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |||
| 223 | + | ctx := context.Background() | |||
| 224 | + | cfg := newTestConfig() | |||
| 225 | + | ||||
| 226 | + | h := NewKeyboardHandler(ctx, cfg) | |||
| 227 | + | ||||
| 228 | + | // No routeKey set -- should default to shortcut handling | |||
| 229 | + | // 'z' is not a registered shortcut in ModeNormal, so returns false | |||
| 230 | + | result := h.handleKeyPress(uint('z'), 0, 0) | |||
| 231 | + | assert.False(t, result, "unregistered key in ModeNormal returns false") | |||
| 232 | + | } | |||
M internal/ui/theme/css.go +2 -2
| @@ -35,8 +35,8 @@ func DefaultFontConfig() FontConfig { | |||
| 35 | 35 | func FontCSSVars(fonts FontConfig) string { | |
| 36 | 36 | var sb strings.Builder | |
| 37 | 37 | // Quote the configured family; include generic fallback. | |
| 38 | - | sb.WriteString(fmt.Sprintf(" --font-sans: %q, sans-serif;\n", fonts.SansFont)) | |
| 39 | - | sb.WriteString(fmt.Sprintf(" --font-mono: %q, monospace;\n", fonts.MonospaceFont)) | |
| 38 | + | fmt.Fprintf(&sb, " --font-sans: %q, sans-serif;\n", fonts.SansFont) | |
| 39 | + | fmt.Fprintf(&sb, " --font-mono: %q, monospace;\n", fonts.MonospaceFont) | |
| 40 | 40 | return sb.String() | |
| 41 | 41 | } | |
| 42 | 42 | ||
| @@ -35,8 +35,8 @@ func DefaultFontConfig() FontConfig { | |||||
| 35 | func FontCSSVars(fonts FontConfig) string { | 35 | func FontCSSVars(fonts FontConfig) string { | ||
| 36 | var sb strings.Builder | 36 | var sb strings.Builder | ||
| 37 | // Quote the configured family; include generic fallback. | 37 | // Quote the configured family; include generic fallback. | ||
| 38 | - | sb.WriteString(fmt.Sprintf(" --font-sans: %q, sans-serif;\n", fonts.SansFont)) | 38 | + | fmt.Fprintf(&sb, " --font-sans: %q, sans-serif;\n", fonts.SansFont) |
| 39 | - | sb.WriteString(fmt.Sprintf(" --font-mono: %q, monospace;\n", fonts.MonospaceFont)) | 39 | + | fmt.Fprintf(&sb, " --font-mono: %q, monospace;\n", fonts.MonospaceFont) |
| 40 | return sb.String() | 40 | return sb.String() | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||