v0.29.0 21b3150

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

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