This commit is contained in:
jahruz67
2026-03-26 09:56:19 -07:00
commit 2d36041f6a
50 changed files with 6563 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
// Package hotkey provides global keyboard shortcut detection and handling.
// It uses the gohook library to capture system-wide key events.
package hotkey
import (
"sync"
"wis-free-v3/internal/logger"
hook "github.com/robotn/gohook"
)
// Listener handles global hotkey events and triggers callbacks when the
// configured shortcut is pressed and released.
type Listener struct {
startCallback func()
stopCallback func()
isListening bool
stopChan chan struct{}
triggerKeys []uint16
modifiers [][]uint16
mu sync.RWMutex
}
// NewListener creates a new hotkey listener with the specified shortcut and callbacks.
// The shortcut should be in format like "ctrl+k" or "alt+shift+space".
// onStart is called when the shortcut is pressed, onStop when it's released.
func NewListener(shortcut string, onStart, onStop func()) *Listener {
trigger, mods := ParseShortcut(shortcut)
logger.Info("Hotkey listener created: shortcut=%s, trigger=%d, modifiers=%v",
shortcut, trigger, mods)
return &Listener{
startCallback: onStart,
stopCallback: onStop,
stopChan: make(chan struct{}),
triggerKeys: trigger,
modifiers: mods,
}
}
// UpdateShortcut changes the shortcut without stopping the listener.
// This allows hot-swapping the shortcut while the application is running.
func (l *Listener) UpdateShortcut(shortcut string) {
trigger, mods := ParseShortcut(shortcut)
l.mu.Lock()
l.triggerKeys = trigger
l.modifiers = mods
l.mu.Unlock()
logger.Info("Hotkey updated: shortcut=%s, trigger=%d", shortcut, trigger)
}
// Start begins listening for the configured shortcut in a background goroutine.
// It's safe to call Start multiple times; subsequent calls are ignored.
func (l *Listener) Start() {
if l.isListening {
return
}
l.isListening = true
go l.eventLoop()
}
// Stop terminates the hotkey listener.
// It's safe to call Stop multiple times.
func (l *Listener) Stop() {
if !l.isListening {
return
}
l.isListening = false
close(l.stopChan)
logger.Info("Hotkey listener stopped")
}
// eventLoop runs the main keyboard event processing loop.
func (l *Listener) eventLoop() {
logger.Info("Hotkey listener started")
evChan := hook.Start()
defer hook.End()
var isRecording bool
pressedKeys := make(map[uint16]bool)
for {
select {
case <-l.stopChan:
return
case ev := <-evChan:
l.handleKeyEvent(ev, pressedKeys, &isRecording)
}
}
}
// handleKeyEvent processes a single keyboard event.
func (l *Listener) handleKeyEvent(ev hook.Event, pressedKeys map[uint16]bool, isRecording *bool) {
// Update pressed keys state
switch ev.Kind {
case hook.KeyDown, hook.KeyHold:
pressedKeys[ev.Rawcode] = true
case hook.KeyUp:
delete(pressedKeys, ev.Rawcode)
default:
return
}
// Read current configuration
l.mu.RLock()
triggerVariants := l.triggerKeys
modGroups := l.modifiers
l.mu.RUnlock()
if len(triggerVariants) == 0 {
return
}
// 1. Check trigger key first (fast path)
triggerPressed := false
for _, t := range triggerVariants {
if pressedKeys[t] {
triggerPressed = true
break
}
}
// 2. State transition handling
active := false
if triggerPressed {
active = true
for _, group := range modGroups {
groupPressed := false
for _, m := range group {
if pressedKeys[m] {
groupPressed = true
break
}
}
if !groupPressed {
active = false
break
}
}
}
if active && !*isRecording {
logger.Info("Shortcut activated: starting recording")
l.startCallback()
*isRecording = true
} else if !active && *isRecording {
logger.Info("Shortcut released: stopping recording")
l.stopCallback()
*isRecording = false
}
}
+112
View File
@@ -0,0 +1,112 @@
// Package hotkey provides global keyboard shortcut detection and handling.
package hotkey
import "strings"
// Windows Virtual Key Codes
// See: https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes
const (
VK_LCTRL = 162 // Left Control
VK_RCTRL = 163 // Right Control
VK_LSHIFT = 160 // Left Shift
VK_RSHIFT = 161 // Right Shift
VK_LALT = 164 // Left Alt (Menu)
VK_RALT = 165 // Right Alt (Menu)
VK_LWIN = 91 // Left Windows key
VK_RWIN = 92 // Right Windows key
)
// keyMap maps human-readable key names to Windows Virtual Key Codes.
// Each key may have multiple valid codes (e.g., left and right variants).
var keyMap = map[string][]uint16{
// Modifier keys
"ctrl": {VK_LCTRL, VK_RCTRL, 17},
"control": {VK_LCTRL, VK_RCTRL, 17},
"shift": {VK_LSHIFT, VK_RSHIFT, 16},
"alt": {VK_LALT, VK_RALT, 18},
"win": {VK_LWIN, VK_RWIN, 91, 92},
"windows": {VK_LWIN, VK_RWIN, 91, 92},
"meta": {VK_LWIN, VK_RWIN, 91, 92},
"super": {VK_LWIN, VK_RWIN, 91, 92},
// Special keys
"backspace": {8},
"tab": {9},
"enter": {13},
"escape": {27},
"esc": {27},
"space": {32},
"left": {37},
"up": {38},
"right": {39},
"down": {40},
"delete": {46},
"del": {46},
// Number keys (top row)
"0": {48}, "1": {49}, "2": {50}, "3": {51}, "4": {52},
"5": {53}, "6": {54}, "7": {55}, "8": {56}, "9": {57},
// Letter keys
"a": {65}, "b": {66}, "c": {67}, "d": {68}, "e": {69},
"f": {70}, "g": {71}, "h": {72}, "i": {73}, "j": {74},
"k": {75}, "l": {76}, "m": {77}, "n": {78}, "o": {79},
"p": {80}, "q": {81}, "r": {82}, "s": {83}, "t": {84},
"u": {85}, "v": {86}, "w": {87}, "x": {88}, "y": {89},
"z": {90},
// Function keys
"f1": {112}, "f2": {113}, "f3": {114}, "f4": {115},
"f5": {116}, "f6": {117}, "f7": {118}, "f8": {119},
"f9": {120}, "f10": {121}, "f11": {122}, "f12": {123},
}
// modifierKeys identifies which key names are modifiers.
var modifierKeys = map[string]bool{
"ctrl": true, "control": true,
"shift": true,
"alt": true,
"win": true, "windows": true, "meta": true, "super": true,
}
// ParseShortcut parses a shortcut string into a trigger key and modifier keys.
// The shortcut format is "modifier+modifier+key" (e.g., "ctrl+shift+k").
//
// Returns:
// - trigger: the primary key code that activates the shortcut
// - modifiers: slice of modifier key code variants that must be held
func ParseShortcut(shortcut string) (trigger []uint16, modifiers [][]uint16) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(shortcut)), "+")
if len(parts) == 0 {
return nil, nil
}
// Last part is always the trigger key
triggerName := strings.TrimSpace(parts[len(parts)-1])
if codes, ok := keyMap[triggerName]; ok && len(codes) > 0 {
trigger = codes
}
// Preceding parts are modifiers
for i := 0; i < len(parts)-1; i++ {
modName := strings.TrimSpace(parts[i])
if codes, ok := keyMap[modName]; ok {
modifiers = append(modifiers, codes)
}
}
return trigger, modifiers
}
// IsModifier returns true if the given key name is a modifier key.
func IsModifier(keyName string) bool {
return modifierKeys[strings.ToLower(keyName)]
}
// GetKeyCode returns the virtual key code(s) for a given key name.
// Returns nil if the key name is not recognized.
func GetKeyCode(keyName string) []uint16 {
return keyMap[strings.ToLower(keyName)]
}