detached b3920c5

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 {
6060 }
6161
6262 return &MessageRouter{
63- handlers: make(map[string]handlerEntry),
64- baseCtx: ctx,
63+ handlers: make(map[string]handlerEntry),
64+ baseCtx: ctx,
6565 syncedIDs: make(map[WebViewID]bool),
6666 }
6767 }
@@ -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 (
1414 "github.com/bnema/dumber/internal/infrastructure/desktop"
1515 "github.com/bnema/dumber/internal/infrastructure/filtering"
1616 "github.com/bnema/dumber/internal/infrastructure/snapshot"
17+ "github.com/bnema/dumber/internal/infrastructure/textinput"
1718 "github.com/bnema/dumber/internal/infrastructure/webkit"
1819
1920 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
@@ -108,6 +109,11 @@ type App struct {
108109
109110 movePaneToTabUC *usecase.MovePaneToTabUseCase
110111
112+ // Accent picker for dead keys support
113+ accentPicker *component.AccentPicker
114+ insertAccentUC *usecase.InsertAccentUseCase
115+ accentFocusProvider *textinput.FocusProvider
116+
111117 // lifecycle
112118 cancel context.CancelCauseFunc
113119 }
@@ -217,6 +223,7 @@ func (a *App) onActivate(ctx context.Context) {
217223 a.initLayoutInfrastructure()
218224 a.initAppToasterOverlay()
219225 a.initFocusAndBorderOverlay()
226+ a.initAccentPicker(ctx)
220227
221228 a.initCoordinators(ctx)
222229 a.initKeyboardHandler(ctx)
@@ -387,6 +394,49 @@ func (a *App) initFocusAndBorderOverlay() {
387394 a.mainWindow.AddOverlay(gtkWidget)
388395 }
389396
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+
390440 func (a *App) initKeyboardHandler(ctx context.Context) {
391441 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
392442 return
@@ -414,6 +464,10 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
414464 }
415465 return wsView.IsOmniboxVisible()
416466 })
467+ // Wire accent handler for dead keys support
468+ if a.insertAccentUC != nil {
469+ a.keyboardHandler.SetAccentHandler(a.insertAccentUC)
470+ }
417471 a.keyboardHandler.AttachTo(a.mainWindow.Window())
418472
419473 // Create global shortcut handler for Alt+1-9 tab switching.
@@ -461,6 +515,18 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
461515 log.Error().Err(err).Str("url", url).Msg("navigation failed")
462516 }
463517 },
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+ },
464530 }
465531 a.navCoord.SetOmniboxProvider(a)
466532 log.Debug().Msg("omnibox config stored, provider set")
@@ -479,6 +545,18 @@ func (a *App) initFindBarConfig() {
479545 }
480546 return wv.GetFindController()
481547 },
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+ },
482560 }
483561 }
484562
@@ -1219,6 +1297,12 @@ func (a *App) initCoordinators(ctx context.Context) {
12191297 a.deps.OnFirstWebViewShown(ctx)
12201298 })
12211299 }
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+ }
12221306 })
12231307
12241308 // 5. Keyboard Dispatcher
@@ -1538,6 +1622,23 @@ func (a *App) activeWorkspaceView() *component.WorkspaceView {
15381622 return a.workspaceViews[activeTab.ID]
15391623 }
15401624
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+
15411642 // attachPopupToTab attaches a popup WebView to a newly created tab.
15421643 // This is called when a popup uses tabbed behavior.
15431644 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.MovePaneToTabUseCase110 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 // lifecycle117 // lifecycle
112 cancel context.CancelCauseFunc118 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 return442 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 Dispatcher1308 // 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 {
132132 UIScale float64 // UI scale for favicon sizing
133133 OnNavigate func(url string) // Callback when user navigates via omnibox
134134 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
135137 }
136138
137139 // NewOmnibox creates a new native GTK4 omnibox widget.
@@ -166,6 +168,7 @@ func NewOmnibox(ctx context.Context, cfg OmniboxConfig) *Omnibox {
166168
167169 o.setupKeyboardHandling()
168170 o.setupEntryChanged()
171+ o.setupFocusCallbacks(cfg.OnFocusIn, cfg.OnFocusOut)
169172
170173 // Set navigation callback if provided
171174 if cfg.OnNavigate != nil {
@@ -459,6 +462,37 @@ func (o *Omnibox) setupEntryChanged() {
459462 o.entry.ConnectSearchChanged(&changedCb)
460463 }
461464
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+
462496 // handleKeyPress processes keyboard events.
463497 // Returns true if the event was handled.
464498 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 sizing132 UIScale float64 // UI scale for favicon sizing
133 OnNavigate func(url string) // Callback when user navigates via omnibox133 OnNavigate func(url string) // Callback when user navigates via omnibox
134 OnToast func(ctx context.Context, message string, level ToastLevel) // Callback to show toast notification134 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 provided173 // 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 {
6161 revealMu sync.Mutex
6262 pendingReveal map[entity.PaneID]bool
6363
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
7171
7272 // Gesture action handler for mouse button navigation
7373 gestureActionHandler input.ActionHandler
@@ -92,6 +92,9 @@ type ContentCoordinator struct {
9292
9393 // Callback when fullscreen state changes (for hiding/showing tab bar)
9494 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)
9598 }
9699
97100 type pendingThemeUpdate struct {
@@ -165,6 +168,11 @@ func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
165168 c.onFullscreenChanged = fn
166169 }
167170
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+
168176 // EnsureWebView acquires or reuses a WebView for the given pane.
169177 func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
170178 log := logging.FromContext(ctx)
@@ -61,13 +61,13 @@ type ContentCoordinator struct {
61 revealMu sync.Mutex61 revealMu sync.Mutex
62 pendingReveal map[entity.PaneID]bool62 pendingReveal map[entity.PaneID]bool
63 63
64- appearanceMu sync.Mutex64+ appearanceMu sync.Mutex
65- pendingScriptRefresh map[entity.PaneID]bool65+ pendingScriptRefresh map[entity.PaneID]bool
66- pendingThemePanes map[entity.PaneID]bool66+ pendingThemePanes map[entity.PaneID]bool
67- pendingThemeUpdate pendingThemeUpdate67+ pendingThemeUpdate pendingThemeUpdate
68- hasPendingThemeUpdate bool68+ hasPendingThemeUpdate bool
69- currentTheme pendingThemeUpdate69+ currentTheme pendingThemeUpdate
70- hasCurrentTheme bool70+ hasCurrentTheme bool
71 71
72 // Gesture action handler for mouse button navigation72 // Gesture action handler for mouse button navigation
73 gestureActionHandler input.ActionHandler73 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 = fn168 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 {
6060 FilterManager *filtering.Manager
6161 IdleInhibitor port.IdleInhibitor
6262
63+ // Accent picker for dead keys support
64+ InsertAccentUC *usecase.InsertAccentUseCase
65+
6366 // Session management
6467 SessionStateRepo repository.SessionStateRepository
6568 SessionRepo repository.SessionRepository
@@ -60,6 +60,9 @@ type Dependencies struct {
60 FilterManager *filtering.Manager60 FilterManager *filtering.Manager
61 IdleInhibitor port.IdleInhibitor61 IdleInhibitor port.IdleInhibitor
62 62
63+ // Accent picker for dead keys support
64+ InsertAccentUC *usecase.InsertAccentUseCase
65+
63 // Session management66 // Session management
64 SessionStateRepo repository.SessionStateRepository67 SessionStateRepo repository.SessionStateRepository
65 SessionRepo repository.SessionRepository68 SessionRepo repository.SessionRepository
M internal/ui/input/keyboard.go +108 -26
@@ -17,6 +17,20 @@ import (
1717 // Return an error if the action fails.
1818 type ActionHandler func(ctx context.Context, action Action) error
1919
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+
2034 // KeyboardHandler processes keyboard events and dispatches actions.
2135 // It manages modal input modes and routes key events to the appropriate handlers.
2236 type KeyboardHandler struct {
@@ -28,12 +42,15 @@ type KeyboardHandler struct {
2842 onAction ActionHandler
2943 // Optional bypass check (e.g., omnibox visible)
3044 shouldBypass func() bool
45+ // Optional accent handler for dead keys support
46+ accentHandler AccentHandler
3147
3248 // GTK controller (nil until attached)
3349 controller *gtk.EventControllerKey
3450
3551 // 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)
3754
3855 ctx context.Context
3956 mu sync.RWMutex
@@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
7592 h.shouldBypass = fn
7693 }
7794
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+
78102 // Mode returns the current input mode.
79103 func (h *KeyboardHandler) Mode() Mode {
80104 return h.modal.Mode()
@@ -106,6 +130,12 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
106130 }
107131 h.controller.ConnectKeyPressed(&h.keyPressedCb)
108132
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+
109139 // Add controller to window
110140 window.AddController(&h.controller.EventController)
111141
@@ -118,6 +148,7 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
118148 func (h *KeyboardHandler) Detach() {
119149 h.controller = nil
120150 h.keyPressedCb = nil
151+ h.keyReleasedCb = nil
121152 }
122153
123154 // handleKeyPress processes a key press event.
@@ -131,45 +162,71 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
131162
132163 h.mu.RLock()
133164 shouldBypass := h.shouldBypass
165+ accentHandler := h.accentHandler
134166 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
138171 }
139172
140- // Build KeyBinding from event
141173 modifiers := Modifier(state) & modifierMask
142174
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
148179 }
149180
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
153184 }
154185
155- mode := h.modal.Mode()
186+ // Normalize uppercase letters for consistent binding lookup
187+ keyval = normalizeKeyval(keyval)
156188
189+ binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
190+ mode := h.modal.Mode()
157191 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
158192
159193 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 {
165208 return false
166209 }
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+}
167223
224+// dispatchAction dispatches the action and handles mode-related logic.
225+func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
168226 if h.handleModeAction(action) {
169227 return true
170228 }
171229
172- // Dispatch action to handler
173230 h.mu.RLock()
174231 handler := h.onAction
175232 h.mu.RUnlock()
@@ -177,19 +234,14 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
177234 if handler != nil {
178235 if err := handler(h.ctx, action); err != nil {
179236 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")
184238 }
185239 }
186240
187- // In resize mode, any resize keypress should extend the timeout.
188241 if mode == ModeResize && isResizeAction(action) {
189242 h.modal.ResetTimeout(h.ctx)
190243 }
191244
192- // Auto-exit modal mode after certain actions
193245 if mode != ModeNormal && ShouldAutoExitMode(action) {
194246 h.modal.ExitMode(h.ctx)
195247 }
@@ -314,3 +366,33 @@ func (h *KeyboardHandler) EnterSessionMode() {
314366 func (h *KeyboardHandler) ExitMode() {
315367 h.modal.ExitMode(h.ctx)
316368 }
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) error18 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 ActionHandler42 onAction ActionHandler
29 // Optional bypass check (e.g., omnibox visible)43 // Optional bypass check (e.g., omnibox visible)
30 shouldBypass func() bool44 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.EventControllerKey49 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) bool52+ keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
53+ keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
37 54
38 ctx context.Context55 ctx context.Context
39 mu sync.RWMutex56 mu sync.RWMutex
@@ -75,6 +92,13 @@ func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
75 h.shouldBypass = fn92 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 window139 // 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 = nil149 h.controller = nil
120 h.keyPressedCb = nil150 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.shouldBypass164 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 false169+ 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) & modifierMask173 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 lowercase176+ // 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 handling195+ }
162- return true196+
163- }197+ return h.dispatchAction(action, mode)
164- // In normal mode, let the key pass through to WebView198+}
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 false208 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 true227 return true
170 }228 }
171 229
172- // Dispatch action to handler
173 h.mu.RLock()230 h.mu.RLock()
174 handler := h.onAction231 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+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close