v0.32.0 0617278

global_shortcuts.go RawHistoryFolder

1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5	"context"
6	"fmt"
7	"maps"
8	"strings"
9	"time"
10
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/puregotk/v4/gdk"
14	"github.com/bnema/puregotk/v4/gio"
15	"github.com/bnema/puregotk/v4/glib"
16	"github.com/bnema/puregotk/v4/gtk"
17	"github.com/rs/zerolog"
18)
19
20// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
21// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
22// to intercept shortcuts before they reach the WebView.
23type GlobalShortcutHandler struct {
24	controller            *gtk.ShortcutController
25	releaseController     *gtk.EventControllerKey
26	window                *gtk.ApplicationWindow
27	kbHandler             *KeyboardHandler
28	onAction              ActionHandler
29	ctx                   context.Context
30	registered            map[KeyBinding]Action
31	shortcutRefs          map[string]globalShortcutRegistration
32	lastDispatchAt        map[Action]time.Time
33	heldShortcuts         map[globalShortcutHoldKey]struct{}
34	globalAction          *gio.SimpleAction
35	globalActionCb        func(gio.SimpleAction, uintptr)
36	globalActionHandlerID uint
37	keyReleasedCb         func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
38	keyReleasedHandlerID  uint
39	// generation is mutated only from the GTK main thread alongside controller replacement.
40	generation uint64
41}
42
43// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
44// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
45func NewGlobalShortcutHandler(
46	ctx context.Context,
47	window *gtk.ApplicationWindow,
48	workspace *entity.WorkspaceConfig,
49	session *entity.SessionConfig,
50	kbHandler *KeyboardHandler,
51	onAction ActionHandler,
52) *GlobalShortcutHandler {
53	log := logging.FromContext(ctx)
54	log.Debug().Msg("creating global shortcut handler")
55
56	h := &GlobalShortcutHandler{
57		controller:        gtk.NewShortcutController(),
58		releaseController: gtk.NewEventControllerKey(),
59		window:            window,
60		kbHandler:         kbHandler,
61		onAction:          onAction,
62		ctx:               ctx,
63		registered:        make(map[KeyBinding]Action),
64		shortcutRefs:      make(map[string]globalShortcutRegistration),
65		lastDispatchAt:    make(map[Action]time.Time),
66		heldShortcuts:     make(map[globalShortcutHoldKey]struct{}),
67	}
68
69	if h.controller == nil {
70		log.Error().Msg("failed to create shortcut controller")
71		return nil
72	}
73	if h.releaseController == nil {
74		log.Error().Msg("failed to create global shortcut release controller")
75		return nil
76	}
77
78	h.configureGlobalControllers()
79	h.registerDefaultGlobalShortcuts(log)
80
81	if workspace != nil {
82		actionMap := globalShortcutActionMap()
83
84		for actionName, actionBinding := range workspace.Shortcuts.Actions {
85			action, ok := actionMap[actionName]
86			if !ok {
87				continue
88			}
89			for _, keyStr := range actionBinding.Keys {
90				binding, ok := ParseKeyString(keyStr)
91				if !ok {
92					log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
93					continue
94				}
95				if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
96					log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
97				}
98			}
99		}
100
101		occupied := make(map[KeyBinding]Action, len(h.registered))
102		maps.Copy(occupied, h.registered)
103		for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
104			if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
105				continue
106			}
107			if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
108				log.Trace().
109					Str("shortcut", formatBinding(shortcut.Binding)).
110					Str("url", url).
111					Msg("registered floating profile global shortcut")
112			}
113		}
114
115		// Register app-reserved shortcuts (Ctrl+L, Ctrl+F, other mode activations, etc.)
116		// so they work even when WebView has focus.
117		// Vim mode activation is intentionally excluded here: it must flow through
118		// the normal KeyboardHandler path so editable page contexts can pass Ctrl+Y
119		// through instead of having a global shortcut controller consume it first.
120		shortcuts := NewShortcutSet(ctx, workspace, session)
121		for binding, action := range shortcuts.Global {
122			if !shouldRegisterGTKGlobalShortcut(action) {
123				continue
124			}
125			if _, exists := h.registered[binding]; exists {
126				continue
127			}
128			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
129		}
130	}
131
132	// Attach to window
133	window.AddController(&h.releaseController.EventController)
134	window.AddController(&h.controller.EventController)
135
136	log.Debug().
137		Int("shortcuts", len(h.registered)).
138		Msg("global shortcut handler created and attached")
139
140	return h
141}
142
143func (h *GlobalShortcutHandler) configureGlobalControllers() {
144	// Set global scope - this is the key to making shortcuts work
145	// even when WebView has focus.
146	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
147	h.globalAction = gio.NewSimpleAction(globalShortcutActionName, glib.NewVariantType("s"))
148	if h.globalAction != nil {
149		h.globalActionCb = func(_ gio.SimpleAction, parameter uintptr) {
150			h.activateGlobalShortcut(parameter)
151		}
152		h.globalActionHandlerID = h.globalAction.ConnectActivate(&h.globalActionCb)
153		h.window.AddAction(h.globalAction)
154	}
155	h.releaseController.SetPropagationPhase(gtk.PhaseCaptureValue)
156	h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, _ gdk.ModifierType) {
157		h.releaseHeldGlobalShortcuts(keyval, keycode)
158	}
159	h.keyReleasedHandlerID = h.releaseController.ConnectKeyReleased(&h.keyReleasedCb)
160}
161
162func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logger) {
163	// Register Alt+1 through Alt+9 for tab switching.
164	tabActions := []Action{
165		ActionSwitchTabIndex1,
166		ActionSwitchTabIndex2,
167		ActionSwitchTabIndex3,
168		ActionSwitchTabIndex4,
169		ActionSwitchTabIndex5,
170		ActionSwitchTabIndex6,
171		ActionSwitchTabIndex7,
172		ActionSwitchTabIndex8,
173		ActionSwitchTabIndex9,
174	}
175
176	for i, action := range tabActions {
177		keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
178		h.registerShortcut(keyval, gdk.AltMaskValue, action)
179		log.Trace().
180			Uint("keyval", keyval).
181			Str("action", string(action)).
182			Msg("registered global shortcut")
183	}
184
185	// Alt+0 for tab 10.
186	h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
187	log.Trace().
188		Uint("keyval", uint(gdk.KEY_0)).
189		Str("action", string(ActionSwitchTabIndex10)).
190		Msg("registered global shortcut")
191
192	// Alt+Tab for switching to last active tab.
193	h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
194	log.Trace().
195		Uint("keyval", uint(gdk.KEY_Tab)).
196		Str("action", string(ActionSwitchLastTab)).
197		Msg("registered global shortcut")
198
199	// Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus).
200	h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
201	log.Trace().
202		Uint("keyval", uint(gdk.KEY_s)).
203		Str("action", string(ActionOpenSessionManager)).
204		Msg("registered global shortcut")
205}
206
207// registerShortcut creates and registers a single shortcut with the controller.
208func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
209	binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
210	if existing, exists := h.registered[binding]; exists {
211		logging.FromContext(h.ctx).Warn().
212			Str("existing_action", string(existing)).
213			Str("new_action", string(action)).
214			Str("shortcut", formatBinding(binding)).
215			Msg("global shortcut conflict, skipping")
216		return false
217	}
218
219	// Create trigger for this key combination.
220	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
221	if trigger == nil {
222		logging.FromContext(h.ctx).Error().
223			Uint("keyval", keyval).
224			Msg("failed to create keyval trigger")
225		return false
226	}
227
228	// GtkShortcut takes ownership of its action. Each shortcut needs its own
229	// named action even though they all dispatch through the same window action.
230	shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
231	if shortcutAction == nil {
232		logging.FromContext(h.ctx).Error().
233			Uint("keyval", keyval).
234			Msg("failed to create global shortcut action")
235		return false
236	}
237
238	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
239	if shortcut == nil {
240		logging.FromContext(h.ctx).Error().
241			Uint("keyval", keyval).
242			Msg("failed to create shortcut")
243		return false
244	}
245
246	generation := h.generation
247	shortcutID := encodeGlobalShortcutID(action, binding, generation)
248	shortcut.SetArguments(glib.NewVariantString(shortcutID))
249
250	// Add to controller.
251	h.controller.AddShortcut(shortcut)
252	h.registered[binding] = action
253	h.shortcutRefs[shortcutID] = globalShortcutRegistration{
254		action:     action,
255		binding:    binding,
256		generation: generation,
257	}
258	return true
259}
260
261func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
262	log := logging.FromContext(h.ctx)
263	registration, ok := h.globalShortcutRegistration(parameter)
264	if !ok {
265		log.Warn().Msg("global shortcut activated without registered argument")
266		return
267	}
268	h.dispatchGlobalShortcut(registration.action, registration.binding, registration.generation)
269}
270
271func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
272	if parameter == 0 || h.shortcutRefs == nil {
273		return globalShortcutRegistration{}, false
274	}
275	variant := glib.VariantNewFromInternalPtr(parameter)
276	if variant == nil {
277		return globalShortcutRegistration{}, false
278	}
279	shortcutID := variant.GetString(nil)
280	registration, ok := h.shortcutRefs[shortcutID]
281	return registration, ok
282}
283
284func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
285	log := logging.FromContext(h.ctx)
286	if h.isStaleGeneration(generation) {
287		log.Trace().
288			Str("action", string(actionToDispatch)).
289			Str("shortcut", formatBinding(bindingForLog)).
290			Uint64("callback_generation", generation).
291			Uint64("current_generation", h.generation).
292			Msg("stale global shortcut callback ignored")
293		return true
294	}
295	if !h.isActiveWindowShortcutHandler() {
296		log.Trace().
297			Str("action", string(actionToDispatch)).
298			Str("shortcut", formatBinding(bindingForLog)).
299			Msg("inactive window global shortcut callback ignored")
300		return false
301	}
302	if h.kbHandler != nil && shouldIgnoreGlobalShortcutInMode(h.kbHandler.Mode(), actionToDispatch) {
303		log.Trace().
304			Str("action", string(actionToDispatch)).
305			Str("shortcut", formatBinding(bindingForLog)).
306			Str("mode", h.kbHandler.Mode().String()).
307			Msg("global shortcut ignored in current modal mode")
308		return true
309	}
310	eventInfo := h.inspectCurrentShortcutEvent()
311	if !shouldDispatchGlobalShortcutEvent(eventInfo) {
312		appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
313			Msg("global shortcut ignored without current key event")
314		return true
315	}
316	if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
317		log.Trace().
318			Str("action", string(actionToDispatch)).
319			Str("shortcut", formatBinding(bindingForLog)).
320			Msg("held global shortcut repeat suppressed")
321		return true
322	}
323	if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
324		log.Trace().
325			Str("action", string(actionToDispatch)).
326			Str("shortcut", formatBinding(bindingForLog)).
327			Msg("global shortcut repeat suppressed")
328		return true
329	}
330	appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
331		Msg("global shortcut triggered")
332
333	// Mode-enter/exit actions go through KeyboardHandler for modal state.
334	if isModeAction(actionToDispatch) {
335		if h.kbHandler != nil {
336			return h.kbHandler.DispatchAction(actionToDispatch)
337		}
338		log.Warn().
339			Str("action", string(actionToDispatch)).
340			Msg("mode action triggered but keyboard handler not set, falling through to default handler")
341	}
342
343	if h.onAction != nil {
344		if err := h.onAction(h.ctx, actionToDispatch); err != nil {
345			log.Error().
346				Err(err).
347				Str("action", string(actionToDispatch)).
348				Msg("global shortcut action failed")
349		}
350	}
351	return true
352}
353
354const (
355	globalShortcutActionName     = "dumber-global-shortcut"
356	globalShortcutActionFullName = "win." + globalShortcutActionName
357)
358
359type globalShortcutRegistration struct {
360	action     Action
361	binding    KeyBinding
362	generation uint64
363}
364
365func encodeGlobalShortcutID(action Action, binding KeyBinding, generation uint64) string {
366	return fmt.Sprintf("%s\x1f%d\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers, generation)
367}
368
369func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
370	if h == nil {
371		return 0
372	}
373	callbacks := 0
374	if h.globalActionCb != nil {
375		callbacks++
376	}
377	if h.keyReleasedCb != nil {
378		callbacks++
379	}
380	return callbacks
381}
382
383// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
384type globalShortcutHoldKey struct {
385	keyval  uint
386	keycode uint
387}
388
389type globalShortcutEventInfo struct {
390	hasCurrentEvent        bool
391	eventType              gdk.EventType
392	eventKeyval            uint
393	eventKeycode           uint
394	eventLayout            uint
395	eventLevel             uint
396	eventState             gdk.ModifierType
397	eventConsumedModifiers gdk.ModifierType
398	eventTime              uint32
399	hasDevice              bool
400	deviceName             string
401	deviceSource           gdk.InputSource
402	controllerState        gdk.ModifierType
403	controllerTime         uint32
404}
405
406func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
407	var info globalShortcutEventInfo
408	if h == nil || h.controller == nil {
409		return info
410	}
411
412	info.controllerState = h.controller.GetCurrentEventState()
413	info.controllerTime = h.controller.GetCurrentEventTime()
414	if device := h.controller.GetCurrentEventDevice(); device != nil {
415		info.hasDevice = true
416		info.deviceName = device.GetName()
417		info.deviceSource = device.GetSource()
418	}
419
420	event := h.controller.GetCurrentEvent()
421	if event == nil {
422		return info
423	}
424	info.hasCurrentEvent = true
425	info.eventType = event.GetEventType()
426	info.eventState = event.GetModifierState()
427	info.eventTime = event.GetTime()
428	if !info.hasDevice {
429		if device := event.GetDevice(); device != nil {
430			info.hasDevice = true
431			info.deviceName = device.GetName()
432			info.deviceSource = device.GetSource()
433		}
434	}
435
436	switch info.eventType {
437	case gdk.KeyPressValue, gdk.KeyReleaseValue:
438		keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
439		info.eventKeyval = keyEvent.GetKeyval()
440		info.eventKeycode = keyEvent.GetKeycode()
441		info.eventLayout = keyEvent.GetLayout()
442		info.eventLevel = keyEvent.GetLevel()
443		info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
444	}
445
446	return info
447}
448
449func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
450	return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
451}
452
453func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
454	if evt == nil {
455		return nil
456	}
457	evt = evt.
458		Str("action", string(action)).
459		Str("shortcut", formatBinding(binding)).
460		Uint("shortcut_keyval", binding.Keyval).
461		Str("shortcut_key", formatKeyval(binding.Keyval)).
462		Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
463		Str("controller_modifiers", formatModifierMask(info.controllerState)).
464		Uint32("controller_event_time", info.controllerTime).
465		Bool("has_current_event", info.hasCurrentEvent)
466	if info.hasCurrentEvent {
467		evt = evt.
468			Str("event_type", formatEventType(info.eventType)).
469			Int("event_type_value", int(info.eventType)).
470			Str("event_modifiers", formatModifierMask(info.eventState)).
471			Uint32("event_time", info.eventTime)
472		if info.eventKeyval != 0 {
473			evt = evt.
474				Uint("event_keyval", info.eventKeyval).
475				Str("event_key", formatKeyval(info.eventKeyval))
476		}
477		if info.eventKeycode != 0 {
478			evt = evt.Uint("event_keycode", info.eventKeycode)
479		}
480		if info.eventLayout != 0 {
481			evt = evt.Uint("event_layout", info.eventLayout)
482		}
483		if info.eventLevel != 0 {
484			evt = evt.Uint("event_level", info.eventLevel)
485		}
486		if info.eventConsumedModifiers != 0 {
487			evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
488		}
489	}
490	if info.hasDevice {
491		evt = evt.
492			Str("event_device", info.deviceName).
493			Int("event_device_source", int(info.deviceSource))
494	}
495	return evt
496}
497
498func shouldRegisterGTKGlobalShortcut(action Action) bool {
499	return action != ActionEnterVimMode
500}
501
502func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
503	return mode == ModeVim && action != ActionEnterVimMode
504}
505
506func globalShortcutActionMap() map[string]Action {
507	return map[string]Action{
508		"toggle_floating_pane":         ActionToggleFloatingPane,
509		"toggle-floating-pane":         ActionToggleFloatingPane,
510		"consume_or_expel_left":        ActionConsumeOrExpelLeft,
511		"consume-or-expel-left":        ActionConsumeOrExpelLeft,
512		"consume_or_expel_right":       ActionConsumeOrExpelRight,
513		"consume-or-expel-right":       ActionConsumeOrExpelRight,
514		"consume_or_expel_up":          ActionConsumeOrExpelUp,
515		"consume-or-expel-up":          ActionConsumeOrExpelUp,
516		"consume_or_expel_down":        ActionConsumeOrExpelDown,
517		"consume-or-expel-down":        ActionConsumeOrExpelDown,
518		"toggle_history_systemview":    ActionToggleHistorySystemView,
519		"toggle-history-systemview":    ActionToggleHistorySystemView,
520		"toggle_favorites_sidebar":     ActionToggleFavoritesSidebar,
521		"toggle-favorites-sidebar":     ActionToggleFavoritesSidebar,
522		"toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
523		"toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
524		"toggle_config_systemview":     ActionToggleConfigSystemView,
525		"toggle-config-systemview":     ActionToggleConfigSystemView,
526	}
527}
528
529// ReloadShortcuts rebuilds global shortcuts from a new config.
530// It replaces the GTK shortcut controller to ensure stale shortcuts
531// are removed.
532func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
533	log := logging.FromContext(ctx)
534	h.ctx = ctx
535	h.generation++
536
537	if h.window == nil || h.controller == nil {
538		return
539	}
540
541	// Remove old controller from window
542	h.window.RemoveController(&h.controller.EventController)
543
544	// Create fresh controller
545	h.controller = gtk.NewShortcutController()
546	if h.controller == nil {
547		log.Error().Msg("failed to create shortcut controller during reload")
548		return
549	}
550	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
551	h.registered = make(map[KeyBinding]Action)
552	h.shortcutRefs = make(map[string]globalShortcutRegistration)
553	h.lastDispatchAt = make(map[Action]time.Time)
554	h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
555
556	h.registerDefaultGlobalShortcuts(log)
557
558	// Re-register config-driven shortcuts
559	if workspace != nil {
560		actionMap := globalShortcutActionMap()
561
562		for actionName, actionBinding := range workspace.Shortcuts.Actions {
563			action, ok := actionMap[actionName]
564			if !ok {
565				continue
566			}
567			for _, keyStr := range actionBinding.Keys {
568				binding, ok := ParseKeyString(keyStr)
569				if !ok {
570					continue
571				}
572				h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
573			}
574		}
575
576		occupied := make(map[KeyBinding]Action, len(h.registered))
577		maps.Copy(occupied, h.registered)
578		for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
579			if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
580				if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
581					log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
582				}
583			}
584		}
585
586		shortcuts := NewShortcutSet(ctx, workspace, session)
587		for binding, action := range shortcuts.Global {
588			if !shouldRegisterGTKGlobalShortcut(action) {
589				continue
590			}
591			if _, exists := h.registered[binding]; exists {
592				continue
593			}
594			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
595		}
596	}
597
598	// Attach new controller to window
599	h.window.AddController(&h.controller.EventController)
600
601	log.Debug().
602		Int("shortcuts", len(h.registered)).
603		Msg("global shortcuts reloaded")
604}
605
606// Detach removes the global shortcut handler from a live GTK window.
607// Use DetachForDestroy when the window is already closing or being destroyed.
608func (h *GlobalShortcutHandler) Detach() {
609	h.detach(true)
610}
611
612// DetachForDestroy releases retained GTK callbacks during window teardown
613// without mutating the window's controller or action lists while GTK is
614// destroying them.
615func (h *GlobalShortcutHandler) DetachForDestroy() {
616	h.detach(false)
617}
618
619func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
620	h.generation++
621	if removeFromWindow && h.window != nil && h.controller != nil {
622		h.window.RemoveController(&h.controller.EventController)
623	}
624	if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
625		h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
626	}
627	if removeFromWindow && h.window != nil && h.releaseController != nil {
628		h.window.RemoveController(&h.releaseController.EventController)
629	}
630	if h.globalAction != nil && h.globalActionHandlerID != 0 {
631		h.globalAction.DisconnectSignal(h.globalActionHandlerID)
632	}
633	if removeFromWindow && h.window != nil && h.globalAction != nil {
634		h.window.RemoveAction(globalShortcutActionName)
635	}
636	h.controller = nil
637	h.releaseController = nil
638	h.globalAction = nil
639	h.globalActionCb = nil
640	h.globalActionHandlerID = 0
641	h.keyReleasedCb = nil
642	h.keyReleasedHandlerID = 0
643	h.registered = nil
644	h.shortcutRefs = nil
645	h.lastDispatchAt = nil
646	h.heldShortcuts = nil
647}
648
649const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
650
651func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
652	return h == nil || generation != h.generation
653}
654
655func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
656	return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
657}
658
659func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
660	if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
661		return false
662	}
663	if h.heldShortcuts == nil {
664		// Defensive: if hold tracking is unavailable during detach/reload,
665		// suppress rather than risk dispatching a stale global callback.
666		return true
667	}
668	key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
669	if key.keyval == 0 && key.keycode == 0 {
670		return false
671	}
672	if _, ok := h.heldShortcuts[key]; ok {
673		return true
674	}
675	h.heldShortcuts[key] = struct{}{}
676	return false
677}
678
679func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
680	if h == nil || len(h.heldShortcuts) == 0 {
681		return
682	}
683	keyval = normalizeKeyval(keyval)
684	for key := range h.heldShortcuts {
685		if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
686			delete(h.heldShortcuts, key)
687		}
688	}
689}
690
691func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
692	if h == nil {
693		return false
694	}
695	if !isRepeatedGlobalShortcutSuppressed(action) {
696		if _, ok := ParseFloatingProfileTarget(action); !ok {
697			return false
698		}
699	}
700	if h.lastDispatchAt == nil {
701		// Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
702		// rather than letting a stale GTK callback repeat destructive actions.
703		return true
704	}
705	if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
706		return true
707	}
708	h.lastDispatchAt[action] = now
709	return false
710}
711
712func isHeldGlobalShortcutSuppressed(action Action) bool {
713	if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
714		return true
715	}
716	_, ok := ParseFloatingProfileTarget(action)
717	return ok
718}
719
720func isRepeatedGlobalShortcutSuppressed(action Action) bool {
721	switch action {
722	case ActionGoBack,
723		ActionGoForward,
724		ActionZoomReset,
725		ActionReload,
726		ActionHardReload,
727		ActionPrintPage,
728		ActionOpenOmnibox,
729		ActionOpenFind,
730		ActionFindNext,
731		ActionFindPrev,
732		ActionOpenDevTools,
733		ActionToggleFullscreen,
734		ActionToggleFloatingPane,
735		ActionToggleHistorySystemView,
736		ActionToggleFavoritesSidebar,
737		ActionToggleCurrentPageFavorite,
738		ActionToggleConfigSystemView,
739		ActionCopyURL,
740		ActionConsumeOrExpelLeft,
741		ActionConsumeOrExpelRight,
742		ActionConsumeOrExpelUp,
743		ActionConsumeOrExpelDown,
744		ActionClosePane,
745		ActionCloseTab,
746		ActionQuit,
747		ActionOpenSessionManager,
748		ActionSwitchTabIndex1,
749		ActionSwitchTabIndex2,
750		ActionSwitchTabIndex3,
751		ActionSwitchTabIndex4,
752		ActionSwitchTabIndex5,
753		ActionSwitchTabIndex6,
754		ActionSwitchTabIndex7,
755		ActionSwitchTabIndex8,
756		ActionSwitchTabIndex9,
757		ActionSwitchTabIndex10,
758		ActionSwitchLastTab:
759		return true
760	default:
761		return false
762	}
763}
764
765func formatBinding(binding KeyBinding) string {
766	parts := make([]string, 0, 3)
767	if binding.Modifiers&ModCtrl != 0 {
768		parts = append(parts, "ctrl")
769	}
770	if binding.Modifiers&ModShift != 0 {
771		parts = append(parts, "shift")
772	}
773	if binding.Modifiers&ModAlt != 0 {
774		parts = append(parts, "alt")
775	}
776	parts = append(parts, formatKeyval(binding.Keyval))
777	return strings.Join(parts, "+")
778}
779
780func formatKeyval(keyval uint) string {
781	if keyval == 0 {
782		return ""
783	}
784	keyName := gdk.KeyvalName(keyval)
785	if keyName == "" {
786		return fmt.Sprintf("0x%x", keyval)
787	}
788	return strings.ToLower(keyName)
789}
790
791func formatModifierMask(modifiers gdk.ModifierType) string {
792	if modifiers == 0 {
793		return "none"
794	}
795	parts := make([]string, 0, 4)
796	if modifiers&gdk.ControlMaskValue != 0 {
797		parts = append(parts, "ctrl")
798	}
799	if modifiers&gdk.ShiftMaskValue != 0 {
800		parts = append(parts, "shift")
801	}
802	if modifiers&gdk.AltMaskValue != 0 {
803		parts = append(parts, "alt")
804	}
805	remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
806	if remaining != 0 {
807		parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
808	}
809	return strings.Join(parts, "+")
810}
811
812func formatEventType(eventType gdk.EventType) string {
813	switch eventType {
814	case gdk.KeyPressValue:
815		return "key-press"
816	case gdk.KeyReleaseValue:
817		return "key-release"
818	case gdk.ButtonPressValue:
819		return "button-press"
820	case gdk.ButtonReleaseValue:
821		return "button-release"
822	case gdk.MotionNotifyValue:
823		return "motion"
824	case gdk.ScrollValue:
825		return "scroll"
826	case gdk.TouchBeginValue:
827		return "touch-begin"
828	case gdk.TouchUpdateValue:
829		return "touch-update"
830	case gdk.TouchEndValue:
831		return "touch-end"
832	case gdk.FocusChangeValue:
833		return "focus-change"
834	case gdk.DeleteValue:
835		return "delete"
836	default:
837		return fmt.Sprintf("event-%d", int(eventType))
838	}
839}
840
841func isModeAction(action Action) bool {
842	switch action {
843	case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode:
844		return true
845	default:
846		return false
847	}
848}

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