detached b4c833b

Merge pull request #187 from bnema/refactor/keyboard-routing-dead-keys

refactor(input): unified keyboard routing with native dead key support
commit
b4c833bfa7b866e3cdf06086a899517d9927d217
author
bnema · · 2026-03-12 06:53 UTC
committer
GitHub · · 2026-03-12 06:53 UTC
merge of
db04bd6 + 82909be · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +1019 -120
M .golangci.yml +8 -0
@@ -149,6 +149,14 @@ linters:
149149 - text: "G304:"
150150 linters:
151151 - 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
152160 # GTK callbacks require unused parameters
153161 - path: internal/ui/
154162 text: "unused-parameter"
@@ -149,6 +149,14 @@ linters:
149 - text: "G304:"149 - text: "G304:"
150 linters:150 linters:
151 - gosec151 - 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 parameters160 # 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 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
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
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -71,9 +71,6 @@ test: ## Run tests
7171 @echo "Running tests..."
7272 go test -v ./...
7373
74-check-docs: ## Verify README/docs match code
75- @go run ./dev/check-docs.go --fail-on-error
76-
7774 test-race: ## Run tests with race detection
7875 @echo "Running tests with race detection..."
7976 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-purego3+.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 detection74 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() {
7373 }
7474
7575 func runGUI() int {
76+ bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
77+
7678 runtime.LockOSThread()
7779 component.SetSkeletonVersion(version)
7880 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(
5353
5454 // OnKeyPressed handles a key press event.
5555 // 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.
5757 // 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.
5861 func (uc *InsertAccentUseCase) OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool {
5962 log := logging.FromContext(ctx)
6063
@@ -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 {
571571
572572 // runSessionCleanupAsync performs stale session cleanup and old session pruning
573573 // 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.
575576 func runSessionCleanupAsync(
576577 startupCtx context.Context,
577578 sessionUC *usecase.ManageSessionUseCase,
@@ -580,14 +581,10 @@ func runSessionCleanupAsync(
580581 lockDir string,
581582 log *zerolog.Logger,
582583 ) {
583- // Silence unused parameter warning - startupCtx is intentionally unused.
584- _ = startupCtx
585-
586584 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)
591588
592589 // End stale active sessions (orphaned from crashed processes)
593590 if lockDir != "" {
@@ -571,7 +571,8 @@ func markerValue(raw []byte, key string) string {
571 571
572 // runSessionCleanupAsync performs stale session cleanup and old session pruning572 // 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 the586+ // 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 {
602602 tabName = "Tab"
603603 }
604604
605- b.WriteString(fmt.Sprintf(" %s%s %s\n",
605+ fmt.Fprintf(&b, " %s%s %s\n",
606606 treeStyle.Render(branch),
607607 leafStyle.Render(tabIcon),
608608 t.Normal.Render(tabName),
609- ))
609+ )
610610
611611 // Render panes under this tab
612612 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 tab611 // 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 {
5454 valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text)
5555
5656 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))
5858
5959 for _, key := range keys {
60- sb.WriteString(fmt.Sprintf(
60+ fmt.Fprintf(&sb,
6161 " %s %s\n Type: %s | Default: %s\n",
6262 iconStyle.Render(IconCursor),
6363 keyStyle.Render(key.Key),
6464 typeStyle.Render(key.Type),
6565 valueStyle.Render(key.DefaultValue),
66- ))
66+ )
6767 }
6868
6969 return sb.String()
@@ -98,42 +98,42 @@ func (r *ConfigRenderer) RenderChanges(changes []port.KeyChange) string {
9898
9999 // Show renamed keys first (most important for user to understand)
100100 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))
102102 for _, c := range renamed {
103- sb.WriteString(fmt.Sprintf(
103+ fmt.Fprintf(&sb,
104104 " %s %s %s %s\n Value: %s\n",
105105 renameStyle.Render("~"),
106106 valueStyle.Render(c.OldKey),
107107 renameStyle.Render("→"),
108108 keyStyle.Render(c.NewKey),
109109 valueStyle.Render(c.OldValue),
110- ))
110+ )
111111 }
112112 }
113113
114114 // Show new settings
115115 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))
117117 for _, c := range added {
118- sb.WriteString(fmt.Sprintf(
118+ fmt.Fprintf(&sb,
119119 " %s %s\n Default: %s\n",
120120 addStyle.Render("+"),
121121 keyStyle.Render(c.NewKey),
122122 valueStyle.Render(c.NewValue),
123- ))
123+ )
124124 }
125125 }
126126
127127 // Show deprecated settings (will be removed)
128128 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))
130130 for _, c := range removed {
131- sb.WriteString(fmt.Sprintf(
131+ fmt.Fprintf(&sb,
132132 " %s %s %s\n",
133133 removeStyle.Render("-"),
134134 valueStyle.Render(c.OldKey),
135135 removeStyle.Render("(will be removed)"),
136- ))
136+ )
137137 }
138138 }
139139
@@ -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.Builder56 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 settings114 // 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 (
77 "github.com/bnema/dumber/internal/application/port"
88 )
99
10+const noChangesMsg = "No changes detected."
11+
1012 // ConfigDiffFormatter implements port.DiffFormatter for formatting config changes.
1113 type ConfigDiffFormatter struct{}
1214
@@ -18,7 +20,7 @@ func NewDiffFormatter() *ConfigDiffFormatter {
1820 // FormatChangesAsDiff returns changes formatted as a diff for display.
1921 func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string {
2022 if len(changes) == 0 {
21- return "No changes detected."
23+ return noChangesMsg
2224 }
2325
2426 var sb strings.Builder
@@ -27,12 +29,12 @@ func (*ConfigDiffFormatter) FormatChangesAsDiff(changes []port.KeyChange) string
2729 for _, change := range changes {
2830 switch change.Type {
2931 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)
3133 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)
3335 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)
3638 }
3739 }
3840
@@ -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.Builder26 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 {
9595 Str("text", text).
9696 Msg("inserted text into WebView via clipboard paste")
9797
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)
101103 go func() {
102104 time.Sleep(clipboardRestoreDelay)
105+ restoreCtx, cancelRestore := context.WithTimeout(detachedCtx, clipboardRestoreDelay)
106+ defer cancelRestore()
103107 if originalClipboard != "" {
104- if err := t.clipboard.WriteText(context.Background(), originalClipboard); err != nil {
108+ if err := t.clipboard.WriteText(restoreCtx, originalClipboard); err != nil {
105109 log.Debug().Err(err).Msg("failed to restore clipboard")
106110 }
107111 }
@@ -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 delay98+ // Restore original clipboard after a short delay.
99- // This allows the paste operation to complete first99+ // This allows the paste operation to complete first.
100- // Use context.Background() since this cleanup should complete regardless of ctx cancellation100+ // 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 {
160160 return fmt.Errorf("failed to read new binary: %w", err)
161161 }
162162
163- // Write to staging location.
163+ // Write to staging location atomically via temp file.
164164 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()
166177 return fmt.Errorf("failed to write staged binary: %w", err)
167178 }
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+ }
168197
169198 log.Info().
170199 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
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
7+ "unicode/utf8"
68
79 "github.com/bnema/dumber/internal/application/port"
810 "github.com/bnema/dumber/internal/application/usecase"
@@ -11,6 +13,13 @@ import (
1113 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"
1214 )
1315
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+
1423 // Config holds all dependencies for message handlers.
1524 type Config struct {
1625 HistoryUC *usecase.SearchHistoryUseCase
@@ -61,6 +70,54 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
6170 return nil
6271 }
6372
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+
64121 // createKeybindingsHandler wires the gateway and use cases for keybindings.
65122 func createKeybindingsHandler() (*KeybindingsHandler, error) {
66123 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.SearchHistoryUseCase25 HistoryUC *usecase.SearchHistoryUseCase
@@ -61,6 +70,54 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
61 return nil70 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() {
141141 });
142142 })();`
143143
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+
144202 // webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
145203 // Some pages gate support on window.RTCPeerConnection and report false negatives
146204 // when only webkit-prefixed constructors are present.
@@ -333,5 +391,18 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
333391 log.Debug().Msg("auto-copy selection script injected")
334392 }
335393
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+
336407 log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
337408 }
@@ -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 negatives203 // 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) {
197197 FavoritesUC: deps.FavoritesUC,
198198 Clipboard: deps.Clipboard,
199199 ConfigGetter: config.Get,
200+ // AccentHandler is registered after initAccentPicker in onActivate
200201 OnClipboardCopied: func(textLen int) {
201202 // Show brief toast on auto-copy (similar to zellij footer notification)
202203 // Must schedule on GTK main thread since this is called from WebKit handler
@@ -291,6 +292,7 @@ func (a *App) onActivate(ctx context.Context) {
291292 a.installCrashReportNotifier(ctx)
292293 a.initFocusAndBorderOverlay()
293294 a.initAccentPicker(ctx)
295+ a.registerAccentHandlers(ctx)
294296 a.initDownloadHandler(ctx)
295297
296298 a.initCoordinators(ctx)
@@ -298,7 +300,7 @@ func (a *App) onActivate(ctx context.Context) {
298300 logging.Trace().Mark("coordinators_init")
299301 a.initKeyboardHandler(ctx)
300302 a.initOmniboxConfig(ctx)
301- a.initFindBarConfig()
303+ a.initFindBarConfig(ctx)
302304 a.initSessionManager(ctx)
303305 a.initTabPicker(ctx)
304306 a.initSnapshotService(ctx)
@@ -541,6 +543,23 @@ func (a *App) initAccentPicker(ctx context.Context) {
541543 log.Debug().Msg("accent picker initialized")
542544 }
543545
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+
544563 func (a *App) initDownloadHandler(ctx context.Context) {
545564 log := logging.FromContext(ctx)
546565
@@ -631,23 +650,53 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
631650 a.keyboardHandler.SetOnModeChange(func(from, to input.Mode) {
632651 a.handleModeChange(ctx, from, to)
633652 })
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
636662 if a.sessionManager != nil && a.sessionManager.IsVisible() {
637- return true
663+ return input.RoutePassToWidget
638664 }
639665 if a.tabPicker != nil && a.tabPicker.IsVisible() {
640- return true
666+ return input.RoutePassToWidget
641667 }
668+
642669 wsView := a.activeWorkspaceView()
643670 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+ }
645687 }
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
649693 }
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
651700 })
652701 // Wire accent handler for dead keys support
653702 if a.insertAccentUC != nil {
@@ -668,6 +717,33 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
668717 )
669718 }
670719
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+
671747 func (a *App) initOmniboxConfig(ctx context.Context) {
672748 if a.deps == nil || a.deps.Config == nil {
673749 return
@@ -712,12 +788,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
712788 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
713789 }
714790 },
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+ },
715797 }
716798 a.navCoord.SetOmniboxProvider(a)
717799 log.Debug().Msg("omnibox config stored, provider set")
718800 }
719801
720-func (a *App) initFindBarConfig() {
802+func (a *App) initFindBarConfig(ctx context.Context) {
721803 // Store find bar config (find bar is created per-pane via WorkspaceView).
722804 a.findBarCfg = component.FindBarConfig{
723805 GetFindController: func(paneID entity.PaneID) port.FindController {
@@ -742,6 +824,12 @@ func (a *App) initFindBarConfig() {
742824 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
743825 }
744826 },
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+ },
745833 }
746834 }
747835
@@ -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 handler203 // 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 visible654+ // 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 true663+ return input.RoutePassToWidget
638 }664 }
639 if a.tabPicker != nil && a.tabPicker.IsVisible() {665 if a.tabPicker != nil && a.tabPicker.IsVisible() {
640- return true666+ return input.RoutePassToWidget
641 }667 }
668+
642 wsView := a.activeWorkspaceView()669 wsView := a.activeWorkspaceView()
643 if wsView == nil {670 if wsView == nil {
644- return false671+ 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 navigation689+ // WebView focused: text/dead keys pass through for native IM compose,
648- return modifiers&input.ModAlt == 0690+ // shortcut-modified keys (Ctrl+, Alt+) are handled by shortcut system
691+ if input.IsShortcutModified(kc.Modifiers) {
692+ return input.RouteHandleShortcuts
649 }693 }
650- return false694+ 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 support701 // 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 return749 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 {
3939
4040 uc *usecase.FindInPageUseCase
4141
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
4749 }
4850
4951 // FindBarConfig holds configuration for creating a FindBar.
5052 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
5559 }
5660
5761 // NewFindBar creates a new FindBar component.
@@ -59,9 +63,11 @@ func NewFindBar(ctx context.Context, cfg FindBarConfig) *FindBar {
5963 log := logging.FromContext(ctx)
6064
6165 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),
6571 }
6672
6773 if err := fb.createWidgets(); err != nil {
@@ -370,9 +376,23 @@ func (fb *FindBar) attachKeyController() {
370376 }
371377 return true
372378 }
379+ // Try long-press accent detection for text keys
380+ if fb.onAccentKeyPress != nil {
381+ return fb.onAccentKeyPress(keyval, state)
382+ }
373383 return false
374384 }
385+ fb.retainedCallbacks = append(fb.retainedCallbacks, keyPressedCb)
375386 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+
376396 fb.outerBox.AddController(&controller.EventController)
377397 }
378398
@@ -39,19 +39,23 @@ type FindBar struct {
39 39
40 uc *usecase.FindInPageUseCase40 uc *usecase.FindInPageUseCase
41 41
42- visible bool42+ visible bool
43- mu sync.RWMutex43+ mu sync.RWMutex
44- ctx context.Context44+ ctx context.Context
45- onClose func()45+ onClose func()
46- retainedCallbacks []interface{} // Keep callbacks alive for GC46+ 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.FindController54+ 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 focus56+ 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 true377 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 false383 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 {
111111 ctx context.Context
112112
113113 // 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)
117119
118120 // Debouncing
119121 debounceTimer *time.Timer
@@ -144,18 +146,20 @@ type Omnibox struct {
144146
145147 // OmniboxConfig holds configuration for creating an Omnibox.
146148 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
159163 }
160164
161165 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -169,18 +173,20 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
169173 }
170174
171175 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,
184190 }
185191 o.idleCoalescer = mainloop.NewCoalescer(func(fn func()) {
186192 var cb glib.SourceFunc = func(uintptr) bool {
@@ -427,11 +433,26 @@ func (o *Omnibox) setupKeyboardHandling() {
427433 controller.SetPropagationPhase(gtk.PhaseCaptureValue)
428434
429435 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
431444 }
432445 o.retainedCallbacks = append(o.retainedCallbacks, keyPressedCb)
433446 controller.ConnectKeyPressed(&keyPressedCb)
434447
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+
435456 o.outerBox.AddController(&controller.EventController)
436457 }
437458
@@ -111,9 +111,11 @@ type Omnibox struct {
111 ctx context.Context111 ctx context.Context
112 112
113 // Callbacks113 // 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 // Debouncing120 // Debouncing
119 debounceTimer *time.Timer121 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.SearchHistoryUseCase149+ HistoryUC *usecase.SearchHistoryUseCase
148- FavoritesUC *usecase.ManageFavoritesUseCase150+ FavoritesUC *usecase.ManageFavoritesUseCase
149- FaviconAdapter *adapter.FaviconAdapter151+ FaviconAdapter *adapter.FaviconAdapter
150- CopyURLUC *usecase.CopyURLUseCase152+ CopyURLUC *usecase.CopyURLUseCase
151- ShortcutsUC *usecase.SearchShortcutsUseCase153+ ShortcutsUC *usecase.SearchShortcutsUseCase
152- DefaultSearch string154+ DefaultSearch string
153- InitialBehavior string155+ InitialBehavior string
154- UIScale float64 // UI scale for favicon sizing156+ UIScale float64 // UI scale for favicon sizing
155- OnNavigate func(url string) // Callback when user navigates via omnibox157+ OnNavigate func(url string) // Callback when user navigates via omnibox
156- OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification158+ 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 focus160+ 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
8585 // Track original URL for cross-domain redirect favicon caching
8686 c.contentCoord.SetNavigationOrigin(activePaneID, url)
8787 // Pre-load cached favicon asynchronously (don't block navigation start)
88- preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
8988 go func() {
89+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
9090 defer cancelPreload()
9191 c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
9292 }()
@@ -85,8 +85,8 @@ func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error
85 // Track original URL for cross-domain redirect favicon caching85 // 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 @@
11 package input
22
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+
321 import (
422 "context"
523 "sync"
@@ -40,8 +58,10 @@ type KeyboardHandler struct {
4058
4159 // Action handler callback
4260 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
4565 // Optional accent handler for dead keys support
4666 accentHandler AccentHandler
4767 // Optional escape hook for app-level overlays
@@ -99,12 +119,16 @@ func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
99119 h.modal.SetOnModeChange(fn)
100120 }
101121
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) {
105129 h.mu.Lock()
106130 defer h.mu.Unlock()
107- h.shouldBypass = fn
131+ h.routeKey = fn
108132 }
109133
110134 // SetAccentHandler sets the handler for long-press accent detection.
@@ -128,7 +152,10 @@ func (h *KeyboardHandler) Mode() Mode {
128152 }
129153
130154 // 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.
132159 func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
133160 log := logging.FromContext(h.ctx)
134161
@@ -143,8 +170,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
143170 return
144171 }
145172
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)
148177
149178 // Connect key pressed handler (retain callback to prevent GC).
150179 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
@@ -176,42 +205,61 @@ func (h *KeyboardHandler) Detach() {
176205
177206 // handleKeyPress processes a key press event.
178207 // 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
183208 func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
184209 log := logging.FromContext(h.ctx)
185210
186211 h.mu.RLock()
187- shouldBypass := h.shouldBypass
212+ routeKey := h.routeKey
188213 accentHandler := h.accentHandler
189214 onEscape := h.onEscape
190215 h.mu.RUnlock()
191216
192- // Check if accent picker is visible - it takes priority
217+ // Accent picker takes absolute priority when visible -- it has its own key controller
193218 if accentHandler != nil && accentHandler.IsPickerVisible() {
194- return false // Let the accent picker handle the key via its own controller
219+ return false
195220 }
196221
197222 modifiers := Modifier(state) & modifierMask
223+
224+ // Escape in normal mode: check app-level escape hook first
198225 if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
199226 if onEscape != nil && onEscape(h.ctx) {
200227 return true
201228 }
202229 }
203230
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+ })
208239 }
209240
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")
212255 return false
256+
257+ case RouteHandleShortcuts:
258+ // Fall through to shortcut processing below
213259 }
214260
261+ // --- Shortcut processing path ---
262+
215263 // Normalize uppercase letters for consistent binding lookup
216264 keyval = normalizeKeyval(keyval)
217265
@@ -227,16 +275,20 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
227275 }
228276
229277 // 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).
231283 func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
232- if h.modal.Mode() != ModeNormal || accentHandler == nil {
284+ if accentHandler == nil {
233285 return false
234286 }
235287 // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
236288 if modifiers != 0 && modifiers != ModShift {
237289 return false
238290 }
239- if char := keyvalToRune(keyval); char != 0 {
291+ if char := KeyvalToRune(keyval); char != 0 {
240292 return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
241293 }
242294 return false
@@ -407,14 +459,14 @@ func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
407459 }
408460
409461 // Convert keyval to rune for the accent handler
410- if char := keyvalToRune(keyval); char != 0 {
462+ if char := KeyvalToRune(keyval); char != 0 {
411463 accentHandler.OnKeyReleased(h.ctx, char)
412464 }
413465 }
414466
415-// keyvalToRune converts a GTK keyval to a lowercase rune.
467+// KeyvalToRune converts a GTK keyval to a lowercase rune.
416468 // 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 {
418470 // Handle lowercase a-z
419471 if keyval >= uint('a') && keyval <= uint('z') {
420472 return rune(keyval)
@@ -425,3 +477,66 @@ func keyvalToRune(keyval uint) rune {
425477 }
426478 return 0
427479 }
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 input1 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 callback59 // Action handler callback
42 onAction ActionHandler60 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) bool62+ // 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 support65 // Optional accent handler for dead keys support
46 accentHandler AccentHandler66 accentHandler AccentHandler
47 // Optional escape hook for app-level overlays67 // 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 = fn131+ 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 return170 return
144 }171 }
145 172
146- // Set capture phase to intercept events before WebView gets them173+ // 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.shouldBypass212+ routeKey := h.routeKey
188 accentHandler := h.accentHandler213 accentHandler := h.accentHandler
189 onEscape := h.onEscape214 onEscape := h.onEscape
190 h.mu.RUnlock()215 h.mu.RUnlock()
191 216
192- // Check if accent picker is visible - it takes priority217+ // 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 controller219+ return false
195 }220 }
196 221
197 modifiers := Modifier(state) & modifierMask222 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 true227 return true
201 }228 }
202 }229 }
203 230
204- // Check accent detection first - works in both normal and bypass mode231+ // Determine routing for this key event
205- // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView232+ route := RouteHandleShortcuts // default: process through shortcut system
206- if h.tryAccentDetection(accentHandler, keyval, modifiers) {233+ if routeKey != nil && h.modal.Mode() == ModeNormal {
207- return true234+ 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 false255 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 lookup263 // 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 false285 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 false289 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 false294 return false
@@ -407,14 +459,14 @@ func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
407 }459 }
408 460
409 // Convert keyval to rune for the accent handler461 // 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-z470 // 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 0478 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 {
3535 func FontCSSVars(fonts FontConfig) string {
3636 var sb strings.Builder
3737 // 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)
4040 return sb.String()
4141 }
4242
@@ -35,8 +35,8 @@ func DefaultFontConfig() FontConfig {
35 func FontCSSVars(fonts FontConfig) string {35 func FontCSSVars(fonts FontConfig) string {
36 var sb strings.Builder36 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

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close