detached c48b63f

fix(input): suppress ghost shortcut repeats

commit
c48b63f429aa980c583382c3c17089af081b50d4
author
brice · · 2026-05-24 13:06 UTC
parent
7ab5e2e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +393 -14
M internal/ui/input/global_shortcuts.go +181 -5
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/puregotk/v4/gdk"
1313 "github.com/bnema/puregotk/v4/glib"
1414 "github.com/bnema/puregotk/v4/gtk"
15+ "github.com/rs/zerolog"
1516 )
1617
1718 // GlobalShortcutHandler manages keyboard shortcuts that must work globally,
@@ -191,11 +192,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191192 // dispatch one-shot actions after a shortcut reload.
192193 actionToDispatch := action
193194 generation := h.generation
195+ bindingForLog := binding
194196 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
195197 log := logging.FromContext(h.ctx)
196198 if h.isStaleGeneration(generation) {
197199 log.Trace().
198200 Str("action", string(actionToDispatch)).
201+ Str("shortcut", formatBinding(bindingForLog)).
199202 Uint64("callback_generation", generation).
200203 Uint64("current_generation", h.generation).
201204 Msg("stale global shortcut callback ignored")
@@ -204,17 +207,24 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
204207 if !h.isActiveWindowShortcutHandler() {
205208 log.Trace().
206209 Str("action", string(actionToDispatch)).
210+ Str("shortcut", formatBinding(bindingForLog)).
207211 Msg("inactive window global shortcut callback ignored")
208212 return false
209213 }
214+ eventInfo := h.inspectCurrentShortcutEvent()
215+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
216+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
217+ Msg("global shortcut ignored without current key event")
218+ return true
219+ }
210220 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
211221 log.Trace().
212222 Str("action", string(actionToDispatch)).
223+ Str("shortcut", formatBinding(bindingForLog)).
213224 Msg("global shortcut repeat suppressed")
214225 return true
215226 }
216- log.Debug().
217- Str("action", string(actionToDispatch)).
227+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
218228 Msg("global shortcut triggered")
219229
220230 // Mode-enter/exit actions go through KeyboardHandler for modal state
@@ -267,6 +277,115 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
267277 }
268278
269279 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
280+type globalShortcutEventInfo struct {
281+ hasCurrentEvent bool
282+ eventType gdk.EventType
283+ eventKeyval uint
284+ eventKeycode uint
285+ eventLayout uint
286+ eventLevel uint
287+ eventState gdk.ModifierType
288+ eventConsumedModifiers gdk.ModifierType
289+ eventTime uint32
290+ hasDevice bool
291+ deviceName string
292+ deviceSource gdk.InputSource
293+ controllerState gdk.ModifierType
294+ controllerTime uint32
295+}
296+
297+func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
298+ var info globalShortcutEventInfo
299+ if h == nil || h.controller == nil {
300+ return info
301+ }
302+
303+ info.controllerState = h.controller.GetCurrentEventState()
304+ info.controllerTime = h.controller.GetCurrentEventTime()
305+ if device := h.controller.GetCurrentEventDevice(); device != nil {
306+ info.hasDevice = true
307+ info.deviceName = device.GetName()
308+ info.deviceSource = device.GetSource()
309+ }
310+
311+ event := h.controller.GetCurrentEvent()
312+ if event == nil {
313+ return info
314+ }
315+ info.hasCurrentEvent = true
316+ info.eventType = event.GetEventType()
317+ info.eventState = event.GetModifierState()
318+ info.eventTime = event.GetTime()
319+ if !info.hasDevice {
320+ if device := event.GetDevice(); device != nil {
321+ info.hasDevice = true
322+ info.deviceName = device.GetName()
323+ info.deviceSource = device.GetSource()
324+ }
325+ }
326+
327+ switch info.eventType {
328+ case gdk.KeyPressValue, gdk.KeyReleaseValue:
329+ keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
330+ info.eventKeyval = keyEvent.GetKeyval()
331+ info.eventKeycode = keyEvent.GetKeycode()
332+ info.eventLayout = keyEvent.GetLayout()
333+ info.eventLevel = keyEvent.GetLevel()
334+ info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
335+ }
336+
337+ return info
338+}
339+
340+func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
341+ return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
342+}
343+
344+func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
345+ if evt == nil {
346+ return nil
347+ }
348+ evt = evt.
349+ Str("action", string(action)).
350+ Str("shortcut", formatBinding(binding)).
351+ Uint("shortcut_keyval", binding.Keyval).
352+ Str("shortcut_key", formatKeyval(binding.Keyval)).
353+ Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
354+ Str("controller_modifiers", formatModifierMask(info.controllerState)).
355+ Uint32("controller_event_time", info.controllerTime).
356+ Bool("has_current_event", info.hasCurrentEvent)
357+ if info.hasCurrentEvent {
358+ evt = evt.
359+ Str("event_type", formatEventType(info.eventType)).
360+ Int("event_type_value", int(info.eventType)).
361+ Str("event_modifiers", formatModifierMask(info.eventState)).
362+ Uint32("event_time", info.eventTime)
363+ if info.eventKeyval != 0 {
364+ evt = evt.
365+ Uint("event_keyval", info.eventKeyval).
366+ Str("event_key", formatKeyval(info.eventKeyval))
367+ }
368+ if info.eventKeycode != 0 {
369+ evt = evt.Uint("event_keycode", info.eventKeycode)
370+ }
371+ if info.eventLayout != 0 {
372+ evt = evt.Uint("event_layout", info.eventLayout)
373+ }
374+ if info.eventLevel != 0 {
375+ evt = evt.Uint("event_level", info.eventLevel)
376+ }
377+ if info.eventConsumedModifiers != 0 {
378+ evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
379+ }
380+ }
381+ if info.hasDevice {
382+ evt = evt.
383+ Str("event_device", info.deviceName).
384+ Int("event_device_source", int(info.deviceSource))
385+ }
386+ return evt
387+}
388+
270389 func globalShortcutActionMap() map[string]Action {
271390 return map[string]Action{
272391 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -474,14 +593,71 @@ func formatBinding(binding KeyBinding) string {
474593 if binding.Modifiers&ModAlt != 0 {
475594 parts = append(parts, "alt")
476595 }
477- keyName := gdk.KeyvalName(binding.Keyval)
596+ parts = append(parts, formatKeyval(binding.Keyval))
597+ return strings.Join(parts, "+")
598+}
599+
600+func formatKeyval(keyval uint) string {
601+ if keyval == 0 {
602+ return ""
603+ }
604+ keyName := gdk.KeyvalName(keyval)
478605 if keyName == "" {
479- keyName = fmt.Sprintf("0x%x", binding.Keyval)
606+ return fmt.Sprintf("0x%x", keyval)
607+ }
608+ return strings.ToLower(keyName)
609+}
610+
611+func formatModifierMask(modifiers gdk.ModifierType) string {
612+ if modifiers == 0 {
613+ return "none"
614+ }
615+ parts := make([]string, 0, 4)
616+ if modifiers&gdk.ControlMaskValue != 0 {
617+ parts = append(parts, "ctrl")
618+ }
619+ if modifiers&gdk.ShiftMaskValue != 0 {
620+ parts = append(parts, "shift")
621+ }
622+ if modifiers&gdk.AltMaskValue != 0 {
623+ parts = append(parts, "alt")
624+ }
625+ remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
626+ if remaining != 0 {
627+ parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
480628 }
481- parts = append(parts, strings.ToLower(keyName))
482629 return strings.Join(parts, "+")
483630 }
484631
632+func formatEventType(eventType gdk.EventType) string {
633+ switch eventType {
634+ case gdk.KeyPressValue:
635+ return "key-press"
636+ case gdk.KeyReleaseValue:
637+ return "key-release"
638+ case gdk.ButtonPressValue:
639+ return "button-press"
640+ case gdk.ButtonReleaseValue:
641+ return "button-release"
642+ case gdk.MotionNotifyValue:
643+ return "motion"
644+ case gdk.ScrollValue:
645+ return "scroll"
646+ case gdk.TouchBeginValue:
647+ return "touch-begin"
648+ case gdk.TouchUpdateValue:
649+ return "touch-update"
650+ case gdk.TouchEndValue:
651+ return "touch-end"
652+ case gdk.FocusChangeValue:
653+ return "focus-change"
654+ case gdk.DeleteValue:
655+ return "delete"
656+ default:
657+ return fmt.Sprintf("event-%d", int(eventType))
658+ }
659+}
660+
485661 func isModeAction(action Action) bool {
486662 switch action {
487663 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/puregotk/v4/gdk"12 "github.com/bnema/puregotk/v4/gdk"
13 "github.com/bnema/puregotk/v4/glib"13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/gtk"14 "github.com/bnema/puregotk/v4/gtk"
15+ "github.com/rs/zerolog"
15 )16 )
16 17
17 // GlobalShortcutHandler manages keyboard shortcuts that must work globally,18 // GlobalShortcutHandler manages keyboard shortcuts that must work globally,
@@ -191,11 +192,13 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
191 // dispatch one-shot actions after a shortcut reload.192 // dispatch one-shot actions after a shortcut reload.
192 actionToDispatch := action193 actionToDispatch := action
193 generation := h.generation194 generation := h.generation
195+ bindingForLog := binding
194 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {196 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
195 log := logging.FromContext(h.ctx)197 log := logging.FromContext(h.ctx)
196 if h.isStaleGeneration(generation) {198 if h.isStaleGeneration(generation) {
197 log.Trace().199 log.Trace().
198 Str("action", string(actionToDispatch)).200 Str("action", string(actionToDispatch)).
201+ Str("shortcut", formatBinding(bindingForLog)).
199 Uint64("callback_generation", generation).202 Uint64("callback_generation", generation).
200 Uint64("current_generation", h.generation).203 Uint64("current_generation", h.generation).
201 Msg("stale global shortcut callback ignored")204 Msg("stale global shortcut callback ignored")
@@ -204,17 +207,24 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
204 if !h.isActiveWindowShortcutHandler() {207 if !h.isActiveWindowShortcutHandler() {
205 log.Trace().208 log.Trace().
206 Str("action", string(actionToDispatch)).209 Str("action", string(actionToDispatch)).
210+ Str("shortcut", formatBinding(bindingForLog)).
207 Msg("inactive window global shortcut callback ignored")211 Msg("inactive window global shortcut callback ignored")
208 return false212 return false
209 }213 }
214+ eventInfo := h.inspectCurrentShortcutEvent()
215+ if !shouldDispatchGlobalShortcutEvent(eventInfo) {
216+ appendGlobalShortcutEventFields(log.Trace(), bindingForLog, actionToDispatch, eventInfo).
217+ Msg("global shortcut ignored without current key event")
218+ return true
219+ }
210 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {220 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
211 log.Trace().221 log.Trace().
212 Str("action", string(actionToDispatch)).222 Str("action", string(actionToDispatch)).
223+ Str("shortcut", formatBinding(bindingForLog)).
213 Msg("global shortcut repeat suppressed")224 Msg("global shortcut repeat suppressed")
214 return true225 return true
215 }226 }
216- log.Debug().227+ appendGlobalShortcutEventFields(log.Debug(), bindingForLog, actionToDispatch, eventInfo).
217- Str("action", string(actionToDispatch)).
218 Msg("global shortcut triggered")228 Msg("global shortcut triggered")
219 229
220 // Mode-enter/exit actions go through KeyboardHandler for modal state230 // Mode-enter/exit actions go through KeyboardHandler for modal state
@@ -267,6 +277,115 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
267 }277 }
268 278
269 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.279 // globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
280+type globalShortcutEventInfo struct {
281+ hasCurrentEvent bool
282+ eventType gdk.EventType
283+ eventKeyval uint
284+ eventKeycode uint
285+ eventLayout uint
286+ eventLevel uint
287+ eventState gdk.ModifierType
288+ eventConsumedModifiers gdk.ModifierType
289+ eventTime uint32
290+ hasDevice bool
291+ deviceName string
292+ deviceSource gdk.InputSource
293+ controllerState gdk.ModifierType
294+ controllerTime uint32
295+}
296+
297+func (h *GlobalShortcutHandler) inspectCurrentShortcutEvent() globalShortcutEventInfo {
298+ var info globalShortcutEventInfo
299+ if h == nil || h.controller == nil {
300+ return info
301+ }
302+
303+ info.controllerState = h.controller.GetCurrentEventState()
304+ info.controllerTime = h.controller.GetCurrentEventTime()
305+ if device := h.controller.GetCurrentEventDevice(); device != nil {
306+ info.hasDevice = true
307+ info.deviceName = device.GetName()
308+ info.deviceSource = device.GetSource()
309+ }
310+
311+ event := h.controller.GetCurrentEvent()
312+ if event == nil {
313+ return info
314+ }
315+ info.hasCurrentEvent = true
316+ info.eventType = event.GetEventType()
317+ info.eventState = event.GetModifierState()
318+ info.eventTime = event.GetTime()
319+ if !info.hasDevice {
320+ if device := event.GetDevice(); device != nil {
321+ info.hasDevice = true
322+ info.deviceName = device.GetName()
323+ info.deviceSource = device.GetSource()
324+ }
325+ }
326+
327+ switch info.eventType {
328+ case gdk.KeyPressValue, gdk.KeyReleaseValue:
329+ keyEvent := gdk.KeyEventNewFromInternalPtr(event.GoPointer())
330+ info.eventKeyval = keyEvent.GetKeyval()
331+ info.eventKeycode = keyEvent.GetKeycode()
332+ info.eventLayout = keyEvent.GetLayout()
333+ info.eventLevel = keyEvent.GetLevel()
334+ info.eventConsumedModifiers = keyEvent.GetConsumedModifiers()
335+ }
336+
337+ return info
338+}
339+
340+func shouldDispatchGlobalShortcutEvent(info globalShortcutEventInfo) bool {
341+ return info.hasCurrentEvent && info.eventType == gdk.KeyPressValue
342+}
343+
344+func appendGlobalShortcutEventFields(evt *zerolog.Event, binding KeyBinding, action Action, info globalShortcutEventInfo) *zerolog.Event {
345+ if evt == nil {
346+ return nil
347+ }
348+ evt = evt.
349+ Str("action", string(action)).
350+ Str("shortcut", formatBinding(binding)).
351+ Uint("shortcut_keyval", binding.Keyval).
352+ Str("shortcut_key", formatKeyval(binding.Keyval)).
353+ Str("shortcut_modifiers", formatModifierMask(gdk.ModifierType(binding.Modifiers))).
354+ Str("controller_modifiers", formatModifierMask(info.controllerState)).
355+ Uint32("controller_event_time", info.controllerTime).
356+ Bool("has_current_event", info.hasCurrentEvent)
357+ if info.hasCurrentEvent {
358+ evt = evt.
359+ Str("event_type", formatEventType(info.eventType)).
360+ Int("event_type_value", int(info.eventType)).
361+ Str("event_modifiers", formatModifierMask(info.eventState)).
362+ Uint32("event_time", info.eventTime)
363+ if info.eventKeyval != 0 {
364+ evt = evt.
365+ Uint("event_keyval", info.eventKeyval).
366+ Str("event_key", formatKeyval(info.eventKeyval))
367+ }
368+ if info.eventKeycode != 0 {
369+ evt = evt.Uint("event_keycode", info.eventKeycode)
370+ }
371+ if info.eventLayout != 0 {
372+ evt = evt.Uint("event_layout", info.eventLayout)
373+ }
374+ if info.eventLevel != 0 {
375+ evt = evt.Uint("event_level", info.eventLevel)
376+ }
377+ if info.eventConsumedModifiers != 0 {
378+ evt = evt.Str("event_consumed_modifiers", formatModifierMask(info.eventConsumedModifiers))
379+ }
380+ }
381+ if info.hasDevice {
382+ evt = evt.
383+ Str("event_device", info.deviceName).
384+ Int("event_device_source", int(info.deviceSource))
385+ }
386+ return evt
387+}
388+
270 func globalShortcutActionMap() map[string]Action {389 func globalShortcutActionMap() map[string]Action {
271 return map[string]Action{390 return map[string]Action{
272 "toggle_floating_pane": ActionToggleFloatingPane,391 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -474,14 +593,71 @@ func formatBinding(binding KeyBinding) string {
474 if binding.Modifiers&ModAlt != 0 {593 if binding.Modifiers&ModAlt != 0 {
475 parts = append(parts, "alt")594 parts = append(parts, "alt")
476 }595 }
477- keyName := gdk.KeyvalName(binding.Keyval)596+ parts = append(parts, formatKeyval(binding.Keyval))
597+ return strings.Join(parts, "+")
598+}
599+
600+func formatKeyval(keyval uint) string {
601+ if keyval == 0 {
602+ return ""
603+ }
604+ keyName := gdk.KeyvalName(keyval)
478 if keyName == "" {605 if keyName == "" {
479- keyName = fmt.Sprintf("0x%x", binding.Keyval)606+ return fmt.Sprintf("0x%x", keyval)
607+ }
608+ return strings.ToLower(keyName)
609+}
610+
611+func formatModifierMask(modifiers gdk.ModifierType) string {
612+ if modifiers == 0 {
613+ return "none"
614+ }
615+ parts := make([]string, 0, 4)
616+ if modifiers&gdk.ControlMaskValue != 0 {
617+ parts = append(parts, "ctrl")
618+ }
619+ if modifiers&gdk.ShiftMaskValue != 0 {
620+ parts = append(parts, "shift")
621+ }
622+ if modifiers&gdk.AltMaskValue != 0 {
623+ parts = append(parts, "alt")
624+ }
625+ remaining := modifiers &^ (gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.AltMaskValue)
626+ if remaining != 0 {
627+ parts = append(parts, fmt.Sprintf("0x%x", uint(remaining)))
480 }628 }
481- parts = append(parts, strings.ToLower(keyName))
482 return strings.Join(parts, "+")629 return strings.Join(parts, "+")
483 }630 }
484 631
632+func formatEventType(eventType gdk.EventType) string {
633+ switch eventType {
634+ case gdk.KeyPressValue:
635+ return "key-press"
636+ case gdk.KeyReleaseValue:
637+ return "key-release"
638+ case gdk.ButtonPressValue:
639+ return "button-press"
640+ case gdk.ButtonReleaseValue:
641+ return "button-release"
642+ case gdk.MotionNotifyValue:
643+ return "motion"
644+ case gdk.ScrollValue:
645+ return "scroll"
646+ case gdk.TouchBeginValue:
647+ return "touch-begin"
648+ case gdk.TouchUpdateValue:
649+ return "touch-update"
650+ case gdk.TouchEndValue:
651+ return "touch-end"
652+ case gdk.FocusChangeValue:
653+ return "focus-change"
654+ case gdk.DeleteValue:
655+ return "delete"
656+ default:
657+ return fmt.Sprintf("event-%d", int(eventType))
658+ }
659+}
660+
485 func isModeAction(action Action) bool {661 func isModeAction(action Action) bool {
486 switch action {662 switch action {
487 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:663 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
M internal/ui/input/global_shortcuts_test.go +33 -0
@@ -3,6 +3,8 @@ package input
33 import (
44 "testing"
55 "time"
6+
7+ "github.com/bnema/puregotk/v4/gdk"
68 )
79
810 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
@@ -185,3 +187,34 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
185187 t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
186188 }
187189 }
190+
191+func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
192+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
193+ t.Fatal("global shortcut without current event should not dispatch")
194+ }
195+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyReleaseValue}) {
196+ t.Fatal("global shortcut from key-release event should not dispatch")
197+ }
198+ if !shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue}) {
199+ t.Fatal("global shortcut from key-press event should dispatch")
200+ }
201+}
202+
203+func TestFormatModifierMask(t *testing.T) {
204+ got := formatModifierMask(gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.ModifierType(0x2000))
205+ if got != "ctrl+shift+0x2000" {
206+ t.Fatalf("unexpected modifier mask: %q", got)
207+ }
208+ if formatModifierMask(0) != "none" {
209+ t.Fatal("zero modifier mask should render as none")
210+ }
211+}
212+
213+func TestFormatEventType(t *testing.T) {
214+ if got := formatEventType(gdk.KeyPressValue); got != "key-press" {
215+ t.Fatalf("unexpected key press event type: %q", got)
216+ }
217+ if got := formatEventType(gdk.EventType(99)); got != "event-99" {
218+ t.Fatalf("unexpected fallback event type: %q", got)
219+ }
220+}
@@ -3,6 +3,8 @@ package input
3 import (3 import (
4 "testing"4 "testing"
5 "time"5 "time"
6+
7+ "github.com/bnema/puregotk/v4/gdk"
6 )8 )
7 9
8 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {10 func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
@@ -185,3 +187,34 @@ func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
185 t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")187 t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
186 }188 }
187 }189 }
190+
191+func TestShouldDispatchGlobalShortcutEventRequiresCurrentKeyEvent(t *testing.T) {
192+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{}) {
193+ t.Fatal("global shortcut without current event should not dispatch")
194+ }
195+ if shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyReleaseValue}) {
196+ t.Fatal("global shortcut from key-release event should not dispatch")
197+ }
198+ if !shouldDispatchGlobalShortcutEvent(globalShortcutEventInfo{hasCurrentEvent: true, eventType: gdk.KeyPressValue}) {
199+ t.Fatal("global shortcut from key-press event should dispatch")
200+ }
201+}
202+
203+func TestFormatModifierMask(t *testing.T) {
204+ got := formatModifierMask(gdk.ControlMaskValue | gdk.ShiftMaskValue | gdk.ModifierType(0x2000))
205+ if got != "ctrl+shift+0x2000" {
206+ t.Fatalf("unexpected modifier mask: %q", got)
207+ }
208+ if formatModifierMask(0) != "none" {
209+ t.Fatal("zero modifier mask should render as none")
210+ }
211+}
212+
213+func TestFormatEventType(t *testing.T) {
214+ if got := formatEventType(gdk.KeyPressValue); got != "key-press" {
215+ t.Fatalf("unexpected key press event type: %q", got)
216+ }
217+ if got := formatEventType(gdk.EventType(99)); got != "event-99" {
218+ t.Fatalf("unexpected fallback event type: %q", got)
219+ }
220+}
M internal/ui/input/keyboard.go +67 -9
@@ -76,8 +76,9 @@ type KeyboardHandler struct {
7676 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
7777 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
7878
79- ctx context.Context
80- mu sync.RWMutex
79+ activePressedActions map[Action]uint
80+ ctx context.Context
81+ mu sync.RWMutex
8182 }
8283
8384 // NewKeyboardHandler creates a new keyboard handler.
@@ -87,11 +88,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
8788 log.Debug().Msg("creating keyboard handler")
8889
8990 h := &KeyboardHandler{
90- shortcuts: NewShortcutSet(ctx, workspace, session),
91- modal: NewModalState(ctx),
92- workspace: workspace,
93- session: session,
94- ctx: ctx,
91+ shortcuts: NewShortcutSet(ctx, workspace, session),
92+ modal: NewModalState(ctx),
93+ workspace: workspace,
94+ session: session,
95+ activePressedActions: make(map[Action]uint),
96+ ctx: ctx,
9597 }
9698
9799 return h
@@ -244,6 +246,7 @@ func (h *KeyboardHandler) Detach() {
244246 h.controller = nil
245247 h.keyPressedCb = nil
246248 h.keyReleasedCb = nil
249+ h.activePressedActions = nil
247250 }
248251
249252 // handleKeyPress processes a key press event.
@@ -313,6 +316,12 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
313316 if !found {
314317 return mode != ModeNormal // Consume unrecognized keys in modal mode
315318 }
319+ if h.suppressHeldAction(action, keyval) {
320+ log.Trace().
321+ Str("action", string(action)).
322+ Msg("held keyboard action repeat suppressed")
323+ return true
324+ }
316325
317326 return h.dispatchAction(action, mode)
318327 }
@@ -373,6 +382,47 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
373382 return true // Consumed the key
374383 }
375384
385+func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
386+ if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
387+ return false
388+ }
389+
390+ h.mu.Lock()
391+ defer h.mu.Unlock()
392+ if h.activePressedActions == nil {
393+ return true
394+ }
395+ if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
396+ return true
397+ }
398+ h.activePressedActions[action] = keyval
399+ return false
400+}
401+
402+func isRepeatedKeyboardActionSuppressed(action Action) bool {
403+ if isRepeatedGlobalShortcutSuppressed(action) {
404+ return true
405+ }
406+ switch action {
407+ case ActionEnterTabMode,
408+ ActionEnterPaneMode,
409+ ActionEnterSessionMode,
410+ ActionEnterResizeMode,
411+ ActionNewTab,
412+ ActionRenameTab,
413+ ActionSplitRight,
414+ ActionSplitLeft,
415+ ActionSplitUp,
416+ ActionSplitDown,
417+ ActionStackPane,
418+ ActionMovePaneToTab,
419+ ActionMovePaneToNextTab:
420+ return true
421+ default:
422+ return false
423+ }
424+}
425+
376426 func (h *KeyboardHandler) lookupAction(
377427 log *zerolog.Logger,
378428 binding KeyBinding,
@@ -536,9 +586,17 @@ func (h *KeyboardHandler) DispatchAction(action Action) {
536586
537587 // handleKeyRelease processes a key release event for accent detection.
538588 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
539- h.mu.RLock()
589+ h.mu.Lock()
540590 accentHandler := h.accentHandler
541- h.mu.RUnlock()
591+ if len(h.activePressedActions) > 0 {
592+ keyval = normalizeKeyval(keyval)
593+ for action, pressedKeyval := range h.activePressedActions {
594+ if pressedKeyval == keyval {
595+ delete(h.activePressedActions, action)
596+ }
597+ }
598+ }
599+ h.mu.Unlock()
542600
543601 if accentHandler == nil {
544602 return
@@ -76,8 +76,9 @@ type KeyboardHandler struct {
76 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool76 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
77 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)77 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
78 78
79- ctx context.Context79+ activePressedActions map[Action]uint
80- mu sync.RWMutex80+ ctx context.Context
81+ mu sync.RWMutex
81 }82 }
82 83
83 // NewKeyboardHandler creates a new keyboard handler.84 // NewKeyboardHandler creates a new keyboard handler.
@@ -87,11 +88,12 @@ func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig,
87 log.Debug().Msg("creating keyboard handler")88 log.Debug().Msg("creating keyboard handler")
88 89
89 h := &KeyboardHandler{90 h := &KeyboardHandler{
90- shortcuts: NewShortcutSet(ctx, workspace, session),91+ shortcuts: NewShortcutSet(ctx, workspace, session),
91- modal: NewModalState(ctx),92+ modal: NewModalState(ctx),
92- workspace: workspace,93+ workspace: workspace,
93- session: session,94+ session: session,
94- ctx: ctx,95+ activePressedActions: make(map[Action]uint),
96+ ctx: ctx,
95 }97 }
96 98
97 return h99 return h
@@ -244,6 +246,7 @@ func (h *KeyboardHandler) Detach() {
244 h.controller = nil246 h.controller = nil
245 h.keyPressedCb = nil247 h.keyPressedCb = nil
246 h.keyReleasedCb = nil248 h.keyReleasedCb = nil
249+ h.activePressedActions = nil
247 }250 }
248 251
249 // handleKeyPress processes a key press event.252 // handleKeyPress processes a key press event.
@@ -313,6 +316,12 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
313 if !found {316 if !found {
314 return mode != ModeNormal // Consume unrecognized keys in modal mode317 return mode != ModeNormal // Consume unrecognized keys in modal mode
315 }318 }
319+ if h.suppressHeldAction(action, keyval) {
320+ log.Trace().
321+ Str("action", string(action)).
322+ Msg("held keyboard action repeat suppressed")
323+ return true
324+ }
316 325
317 return h.dispatchAction(action, mode)326 return h.dispatchAction(action, mode)
318 }327 }
@@ -373,6 +382,47 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
373 return true // Consumed the key382 return true // Consumed the key
374 }383 }
375 384
385+func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
386+ if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
387+ return false
388+ }
389+
390+ h.mu.Lock()
391+ defer h.mu.Unlock()
392+ if h.activePressedActions == nil {
393+ return true
394+ }
395+ if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
396+ return true
397+ }
398+ h.activePressedActions[action] = keyval
399+ return false
400+}
401+
402+func isRepeatedKeyboardActionSuppressed(action Action) bool {
403+ if isRepeatedGlobalShortcutSuppressed(action) {
404+ return true
405+ }
406+ switch action {
407+ case ActionEnterTabMode,
408+ ActionEnterPaneMode,
409+ ActionEnterSessionMode,
410+ ActionEnterResizeMode,
411+ ActionNewTab,
412+ ActionRenameTab,
413+ ActionSplitRight,
414+ ActionSplitLeft,
415+ ActionSplitUp,
416+ ActionSplitDown,
417+ ActionStackPane,
418+ ActionMovePaneToTab,
419+ ActionMovePaneToNextTab:
420+ return true
421+ default:
422+ return false
423+ }
424+}
425+
376 func (h *KeyboardHandler) lookupAction(426 func (h *KeyboardHandler) lookupAction(
377 log *zerolog.Logger,427 log *zerolog.Logger,
378 binding KeyBinding,428 binding KeyBinding,
@@ -536,9 +586,17 @@ func (h *KeyboardHandler) DispatchAction(action Action) {
536 586
537 // handleKeyRelease processes a key release event for accent detection.587 // handleKeyRelease processes a key release event for accent detection.
538 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {588 func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
539- h.mu.RLock()589+ h.mu.Lock()
540 accentHandler := h.accentHandler590 accentHandler := h.accentHandler
541- h.mu.RUnlock()591+ if len(h.activePressedActions) > 0 {
592+ keyval = normalizeKeyval(keyval)
593+ for action, pressedKeyval := range h.activePressedActions {
594+ if pressedKeyval == keyval {
595+ delete(h.activePressedActions, action)
596+ }
597+ }
598+ }
599+ h.mu.Unlock()
542 600
543 if accentHandler == nil {601 if accentHandler == nil {
544 return602 return
M internal/ui/input/keyboard_test.go +112 -0
@@ -230,3 +230,115 @@ func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
230230 result := h.handleKeyPress(uint('z'), 0, 0)
231231 assert.False(t, result, "unregistered key in ModeNormal returns false")
232232 }
233+
234+func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
235+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
236+ calls := 0
237+ h.SetOnAction(func(_ context.Context, action Action) error {
238+ calls++
239+ if action != ActionSwitchTabIndex2 {
240+ t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
241+ }
242+ return nil
243+ })
244+
245+ const fallbackKeycode = 11
246+ if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
247+ t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
248+ }
249+
250+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
251+ t.Fatal("first tab-switch key press should be consumed")
252+ }
253+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
254+ t.Fatal("repeated held tab-switch key press should still be consumed")
255+ }
256+ if calls != 1 {
257+ t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
258+ }
259+
260+ h.handleKeyRelease(uint('x'))
261+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
262+ t.Fatal("tab-switch key press after release should be consumed")
263+ }
264+ if calls != 2 {
265+ t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
266+ }
267+}
268+
269+func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
270+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
271+ const resizeKeycode = 27
272+ mods := gdk.ControlMaskValue | gdk.AltMaskValue
273+
274+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
275+ t.Fatal("first resize-mode activation should be consumed")
276+ }
277+ if got := h.Mode(); got != ModeResize {
278+ t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
279+ }
280+
281+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
282+ t.Fatal("repeated held resize-mode activation should be consumed")
283+ }
284+ if got := h.Mode(); got != ModeResize {
285+ t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
286+ }
287+
288+ h.handleKeyRelease(uint('x'))
289+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
290+ t.Fatal("resize-mode activation after unrelated release should still be consumed")
291+ }
292+ if got := h.Mode(); got != ModeResize {
293+ t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
294+ }
295+
296+ h.handleKeyRelease(uint('r'))
297+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
298+ t.Fatal("resize-mode activation after release should be consumed")
299+ }
300+ if got := h.Mode(); got != ModeNormal {
301+ t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
302+ }
303+}
304+
305+func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
306+ workspace := newTestWorkspace()
307+ workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
308+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
309+ }
310+ h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
311+ calls := 0
312+ h.SetOnAction(func(_ context.Context, action Action) error {
313+ calls++
314+ if action != ActionToggleFloatingPane {
315+ t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
316+ }
317+ return nil
318+ })
319+
320+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
321+ t.Fatal("first floating pane toggle should be consumed")
322+ }
323+ h.handleKeyRelease(uint('x'))
324+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
325+ t.Fatal("held floating pane toggle repeat should still be consumed")
326+ }
327+ if calls != 1 {
328+ t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
329+ }
330+
331+ h.handleKeyRelease(uint('f'))
332+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
333+ t.Fatal("floating pane toggle after release should be consumed")
334+ }
335+ if calls != 2 {
336+ t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
337+ }
338+}
339+
340+func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
341+ if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
342+ t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
343+ }
344+}
@@ -230,3 +230,115 @@ func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
230 result := h.handleKeyPress(uint('z'), 0, 0)230 result := h.handleKeyPress(uint('z'), 0, 0)
231 assert.False(t, result, "unregistered key in ModeNormal returns false")231 assert.False(t, result, "unregistered key in ModeNormal returns false")
232 }232 }
233+
234+func TestHandleKeyPress_SuppressesHeldHardwareFallbackTabSwitchUntilRelease(t *testing.T) {
235+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
236+ calls := 0
237+ h.SetOnAction(func(_ context.Context, action Action) error {
238+ calls++
239+ if action != ActionSwitchTabIndex2 {
240+ t.Fatalf("action = %s, want %s", action, ActionSwitchTabIndex2)
241+ }
242+ return nil
243+ })
244+
245+ const fallbackKeycode = 11
246+ if got := KeycodeToTabAction[fallbackKeycode]; got != ActionSwitchTabIndex2 {
247+ t.Fatalf("KeycodeToTabAction[%d] = %s, want %s", fallbackKeycode, got, ActionSwitchTabIndex2)
248+ }
249+
250+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
251+ t.Fatal("first tab-switch key press should be consumed")
252+ }
253+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
254+ t.Fatal("repeated held tab-switch key press should still be consumed")
255+ }
256+ if calls != 1 {
257+ t.Fatalf("tab switch handler calls while key held = %d, want 1", calls)
258+ }
259+
260+ h.handleKeyRelease(uint('x'))
261+ if !h.handleKeyPress(uint('x'), fallbackKeycode, gdk.AltMaskValue) {
262+ t.Fatal("tab-switch key press after release should be consumed")
263+ }
264+ if calls != 2 {
265+ t.Fatalf("tab switch handler calls after release = %d, want 2", calls)
266+ }
267+}
268+
269+func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing.T) {
270+ h := NewKeyboardHandler(context.Background(), newTestWorkspace(), newTestSession())
271+ const resizeKeycode = 27
272+ mods := gdk.ControlMaskValue | gdk.AltMaskValue
273+
274+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
275+ t.Fatal("first resize-mode activation should be consumed")
276+ }
277+ if got := h.Mode(); got != ModeResize {
278+ t.Fatalf("mode after first press = %v, want %v", got, ModeResize)
279+ }
280+
281+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
282+ t.Fatal("repeated held resize-mode activation should be consumed")
283+ }
284+ if got := h.Mode(); got != ModeResize {
285+ t.Fatalf("mode after repeated held press = %v, want %v", got, ModeResize)
286+ }
287+
288+ h.handleKeyRelease(uint('x'))
289+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
290+ t.Fatal("resize-mode activation after unrelated release should still be consumed")
291+ }
292+ if got := h.Mode(); got != ModeResize {
293+ t.Fatalf("mode after unrelated release = %v, want %v", got, ModeResize)
294+ }
295+
296+ h.handleKeyRelease(uint('r'))
297+ if !h.handleKeyPress(uint('r'), resizeKeycode, mods) {
298+ t.Fatal("resize-mode activation after release should be consumed")
299+ }
300+ if got := h.Mode(); got != ModeNormal {
301+ t.Fatalf("mode after press following release = %v, want %v", got, ModeNormal)
302+ }
303+}
304+
305+func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
306+ workspace := newTestWorkspace()
307+ workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
308+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
309+ }
310+ h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
311+ calls := 0
312+ h.SetOnAction(func(_ context.Context, action Action) error {
313+ calls++
314+ if action != ActionToggleFloatingPane {
315+ t.Fatalf("action = %s, want %s", action, ActionToggleFloatingPane)
316+ }
317+ return nil
318+ })
319+
320+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
321+ t.Fatal("first floating pane toggle should be consumed")
322+ }
323+ h.handleKeyRelease(uint('x'))
324+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
325+ t.Fatal("held floating pane toggle repeat should still be consumed")
326+ }
327+ if calls != 1 {
328+ t.Fatalf("floating pane toggle calls while key held = %d, want 1", calls)
329+ }
330+
331+ h.handleKeyRelease(uint('f'))
332+ if !h.handleKeyPress(uint('f'), 41, gdk.AltMaskValue) {
333+ t.Fatal("floating pane toggle after release should be consumed")
334+ }
335+ if calls != 2 {
336+ t.Fatalf("floating pane toggle calls after release = %d, want 2", calls)
337+ }
338+}
339+
340+func TestIsRepeatedKeyboardActionSuppressed_AllowsResizeStepRepeats(t *testing.T) {
341+ if isRepeatedKeyboardActionSuppressed(ActionResizeIncreaseLeft) {
342+ t.Fatal("ActionResizeIncreaseLeft should not be suppressed")
343+ }
344+}

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