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 := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
85			action, ok := actionMap[actionName]
86			if !ok {
87				continue
88			}
89			for _, keyStr := range workspace.Shortcuts.Actions[actionName].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
200// registerShortcut creates and registers a single shortcut with the controller.
201func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
202	binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
203	if existing, exists := h.registered[binding]; exists {
204		logging.FromContext(h.ctx).Warn().
205			Str("existing_action", string(existing)).
206			Str("new_action", string(action)).
207			Str("shortcut", formatBinding(binding)).
208			Msg("global shortcut conflict, skipping")
209		return false
210	}
211
212	// Create trigger for this key combination.
213	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
214	if trigger == nil {
215		logging.FromContext(h.ctx).Error().
216			Uint("keyval", keyval).
217			Msg("failed to create keyval trigger")
218		return false
219	}
220
221	// GtkShortcut takes ownership of its action. Each shortcut needs its own
222	// named action even though they all dispatch through the same window action.
223	shortcutAction := gtk.NewNamedAction(globalShortcutActionFullName)
224	if shortcutAction == nil {
225		logging.FromContext(h.ctx).Error().
226			Uint("keyval", keyval).
227			Msg("failed to create global shortcut action")
228		return false
229	}
230
231	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
232	if shortcut == nil {
233		logging.FromContext(h.ctx).Error().
234			Uint("keyval", keyval).
235			Msg("failed to create shortcut")
236		return false
237	}
238
239	generation := h.generation
240	shortcutID := encodeGlobalShortcutID(action, binding, generation)
241	shortcut.SetArguments(glib.NewVariantString(shortcutID))
242
243	// Add to controller.
244	h.controller.AddShortcut(shortcut)
245	h.registered[binding] = action
246	h.shortcutRefs[shortcutID] = globalShortcutRegistration{
247		action:     action,
248		binding:    binding,
249		generation: generation,
250	}
251	return true
252}
253
254func (h *GlobalShortcutHandler) activateGlobalShortcut(parameter uintptr) {
255	log := logging.FromContext(h.ctx)
256	registration, ok := h.globalShortcutRegistration(parameter)
257	if !ok {
258		log.Warn().Msg("global shortcut activated without registered argument")
259		return
260	}
261	h.dispatchGlobalShortcut(registration.action, registration.binding, registration.generation)
262}
263
264func (h *GlobalShortcutHandler) globalShortcutRegistration(parameter uintptr) (globalShortcutRegistration, bool) {
265	if parameter == 0 || h.shortcutRefs == nil {
266		return globalShortcutRegistration{}, false
267	}
268	variant := glib.VariantNewFromInternalPtr(parameter)
269	if variant == nil {
270		return globalShortcutRegistration{}, false
271	}
272	shortcutID := variant.GetString(nil)
273	registration, ok := h.shortcutRefs[shortcutID]
274	return registration, ok
275}
276
277func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, bindingForLog KeyBinding, generation uint64) bool {
278	log := logging.FromContext(h.ctx)
279	if h.isStaleGeneration(generation) {
280		log.Trace().
281			Str("action", string(actionToDispatch)).
282			Str("shortcut", formatBinding(bindingForLog)).
283			Uint64("callback_generation", generation).
284			Uint64("current_generation", h.generation).
285			Msg("stale global shortcut callback ignored")
286		return true
287	}
288	if !h.isActiveWindowShortcutHandler() {
289		log.Trace().
290			Str("action", string(actionToDispatch)).
291			Str("shortcut", formatBinding(bindingForLog)).
292			Msg("inactive window global shortcut callback ignored")
293		return false
294	}
295	if h.kbHandler != nil && shouldIgnoreGlobalShortcutInMode(h.kbHandler.Mode(), actionToDispatch) {
296		log.Trace().
297			Str("action", string(actionToDispatch)).
298			Str("shortcut", formatBinding(bindingForLog)).
299			Str("mode", h.kbHandler.Mode().String()).
300			Msg("global shortcut ignored in current modal mode")
301		return true
302	}
303	eventInfo := h.inspectCurrentShortcutEvent()
304	if !shouldDispatchGlobalShortcutEvent(eventInfo) {
305		appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
306			Msg("global shortcut ignored without current key event")
307		return true
308	}
309	if h.suppressHeldShortcut(actionToDispatch, eventInfo) {
310		log.Trace().
311			Str("action", string(actionToDispatch)).
312			Str("shortcut", formatBinding(bindingForLog)).
313			Msg("held global shortcut repeat suppressed")
314		return true
315	}
316	if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
317		log.Trace().
318			Str("action", string(actionToDispatch)).
319			Str("shortcut", formatBinding(bindingForLog)).
320			Msg("global shortcut repeat suppressed")
321		return true
322	}
323	appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
324		Msg("global shortcut triggered")
325
326	// Mode-enter/exit actions go through KeyboardHandler for modal state.
327	if isModeAction(actionToDispatch) {
328		if h.kbHandler != nil {
329			return h.kbHandler.DispatchAction(actionToDispatch)
330		}
331		log.Warn().
332			Str("action", string(actionToDispatch)).
333			Msg("mode action triggered but keyboard handler not set, falling through to default handler")
334	}
335
336	// After the tmux prefix, dispatch through KeyboardHandler so the
337	// one-shot exit follows the same order as keys it handles directly.
338	if h.kbHandler != nil && h.kbHandler.Mode() == ModePrefix {
339		return h.kbHandler.DispatchAction(actionToDispatch)
340	}
341
342	if h.onAction != nil {
343		if err := h.onAction(h.ctx, actionToDispatch); err != nil {
344			log.Error().
345				Err(err).
346				Str("action", string(actionToDispatch)).
347				Msg("global shortcut action failed")
348		}
349	}
350	return true
351}
352
353const (
354	globalShortcutActionName     = "dumber-global-shortcut"
355	globalShortcutActionFullName = "win." + globalShortcutActionName
356)
357
358type globalShortcutRegistration struct {
359	action     Action
360	binding    KeyBinding
361	generation uint64
362}
363
364func encodeGlobalShortcutID(action Action, binding KeyBinding, generation uint64) string {
365	return fmt.Sprintf("%s\x1f%d\x1f%d\x1f%d", action, binding.Keyval, binding.Modifiers, generation)
366}
367
368func (h *GlobalShortcutHandler) estimatedPuregoCallbackBudget() int {
369	if h == nil {
370		return 0
371	}
372	callbacks := 0
373	if h.globalActionCb != nil {
374		callbacks++
375	}
376	if h.keyReleasedCb != nil {
377		callbacks++
378	}
379	return callbacks
380}
381
382// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
383type globalShortcutHoldKey struct {
384	keyval  uint
385	keycode uint
386}
387
388type globalShortcutEventInfo struct {
389	hasCurrentEvent        bool
390	eventType              gdk.EventType
391	eventKeyval            uint
392	eventKeycode           uint
393	eventLayout            uint
394	eventLevel             uint
395	eventState             gdk.ModifierType
396	eventConsumedModifiers gdk.ModifierType
397	eventTime              uint32
398	hasDevice              bool
399	deviceName             string
400	deviceSource           gdk.InputSource
401	controllerState        gdk.ModifierType
402	controllerTime         uint32
403}
404
405func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
406	var info globalShortcutEventInfo
407	if h == nil || h.controller == nil {
408		return info
409	}
410
411	info.controllerState = h.controller.GetCurrentEventState()
412	info.controllerTime = h.controller.GetCurrentEventTime()
413	if device := h.controller.GetCurrentEventDevice(); device != nil {
414		info.hasDevice = true
415		info.deviceName = device.GetName()
416		info.deviceSource = device.GetSource()
417	}
418
419	event := h.controller.GetCurrentEvent()
420	if event == nil {
421		return info
422	}
423	info.hasCurrentEvent = true
424	info.eventType = event.GetEventType()
425	info.eventState = event.GetModifierState()
426	info.eventTime = event.GetTime()
427	if !info.hasDevice {
428		if device := event.GetDevice(); device != nil {
429			info.hasDevice = true
430			info.deviceName = device.GetName()
431			info.deviceSource = device.GetSource()
432		}
433	}
434
435	switch info.eventType {
436	case gdk.KeyPressValue, gdk.KeyReleaseValue:
437		keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
438		info.eventKeyval = keyEvent.GetKeyval()
439		info.eventKeycode = keyEvent.GetKeycode()
440		info.eventLayout = keyEvent.GetLayout()
441		info.eventLevel = keyEvent.GetLevel()
442		info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
443	}
444
445	return info
446}
447
448func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
449	return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
450}
451
452func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
453	if evt == nil {
454		return nil
455	}
456	evt = evt.
457		Str("action", string(action)).
458		Str("shortcut", formatBinding(binding)).
459		Uint("shortcut_keyval", binding.Keyval).
460		Str("shortcut_key", formatKeyval(binding.Keyval)).
461		Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
462		Str("controller_modifiers", formatModifierMask(info.controllerState)).
463		Uint32("controller_event_time", info.controllerTime).
464		Bool("has_current_event", info.hasCurrentEvent)
465	if info.hasCurrentEvent {
466		evt = evt.
467			Str("event_type", formatEventType(info.eventType)).
468			Int("event_type_value", int(info.eventType)).
469			Str("event_modifiers", formatModifierMask(info.eventState)).
470			Uint32("event_time", info.eventTime)
471		if info.eventKeyval != 0 {
472			evt = evt.
473				Uint("event_keyval", info.eventKeyval).
474				Str("event_key", formatKeyval(info.eventKeyval))
475		}
476		if info.eventKeycode != 0 {
477			evt = evt.Uint("event_keycode", info.eventKeycode)
478		}
479		if info.eventLayout != 0 {
480			evt = evt.Uint("event_layout", info.eventLayout)
481		}
482		if info.eventLevel != 0 {
483			evt = evt.Uint("event_level", info.eventLevel)
484		}
485		if info.eventConsumedModifiers != 0 {
486			evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
487		}
488	}
489	if info.hasDevice {
490		evt = evt.
491			Str("event_device", info.deviceName).
492			Int("event_device_source", int(info.deviceSource))
493	}
494	return evt
495}
496
497func shouldRegisterGTKGlobalShortcut(action Action) bool {
498	return action != ActionEnterVimMode
499}
500
501func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool {
502	return mode == ModeVim && action != ActionEnterVimMode
503}
504
505func globalShortcutActionMap() map[string]Action {
506	return map[string]Action{
507		"toggle_floating_pane":         ActionToggleFloatingPane,
508		"toggle-floating-pane":         ActionToggleFloatingPane,
509		"consume_or_expel_left":        ActionConsumeOrExpelLeft,
510		"consume-or-expel-left":        ActionConsumeOrExpelLeft,
511		"consume_or_expel_right":       ActionConsumeOrExpelRight,
512		"consume-or-expel-right":       ActionConsumeOrExpelRight,
513		"consume_or_expel_up":          ActionConsumeOrExpelUp,
514		"consume-or-expel-up":          ActionConsumeOrExpelUp,
515		"consume_or_expel_down":        ActionConsumeOrExpelDown,
516		"consume-or-expel-down":        ActionConsumeOrExpelDown,
517		"toggle_history_systemview":    ActionToggleHistorySystemView,
518		"toggle-history-systemview":    ActionToggleHistorySystemView,
519		"toggle_favorites_sidebar":     ActionToggleFavoritesSidebar,
520		"toggle-favorites-sidebar":     ActionToggleFavoritesSidebar,
521		"toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522		"toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523		"toggle_config_systemview":     ActionToggleConfigSystemView,
524		"toggle-config-systemview":     ActionToggleConfigSystemView,
525	}
526}
527
528// ReloadShortcuts rebuilds global shortcuts from a new config.
529// It replaces the GTK shortcut controller to ensure stale shortcuts
530// are removed.
531func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
532	log := logging.FromContext(ctx)
533	h.ctx = ctx
534	h.generation++
535
536	if h.window == nil || h.controller == nil {
537		return
538	}
539
540	// Remove old controller from window
541	h.window.RemoveController(&h.controller.EventController)
542
543	// Create fresh controller
544	h.controller = gtk.NewShortcutController()
545	if h.controller == nil {
546		log.Error().Msg("failed to create shortcut controller during reload")
547		return
548	}
549	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
550	h.registered = make(map[KeyBinding]Action)
551	h.shortcutRefs = make(map[string]globalShortcutRegistration)
552	h.lastDispatchAt = make(map[Action]time.Time)
553	h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
554
555	h.registerDefaultGlobalShortcuts(log)
556
557	// Re-register config-driven shortcuts
558	if workspace != nil {
559		actionMap := globalShortcutActionMap()
560
561		for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
562			action, ok := actionMap[actionName]
563			if !ok {
564				continue
565			}
566			for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
567				binding, ok := ParseKeyString(keyStr)
568				if !ok {
569					continue
570				}
571				h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
572			}
573		}
574
575		occupied := make(map[KeyBinding]Action, len(h.registered))
576		maps.Copy(occupied, h.registered)
577		for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
578			if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
579				if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
580					log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
581				}
582			}
583		}
584
585		shortcuts := NewShortcutSet(ctx, workspace, session)
586		for binding, action := range shortcuts.Global {
587			if !shouldRegisterGTKGlobalShortcut(action) {
588				continue
589			}
590			if _, exists := h.registered[binding]; exists {
591				continue
592			}
593			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
594		}
595	}
596
597	// Attach new controller to window
598	h.window.AddController(&h.controller.EventController)
599
600	log.Debug().
601		Int("shortcuts", len(h.registered)).
602		Msg("global shortcuts reloaded")
603}
604
605// Detach removes the global shortcut handler from a live GTK window.
606// Use DetachForDestroy when the window is already closing or being destroyed.
607func (h *GlobalShortcutHandler) Detach() {
608	h.detach(true)
609}
610
611// DetachForDestroy releases retained GTK callbacks during window teardown
612// without mutating the window's controller or action lists while GTK is
613// destroying them.
614func (h *GlobalShortcutHandler) DetachForDestroy() {
615	h.detach(false)
616}
617
618func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
619	h.generation++
620	if removeFromWindow && h.window != nil && h.controller != nil {
621		h.window.RemoveController(&h.controller.EventController)
622	}
623	if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
624		h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
625	}
626	if removeFromWindow && h.window != nil && h.releaseController != nil {
627		h.window.RemoveController(&h.releaseController.EventController)
628	}
629	if h.globalAction != nil && h.globalActionHandlerID != 0 {
630		h.globalAction.DisconnectSignal(h.globalActionHandlerID)
631	}
632	if removeFromWindow && h.window != nil && h.globalAction != nil {
633		h.window.RemoveAction(globalShortcutActionName)
634	}
635	h.controller = nil
636	h.releaseController = nil
637	h.globalAction = nil
638	h.globalActionCb = nil
639	h.globalActionHandlerID = 0
640	h.keyReleasedCb = nil
641	h.keyReleasedHandlerID = 0
642	h.registered = nil
643	h.shortcutRefs = nil
644	h.lastDispatchAt = nil
645	h.heldShortcuts = nil
646}
647
648const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
649
650func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
651	return h == nil || generation != h.generation
652}
653
654func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
655	return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
656}
657
658func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
659	if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
660		return false
661	}
662	if h.heldShortcuts == nil {
663		// Defensive: if hold tracking is unavailable during detach/reload,
664		// suppress rather than risk dispatching a stale global callback.
665		return true
666	}
667	key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
668	if key.keyval == 0 && key.keycode == 0 {
669		return false
670	}
671	if _, ok := h.heldShortcuts[key]; ok {
672		return true
673	}
674	h.heldShortcuts[key] = struct{}{}
675	return false
676}
677
678func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
679	if h == nil || len(h.heldShortcuts) == 0 {
680		return
681	}
682	keyval = normalizeKeyval(keyval)
683	for key := range h.heldShortcuts {
684		if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
685			delete(h.heldShortcuts, key)
686		}
687	}
688}
689
690func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
691	if h == nil {
692		return false
693	}
694	if !isRepeatedGlobalShortcutSuppressed(action) {
695		if _, ok := ParseFloatingProfileTarget(action); !ok {
696			return false
697		}
698	}
699	if h.lastDispatchAt == nil {
700		// Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
701		// rather than letting a stale GTK callback repeat destructive actions.
702		return true
703	}
704	if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
705		return true
706	}
707	h.lastDispatchAt[action] = now
708	return false
709}
710
711func isHeldGlobalShortcutSuppressed(action Action) bool {
712	if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
713		return true
714	}
715	_, ok := ParseFloatingProfileTarget(action)
716	return ok
717}
718
719func isRepeatedGlobalShortcutSuppressed(action Action) bool {
720	switch action {
721	case ActionGoBack,
722		ActionGoForward,
723		ActionZoomReset,
724		ActionReload,
725		ActionHardReload,
726		ActionPrintPage,
727		ActionOpenOmnibox,
728		ActionOpenFind,
729		ActionFindNext,
730		ActionFindPrev,
731		ActionOpenDevTools,
732		ActionToggleFullscreen,
733		ActionToggleFloatingPane,
734		ActionToggleHistorySystemView,
735		ActionToggleFavoritesSidebar,
736		ActionToggleCurrentPageFavorite,
737		ActionToggleConfigSystemView,
738		ActionCopyURL,
739		ActionConsumeOrExpelLeft,
740		ActionConsumeOrExpelRight,
741		ActionConsumeOrExpelUp,
742		ActionConsumeOrExpelDown,
743		ActionClosePane,
744		ActionCloseTab,
745		ActionQuit,
746		ActionOpenSessionManager,
747		ActionSwitchTabIndex1,
748		ActionSwitchTabIndex2,
749		ActionSwitchTabIndex3,
750		ActionSwitchTabIndex4,
751		ActionSwitchTabIndex5,
752		ActionSwitchTabIndex6,
753		ActionSwitchTabIndex7,
754		ActionSwitchTabIndex8,
755		ActionSwitchTabIndex9,
756		ActionSwitchTabIndex10,
757		ActionSwitchLastTab:
758		return true
759	default:
760		return false
761	}
762}
763
764func formatBinding(binding KeyBinding) string {
765	parts := make([]string, 0, 3)
766	if binding.Modifiers&ModCtrl != 0 {
767		parts = append(parts, "ctrl")
768	}
769	if binding.Modifiers&ModShift != 0 {
770		parts = append(parts, "shift")
771	}
772	if binding.Modifiers&ModAlt != 0 {
773		parts = append(parts, "alt")
774	}
775	parts = append(parts, formatKeyval(binding.Keyval))
776	return strings.Join(parts, "+")
777}
778
779func formatKeyval(keyval uint) string {
780	if keyval == 0 {
781		return ""
782	}
783	keyName := gdk.KeyvalName(keyval)
784	if keyName == "" {
785		return fmt.Sprintf("0x%x", keyval)
786	}
787	return strings.ToLower(keyName)
788}
789
790func formatModifierMask(modifiers gdk.ModifierType) string {
791	if modifiers == 0 {
792		return "none"
793	}
794	parts := make([]string, 0, 4)
795	if modifiers&gdk.ControlMaskValue != 0 {
796		parts = append(parts, "ctrl")
797	}
798	if modifiers&gdk.ShiftMaskValue != 0 {
799		parts = append(parts, "shift")
800	}
801	if modifiers&gdk.AltMaskValue != 0 {
802		parts = append(parts, "alt")
803	}
804	remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
805	if remaining != 0 {
806		parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
807	}
808	return strings.Join(parts, "+")
809}
810
811func formatEventType(eventType gdk.EventType) string {
812	switch eventType {
813	case gdk.KeyPressValue:
814		return "key-press"
815	case gdk.KeyReleaseValue:
816		return "key-release"
817	case gdk.ButtonPressValue:
818		return "button-press"
819	case gdk.ButtonReleaseValue:
820		return "button-release"
821	case gdk.MotionNotifyValue:
822		return "motion"
823	case gdk.ScrollValue:
824		return "scroll"
825	case gdk.TouchBeginValue:
826		return "touch-begin"
827	case gdk.TouchUpdateValue:
828		return "touch-update"
829	case gdk.TouchEndValue:
830		return "touch-end"
831	case gdk.FocusChangeValue:
832		return "focus-change"
833	case gdk.DeleteValue:
834		return "delete"
835	default:
836		return fmt.Sprintf("event-%d", int(eventType))
837	}
838}
839
840func isModeAction(action Action) bool {
841	switch action {
842	case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode,
843		ActionEnterPrefixMode, 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