feat(ui): integrate accent picker into keyboard handler and app
- commit
- b3920c50aa754e819aebf4bb4028714fd86f7f72
- author
- brice · · 2025-12-29 17:43 UTC
- committer
- brice · · 2025-12-30 13:48 UTC
- parent
- 7628551
- signature
- unsigned
6 files · +263 -35
M internal/infrastructure/webkit/messaging.go +2 -2
| @@ -60,8 +60,8 @@ func NewMessageRouter(ctx context.Context) *MessageRouter { | |||
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | return &MessageRouter{ | |
| 63 | - | handlers: make(map[string]handlerEntry), | |
| 64 | - | baseCtx: ctx, | |
| 63 | + | handlers: make(map[string]handlerEntry), | |
| 64 | + | baseCtx: ctx, | |
| 65 | 65 | syncedIDs: make(map[WebViewID]bool), | |
| 66 | 66 | } | |
| 67 | 67 | } | |
| @@ -60,8 +60,8 @@ func NewMessageRouter(ctx context.Context) *MessageRouter { | |||||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | return &MessageRouter{ | 62 | return &MessageRouter{ | ||
| 63 | - | handlers: make(map[string]handlerEntry), | 63 | + | handlers: make(map[string]handlerEntry), |
| 64 | - | baseCtx: ctx, | 64 | + | baseCtx: ctx, |
| 65 | syncedIDs: make(map[WebViewID]bool), | 65 | syncedIDs: make(map[WebViewID]bool), | ||
| 66 | } | 66 | } | ||
| 67 | } | 67 | } | ||
M internal/ui/app.go +101 -0
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 18 | 19 | ||
| 19 | 20 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers" | |
| @@ -108,6 +109,11 @@ type App struct { | |||
| 108 | 109 | ||
| 109 | 110 | movePaneToTabUC *usecase.MovePaneToTabUseCase | |
| 110 | 111 | ||
| 112 | + | // Accent picker for dead keys support | |
| 113 | + | accentPicker *component.AccentPicker | |
| 114 | + | insertAccentUC *usecase.InsertAccentUseCase | |
| 115 | + | accentFocusProvider *textinput.FocusProvider | |
| 116 | + | ||
| 111 | 117 | // lifecycle | |
| 112 | 118 | cancel context.CancelCauseFunc | |
| 113 | 119 | } | |
| @@ -217,6 +223,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 217 | 223 | a.initLayoutInfrastructure() | |
| 218 | 224 | a.initAppToasterOverlay() | |
| 219 | 225 | a.initFocusAndBorderOverlay() | |
| 226 | + | a.initAccentPicker(ctx) | |
| 220 | 227 | ||
| 221 | 228 | a.initCoordinators(ctx) | |
| 222 | 229 | a.initKeyboardHandler(ctx) | |
| @@ -387,6 +394,49 @@ func (a *App) initFocusAndBorderOverlay() { | |||
| 387 | 394 | a.mainWindow.AddOverlay(gtkWidget) | |
| 388 | 395 | } | |
| 389 | 396 | ||
| 397 | + | func (a *App) initAccentPicker(ctx context.Context) { | |
| 398 | + | log := logging.FromContext(ctx) | |
| 399 | + | ||
| 400 | + | if a.widgetFactory == nil || a.mainWindow == nil { | |
| 401 | + | log.Debug().Msg("widget factory or main window not available, skipping accent picker") | |
| 402 | + | return | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | // Create accent picker component | |
| 406 | + | a.accentPicker = component.NewAccentPicker(a.widgetFactory) | |
| 407 | + | if a.accentPicker == nil { | |
| 408 | + | log.Warn().Msg("failed to create accent picker") | |
| 409 | + | return | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | // Add to main window overlay | |
| 413 | + | pickerWidget := a.accentPicker.Widget() | |
| 414 | + | if pickerWidget != nil { | |
| 415 | + | gtkWidget := pickerWidget.GtkWidget() | |
| 416 | + | if gtkWidget != nil { | |
| 417 | + | a.mainWindow.AddOverlay(gtkWidget) | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | // Create focus provider (tracks which input has focus) | |
| 422 | + | a.accentFocusProvider = textinput.NewFocusProvider() | |
| 423 | + | ||
| 424 | + | // Create the use case with glib.IdleAdd for thread-safe GTK calls | |
| 425 | + | a.insertAccentUC = usecase.NewInsertAccentUseCase( | |
| 426 | + | a.accentFocusProvider, | |
| 427 | + | a.accentPicker, | |
| 428 | + | func(fn func()) { | |
| 429 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 430 | + | fn() | |
| 431 | + | return false // Don't repeat | |
| 432 | + | }) | |
| 433 | + | glib.IdleAdd(&cb, 0) | |
| 434 | + | }, | |
| 435 | + | ) | |
| 436 | + | ||
| 437 | + | log.Debug().Msg("accent picker initialized") | |
| 438 | + | } | |
| 439 | + | ||
| 390 | 440 | func (a *App) initKeyboardHandler(ctx context.Context) { | |
| 391 | 441 | if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil { | |
| 392 | 442 | return | |
| @@ -414,6 +464,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 414 | 464 | } | |
| 415 | 465 | return wsView.IsOmniboxVisible() | |
| 416 | 466 | }) | |
| 467 | + | // Wire accent handler for dead keys support | |
| 468 | + | if a.insertAccentUC != nil { | |
| 469 | + | a.keyboardHandler.SetAccentHandler(a.insertAccentUC) | |
| 470 | + | } | |
| 417 | 471 | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | |
| 418 | 472 | ||
| 419 | 473 | // Create global shortcut handler for Alt+1-9 tab switching. | |
| @@ -461,6 +515,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 461 | 515 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | |
| 462 | 516 | } | |
| 463 | 517 | }, | |
| 518 | + | OnFocusIn: func(entry *gtk.SearchEntry) { | |
| 519 | + | // Set omnibox entry as the focused input for accent picker | |
| 520 | + | if a.accentFocusProvider != nil && entry != nil { | |
| 521 | + | a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry)) | |
| 522 | + | } | |
| 523 | + | }, | |
| 524 | + | OnFocusOut: func() { | |
| 525 | + | // When omnibox loses focus, set WebView as the focused input | |
| 526 | + | if a.accentFocusProvider != nil { | |
| 527 | + | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 528 | + | } | |
| 529 | + | }, | |
| 464 | 530 | } | |
| 465 | 531 | a.navCoord.SetOmniboxProvider(a) | |
| 466 | 532 | log.Debug().Msg("omnibox config stored, provider set") | |
| @@ -479,6 +545,18 @@ func (a *App) initFindBarConfig() { | |||
| 479 | 545 | } | |
| 480 | 546 | return wv.GetFindController() | |
| 481 | 547 | }, | |
| 548 | + | OnFocusIn: func(entry *gtk.SearchEntry) { | |
| 549 | + | // Set find bar entry as the focused input for accent picker | |
| 550 | + | if a.accentFocusProvider != nil && entry != nil { | |
| 551 | + | a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry)) | |
| 552 | + | } | |
| 553 | + | }, | |
| 554 | + | OnFocusOut: func() { | |
| 555 | + | // When find bar loses focus, set WebView as the focused input | |
| 556 | + | if a.accentFocusProvider != nil { | |
| 557 | + | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 558 | + | } | |
| 559 | + | }, | |
| 482 | 560 | } | |
| 483 | 561 | } | |
| 484 | 562 | ||
| @@ -1219,6 +1297,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1219 | 1297 | a.deps.OnFirstWebViewShown(ctx) | |
| 1220 | 1298 | }) | |
| 1221 | 1299 | } | |
| 1300 | + | ||
| 1301 | + | // Set the WebView as the focused input for accent picker (if this is the active pane) | |
| 1302 | + | ws := a.activeWorkspace() | |
| 1303 | + | if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil { | |
| 1304 | + | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| 1305 | + | } | |
| 1222 | 1306 | }) | |
| 1223 | 1307 | ||
| 1224 | 1308 | // 5. Keyboard Dispatcher | |
| @@ -1538,6 +1622,23 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView { | |||
| 1538 | 1622 | return a.workspaceViews[activeTab.ID] | |
| 1539 | 1623 | } | |
| 1540 | 1624 | ||
| 1625 | + | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | |
| 1626 | + | // Used by the accent picker to insert accented characters into web content. | |
| 1627 | + | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |
| 1628 | + | ws := a.activeWorkspace() | |
| 1629 | + | if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil { | |
| 1630 | + | return nil | |
| 1631 | + | } | |
| 1632 | + | ||
| 1633 | + | wv := a.contentCoord.GetWebView(ws.ActivePaneID) | |
| 1634 | + | if wv == nil { | |
| 1635 | + | return nil | |
| 1636 | + | } | |
| 1637 | + | ||
| 1638 | + | // Get the underlying webkit.WebView for the text input target | |
| 1639 | + | return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard) | |
| 1640 | + | } | |
| 1641 | + | ||
| 1541 | 1642 | // attachPopupToTab attaches a popup WebView to a newly created tab. | |
| 1542 | 1643 | // This is called when a popup uses tabbed behavior. | |
| 1543 | 1644 | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 15 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | 16 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | ||
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/textinput" | |||
| 17 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 18 | 19 | ||||
| 19 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers" | 20 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers" | ||
| @@ -108,6 +109,11 @@ type App struct { | |||||
| 108 | 109 | ||||
| 109 | movePaneToTabUC *usecase.MovePaneToTabUseCase | 110 | movePaneToTabUC *usecase.MovePaneToTabUseCase | ||
| 110 | 111 | ||||
| 112 | + | // Accent picker for dead keys support | |||
| 113 | + | accentPicker *component.AccentPicker | |||
| 114 | + | insertAccentUC *usecase.InsertAccentUseCase | |||
| 115 | + | accentFocusProvider *textinput.FocusProvider | |||
| 116 | + | ||||
| 111 | // lifecycle | 117 | // lifecycle | ||
| 112 | cancel context.CancelCauseFunc | 118 | cancel context.CancelCauseFunc | ||
| 113 | } | 119 | } | ||
| @@ -217,6 +223,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 217 | a.initLayoutInfrastructure() | 223 | a.initLayoutInfrastructure() | ||
| 218 | a.initAppToasterOverlay() | 224 | a.initAppToasterOverlay() | ||
| 219 | a.initFocusAndBorderOverlay() | 225 | a.initFocusAndBorderOverlay() | ||
| 226 | + | a.initAccentPicker(ctx) | |||
| 220 | 227 | ||||
| 221 | a.initCoordinators(ctx) | 228 | a.initCoordinators(ctx) | ||
| 222 | a.initKeyboardHandler(ctx) | 229 | a.initKeyboardHandler(ctx) | ||
| @@ -387,6 +394,49 @@ func (a *App) initFocusAndBorderOverlay() { | |||||
| 387 | a.mainWindow.AddOverlay(gtkWidget) | 394 | a.mainWindow.AddOverlay(gtkWidget) | ||
| 388 | } | 395 | } | ||
| 389 | 396 | ||||
| 397 | + | func (a *App) initAccentPicker(ctx context.Context) { | |||
| 398 | + | log := logging.FromContext(ctx) | |||
| 399 | + | ||||
| 400 | + | if a.widgetFactory == nil || a.mainWindow == nil { | |||
| 401 | + | log.Debug().Msg("widget factory or main window not available, skipping accent picker") | |||
| 402 | + | return | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | // Create accent picker component | |||
| 406 | + | a.accentPicker = component.NewAccentPicker(a.widgetFactory) | |||
| 407 | + | if a.accentPicker == nil { | |||
| 408 | + | log.Warn().Msg("failed to create accent picker") | |||
| 409 | + | return | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // Add to main window overlay | |||
| 413 | + | pickerWidget := a.accentPicker.Widget() | |||
| 414 | + | if pickerWidget != nil { | |||
| 415 | + | gtkWidget := pickerWidget.GtkWidget() | |||
| 416 | + | if gtkWidget != nil { | |||
| 417 | + | a.mainWindow.AddOverlay(gtkWidget) | |||
| 418 | + | } | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | // Create focus provider (tracks which input has focus) | |||
| 422 | + | a.accentFocusProvider = textinput.NewFocusProvider() | |||
| 423 | + | ||||
| 424 | + | // Create the use case with glib.IdleAdd for thread-safe GTK calls | |||
| 425 | + | a.insertAccentUC = usecase.NewInsertAccentUseCase( | |||
| 426 | + | a.accentFocusProvider, | |||
| 427 | + | a.accentPicker, | |||
| 428 | + | func(fn func()) { | |||
| 429 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 430 | + | fn() | |||
| 431 | + | return false // Don't repeat | |||
| 432 | + | }) | |||
| 433 | + | glib.IdleAdd(&cb, 0) | |||
| 434 | + | }, | |||
| 435 | + | ) | |||
| 436 | + | ||||
| 437 | + | log.Debug().Msg("accent picker initialized") | |||
| 438 | + | } | |||
| 439 | + | ||||
| 390 | func (a *App) initKeyboardHandler(ctx context.Context) { | 440 | func (a *App) initKeyboardHandler(ctx context.Context) { | ||
| 391 | if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil { | 441 | if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil { | ||
| 392 | return | 442 | return | ||
| @@ -414,6 +464,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||||
| 414 | } | 464 | } | ||
| 415 | return wsView.IsOmniboxVisible() | 465 | return wsView.IsOmniboxVisible() | ||
| 416 | }) | 466 | }) | ||
| 467 | + | // Wire accent handler for dead keys support | |||
| 468 | + | if a.insertAccentUC != nil { | |||
| 469 | + | a.keyboardHandler.SetAccentHandler(a.insertAccentUC) | |||
| 470 | + | } | |||
| 417 | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | 471 | a.keyboardHandler.AttachTo(a.mainWindow.Window()) | ||
| 418 | 472 | ||||
| 419 | // Create global shortcut handler for Alt+1-9 tab switching. | 473 | // Create global shortcut handler for Alt+1-9 tab switching. | ||
| @@ -461,6 +515,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||||
| 461 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | 515 | log.Error().Err(err).Str("url", url).Msg("navigation failed") | ||
| 462 | } | 516 | } | ||
| 463 | }, | 517 | }, | ||
| 518 | + | OnFocusIn: func(entry *gtk.SearchEntry) { | |||
| 519 | + | // Set omnibox entry as the focused input for accent picker | |||
| 520 | + | if a.accentFocusProvider != nil && entry != nil { | |||
| 521 | + | a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry)) | |||
| 522 | + | } | |||
| 523 | + | }, | |||
| 524 | + | OnFocusOut: func() { | |||
| 525 | + | // When omnibox loses focus, set WebView as the focused input | |||
| 526 | + | if a.accentFocusProvider != nil { | |||
| 527 | + | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |||
| 528 | + | } | |||
| 529 | + | }, | |||
| 464 | } | 530 | } | ||
| 465 | a.navCoord.SetOmniboxProvider(a) | 531 | a.navCoord.SetOmniboxProvider(a) | ||
| 466 | log.Debug().Msg("omnibox config stored, provider set") | 532 | log.Debug().Msg("omnibox config stored, provider set") | ||
| @@ -479,6 +545,18 @@ func (a *App) initFindBarConfig() { | |||||
| 479 | } | 545 | } | ||
| 480 | return wv.GetFindController() | 546 | return wv.GetFindController() | ||
| 481 | }, | 547 | }, | ||
| 548 | + | OnFocusIn: func(entry *gtk.SearchEntry) { | |||
| 549 | + | // Set find bar entry as the focused input for accent picker | |||
| 550 | + | if a.accentFocusProvider != nil && entry != nil { | |||
| 551 | + | a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry)) | |||
| 552 | + | } | |||
| 553 | + | }, | |||
| 554 | + | OnFocusOut: func() { | |||
| 555 | + | // When find bar loses focus, set WebView as the focused input | |||
| 556 | + | if a.accentFocusProvider != nil { | |||
| 557 | + | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |||
| 558 | + | } | |||
| 559 | + | }, | |||
| 482 | } | 560 | } | ||
| 483 | } | 561 | } | ||
| 484 | 562 | ||||
| @@ -1219,6 +1297,12 @@ func (a *App) initCoordinators(ctx context.Context) { | |||||
| 1219 | a.deps.OnFirstWebViewShown(ctx) | 1297 | a.deps.OnFirstWebViewShown(ctx) | ||
| 1220 | }) | 1298 | }) | ||
| 1221 | } | 1299 | } | ||
| 1300 | + | ||||
| 1301 | + | // Set the WebView as the focused input for accent picker (if this is the active pane) | |||
| 1302 | + | ws := a.activeWorkspace() | |||
| 1303 | + | if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil { | |||
| 1304 | + | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |||
| 1305 | + | } | |||
| 1222 | }) | 1306 | }) | ||
| 1223 | 1307 | ||||
| 1224 | // 5. Keyboard Dispatcher | 1308 | // 5. Keyboard Dispatcher | ||
| @@ -1538,6 +1622,23 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView { | |||||
| 1538 | return a.workspaceViews[activeTab.ID] | 1622 | return a.workspaceViews[activeTab.ID] | ||
| 1539 | } | 1623 | } | ||
| 1540 | 1624 | ||||
| 1625 | + | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | |||
| 1626 | + | // Used by the accent picker to insert accented characters into web content. | |||
| 1627 | + | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |||
| 1628 | + | ws := a.activeWorkspace() | |||
| 1629 | + | if ws == nil || a.contentCoord == nil || a.deps == nil || a.deps.Clipboard == nil { | |||
| 1630 | + | return nil | |||
| 1631 | + | } | |||
| 1632 | + | ||||
| 1633 | + | wv := a.contentCoord.GetWebView(ws.ActivePaneID) | |||
| 1634 | + | if wv == nil { | |||
| 1635 | + | return nil | |||
| 1636 | + | } | |||
| 1637 | + | ||||
| 1638 | + | // Get the underlying webkit.WebView for the text input target | |||
| 1639 | + | return textinput.NewWebViewTarget(wv.Widget(), a.deps.Clipboard) | |||
| 1640 | + | } | |||
| 1641 | + | ||||
| 1541 | // attachPopupToTab attaches a popup WebView to a newly created tab. | 1642 | // attachPopupToTab attaches a popup WebView to a newly created tab. | ||
| 1542 | // This is called when a popup uses tabbed behavior. | 1643 | // This is called when a popup uses tabbed behavior. | ||
| 1543 | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | 1644 | func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) { | ||
M internal/ui/component/omnibox.go +34 -0
| @@ -132,6 +132,8 @@ type OmniboxConfig struct { | |||
| 132 | 132 | UIScale float64 // UI scale for favicon sizing | |
| 133 | 133 | OnNavigate func(url string) // Callback when user navigates via omnibox | |
| 134 | 134 | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | |
| 135 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |
| 136 | + | OnFocusOut func() // Callback when entry loses focus | |
| 135 | 137 | } | |
| 136 | 138 | ||
| 137 | 139 | // NewOmnibox creates a new native GTK4 omnibox widget. | |
| @@ -166,6 +168,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||
| 166 | 168 | ||
| 167 | 169 | o.setupKeyboardHandling() | |
| 168 | 170 | o.setupEntryChanged() | |
| 171 | + | o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut) | |
| 169 | 172 | ||
| 170 | 173 | // Set navigation callback if provided | |
| 171 | 174 | if cfg.OnNavigate != nil { | |
| @@ -459,6 +462,37 @@ func (o *Omnibox) setupEntryChanged() { | |||
| 459 | 462 | o.entry.ConnectSearchChanged(&changedCb) | |
| 460 | 463 | } | |
| 461 | 464 | ||
| 465 | + | // setupFocusCallbacks wires focus in/out callbacks for accent picker integration. | |
| 466 | + | func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) { | |
| 467 | + | if onFocusIn == nil && onFocusOut == nil { | |
| 468 | + | return | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | focusController := gtk.NewEventControllerFocus() | |
| 472 | + | if focusController == nil { | |
| 473 | + | return | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | if onFocusIn != nil { | |
| 477 | + | entry := o.entry // Capture for closure | |
| 478 | + | focusInCb := func(_ gtk.EventControllerFocus) { | |
| 479 | + | onFocusIn(entry) | |
| 480 | + | } | |
| 481 | + | o.retainedCallbacks = append(o.retainedCallbacks, focusInCb) | |
| 482 | + | focusController.ConnectEnter(&focusInCb) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | if onFocusOut != nil { | |
| 486 | + | focusOutCb := func(_ gtk.EventControllerFocus) { | |
| 487 | + | onFocusOut() | |
| 488 | + | } | |
| 489 | + | o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb) | |
| 490 | + | focusController.ConnectLeave(&focusOutCb) | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | o.entry.AddController(&focusController.EventController) | |
| 494 | + | } | |
| 495 | + | ||
| 462 | 496 | // handleKeyPress processes keyboard events. | |
| 463 | 497 | // Returns true if the event was handled. | |
| 464 | 498 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | |
| @@ -132,6 +132,8 @@ type OmniboxConfig struct { | |||||
| 132 | UIScale float64 // UI scale for favicon sizing | 132 | UIScale float64 // UI scale for favicon sizing | ||
| 133 | OnNavigate func(url string) // Callback when user navigates via omnibox | 133 | OnNavigate func(url string) // Callback when user navigates via omnibox | ||
| 134 | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | 134 | OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification | ||
| 135 | + | OnFocusIn func(entry *gtk.SearchEntry) // Callback when entry gains focus (for accent picker) | |||
| 136 | + | OnFocusOut func() // Callback when entry loses focus | |||
| 135 | } | 137 | } | ||
| 136 | 138 | ||||
| 137 | // NewOmnibox creates a new native GTK4 omnibox widget. | 139 | // NewOmnibox creates a new native GTK4 omnibox widget. | ||
| @@ -166,6 +168,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox { | |||||
| 166 | 168 | ||||
| 167 | o.setupKeyboardHandling() | 169 | o.setupKeyboardHandling() | ||
| 168 | o.setupEntryChanged() | 170 | o.setupEntryChanged() | ||
| 171 | + | o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut) | |||
| 169 | 172 | ||||
| 170 | // Set navigation callback if provided | 173 | // Set navigation callback if provided | ||
| 171 | if cfg.OnNavigate != nil { | 174 | if cfg.OnNavigate != nil { | ||
| @@ -459,6 +462,37 @@ func (o *Omnibox) setupEntryChanged() { | |||||
| 459 | o.entry.ConnectSearchChanged(&changedCb) | 462 | o.entry.ConnectSearchChanged(&changedCb) | ||
| 460 | } | 463 | } | ||
| 461 | 464 | ||||
| 465 | + | // setupFocusCallbacks wires focus in/out callbacks for accent picker integration. | |||
| 466 | + | func (o *Omnibox) setupFocusCallbacks(onFocusIn func(*gtk.SearchEntry), onFocusOut func()) { | |||
| 467 | + | if onFocusIn == nil && onFocusOut == nil { | |||
| 468 | + | return | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | focusController := gtk.NewEventControllerFocus() | |||
| 472 | + | if focusController == nil { | |||
| 473 | + | return | |||
| 474 | + | } | |||
| 475 | + | ||||
| 476 | + | if onFocusIn != nil { | |||
| 477 | + | entry := o.entry // Capture for closure | |||
| 478 | + | focusInCb := func(_ gtk.EventControllerFocus) { | |||
| 479 | + | onFocusIn(entry) | |||
| 480 | + | } | |||
| 481 | + | o.retainedCallbacks = append(o.retainedCallbacks, focusInCb) | |||
| 482 | + | focusController.ConnectEnter(&focusInCb) | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | if onFocusOut != nil { | |||
| 486 | + | focusOutCb := func(_ gtk.EventControllerFocus) { | |||
| 487 | + | onFocusOut() | |||
| 488 | + | } | |||
| 489 | + | o.retainedCallbacks = append(o.retainedCallbacks, focusOutCb) | |||
| 490 | + | focusController.ConnectLeave(&focusOutCb) | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | o.entry.AddController(&focusController.EventController) | |||
| 494 | + | } | |||
| 495 | + | ||||
| 462 | // handleKeyPress processes keyboard events. | 496 | // handleKeyPress processes keyboard events. | ||
| 463 | // Returns true if the event was handled. | 497 | // Returns true if the event was handled. | ||
| 464 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | 498 | func (o *Omnibox) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { | ||
M internal/ui/coordinator/content.go +15 -7
| @@ -61,13 +61,13 @@ type ContentCoordinator struct { | |||
| 61 | 61 | revealMu sync.Mutex | |
| 62 | 62 | pendingReveal map[entity.PaneID]bool | |
| 63 | 63 | ||
| 64 | - | appearanceMu sync.Mutex | |
| 65 | - | pendingScriptRefresh map[entity.PaneID]bool | |
| 66 | - | pendingThemePanes map[entity.PaneID]bool | |
| 67 | - | pendingThemeUpdate pendingThemeUpdate | |
| 68 | - | hasPendingThemeUpdate bool | |
| 69 | - | currentTheme pendingThemeUpdate | |
| 70 | - | hasCurrentTheme bool | |
| 64 | + | appearanceMu sync.Mutex | |
| 65 | + | pendingScriptRefresh map[entity.PaneID]bool | |
| 66 | + | pendingThemePanes map[entity.PaneID]bool | |
| 67 | + | pendingThemeUpdate pendingThemeUpdate | |
| 68 | + | hasPendingThemeUpdate bool | |
| 69 | + | currentTheme pendingThemeUpdate | |
| 70 | + | hasCurrentTheme bool | |
| 71 | 71 | ||
| 72 | 72 | // Gesture action handler for mouse button navigation | |
| 73 | 73 | gestureActionHandler input.ActionHandler | |
| @@ -92,6 +92,9 @@ type ContentCoordinator struct { | |||
| 92 | 92 | ||
| 93 | 93 | // Callback when fullscreen state changes (for hiding/showing tab bar) | |
| 94 | 94 | onFullscreenChanged func(entering bool) | |
| 95 | + | ||
| 96 | + | // Callback when WebView gains focus (for accent picker text input targeting) | |
| 97 | + | onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView) | |
| 95 | 98 | } | |
| 96 | 99 | ||
| 97 | 100 | type pendingThemeUpdate struct { | |
| @@ -165,6 +168,11 @@ func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) { | |||
| 165 | 168 | c.onFullscreenChanged = fn | |
| 166 | 169 | } | |
| 167 | 170 | ||
| 171 | + | // SetOnWebViewFocused sets the callback for when a WebView gains focus. | |
| 172 | + | func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) { | |
| 173 | + | c.onWebViewFocused = fn | |
| 174 | + | } | |
| 175 | + | ||
| 168 | 176 | // EnsureWebView acquires or reuses a WebView for the given pane. | |
| 169 | 177 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | |
| 170 | 178 | log := logging.FromContext(ctx) | |
| @@ -61,13 +61,13 @@ type ContentCoordinator struct { | |||||
| 61 | revealMu sync.Mutex | 61 | revealMu sync.Mutex | ||
| 62 | pendingReveal map[entity.PaneID]bool | 62 | pendingReveal map[entity.PaneID]bool | ||
| 63 | 63 | ||||
| 64 | - | appearanceMu sync.Mutex | 64 | + | appearanceMu sync.Mutex |
| 65 | - | pendingScriptRefresh map[entity.PaneID]bool | 65 | + | pendingScriptRefresh map[entity.PaneID]bool |
| 66 | - | pendingThemePanes map[entity.PaneID]bool | 66 | + | pendingThemePanes map[entity.PaneID]bool |
| 67 | - | pendingThemeUpdate pendingThemeUpdate | 67 | + | pendingThemeUpdate pendingThemeUpdate |
| 68 | - | hasPendingThemeUpdate bool | 68 | + | hasPendingThemeUpdate bool |
| 69 | - | currentTheme pendingThemeUpdate | 69 | + | currentTheme pendingThemeUpdate |
| 70 | - | hasCurrentTheme bool | 70 | + | hasCurrentTheme bool |
| 71 | 71 | ||||
| 72 | // Gesture action handler for mouse button navigation | 72 | // Gesture action handler for mouse button navigation | ||
| 73 | gestureActionHandler input.ActionHandler | 73 | gestureActionHandler input.ActionHandler | ||
| @@ -92,6 +92,9 @@ type ContentCoordinator struct { | |||||
| 92 | 92 | ||||
| 93 | // Callback when fullscreen state changes (for hiding/showing tab bar) | 93 | // Callback when fullscreen state changes (for hiding/showing tab bar) | ||
| 94 | onFullscreenChanged func(entering bool) | 94 | onFullscreenChanged func(entering bool) | ||
| 95 | + | ||||
| 96 | + | // Callback when WebView gains focus (for accent picker text input targeting) | |||
| 97 | + | onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView) | |||
| 95 | } | 98 | } | ||
| 96 | 99 | ||||
| 97 | type pendingThemeUpdate struct { | 100 | type pendingThemeUpdate struct { | ||
| @@ -165,6 +168,11 @@ func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) { | |||||
| 165 | c.onFullscreenChanged = fn | 168 | c.onFullscreenChanged = fn | ||
| 166 | } | 169 | } | ||
| 167 | 170 | ||||
| 171 | + | // SetOnWebViewFocused sets the callback for when a WebView gains focus. | |||
| 172 | + | func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) { | |||
| 173 | + | c.onWebViewFocused = fn | |||
| 174 | + | } | |||
| 175 | + | ||||
| 168 | // EnsureWebView acquires or reuses a WebView for the given pane. | 176 | // EnsureWebView acquires or reuses a WebView for the given pane. | ||
| 169 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | 177 | func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { | ||
| 170 | log := logging.FromContext(ctx) | 178 | log := logging.FromContext(ctx) | ||
M internal/ui/deps.go +3 -0
| @@ -60,6 +60,9 @@ type Dependencies struct { | |||
| 60 | 60 | FilterManager *filtering.Manager | |
| 61 | 61 | IdleInhibitor port.IdleInhibitor | |
| 62 | 62 | ||
| 63 | + | // Accent picker for dead keys support | |
| 64 | + | InsertAccentUC *usecase.InsertAccentUseCase | |
| 65 | + | ||
| 63 | 66 | // Session management | |
| 64 | 67 | SessionStateRepo repository.SessionStateRepository | |
| 65 | 68 | SessionRepo repository.SessionRepository | |
| @@ -60,6 +60,9 @@ type Dependencies struct { | |||||
| 60 | FilterManager *filtering.Manager | 60 | FilterManager *filtering.Manager | ||
| 61 | IdleInhibitor port.IdleInhibitor | 61 | IdleInhibitor port.IdleInhibitor | ||
| 62 | 62 | ||||
| 63 | + | // Accent picker for dead keys support | |||
| 64 | + | InsertAccentUC *usecase.InsertAccentUseCase | |||
| 65 | + | ||||
| 63 | // Session management | 66 | // Session management | ||
| 64 | SessionStateRepo repository.SessionStateRepository | 67 | SessionStateRepo repository.SessionStateRepository | ||
| 65 | SessionRepo repository.SessionRepository | 68 | SessionRepo repository.SessionRepository | ||
M internal/ui/input/keyboard.go +108 -26
| @@ -17,6 +17,20 @@ import ( | |||
| 17 | 17 | // Return an error if the action fails. | |
| 18 | 18 | type ActionHandler func(ctx context.Context, action Action) error | |
| 19 | 19 | ||
| 20 | + | // AccentHandler handles long-press accent detection. | |
| 21 | + | // Called for character keys that may have accent variants. | |
| 22 | + | type AccentHandler interface { | |
| 23 | + | // OnKeyPressed is called when a key is pressed. | |
| 24 | + | // Returns true if the accent handler is handling this key (picker visible or will show). | |
| 25 | + | OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool | |
| 26 | + | // OnKeyReleased is called when a key is released. | |
| 27 | + | OnKeyReleased(ctx context.Context, char rune) | |
| 28 | + | // IsPickerVisible returns true if the accent picker is currently visible. | |
| 29 | + | IsPickerVisible() bool | |
| 30 | + | // Cancel cancels any pending long-press detection. | |
| 31 | + | Cancel(ctx context.Context) | |
| 32 | + | } | |
| 33 | + | ||
| 20 | 34 | // KeyboardHandler processes keyboard events and dispatches actions. | |
| 21 | 35 | // It manages modal input modes and routes key events to the appropriate handlers. | |
| 22 | 36 | type KeyboardHandler struct { | |
| @@ -28,12 +42,15 @@ type KeyboardHandler struct { | |||
| 28 | 42 | onAction ActionHandler | |
| 29 | 43 | // Optional bypass check (e.g., omnibox visible) | |
| 30 | 44 | shouldBypass func() bool | |
| 45 | + | // Optional accent handler for dead keys support | |
| 46 | + | accentHandler AccentHandler | |
| 31 | 47 | ||
| 32 | 48 | // GTK controller (nil until attached) | |
| 33 | 49 | controller *gtk.EventControllerKey | |
| 34 | 50 | ||
| 35 | 51 | // Callback retention: must stay reachable by Go GC. | |
| 36 | - | keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | |
| 52 | + | keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | |
| 53 | + | keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) | |
| 37 | 54 | ||
| 38 | 55 | ctx context.Context | |
| 39 | 56 | mu sync.RWMutex | |
| @@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) { | |||
| 75 | 92 | h.shouldBypass = fn | |
| 76 | 93 | } | |
| 77 | 94 | ||
| 95 | + | // SetAccentHandler sets the handler for long-press accent detection. | |
| 96 | + | func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) { | |
| 97 | + | h.mu.Lock() | |
| 98 | + | defer h.mu.Unlock() | |
| 99 | + | h.accentHandler = handler | |
| 100 | + | } | |
| 101 | + | ||
| 78 | 102 | // Mode returns the current input mode. | |
| 79 | 103 | func (h *KeyboardHandler) Mode() Mode { | |
| 80 | 104 | return h.modal.Mode() | |
| @@ -106,6 +130,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 106 | 130 | } | |
| 107 | 131 | h.controller.ConnectKeyPressed(&h.keyPressedCb) | |
| 108 | 132 | ||
| 133 | + | // Connect key released handler for accent detection. | |
| 134 | + | h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { | |
| 135 | + | h.handleKeyRelease(keyval) | |
| 136 | + | } | |
| 137 | + | h.controller.ConnectKeyReleased(&h.keyReleasedCb) | |
| 138 | + | ||
| 109 | 139 | // Add controller to window | |
| 110 | 140 | window.AddController(&h.controller.EventController) | |
| 111 | 141 | ||
| @@ -118,6 +148,7 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 118 | 148 | func (h *KeyboardHandler) Detach() { | |
| 119 | 149 | h.controller = nil | |
| 120 | 150 | h.keyPressedCb = nil | |
| 151 | + | h.keyReleasedCb = nil | |
| 121 | 152 | } | |
| 122 | 153 | ||
| 123 | 154 | // handleKeyPress processes a key press event. | |
| @@ -131,45 +162,71 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 131 | 162 | ||
| 132 | 163 | h.mu.RLock() | |
| 133 | 164 | shouldBypass := h.shouldBypass | |
| 165 | + | accentHandler := h.accentHandler | |
| 134 | 166 | h.mu.RUnlock() | |
| 135 | - | if shouldBypass != nil && shouldBypass() { | |
| 136 | - | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | |
| 137 | - | return false | |
| 167 | + | ||
| 168 | + | // Check if accent picker is visible - it takes priority | |
| 169 | + | if accentHandler != nil && accentHandler.IsPickerVisible() { | |
| 170 | + | return false // Let the accent picker handle the key via its own controller | |
| 138 | 171 | } | |
| 139 | 172 | ||
| 140 | - | // Build KeyBinding from event | |
| 141 | 173 | modifiers := Modifier(state) & modifierMask | |
| 142 | 174 | ||
| 143 | - | // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M). | |
| 144 | - | // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase | |
| 145 | - | // keyvals and rely on ModShift to represent Shift. | |
| 146 | - | if keyval >= uint('A') && keyval <= uint('Z') { | |
| 147 | - | keyval += (uint('a') - uint('A')) | |
| 175 | + | // Check accent detection first - works in both normal and bypass mode | |
| 176 | + | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView | |
| 177 | + | if h.tryAccentDetection(accentHandler, keyval, modifiers) { | |
| 178 | + | return true | |
| 148 | 179 | } | |
| 149 | 180 | ||
| 150 | - | binding := KeyBinding{ | |
| 151 | - | Keyval: keyval, | |
| 152 | - | Modifiers: modifiers, | |
| 181 | + | if shouldBypass != nil && shouldBypass() { | |
| 182 | + | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | |
| 183 | + | return false | |
| 153 | 184 | } | |
| 154 | 185 | ||
| 155 | - | mode := h.modal.Mode() | |
| 186 | + | // Normalize uppercase letters for consistent binding lookup | |
| 187 | + | keyval = normalizeKeyval(keyval) | |
| 156 | 188 | ||
| 189 | + | binding := KeyBinding{Keyval: keyval, Modifiers: modifiers} | |
| 190 | + | mode := h.modal.Mode() | |
| 157 | 191 | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) | |
| 158 | 192 | ||
| 159 | 193 | if !found { | |
| 160 | - | if mode != ModeNormal { | |
| 161 | - | // In modal mode, consume unrecognized keys to prevent WebView handling | |
| 162 | - | return true | |
| 163 | - | } | |
| 164 | - | // In normal mode, let the key pass through to WebView | |
| 194 | + | return mode != ModeNormal // Consume unrecognized keys in modal mode | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | return h.dispatchAction(action, mode) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // tryAccentDetection starts long-press detection for accent-eligible keys. | |
| 201 | + | // Returns true if the key should be suppressed (blocked from reaching the WebView). | |
| 202 | + | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | |
| 203 | + | if h.modal.Mode() != ModeNormal || accentHandler == nil { | |
| 204 | + | return false | |
| 205 | + | } | |
| 206 | + | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | |
| 207 | + | if modifiers != 0 && modifiers != ModShift { | |
| 165 | 208 | return false | |
| 166 | 209 | } | |
| 210 | + | if char := keyvalToRune(keyval); char != 0 { | |
| 211 | + | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | |
| 212 | + | } | |
| 213 | + | return false | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup. | |
| 217 | + | func normalizeKeyval(keyval uint) uint { | |
| 218 | + | if keyval >= uint('A') && keyval <= uint('Z') { | |
| 219 | + | return keyval + (uint('a') - uint('A')) | |
| 220 | + | } | |
| 221 | + | return keyval | |
| 222 | + | } | |
| 167 | 223 | ||
| 224 | + | // dispatchAction dispatches the action and handles mode-related logic. | |
| 225 | + | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |
| 168 | 226 | if h.handleModeAction(action) { | |
| 169 | 227 | return true | |
| 170 | 228 | } | |
| 171 | 229 | ||
| 172 | - | // Dispatch action to handler | |
| 173 | 230 | h.mu.RLock() | |
| 174 | 231 | handler := h.onAction | |
| 175 | 232 | h.mu.RUnlock() | |
| @@ -177,19 +234,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||
| 177 | 234 | if handler != nil { | |
| 178 | 235 | if err := handler(h.ctx, action); err != nil { | |
| 179 | 236 | log := logging.FromContext(h.ctx) | |
| 180 | - | log.Error(). | |
| 181 | - | Err(err). | |
| 182 | - | Str("action", string(action)). | |
| 183 | - | Msg("action handler error") | |
| 237 | + | log.Error().Err(err).Str("action", string(action)).Msg("action handler error") | |
| 184 | 238 | } | |
| 185 | 239 | } | |
| 186 | 240 | ||
| 187 | - | // In resize mode, any resize keypress should extend the timeout. | |
| 188 | 241 | if mode == ModeResize && isResizeAction(action) { | |
| 189 | 242 | h.modal.ResetTimeout(h.ctx) | |
| 190 | 243 | } | |
| 191 | 244 | ||
| 192 | - | // Auto-exit modal mode after certain actions | |
| 193 | 245 | if mode != ModeNormal && ShouldAutoExitMode(action) { | |
| 194 | 246 | h.modal.ExitMode(h.ctx) | |
| 195 | 247 | } | |
| @@ -314,3 +366,33 @@ func (h *KeyboardHandler) EnterSessionMode() { | |||
| 314 | 366 | func (h *KeyboardHandler) ExitMode() { | |
| 315 | 367 | h.modal.ExitMode(h.ctx) | |
| 316 | 368 | } | |
| 369 | + | ||
| 370 | + | // handleKeyRelease processes a key release event for accent detection. | |
| 371 | + | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |
| 372 | + | h.mu.RLock() | |
| 373 | + | accentHandler := h.accentHandler | |
| 374 | + | h.mu.RUnlock() | |
| 375 | + | ||
| 376 | + | if accentHandler == nil { | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // Convert keyval to rune for the accent handler | |
| 381 | + | if char := keyvalToRune(keyval); char != 0 { | |
| 382 | + | accentHandler.OnKeyReleased(h.ctx, char) | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // keyvalToRune converts a GTK keyval to a lowercase rune. | |
| 387 | + | // Returns 0 if the keyval is not a printable character with potential accents. | |
| 388 | + | func keyvalToRune(keyval uint) rune { | |
| 389 | + | // Handle lowercase a-z | |
| 390 | + | if keyval >= uint('a') && keyval <= uint('z') { | |
| 391 | + | return rune(keyval) | |
| 392 | + | } | |
| 393 | + | // Handle uppercase A-Z (convert to lowercase for accent lookup) | |
| 394 | + | if keyval >= uint('A') && keyval <= uint('Z') { | |
| 395 | + | return rune(keyval + ('a' - 'A')) | |
| 396 | + | } | |
| 397 | + | return 0 | |
| 398 | + | } | |
| @@ -17,6 +17,20 @@ import ( | |||||
| 17 | // Return an error if the action fails. | 17 | // Return an error if the action fails. | ||
| 18 | type ActionHandler func(ctx context.Context, action Action) error | 18 | type ActionHandler func(ctx context.Context, action Action) error | ||
| 19 | 19 | ||||
| 20 | + | // AccentHandler handles long-press accent detection. | |||
| 21 | + | // Called for character keys that may have accent variants. | |||
| 22 | + | type AccentHandler interface { | |||
| 23 | + | // OnKeyPressed is called when a key is pressed. | |||
| 24 | + | // Returns true if the accent handler is handling this key (picker visible or will show). | |||
| 25 | + | OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool | |||
| 26 | + | // OnKeyReleased is called when a key is released. | |||
| 27 | + | OnKeyReleased(ctx context.Context, char rune) | |||
| 28 | + | // IsPickerVisible returns true if the accent picker is currently visible. | |||
| 29 | + | IsPickerVisible() bool | |||
| 30 | + | // Cancel cancels any pending long-press detection. | |||
| 31 | + | Cancel(ctx context.Context) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 20 | // KeyboardHandler processes keyboard events and dispatches actions. | 34 | // KeyboardHandler processes keyboard events and dispatches actions. | ||
| 21 | // It manages modal input modes and routes key events to the appropriate handlers. | 35 | // It manages modal input modes and routes key events to the appropriate handlers. | ||
| 22 | type KeyboardHandler struct { | 36 | type KeyboardHandler struct { | ||
| @@ -28,12 +42,15 @@ type KeyboardHandler struct { | |||||
| 28 | onAction ActionHandler | 42 | onAction ActionHandler | ||
| 29 | // Optional bypass check (e.g., omnibox visible) | 43 | // Optional bypass check (e.g., omnibox visible) | ||
| 30 | shouldBypass func() bool | 44 | shouldBypass func() bool | ||
| 45 | + | // Optional accent handler for dead keys support | |||
| 46 | + | accentHandler AccentHandler | |||
| 31 | 47 | ||||
| 32 | // GTK controller (nil until attached) | 48 | // GTK controller (nil until attached) | ||
| 33 | controller *gtk.EventControllerKey | 49 | controller *gtk.EventControllerKey | ||
| 34 | 50 | ||||
| 35 | // Callback retention: must stay reachable by Go GC. | 51 | // Callback retention: must stay reachable by Go GC. | ||
| 36 | - | keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool | 52 | + | keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool |
| 53 | + | keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) | |||
| 37 | 54 | ||||
| 38 | ctx context.Context | 55 | ctx context.Context | ||
| 39 | mu sync.RWMutex | 56 | mu sync.RWMutex | ||
| @@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) { | |||||
| 75 | h.shouldBypass = fn | 92 | h.shouldBypass = fn | ||
| 76 | } | 93 | } | ||
| 77 | 94 | ||||
| 95 | + | // SetAccentHandler sets the handler for long-press accent detection. | |||
| 96 | + | func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) { | |||
| 97 | + | h.mu.Lock() | |||
| 98 | + | defer h.mu.Unlock() | |||
| 99 | + | h.accentHandler = handler | |||
| 100 | + | } | |||
| 101 | + | ||||
| 78 | // Mode returns the current input mode. | 102 | // Mode returns the current input mode. | ||
| 79 | func (h *KeyboardHandler) Mode() Mode { | 103 | func (h *KeyboardHandler) Mode() Mode { | ||
| 80 | return h.modal.Mode() | 104 | return h.modal.Mode() | ||
| @@ -106,6 +130,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 106 | } | 130 | } | ||
| 107 | h.controller.ConnectKeyPressed(&h.keyPressedCb) | 131 | h.controller.ConnectKeyPressed(&h.keyPressedCb) | ||
| 108 | 132 | ||||
| 133 | + | // Connect key released handler for accent detection. | |||
| 134 | + | h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { | |||
| 135 | + | h.handleKeyRelease(keyval) | |||
| 136 | + | } | |||
| 137 | + | h.controller.ConnectKeyReleased(&h.keyReleasedCb) | |||
| 138 | + | ||||
| 109 | // Add controller to window | 139 | // Add controller to window | ||
| 110 | window.AddController(&h.controller.EventController) | 140 | window.AddController(&h.controller.EventController) | ||
| 111 | 141 | ||||
| @@ -118,6 +148,7 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 118 | func (h *KeyboardHandler) Detach() { | 148 | func (h *KeyboardHandler) Detach() { | ||
| 119 | h.controller = nil | 149 | h.controller = nil | ||
| 120 | h.keyPressedCb = nil | 150 | h.keyPressedCb = nil | ||
| 151 | + | h.keyReleasedCb = nil | |||
| 121 | } | 152 | } | ||
| 122 | 153 | ||||
| 123 | // handleKeyPress processes a key press event. | 154 | // handleKeyPress processes a key press event. | ||
| @@ -131,45 +162,71 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 131 | 162 | ||||
| 132 | h.mu.RLock() | 163 | h.mu.RLock() | ||
| 133 | shouldBypass := h.shouldBypass | 164 | shouldBypass := h.shouldBypass | ||
| 165 | + | accentHandler := h.accentHandler | |||
| 134 | h.mu.RUnlock() | 166 | h.mu.RUnlock() | ||
| 135 | - | if shouldBypass != nil && shouldBypass() { | 167 | + | |
| 136 | - | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") | 168 | + | // Check if accent picker is visible - it takes priority |
| 137 | - | return false | 169 | + | if accentHandler != nil && accentHandler.IsPickerVisible() { |
| 170 | + | return false // Let the accent picker handle the key via its own controller | |||
| 138 | } | 171 | } | ||
| 139 | 172 | ||||
| 140 | - | // Build KeyBinding from event | |||
| 141 | modifiers := Modifier(state) & modifierMask | 173 | modifiers := Modifier(state) & modifierMask | ||
| 142 | 174 | ||||
| 143 | - | // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M). | 175 | + | // Check accent detection first - works in both normal and bypass mode |
| 144 | - | // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase | 176 | + | // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView |
| 145 | - | // keyvals and rely on ModShift to represent Shift. | 177 | + | if h.tryAccentDetection(accentHandler, keyval, modifiers) { |
| 146 | - | if keyval >= uint('A') && keyval <= uint('Z') { | 178 | + | return true |
| 147 | - | keyval += (uint('a') - uint('A')) | |||
| 148 | } | 179 | } | ||
| 149 | 180 | ||||
| 150 | - | binding := KeyBinding{ | 181 | + | if shouldBypass != nil && shouldBypass() { |
| 151 | - | Keyval: keyval, | 182 | + | log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed") |
| 152 | - | Modifiers: modifiers, | 183 | + | return false |
| 153 | } | 184 | } | ||
| 154 | 185 | ||||
| 155 | - | mode := h.modal.Mode() | 186 | + | // Normalize uppercase letters for consistent binding lookup |
| 187 | + | keyval = normalizeKeyval(keyval) | |||
| 156 | 188 | ||||
| 189 | + | binding := KeyBinding{Keyval: keyval, Modifiers: modifiers} | |||
| 190 | + | mode := h.modal.Mode() | |||
| 157 | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) | 191 | action, found := h.lookupAction(log, binding, mode, modifiers, keycode) | ||
| 158 | 192 | ||||
| 159 | if !found { | 193 | if !found { | ||
| 160 | - | if mode != ModeNormal { | 194 | + | return mode != ModeNormal // Consume unrecognized keys in modal mode |
| 161 | - | // In modal mode, consume unrecognized keys to prevent WebView handling | 195 | + | } |
| 162 | - | return true | 196 | + | |
| 163 | - | } | 197 | + | return h.dispatchAction(action, mode) |
| 164 | - | // In normal mode, let the key pass through to WebView | 198 | + | } |
| 199 | + | ||||
| 200 | + | // tryAccentDetection starts long-press detection for accent-eligible keys. | |||
| 201 | + | // Returns true if the key should be suppressed (blocked from reaching the WebView). | |||
| 202 | + | func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { | |||
| 203 | + | if h.modal.Mode() != ModeNormal || accentHandler == nil { | |||
| 204 | + | return false | |||
| 205 | + | } | |||
| 206 | + | // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) | |||
| 207 | + | if modifiers != 0 && modifiers != ModShift { | |||
| 165 | return false | 208 | return false | ||
| 166 | } | 209 | } | ||
| 210 | + | if char := keyvalToRune(keyval); char != 0 { | |||
| 211 | + | return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) | |||
| 212 | + | } | |||
| 213 | + | return false | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup. | |||
| 217 | + | func normalizeKeyval(keyval uint) uint { | |||
| 218 | + | if keyval >= uint('A') && keyval <= uint('Z') { | |||
| 219 | + | return keyval + (uint('a') - uint('A')) | |||
| 220 | + | } | |||
| 221 | + | return keyval | |||
| 222 | + | } | |||
| 167 | 223 | ||||
| 224 | + | // dispatchAction dispatches the action and handles mode-related logic. | |||
| 225 | + | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |||
| 168 | if h.handleModeAction(action) { | 226 | if h.handleModeAction(action) { | ||
| 169 | return true | 227 | return true | ||
| 170 | } | 228 | } | ||
| 171 | 229 | ||||
| 172 | - | // Dispatch action to handler | |||
| 173 | h.mu.RLock() | 230 | h.mu.RLock() | ||
| 174 | handler := h.onAction | 231 | handler := h.onAction | ||
| 175 | h.mu.RUnlock() | 232 | h.mu.RUnlock() | ||
| @@ -177,19 +234,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie | |||||
| 177 | if handler != nil { | 234 | if handler != nil { | ||
| 178 | if err := handler(h.ctx, action); err != nil { | 235 | if err := handler(h.ctx, action); err != nil { | ||
| 179 | log := logging.FromContext(h.ctx) | 236 | log := logging.FromContext(h.ctx) | ||
| 180 | - | log.Error(). | 237 | + | log.Error().Err(err).Str("action", string(action)).Msg("action handler error") |
| 181 | - | Err(err). | |||
| 182 | - | Str("action", string(action)). | |||
| 183 | - | Msg("action handler error") | |||
| 184 | } | 238 | } | ||
| 185 | } | 239 | } | ||
| 186 | 240 | ||||
| 187 | - | // In resize mode, any resize keypress should extend the timeout. | |||
| 188 | if mode == ModeResize && isResizeAction(action) { | 241 | if mode == ModeResize && isResizeAction(action) { | ||
| 189 | h.modal.ResetTimeout(h.ctx) | 242 | h.modal.ResetTimeout(h.ctx) | ||
| 190 | } | 243 | } | ||
| 191 | 244 | ||||
| 192 | - | // Auto-exit modal mode after certain actions | |||
| 193 | if mode != ModeNormal && ShouldAutoExitMode(action) { | 245 | if mode != ModeNormal && ShouldAutoExitMode(action) { | ||
| 194 | h.modal.ExitMode(h.ctx) | 246 | h.modal.ExitMode(h.ctx) | ||
| 195 | } | 247 | } | ||
| @@ -314,3 +366,33 @@ func (h *KeyboardHandler) EnterSessionMode() { | |||||
| 314 | func (h *KeyboardHandler) ExitMode() { | 366 | func (h *KeyboardHandler) ExitMode() { | ||
| 315 | h.modal.ExitMode(h.ctx) | 367 | h.modal.ExitMode(h.ctx) | ||
| 316 | } | 368 | } | ||
| 369 | + | ||||
| 370 | + | // handleKeyRelease processes a key release event for accent detection. | |||
| 371 | + | func (h *KeyboardHandler) handleKeyRelease(keyval uint) { | |||
| 372 | + | h.mu.RLock() | |||
| 373 | + | accentHandler := h.accentHandler | |||
| 374 | + | h.mu.RUnlock() | |||
| 375 | + | ||||
| 376 | + | if accentHandler == nil { | |||
| 377 | + | return | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | // Convert keyval to rune for the accent handler | |||
| 381 | + | if char := keyvalToRune(keyval); char != 0 { | |||
| 382 | + | accentHandler.OnKeyReleased(h.ctx, char) | |||
| 383 | + | } | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // keyvalToRune converts a GTK keyval to a lowercase rune. | |||
| 387 | + | // Returns 0 if the keyval is not a printable character with potential accents. | |||
| 388 | + | func keyvalToRune(keyval uint) rune { | |||
| 389 | + | // Handle lowercase a-z | |||
| 390 | + | if keyval >= uint('a') && keyval <= uint('z') { | |||
| 391 | + | return rune(keyval) | |||
| 392 | + | } | |||
| 393 | + | // Handle uppercase A-Z (convert to lowercase for accent lookup) | |||
| 394 | + | if keyval >= uint('A') && keyval <= uint('Z') { | |||
| 395 | + | return rune(keyval + ('a' - 'A')) | |||
| 396 | + | } | |||
| 397 | + | return 0 | |||
| 398 | + | } | |||