detached 855acf7

Merge pull request #263 from bnema/refactor/cef2gtk-idle-size-observer

fix(cef): use bounded size observation
commit
855acf7156397459f82729ee0032765f8ec2b7eb
author
bnema · · 2026-05-25 04:56 UTC
committer
GitHub · · 2026-05-25 04:56 UTC
merge of
7ab5e2e + 36f1195 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +977 -131
M go.mod +3 -3
@@ -6,11 +6,11 @@ require (
66 github.com/a-h/templ v0.3.1020
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
9- github.com/bnema/purego-cef v0.13.0
10- github.com/bnema/purego-cef2gtk v0.5.2
9+ github.com/bnema/purego-cef v0.13.1
10+ github.com/bnema/purego-cef2gtk v0.5.4
1111 github.com/bnema/purego-pipewire v0.1.3
1212 github.com/bnema/purego-sqlite v0.1.2
13- github.com/bnema/puregotk v0.5.2
13+ github.com/bnema/puregotk v0.5.3
1414 github.com/charmbracelet/bubbles v1.0.0
1515 github.com/charmbracelet/bubbletea v1.3.10
1616 github.com/charmbracelet/lipgloss v1.1.0
@@ -6,11 +6,11 @@ require (
6 github.com/a-h/templ v0.3.10206 github.com/a-h/templ v0.3.1020
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9- github.com/bnema/purego-cef v0.13.09+ github.com/bnema/purego-cef v0.13.1
10- github.com/bnema/purego-cef2gtk v0.5.210+ github.com/bnema/purego-cef2gtk v0.5.4
11 github.com/bnema/purego-pipewire v0.1.311 github.com/bnema/purego-pipewire v0.1.3
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
13- github.com/bnema/puregotk v0.5.213+ github.com/bnema/puregotk v0.5.3
14 github.com/charmbracelet/bubbles v1.0.014 github.com/charmbracelet/bubbles v1.0.0
15 github.com/charmbracelet/bubbletea v1.3.1015 github.com/charmbracelet/bubbletea v1.3.10
16 github.com/charmbracelet/lipgloss v1.1.016 github.com/charmbracelet/lipgloss v1.1.0
M go.sum +6 -6 generated file: collapsed
M internal/infrastructure/cef/factory.go +98 -31
@@ -177,18 +177,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
177177 // the one-shot initial resize path; once that path has been consumed,
178178 // later size events must go through normal viewport sync even if popup
179179 // attachment keeps pendingCreate alive a little longer.
180- if wv.shouldStartBrowserCreateFromSizeObserver() {
181- if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
182- logging.FromContext(ctx).Warn().
183- Err(err).
184- Uint64("webview_id", uint64(wv.id)).
185- Int32("resize_width", w).
186- Int32("resize_height", h).
187- Msg("cef: cef2gtk view not ready after resize, will retry")
188- return
189- }
190- wv.markInitialBrowserCreateResizeHandled()
191- onFirstResize(w, h)
180+ if f.handleInitialBrowserCreateSizeObserver(ctx, wv, onFirstResize, wv.viewBridge.PrepareOnGTKThread, w, h) {
192181 return
193182 }
194183
@@ -203,6 +192,49 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
203192 })
204193 }
205194
195+func (f *WebViewFactory) handleInitialBrowserCreateSizeObserver(
196+ ctx context.Context,
197+ wv *WebView,
198+ onFirstResize func(w, h int32),
199+ prepareOnGTK func() error,
200+ w, h int32,
201+) bool {
202+ if wv == nil || !wv.shouldStartBrowserCreateFromSizeObserver() {
203+ return false
204+ }
205+ if !initialBrowserCreateSizeReadyFromObserver(w, h) {
206+ if wv.awaitsNativePopupAttachment() && onFirstResize != nil {
207+ onFirstResize(w, h)
208+ }
209+ logging.FromContext(ctx).Debug().
210+ Uint64("webview_id", uint64(wv.id)).
211+ Int32("resize_width", w).
212+ Int32("resize_height", h).
213+ Msg("cef: ignoring bootstrap size observer event before real view size is ready")
214+ return true
215+ }
216+ if prepareOnGTK != nil {
217+ if err := prepareOnGTK(); err != nil {
218+ logging.FromContext(ctx).Warn().
219+ Err(err).
220+ Uint64("webview_id", uint64(wv.id)).
221+ Int32("resize_width", w).
222+ Int32("resize_height", h).
223+ Msg("cef: cef2gtk view not ready after resize, will retry")
224+ return true
225+ }
226+ }
227+ wv.markInitialBrowserCreateResizeHandled()
228+ if onFirstResize != nil {
229+ onFirstResize(w, h)
230+ }
231+ return true
232+}
233+
234+func initialBrowserCreateSizeReadyFromObserver(width, height int32) bool {
235+ return observedViewportSizeReady(width, height, width, height)
236+}
237+
206238 func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
207239 log := logging.FromContext(ctx)
208240 pc := wv.takePendingCreate()
@@ -329,26 +361,61 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
329361 }
330362
331363 f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
332- log := logging.FromContext(ctx)
333- if popupWV.awaitsNativePopupAttachment() {
334- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")
335- popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
336- if !popupWV.startNativePopupFallback() {
337- return
338- }
339- logging.FromContext(ctx).Warn().
340- Uint64("webview_id", uint64(popupWV.id)).
341- Msg("cef: native popup attach timed out, creating popup browser directly")
342- f.postPendingBrowserCreate(ctx, popupWV, w, h)
343- })
344- return
345- }
364+ f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) {
365+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
366+ }, w, h)
367+ })
368+ return popupWV, nil
369+}
346370
347- if !popupWV.preparePopupShellDirectBrowserCreation() {
348- return
371+func (f *WebViewFactory) handlePopupShellInitialResize(
372+ ctx context.Context,
373+ popupWV *WebView,
374+ postPendingCreate func(w, h int32),
375+ w, h int32,
376+) {
377+ if popupWV == nil {
378+ return
379+ }
380+ log := logging.FromContext(ctx)
381+ if popupWV.awaitsNativePopupAttachment() {
382+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell initial size observed, awaiting native popup attach")
383+ f.schedulePopupShellNativeFallback(ctx, popupWV)
384+ return
385+ }
386+ if popupWV.awaitsBrowserCreateFromNativePopupFallback() {
387+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell size ready after fallback, creating browser directly")
388+ if postPendingCreate != nil {
389+ postPendingCreate(w, h)
349390 }
350- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
351- f.postPendingBrowserCreate(ctx, popupWV, w, h)
391+ return
392+ }
393+ if !popupWV.preparePopupShellDirectBrowserCreation() {
394+ return
395+ }
396+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
397+ if postPendingCreate != nil {
398+ postPendingCreate(w, h)
399+ }
400+}
401+
402+func (f *WebViewFactory) schedulePopupShellNativeFallback(ctx context.Context, popupWV *WebView) {
403+ if popupWV == nil {
404+ return
405+ }
406+ popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
407+ popupWV.runOnGTK(func() {
408+ if !popupWV.startNativePopupFallback() {
409+ return
410+ }
411+ logging.FromContext(ctx).Warn().
412+ Uint64("webview_id", uint64(popupWV.id)).
413+ Msg("cef: native popup attach timed out, starting direct popup fallback")
414+ synced := popupWV.syncViewportNowOnGTK(ctx, "native-popup-fallback-timeout")
415+ logging.FromContext(ctx).Debug().
416+ Uint64("webview_id", uint64(popupWV.id)).
417+ Bool("browser_ready", synced).
418+ Msg("cef: popup fallback viewport sync requested")
419+ })
352420 })
353- return popupWV, nil
354421 }
@@ -177,18 +177,7 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
177 // the one-shot initial resize path; once that path has been consumed,177 // the one-shot initial resize path; once that path has been consumed,
178 // later size events must go through normal viewport sync even if popup178 // later size events must go through normal viewport sync even if popup
179 // attachment keeps pendingCreate alive a little longer.179 // attachment keeps pendingCreate alive a little longer.
180- if wv.shouldStartBrowserCreateFromSizeObserver() {180+ if f.handleInitialBrowserCreateSizeObserver(ctx, wv, onFirstResize, wv.viewBridge.PrepareOnGTKThread, w, h) {
181- if err := wv.viewBridge.PrepareOnGTKThread(); err != nil {
182- logging.FromContext(ctx).Warn().
183- Err(err).
184- Uint64("webview_id", uint64(wv.id)).
185- Int32("resize_width", w).
186- Int32("resize_height", h).
187- Msg("cef: cef2gtk view not ready after resize, will retry")
188- return
189- }
190- wv.markInitialBrowserCreateResizeHandled()
191- onFirstResize(w, h)
192 return181 return
193 }182 }
194 183
@@ -203,6 +192,49 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
203 })192 })
204 }193 }
205 194
195+func (f *WebViewFactory) handleInitialBrowserCreateSizeObserver(
196+ ctx context.Context,
197+ wv *WebView,
198+ onFirstResize func(w, h int32),
199+ prepareOnGTK func() error,
200+ w, h int32,
201+) bool {
202+ if wv == nil || !wv.shouldStartBrowserCreateFromSizeObserver() {
203+ return false
204+ }
205+ if !initialBrowserCreateSizeReadyFromObserver(w, h) {
206+ if wv.awaitsNativePopupAttachment() && onFirstResize != nil {
207+ onFirstResize(w, h)
208+ }
209+ logging.FromContext(ctx).Debug().
210+ Uint64("webview_id", uint64(wv.id)).
211+ Int32("resize_width", w).
212+ Int32("resize_height", h).
213+ Msg("cef: ignoring bootstrap size observer event before real view size is ready")
214+ return true
215+ }
216+ if prepareOnGTK != nil {
217+ if err := prepareOnGTK(); err != nil {
218+ logging.FromContext(ctx).Warn().
219+ Err(err).
220+ Uint64("webview_id", uint64(wv.id)).
221+ Int32("resize_width", w).
222+ Int32("resize_height", h).
223+ Msg("cef: cef2gtk view not ready after resize, will retry")
224+ return true
225+ }
226+ }
227+ wv.markInitialBrowserCreateResizeHandled()
228+ if onFirstResize != nil {
229+ onFirstResize(w, h)
230+ }
231+ return true
232+}
233+
234+func initialBrowserCreateSizeReadyFromObserver(width, height int32) bool {
235+ return observedViewportSizeReady(width, height, width, height)
236+}
237+
206 func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {238 func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) {
207 log := logging.FromContext(ctx)239 log := logging.FromContext(ctx)
208 pc := wv.takePendingCreate()240 pc := wv.takePendingCreate()
@@ -329,26 +361,61 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
329 }361 }
330 362
331 f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {363 f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) {
332- log := logging.FromContext(ctx)364+ f.handlePopupShellInitialResize(ctx, popupWV, func(w, h int32) {
333- if popupWV.awaitsNativePopupAttachment() {365+ f.postPendingBrowserCreate(ctx, popupWV, w, h)
334- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach")366+ }, w, h)
335- popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {367+ })
336- if !popupWV.startNativePopupFallback() {368+ return popupWV, nil
337- return369+}
338- }
339- logging.FromContext(ctx).Warn().
340- Uint64("webview_id", uint64(popupWV.id)).
341- Msg("cef: native popup attach timed out, creating popup browser directly")
342- f.postPendingBrowserCreate(ctx, popupWV, w, h)
343- })
344- return
345- }
346 370
347- if !popupWV.preparePopupShellDirectBrowserCreation() {371+func (f *WebViewFactory) handlePopupShellInitialResize(
348- return372+ ctx context.Context,
373+ popupWV *WebView,
374+ postPendingCreate func(w, h int32),
375+ w, h int32,
376+) {
377+ if popupWV == nil {
378+ return
379+ }
380+ log := logging.FromContext(ctx)
381+ if popupWV.awaitsNativePopupAttachment() {
382+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell initial size observed, awaiting native popup attach")
383+ f.schedulePopupShellNativeFallback(ctx, popupWV)
384+ return
385+ }
386+ if popupWV.awaitsBrowserCreateFromNativePopupFallback() {
387+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell size ready after fallback, creating browser directly")
388+ if postPendingCreate != nil {
389+ postPendingCreate(w, h)
349 }390 }
350- log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")391+ return
351- f.postPendingBrowserCreate(ctx, popupWV, w, h)392+ }
393+ if !popupWV.preparePopupShellDirectBrowserCreation() {
394+ return
395+ }
396+ log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
397+ if postPendingCreate != nil {
398+ postPendingCreate(w, h)
399+ }
400+}
401+
402+func (f *WebViewFactory) schedulePopupShellNativeFallback(ctx context.Context, popupWV *WebView) {
403+ if popupWV == nil {
404+ return
405+ }
406+ popupWV.scheduleNativePopupFallback(nativePopupAttachFallbackDelay, func() {
407+ popupWV.runOnGTK(func() {
408+ if !popupWV.startNativePopupFallback() {
409+ return
410+ }
411+ logging.FromContext(ctx).Warn().
412+ Uint64("webview_id", uint64(popupWV.id)).
413+ Msg("cef: native popup attach timed out, starting direct popup fallback")
414+ synced := popupWV.syncViewportNowOnGTK(ctx, "native-popup-fallback-timeout")
415+ logging.FromContext(ctx).Debug().
416+ Uint64("webview_id", uint64(popupWV.id)).
417+ Bool("browser_ready", synced).
418+ Msg("cef: popup fallback viewport sync requested")
419+ })
352 })420 })
353- return popupWV, nil
354 }421 }
M internal/infrastructure/cef/factory_resize_test.go +169 -0
@@ -55,6 +55,175 @@ func TestShouldStartBrowserCreateFromSizeObserver_FalseAfterPendingCreateConsume
5555 require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
5656 }
5757
58+func TestInitialBrowserCreateSizeReadyFromObserver(t *testing.T) {
59+ tests := []struct {
60+ name string
61+ width int32
62+ height int32
63+ want bool
64+ }{
65+ {name: "rejects bootstrap one by one", width: 1, height: 1, want: false},
66+ {name: "rejects zero width", width: 0, height: 480, want: false},
67+ {name: "rejects one pixel height", width: 640, height: 1, want: false},
68+ {name: "accepts real positive size", width: 640, height: 480, want: true},
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ require.Equal(t, tt.want, initialBrowserCreateSizeReadyFromObserver(tt.width, tt.height))
74+ })
75+ }
76+}
77+
78+func TestHandleInitialBrowserCreateSizeObserver_IgnoresBootstrapForNormalWebView(t *testing.T) {
79+ factory := &WebViewFactory{}
80+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
81+ prepared := false
82+ resized := false
83+
84+ handled := factory.handleInitialBrowserCreateSizeObserver(
85+ context.Background(),
86+ wv,
87+ func(int32, int32) { resized = true },
88+ func() error {
89+ prepared = true
90+ return nil
91+ },
92+ 1,
93+ 1,
94+ )
95+
96+ require.True(t, handled)
97+ require.False(t, prepared)
98+ require.False(t, resized)
99+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
100+}
101+
102+func TestHandleInitialBrowserCreateSizeObserver_BootstrapPopupArmsFallbackWithoutPreparing(t *testing.T) {
103+ factory := &WebViewFactory{}
104+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
105+ prepared := false
106+ var scheduled func()
107+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
108+ scheduled = fn
109+ return stubStoppableTimer{}
110+ }
111+
112+ handled := factory.handleInitialBrowserCreateSizeObserver(
113+ context.Background(),
114+ wv,
115+ func(w, h int32) {
116+ factory.handlePopupShellInitialResize(context.Background(), wv, nil, w, h)
117+ },
118+ func() error {
119+ prepared = true
120+ return nil
121+ },
122+ 1,
123+ 1,
124+ )
125+
126+ require.True(t, handled)
127+ require.False(t, prepared)
128+ require.NotNil(t, scheduled)
129+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
130+}
131+
132+func TestHandleInitialBrowserCreateSizeObserver_ReadySizePreparesAndConsumesInitialResize(t *testing.T) {
133+ factory := &WebViewFactory{}
134+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
135+ prepared := 0
136+ resizes := 0
137+
138+ handled := factory.handleInitialBrowserCreateSizeObserver(
139+ context.Background(),
140+ wv,
141+ func(int32, int32) { resizes++ },
142+ func() error {
143+ prepared++
144+ return nil
145+ },
146+ 640,
147+ 480,
148+ )
149+
150+ require.True(t, handled)
151+ require.Equal(t, 1, prepared)
152+ require.Equal(t, 1, resizes)
153+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
154+}
155+
156+func TestHandleInitialBrowserCreateSizeObserver_ReadySizeAfterPopupFallbackPostsCreate(t *testing.T) {
157+ factory := &WebViewFactory{}
158+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
159+ prepared := 0
160+ posted := 0
161+ var scheduled func()
162+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
163+ scheduled = fn
164+ return stubStoppableTimer{}
165+ }
166+ onFirstResize := func(w, h int32) {
167+ factory.handlePopupShellInitialResize(context.Background(), wv, func(int32, int32) {
168+ posted++
169+ }, w, h)
170+ }
171+
172+ handled := factory.handleInitialBrowserCreateSizeObserver(
173+ context.Background(),
174+ wv,
175+ onFirstResize,
176+ func() error {
177+ prepared++
178+ return nil
179+ },
180+ 0,
181+ 480,
182+ )
183+ require.True(t, handled)
184+ require.Equal(t, 0, prepared)
185+ require.Equal(t, 0, posted)
186+ require.NotNil(t, scheduled)
187+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
188+
189+ scheduled()
190+ require.True(t, wv.awaitsBrowserCreateFromNativePopupFallback())
191+
192+ handled = factory.handleInitialBrowserCreateSizeObserver(
193+ context.Background(),
194+ wv,
195+ onFirstResize,
196+ func() error {
197+ prepared++
198+ return nil
199+ },
200+ 640,
201+ 480,
202+ )
203+ require.True(t, handled)
204+ require.Equal(t, 1, prepared)
205+ require.Equal(t, 1, posted)
206+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
207+}
208+
209+func TestSchedulePopupShellNativeFallback_StartsFallbackOnTimeout(t *testing.T) {
210+ factory := &WebViewFactory{}
211+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
212+ var scheduled func()
213+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
214+ scheduled = fn
215+ return stubStoppableTimer{}
216+ }
217+
218+ factory.schedulePopupShellNativeFallback(context.Background(), wv)
219+ require.NotNil(t, scheduled)
220+
221+ scheduled()
222+
223+ require.True(t, wv.nativePopupFallbackStarted)
224+ require.False(t, wv.nativePopupCandidate)
225+}
226+
58227 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
59228 oldNewTask := cefNewTask
60229 oldPostTask := cefPostTask
@@ -55,6 +55,175 @@ func TestShouldStartBrowserCreateFromSizeObserver_FalseAfterPendingCreateConsume
55 require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())55 require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
56 }56 }
57 57
58+func TestInitialBrowserCreateSizeReadyFromObserver(t *testing.T) {
59+ tests := []struct {
60+ name string
61+ width int32
62+ height int32
63+ want bool
64+ }{
65+ {name: "rejects bootstrap one by one", width: 1, height: 1, want: false},
66+ {name: "rejects zero width", width: 0, height: 480, want: false},
67+ {name: "rejects one pixel height", width: 640, height: 1, want: false},
68+ {name: "accepts real positive size", width: 640, height: 480, want: true},
69+ }
70+
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ require.Equal(t, tt.want, initialBrowserCreateSizeReadyFromObserver(tt.width, tt.height))
74+ })
75+ }
76+}
77+
78+func TestHandleInitialBrowserCreateSizeObserver_IgnoresBootstrapForNormalWebView(t *testing.T) {
79+ factory := &WebViewFactory{}
80+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
81+ prepared := false
82+ resized := false
83+
84+ handled := factory.handleInitialBrowserCreateSizeObserver(
85+ context.Background(),
86+ wv,
87+ func(int32, int32) { resized = true },
88+ func() error {
89+ prepared = true
90+ return nil
91+ },
92+ 1,
93+ 1,
94+ )
95+
96+ require.True(t, handled)
97+ require.False(t, prepared)
98+ require.False(t, resized)
99+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
100+}
101+
102+func TestHandleInitialBrowserCreateSizeObserver_BootstrapPopupArmsFallbackWithoutPreparing(t *testing.T) {
103+ factory := &WebViewFactory{}
104+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
105+ prepared := false
106+ var scheduled func()
107+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
108+ scheduled = fn
109+ return stubStoppableTimer{}
110+ }
111+
112+ handled := factory.handleInitialBrowserCreateSizeObserver(
113+ context.Background(),
114+ wv,
115+ func(w, h int32) {
116+ factory.handlePopupShellInitialResize(context.Background(), wv, nil, w, h)
117+ },
118+ func() error {
119+ prepared = true
120+ return nil
121+ },
122+ 1,
123+ 1,
124+ )
125+
126+ require.True(t, handled)
127+ require.False(t, prepared)
128+ require.NotNil(t, scheduled)
129+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
130+}
131+
132+func TestHandleInitialBrowserCreateSizeObserver_ReadySizePreparesAndConsumesInitialResize(t *testing.T) {
133+ factory := &WebViewFactory{}
134+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
135+ prepared := 0
136+ resizes := 0
137+
138+ handled := factory.handleInitialBrowserCreateSizeObserver(
139+ context.Background(),
140+ wv,
141+ func(int32, int32) { resizes++ },
142+ func() error {
143+ prepared++
144+ return nil
145+ },
146+ 640,
147+ 480,
148+ )
149+
150+ require.True(t, handled)
151+ require.Equal(t, 1, prepared)
152+ require.Equal(t, 1, resizes)
153+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
154+}
155+
156+func TestHandleInitialBrowserCreateSizeObserver_ReadySizeAfterPopupFallbackPostsCreate(t *testing.T) {
157+ factory := &WebViewFactory{}
158+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
159+ prepared := 0
160+ posted := 0
161+ var scheduled func()
162+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
163+ scheduled = fn
164+ return stubStoppableTimer{}
165+ }
166+ onFirstResize := func(w, h int32) {
167+ factory.handlePopupShellInitialResize(context.Background(), wv, func(int32, int32) {
168+ posted++
169+ }, w, h)
170+ }
171+
172+ handled := factory.handleInitialBrowserCreateSizeObserver(
173+ context.Background(),
174+ wv,
175+ onFirstResize,
176+ func() error {
177+ prepared++
178+ return nil
179+ },
180+ 0,
181+ 480,
182+ )
183+ require.True(t, handled)
184+ require.Equal(t, 0, prepared)
185+ require.Equal(t, 0, posted)
186+ require.NotNil(t, scheduled)
187+ require.True(t, wv.shouldStartBrowserCreateFromSizeObserver())
188+
189+ scheduled()
190+ require.True(t, wv.awaitsBrowserCreateFromNativePopupFallback())
191+
192+ handled = factory.handleInitialBrowserCreateSizeObserver(
193+ context.Background(),
194+ wv,
195+ onFirstResize,
196+ func() error {
197+ prepared++
198+ return nil
199+ },
200+ 640,
201+ 480,
202+ )
203+ require.True(t, handled)
204+ require.Equal(t, 1, prepared)
205+ require.Equal(t, 1, posted)
206+ require.False(t, wv.shouldStartBrowserCreateFromSizeObserver())
207+}
208+
209+func TestSchedulePopupShellNativeFallback_StartsFallbackOnTimeout(t *testing.T) {
210+ factory := &WebViewFactory{}
211+ wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
212+ var scheduled func()
213+ wv.nativePopupFallbackSchedule = func(_ time.Duration, fn func()) stoppableTimer {
214+ scheduled = fn
215+ return stubStoppableTimer{}
216+ }
217+
218+ factory.schedulePopupShellNativeFallback(context.Background(), wv)
219+ require.NotNil(t, scheduled)
220+
221+ scheduled()
222+
223+ require.True(t, wv.nativePopupFallbackStarted)
224+ require.False(t, wv.nativePopupCandidate)
225+}
226+
58 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {227 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
59 oldNewTask := cefNewTask228 oldNewTask := cefNewTask
60 oldPostTask := cefPostTask229 oldPostTask := cefPostTask
M internal/infrastructure/cef/webview.go +9 -0
@@ -767,6 +767,15 @@ func (wv *WebView) awaitsNativePopupAttachment() bool {
767767 return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)
768768 }
769769
770+func (wv *WebView) awaitsBrowserCreateFromNativePopupFallback() bool {
771+ if wv == nil {
772+ return false
773+ }
774+ wv.mu.RLock()
775+ defer wv.mu.RUnlock()
776+ return wv.browser == nil && wv.nativePopupFallbackStarted && wv.pendingCreate != nil
777+}
778+
770779 func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {
771780 if wv != nil && wv.nativePopupFallbackSchedule != nil {
772781 return wv.nativePopupFallbackSchedule
@@ -767,6 +767,15 @@ func (wv *WebView) awaitsNativePopupAttachment() bool {
767 return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)767 return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)
768 }768 }
769 769
770+func (wv *WebView) awaitsBrowserCreateFromNativePopupFallback() bool {
771+ if wv == nil {
772+ return false
773+ }
774+ wv.mu.RLock()
775+ defer wv.mu.RUnlock()
776+ return wv.browser == nil && wv.nativePopupFallbackStarted && wv.pendingCreate != nil
777+}
778+
770 func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {779 func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {
771 if wv != nil && wv.nativePopupFallbackSchedule != nil {780 if wv != nil && wv.nativePopupFallbackSchedule != nil {
772 return wv.nativePopupFallbackSchedule781 return wv.nativePopupFallbackSchedule
M internal/ui/input/gesture.go +51 -5
@@ -29,10 +29,14 @@ const (
2929 // GestureHandler handles mouse button gestures for navigation.
3030 // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.
3131 type GestureHandler struct {
32- clickGesture *gtk.GestureClick
32+ clickGesture *gtk.GestureClick
33+ widget *gtk.Widget
34+ pressedHandlerID uint
35+ destroyHandlerID uint
3336
3437 // Callback retention: must stay reachable by Go GC.
3538 pressedCb func(gtk.GestureClick, int, float64, float64)
39+ destroyCb func(gtk.Widget)
3640
3741 // Action handler callback (fallback if no direct navigator)
3842 onAction ActionHandler
@@ -81,6 +85,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
8185 log.Error().Msg("cannot attach gesture handler to nil widget")
8286 return
8387 }
88+ h.detachExistingAttachment()
8489
8590 // Create click gesture that listens to all buttons
8691 h.clickGesture = gtk.NewGestureClick()
@@ -96,7 +101,12 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
96101 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
97102 h.handlePressed(nPress)
98103 }
99- h.clickGesture.ConnectPressed(&h.pressedCb)
104+ h.pressedHandlerID = h.clickGesture.ConnectPressed(&h.pressedCb)
105+ h.widget = widget
106+ h.destroyCb = func(_ gtk.Widget) {
107+ h.Detach()
108+ }
109+ h.destroyHandlerID = widget.ConnectDestroy(&h.destroyCb)
100110
101111 // Add controller to widget
102112 widget.AddController(&h.clickGesture.EventController)
@@ -160,10 +170,46 @@ func (h *GestureHandler) handlePressed(nPress int) {
160170 }
161171 }
162172
163-// Detach removes the gesture handler.
164-// Note: GTK handles cleanup when the widget is destroyed,
165-// but we clear our reference here.
173+func (h *GestureHandler) detachExistingAttachment() {
174+ if h == nil {
175+ return
176+ }
177+ h.mu.RLock()
178+ attached := h.widget != nil || h.clickGesture != nil || h.pressedHandlerID != 0 || h.destroyHandlerID != 0
179+ h.mu.RUnlock()
180+ if attached {
181+ h.Detach()
182+ }
183+}
184+
185+// Detach removes the gesture handler and disconnects its GTK signals.
166186 func (h *GestureHandler) Detach() {
187+ if h == nil {
188+ return
189+ }
190+ h.mu.Lock()
191+ widget := h.widget
192+ clickGesture := h.clickGesture
193+ pressedHandlerID := h.pressedHandlerID
194+ destroyHandlerID := h.destroyHandlerID
195+
196+ h.widget = nil
167197 h.clickGesture = nil
198+ h.pressedHandlerID = 0
199+ h.destroyHandlerID = 0
168200 h.pressedCb = nil
201+ h.destroyCb = nil
202+ h.onAction = nil
203+ h.navigator = nil
204+ h.mu.Unlock()
205+
206+ if clickGesture != nil && pressedHandlerID != 0 {
207+ clickGesture.DisconnectSignal(pressedHandlerID)
208+ }
209+ if widget != nil && destroyHandlerID != 0 {
210+ widget.DisconnectSignal(destroyHandlerID)
211+ }
212+ if widget != nil && clickGesture != nil {
213+ widget.RemoveController(&clickGesture.EventController)
214+ }
169215 }
@@ -29,10 +29,14 @@ const (
29 // GestureHandler handles mouse button gestures for navigation.29 // GestureHandler handles mouse button gestures for navigation.
30 // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.30 // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets.
31 type GestureHandler struct {31 type GestureHandler struct {
32- clickGesture *gtk.GestureClick32+ clickGesture *gtk.GestureClick
33+ widget *gtk.Widget
34+ pressedHandlerID uint
35+ destroyHandlerID uint
33 36
34 // Callback retention: must stay reachable by Go GC.37 // Callback retention: must stay reachable by Go GC.
35 pressedCb func(gtk.GestureClick, int, float64, float64)38 pressedCb func(gtk.GestureClick, int, float64, float64)
39+ destroyCb func(gtk.Widget)
36 40
37 // Action handler callback (fallback if no direct navigator)41 // Action handler callback (fallback if no direct navigator)
38 onAction ActionHandler42 onAction ActionHandler
@@ -81,6 +85,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
81 log.Error().Msg("cannot attach gesture handler to nil widget")85 log.Error().Msg("cannot attach gesture handler to nil widget")
82 return86 return
83 }87 }
88+ h.detachExistingAttachment()
84 89
85 // Create click gesture that listens to all buttons90 // Create click gesture that listens to all buttons
86 h.clickGesture = gtk.NewGestureClick()91 h.clickGesture = gtk.NewGestureClick()
@@ -96,7 +101,12 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) {
96 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {101 h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) {
97 h.handlePressed(nPress)102 h.handlePressed(nPress)
98 }103 }
99- h.clickGesture.ConnectPressed(&h.pressedCb)104+ h.pressedHandlerID = h.clickGesture.ConnectPressed(&h.pressedCb)
105+ h.widget = widget
106+ h.destroyCb = func(_ gtk.Widget) {
107+ h.Detach()
108+ }
109+ h.destroyHandlerID = widget.ConnectDestroy(&h.destroyCb)
100 110
101 // Add controller to widget111 // Add controller to widget
102 widget.AddController(&h.clickGesture.EventController)112 widget.AddController(&h.clickGesture.EventController)
@@ -160,10 +170,46 @@ func (h *GestureHandler) handlePressed(nPress int) {
160 }170 }
161 }171 }
162 172
163-// Detach removes the gesture handler.173+func (h *GestureHandler) detachExistingAttachment() {
164-// Note: GTK handles cleanup when the widget is destroyed,174+ if h == nil {
165-// but we clear our reference here.175+ return
176+ }
177+ h.mu.RLock()
178+ attached := h.widget != nil || h.clickGesture != nil || h.pressedHandlerID != 0 || h.destroyHandlerID != 0
179+ h.mu.RUnlock()
180+ if attached {
181+ h.Detach()
182+ }
183+}
184+
185+// Detach removes the gesture handler and disconnects its GTK signals.
166 func (h *GestureHandler) Detach() {186 func (h *GestureHandler) Detach() {
187+ if h == nil {
188+ return
189+ }
190+ h.mu.Lock()
191+ widget := h.widget
192+ clickGesture := h.clickGesture
193+ pressedHandlerID := h.pressedHandlerID
194+ destroyHandlerID := h.destroyHandlerID
195+
196+ h.widget = nil
167 h.clickGesture = nil197 h.clickGesture = nil
198+ h.pressedHandlerID = 0
199+ h.destroyHandlerID = 0
168 h.pressedCb = nil200 h.pressedCb = nil
201+ h.destroyCb = nil
202+ h.onAction = nil
203+ h.navigator = nil
204+ h.mu.Unlock()
205+
206+ if clickGesture != nil && pressedHandlerID != 0 {
207+ clickGesture.DisconnectSignal(pressedHandlerID)
208+ }
209+ if widget != nil && destroyHandlerID != 0 {
210+ widget.DisconnectSignal(destroyHandlerID)
211+ }
212+ if widget != nil && clickGesture != nil {
213+ widget.RemoveController(&clickGesture.EventController)
214+ }
169 }215 }
A internal/ui/input/gesture_test.go +52 -0
@@ -0,0 +1,52 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/puregotk/v4/gtk"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type testGestureNavigator struct{}
12+
13+func (testGestureNavigator) GoBackDirect() {}
14+func (testGestureNavigator) GoForwardDirect() {}
15+
16+func TestGestureHandlerAttachClearsExistingAttachmentState(t *testing.T) {
17+ h := NewGestureHandler(context.Background())
18+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
19+ h.destroyCb = func(gtk.Widget) {}
20+ h.pressedHandlerID = 1
21+ h.destroyHandlerID = 2
22+ h.onAction = func(context.Context, Action) error { return nil }
23+ h.navigator = testGestureNavigator{}
24+
25+ h.detachExistingAttachment()
26+
27+ assert.Nil(t, h.pressedCb)
28+ assert.Nil(t, h.destroyCb)
29+ assert.Zero(t, h.pressedHandlerID)
30+ assert.Zero(t, h.destroyHandlerID)
31+ assert.Nil(t, h.onAction)
32+ assert.Nil(t, h.navigator)
33+}
34+
35+func TestGestureHandlerDetachClearsRetainedCallbacksAndState(t *testing.T) {
36+ h := NewGestureHandler(context.Background())
37+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
38+ h.destroyCb = func(gtk.Widget) {}
39+ h.pressedHandlerID = 1
40+ h.destroyHandlerID = 2
41+ h.onAction = func(context.Context, Action) error { return nil }
42+ h.navigator = testGestureNavigator{}
43+
44+ h.Detach()
45+
46+ assert.Nil(t, h.pressedCb)
47+ assert.Nil(t, h.destroyCb)
48+ assert.Zero(t, h.pressedHandlerID)
49+ assert.Zero(t, h.destroyHandlerID)
50+ assert.Nil(t, h.onAction)
51+ assert.Nil(t, h.navigator)
52+}
@@ -0,0 +1,52 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/puregotk/v4/gtk"
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+type testGestureNavigator struct{}
12+
13+func (testGestureNavigator) GoBackDirect() {}
14+func (testGestureNavigator) GoForwardDirect() {}
15+
16+func TestGestureHandlerAttachClearsExistingAttachmentState(t *testing.T) {
17+ h := NewGestureHandler(context.Background())
18+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
19+ h.destroyCb = func(gtk.Widget) {}
20+ h.pressedHandlerID = 1
21+ h.destroyHandlerID = 2
22+ h.onAction = func(context.Context, Action) error { return nil }
23+ h.navigator = testGestureNavigator{}
24+
25+ h.detachExistingAttachment()
26+
27+ assert.Nil(t, h.pressedCb)
28+ assert.Nil(t, h.destroyCb)
29+ assert.Zero(t, h.pressedHandlerID)
30+ assert.Zero(t, h.destroyHandlerID)
31+ assert.Nil(t, h.onAction)
32+ assert.Nil(t, h.navigator)
33+}
34+
35+func TestGestureHandlerDetachClearsRetainedCallbacksAndState(t *testing.T) {
36+ h := NewGestureHandler(context.Background())
37+ h.pressedCb = func(gtk.GestureClick, int, float64, float64) {}
38+ h.destroyCb = func(gtk.Widget) {}
39+ h.pressedHandlerID = 1
40+ h.destroyHandlerID = 2
41+ h.onAction = func(context.Context, Action) error { return nil }
42+ h.navigator = testGestureNavigator{}
43+
44+ h.Detach()
45+
46+ assert.Nil(t, h.pressedCb)
47+ assert.Nil(t, h.destroyCb)
48+ assert.Zero(t, h.pressedHandlerID)
49+ assert.Zero(t, h.destroyHandlerID)
50+ assert.Nil(t, h.onAction)
51+ assert.Nil(t, h.navigator)
52+}
M internal/ui/input/global_shortcuts.go +328 -77
@@ -12,19 +12,24 @@ import (
1212 "github.com/bnema/puregotk/v4/gdk"
1313 "github.com/bnema/puregotk/v4/glib"
1414 "github.com/bnema/puregotk/v4/gtk"
15+ "github.com/rs/zerolog"
1516 )
1617
1718 // GlobalShortcutHandler manages keyboard shortcuts that must work globally,
1819 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
1920 // to intercept shortcuts before they reach the WebView.
2021 type GlobalShortcutHandler struct {
21- controller *gtk.ShortcutController
22- window *gtk.ApplicationWindow
23- kbHandler *KeyboardHandler
24- onAction ActionHandler
25- ctx context.Context
26- registered map[KeyBinding]Action
27- lastDispatchAt map[Action]time.Time
22+ controller *gtk.ShortcutController
23+ releaseController *gtk.EventControllerKey
24+ window *gtk.ApplicationWindow
25+ kbHandler *KeyboardHandler
26+ onAction ActionHandler
27+ ctx context.Context
28+ registered map[KeyBinding]Action
29+ lastDispatchAt map[Action]time.Time
30+ heldShortcuts map[globalShortcutHoldKey]struct{}
31+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
32+ keyReleasedHandlerID uint
2833 // generation is mutated only from the GTK main thread alongside controller replacement.
2934 generation uint64
3035
@@ -46,67 +51,29 @@ func NewGlobalShortcutHandler(
4651 log.Debug().Msg("creating global shortcut handler")
4752
4853 h := &GlobalShortcutHandler{
49- controller: gtk.NewShortcutController(),
50- window: window,
51- kbHandler: kbHandler,
52- onAction: onAction,
53- ctx: ctx,
54- callbacks: make([]gtk.ShortcutFunc, 0),
55- registered: make(map[KeyBinding]Action),
56- lastDispatchAt: make(map[Action]time.Time),
54+ controller: gtk.NewShortcutController(),
55+ releaseController: gtk.NewEventControllerKey(),
56+ window: window,
57+ kbHandler: kbHandler,
58+ onAction: onAction,
59+ ctx: ctx,
60+ callbacks: make([]gtk.ShortcutFunc, 0),
61+ registered: make(map[KeyBinding]Action),
62+ lastDispatchAt: make(map[Action]time.Time),
63+ heldShortcuts: make(map[globalShortcutHoldKey]struct{}),
5764 }
5865
5966 if h.controller == nil {
6067 log.Error().Msg("failed to create shortcut controller")
6168 return nil
6269 }
63-
64- // Set global scope - this is the key to making shortcuts work
65- // even when WebView has focus
66- h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
67-
68- // Register Alt+1 through Alt+9 for tab switching
69- tabActions := []Action{
70- ActionSwitchTabIndex1,
71- ActionSwitchTabIndex2,
72- ActionSwitchTabIndex3,
73- ActionSwitchTabIndex4,
74- ActionSwitchTabIndex5,
75- ActionSwitchTabIndex6,
76- ActionSwitchTabIndex7,
77- ActionSwitchTabIndex8,
78- ActionSwitchTabIndex9,
79- }
80-
81- for i, action := range tabActions {
82- keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
83- h.registerShortcut(keyval, gdk.AltMaskValue, action)
84- log.Trace().
85- Uint("keyval", keyval).
86- Str("action", string(action)).
87- Msg("registered global shortcut")
70+ if h.releaseController == nil {
71+ log.Error().Msg("failed to create global shortcut release controller")
72+ return nil
8873 }
8974
90- // Alt+0 for tab 10
91- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
92- log.Trace().
93- Uint("keyval", uint(gdk.KEY_0)).
94- Str("action", string(ActionSwitchTabIndex10)).
95- Msg("registered global shortcut")
96-
97- // Alt+Tab for switching to last active tab
98- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
99- log.Trace().
100- Uint("keyval", uint(gdk.KEY_Tab)).
101- Str("action", string(ActionSwitchLastTab)).
102- Msg("registered global shortcut")
103-
104- // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
105- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
106- log.Trace().
107- Uint("keyval", uint(gdk.KEY_s)).
108- Str("action", string(ActionOpenSessionManager)).
109- Msg("registered global shortcut")
75+ h.configureGlobalControllers()
76+ h.registerDefaultGlobalShortcuts(log)
11077
11178 if workspace != nil {
11279 actionMap := globalShortcutActionMap()
@@ -156,6 +123,7 @@ func NewGlobalShortcutHandler(
156123 }
157124
158125 // Attach to window
126+ window.AddController(&h.releaseController.EventController)
159127 window.AddController(&h.controller.EventController)
160128
161129 log.Debug().
@@ -165,6 +133,62 @@ func NewGlobalShortcutHandler(
165133 return h
166134 }
167135
136+func (h *GlobalShortcutHandler) configureGlobalControllers() {
137+ // Set global scope - this is the key to making shortcuts work
138+ // even when WebView has focus.
139+ h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
140+ h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
141+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
142+ h.releaseHeldGlobalShortcuts(keyval, keycode)
143+ }
144+ h.keyReleasedHandlerID = h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
145+}
146+
147+func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logger) {
148+ // Register Alt+1 through Alt+9 for tab switching.
149+ tabActions := []Action{
150+ ActionSwitchTabIndex1,
151+ ActionSwitchTabIndex2,
152+ ActionSwitchTabIndex3,
153+ ActionSwitchTabIndex4,
154+ ActionSwitchTabIndex5,
155+ ActionSwitchTabIndex6,
156+ ActionSwitchTabIndex7,
157+ ActionSwitchTabIndex8,
158+ ActionSwitchTabIndex9,
159+ }
160+
161+ for i, action := range tabActions {
162+ keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
163+ h.registerShortcut(keyval, gdk.AltMaskValue, action)
164+ log.Trace().
165+ Uint("keyval", keyval).
166+ Str("action", string(action)).
167+ Msg("registered global shortcut")
168+ }
169+
170+ // Alt+0 for tab 10.
171+ h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
172+ log.Trace().
173+ Uint("keyval", uint(gdk.KEY_0)).
174+ Str("action", string(ActionSwitchTabIndex10)).
175+ Msg("registered global shortcut")
176+
177+ // Alt+Tab for switching to last active tab.
178+ h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
179+ log.Trace().
180+ Uint("keyval", uint(gdk.KEY_Tab)).
181+ Str("action", string(ActionSwitchLastTab)).
182+ Msg("registered global shortcut")
183+
184+ // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
185+ h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
186+ log.Trace().
187+ Uint("keyval", uint(gdk.KEY_s)).
188+ Str("action", string(ActionOpenSessionManager)).
189+ Msg("registered global shortcut")
190+}
191+
168192 // registerShortcut creates and registers a single shortcut with the controller.
169193 func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
170194 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
@@ -191,11 +215,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191215 // dispatch one-shot actions after a shortcut reload.
192216 actionToDispatch := action
193217 generation := h.generation
218+ bindingForLog := binding
194219 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
195220 log := logging.FromContext(h.ctx)
196221 if h.isStaleGeneration(generation) {
197222 log.Trace().
198223 Str("action", string(actionToDispatch)).
224+ Str("shortcut", formatBinding(bindingForLog)).
199225 Uint64("callback_generation", generation).
200226 Uint64("current_generation", h.generation).
201227 Msg("stale global shortcut callback ignored")
@@ -204,17 +230,31 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
204230 if !h.isActiveWindowShortcutHandler() {
205231 log.Trace().
206232 Str("action", string(actionToDispatch)).
233+ Str("shortcut", formatBinding(bindingForLog)).
207234 Msg("inactive window global shortcut callback ignored")
208235 return false
209236 }
237+ eventInfo := h.inspectCurrentShortcutEvent()
238+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
239+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
240+ Msg("global shortcut ignored without current key event")
241+ return true
242+ }
243+ if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
244+ log.Trace().
245+ Str("action", string(actionToDispatch)).
246+ Str("shortcut", formatBinding(bindingForLog)).
247+ Msg("held global shortcut repeat suppressed")
248+ return true
249+ }
210250 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
211251 log.Trace().
212252 Str("action", string(actionToDispatch)).
253+ Str("shortcut", formatBinding(bindingForLog)).
213254 Msg("global shortcut repeat suppressed")
214255 return true
215256 }
216- log.Debug().
217- Str("action", string(actionToDispatch)).
257+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
218258 Msg("global shortcut triggered")
219259
220260 // Mode-enter/exit actions go through KeyboardHandler for modal state
@@ -267,6 +307,120 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
267307 }
268308
269309 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
310+type globalShortcutHoldKey struct {
311+ keyval uint
312+ keycode uint
313+}
314+
315+type globalShortcutEventInfo struct {
316+ hasCurrentEvent bool
317+ eventType gdk.EventType
318+ eventKeyval uint
319+ eventKeycode uint
320+ eventLayout uint
321+ eventLevel uint
322+ eventState gdk.ModifierType
323+ eventConsumedModifiers gdk.ModifierType
324+ eventTime uint32
325+ hasDevice bool
326+ deviceName string
327+ deviceSource gdk.InputSource
328+ controllerState gdk.ModifierType
329+ controllerTime uint32
330+}
331+
332+func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
333+ var info globalShortcutEventInfo
334+ if h == nil || h.controller == nil {
335+ return info
336+ }
337+
338+ info.controllerState = h.controller.GetCurrentEventState()
339+ info.controllerTime = h.controller.GetCurrentEventTime()
340+ if device := h.controller.GetCurrentEventDevice(); device != nil {
341+ info.hasDevice = true
342+ info.deviceName = device.GetName()
343+ info.deviceSource = device.GetSource()
344+ }
345+
346+ event := h.controller.GetCurrentEvent()
347+ if event == nil {
348+ return info
349+ }
350+ info.hasCurrentEvent = true
351+ info.eventType = event.GetEventType()
352+ info.eventState = event.GetModifierState()
353+ info.eventTime = event.GetTime()
354+ if !info.hasDevice {
355+ if device := event.GetDevice(); device != nil {
356+ info.hasDevice = true
357+ info.deviceName = device.GetName()
358+ info.deviceSource = device.GetSource()
359+ }
360+ }
361+
362+ switch info.eventType {
363+ case gdk.KeyPressValue, gdk.KeyReleaseValue:
364+ keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
365+ info.eventKeyval = keyEvent.GetKeyval()
366+ info.eventKeycode = keyEvent.GetKeycode()
367+ info.eventLayout = keyEvent.GetLayout()
368+ info.eventLevel = keyEvent.GetLevel()
369+ info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
370+ }
371+
372+ return info
373+}
374+
375+func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
376+ return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
377+}
378+
379+func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
380+ if evt == nil {
381+ return nil
382+ }
383+ evt = evt.
384+ Str("action", string(action)).
385+ Str("shortcut", formatBinding(binding)).
386+ Uint("shortcut_keyval", binding.Keyval).
387+ Str("shortcut_key", formatKeyval(binding.Keyval)).
388+ Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
389+ Str("controller_modifiers", formatModifierMask(info.controllerState)).
390+ Uint32("controller_event_time", info.controllerTime).
391+ Bool("has_current_event", info.hasCurrentEvent)
392+ if info.hasCurrentEvent {
393+ evt = evt.
394+ Str("event_type", formatEventType(info.eventType)).
395+ Int("event_type_value", int(info.eventType)).
396+ Str("event_modifiers", formatModifierMask(info.eventState)).
397+ Uint32("event_time", info.eventTime)
398+ if info.eventKeyval != 0 {
399+ evt = evt.
400+ Uint("event_keyval", info.eventKeyval).
401+ Str("event_key", formatKeyval(info.eventKeyval))
402+ }
403+ if info.eventKeycode != 0 {
404+ evt = evt.Uint("event_keycode", info.eventKeycode)
405+ }
406+ if info.eventLayout != 0 {
407+ evt = evt.Uint("event_layout", info.eventLayout)
408+ }
409+ if info.eventLevel != 0 {
410+ evt = evt.Uint("event_level", info.eventLevel)
411+ }
412+ if info.eventConsumedModifiers != 0 {
413+ evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
414+ }
415+ }
416+ if info.hasDevice {
417+ evt = evt.
418+ Str("event_device", info.deviceName).
419+ Int("event_device_source", int(info.deviceSource))
420+ }
421+ return evt
422+}
423+
270424 func globalShortcutActionMap() map[string]Action {
271425 return map[string]Action{
272426 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -313,19 +467,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
313467 h.callbacks = make([]gtk.ShortcutFunc, 0)
314468 h.registered = make(map[KeyBinding]Action)
315469 h.lastDispatchAt = make(map[Action]time.Time)
470+ h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
316471
317- // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
318- tabActions := []Action{
319- ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3,
320- ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6,
321- ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9,
322- }
323- for i, action := range tabActions {
324- h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action)
325- }
326- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
327- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
328- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
472+ h.registerDefaultGlobalShortcuts(log)
329473
330474 // Re-register config-driven shortcuts
331475 if workspace != nil {
@@ -382,10 +526,20 @@ func (h *GlobalShortcutHandler) Detach() {
382526 if h.window != nil && h.controller != nil {
383527 h.window.RemoveController(&h.controller.EventController)
384528 }
529+ if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
530+ h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
531+ }
532+ if h.window != nil && h.releaseController != nil {
533+ h.window.RemoveController(&h.releaseController.EventController)
534+ }
385535 h.controller = nil
536+ h.releaseController = nil
537+ h.keyReleasedCb = nil
538+ h.keyReleasedHandlerID = 0
386539 h.callbacks = nil
387540 h.registered = nil
388541 h.lastDispatchAt = nil
542+ h.heldShortcuts = nil
389543 }
390544
391545 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
@@ -398,6 +552,38 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
398552 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
399553 }
400554
555+func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
556+ if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
557+ return false
558+ }
559+ if h.heldShortcuts == nil {
560+ // Defensive: if hold tracking is unavailable during detach/reload,
561+ // suppress rather than risk dispatching a stale global callback.
562+ return true
563+ }
564+ key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
565+ if key.keyval == 0 && key.keycode == 0 {
566+ return false
567+ }
568+ if _, ok := h.heldShortcuts[key]; ok {
569+ return true
570+ }
571+ h.heldShortcuts[key] = struct{}{}
572+ return false
573+}
574+
575+func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
576+ if h == nil || len(h.heldShortcuts) == 0 {
577+ return
578+ }
579+ keyval = normalizeKeyval(keyval)
580+ for key := range h.heldShortcuts {
581+ if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
582+ delete(h.heldShortcuts, key)
583+ }
584+ }
585+}
586+
401587 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
402588 if h == nil {
403589 return false
@@ -419,6 +605,14 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
419605 return false
420606 }
421607
608+func isHeldGlobalShortcutSuppressed(action Action) bool {
609+ if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
610+ return true
611+ }
612+ _, ok := ParseFloatingProfileTarget(action)
613+ return ok
614+}
615+
422616 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
423617 switch action {
424618 case ActionGoBack,
@@ -474,14 +668,71 @@ func formatBinding(binding KeyBinding) string {
474668 if binding.Modifiers&ModAlt != 0 {
475669 parts = append(parts, "alt")
476670 }
477- keyName := gdk.KeyvalName(binding.Keyval)
671+ parts = append(parts, formatKeyval(binding.Keyval))
672+ return strings.Join(parts, "+")
673+}
674+
675+func formatKeyval(keyval uint) string {
676+ if keyval == 0 {
677+ return ""
678+ }
679+ keyName := gdk.KeyvalName(keyval)
478680 if keyName == "" {
479- keyName = fmt.Sprintf("0x%x", binding.Keyval)
681+ return fmt.Sprintf("0x%x", keyval)
682+ }
683+ return strings.ToLower(keyName)
684+}
685+
686+func formatModifierMask(modifiers gdk.ModifierType) string {
687+ if modifiers == 0 {
688+ return "none"
689+ }
690+ parts := make([]string, 0, 4)
691+ if modifiers&gdk.ControlMaskValue != 0 {
692+ parts = append(parts, "ctrl")
693+ }
694+ if modifiers&gdk.ShiftMaskValue != 0 {
695+ parts = append(parts, "shift")
696+ }
697+ if modifiers&gdk.AltMaskValue != 0 {
698+ parts = append(parts, "alt")
699+ }
700+ remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
701+ if remaining != 0 {
702+ parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
480703 }
481- parts = append(parts, strings.ToLower(keyName))
482704 return strings.Join(parts, "+")
483705 }
484706
707+func formatEventType(eventType gdk.EventType) string {
708+ switch eventType {
709+ case gdk.KeyPressValue:
710+ return "key-press"
711+ case gdk.KeyReleaseValue:
712+ return "key-release"
713+ case gdk.ButtonPressValue:
714+ return "button-press"
715+ case gdk.ButtonReleaseValue:
716+ return "button-release"
717+ case gdk.MotionNotifyValue:
718+ return "motion"
719+ case gdk.ScrollValue:
720+ return "scroll"
721+ case gdk.TouchBeginValue:
722+ return "touch-begin"
723+ case gdk.TouchUpdateValue:
724+ return "touch-update"
725+ case gdk.TouchEndValue:
726+ return "touch-end"
727+ case gdk.FocusChangeValue:
728+ return "focus-change"
729+ case gdk.DeleteValue:
730+ return "delete"
731+ default:
732+ return fmt.Sprintf("event-%d", int(eventType))
733+ }
734+}
735+
485736 func isModeAction(action Action) bool {
486737 switch action {
487738 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
@@ -12,19 +12,24 @@ import (
12 "github.com/bnema/puregotk/v4/gdk"12 "github.com/bnema/puregotk/v4/gdk"
13 "github.com/bnema/puregotk/v4/glib"13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/gtk"14 "github.com/bnema/puregotk/v4/gtk"
15+ "github.com/rs/zerolog"
15 )16 )
16 17
17 // GlobalShortcutHandler manages keyboard shortcuts that must work globally,18 // GlobalShortcutHandler manages keyboard shortcuts that must work globally,
18 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL19 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
19 // to intercept shortcuts before they reach the WebView.20 // to intercept shortcuts before they reach the WebView.
20 type GlobalShortcutHandler struct {21 type GlobalShortcutHandler struct {
21- controller *gtk.ShortcutController22+ controller *gtk.ShortcutController
22- window *gtk.ApplicationWindow23+ releaseController *gtk.EventControllerKey
23- kbHandler *KeyboardHandler24+ window *gtk.ApplicationWindow
24- onAction ActionHandler25+ kbHandler *KeyboardHandler
25- ctx context.Context26+ onAction ActionHandler
26- registered map[KeyBinding]Action27+ ctx context.Context
27- lastDispatchAt map[Action]time.Time28+ registered map[KeyBinding]Action
29+ lastDispatchAt map[Action]time.Time
30+ heldShortcuts map[globalShortcutHoldKey]struct{}
31+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
32+ keyReleasedHandlerID uint
28 // generation is mutated only from the GTK main thread alongside controller replacement.33 // generation is mutated only from the GTK main thread alongside controller replacement.
29 generation uint6434 generation uint64
30 35
@@ -46,67 +51,29 @@ func NewGlobalShortcutHandler(
46 log.Debug().Msg("creating global shortcut handler")51 log.Debug().Msg("creating global shortcut handler")
47 52
48 h := &GlobalShortcutHandler{53 h := &GlobalShortcutHandler{
49- controller: gtk.NewShortcutController(),54+ controller: gtk.NewShortcutController(),
50- window: window,55+ releaseController: gtk.NewEventControllerKey(),
51- kbHandler: kbHandler,56+ window: window,
52- onAction: onAction,57+ kbHandler: kbHandler,
53- ctx: ctx,58+ onAction: onAction,
54- callbacks: make([]gtk.ShortcutFunc, 0),59+ ctx: ctx,
55- registered: make(map[KeyBinding]Action),60+ callbacks: make([]gtk.ShortcutFunc, 0),
56- lastDispatchAt: make(map[Action]time.Time),61+ registered: make(map[KeyBinding]Action),
62+ lastDispatchAt: make(map[Action]time.Time),
63+ heldShortcuts: make(map[globalShortcutHoldKey]struct{}),
57 }64 }
58 65
59 if h.controller == nil {66 if h.controller == nil {
60 log.Error().Msg("failed to create shortcut controller")67 log.Error().Msg("failed to create shortcut controller")
61 return nil68 return nil
62 }69 }
63-70+ if h.releaseController == nil {
64- // Set global scope - this is the key to making shortcuts work71+ log.Error().Msg("failed to create global shortcut release controller")
65- // even when WebView has focus72+ return nil
66- h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
67-
68- // Register Alt+1 through Alt+9 for tab switching
69- tabActions := []Action{
70- ActionSwitchTabIndex1,
71- ActionSwitchTabIndex2,
72- ActionSwitchTabIndex3,
73- ActionSwitchTabIndex4,
74- ActionSwitchTabIndex5,
75- ActionSwitchTabIndex6,
76- ActionSwitchTabIndex7,
77- ActionSwitchTabIndex8,
78- ActionSwitchTabIndex9,
79- }
80-
81- for i, action := range tabActions {
82- keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
83- h.registerShortcut(keyval, gdk.AltMaskValue, action)
84- log.Trace().
85- Uint("keyval", keyval).
86- Str("action", string(action)).
87- Msg("registered global shortcut")
88 }73 }
89 74
90- // Alt+0 for tab 1075+ h.configureGlobalControllers()
91- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)76+ h.registerDefaultGlobalShortcuts(log)
92- log.Trace().
93- Uint("keyval", uint(gdk.KEY_0)).
94- Str("action", string(ActionSwitchTabIndex10)).
95- Msg("registered global shortcut")
96-
97- // Alt+Tab for switching to last active tab
98- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
99- log.Trace().
100- Uint("keyval", uint(gdk.KEY_Tab)).
101- Str("action", string(ActionSwitchLastTab)).
102- Msg("registered global shortcut")
103-
104- // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
105- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
106- log.Trace().
107- Uint("keyval", uint(gdk.KEY_s)).
108- Str("action", string(ActionOpenSessionManager)).
109- Msg("registered global shortcut")
110 77
111 if workspace != nil {78 if workspace != nil {
112 actionMap := globalShortcutActionMap()79 actionMap := globalShortcutActionMap()
@@ -156,6 +123,7 @@ func NewGlobalShortcutHandler(
156 }123 }
157 124
158 // Attach to window125 // Attach to window
126+ window.AddController(&h.releaseController.EventController)
159 window.AddController(&h.controller.EventController)127 window.AddController(&h.controller.EventController)
160 128
161 log.Debug().129 log.Debug().
@@ -165,6 +133,62 @@ func NewGlobalShortcutHandler(
165 return h133 return h
166 }134 }
167 135
136+func (h *GlobalShortcutHandler) configureGlobalControllers() {
137+ // Set global scope - this is the key to making shortcuts work
138+ // even when WebView has focus.
139+ h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
140+ h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
141+ h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
142+ h.releaseHeldGlobalShortcuts(keyval, keycode)
143+ }
144+ h.keyReleasedHandlerID = h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
145+}
146+
147+func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logger) {
148+ // Register Alt+1 through Alt+9 for tab switching.
149+ tabActions := []Action{
150+ ActionSwitchTabIndex1,
151+ ActionSwitchTabIndex2,
152+ ActionSwitchTabIndex3,
153+ ActionSwitchTabIndex4,
154+ ActionSwitchTabIndex5,
155+ ActionSwitchTabIndex6,
156+ ActionSwitchTabIndex7,
157+ ActionSwitchTabIndex8,
158+ ActionSwitchTabIndex9,
159+ }
160+
161+ for i, action := range tabActions {
162+ keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
163+ h.registerShortcut(keyval, gdk.AltMaskValue, action)
164+ log.Trace().
165+ Uint("keyval", keyval).
166+ Str("action", string(action)).
167+ Msg("registered global shortcut")
168+ }
169+
170+ // Alt+0 for tab 10.
171+ h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
172+ log.Trace().
173+ Uint("keyval", uint(gdk.KEY_0)).
174+ Str("action", string(ActionSwitchTabIndex10)).
175+ Msg("registered global shortcut")
176+
177+ // Alt+Tab for switching to last active tab.
178+ h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
179+ log.Trace().
180+ Uint("keyval", uint(gdk.KEY_Tab)).
181+ Str("action", string(ActionSwitchLastTab)).
182+ Msg("registered global shortcut")
183+
184+ // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
185+ h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
186+ log.Trace().
187+ Uint("keyval", uint(gdk.KEY_s)).
188+ Str("action", string(ActionOpenSessionManager)).
189+ Msg("registered global shortcut")
190+}
191+
168 // registerShortcut creates and registers a single shortcut with the controller.192 // registerShortcut creates and registers a single shortcut with the controller.
169 func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {193 func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
170 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}194 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
@@ -191,11 +215,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191 // dispatch one-shot actions after a shortcut reload.215 // dispatch one-shot actions after a shortcut reload.
192 actionToDispatch := action216 actionToDispatch := action
193 generation := h.generation217 generation := h.generation
218+ bindingForLog := binding
194 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {219 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
195 log := logging.FromContext(h.ctx)220 log := logging.FromContext(h.ctx)
196 if h.isStaleGeneration(generation) {221 if h.isStaleGeneration(generation) {
197 log.Trace().222 log.Trace().
198 Str("action", string(actionToDispatch)).223 Str("action", string(actionToDispatch)).
224+ Str("shortcut", formatBinding(bindingForLog)).
199 Uint64("callback_generation", generation).225 Uint64("callback_generation", generation).
200 Uint64("current_generation", h.generation).226 Uint64("current_generation", h.generation).
201 Msg("stale global shortcut callback ignored")227 Msg("stale global shortcut callback ignored")
@@ -204,17 +230,31 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
204 if !h.isActiveWindowShortcutHandler() {230 if !h.isActiveWindowShortcutHandler() {
205 log.Trace().231 log.Trace().
206 Str("action", string(actionToDispatch)).232 Str("action", string(actionToDispatch)).
233+ Str("shortcut", formatBinding(bindingForLog)).
207 Msg("inactive window global shortcut callback ignored")234 Msg("inactive window global shortcut callback ignored")
208 return false235 return false
209 }236 }
237+ eventInfo := h.inspectCurrentShortcutEvent()
238+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
239+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
240+ Msg("global shortcut ignored without current key event")
241+ return true
242+ }
243+ if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
244+ log.Trace().
245+ Str("action", string(actionToDispatch)).
246+ Str("shortcut", formatBinding(bindingForLog)).
247+ Msg("held global shortcut repeat suppressed")
248+ return true
249+ }
210 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {250 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
211 log.Trace().251 log.Trace().
212 Str("action", string(actionToDispatch)).252 Str("action", string(actionToDispatch)).
253+ Str("shortcut", formatBinding(bindingForLog)).
213 Msg("global shortcut repeat suppressed")254 Msg("global shortcut repeat suppressed")
214 return true255 return true
215 }256 }
216- log.Debug().257+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
217- Str("action", string(actionToDispatch)).
218 Msg("global shortcut triggered")258 Msg("global shortcut triggered")
219 259
220 // Mode-enter/exit actions go through KeyboardHandler for modal state260 // Mode-enter/exit actions go through KeyboardHandler for modal state
@@ -267,6 +307,120 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
267 }307 }
268 308
269 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.309 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
310+type globalShortcutHoldKey struct {
311+ keyval uint
312+ keycode uint
313+}
314+
315+type globalShortcutEventInfo struct {
316+ hasCurrentEvent bool
317+ eventType gdk.EventType
318+ eventKeyval uint
319+ eventKeycode uint
320+ eventLayout uint
321+ eventLevel uint
322+ eventState gdk.ModifierType
323+ eventConsumedModifiers gdk.ModifierType
324+ eventTime uint32
325+ hasDevice bool
326+ deviceName string
327+ deviceSource gdk.InputSource
328+ controllerState gdk.ModifierType
329+ controllerTime uint32
330+}
331+
332+func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
333+ var info globalShortcutEventInfo
334+ if h == nil || h.controller == nil {
335+ return info
336+ }
337+
338+ info.controllerState = h.controller.GetCurrentEventState()
339+ info.controllerTime = h.controller.GetCurrentEventTime()
340+ if device := h.controller.GetCurrentEventDevice(); device != nil {
341+ info.hasDevice = true
342+ info.deviceName = device.GetName()
343+ info.deviceSource = device.GetSource()
344+ }
345+
346+ event := h.controller.GetCurrentEvent()
347+ if event == nil {
348+ return info
349+ }
350+ info.hasCurrentEvent = true
351+ info.eventType = event.GetEventType()
352+ info.eventState = event.GetModifierState()
353+ info.eventTime = event.GetTime()
354+ if !info.hasDevice {
355+ if device := event.GetDevice(); device != nil {
356+ info.hasDevice = true
357+ info.deviceName = device.GetName()
358+ info.deviceSource = device.GetSource()
359+ }
360+ }
361+
362+ switch info.eventType {
363+ case gdk.KeyPressValue, gdk.KeyReleaseValue:
364+ keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
365+ info.eventKeyval = keyEvent.GetKeyval()
366+ info.eventKeycode = keyEvent.GetKeycode()
367+ info.eventLayout = keyEvent.GetLayout()
368+ info.eventLevel = keyEvent.GetLevel()
369+ info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
370+ }
371+
372+ return info
373+}
374+
375+func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
376+ return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
377+}
378+
379+func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
380+ if evt == nil {
381+ return nil
382+ }
383+ evt = evt.
384+ Str("action", string(action)).
385+ Str("shortcut", formatBinding(binding)).
386+ Uint("shortcut_keyval", binding.Keyval).
387+ Str("shortcut_key", formatKeyval(binding.Keyval)).
388+ Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
389+ Str("controller_modifiers", formatModifierMask(info.controllerState)).
390+ Uint32("controller_event_time", info.controllerTime).
391+ Bool("has_current_event", info.hasCurrentEvent)
392+ if info.hasCurrentEvent {
393+ evt = evt.
394+ Str("event_type", formatEventType(info.eventType)).
395+ Int("event_type_value", int(info.eventType)).
396+ Str("event_modifiers", formatModifierMask(info.eventState)).
397+ Uint32("event_time", info.eventTime)
398+ if info.eventKeyval != 0 {
399+ evt = evt.
400+ Uint("event_keyval", info.eventKeyval).
401+ Str("event_key", formatKeyval(info.eventKeyval))
402+ }
403+ if info.eventKeycode != 0 {
404+ evt = evt.Uint("event_keycode", info.eventKeycode)
405+ }
406+ if info.eventLayout != 0 {
407+ evt = evt.Uint("event_layout", info.eventLayout)
408+ }
409+ if info.eventLevel != 0 {
410+ evt = evt.Uint("event_level", info.eventLevel)
411+ }
412+ if info.eventConsumedModifiers != 0 {
413+ evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
414+ }
415+ }
416+ if info.hasDevice {
417+ evt = evt.
418+ Str("event_device", info.deviceName).
419+ Int("event_device_source", int(info.deviceSource))
420+ }
421+ return evt
422+}
423+
270 func globalShortcutActionMap() map[string]Action {424 func globalShortcutActionMap() map[string]Action {
271 return map[string]Action{425 return map[string]Action{
272 "toggle_floating_pane": ActionToggleFloatingPane,426 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -313,19 +467,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
313 h.callbacks = make([]gtk.ShortcutFunc, 0)467 h.callbacks = make([]gtk.ShortcutFunc, 0)
314 h.registered = make(map[KeyBinding]Action)468 h.registered = make(map[KeyBinding]Action)
315 h.lastDispatchAt = make(map[Action]time.Time)469 h.lastDispatchAt = make(map[Action]time.Time)
470+ h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
316 471
317- // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)472+ h.registerDefaultGlobalShortcuts(log)
318- tabActions := []Action{
319- ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3,
320- ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6,
321- ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9,
322- }
323- for i, action := range tabActions {
324- h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action)
325- }
326- h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
327- h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
328- h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
329 473
330 // Re-register config-driven shortcuts474 // Re-register config-driven shortcuts
331 if workspace != nil {475 if workspace != nil {
@@ -382,10 +526,20 @@ func (h *GlobalShortcutHandler) Detach() {
382 if h.window != nil && h.controller != nil {526 if h.window != nil && h.controller != nil {
383 h.window.RemoveController(&h.controller.EventController)527 h.window.RemoveController(&h.controller.EventController)
384 }528 }
529+ if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
530+ h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
531+ }
532+ if h.window != nil && h.releaseController != nil {
533+ h.window.RemoveController(&h.releaseController.EventController)
534+ }
385 h.controller = nil535 h.controller = nil
536+ h.releaseController = nil
537+ h.keyReleasedCb = nil
538+ h.keyReleasedHandlerID = 0
386 h.callbacks = nil539 h.callbacks = nil
387 h.registered = nil540 h.registered = nil
388 h.lastDispatchAt = nil541 h.lastDispatchAt = nil
542+ h.heldShortcuts = nil
389 }543 }
390 544
391 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond545 const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
@@ -398,6 +552,38 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
398 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()552 return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
399 }553 }
400 554
555+func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
556+ if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
557+ return false
558+ }
559+ if h.heldShortcuts == nil {
560+ // Defensive: if hold tracking is unavailable during detach/reload,
561+ // suppress rather than risk dispatching a stale global callback.
562+ return true
563+ }
564+ key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
565+ if key.keyval == 0 && key.keycode == 0 {
566+ return false
567+ }
568+ if _, ok := h.heldShortcuts[key]; ok {
569+ return true
570+ }
571+ h.heldShortcuts[key] = struct{}{}
572+ return false
573+}
574+
575+func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
576+ if h == nil || len(h.heldShortcuts) == 0 {
577+ return
578+ }
579+ keyval = normalizeKeyval(keyval)
580+ for key := range h.heldShortcuts {
581+ if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
582+ delete(h.heldShortcuts, key)
583+ }
584+ }
585+}
586+
401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {587 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
402 if h == nil {588 if h == nil {
403 return false589 return false
@@ -419,6 +605,14 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
419 return false605 return false
420 }606 }
421 607
608+func isHeldGlobalShortcutSuppressed(action Action) bool {
609+ if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
610+ return true
611+ }
612+ _, ok := ParseFloatingProfileTarget(action)
613+ return ok
614+}
615+
422 func isRepeatedGlobalShortcutSuppressed(action Action) bool {616 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
423 switch action {617 switch action {
424 case ActionGoBack,618 case ActionGoBack,
@@ -474,14 +668,71 @@ func formatBinding(binding KeyBinding) string {
474 if binding.Modifiers&ModAlt != 0 {668 if binding.Modifiers&ModAlt != 0 {
475 parts = append(parts, "alt")669 parts = append(parts, "alt")
476 }670 }
477- keyName := gdk.KeyvalName(binding.Keyval)671+ parts = append(parts, formatKeyval(binding.Keyval))
672+ return strings.Join(parts, "+")
673+}
674+
675+func formatKeyval(keyval uint) string {
676+ if keyval == 0 {
677+ return ""
678+ }
679+ keyName := gdk.KeyvalName(keyval)
478 if keyName == "" {680 if keyName == "" {
479- keyName = fmt.Sprintf("0x%x", binding.Keyval)681+ return fmt.Sprintf("0x%x", keyval)
682+ }
683+ return strings.ToLower(keyName)
684+}
685+
686+func formatModifierMask(modifiers gdk.ModifierType) string {
687+ if modifiers == 0 {
688+ return "none"
689+ }
690+ parts := make([]string, 0, 4)
691+ if modifiers&gdk.ControlMaskValue != 0 {
692+ parts = append(parts, "ctrl")
693+ }
694+ if modifiers&gdk.ShiftMaskValue != 0 {
695+ parts = append(parts, "shift")
696+ }
697+ if modifiers&gdk.AltMaskValue != 0 {
698+ parts = append(parts, "alt")
699+ }
700+ remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
701+ if remaining != 0 {
702+ parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
480 }703 }
481- parts = append(parts, strings.ToLower(keyName))
482 return strings.Join(parts, "+")704 return strings.Join(parts, "+")
483 }705 }
484 706
707+func formatEventType(eventType gdk.EventType) string {
708+ switch eventType {
709+ case gdk.KeyPressValue:
710+ return "key-press"
711+ case gdk.KeyReleaseValue:
712+ return "key-release"
713+ case gdk.ButtonPressValue:
714+ return "button-press"
715+ case gdk.ButtonReleaseValue:
716+ return "button-release"
717+ case gdk.MotionNotifyValue:
718+ return "motion"
719+ case gdk.ScrollValue:
720+ return "scroll"
721+ case gdk.TouchBeginValue:
722+ return "touch-begin"
723+ case gdk.TouchUpdateValue:
724+ return "touch-update"
725+ case gdk.TouchEndValue:
726+ return "touch-end"
727+ case gdk.FocusChangeValue:
728+ return "focus-change"
729+ case gdk.DeleteValue:
730+ return "delete"
731+ default:
732+ return fmt.Sprintf("event-%d", int(eventType))
733+ }
734+}
735+
485 func isModeAction(action Action) bool {736 func isModeAction(action Action) bool {
486 switch action {737 switch action {
487 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:738 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
M internal/ui/input/global_shortcuts_test.go +80 -0
@@ -3,6 +3,8 @@ package input
33 import (
44 "testing"
55 "time"
6+
7+ "github.com/bnema/puregotk/v4/gdk"
68 )
79
810 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
@@ -185,3 +187,81 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
185187 t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
186188 }
187189 }
190+
191+func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testing.T) {
192+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
193+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('f'), eventKeycode: 41}
194+
195+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
196+ t.Fatal("first held global shortcut dispatch was suppressed")
197+ }
198+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
199+ t.Fatal("held global shortcut repeat after cooldown was not suppressed")
200+ }
201+
202+ h.releaseHeldGlobalShortcuts(uint('x'), 0)
203+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
204+ t.Fatal("unrelated key release re-armed held global shortcut")
205+ }
206+
207+ h.releaseHeldGlobalShortcuts(uint('f'), 0)
208+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
209+ t.Fatal("matching key release did not re-arm held global shortcut")
210+ }
211+}
212+
213+func TestGlobalShortcutHandlerSuppressesHeldModeAction(t *testing.T) {
214+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
215+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('p'), eventKeycode: 33}
216+
217+ if h.suppressHeldShortcut(ActionEnterPaneMode, info) {
218+ t.Fatal("first mode shortcut dispatch was suppressed")
219+ }
220+ if !h.suppressHeldShortcut(ActionEnterPaneMode, info) {
221+ t.Fatal("held mode shortcut repeat was not suppressed")
222+ }
223+}
224+
225+func TestGlobalShortcutHandlerSuppressesHeldFloatingProfileAction(t *testing.T) {
226+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
227+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('o'), eventKeycode: 32}
228+ action := NewFloatingProfileAction("work", "https://example.com")
229+
230+ if h.suppressHeldShortcut(action, info) {
231+ t.Fatal("first floating-profile shortcut dispatch was suppressed")
232+ }
233+ if !h.suppressHeldShortcut(action, info) {
234+ t.Fatal("held floating-profile shortcut repeat was not suppressed")
235+ }
236+}
237+
238+func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
239+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
240+ t.Fatal("global shortcut without current event should not dispatch")
241+ }
242+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyReleaseValue}) {
243+ t.Fatal("global shortcut from key-release event should not dispatch")
244+ }
245+ if !shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue}) {
246+ t.Fatal("global shortcut from key-press event should dispatch")
247+ }
248+}
249+
250+func TestFormatModifierMask(t *testing.T) {
251+ got := formatModifierMask(gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.ModifierType(0x2000))
252+ if got != "ctrl+shift+0x2000" {
253+ t.Fatalf("unexpected modifier mask: %q", got)
254+ }
255+ if formatModifierMask(0) != "none" {
256+ t.Fatal("zero modifier mask should render as none")
257+ }
258+}
259+
260+func TestFormatEventType(t *testing.T) {
261+ if got := formatEventType(gdk.KeyPressValue); got != "key-press" {
262+ t.Fatalf("unexpected key press event type: %q", got)
263+ }
264+ if got := formatEventType(gdk.EventType(99)); got != "event-99" {
265+ t.Fatalf("unexpected fallback event type: %q", got)
266+ }
267+}
@@ -3,6 +3,8 @@ package input
3 import (3 import (
4 "testing"4 "testing"
5 "time"5 "time"
6+
7+ "github.com/bnema/puregotk/v4/gdk"
6 )8 )
7 9
8 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {10 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
@@ -185,3 +187,81 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
185 t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")187 t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
186 }188 }
187 }189 }
190+
191+func TestGlobalShortcutHandlerSuppressesHeldGlobalShortcutUntilRelease(t *testing.T) {
192+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
193+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('f'), eventKeycode: 41}
194+
195+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
196+ t.Fatal("first held global shortcut dispatch was suppressed")
197+ }
198+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
199+ t.Fatal("held global shortcut repeat after cooldown was not suppressed")
200+ }
201+
202+ h.releaseHeldGlobalShortcuts(uint('x'), 0)
203+ if !h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
204+ t.Fatal("unrelated key release re-armed held global shortcut")
205+ }
206+
207+ h.releaseHeldGlobalShortcuts(uint('f'), 0)
208+ if h.suppressHeldShortcut(ActionToggleFloatingPane, info) {
209+ t.Fatal("matching key release did not re-arm held global shortcut")
210+ }
211+}
212+
213+func TestGlobalShortcutHandlerSuppressesHeldModeAction(t *testing.T) {
214+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
215+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('p'), eventKeycode: 33}
216+
217+ if h.suppressHeldShortcut(ActionEnterPaneMode, info) {
218+ t.Fatal("first mode shortcut dispatch was suppressed")
219+ }
220+ if !h.suppressHeldShortcut(ActionEnterPaneMode, info) {
221+ t.Fatal("held mode shortcut repeat was not suppressed")
222+ }
223+}
224+
225+func TestGlobalShortcutHandlerSuppressesHeldFloatingProfileAction(t *testing.T) {
226+ h := &GlobalShortcutHandler{heldShortcuts: make(map[globalShortcutHoldKey]struct{})}
227+ info := globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue, eventKeyval: uint('o'), eventKeycode: 32}
228+ action := NewFloatingProfileAction("work", "https://example.com")
229+
230+ if h.suppressHeldShortcut(action, info) {
231+ t.Fatal("first floating-profile shortcut dispatch was suppressed")
232+ }
233+ if !h.suppressHeldShortcut(action, info) {
234+ t.Fatal("held floating-profile shortcut repeat was not suppressed")
235+ }
236+}
237+
238+func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
239+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
240+ t.Fatal("global shortcut without current event should not dispatch")
241+ }
242+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyReleaseValue}) {
243+ t.Fatal("global shortcut from key-release event should not dispatch")
244+ }
245+ if !shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue}) {
246+ t.Fatal("global shortcut from key-press event should dispatch")
247+ }
248+}
249+
250+func TestFormatModifierMask(t *testing.T) {
251+ got := formatModifierMask(gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.ModifierType(0x2000))
252+ if got != "ctrl+shift+0x2000" {
253+ t.Fatalf("unexpected modifier mask: %q", got)
254+ }
255+ if formatModifierMask(0) != "none" {
256+ t.Fatal("zero modifier mask should render as none")
257+ }
258+}
259+
260+func TestFormatEventType(t *testing.T) {
261+ if got := formatEventType(gdk.KeyPressValue); got != "key-press" {
262+ t.Fatalf("unexpected key press event type: %q", got)
263+ }
264+ if got := formatEventType(gdk.EventType(99)); got != "event-99" {
265+ t.Fatalf("unexpected fallback event type: %q", got)
266+ }
267+}
M internal/ui/input/keyboard.go +69 -9
@@ -76,8 +76,9 @@ type KeyboardHandler struct {
7676 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
7777 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
7878
79- ctx context.Context
80- mu sync.RWMutex
79+ activePressedActions map[Action]uint
80+ ctx context.Context
81+ mu sync.RWMutex
8182 }
8283
8384 // NewKeyboardHandler creates a new keyboard handler.
@@ -87,11 +88,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
8788 log.Debug().Msg("creating keyboard handler")
8889
8990 h := &KeyboardHandler{
90- shortcuts: NewShortcutSet(ctx, workspace, session),
91- modal: NewModalState(ctx),
92- workspace: workspace,
93- session: session,
94- ctx: ctx,
91+ shortcuts: NewShortcutSet(ctx, workspace, session),
92+ modal: NewModalState(ctx),
93+ workspace: workspace,
94+ session: session,
95+ activePressedActions: make(map[Action]uint),
96+ ctx: ctx,
9597 }
9698
9799 return h
@@ -241,9 +243,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
241243 // Note: GTK handles cleanup when the widget is destroyed,
242244 // but we clear our reference here.
243245 func (h *KeyboardHandler) Detach() {
246+ h.mu.Lock()
247+ defer h.mu.Unlock()
244248 h.controller = nil
245249 h.keyPressedCb = nil
246250 h.keyReleasedCb = nil
251+ h.activePressedActions = nil
247252 }
248253
249254 // handleKeyPress processes a key press event.
@@ -313,6 +318,12 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
313318 if !found {
314319 return mode != ModeNormal // Consume unrecognized keys in modal mode
315320 }
321+ if h.suppressHeldAction(action, keyval) {
322+ log.Trace().
323+ Str("action", string(action)).
324+ Msg("held keyboard action repeat suppressed")
325+ return true
326+ }
316327
317328 return h.dispatchAction(action, mode)
318329 }
@@ -373,6 +384,47 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
373384 return true // Consumed the key
374385 }
375386
387+func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
388+ if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
389+ return false
390+ }
391+
392+ h.mu.Lock()
393+ defer h.mu.Unlock()
394+ if h.activePressedActions == nil {
395+ return true
396+ }
397+ if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
398+ return true
399+ }
400+ h.activePressedActions[action] = keyval
401+ return false
402+}
403+
404+func isRepeatedKeyboardActionSuppressed(action Action) bool {
405+ if isRepeatedGlobalShortcutSuppressed(action) {
406+ return true
407+ }
408+ switch action {
409+ case ActionEnterTabMode,
410+ ActionEnterPaneMode,
411+ ActionEnterSessionMode,
412+ ActionEnterResizeMode,
413+ ActionNewTab,
414+ ActionRenameTab,
415+ ActionSplitRight,
416+ ActionSplitLeft,
417+ ActionSplitUp,
418+ ActionSplitDown,
419+ ActionStackPane,
420+ ActionMovePaneToTab,
421+ ActionMovePaneToNextTab:
422+ return true
423+ default:
424+ return false
425+ }
426+}
427+
376428 func (h *KeyboardHandler) lookupAction(
377429 log *zerolog.Logger,
378430 binding KeyBinding,
@@ -536,9 +588,17 @@ func (h *KeyboardHandler) DispatchAction(action Action) {
536588
537589 // handleKeyRelease processes a key release event for accent detection.
538590 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
539- h.mu.RLock()
591+ h.mu.Lock()
540592 accentHandler := h.accentHandler
541- h.mu.RUnlock()
593+ if len(h.activePressedActions) > 0 {
594+ keyval = normalizeKeyval(keyval)
595+ for action, pressedKeyval := range h.activePressedActions {
596+ if pressedKeyval == keyval {
597+ delete(h.activePressedActions, action)
598+ }
599+ }
600+ }
601+ h.mu.Unlock()
542602
543603 if accentHandler == nil {
544604 return
@@ -76,8 +76,9 @@ type KeyboardHandler struct {
76 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool76 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
77 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)77 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
78 78
79- ctx context.Context79+ activePressedActions map[Action]uint
80- mu sync.RWMutex80+ ctx context.Context
81+ mu sync.RWMutex
81 }82 }
82 83
83 // NewKeyboardHandler creates a new keyboard handler.84 // NewKeyboardHandler creates a new keyboard handler.
@@ -87,11 +88,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
87 log.Debug().Msg("creating keyboard handler")88 log.Debug().Msg("creating keyboard handler")
88 89
89 h := &KeyboardHandler{90 h := &KeyboardHandler{
90- shortcuts: NewShortcutSet(ctx, workspace, session),91+ shortcuts: NewShortcutSet(ctx, workspace, session),
91- modal: NewModalState(ctx),92+ modal: NewModalState(ctx),
92- workspace: workspace,93+ workspace: workspace,
93- session: session,94+ session: session,
94- ctx: ctx,95+ activePressedActions: make(map[Action]uint),
96+ ctx: ctx,
95 }97 }
96 98
97 return h99 return h
@@ -241,9 +243,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
241 // Note: GTK handles cleanup when the widget is destroyed,243 // Note: GTK handles cleanup when the widget is destroyed,
242 // but we clear our reference here.244 // but we clear our reference here.
243 func (h *KeyboardHandler) Detach() {245 func (h *KeyboardHandler) Detach() {
246+ h.mu.Lock()
247+ defer h.mu.Unlock()
244 h.controller = nil248 h.controller = nil
245 h.keyPressedCb = nil249 h.keyPressedCb = nil
246 h.keyReleasedCb = nil250 h.keyReleasedCb = nil
251+ h.activePressedActions = nil
247 }252 }
248 253
249 // handleKeyPress processes a key press event.254 // handleKeyPress processes a key press event.
@@ -313,6 +318,12 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
313 if !found {318 if !found {
314 return mode != ModeNormal // Consume unrecognized keys in modal mode319 return mode != ModeNormal // Consume unrecognized keys in modal mode
315 }320 }
321+ if h.suppressHeldAction(action, keyval) {
322+ log.Trace().
323+ Str("action", string(action)).
324+ Msg("held keyboard action repeat suppressed")
325+ return true
326+ }
316 327
317 return h.dispatchAction(action, mode)328 return h.dispatchAction(action, mode)
318 }329 }
@@ -373,6 +384,47 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
373 return true // Consumed the key384 return true // Consumed the key
374 }385 }
375 386
387+func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
388+ if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
389+ return false
390+ }
391+
392+ h.mu.Lock()
393+ defer h.mu.Unlock()
394+ if h.activePressedActions == nil {
395+ return true
396+ }
397+ if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
398+ return true
399+ }
400+ h.activePressedActions[action] = keyval
401+ return false
402+}
403+
404+func isRepeatedKeyboardActionSuppressed(action Action) bool {
405+ if isRepeatedGlobalShortcutSuppressed(action) {
406+ return true
407+ }
408+ switch action {
409+ case ActionEnterTabMode,
410+ ActionEnterPaneMode,
411+ ActionEnterSessionMode,
412+ ActionEnterResizeMode,
413+ ActionNewTab,
414+ ActionRenameTab,
415+ ActionSplitRight,
416+ ActionSplitLeft,
417+ ActionSplitUp,
418+ ActionSplitDown,
419+ ActionStackPane,
420+ ActionMovePaneToTab,
421+ ActionMovePaneToNextTab:
422+ return true
423+ default:
424+ return false
425+ }
426+}
427+
376 func (h *KeyboardHandler) lookupAction(428 func (h *KeyboardHandler) lookupAction(
377 log *zerolog.Logger,429 log *zerolog.Logger,
378 binding KeyBinding,430 binding KeyBinding,
@@ -536,9 +588,17 @@ func (h *KeyboardHandler) DispatchAction(action Action) {
536 588
537 // handleKeyRelease processes a key release event for accent detection.589 // handleKeyRelease processes a key release event for accent detection.
538 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {590 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
539- h.mu.RLock()591+ h.mu.Lock()
540 accentHandler := h.accentHandler592 accentHandler := h.accentHandler
541- h.mu.RUnlock()593+ if len(h.activePressedActions) > 0 {
594+ keyval = normalizeKeyval(keyval)
595+ for action, pressedKeyval := range h.activePressedActions {
596+ if pressedKeyval == keyval {
597+ delete(h.activePressedActions, action)
598+ }
599+ }
600+ }
601+ h.mu.Unlock()
542 602
543 if accentHandler == nil {603 if accentHandler == nil {
544 return604 return
M internal/ui/input/keyboard_test.go +112 -0
@@ -230,3 +230,115 @@ func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
230230 result := h.handleKeyPress(uint('z'), 0, 0)
231231 assert.False(t, result, "unregistered key in ModeNormal returns false")
232232 }
233+
234+func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
235+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
236+ calls := 0
237+ h.SetOnAction(func(_ context.Context, action Action) error {
238+ calls++
239+ if action != ActionSwitchTabIndex2 {
240+ t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
241+ }
242+ return nil
243+ })
244+
245+ const fallbackKeycode = 11
246+ if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
247+ t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
248+ }
249+
250+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
251+ t.Fatal("first tab-switch key press should be consumed")
252+ }
253+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
254+ t.Fatal("repeated held tab-switch key press should still be consumed")
255+ }
256+ if calls != 1 {
257+ t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
258+ }
259+
260+ h.handleKeyRelease(uint('x'))
261+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
262+ t.Fatal("tab-switch key press after release should be consumed")
263+ }
264+ if calls != 2 {
265+ t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
266+ }
267+}
268+
269+func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
270+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
271+ const resizeKeycode = 27
272+ mods := gdk.ControlMaskValue | gdk.AltMaskValue
273+
274+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
275+ t.Fatal("first resize-mode activation should be consumed")
276+ }
277+ if got := h.Mode(); got != ModeResize {
278+ t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
279+ }
280+
281+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
282+ t.Fatal("repeated held resize-mode activation should be consumed")
283+ }
284+ if got := h.Mode(); got != ModeResize {
285+ t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
286+ }
287+
288+ h.handleKeyRelease(uint('x'))
289+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
290+ t.Fatal("resize-mode activation after unrelated release should still be consumed")
291+ }
292+ if got := h.Mode(); got != ModeResize {
293+ t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
294+ }
295+
296+ h.handleKeyRelease(uint('r'))
297+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
298+ t.Fatal("resize-mode activation after release should be consumed")
299+ }
300+ if got := h.Mode(); got != ModeNormal {
301+ t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
302+ }
303+}
304+
305+func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
306+ workspace := newTestWorkspace()
307+ workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
308+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
309+ }
310+ h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
311+ calls := 0
312+ h.SetOnAction(func(_ context.Context, action Action) error {
313+ calls++
314+ if action != ActionToggleFloatingPane {
315+ t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
316+ }
317+ return nil
318+ })
319+
320+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
321+ t.Fatal("first floating pane toggle should be consumed")
322+ }
323+ h.handleKeyRelease(uint('x'))
324+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
325+ t.Fatal("held floating pane toggle repeat should still be consumed")
326+ }
327+ if calls != 1 {
328+ t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
329+ }
330+
331+ h.handleKeyRelease(uint('f'))
332+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
333+ t.Fatal("floating pane toggle after release should be consumed")
334+ }
335+ if calls != 2 {
336+ t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
337+ }
338+}
339+
340+func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
341+ if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
342+ t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
343+ }
344+}
@@ -230,3 +230,115 @@ func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
230 result := h.handleKeyPress(uint('z'), 0, 0)230 result := h.handleKeyPress(uint('z'), 0, 0)
231 assert.False(t, result, "unregistered key in ModeNormal returns false")231 assert.False(t, result, "unregistered key in ModeNormal returns false")
232 }232 }
233+
234+func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
235+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
236+ calls := 0
237+ h.SetOnAction(func(_ context.Context, action Action) error {
238+ calls++
239+ if action != ActionSwitchTabIndex2 {
240+ t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
241+ }
242+ return nil
243+ })
244+
245+ const fallbackKeycode = 11
246+ if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
247+ t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
248+ }
249+
250+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
251+ t.Fatal("first tab-switch key press should be consumed")
252+ }
253+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
254+ t.Fatal("repeated held tab-switch key press should still be consumed")
255+ }
256+ if calls != 1 {
257+ t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
258+ }
259+
260+ h.handleKeyRelease(uint('x'))
261+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
262+ t.Fatal("tab-switch key press after release should be consumed")
263+ }
264+ if calls != 2 {
265+ t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
266+ }
267+}
268+
269+func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
270+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
271+ const resizeKeycode = 27
272+ mods := gdk.ControlMaskValue | gdk.AltMaskValue
273+
274+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
275+ t.Fatal("first resize-mode activation should be consumed")
276+ }
277+ if got := h.Mode(); got != ModeResize {
278+ t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
279+ }
280+
281+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
282+ t.Fatal("repeated held resize-mode activation should be consumed")
283+ }
284+ if got := h.Mode(); got != ModeResize {
285+ t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
286+ }
287+
288+ h.handleKeyRelease(uint('x'))
289+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
290+ t.Fatal("resize-mode activation after unrelated release should still be consumed")
291+ }
292+ if got := h.Mode(); got != ModeResize {
293+ t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
294+ }
295+
296+ h.handleKeyRelease(uint('r'))
297+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
298+ t.Fatal("resize-mode activation after release should be consumed")
299+ }
300+ if got := h.Mode(); got != ModeNormal {
301+ t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
302+ }
303+}
304+
305+func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
306+ workspace := newTestWorkspace()
307+ workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
308+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
309+ }
310+ h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
311+ calls := 0
312+ h.SetOnAction(func(_ context.Context, action Action) error {
313+ calls++
314+ if action != ActionToggleFloatingPane {
315+ t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
316+ }
317+ return nil
318+ })
319+
320+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
321+ t.Fatal("first floating pane toggle should be consumed")
322+ }
323+ h.handleKeyRelease(uint('x'))
324+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
325+ t.Fatal("held floating pane toggle repeat should still be consumed")
326+ }
327+ if calls != 1 {
328+ t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
329+ }
330+
331+ h.handleKeyRelease(uint('f'))
332+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
333+ t.Fatal("floating pane toggle after release should be consumed")
334+ }
335+ if calls != 2 {
336+ t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
337+ }
338+}
339+
340+func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
341+ if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
342+ t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
343+ }
344+}

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