Merge pull request #263 from bnema/refactor/cef2gtk-idle-size-observer
fix(cef): use bounded size observation
11 files · +977 -131
M go.mod +3 -3
| @@ -6,11 +6,11 @@ require ( | |||
| 6 | 6 | github.com/a-h/templ v0.3.1020 | |
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | 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 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.3 | |
| 12 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| 13 | - | github.com/bnema/puregotk v0.5.2 | |
| 13 | + | github.com/bnema/puregotk v0.5.3 | |
| 14 | 14 | github.com/charmbracelet/bubbles v1.0.0 | |
| 15 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 16 | 16 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -6,11 +6,11 @@ require ( | |||||
| 6 | github.com/a-h/templ v0.3.1020 | 6 | github.com/a-h/templ v0.3.1020 | ||
| 7 | github.com/andybalholm/brotli v1.2.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | - | github.com/bnema/purego-cef v0.13.0 | 9 | + | github.com/bnema/purego-cef v0.13.1 |
| 10 | - | github.com/bnema/purego-cef2gtk v0.5.2 | 10 | + | github.com/bnema/purego-cef2gtk v0.5.4 |
| 11 | github.com/bnema/purego-pipewire v0.1.3 | 11 | github.com/bnema/purego-pipewire v0.1.3 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | github.com/bnema/purego-sqlite v0.1.2 | ||
| 13 | - | github.com/bnema/puregotk v0.5.2 | 13 | + | github.com/bnema/puregotk v0.5.3 |
| 14 | github.com/charmbracelet/bubbles v1.0.0 | 14 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 15 | github.com/charmbracelet/bubbletea v1.3.10 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 16 | github.com/charmbracelet/lipgloss v1.1.0 | 16 | 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( | |||
| 177 | 177 | // the one-shot initial resize path; once that path has been consumed, | |
| 178 | 178 | // later size events must go through normal viewport sync even if popup | |
| 179 | 179 | // 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) { | |
| 192 | 181 | 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 | 238 | func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) { | |
| 207 | 239 | log := logging.FromContext(ctx) | |
| 208 | 240 | pc := wv.takePendingCreate() | |
| @@ -329,26 +361,61 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 329 | 361 | } | |
| 330 | 362 | ||
| 331 | 363 | 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 | + | } | |
| 346 | 370 | ||
| 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) | |
| 349 | 390 | } | |
| 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 | + | }) | |
| 352 | 420 | }) | |
| 353 | - | return popupWV, nil | |
| 354 | 421 | } | |
| @@ -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 popup | 178 | // 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 | return | 181 | 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 | - | return | 369 | + | } |
| 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 | - | return | 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) | |||
| 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 | |||
| 55 | 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 | 227 | func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) { | |
| 59 | 228 | oldNewTask := cefNewTask | |
| 60 | 229 | 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 := cefNewTask | 228 | oldNewTask := cefNewTask | ||
| 60 | oldPostTask := cefPostTask | 229 | oldPostTask := cefPostTask | ||
M internal/infrastructure/cef/webview.go +9 -0
| @@ -767,6 +767,15 @@ func (wv *WebView) awaitsNativePopupAttachment() bool { | |||
| 767 | 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 | 779 | func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer { | |
| 771 | 780 | if wv != nil && wv.nativePopupFallbackSchedule != nil { | |
| 772 | 781 | 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.nativePopupFallbackSchedule | 781 | return wv.nativePopupFallbackSchedule | ||
M internal/ui/input/gesture.go +51 -5
| @@ -29,10 +29,14 @@ const ( | |||
| 29 | 29 | // GestureHandler handles mouse button gestures for navigation. | |
| 30 | 30 | // It recognizes mouse buttons 8 (back) and 9 (forward) on WebView widgets. | |
| 31 | 31 | type GestureHandler struct { | |
| 32 | - | clickGesture *gtk.GestureClick | |
| 32 | + | clickGesture *gtk.GestureClick | |
| 33 | + | widget *gtk.Widget | |
| 34 | + | pressedHandlerID uint | |
| 35 | + | destroyHandlerID uint | |
| 33 | 36 | ||
| 34 | 37 | // Callback retention: must stay reachable by Go GC. | |
| 35 | 38 | pressedCb func(gtk.GestureClick, int, float64, float64) | |
| 39 | + | destroyCb func(gtk.Widget) | |
| 36 | 40 | ||
| 37 | 41 | // Action handler callback (fallback if no direct navigator) | |
| 38 | 42 | onAction ActionHandler | |
| @@ -81,6 +85,7 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | |||
| 81 | 85 | log.Error().Msg("cannot attach gesture handler to nil widget") | |
| 82 | 86 | return | |
| 83 | 87 | } | |
| 88 | + | h.detachExistingAttachment() | |
| 84 | 89 | ||
| 85 | 90 | // Create click gesture that listens to all buttons | |
| 86 | 91 | h.clickGesture = gtk.NewGestureClick() | |
| @@ -96,7 +101,12 @@ func (h *GestureHandler) AttachTo(widget *gtk.Widget) { | |||
| 96 | 101 | h.pressedCb = func(_ gtk.GestureClick, nPress int, _ float64, _ float64) { | |
| 97 | 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 | 111 | // Add controller to widget | |
| 102 | 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. | |
| 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. | |
| 166 | 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 | 197 | h.clickGesture = nil | |
| 198 | + | h.pressedHandlerID = 0 | |
| 199 | + | h.destroyHandlerID = 0 | |
| 168 | 200 | 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 | } | |
| @@ -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.GestureClick | 32 | + | 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 ActionHandler | 42 | 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 | return | 86 | return | ||
| 83 | } | 87 | } | ||
| 88 | + | h.detachExistingAttachment() | |||
| 84 | 89 | ||||
| 85 | // Create click gesture that listens to all buttons | 90 | // 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 widget | 111 | // 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 = nil | 197 | h.clickGesture = nil | ||
| 198 | + | h.pressedHandlerID = 0 | |||
| 199 | + | h.destroyHandlerID = 0 | |||
| 168 | h.pressedCb = nil | 200 | 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 ( | |||
| 12 | 12 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/glib" | |
| 14 | 14 | "github.com/bnema/puregotk/v4/gtk" | |
| 15 | + | "github.com/rs/zerolog" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | 18 | // GlobalShortcutHandler manages keyboard shortcuts that must work globally, | |
| 18 | 19 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | |
| 19 | 20 | // to intercept shortcuts before they reach the WebView. | |
| 20 | 21 | 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 | |
| 28 | 33 | // generation is mutated only from the GTK main thread alongside controller replacement. | |
| 29 | 34 | generation uint64 | |
| 30 | 35 | ||
| @@ -46,67 +51,29 @@ func NewGlobalShortcutHandler( | |||
| 46 | 51 | log.Debug().Msg("creating global shortcut handler") | |
| 47 | 52 | ||
| 48 | 53 | 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{}), | |
| 57 | 64 | } | |
| 58 | 65 | ||
| 59 | 66 | if h.controller == nil { | |
| 60 | 67 | log.Error().Msg("failed to create shortcut controller") | |
| 61 | 68 | return nil | |
| 62 | 69 | } | |
| 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 | |
| 88 | 73 | } | |
| 89 | 74 | ||
| 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) | |
| 110 | 77 | ||
| 111 | 78 | if workspace != nil { | |
| 112 | 79 | actionMap := globalShortcutActionMap() | |
| @@ -156,6 +123,7 @@ func NewGlobalShortcutHandler( | |||
| 156 | 123 | } | |
| 157 | 124 | ||
| 158 | 125 | // Attach to window | |
| 126 | + | window.AddController(&h.releaseController.EventController) | |
| 159 | 127 | window.AddController(&h.controller.EventController) | |
| 160 | 128 | ||
| 161 | 129 | log.Debug(). | |
| @@ -165,6 +133,62 @@ func NewGlobalShortcutHandler( | |||
| 165 | 133 | 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 | 192 | // registerShortcut creates and registers a single shortcut with the controller. | |
| 169 | 193 | func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool { | |
| 170 | 194 | binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask} | |
| @@ -191,11 +215,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 191 | 215 | // dispatch one-shot actions after a shortcut reload. | |
| 192 | 216 | actionToDispatch := action | |
| 193 | 217 | generation := h.generation | |
| 218 | + | bindingForLog := binding | |
| 194 | 219 | callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool { | |
| 195 | 220 | log := logging.FromContext(h.ctx) | |
| 196 | 221 | if h.isStaleGeneration(generation) { | |
| 197 | 222 | log.Trace(). | |
| 198 | 223 | Str("action", string(actionToDispatch)). | |
| 224 | + | Str("shortcut", formatBinding(bindingForLog)). | |
| 199 | 225 | Uint64("callback_generation", generation). | |
| 200 | 226 | Uint64("current_generation", h.generation). | |
| 201 | 227 | Msg("stale global shortcut callback ignored") | |
| @@ -204,17 +230,31 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 204 | 230 | if !h.isActiveWindowShortcutHandler() { | |
| 205 | 231 | log.Trace(). | |
| 206 | 232 | Str("action", string(actionToDispatch)). | |
| 233 | + | Str("shortcut", formatBinding(bindingForLog)). | |
| 207 | 234 | Msg("inactive window global shortcut callback ignored") | |
| 208 | 235 | 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 | 250 | if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) { | |
| 211 | 251 | log.Trace(). | |
| 212 | 252 | Str("action", string(actionToDispatch)). | |
| 253 | + | Str("shortcut", formatBinding(bindingForLog)). | |
| 213 | 254 | Msg("global shortcut repeat suppressed") | |
| 214 | 255 | return true | |
| 215 | 256 | } | |
| 216 | - | log.Debug(). | |
| 217 | - | Str("action", string(actionToDispatch)). | |
| 257 | + | appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo). | |
| 218 | 258 | Msg("global shortcut triggered") | |
| 219 | 259 | ||
| 220 | 260 | // 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 | 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 | 424 | func globalShortcutActionMap() map[string]Action { | |
| 271 | 425 | return map[string]Action{ | |
| 272 | 426 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| @@ -313,19 +467,9 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 313 | 467 | h.callbacks = make([]gtk.ShortcutFunc, 0) | |
| 314 | 468 | h.registered = make(map[KeyBinding]Action) | |
| 315 | 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) | |
| 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) | |
| 329 | 473 | ||
| 330 | 474 | // Re-register config-driven shortcuts | |
| 331 | 475 | if workspace != nil { | |
| @@ -382,10 +526,20 @@ func (h *GlobalShortcutHandler) Detach() { | |||
| 382 | 526 | if h.window != nil && h.controller != nil { | |
| 383 | 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 | 535 | h.controller = nil | |
| 536 | + | h.releaseController = nil | |
| 537 | + | h.keyReleasedCb = nil | |
| 538 | + | h.keyReleasedHandlerID = 0 | |
| 386 | 539 | h.callbacks = nil | |
| 387 | 540 | h.registered = nil | |
| 388 | 541 | h.lastDispatchAt = nil | |
| 542 | + | h.heldShortcuts = nil | |
| 389 | 543 | } | |
| 390 | 544 | ||
| 391 | 545 | const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond | |
| @@ -398,6 +552,38 @@ func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool { | |||
| 398 | 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 | 587 | func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool { | |
| 402 | 588 | if h == nil { | |
| 403 | 589 | return false | |
| @@ -419,6 +605,14 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time | |||
| 419 | 605 | 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 | 616 | func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |
| 423 | 617 | switch action { | |
| 424 | 618 | case ActionGoBack, | |
| @@ -474,14 +668,71 @@ func formatBinding(binding KeyBinding) string { | |||
| 474 | 668 | if binding.Modifiers&ModAlt != 0 { | |
| 475 | 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 | 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 | 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 | 736 | func isModeAction(action Action) bool { | |
| 486 | 737 | switch action { | |
| 487 | 738 | 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_GLOBAL | 19 | // 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.ShortcutController | 22 | + | controller *gtk.ShortcutController |
| 22 | - | window *gtk.ApplicationWindow | 23 | + | releaseController *gtk.EventControllerKey |
| 23 | - | kbHandler *KeyboardHandler | 24 | + | window *gtk.ApplicationWindow |
| 24 | - | onAction ActionHandler | 25 | + | kbHandler *KeyboardHandler |
| 25 | - | ctx context.Context | 26 | + | onAction ActionHandler |
| 26 | - | registered map[KeyBinding]Action | 27 | + | ctx context.Context |
| 27 | - | lastDispatchAt map[Action]time.Time | 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 | |||
| 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 uint64 | 34 | 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 nil | 68 | return nil | ||
| 62 | } | 69 | } | ||
| 63 | - | 70 | + | if h.releaseController == nil { | |
| 64 | - | // Set global scope - this is the key to making shortcuts work | 71 | + | log.Error().Msg("failed to create global shortcut release controller") |
| 65 | - | // even when WebView has focus | 72 | + | 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 10 | 75 | + | 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 window | 125 | // 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 h | 133 | 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 := action | 216 | actionToDispatch := action | ||
| 193 | generation := h.generation | 217 | 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 false | 235 | 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 true | 255 | 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 state | 260 | // 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 shortcuts | 474 | // 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 = nil | 535 | h.controller = nil | ||
| 536 | + | h.releaseController = nil | |||
| 537 | + | h.keyReleasedCb = nil | |||
| 538 | + | h.keyReleasedHandlerID = 0 | |||
| 386 | h.callbacks = nil | 539 | h.callbacks = nil | ||
| 387 | h.registered = nil | 540 | h.registered = nil | ||
| 388 | h.lastDispatchAt = nil | 541 | h.lastDispatchAt = nil | ||
| 542 | + | h.heldShortcuts = nil | |||
| 389 | } | 543 | } | ||
| 390 | 544 | ||||
| 391 | const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond | 545 | 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 false | 589 | return false | ||
| @@ -419,6 +605,14 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time | |||||
| 419 | return false | 605 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 6 | 8 | ) | |
| 7 | 9 | ||
| 8 | 10 | func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) { | |
| @@ -185,3 +187,81 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) { | |||
| 185 | 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 | + | } | |
| @@ -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 { | |||
| 76 | 76 | keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | |
| 77 | 77 | keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) | |
| 78 | 78 | ||
| 79 | - | ctx context.Context | |
| 80 | - | mu sync.RWMutex | |
| 79 | + | activePressedActions map[Action]uint | |
| 80 | + | ctx context.Context | |
| 81 | + | mu sync.RWMutex | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | 84 | // NewKeyboardHandler creates a new keyboard handler. | |
| @@ -87,11 +88,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, | |||
| 87 | 88 | log.Debug().Msg("creating keyboard handler") | |
| 88 | 89 | ||
| 89 | 90 | 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, | |
| 95 | 97 | } | |
| 96 | 98 | ||
| 97 | 99 | return h | |
| @@ -241,9 +243,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 241 | 243 | // Note: GTK handles cleanup when the widget is destroyed, | |
| 242 | 244 | // but we clear our reference here. | |
| 243 | 245 | func (h *KeyboardHandler) Detach() { | |
| 246 | + | h.mu.Lock() | |
| 247 | + | defer h.mu.Unlock() | |
| 244 | 248 | h.controller = nil | |
| 245 | 249 | h.keyPressedCb = nil | |
| 246 | 250 | h.keyReleasedCb = nil | |
| 251 | + | h.activePressedActions = nil | |
| 247 | 252 | } | |
| 248 | 253 | ||
| 249 | 254 | // handleKeyPress processes a key press event. | |
| @@ -313,6 +318,12 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 313 | 318 | if !found { | |
| 314 | 319 | 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 | 328 | return h.dispatchAction(action, mode) | |
| 318 | 329 | } | |
| @@ -373,6 +384,47 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |||
| 373 | 384 | 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 | 428 | func (h *KeyboardHandler) lookupAction( | |
| 377 | 429 | log *zerolog.Logger, | |
| 378 | 430 | binding KeyBinding, | |
| @@ -536,9 +588,17 @@ func (h *KeyboardHandler) DispatchAction(action Action) { | |||
| 536 | 588 | ||
| 537 | 589 | // handleKeyRelease processes a key release event for accent detection. | |
| 538 | 590 | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |
| 539 | - | h.mu.RLock() | |
| 591 | + | h.mu.Lock() | |
| 540 | 592 | 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 | 603 | if accentHandler == nil { | |
| 544 | 604 | return | |
| @@ -76,8 +76,9 @@ type KeyboardHandler struct { | |||||
| 76 | keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | 76 | 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.Context | 79 | + | activePressedActions map[Action]uint |
| 80 | - | mu sync.RWMutex | 80 | + | 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 h | 99 | 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 = nil | 248 | h.controller = nil | ||
| 245 | h.keyPressedCb = nil | 249 | h.keyPressedCb = nil | ||
| 246 | h.keyReleasedCb = nil | 250 | 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 mode | 319 | 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 key | 384 | 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.accentHandler | 592 | 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 | return | 604 | return | ||
M internal/ui/input/keyboard_test.go +112 -0
| @@ -230,3 +230,115 @@ func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |||
| 230 | 230 | result := h.handleKeyPress(uint('z'), 0, 0) | |
| 231 | 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 | + | } | |
| @@ -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 | + | } | |||