v0.30.0 eb3068f

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

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