main 5041353

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		"consume-or-expel-left":        ActionConsumeOrExpelLeft,
509		"consume-or-expel-right":       ActionConsumeOrExpelRight,
510		"consume-or-expel-up":          ActionConsumeOrExpelUp,
511		"consume-or-expel-down":        ActionConsumeOrExpelDown,
512		"toggle-history-systemview":    ActionToggleHistorySystemView,
513		"toggle-favorites-sidebar":     ActionToggleFavoritesSidebar,
514		"toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
515		"toggle-config-systemview":     ActionToggleConfigSystemView,
516	}
517}
518
519// ReloadShortcuts rebuilds global shortcuts from a new config.
520// It replaces the GTK shortcut controller to ensure stale shortcuts
521// are removed.
522func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
523	log := logging.FromContext(ctx)
524	h.ctx = ctx
525	h.generation++
526
527	if h.window == nil || h.controller == nil {
528		return
529	}
530
531	// Remove old controller from window
532	h.window.RemoveController(&h.controller.EventController)
533
534	// Create fresh controller
535	h.controller = gtk.NewShortcutController()
536	if h.controller == nil {
537		log.Error().Msg("failed to create shortcut controller during reload")
538		return
539	}
540	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
541	h.registered = make(map[KeyBinding]Action)
542	h.shortcutRefs = make(map[string]globalShortcutRegistration)
543	h.lastDispatchAt = make(map[Action]time.Time)
544	h.heldShortcuts = make(map[globalShortcutHoldKey]struct{})
545
546	h.registerDefaultGlobalShortcuts(log)
547
548	// Re-register config-driven shortcuts
549	if workspace != nil {
550		actionMap := globalShortcutActionMap()
551
552		for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) {
553			action, ok := actionMap[actionName]
554			if !ok {
555				continue
556			}
557			for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys {
558				binding, ok := ParseKeyString(keyStr)
559				if !ok {
560					continue
561				}
562				h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
563			}
564		}
565
566		occupied := make(map[KeyBinding]Action, len(h.registered))
567		maps.Copy(occupied, h.registered)
568		for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
569			if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
570				if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
571					log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
572				}
573			}
574		}
575
576		shortcuts := NewShortcutSet(ctx, workspace, session)
577		for binding, action := range shortcuts.Global {
578			if !shouldRegisterGTKGlobalShortcut(action) {
579				continue
580			}
581			if _, exists := h.registered[binding]; exists {
582				continue
583			}
584			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
585		}
586	}
587
588	// Attach new controller to window
589	h.window.AddController(&h.controller.EventController)
590
591	log.Debug().
592		Int("shortcuts", len(h.registered)).
593		Msg("global shortcuts reloaded")
594}
595
596// Detach removes the global shortcut handler from a live GTK window.
597// Use DetachForDestroy when the window is already closing or being destroyed.
598func (h *GlobalShortcutHandler) Detach() {
599	h.detach(true)
600}
601
602// DetachForDestroy releases retained GTK callbacks during window teardown
603// without mutating the window's controller or action lists while GTK is
604// destroying them.
605func (h *GlobalShortcutHandler) DetachForDestroy() {
606	h.detach(false)
607}
608
609func (h *GlobalShortcutHandler) detach(removeFromWindow bool) {
610	h.generation++
611	if removeFromWindow && h.window != nil && h.controller != nil {
612		h.window.RemoveController(&h.controller.EventController)
613	}
614	if h.releaseController != nil && h.keyReleasedHandlerID != 0 {
615		h.releaseController.DisconnectSignal(h.keyReleasedHandlerID)
616	}
617	if removeFromWindow && h.window != nil && h.releaseController != nil {
618		h.window.RemoveController(&h.releaseController.EventController)
619	}
620	if h.globalAction != nil && h.globalActionHandlerID != 0 {
621		h.globalAction.DisconnectSignal(h.globalActionHandlerID)
622	}
623	if removeFromWindow && h.window != nil && h.globalAction != nil {
624		h.window.RemoveAction(globalShortcutActionName)
625	}
626	h.controller = nil
627	h.releaseController = nil
628	h.globalAction = nil
629	h.globalActionCb = nil
630	h.globalActionHandlerID = 0
631	h.keyReleasedCb = nil
632	h.keyReleasedHandlerID = 0
633	h.registered = nil
634	h.shortcutRefs = nil
635	h.lastDispatchAt = nil
636	h.heldShortcuts = nil
637}
638
639const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond
640
641func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
642	return h == nil || generation != h.generation
643}
644
645func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
646	return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
647}
648
649func (h *GlobalShortcutHandler) suppressHeldShortcut(action Action, info globalShortcutEventInfo) bool {
650	if h == nil || !isHeldGlobalShortcutSuppressed(action) || !info.hasCurrentEvent {
651		return false
652	}
653	if h.heldShortcuts == nil {
654		// Defensive: if hold tracking is unavailable during detach/reload,
655		// suppress rather than risk dispatching a stale global callback.
656		return true
657	}
658	key := globalShortcutHoldKey{keyval: normalizeKeyval(info.eventKeyval), keycode: info.eventKeycode}
659	if key.keyval == 0 && key.keycode == 0 {
660		return false
661	}
662	if _, ok := h.heldShortcuts[key]; ok {
663		return true
664	}
665	h.heldShortcuts[key] = struct{}{}
666	return false
667}
668
669func (h *GlobalShortcutHandler) releaseHeldGlobalShortcuts(keyval, keycode uint) {
670	if h == nil || len(h.heldShortcuts) == 0 {
671		return
672	}
673	keyval = normalizeKeyval(keyval)
674	for key := range h.heldShortcuts {
675		if (keyval != 0 && key.keyval == keyval) || (keycode != 0 && key.keycode == keycode) {
676			delete(h.heldShortcuts, key)
677		}
678	}
679}
680
681func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
682	if h == nil {
683		return false
684	}
685	if !isRepeatedGlobalShortcutSuppressed(action) {
686		if _, ok := ParseFloatingProfileTarget(action); !ok {
687			return false
688		}
689	}
690	if h.lastDispatchAt == nil {
691		// Detached/reloading handlers clear dispatch state; consume one-shot shortcuts
692		// rather than letting a stale GTK callback repeat destructive actions.
693		return true
694	}
695	if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow {
696		return true
697	}
698	h.lastDispatchAt[action] = now
699	return false
700}
701
702func isHeldGlobalShortcutSuppressed(action Action) bool {
703	if isRepeatedGlobalShortcutSuppressed(action) || isModeAction(action) {
704		return true
705	}
706	_, ok := ParseFloatingProfileTarget(action)
707	return ok
708}
709
710func isRepeatedGlobalShortcutSuppressed(action Action) bool {
711	switch action {
712	case ActionGoBack,
713		ActionGoForward,
714		ActionZoomReset,
715		ActionReload,
716		ActionHardReload,
717		ActionPrintPage,
718		ActionOpenOmnibox,
719		ActionOpenFind,
720		ActionFindNext,
721		ActionFindPrev,
722		ActionOpenDevTools,
723		ActionToggleFullscreen,
724		ActionToggleFloatingPane,
725		ActionToggleHistorySystemView,
726		ActionToggleFavoritesSidebar,
727		ActionToggleCurrentPageFavorite,
728		ActionToggleConfigSystemView,
729		ActionCopyURL,
730		ActionConsumeOrExpelLeft,
731		ActionConsumeOrExpelRight,
732		ActionConsumeOrExpelUp,
733		ActionConsumeOrExpelDown,
734		ActionClosePane,
735		ActionCloseTab,
736		ActionQuit,
737		ActionOpenSessionManager,
738		ActionSwitchTabIndex1,
739		ActionSwitchTabIndex2,
740		ActionSwitchTabIndex3,
741		ActionSwitchTabIndex4,
742		ActionSwitchTabIndex5,
743		ActionSwitchTabIndex6,
744		ActionSwitchTabIndex7,
745		ActionSwitchTabIndex8,
746		ActionSwitchTabIndex9,
747		ActionSwitchTabIndex10,
748		ActionSwitchLastTab:
749		return true
750	default:
751		return false
752	}
753}
754
755func formatBinding(binding KeyBinding) string {
756	parts := make([]string, 0, 3)
757	if binding.Modifiers&ModCtrl != 0 {
758		parts = append(parts, "ctrl")
759	}
760	if binding.Modifiers&ModShift != 0 {
761		parts = append(parts, "shift")
762	}
763	if binding.Modifiers&ModAlt != 0 {
764		parts = append(parts, "alt")
765	}
766	parts = append(parts, formatKeyval(binding.Keyval))
767	return strings.Join(parts, "+")
768}
769
770func formatKeyval(keyval uint) string {
771	if keyval == 0 {
772		return ""
773	}
774	keyName := gdk.KeyvalName(keyval)
775	if keyName == "" {
776		return fmt.Sprintf("0x%x", keyval)
777	}
778	return strings.ToLower(keyName)
779}
780
781func formatModifierMask(modifiers gdk.ModifierType) string {
782	if modifiers == 0 {
783		return "none"
784	}
785	parts := make([]string, 0, 4)
786	if modifiers&gdk.ControlMaskValue != 0 {
787		parts = append(parts, "ctrl")
788	}
789	if modifiers&gdk.ShiftMaskValue != 0 {
790		parts = append(parts, "shift")
791	}
792	if modifiers&gdk.AltMaskValue != 0 {
793		parts = append(parts, "alt")
794	}
795	remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
796	if remaining != 0 {
797		parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
798	}
799	return strings.Join(parts, "+")
800}
801
802func formatEventType(eventType gdk.EventType) string {
803	switch eventType {
804	case gdk.KeyPressValue:
805		return "key-press"
806	case gdk.KeyReleaseValue:
807		return "key-release"
808	case gdk.ButtonPressValue:
809		return "button-press"
810	case gdk.ButtonReleaseValue:
811		return "button-release"
812	case gdk.MotionNotifyValue:
813		return "motion"
814	case gdk.ScrollValue:
815		return "scroll"
816	case gdk.TouchBeginValue:
817		return "touch-begin"
818	case gdk.TouchUpdateValue:
819		return "touch-update"
820	case gdk.TouchEndValue:
821		return "touch-end"
822	case gdk.FocusChangeValue:
823		return "focus-change"
824	case gdk.DeleteValue:
825		return "delete"
826	default:
827		return fmt.Sprintf("event-%d", int(eventType))
828	}
829}
830
831func isModeAction(action Action) bool {
832	switch action {
833	case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode,
834		ActionEnterPrefixMode, ActionExitMode:
835		return true
836	default:
837		return false
838	}
839}

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