diff --git a/.gitignore b/.gitignore index 87e376b..0386572 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ test-data/eval-runs/ .superpowers/ .claude/settings.local.json frontend/test-results +.worktrees/ diff --git a/docs/superpowers/plans/2026-04-06-keyboard-hook-configurable-shortcuts.md b/docs/superpowers/plans/2026-04-06-keyboard-hook-configurable-shortcuts.md new file mode 100644 index 0000000..1b87956 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-keyboard-hook-configurable-shortcuts.md @@ -0,0 +1,1584 @@ +# Low-Level Keyboard Hook + Configurable Shortcuts — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `RegisterHotKey` with `WH_KEYBOARD_LL` low-level keyboard hook to support natural double-tap detection (hold modifier, tap trigger key twice) and configurable per-feature shortcuts. + +**Architecture:** The Windows shortcut service is rewritten to use `SetWindowsHookEx(WH_KEYBOARD_LL)` with internal modifier tracking and a `SetTimer`-based double-tap state machine. A key combo parser converts string formats like `"ctrl+g"` to modifier bitmask + virtual key code. Settings gain per-feature shortcut fields with double-tap/independent mode toggle. The `Detector` goroutine-based approach is removed — all detection is single-threaded on the hook's message-pump thread. + +**Tech Stack:** Go 1.26 (Win32 syscalls: user32.dll), Wails v3, Wire DI, Angular v21, PrimeNG v21, Vitest + +**Spec:** `docs/superpowers/specs/2026-04-06-keyboard-hook-configurable-shortcuts-design.md` + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `internal/features/shortcut/keycombo.go` | Parse key combo strings ↔ modifier bitmask + virtual key code | +| Create | `internal/features/shortcut/keycombo_test.go` | Unit tests for parser | +| Modify | `internal/features/shortcut/service.go` | Updated interface: `Register(ShortcutConfig)`, `UpdateConfig`, `ShortcutEvent.Action` | +| Rewrite | `internal/features/shortcut/service_windows.go` | `WH_KEYBOARD_LL` hook, modifier tracking, double-tap state machine | +| Modify | `internal/features/shortcut/service_linux.go` | Updated to match new interface | +| Delete | `internal/features/shortcut/detect.go` | No longer needed — detection in hook thread | +| Delete | `internal/features/shortcut/detect_test.go` | No longer needed | +| Modify | `internal/features/settings/model.go` | Add `ShortcutMode`, `ShortcutFix`, `ShortcutPyramidize`, `ShortcutDoubleTapDelay` | +| Modify | `internal/app/wire.go` | Update shortcut provider to pass config | +| Modify | `main.go` | Simplify shortcut goroutine, remove Detector + atomic, update event names | +| Modify | `frontend/src/app/core/wails.service.ts` | Rename `shortcutSingle$` → `shortcutFix$`, `shortcutDouble$` → `shortcutPyramidize$` | +| Modify | `frontend/src/testing/wails-mock.ts` | Update mock subjects | +| Modify | `frontend/src/app/core/message-bus.service.ts` | Update event type union | +| Modify | `frontend/src/app/features/fix/fix.component.ts` | Subscribe to `shortcutFix$` | +| Modify | `frontend/src/app/features/fix/fix.component.spec.ts` | Update tests | +| Modify | `frontend/src/app/features/text-enhancement/text-enhancement.component.ts` | Subscribe to `shortcutPyramidize$` | +| Modify | `frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts` | Update tests | +| Modify | `frontend/src/app/layout/shell.component.ts` | Subscribe to `shortcutPyramidize$` | +| Modify | `frontend/src/app/layout/shell.component.spec.ts` | Update tests | +| Create | `frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.ts` | Reusable key combo recorder | +| Create | `frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts` | Tests | +| Modify | `frontend/src/app/features/settings/settings.component.ts` | Replace shortcut_key input with shortcuts section | +| Modify | `frontend/src/app/features/settings/settings.component.spec.ts` | Tests for shortcuts section | + +--- + +### Task 1: Key combo parser — test and implementation + +**Files:** +- Create: `internal/features/shortcut/keycombo.go` +- Create: `internal/features/shortcut/keycombo_test.go` + +A pure Go utility that parses `"ctrl+g"` → `KeyCombo{Modifiers: ModCtrl, VK: 0x47}` and formats back to display string `"Ctrl + G"`. Also maps key names to Windows virtual key codes. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/features/shortcut/keycombo_test.go`: + +```go +package shortcut + +import "testing" + +func TestParseKeyCombo_CtrlG(t *testing.T) { + kc, err := ParseKeyCombo("ctrl+g") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != ModCtrl { + t.Fatalf("expected ModCtrl (%d), got %d", ModCtrl, kc.Modifiers) + } + if kc.VK != 0x47 { + t.Fatalf("expected VK 0x47, got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_CtrlShiftE(t *testing.T) { + kc, err := ParseKeyCombo("ctrl+shift+e") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != ModCtrl|ModShift { + t.Fatalf("expected ModCtrl|ModShift (%d), got %d", ModCtrl|ModShift, kc.Modifiers) + } + if kc.VK != 0x45 { + t.Fatalf("expected VK 0x45 (E), got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_StandaloneF8(t *testing.T) { + kc, err := ParseKeyCombo("f8") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != 0 { + t.Fatalf("expected no modifiers, got %d", kc.Modifiers) + } + if kc.VK != 0x77 { + t.Fatalf("expected VK 0x77 (F8), got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_TripleModifier(t *testing.T) { + kc, err := ParseKeyCombo("ctrl+shift+alt+k") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != ModCtrl|ModShift|ModAlt { + t.Fatalf("expected all three modifiers, got %d", kc.Modifiers) + } + if kc.VK != 0x4B { + t.Fatalf("expected VK 0x4B (K), got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_Invalid(t *testing.T) { + _, err := ParseKeyCombo("") + if err == nil { + t.Fatal("expected error for empty string") + } + _, err = ParseKeyCombo("ctrl+") + if err == nil { + t.Fatal("expected error for trailing +") + } + _, err = ParseKeyCombo("ctrl+shift") + if err == nil { + t.Fatal("expected error for modifiers-only combo") + } + _, err = ParseKeyCombo("ctrl+unknownkey") + if err == nil { + t.Fatal("expected error for unknown key name") + } +} + +func TestKeyCombo_DisplayString(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"ctrl+g", "Ctrl + G"}, + {"ctrl+shift+e", "Ctrl + Shift + E"}, + {"f8", "F8"}, + {"alt+f4", "Alt + F4"}, + } + for _, tt := range tests { + kc, err := ParseKeyCombo(tt.input) + if err != nil { + t.Fatalf("parse %q: %v", tt.input, err) + } + got := kc.DisplayString() + if got != tt.expected { + t.Errorf("DisplayString(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestKeyCombo_String(t *testing.T) { + kc, _ := ParseKeyCombo("ctrl+shift+g") + got := kc.String() + if got != "ctrl+shift+g" { + t.Errorf("String() = %q, want %q", got, "ctrl+shift+g") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/features/shortcut/ -run TestParseKeyCombo -v` +Expected: compilation error — `ParseKeyCombo`, `KeyCombo`, `ModCtrl` not defined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/features/shortcut/keycombo.go`: + +```go +package shortcut + +import ( + "fmt" + "strings" +) + +// Modifier bitmask flags matching Win32 modifier virtual key codes. +type Modifier uint8 + +const ( + ModCtrl Modifier = 1 << iota // VK_CONTROL (0x11) + ModShift // VK_SHIFT (0x10) + ModAlt // VK_MENU (0x12) + ModWin // VK_LWIN (0x5B) +) + +// KeyCombo represents a parsed keyboard shortcut (modifier bitmask + trigger key). +type KeyCombo struct { + Modifiers Modifier + VK uint16 // Windows virtual key code + KeyName string // lowercase key name, e.g. "g", "f8" +} + +// modifierNames maps string names to modifier flags. +var modifierNames = map[string]Modifier{ + "ctrl": ModCtrl, + "shift": ModShift, + "alt": ModAlt, + "win": ModWin, +} + +// modifierDisplay is the display-order list of modifiers. +var modifierDisplay = []struct { + Flag Modifier + Name string +}{ + {ModCtrl, "Ctrl"}, + {ModShift, "Shift"}, + {ModAlt, "Alt"}, + {ModWin, "Win"}, +} + +// keyNames maps lowercase key names to Windows virtual key codes. +var keyNames = map[string]uint16{ + // Letters A-Z + "a": 0x41, "b": 0x42, "c": 0x43, "d": 0x44, "e": 0x45, + "f": 0x46, "g": 0x47, "h": 0x48, "i": 0x49, "j": 0x4A, + "k": 0x4B, "l": 0x4C, "m": 0x4D, "n": 0x4E, "o": 0x4F, + "p": 0x50, "q": 0x51, "r": 0x52, "s": 0x53, "t": 0x54, + "u": 0x55, "v": 0x56, "w": 0x57, "x": 0x58, "y": 0x59, "z": 0x5A, + // Digits 0-9 + "0": 0x30, "1": 0x31, "2": 0x32, "3": 0x33, "4": 0x34, + "5": 0x35, "6": 0x36, "7": 0x37, "8": 0x38, "9": 0x39, + // Function keys F1-F12 + "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, + "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77, + "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B, + // Punctuation / special + ";": 0xBA, "=": 0xBB, ",": 0xBC, "-": 0xBD, ".": 0xBE, + "/": 0xBF, "`": 0xC0, "[": 0xDB, "\\": 0xDC, "]": 0xDD, "'": 0xDE, + "space": 0x20, "enter": 0x0D, "tab": 0x09, "escape": 0x1B, + "backspace": 0x08, "delete": 0x2E, "insert": 0x2D, + "home": 0x24, "end": 0x23, "pageup": 0x21, "pagedown": 0x22, + "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27, +} + +// vkToName is the reverse map, built on init. +var vkToName map[uint16]string + +func init() { + vkToName = make(map[uint16]string, len(keyNames)) + for name, vk := range keyNames { + // Prefer shorter name if duplicate VK (shouldn't happen but defensive). + if existing, ok := vkToName[vk]; !ok || len(name) < len(existing) { + vkToName[vk] = name + } + } +} + +// ParseKeyCombo parses a shortcut string like "ctrl+g" or "f8" into a KeyCombo. +func ParseKeyCombo(s string) (KeyCombo, error) { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return KeyCombo{}, fmt.Errorf("empty key combo") + } + parts := strings.Split(s, "+") + var mods Modifier + var triggerKey string + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return KeyCombo{}, fmt.Errorf("empty part in key combo %q", s) + } + if mod, ok := modifierNames[part]; ok { + mods |= mod + } else { + if triggerKey != "" { + return KeyCombo{}, fmt.Errorf("multiple trigger keys in %q: %q and %q", s, triggerKey, part) + } + triggerKey = part + } + } + + if triggerKey == "" { + return KeyCombo{}, fmt.Errorf("no trigger key in %q (modifiers only)", s) + } + + vk, ok := keyNames[triggerKey] + if !ok { + return KeyCombo{}, fmt.Errorf("unknown key name %q", triggerKey) + } + + return KeyCombo{Modifiers: mods, VK: vk, KeyName: triggerKey}, nil +} + +// DisplayString returns a human-readable representation like "Ctrl + Shift + G". +func (kc KeyCombo) DisplayString() string { + var parts []string + for _, md := range modifierDisplay { + if kc.Modifiers&md.Flag != 0 { + parts = append(parts, md.Name) + } + } + parts = append(parts, strings.ToUpper(kc.KeyName)) + return strings.Join(parts, " + ") +} + +// String returns the canonical lowercase format like "ctrl+shift+g". +func (kc KeyCombo) String() string { + var parts []string + for _, md := range modifierDisplay { + if kc.Modifiers&md.Flag != 0 { + parts = append(parts, strings.ToLower(md.Name)) + } + } + parts = append(parts, kc.KeyName) + return strings.Join(parts, "+") +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/features/shortcut/ -run "TestParseKeyCombo|TestKeyCombo" -v` +Expected: all 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/features/shortcut/keycombo.go internal/features/shortcut/keycombo_test.go +git commit -m "feat(shortcut): add key combo parser with VK code mapping (#30)" +``` + +--- + +### Task 2: Update service interface, event model, and settings model + +**Files:** +- Modify: `internal/features/shortcut/service.go` +- Modify: `internal/features/settings/model.go` + +- [ ] **Step 1: Update ShortcutEvent and Service interface** + +Replace `internal/features/shortcut/service.go` entirely: + +```go +package shortcut + +import "time" + +// ShortcutEvent carries the payload emitted when a shortcut fires. +type ShortcutEvent struct { + Source string // "hotkey" | "simulate" + Action string // "fix" | "pyramidize" +} + +// ShortcutConfig holds the configuration for shortcut detection. +type ShortcutConfig struct { + Mode string // "double_tap" | "independent" + FixCombo string // e.g. "ctrl+g" + PyramidizeCombo string // e.g. "ctrl+shift+g" + DoubleTapDelay time.Duration // e.g. 200ms +} + +// Service is the platform-agnostic interface for global shortcut handling. +// Platform-specific implementations are in service_windows.go / service_linux.go. +type Service interface { + // Register activates the global shortcut listener with the given configuration. + Register(cfg ShortcutConfig) error + // Unregister deactivates the listener. + Unregister() + // Triggered returns a channel that receives an event each time a shortcut fires. + Triggered() <-chan ShortcutEvent + // UpdateConfig hot-reloads the shortcut configuration without restarting the app. + UpdateConfig(cfg ShortcutConfig) error +} +``` + +- [ ] **Step 2: Update Settings model** + +In `internal/features/settings/model.go`, add the new shortcut fields to the `Settings` struct (after `ShortcutKey`): + +```go + ShortcutKey string `json:"shortcut_key"` // LEGACY — migrated to ShortcutFix on load + ShortcutMode string `json:"shortcut_mode"` // "double_tap" | "independent" + ShortcutFix string `json:"shortcut_fix"` // e.g. "ctrl+g" + ShortcutPyramidize string `json:"shortcut_pyramidize"` // e.g. "ctrl+shift+g" (independent mode only) + ShortcutDoubleTapDelay int `json:"shortcut_double_tap_delay"` // ms, 100-500, default 200 +``` + +Update `Default()` to include the new fields: + +```go +func Default() Settings { + return Settings{ + ActiveProvider: "openai", + ShortcutKey: "ctrl+g", + ShortcutMode: "double_tap", + ShortcutFix: "ctrl+g", + ShortcutPyramidize: "ctrl+shift+g", + ShortcutDoubleTapDelay: 200, + ThemePreference: "dark", + LogLevel: "off", + PyramidizeQualityThreshold: DefaultQualityThreshold, + } +} +``` + +- [ ] **Step 3: Add settings migration for legacy shortcut_key** + +In `internal/features/settings/service.go`, add migration logic after the existing `debug_logging` migration (around line 82). When loading settings, if `shortcut_fix` is empty but `shortcut_key` has a value, derive the new fields: + +```go + // Migrate legacy shortcut_key → shortcut_fix + defaults. + if s.current.ShortcutFix == "" && s.current.ShortcutKey != "" { + s.current.ShortcutFix = s.current.ShortcutKey + s.current.ShortcutMode = "double_tap" + s.current.ShortcutPyramidize = "ctrl+shift+g" + s.current.ShortcutDoubleTapDelay = 200 + logger.Info("settings: migrated shortcut_key to shortcut_fix", "key", s.current.ShortcutFix) + } +``` + +- [ ] **Step 4: Verify compilation** + +Run: `go build -o bin/KeyLint .` +Expected: compilation errors — `service_windows.go` and `service_linux.go` don't match new interface yet. That's expected — fixed in Tasks 3 and 4. + +Run: `go test ./internal/features/shortcut/ -run "TestParseKeyCombo|TestKeyCombo" -v` +Expected: parser tests still pass (they don't depend on the interface). + +- [ ] **Step 5: Commit** + +```bash +git add internal/features/shortcut/service.go internal/features/settings/model.go internal/features/settings/service.go +git commit -m "feat(shortcut): update service interface and settings model for configurable shortcuts (#30)" +``` + +--- + +### Task 3: Update Linux stub for new interface + +**Files:** +- Modify: `internal/features/shortcut/service_linux.go` + +- [ ] **Step 1: Update the Linux stub** + +Replace `internal/features/shortcut/service_linux.go` entirely: + +```go +//go:build !windows + +package shortcut + +import "keylint/internal/logger" + +// linuxService is a no-op shortcut service for Linux. +// On Linux, shortcuts are simulated via --simulate-shortcut CLI flag or the +// dev-tools UI button, which manually sends on the channel. +type linuxService struct { + ch chan ShortcutEvent +} + +// NewPlatformService returns the Linux stub implementation. +func NewPlatformService() Service { + return &linuxService{ + ch: make(chan ShortcutEvent, 1), + } +} + +func (s *linuxService) Register(cfg ShortcutConfig) error { + logger.Info("shortcut: register (no-op on Linux)", "fix", cfg.FixCombo) + return nil +} +func (s *linuxService) Unregister() {} +func (s *linuxService) Triggered() <-chan ShortcutEvent { return s.ch } +func (s *linuxService) UpdateConfig(cfg ShortcutConfig) error { + logger.Info("shortcut: config updated (no-op on Linux)", "fix", cfg.FixCombo) + return nil +} + +// Simulate fires a synthetic shortcut event (used by --simulate-shortcut and dev UI). +func (s *linuxService) Simulate() { + s.ch <- ShortcutEvent{Source: "simulate", Action: "fix"} +} +``` + +- [ ] **Step 2: Verify compilation on Linux** + +Run: `go build -o bin/KeyLint .` +Expected: still fails — `main.go` calls `Register()` without config arg and references `Detector`. Fixed in Task 5. + +Run: `go vet ./internal/features/shortcut/` +Expected: pass (checks the stub compiles in isolation). + +- [ ] **Step 3: Commit** + +```bash +git add internal/features/shortcut/service_linux.go +git commit -m "feat(shortcut): update Linux stub for new service interface (#30)" +``` + +--- + +### Task 4: Windows low-level keyboard hook implementation + +**Files:** +- Rewrite: `internal/features/shortcut/service_windows.go` + +This is the core of the feature. Replaces `RegisterHotKey` with `WH_KEYBOARD_LL`. The hook callback tracks modifier state, matches key combos, and runs the double-tap state machine. All state is single-threaded (hook thread = message-pump thread). + +- [ ] **Step 1: Write the new Windows service** + +Replace `internal/features/shortcut/service_windows.go` entirely: + +```go +//go:build windows + +package shortcut + +import ( + "fmt" + "runtime" + "sync" + "syscall" + "time" + "unsafe" + + "keylint/internal/logger" +) + +const ( + whKeyboardLL = 13 + wmKeyDown = 0x0100 + wmKeyUp = 0x0101 + wmSysKeyDown = 0x0104 + wmSysKeyUp = 0x0105 + wmTimer = 0x0113 + + vkLControl = 0xA2 + vkRControl = 0xA3 + vkLShift = 0xA0 + vkRShift = 0xA1 + vkLMenu = 0xA4 // Left Alt + vkRMenu = 0xA5 // Right Alt + vkLWin = 0x5B + vkRWin = 0x5C + + // Tag for self-generated input (CopyFromForeground sends Ctrl+C). + // Checked in the hook to avoid intercepting our own synthetic keypresses. + extraInfoTag = 0x4B4C // "KL" in hex +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + setWindowsHookEx = user32.NewProc("SetWindowsHookExW") + unhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx") + callNextHookEx = user32.NewProc("CallNextHookEx") + getMessage = user32.NewProc("GetMessageW") + setTimer = user32.NewProc("SetTimer") + killTimer = user32.NewProc("KillTimer") + postThreadMessage = user32.NewProc("PostThreadMessageW") + getThreadId = syscall.NewLazyDLL("kernel32.dll").NewProc("GetCurrentThreadId") +) + +// kbdLLHookStruct mirrors the Win32 KBDLLHOOKSTRUCT. +type kbdLLHookStruct struct { + VKCode uint32 + ScanCode uint32 + Flags uint32 + Time uint32 + DwExtraInfo uintptr +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt [2]int32 +} + +// doubleTapTimerID is the SetTimer ID for the double-tap detection window. +const doubleTapTimerID = 1 + +// wmApp is the base for custom messages posted to the message loop. +const wmApp = 0x8000 + +const ( + wmAction = wmApp + 1 // WParam: 0=fix, 1=pyramidize +) + +type windowsService struct { + ch chan ShortcutEvent + hookH uintptr // hook handle + threadID uint32 + + // Configuration — guarded by mu for hot-reload from UpdateConfig. + mu sync.Mutex + mode string + fixCombo KeyCombo + pyramidizeCombo KeyCombo + doubleTapDelay uint32 // milliseconds + + // Double-tap state (only accessed on the hook thread — no lock needed). + waiting bool // true = first tap received, waiting for second or timeout + mods Modifier // currently held modifier keys +} + +// NewPlatformService returns the Windows WH_KEYBOARD_LL implementation. +func NewPlatformService() Service { + return &windowsService{ch: make(chan ShortcutEvent, 2)} +} + +func (s *windowsService) Register(cfg ShortcutConfig) error { + if err := s.applyConfig(cfg); err != nil { + return err + } + + ready := make(chan error, 1) + go func() { + runtime.LockOSThread() + + tid, _, _ := getThreadId.Call() + s.threadID = uint32(tid) + + hookProc := syscall.NewCallback(s.hookCallback) + h, _, err := setWindowsHookEx.Call(whKeyboardLL, hookProc, 0, 0) + if h == 0 { + logger.Error("shortcut: SetWindowsHookEx failed", "err", err) + ready <- fmt.Errorf("SetWindowsHookEx failed: %w", err) + return + } + s.hookH = h + logger.Info("shortcut: WH_KEYBOARD_LL hook installed") + ready <- nil + + s.messageLoop() + }() + return <-ready +} + +func (s *windowsService) Unregister() { + if s.hookH != 0 { + unhookWindowsHookEx.Call(s.hookH) + s.hookH = 0 + logger.Info("shortcut: hook uninstalled") + } +} + +func (s *windowsService) Triggered() <-chan ShortcutEvent { return s.ch } + +func (s *windowsService) UpdateConfig(cfg ShortcutConfig) error { + if err := s.applyConfig(cfg); err != nil { + return err + } + // Reset any in-progress double-tap detection. + if s.threadID != 0 { + killTimer.Call(0, doubleTapTimerID) + } + logger.Info("shortcut: config updated", "mode", cfg.Mode, "fix", cfg.FixCombo) + return nil +} + +func (s *windowsService) applyConfig(cfg ShortcutConfig) error { + fixKC, err := ParseKeyCombo(cfg.FixCombo) + if err != nil { + return fmt.Errorf("invalid fix combo %q: %w", cfg.FixCombo, err) + } + + var pyrKC KeyCombo + if cfg.Mode == "independent" { + pyrKC, err = ParseKeyCombo(cfg.PyramidizeCombo) + if err != nil { + return fmt.Errorf("invalid pyramidize combo %q: %w", cfg.PyramidizeCombo, err) + } + } + + delay := uint32(cfg.DoubleTapDelay / time.Millisecond) + if delay < 100 { + delay = 100 + } + if delay > 500 { + delay = 500 + } + + s.mu.Lock() + s.mode = cfg.Mode + s.fixCombo = fixKC + s.pyramidizeCombo = pyrKC + s.doubleTapDelay = delay + s.mu.Unlock() + + return nil +} + +// hookCallback is called by Windows for every keyboard event system-wide. +// It must return quickly. Returning 1 suppresses the key; returning CallNextHookEx passes it. +func (s *windowsService) hookCallback(nCode int, wParam uintptr, lParam uintptr) uintptr { + if nCode < 0 { + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + kb := (*kbdLLHookStruct)(unsafe.Pointer(lParam)) + + // Pass through our own synthetic keypresses (from CopyFromForeground / PasteToForeground). + if kb.DwExtraInfo == extraInfoTag { + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + vk := uint16(kb.VKCode) + isDown := wParam == wmKeyDown || wParam == wmSysKeyDown + + // Track modifier state. + if mod := vkToModifier(vk); mod != 0 { + if isDown { + s.mods |= mod + } else { + s.mods &^= mod + // If modifier released while waiting for double-tap, fire fix immediately. + if s.waiting { + s.waiting = false + killTimer.Call(0, doubleTapTimerID) + s.postAction(0) // fix + } + } + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + if !isDown { + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + // Read current config. + s.mu.Lock() + mode := s.mode + fixKC := s.fixCombo + pyrKC := s.pyramidizeCombo + delay := s.doubleTapDelay + s.mu.Unlock() + + if mode == "independent" { + // Independent mode: check both combos, fire immediately. + if vk == fixKC.VK && s.mods == fixKC.Modifiers { + s.postAction(0) // fix + return 1 // suppress + } + if vk == pyrKC.VK && s.mods == pyrKC.Modifiers { + s.postAction(1) // pyramidize + return 1 // suppress + } + } else { + // Double-tap mode: match fix combo's trigger key + modifiers. + if vk == fixKC.VK && s.mods == fixKC.Modifiers { + if s.waiting { + // Second tap within window → pyramidize. + s.waiting = false + killTimer.Call(0, doubleTapTimerID) + s.postAction(1) // pyramidize + } else { + // First tap → start timer. + s.waiting = true + setTimer.Call(0, doubleTapTimerID, uintptr(delay), 0) + } + return 1 // suppress + } + } + + // Not a match — pass through. + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret +} + +func (s *windowsService) postAction(action uintptr) { + postThreadMessage.Call(uintptr(s.threadID), wmAction, action, 0) +} + +func (s *windowsService) messageLoop() { + logger.Info("shortcut: message loop started") + var m msg + for { + ret, _, _ := getMessage.Call(uintptr(unsafe.Pointer(&m)), 0, 0, 0) + if ret == 0 { + break + } + switch m.Message { + case wmTimer: + // Double-tap timer expired → fix. + if s.waiting { + s.waiting = false + killTimer.Call(0, doubleTapTimerID) + s.postAction(0) // fix + } + case wmAction: + action := "fix" + if m.WParam == 1 { + action = "pyramidize" + } + logger.Info("shortcut: action detected", "action", action) + s.ch <- ShortcutEvent{Source: "hotkey", Action: action} + } + } +} + +// Simulate fires a synthetic shortcut event (used by --simulate-shortcut and dev UI). +func (s *windowsService) Simulate() { + s.ch <- ShortcutEvent{Source: "simulate", Action: "fix"} +} + +// vkToModifier maps virtual key codes to modifier flags. +func vkToModifier(vk uint16) Modifier { + switch vk { + case vkLControl, vkRControl: + return ModCtrl + case vkLShift, vkRShift: + return ModShift + case vkLMenu, vkRMenu: + return ModAlt + case vkLWin, vkRWin: + return ModWin + default: + return 0 + } +} +``` + +- [ ] **Step 2: Tag CopyFromForeground with extraInfoTag** + +In `internal/features/clipboard/paste_windows.go`, update the `pasteInput` structs in `CopyFromForeground()` and `PasteToForeground()` to set `dwExtraInfo` to the tag so the keyboard hook passes them through. + +In `CopyFromForeground()` (around line 48), change the inputs array: + +```go + inputs := [4]pasteInput{ + {inputType: inputKeyboard, wVk: vkControl, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkC, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkC, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkControl, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + } +``` + +In `PasteToForeground()` (around line 74), same pattern: + +```go + inputs := [4]pasteInput{ + {inputType: inputKeyboard, wVk: vkControl, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkV, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkV, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkControl, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + } +``` + +- [ ] **Step 3: Verify it compiles (on Linux, cross-check vet)** + +Run: `go vet ./internal/features/shortcut/` +Run: `go vet ./internal/features/clipboard/` +Expected: both pass. Full build still fails due to main.go — fixed in Task 5. + +- [ ] **Step 4: Commit** + +```bash +git add internal/features/shortcut/service_windows.go internal/features/clipboard/paste_windows.go +git commit -m "feat(shortcut): replace RegisterHotKey with WH_KEYBOARD_LL low-level hook (#30)" +``` + +--- + +### Task 5: Simplify main.go — remove Detector, update event names + +**Files:** +- Modify: `main.go` + +Remove the Detector, the atomic.Bool, and the two goroutines. Replace with a single goroutine that reads classified events and emits Wails events. Pass config to `Register()`. + +- [ ] **Step 1: Update event registrations** + +Replace lines 35-36: + +```go + application.RegisterEvent[string]("shortcut:fix") + application.RegisterEvent[string]("shortcut:pyramidize") +``` + +- [ ] **Step 2: Update Register() call with config** + +Replace lines 123-130 (the Register block): + +```go + // Register the global shortcut (no-op on Linux). + shortcutCfg := shortcut.ShortcutConfig{ + Mode: cfg.ShortcutMode, + FixCombo: cfg.ShortcutFix, + PyramidizeCombo: cfg.ShortcutPyramidize, + DoubleTapDelay: time.Duration(cfg.ShortcutDoubleTapDelay) * time.Millisecond, + } + if err := services.Shortcut.Register(shortcutCfg); err != nil { + log.Printf("warn: shortcut registration failed: %v", err) + logger.Warn("shortcut: registration failed", "err", err) + } else { + logger.Info("shortcut: registered", "mode", cfg.ShortcutMode, "fix", cfg.ShortcutFix) + } + wailsApp.OnShutdown(func() { services.Shortcut.Unregister() }) +``` + +- [ ] **Step 3: Replace the entire shortcut goroutine section** + +Remove lines 133-171 (the Detector, atomic.Bool, both goroutines) and replace with: + +```go + // Forward classified shortcut events to the frontend. + go func() { + for event := range services.Shortcut.Triggered() { + logger.Info("shortcut: action", "action", event.Action, "source", event.Source) + pyramidizeSvc.CaptureSourceApp() + if err := services.Clipboard.CopyFromForeground(); err != nil { + logger.Warn("shortcut: CopyFromForeground failed", "err", err) + } + switch event.Action { + case "fix": + wailsApp.Event.Emit("shortcut:fix", event.Source) + case "pyramidize": + window.Show().Focus() + wailsApp.Event.Emit("shortcut:pyramidize", event.Source) + } + } + }() +``` + +- [ ] **Step 4: Clean up imports** + +Remove `"sync/atomic"` from imports (no longer used). Remove `"keylint/internal/features/shortcut"` only if the `shortcut.` prefix is no longer referenced — but it IS still used for `shortcut.ShortcutConfig`, so keep it. The `time` import is still used. + +- [ ] **Step 5: Verify build and Go tests** + +Run: `go build -o bin/KeyLint .` +Expected: compiles cleanly. + +Run: `go test ./internal/...` +Expected: all pass (detect_test.go still runs but will be removed in Task 6). + +- [ ] **Step 6: Commit** + +```bash +git add main.go +git commit -m "feat(shortcut): simplify main.go — remove Detector, use classified events (#30)" +``` + +--- + +### Task 6: Remove Detector (no longer needed) + +**Files:** +- Delete: `internal/features/shortcut/detect.go` +- Delete: `internal/features/shortcut/detect_test.go` + +- [ ] **Step 1: Delete the files** + +```bash +rm internal/features/shortcut/detect.go internal/features/shortcut/detect_test.go +``` + +- [ ] **Step 2: Verify build and tests** + +Run: `go build -o bin/KeyLint .` +Expected: compiles cleanly (nothing imports Detector anymore). + +Run: `go test ./internal/features/shortcut/ -v` +Expected: only keycombo tests run and pass. + +- [ ] **Step 3: Commit** + +```bash +git add -A internal/features/shortcut/detect.go internal/features/shortcut/detect_test.go +git commit -m "chore(shortcut): remove Detector — detection now in WH_KEYBOARD_LL hook (#30)" +``` + +--- + +### Task 7: Frontend — rename observables to shortcutFix$/shortcutPyramidize$ + +**Files:** +- Modify: `frontend/src/app/core/wails.service.ts` +- Modify: `frontend/src/testing/wails-mock.ts` +- Modify: `frontend/src/app/core/message-bus.service.ts` +- Modify: `frontend/src/app/features/fix/fix.component.ts:107` +- Modify: `frontend/src/app/features/fix/fix.component.spec.ts:113-126` +- Modify: `frontend/src/app/features/text-enhancement/text-enhancement.component.ts:1096` +- Modify: `frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts:253-331` +- Modify: `frontend/src/app/layout/shell.component.ts:118` +- Modify: `frontend/src/app/layout/shell.component.spec.ts:114-121` +- Modify: `frontend/src/app/core/wails.service.spec.ts` + +This is a mechanical global rename: `shortcutSingle` → `shortcutFix`, `shortcutDouble` → `shortcutPyramidize`, `shortcut:single` → `shortcut:fix`, `shortcut:double` → `shortcut:pyramidize`. + +- [ ] **Step 1: Update wails.service.ts** + +Replace all occurrences: +- `shortcutSingle` → `shortcutFix` (subjects and observables) +- `shortcutDouble` → `shortcutPyramidize` +- `'shortcut:single'` → `'shortcut:fix'` +- `'shortcut:double'` → `'shortcut:pyramidize'` +- Update JSDoc comments accordingly + +- [ ] **Step 2: Update wails-mock.ts** + +Replace all occurrences: +- `shortcutSingle$` → `shortcutFix$` +- `shortcutDouble$` → `shortcutPyramidize$` +- `_shortcutSingle$` → `_shortcutFix$` +- `_shortcutDouble$` → `_shortcutPyramidize$` + +- [ ] **Step 3: Update message-bus.service.ts** + +Replace the event type union: + +```typescript +export type BusEvent = + | { type: 'shortcut:fix'; source: string } + | { type: 'shortcut:pyramidize'; source: string } + | { type: 'enhancement:complete'; text: string } + | { type: 'enhancement:error'; message: string }; +``` + +- [ ] **Step 4: Update wails.service.spec.ts** + +Replace `'shortcut:single'` → `'shortcut:fix'` in the MessageBusService test. + +- [ ] **Step 5: Update fix.component.ts and spec** + +In `fix.component.ts:107`: +```typescript + this.sub = this.wails.shortcutFix$.subscribe(() => { +``` + +In `fix.component.spec.ts`: +- Test name: `'shortcutFix$ triggers fixClipboard'` +- Subject: `wailsMock._shortcutFix$.next('hotkey')` +- Unsubscribe test: `wailsMock._shortcutFix$.next('hotkey')` + +- [ ] **Step 6: Update text-enhancement.component.ts and spec** + +In `text-enhancement.component.ts:1096`: +```typescript + this.sub = this.wails.shortcutPyramidize$.subscribe(async () => { +``` + +In spec: all `shortcutDouble$` → `shortcutPyramidize$`, all `_shortcutDouble$` → `_shortcutPyramidize$`. + +- [ ] **Step 7: Update shell.component.ts and spec** + +In `shell.component.ts:118`: +```typescript + this.wails.shortcutPyramidize$.subscribe(() => { +``` + +In spec: `shortcutDouble$` → `shortcutPyramidize$`, `_shortcutDouble$` → `_shortcutPyramidize$`. + +- [ ] **Step 8: Run all frontend tests** + +Run: `cd frontend && npm test` +Expected: 132 tests, 0 failures. + +- [ ] **Step 9: Verify no stale references** + +Run: `grep -r "shortcutSingle\|shortcutDouble\|shortcut:single\|shortcut:double" frontend/src/ --include="*.ts"` +Expected: no results. + +- [ ] **Step 10: Commit** + +```bash +git add frontend/src/ +git commit -m "refactor(frontend): rename shortcut observables to shortcutFix$/shortcutPyramidize$ (#30)" +``` + +--- + +### Task 8: Frontend — update Settings model for new shortcut fields + +**Files:** +- Modify: `frontend/src/app/core/wails.service.ts` (BROWSER_MODE_DEFAULTS) +- Modify: `frontend/src/testing/wails-mock.ts` (defaultSettings) + +- [ ] **Step 1: Update BROWSER_MODE_DEFAULTS in wails.service.ts** + +Add the new fields (around line 30): + +```typescript +const BROWSER_MODE_DEFAULTS: Settings = { + active_provider: 'claude', + providers: { ollama_url: '', aws_region: '' }, + shortcut_key: 'ctrl+g', + shortcut_mode: 'double_tap', + shortcut_fix: 'ctrl+g', + shortcut_pyramidize: 'ctrl+shift+g', + shortcut_double_tap_delay: 200, + start_on_boot: false, + theme_preference: 'dark', + completed_setup: false, + log_level: 'off', + sensitive_logging: false, + update_channel: '', + app_presets: [], + pyramidize_quality_threshold: 0.65, +}; +``` + +- [ ] **Step 2: Update defaultSettings in wails-mock.ts** + +Add the same fields to the mock defaults: + +```typescript +export const defaultSettings: Settings = { + active_provider: 'openai', + providers: { ollama_url: '', aws_region: '' }, + shortcut_key: 'ctrl+g', + shortcut_mode: 'double_tap', + shortcut_fix: 'ctrl+g', + shortcut_pyramidize: 'ctrl+shift+g', + shortcut_double_tap_delay: 200, + start_on_boot: false, + theme_preference: 'dark', + completed_setup: false, + log_level: 'off', + sensitive_logging: false, + update_channel: '', + app_presets: [], + pyramidize_quality_threshold: 0.65, +}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cd frontend && npm test` +Expected: all pass (new fields don't break anything — they're just data). + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/app/core/wails.service.ts frontend/src/testing/wails-mock.ts +git commit -m "feat(settings): add shortcut configuration fields to frontend defaults (#30)" +``` + +--- + +### Task 9: Frontend — shortcut recorder component + +**Files:** +- Create: `frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.ts` +- Create: `frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts` + +A reusable component that captures keyboard shortcuts. Shows the current combo formatted (e.g., "Ctrl + G"). Clicking "Record..." enters capture mode, next key combo is captured and emitted, Escape cancels. + +- [ ] **Step 1: Write the failing tests** + +Create `frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts`: + +```typescript +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { ShortcutRecorderComponent } from './shortcut-recorder.component'; + +describe('ShortcutRecorderComponent', () => { + let fixture: ComponentFixture; + let component: ShortcutRecorderComponent; + let el: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ShortcutRecorderComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ShortcutRecorderComponent); + component = fixture.componentInstance; + component.value = 'ctrl+g'; + fixture.detectChanges(); + await fixture.whenStable(); + el = fixture.nativeElement; + }); + + it('renders the formatted key combo', () => { + const display = el.querySelector('[data-testid="combo-display"]'); + expect(display?.textContent?.trim()).toBe('Ctrl + G'); + }); + + it('shows Record button', () => { + expect(el.querySelector('[data-testid="record-btn"]')).toBeTruthy(); + }); + + it('enters recording mode on Record click', () => { + el.querySelector('[data-testid="record-btn"]')?.click(); + fixture.detectChanges(); + expect(component.recording).toBe(true); + expect(el.querySelector('[data-testid="recording-indicator"]')).toBeTruthy(); + }); + + it('exits recording mode on Escape', () => { + component.recording = true; + fixture.detectChanges(); + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + el.querySelector('[data-testid="recorder-field"]')?.dispatchEvent(event); + fixture.detectChanges(); + expect(component.recording).toBe(false); + }); + + it('captures a key combo and emits valueChange', () => { + const spy = vi.fn(); + component.valueChange.subscribe(spy); + component.recording = true; + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, shiftKey: false, altKey: false, metaKey: false }); + el.querySelector('[data-testid="recorder-field"]')?.dispatchEvent(event); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith('ctrl+k'); + expect(component.recording).toBe(false); + }); + + it('ignores modifier-only keypresses during recording', () => { + const spy = vi.fn(); + component.valueChange.subscribe(spy); + component.recording = true; + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', { key: 'Control', ctrlKey: true }); + el.querySelector('[data-testid="recorder-field"]')?.dispatchEvent(event); + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + expect(component.recording).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Write the component** + +Create `frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.ts`: + +```typescript +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ButtonModule } from 'primeng/button'; + +const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +const KEY_TO_NAME: Record = { + ' ': 'space', 'Enter': 'enter', 'Tab': 'tab', 'Escape': 'escape', + 'Backspace': 'backspace', 'Delete': 'delete', 'Insert': 'insert', + 'Home': 'home', 'End': 'end', 'PageUp': 'pageup', 'PageDown': 'pagedown', + 'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right', +}; + +function formatCombo(combo: string): string { + if (!combo) return ''; + const parts = combo.split('+'); + return parts.map(p => { + if (p === 'ctrl') return 'Ctrl'; + if (p === 'shift') return 'Shift'; + if (p === 'alt') return 'Alt'; + if (p === 'win') return 'Win'; + return p.length === 1 ? p.toUpperCase() : p.charAt(0).toUpperCase() + p.slice(1).toUpperCase(); + }).join(' + '); +} + +@Component({ + selector: 'app-shortcut-recorder', + standalone: true, + imports: [CommonModule, ButtonModule], + template: ` +
+ @if (recording) { + Press a key combo... + } @else { + {{ displayValue }} + } + +
+ `, + styles: [` + .recorder-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--p-content-border-color); + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: var(--p-content-hover-background); + min-height: 2.5rem; + outline: none; + } + .recorder-wrapper.recording { + border-color: var(--p-primary-color); + box-shadow: 0 0 0 1px var(--p-primary-color); + } + .combo-text { + font-family: monospace; + font-size: 0.9rem; + color: var(--p-text-color); + } + .recording-text { + font-size: 0.85rem; + color: var(--p-text-muted-color); + animation: pulse 1.5s ease-in-out infinite; + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + `], +}) +export class ShortcutRecorderComponent { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + recording = false; + + get displayValue(): string { + return formatCombo(this.value); + } + + toggleRecording(): void { + this.recording = !this.recording; + } + + onKeyDown(event: KeyboardEvent): void { + if (!this.recording) return; + + event.preventDefault(); + event.stopPropagation(); + + if (event.key === 'Escape') { + this.recording = false; + return; + } + + // Ignore modifier-only presses — wait for a trigger key. + if (MODIFIER_KEYS.has(event.key)) return; + + const parts: string[] = []; + if (event.ctrlKey) parts.push('ctrl'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + if (event.metaKey) parts.push('win'); + + // Map the key to our canonical name. + let keyName = KEY_TO_NAME[event.key] ?? event.key.toLowerCase(); + // Function keys come as "F1", "F12" etc. + if (/^f\d{1,2}$/i.test(event.key)) { + keyName = event.key.toLowerCase(); + } + + parts.push(keyName); + const combo = parts.join('+'); + + this.value = combo; + this.valueChange.emit(combo); + this.recording = false; + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `cd frontend && npx vitest run src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts` +Expected: all 6 tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/app/features/settings/shortcut-recorder/ +git commit -m "feat(settings): add shortcut recorder component (#30)" +``` + +--- + +### Task 10: Frontend — settings UI shortcuts section + +**Files:** +- Modify: `frontend/src/app/features/settings/settings.component.ts` + +Replace the existing shortcut_key text input (line 59-62) with the expanded shortcuts section. + +- [ ] **Step 1: Add ShortcutRecorderComponent to imports** + +In the `@Component` decorator's `imports` array, add `ShortcutRecorderComponent`: + +```typescript +import { ShortcutRecorderComponent } from './shortcut-recorder/shortcut-recorder.component'; +``` + +And add `ShortcutRecorderComponent` and `SliderModule` to the `imports` array. Also add: + +```typescript +import { SliderModule } from 'primeng/slider'; +``` + +- [ ] **Step 2: Replace the shortcut form group in the template** + +Replace the shortcut_key form group (lines 59-62): + +```html +
+ + +
+``` + +With: + +```html + +
+
+
+ + + @if (settings.shortcut_mode === 'double_tap') { + Hold your modifier keys, tap the trigger key once for Fix, twice for Pyramidize. + } @else { + Assign separate shortcuts for each action. + } + +
+ +
+
+ + @if (settings.shortcut_mode === 'double_tap') { +
+ + + Single tap → Fix · Double tap → Pyramidize +
+
+ + + How long to wait for a second tap. Lower = faster but harder to trigger. +
+ } @else { +
+ + + Silently fixes clipboard text. +
+
+ + + Opens the Pyramidize editor with clipboard text. +
+ } +``` + +- [ ] **Step 3: Run tests** + +Run: `cd frontend && npm test` +Expected: all tests pass. The settings spec should continue working — the shortcut_key field is removed from the template but the old tests that reference `[data-testid="shortcut-input"]` need updating. + +- [ ] **Step 4: Update settings test for new shortcut section** + +In `settings.component.spec.ts`, replace any test referencing `data-testid="shortcut-input"` with: + +```typescript + it('renders shortcut mode toggle in general tab', async () => { + const fixture = await createAndWait(); + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelector('[data-testid="shortcut-mode-section"]')).toBeTruthy(); + expect(el.querySelector('[data-testid="shortcut-fix-section"]')).toBeTruthy(); + }); +``` + +- [ ] **Step 5: Run tests again** + +Run: `cd frontend && npm test` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/app/features/settings/ +git commit -m "feat(settings): add configurable shortcuts UI with mode toggle and recorder (#30)" +``` + +--- + +### Task 11: Hot-reload — notify backend when shortcuts change + +**Files:** +- Modify: `main.go` + +When settings are saved and shortcut config changed, call `UpdateConfig` on the shortcut service so the hook reloads without app restart. + +- [ ] **Step 1: Add settings change listener** + +After the shortcut registration block in `main.go`, add a listener for settings changes. Subscribe to the `settings:changed` Wails event and call `UpdateConfig`: + +```go + // Hot-reload shortcuts when settings change. + wailsApp.Event.On("settings:changed", func(ev *application.CustomEvent) { + newCfg := services.Settings.Get() + newShortcutCfg := shortcut.ShortcutConfig{ + Mode: newCfg.ShortcutMode, + FixCombo: newCfg.ShortcutFix, + PyramidizeCombo: newCfg.ShortcutPyramidize, + DoubleTapDelay: time.Duration(newCfg.ShortcutDoubleTapDelay) * time.Millisecond, + } + if err := services.Shortcut.UpdateConfig(newShortcutCfg); err != nil { + logger.Warn("shortcut: hot-reload failed", "err", err) + } + }) +``` + +- [ ] **Step 2: Verify build** + +Run: `go build -o bin/KeyLint .` +Expected: compiles cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add main.go +git commit -m "feat(shortcut): hot-reload shortcuts on settings change (#30)" +``` + +--- + +### Task 12: Full test suite verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run all frontend tests** + +Run: `cd frontend && npm test` +Expected: 0 failures. + +- [ ] **Step 2: Run all Go tests** + +Run: `go test ./internal/...` +Expected: all pass, keycombo tests pass, no detect tests (removed). + +- [ ] **Step 3: Verify full build** + +Run: `go build -o bin/KeyLint .` +Expected: compiles cleanly. + +- [ ] **Step 4: Search for stale references** + +Run: `grep -r "shortcutSingle\|shortcutDouble\|shortcut:single\|shortcut:double\|shortcutTriggered\|shortcut:triggered" frontend/src/ --include="*.ts"` +Expected: no results. + +Run: `grep -r "RegisterHotKey\|UnregisterHotKey" internal/ --include="*.go"` +Expected: no results (fully replaced with WH_KEYBOARD_LL). + +Run: `grep -r "detect\.go\|Detector\|PressResult" internal/ --include="*.go"` +Expected: no results (fully removed). + +- [ ] **Step 5: Verify detect.go is gone** + +Run: `ls internal/features/shortcut/detect*.go 2>/dev/null` +Expected: no output (files deleted). + +- [ ] **Step 6: Commit any fixups if needed** diff --git a/docs/superpowers/plans/2026-04-06-shortcut-double-press.md b/docs/superpowers/plans/2026-04-06-shortcut-double-press.md new file mode 100644 index 0000000..2f975cc --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-shortcut-double-press.md @@ -0,0 +1,715 @@ +# Shortcut Double-Press Detection — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add single/double press detection to the Ctrl+G hotkey so single press always triggers silent fix and double press opens Pyramidize UI. + +**Architecture:** Go-side state machine in `main.go` buffers hotkey events with a 200ms timer. Emits `shortcut:single` or `shortcut:double` as distinct Wails events. Frontend subscribes to the appropriate event per component. The old undifferentiated `shortcut:triggered` event is removed. + +**Tech Stack:** Go 1.26, Wails v3, Angular v21, RxJS, Vitest + +**Spec:** `docs/superpowers/specs/2026-04-06-shortcut-double-press-design.md` + +--- + +## File Map + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `main.go:34,131-147` | Register new events, replace shortcut goroutine with state machine | +| Modify | `frontend/src/app/core/wails.service.ts:39-44,52-61,242-246` | Replace `shortcutTriggered$` with `shortcutSingle$` and `shortcutDouble$` | +| Modify | `frontend/src/testing/wails-mock.ts:34-42` | Replace mock subjects to match new observables | +| Modify | `frontend/src/app/features/fix/fix.component.ts:107` | Subscribe to `shortcutSingle$` | +| Modify | `frontend/src/app/features/text-enhancement/text-enhancement.component.ts:1096` | Subscribe to `shortcutDouble$` | +| Modify | `frontend/src/app/layout/shell.component.ts:102,113-117,153-155` | Add `shortcutDouble$` subscription for navigation to `/enhance` | +| Modify | `frontend/src/app/features/fix/fix.component.spec.ts:113-126` | Update tests for `shortcutSingle$` | +| Modify | `frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts:253-331` | Update tests for `shortcutDouble$` | +| Modify | `frontend/src/app/layout/shell.component.spec.ts` | Add test for navigate on `shortcutDouble$` | +| Create | `internal/features/shortcut/detect.go` | Standalone double-press detector (testable without Wails) | +| Create | `internal/features/shortcut/detect_test.go` | Unit tests for detector state machine | + +--- + +### Task 1: Go double-press detector — test and implementation + +**Files:** +- Create: `internal/features/shortcut/detect.go` +- Create: `internal/features/shortcut/detect_test.go` + +The detector is a standalone struct with no Wails dependency. It receives raw press events and emits classified results (`Single` or `Double`) via a channel. This makes it fully testable with fake time. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/features/shortcut/detect_test.go`: + +```go +package shortcut + +import ( + "testing" + "time" +) + +func TestDetector_SinglePress(t *testing.T) { + d := NewDetector(200 * time.Millisecond) + defer d.Stop() + + d.Press() + + select { + case r := <-d.Result(): + if r != Single { + t.Fatalf("expected Single, got %v", r) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for result") + } +} + +func TestDetector_DoublePress(t *testing.T) { + d := NewDetector(200 * time.Millisecond) + defer d.Stop() + + d.Press() + d.Press() // within threshold + + select { + case r := <-d.Result(): + if r != Double { + t.Fatalf("expected Double, got %v", r) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for result") + } +} + +func TestDetector_SlowDoublePress(t *testing.T) { + d := NewDetector(200 * time.Millisecond) + defer d.Stop() + + d.Press() + // Wait for first single to fire + r1 := <-d.Result() + if r1 != Single { + t.Fatalf("expected first Single, got %v", r1) + } + + d.Press() + r2 := <-d.Result() + if r2 != Single { + t.Fatalf("expected second Single, got %v", r2) + } +} + +func TestDetector_TriplePress(t *testing.T) { + d := NewDetector(200 * time.Millisecond) + defer d.Stop() + + d.Press() + d.Press() + d.Press() // third press starts new cycle + + // First result: Double from presses 1+2 + r1 := <-d.Result() + if r1 != Double { + t.Fatalf("expected Double, got %v", r1) + } + + // Second result: Single from press 3 (after timeout) + select { + case r2 := <-d.Result(): + if r2 != Single { + t.Fatalf("expected Single, got %v", r2) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for second result") + } +} + +func TestDetector_Stop(t *testing.T) { + d := NewDetector(200 * time.Millisecond) + d.Stop() + + // Result channel should be closed after Stop + _, ok := <-d.Result() + if ok { + t.Fatal("expected result channel to be closed") + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/features/shortcut/ -run TestDetector -v` +Expected: compilation error — `NewDetector`, `Single`, `Double` not defined. + +- [ ] **Step 3: Write the detector implementation** + +Create `internal/features/shortcut/detect.go`: + +```go +package shortcut + +import "time" + +// PressResult classifies a hotkey press as single or double. +type PressResult int + +const ( + Single PressResult = iota + Double +) + +// Detector implements double-press detection with a configurable threshold. +// It receives raw press events via Press() and emits classified results on Result(). +type Detector struct { + threshold time.Duration + presses chan struct{} + results chan PressResult + done chan struct{} +} + +// NewDetector creates and starts a detector goroutine. +func NewDetector(threshold time.Duration) *Detector { + d := &Detector{ + threshold: threshold, + presses: make(chan struct{}), + results: make(chan PressResult, 2), + done: make(chan struct{}), + } + go d.loop() + return d +} + +// Press records a hotkey press. Non-blocking (channel send). +func (d *Detector) Press() { + select { + case d.presses <- struct{}{}: + case <-d.done: + } +} + +// Result returns the channel that receives classified press results. +func (d *Detector) Result() <-chan PressResult { + return d.results +} + +// Stop shuts down the detector goroutine and closes the result channel. +func (d *Detector) Stop() { + select { + case <-d.done: + default: + close(d.done) + } +} + +func (d *Detector) loop() { + defer close(d.results) + + var timer *time.Timer + var timerC <-chan time.Time + + for { + select { + case <-d.done: + if timer != nil { + timer.Stop() + } + return + + case <-d.presses: + if timer != nil { + // Second press within window → double + timer.Stop() + timer = nil + timerC = nil + d.results <- Double + } else { + // First press → start detection window + timer = time.NewTimer(d.threshold) + timerC = timer.C + } + + case <-timerC: + // Timer expired → single press + timer = nil + timerC = nil + d.results <- Single + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/features/shortcut/ -run TestDetector -v` +Expected: all 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/features/shortcut/detect.go internal/features/shortcut/detect_test.go +git commit -m "feat(shortcut): add double-press detector with state machine (#30)" +``` + +--- + +### Task 2: Wire detector into `main.go` shortcut goroutine + +**Files:** +- Modify: `main.go:34,131-147` + +Replace the event registration and the shortcut goroutine with the state machine that uses the detector, emits distinct events, and focuses the window on double press. + +- [ ] **Step 1: Update event registrations** + +In `main.go`, replace line 34: + +```go +// Before: +application.RegisterEvent[string]("shortcut:triggered") + +// After: +application.RegisterEvent[string]("shortcut:single") +application.RegisterEvent[string]("shortcut:double") +``` + +- [ ] **Step 2: Replace the shortcut goroutine** + +Replace `main.go:131-147` (the entire `go func()` block and its preceding comments) with: + +```go + // Double-press detection: single press → silent fix, double press → show Pyramidize UI. + // Clipboard is captured on the first press (while source app still has focus). + // The detector classifies presses and emits Single/Double results. + detector := shortcut.NewDetector(200 * time.Millisecond) + wailsApp.OnShutdown(func() { detector.Stop() }) + + // Feed raw hotkey events into the detector; capture clipboard on each first press. + go func() { + ch := services.Shortcut.Triggered() + for event := range ch { + logger.Info("shortcut: triggered", "source", event.Source) + pyramidizeSvc.CaptureSourceApp() + if err := services.Clipboard.CopyFromForeground(); err != nil { + logger.Warn("shortcut: CopyFromForeground failed", "err", err) + } + detector.Press() + } + }() + + // Consume classified results and emit the appropriate Wails event. + go func() { + for result := range detector.Result() { + switch result { + case shortcut.Single: + logger.Info("shortcut: single press detected") + wailsApp.Event.Emit("shortcut:single", "hotkey") + case shortcut.Double: + logger.Info("shortcut: double press detected") + window.Show().Focus() + wailsApp.Event.Emit("shortcut:double", "hotkey") + } + } + }() +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `go build -o bin/KeyLint .` +Expected: compiles cleanly with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add main.go +git commit -m "feat(shortcut): wire double-press detector into main goroutine (#30)" +``` + +--- + +### Task 3: Update `WailsService` — replace `shortcutTriggered$` with `shortcutSingle$` / `shortcutDouble$` + +**Files:** +- Modify: `frontend/src/app/core/wails.service.ts:39-44,52-61,242-246` + +- [ ] **Step 1: Replace subjects and observables** + +In `wails.service.ts`, replace lines 39-44: + +```typescript +// Before: + private readonly shortcutTriggered = new Subject(); + ... + readonly shortcutTriggered$: Observable = this.shortcutTriggered.asObservable(); + +// After: + private readonly shortcutSingle = new Subject(); + private readonly shortcutDouble = new Subject(); + ... + /** Emits on single press of global shortcut (silent fix). */ + readonly shortcutSingle$: Observable = this.shortcutSingle.asObservable(); + /** Emits on double press of global shortcut (open Pyramidize UI). */ + readonly shortcutDouble$: Observable = this.shortcutDouble.asObservable(); +``` + +- [ ] **Step 2: Update `listenToEvents()`** + +Replace the `shortcut:triggered` listener (lines 54-56) with: + +```typescript + Events.On('shortcut:single', (ev) => { + this.shortcutSingle.next(ev.data as string); + }), + Events.On('shortcut:double', (ev) => { + this.shortcutDouble.next(ev.data as string); + }), +``` + +- [ ] **Step 3: Update `ngOnDestroy()`** + +Replace `this.shortcutTriggered.complete()` (line 244) with: + +```typescript + this.shortcutSingle.complete(); + this.shortcutDouble.complete(); +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/app/core/wails.service.ts +git commit -m "feat(shortcut): split shortcutTriggered$ into shortcutSingle$ and shortcutDouble$ (#30)" +``` + +--- + +### Task 4: Update wails mock + +**Files:** +- Modify: `frontend/src/testing/wails-mock.ts:34-42` + +- [ ] **Step 1: Replace mock subjects** + +In `wails-mock.ts`, replace lines 34-42: + +```typescript +// Before: + const shortcutTriggered$ = new Subject(); + ... + shortcutTriggered$: shortcutTriggered$.asObservable(), + ... + _shortcutTriggered$: shortcutTriggered$, + +// After: + const shortcutSingle$ = new Subject(); + const shortcutDouble$ = new Subject(); + ... + shortcutSingle$: shortcutSingle$.asObservable(), + shortcutDouble$: shortcutDouble$.asObservable(), + ... + _shortcutSingle$: shortcutSingle$, + _shortcutDouble$: shortcutDouble$, +``` + +The full return block (lines 37-42) becomes: + +```typescript + return { + shortcutSingle$: shortcutSingle$.asObservable(), + shortcutDouble$: shortcutDouble$.asObservable(), + settingsChanged$: settingsChanged$.asObservable(), + // Expose subjects so tests can trigger events + _shortcutSingle$: shortcutSingle$, + _shortcutDouble$: shortcutDouble$, + _settingsChanged$: settingsChanged$, + ... +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/testing/wails-mock.ts +git commit -m "refactor(test): update wails mock for shortcutSingle$/shortcutDouble$ (#30)" +``` + +--- + +### Task 5: Update `FixComponent` — subscribe to `shortcutSingle$` + +**Files:** +- Modify: `frontend/src/app/features/fix/fix.component.ts:107` +- Modify: `frontend/src/app/features/fix/fix.component.spec.ts:113-126` + +- [ ] **Step 1: Update the test first** + +In `fix.component.spec.ts`, replace lines 113-126: + +```typescript + it('shortcutSingle$ triggers fixClipboard', async () => { + wailsMock.readClipboard.mockResolvedValue('shortcut clipboard'); + wailsMock._shortcutSingle$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + expect(wailsMock.readClipboard).toHaveBeenCalled(); + expect(enhanceSpy).toHaveBeenCalledWith('shortcut clipboard'); + }); + + it('ngOnDestroy unsubscribes from shortcut events', async () => { + component.ngOnDestroy(); + wailsMock._shortcutSingle$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + expect(wailsMock.readClipboard).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx vitest run src/app/features/fix/fix.component.spec.ts` +Expected: FAIL — `_shortcutSingle$` does not exist on the mock (fixed in Task 4), or `shortcutSingle$` not on `WailsService` (fixed in Task 3). If Tasks 3 and 4 are done, the test fails because `fix.component.ts` still subscribes to the old `shortcutTriggered$`. + +- [ ] **Step 3: Update the component** + +In `fix.component.ts`, change line 107: + +```typescript +// Before: + this.sub = this.wails.shortcutTriggered$.subscribe(() => { + +// After: + this.sub = this.wails.shortcutSingle$.subscribe(() => { +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/app/features/fix/fix.component.spec.ts` +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/features/fix/fix.component.ts frontend/src/app/features/fix/fix.component.spec.ts +git commit -m "feat(fix): subscribe to shortcutSingle$ for silent fix (#30)" +``` + +--- + +### Task 6: Update `TextEnhancementComponent` — subscribe to `shortcutDouble$` + +**Files:** +- Modify: `frontend/src/app/features/text-enhancement/text-enhancement.component.ts:1096` +- Modify: `frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts:253-331` + +- [ ] **Step 1: Update the tests first** + +In `text-enhancement.component.spec.ts`, replace the shortcut test descriptions and subject references (lines 253-331). Every occurrence of `shortcutTriggered$` in test descriptions becomes `shortcutDouble$`, and every `_shortcutTriggered$` becomes `_shortcutDouble$`: + +```typescript + // ── 13. shortcutDouble$ with empty originalText sets originalText from clipboard ── + + it('shortcutDouble$ with empty originalText sets originalText from clipboard', async () => { + component.originalTextView = ''; + wailsMock.readClipboard.mockResolvedValue('clipboard hotkey content'); + wailsMock.getSourceApp.mockResolvedValue('TestApp'); + + wailsMock._shortcutDouble$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + + expect(wailsMock.readClipboard).toHaveBeenCalled(); + expect(component.originalTextView).toBe('clipboard hotkey content'); + }); + + // ── 14. shortcutDouble$ with existing originalText shows confirm dialog ── + + it('shortcutDouble$ with existing originalText shows confirm dialog', async () => { + component.originalTextView = 'existing content'; + wailsMock.readClipboard.mockResolvedValue('new clipboard content'); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + wailsMock._shortcutDouble$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + + expect(confirmSpy).toHaveBeenCalled(); + // Since user cancelled, originalText should remain unchanged + expect(component.originalTextView).toBe('existing content'); + }); + + it('shortcutDouble$ with existing originalText and confirm=true replaces content', async () => { + component.originalTextView = 'existing content'; + wailsMock.readClipboard.mockResolvedValue('new clipboard content'); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + wailsMock._shortcutDouble$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + + expect(component.originalTextView).toBe('new clipboard content'); + }); +``` + +And the `ngOnDestroy` test (around line 325): + +```typescript + it('ngOnDestroy unsubscribes from shortcut events', async () => { + component.ngOnDestroy(); + const prevReadCount = (wailsMock.readClipboard as ReturnType).mock.calls.length; + wailsMock._shortcutDouble$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + expect((wailsMock.readClipboard as ReturnType).mock.calls.length).toBe(prevReadCount); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd frontend && npx vitest run src/app/features/text-enhancement/text-enhancement.component.spec.ts` +Expected: FAIL — component still subscribes to old `shortcutTriggered$`. + +- [ ] **Step 3: Update the component** + +In `text-enhancement.component.ts`, change line 1096: + +```typescript +// Before: + this.sub = this.wails.shortcutTriggered$.subscribe(async () => { + +// After: + this.sub = this.wails.shortcutDouble$.subscribe(async () => { +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/app/features/text-enhancement/text-enhancement.component.spec.ts` +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/features/text-enhancement/text-enhancement.component.ts frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts +git commit -m "feat(enhance): subscribe to shortcutDouble$ for pyramidize UI (#30)" +``` + +--- + +### Task 7: Add navigation on double press to `ShellComponent` + +**Files:** +- Modify: `frontend/src/app/layout/shell.component.ts:102,113-117,153-155` +- Modify: `frontend/src/app/layout/shell.component.spec.ts` + +ShellComponent is always mounted, so it handles the "navigate to `/enhance` on double press" logic. + +- [ ] **Step 1: Write the failing test** + +Add to the end of `shell.component.spec.ts` (before the closing `});`): + +```typescript + it('navigates to /enhance on shortcutDouble$', async () => { + const fixture = await createAndWait('dark'); + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + wailsMock._shortcutDouble$.next('hotkey'); + await fixture.whenStable(); + + expect(navigateSpy).toHaveBeenCalledWith(['/enhance']); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/app/layout/shell.component.spec.ts` +Expected: FAIL — `_shortcutDouble$` does not exist (after Task 4 it exists, but ShellComponent doesn't subscribe to it yet, so navigate is never called). + +- [ ] **Step 3: Update the component** + +In `shell.component.ts`: + +1. Change the `sub` field to an array (line 102): + +```typescript +// Before: + private sub?: Subscription; + +// After: + private subs: Subscription[] = []; +``` + +2. Update `ngOnInit()` (lines 113-117): + +```typescript +// Before: + ngOnInit(): void { + void this.applyTheme(); + void this.loadVersionInfo(); + this.sub = this.wails.settingsChanged$.subscribe(() => void this.applyTheme()); + } + +// After: + ngOnInit(): void { + void this.applyTheme(); + void this.loadVersionInfo(); + this.subs.push( + this.wails.settingsChanged$.subscribe(() => void this.applyTheme()), + this.wails.shortcutDouble$.subscribe(() => { + void this.router.navigate(['/enhance']); + }), + ); + } +``` + +3. Update `ngOnDestroy()` (lines 153-155): + +```typescript +// Before: + ngOnDestroy(): void { + this.sub?.unsubscribe(); + } + +// After: + ngOnDestroy(): void { + this.subs.forEach(s => s.unsubscribe()); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd frontend && npx vitest run src/app/layout/shell.component.spec.ts` +Expected: all tests PASS (including the new navigation test). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/app/layout/shell.component.ts frontend/src/app/layout/shell.component.spec.ts +git commit -m "feat(shell): navigate to /enhance on shortcutDouble$ (#30)" +``` + +--- + +### Task 8: Full test suite verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run all frontend tests** + +Run: `cd frontend && npm test` +Expected: 0 failures. All tests pass — no leftover references to `shortcutTriggered$`. + +- [ ] **Step 2: Run all Go tests** + +Run: `go test ./internal/...` +Expected: all pass, including the new `TestDetector_*` tests. + +- [ ] **Step 3: Verify full build** + +Run: `go build -o bin/KeyLint .` +Expected: compiles cleanly. + +- [ ] **Step 4: Search for stale references** + +Run: `grep -r "shortcutTriggered" frontend/src/ --include="*.ts" -l` +Expected: no results. All references have been updated. + +Run: `grep -r "shortcut:triggered" . --include="*.go" --include="*.ts" -l` +Expected: no results (except possibly the design spec). + +- [ ] **Step 5: Commit any fixups if needed** + +If stale references were found, fix them and commit. diff --git a/docs/superpowers/specs/2026-04-06-keyboard-hook-configurable-shortcuts-design.md b/docs/superpowers/specs/2026-04-06-keyboard-hook-configurable-shortcuts-design.md new file mode 100644 index 0000000..da5d36f --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-keyboard-hook-configurable-shortcuts-design.md @@ -0,0 +1,210 @@ +# Low-Level Keyboard Hook + Configurable Shortcuts — Design Spec + +**Date:** 2026-04-06 +**Issue:** #30 — Shortcut flow broken on Windows (follow-up) +**Status:** Draft +**Supersedes:** `2026-04-06-shortcut-double-press-design.md` (partial — replaces backend input capture) + +## Problem + +The initial double-press detection implementation uses `RegisterHotKey` for input capture. `RegisterHotKey` fires `WM_HOTKEY` only when the full modifier+key combo is pressed from scratch. When the user holds Ctrl and taps G a second time, Windows does NOT fire a second `WM_HOTKEY` — instead the raw G keydown passes through to the foreground app, typing "g". This makes double-press detection unusable with natural hand gestures (hold Ctrl, tap G twice). + +Additionally, the shortcut is hardcoded to `Ctrl+G` with no user configuration. + +## Design + +### Win32: Replace `RegisterHotKey` with `WH_KEYBOARD_LL` + +Replace the entire `service_windows.go` implementation. Instead of `RegisterHotKey`, install a low-level keyboard hook via `SetWindowsHookEx(WH_KEYBOARD_LL, ...)`. + +**How it works:** + +- The hook callback receives every keydown/keyup event system-wide, before any app sees them. +- The service tracks modifier key state (Ctrl, Shift, Alt, Win) internally from keydown/keyup events. +- When a keydown matches a configured shortcut's trigger key and the active modifiers match, the service: + 1. Suppresses the keypress (returns 1 from the hook proc so the foreground app never sees it) + 2. Emits a `ShortcutEvent` on the channel with the matched action +- Non-matching keypresses pass through unchanged (return `CallNextHookEx`). + +**Modifier tracking:** + +The hook maintains a bitmask of currently-held modifier keys, updated on every keydown/keyup for VK_CONTROL, VK_SHIFT, VK_MENU (Alt), and VK_LWIN/VK_RWIN. This is necessary because `WH_KEYBOARD_LL` receives raw key events, not combo events. + +**Thread model:** + +`SetWindowsHookEx(WH_KEYBOARD_LL)` requires a message pump on the installing thread. The existing `runtime.LockOSThread()` + `GetMessageW` loop pattern is reused. The hook callback runs on the same thread. + +### Double-Tap Detection + +In double-tap mode, the trigger key (e.g., G) can be tapped once or twice while modifiers are held. + +**State machine (runs inside the hook callback thread):** + +``` +idle → [modifier+trigger keydown] → suppress key, capture clipboard, start timer → waiting +waiting → [same trigger keydown, modifiers still held] → suppress key, cancel timer → emit pyramidize → idle +waiting → [timer expires] → emit fix → idle +waiting → [modifier released] → cancel timer, emit fix immediately → idle +``` + +**Key behaviors:** + +- **First trigger keydown:** suppressed immediately (the foreground app never sees it), clipboard captured, 200ms timer starts. +- **Second trigger keydown (within window, modifiers still held):** suppressed, timer cancelled, `pyramidize` action emitted immediately. +- **Modifier released before timer expires:** timer cancelled, `fix` action emitted immediately. This feels natural — lifting off Ctrl means "I'm done." +- **Timer expires:** `fix` action emitted. +- **Non-trigger keys during the wait window:** pass through unchanged. The user can type other keys; only the configured trigger key is intercepted. + +Since the hook callback and timer run on the same OS thread (message-pump thread), there are no concurrency issues with the state machine — all state mutations are single-threaded. + +### Independent Mode + +When double-tap mode is disabled, two separate shortcut bindings are active. Each fires its action immediately on keydown — no timer, no state machine. The hook checks each keydown against both bindings and emits the matching action. + +### Shortcut Configuration + +**Settings schema additions:** + +```json +{ + "shortcut_mode": "double_tap", + "shortcut_fix": "ctrl+g", + "shortcut_pyramidize": "ctrl+shift+g", + "shortcut_double_tap_delay": 200 +} +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `shortcut_mode` | `"double_tap" \| "independent"` | `"double_tap"` | Detection mode | +| `shortcut_fix` | string | `"ctrl+g"` | Shortcut for silent fix. In double-tap mode, this is the base combo (single tap). | +| `shortcut_pyramidize` | string | `"ctrl+shift+g"` | Shortcut for pyramidize. Only used in independent mode. In double-tap mode, pyramidize is triggered by double-tapping the trigger key of `shortcut_fix`. | +| `shortcut_double_tap_delay` | int (ms) | `200` | Detection window. Range: 100–500. Only used in double-tap mode. | + +**Key format:** lowercase, `+`-separated. Modifiers: `ctrl`, `shift`, `alt`, `win`. Trigger: single key name (`g`, `f8`, `;`, etc.). Examples: `ctrl+g`, `ctrl+shift+e`, `f8`, `alt+k`. + +**Parsing:** A utility function parses the string into a struct with a modifier bitmask and a virtual key code. This is used both by the hook (to match keypresses) and by the settings UI (to display formatted key names). + +### ShortcutEvent Changes + +The `ShortcutEvent` struct gains an `Action` field: + +```go +type ShortcutEvent struct { + Source string // "hotkey" | "simulate" + Action string // "fix" | "pyramidize" +} +``` + +This replaces the current design where `main.go` emits `shortcut:single` and `shortcut:double` — instead the event carries the semantic action directly. The Wails events become `shortcut:fix` and `shortcut:pyramidize`. + +### `main.go` Changes + +The shortcut goroutine simplifies. The `Detector` from the current implementation is removed — double-tap detection now lives inside the hook's message-pump thread (single-threaded, no channels needed for timing). The goroutine just reads classified events from the channel and emits the appropriate Wails event: + +``` +go func() { + for event := range services.Shortcut.Triggered() { + switch event.Action { + case "fix": + emit("shortcut:fix", "hotkey") + case "pyramidize": + window.Show().Focus() + emit("shortcut:pyramidize", "hotkey") + } + } +}() +``` + +Clipboard capture (`CaptureSourceApp` + `CopyFromForeground`) moves into the shortcut service itself, called on the first trigger keydown before emitting the event. This keeps the timing tight — clipboard is captured at the OS hook level, not after a channel round-trip. + +### Service Interface Changes + +```go +type Service interface { + Register(cfg ShortcutConfig) error + Unregister() + Triggered() <-chan ShortcutEvent + UpdateConfig(cfg ShortcutConfig) error +} + +type ShortcutConfig struct { + Mode string // "double_tap" | "independent" + FixCombo string // e.g. "ctrl+g" + PyramidizeCombo string // e.g. "ctrl+shift+g" + DoubleTapDelay time.Duration // e.g. 200ms +} +``` + +`Register` now takes a config. `UpdateConfig` allows hot-reloading shortcuts when settings change (reinstalls the hook with new bindings — no app restart needed). + +### Frontend Changes + +**`wails.service.ts`:** Rename `shortcutSingle$` → `shortcutFix$`, `shortcutDouble$` → `shortcutPyramidize$`. These map to `shortcut:fix` and `shortcut:pyramidize` Wails events. + +**`wails-mock.ts`:** Update mock subjects to match. + +**`fix.component.ts`:** Subscribe to `shortcutFix$`. + +**`text-enhancement.component.ts`:** Subscribe to `shortcutPyramidize$`. + +**`shell.component.ts`:** Navigate to `/enhance` on `shortcutPyramidize$`. + +**`message-bus.service.ts`:** Update event type union. + +### Settings UI + +The existing `shortcut_key` text input in the General tab is replaced with an expanded shortcuts section. + +**Double-tap mode ON (default):** + +- Toggle row: "Double-tap mode" label + hint ("Hold your modifier keys, tap the trigger key once for Fix, twice for Pyramidize.") + toggle switch. +- Form group: "Shortcut" label + shortcut recorder field showing formatted key combo (e.g., "Ctrl + G") + "Record..." button. Below: hint "Single tap → Fix · Double tap → Pyramidize". +- Form group: "Double-tap delay" label + slider (100–500ms) showing live value. Below: hint "How long to wait for a second tap. Lower = faster but harder to trigger." + +**Double-tap mode OFF:** + +- Same toggle row, hint changes to "Assign separate shortcuts for each action." +- Form group: "Fix shortcut" + recorder + hint "Silently fixes clipboard text." +- Form group: "Pyramidize shortcut" + recorder + hint "Opens the Pyramidize editor with clipboard text." +- Delay slider hidden. + +**Shortcut recorder:** A compact input-like element. Click "Record..." → it enters capture mode (visual indicator, e.g., pulsing border), captures the next key combo, displays it formatted, exits capture mode. Pressing Escape cancels recording. + +The recorder is a reusable subcomponent: `shortcut-recorder/shortcut-recorder.component.ts` colocated under the settings folder. + +## Testing + +### Go Unit Tests + +- **Key format parser:** parse `"ctrl+g"` → modifiers + vkCode, roundtrip, edge cases (standalone `f8`, triple modifier `ctrl+shift+alt+k`). +- **Config validation:** reject invalid combos, empty strings, unknown key names. +- **Double-tap state machine:** not directly unit-testable since it lives in the hook callback (single-threaded, OS-level). Tested via integration/manual on Windows. + +### Frontend Vitest Tests + +- **Shortcut recorder component:** renders, enters/exits capture mode, displays formatted combo, cancel on Escape. +- **Settings shortcuts section:** toggle between modes, correct fields shown/hidden, slider range. +- **WailsService:** `shortcutFix$` and `shortcutPyramidize$` emit correctly. +- **Component subscriptions:** fix subscribes to `shortcutFix$`, text-enhancement to `shortcutPyramidize$`, shell navigates on `shortcutPyramidize$`. + +### Manual QA (Windows) + +- Hold Ctrl, tap G once → silent fix fires after delay. +- Hold Ctrl, tap G twice quickly → pyramidize opens. +- Hold Ctrl, tap G once, release Ctrl quickly → fix fires immediately (no delay). +- Switch to independent mode, verify both shortcuts fire independently. +- Record new shortcut combo in settings, verify it takes effect without restart. +- Verify suppressed keys don't leak to foreground app. + +## Acceptance Criteria + +- [ ] `RegisterHotKey` replaced with `WH_KEYBOARD_LL` low-level hook +- [ ] Double-tap mode: hold modifier + tap trigger once = fix, twice = pyramidize +- [ ] Modifier release during detection window cancels timer and fires fix immediately +- [ ] Independent mode: two separate shortcuts, no timer +- [ ] Settings UI with mode toggle, shortcut recorder, and delay slider +- [ ] Hot-reload: changing shortcuts in settings takes effect without app restart +- [ ] Suppressed keys never reach the foreground app +- [ ] All existing frontend tests updated and passing +- [ ] Shortcut recorder component with capture mode diff --git a/docs/superpowers/specs/2026-04-06-shortcut-double-press-design.md b/docs/superpowers/specs/2026-04-06-shortcut-double-press-design.md new file mode 100644 index 0000000..d858205 --- /dev/null +++ b/docs/superpowers/specs/2026-04-06-shortcut-double-press-design.md @@ -0,0 +1,129 @@ +# Shortcut Double-Press Detection — Design Spec + +**Date:** 2026-04-06 +**Issue:** #30 — Shortcut flow broken on Windows +**Status:** Draft + +## Problem + +Every Ctrl+G press emits the same `shortcut:triggered` event. There is no single vs double press detection. Both `FixComponent` and `TextEnhancementComponent` independently subscribe to this event, so behavior depends on which route is active rather than user intent. This causes: + +1. Pyramidize captures clipboard on single press if `/enhance` is the active route +2. Window doesn't focus on double press (no double press concept exists) +3. Silent fix leaks into Pyramidize state when the UI was previously open but hidden + +## Design + +### Go Backend: State Machine in `main.go` + +The shortcut goroutine becomes a state machine with a 200ms double-press detection window. + +**States:** + +``` +idle → [press] → waiting (start 200ms timer, capture clipboard immediately) +waiting → [press within 200ms] → double detected → focus window → emit shortcut:double → idle +waiting → [timer expires] → single detected → emit shortcut:single → idle +``` + +**Key behaviors:** + +- **Clipboard capture happens on the first press**, before the timer starts. This is critical because the source app still has focus at this point. `CaptureSourceApp()` and `CopyFromForeground()` run immediately. +- **The second press in a double-press is consumed.** It does not restart the timer or trigger a new cycle. +- **200ms threshold** matches the v1 Tauri implementation. This introduces a 200ms delay before single-press silent fix fires, which is imperceptible in the fix-and-paste-back flow. +- **Window focus on double press:** The Go handler calls `window.Show()` and brings the window to front before emitting `shortcut:double`. + +**Events emitted:** + +| Event | Payload | When | +|---|---|---| +| `shortcut:single` | source app name (string) | 200ms timer expires with no second press | +| `shortcut:double` | source app name (string) | Second press arrives within 200ms | + +The old `shortcut:triggered` event is removed entirely. + +### Frontend: Two Observables + +**`wails.service.ts`:** + +- Remove `shortcutTriggered` / `shortcutTriggered$` +- Add `shortcutSingle$`: Observable listening to `shortcut:single` +- Add `shortcutDouble$`: Observable listening to `shortcut:double` + +**`fix.component.ts`:** + +- Subscribe to `shortcutSingle$` instead of `shortcutTriggered$` +- No other logic changes needed + +**`text-enhancement.component.ts`:** + +- Subscribe to `shortcutDouble$` instead of `shortcutTriggered$` +- No other logic changes needed — it already loads clipboard and resets UI + +**`shell.component.ts`:** + +- Add subscription to `shortcutDouble$` that navigates to `/enhance` +- This lives in ShellComponent because it's always mounted regardless of the current route. TextEnhancementComponent may not be mounted when the double press arrives. + +### State Machine Implementation Detail + +The state machine lives in the shortcut goroutine in `main.go`. It uses a `time.Timer` for the detection window. + +``` +var pressTimer *time.Timer +var pendingSource string + +for { + select { + case event := <-shortcutCh: + if pressTimer != nil { + // Second press within window → double + pressTimer.Stop() + pressTimer = nil + focusWindow() + emit("shortcut:double", pendingSource) + } else { + // First press → capture clipboard, start timer + captureSourceApp() + copyFromForeground() + pendingSource = event.Source + pressTimer = time.AfterFunc(200ms, func() { + pressTimer = nil + emit("shortcut:single", pendingSource) + }) + } + } +} +``` + +This is pseudocode — the real implementation will handle the `AfterFunc` callback thread-safely since it runs on a separate goroutine. + +## Testing + +### Go Unit Tests + +Test the state machine logic directly (no real hotkeys needed): + +- **Single press:** send one event, verify `shortcut:single` emitted after ~200ms, `shortcut:double` not emitted +- **Double press:** send two events within 200ms, verify `shortcut:double` emitted immediately, `shortcut:single` not emitted +- **Triple press:** send three rapid events, verify double fires on second press, third press starts a new cycle +- **Slow double press:** send two events 300ms apart, verify two `shortcut:single` events (not a double) + +### Frontend Vitest Tests + +- **`WailsService`:** mock Wails events for `shortcut:single` and `shortcut:double`, verify correct observables emit +- **`FixComponent`:** verify subscribes to `shortcutSingle$` only, calls `fixClipboard()` on emit +- **`TextEnhancementComponent`:** verify subscribes to `shortcutDouble$` only, loads clipboard on emit +- **`ShellComponent`:** verify navigates to `/enhance` on `shortcutDouble$` + +### Not Tested + +- E2E: global hotkeys cannot be triggered from Playwright +- Real timing on Windows: relies on manual QA + +## Acceptance Criteria (from #30) + +- [ ] Single Ctrl+G press always triggers silent fix, regardless of active route or window visibility +- [ ] Double Ctrl+G press (within ~200ms) opens Pyramidize UI, brings window to front, and loads clipboard into editor +- [ ] Pyramidize page does not capture clipboard on single press +- [ ] Window correctly receives focus on double press diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts index 4f42141..4b5c718 100644 --- a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -9,7 +9,8 @@ declare module "@wailsio/runtime" { namespace Events { interface CustomEvents { "settings:changed": string; - "shortcut:triggered": string; + "shortcut:fix": string; + "shortcut:pyramidize": string; } } } diff --git a/frontend/bindings/keylint/internal/features/settings/models.js b/frontend/bindings/keylint/internal/features/settings/models.js index 1fea0c2..f6ba622 100644 --- a/frontend/bindings/keylint/internal/features/settings/models.js +++ b/frontend/bindings/keylint/internal/features/settings/models.js @@ -149,12 +149,44 @@ export class Settings { } if (!("shortcut_key" in $$source)) { /** - * e.g. "ctrl+g" + * LEGACY — migrated to ShortcutFix on load * @member * @type {string} */ this["shortcut_key"] = ""; } + if (!("shortcut_mode" in $$source)) { + /** + * "double_tap" | "independent" + * @member + * @type {string} + */ + this["shortcut_mode"] = ""; + } + if (!("shortcut_fix" in $$source)) { + /** + * e.g. "ctrl+g" + * @member + * @type {string} + */ + this["shortcut_fix"] = ""; + } + if (!("shortcut_pyramidize" in $$source)) { + /** + * e.g. "ctrl+shift+g" (independent mode only) + * @member + * @type {string} + */ + this["shortcut_pyramidize"] = ""; + } + if (!("shortcut_double_tap_delay" in $$source)) { + /** + * ms, 100-500, default 200 + * @member + * @type {number} + */ + this["shortcut_double_tap_delay"] = 0; + } if (!("start_on_boot" in $$source)) { /** * @member @@ -228,13 +260,13 @@ export class Settings { */ static createFrom($$source = {}) { const $$createField1_0 = $$createType0; - const $$createField9_0 = $$createType2; + const $$createField13_0 = $$createType2; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("providers" in $$parsedSource) { $$parsedSource["providers"] = $$createField1_0($$parsedSource["providers"]); } if ("app_presets" in $$parsedSource) { - $$parsedSource["app_presets"] = $$createField9_0($$parsedSource["app_presets"]); + $$parsedSource["app_presets"] = $$createField13_0($$parsedSource["app_presets"]); } return new Settings(/** @type {Partial} */($$parsedSource)); } diff --git a/frontend/bindings/keylint/simulateservice.js b/frontend/bindings/keylint/simulateservice.js index cab8988..996f085 100644 --- a/frontend/bindings/keylint/simulateservice.js +++ b/frontend/bindings/keylint/simulateservice.js @@ -11,6 +11,15 @@ // @ts-ignore: Unused imports import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; +/** + * SetShortcutPaused temporarily disables shortcut detection (e.g. while recording a new shortcut in settings). + * @param {boolean} paused + * @returns {$CancellablePromise} + */ +export function SetShortcutPaused(paused) { + return $Call.ByID(2045995901, paused); +} + /** * @returns {$CancellablePromise} */ diff --git a/frontend/src/app/core/message-bus.service.ts b/frontend/src/app/core/message-bus.service.ts index 5e5c41f..b278291 100644 --- a/frontend/src/app/core/message-bus.service.ts +++ b/frontend/src/app/core/message-bus.service.ts @@ -2,7 +2,8 @@ import { Injectable } from '@angular/core'; import { Subject, Observable } from 'rxjs'; export type BusEvent = - | { type: 'shortcut:triggered'; source: string } + | { type: 'shortcut:fix'; source: string } + | { type: 'shortcut:pyramidize'; source: string } | { type: 'enhancement:complete'; text: string } | { type: 'enhancement:error'; message: string }; diff --git a/frontend/src/app/core/wails.service.spec.ts b/frontend/src/app/core/wails.service.spec.ts index b4a2565..775f394 100644 --- a/frontend/src/app/core/wails.service.spec.ts +++ b/frontend/src/app/core/wails.service.spec.ts @@ -64,9 +64,9 @@ describe('MessageBusService', () => { const received: string[] = []; svc.events$.subscribe(e => received.push(e.type)); - svc.emit({ type: 'shortcut:triggered', source: 'test' }); + svc.emit({ type: 'shortcut:fix', source: 'test' }); svc.emit({ type: 'enhancement:complete', text: 'done' }); - expect(received).toEqual(['shortcut:triggered', 'enhancement:complete']); + expect(received).toEqual(['shortcut:fix', 'enhancement:complete']); }); }); diff --git a/frontend/src/app/core/wails.service.ts b/frontend/src/app/core/wails.service.ts index f2de1ed..4283f6a 100644 --- a/frontend/src/app/core/wails.service.ts +++ b/frontend/src/app/core/wails.service.ts @@ -24,6 +24,10 @@ const BROWSER_MODE_DEFAULTS: Settings = { active_provider: 'claude', providers: { ollama_url: '', aws_region: '' }, shortcut_key: 'ctrl+g', + shortcut_mode: 'double_tap', + shortcut_fix: 'ctrl+g', + shortcut_pyramidize: 'ctrl+shift+g', + shortcut_double_tap_delay: 200, start_on_boot: false, theme_preference: 'dark', completed_setup: false, @@ -36,12 +40,15 @@ const BROWSER_MODE_DEFAULTS: Settings = { @Injectable({ providedIn: 'root' }) export class WailsService implements OnDestroy { - private readonly shortcutTriggered = new Subject(); + private readonly shortcutFix = new Subject(); + private readonly shortcutPyramidize = new Subject(); private readonly settingsChanged = new Subject(); private readonly unsubscribers: Array<() => void> = []; - /** Emits whenever the global shortcut fires (real hotkey or simulated). */ - readonly shortcutTriggered$: Observable = this.shortcutTriggered.asObservable(); + /** Emits on fix shortcut (silent grammar fix). */ + readonly shortcutFix$: Observable = this.shortcutFix.asObservable(); + /** Emits on pyramidize shortcut (open Pyramidize UI). */ + readonly shortcutPyramidize$: Observable = this.shortcutPyramidize.asObservable(); /** Emits whenever settings are saved from the backend. */ readonly settingsChanged$: Observable = this.settingsChanged.asObservable(); @@ -51,8 +58,11 @@ export class WailsService implements OnDestroy { private listenToEvents(): void { this.unsubscribers.push( - Events.On('shortcut:triggered', (ev) => { - this.shortcutTriggered.next(ev.data as string); + Events.On('shortcut:fix', (ev) => { + this.shortcutFix.next(ev.data as string); + }), + Events.On('shortcut:pyramidize', (ev) => { + this.shortcutPyramidize.next(ev.data as string); }), Events.On('settings:changed', () => { this.settingsChanged.next(); @@ -164,6 +174,14 @@ export class WailsService implements OnDestroy { return UpdaterService.DownloadAndInstall(); } + setShortcutPaused(paused: boolean): Promise { + try { + return SimulateService.SetShortcutPaused(paused).catch(() => {}); + } catch { + return Promise.resolve(); + } + } + simulateShortcut(): Promise { if (!isDevMode()) return Promise.resolve(); return SimulateService.SimulateShortcut(); @@ -241,7 +259,8 @@ export class WailsService implements OnDestroy { ngOnDestroy(): void { this.unsubscribers.forEach(fn => fn()); - this.shortcutTriggered.complete(); + this.shortcutFix.complete(); + this.shortcutPyramidize.complete(); this.settingsChanged.complete(); } } diff --git a/frontend/src/app/features/dev-tools/dev-tools.component.ts b/frontend/src/app/features/dev-tools/dev-tools.component.ts index 93018ae..8a3bd2e 100644 --- a/frontend/src/app/features/dev-tools/dev-tools.component.ts +++ b/frontend/src/app/features/dev-tools/dev-tools.component.ts @@ -14,7 +14,7 @@ import { WailsService, Settings as AppSettings } from '../../core/wails.service'

This panel is only visible in development mode.

-

Fires a synthetic shortcut:triggered event — same as pressing Ctrl+G on Windows.

+

Fires a synthetic shortcut event — same as pressing Ctrl+G on Windows.

{ expect(component.loading).toBe(false); }); - it('shortcutTriggered$ triggers fixClipboard', async () => { - wailsMock.readClipboard.mockResolvedValue('shortcut clipboard'); - wailsMock._shortcutTriggered$.next('hotkey'); - await new Promise(r => setTimeout(r, 0)); - expect(wailsMock.readClipboard).toHaveBeenCalled(); - expect(enhanceSpy).toHaveBeenCalledWith('shortcut clipboard'); - }); - - it('ngOnDestroy unsubscribes from shortcut events', async () => { - component.ngOnDestroy(); - wailsMock._shortcutTriggered$.next('hotkey'); - await new Promise(r => setTimeout(r, 0)); - expect(wailsMock.readClipboard).not.toHaveBeenCalled(); - }); + // Silent fix via shortcut is handled by ShellComponent (always mounted). + // See shell.component.spec.ts for shortcutFix$ tests. }); diff --git a/frontend/src/app/features/fix/fix.component.ts b/frontend/src/app/features/fix/fix.component.ts index 75ba475..3db1a0c 100644 --- a/frontend/src/app/features/fix/fix.component.ts +++ b/frontend/src/app/features/fix/fix.component.ts @@ -103,11 +103,8 @@ export class FixComponent implements OnInit, OnDestroy { _syncAutoCopy(value: boolean): void { _autoCopyCache = value; } ngOnInit(): void { - // On shortcut: silently fix clipboard and write result back. - this.sub = this.wails.shortcutTriggered$.subscribe(() => { - this.log.info('fix: shortcut received'); - void this.fixClipboard(); - }); + // Silent fix is handled by ShellComponent (always mounted). + // This component only provides the manual fix UI. } async fix(): Promise { diff --git a/frontend/src/app/features/settings/settings.component.spec.ts b/frontend/src/app/features/settings/settings.component.spec.ts index 79a4e12..881e7ba 100644 --- a/frontend/src/app/features/settings/settings.component.spec.ts +++ b/frontend/src/app/features/settings/settings.component.spec.ts @@ -77,10 +77,9 @@ describe('SettingsComponent', () => { expect(section!.querySelector('small')).toBeTruthy(); }); - it('shortcut key input is present with correct initial value', () => { - const input = el.querySelector('[data-testid="shortcut-input"]'); - expect(input).toBeTruthy(); - expect(input?.value).toBe('ctrl+g'); + it('renders shortcut mode toggle in general tab', () => { + expect(el.querySelector('[data-testid="shortcut-mode-section"]')).toBeTruthy(); + expect(el.querySelector('[data-testid="shortcut-fix-section"]')).toBeTruthy(); }); it('Save button is present', () => { diff --git a/frontend/src/app/features/settings/settings.component.ts b/frontend/src/app/features/settings/settings.component.ts index 8b004e9..420173e 100644 --- a/frontend/src/app/features/settings/settings.component.ts +++ b/frontend/src/app/features/settings/settings.component.ts @@ -5,6 +5,7 @@ import { ButtonModule } from 'primeng/button'; import { InputTextModule } from 'primeng/inputtext'; import { SelectModule } from 'primeng/select'; import { ToggleSwitchModule } from 'primeng/toggleswitch'; +import { SliderModule } from 'primeng/slider'; import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'primeng/tabs'; import { MessageModule } from 'primeng/message'; import { CardModule } from 'primeng/card'; @@ -13,6 +14,7 @@ import { ActivatedRoute } from '@angular/router'; import { WailsService, Settings as AppSettings, KeyStatus, UpdateInfo, AppPreset } from '../../core/wails.service'; import { DOCUMENT_TYPE_OPTIONS } from '../../core/constants'; import { LogService } from '../../core/log.service'; +import { ShortcutRecorderComponent } from './shortcut-recorder/shortcut-recorder.component'; interface ProviderKey { id: string; @@ -28,8 +30,9 @@ interface ProviderKey { standalone: true, imports: [ CommonModule, FormsModule, - ButtonModule, InputTextModule, SelectModule, ToggleSwitchModule, + ButtonModule, InputTextModule, SelectModule, ToggleSwitchModule, SliderModule, Tabs, TabList, Tab, TabPanels, TabPanel, MessageModule, CardModule, TagModule, + ShortcutRecorderComponent, ], template: `
@@ -55,10 +58,63 @@ interface ProviderKey { optionValue="value" />
-
- - + +
+
+
+ + + @if (settings.shortcut_mode === 'double_tap') { + Hold your modifier keys, tap the trigger key once for Fix, twice for Pyramidize. + } @else { + Assign separate shortcuts for each action. + } + +
+ +
+ + @if (settings.shortcut_mode === 'double_tap') { +
+ + + Single tap → Fix · Double tap → Pyramidize +
+
+ + + How long to wait for a second tap. Lower = faster but harder to trigger. +
+ } @else { +
+ + + Silently fixes clipboard text. +
+
+ + + Opens the Pyramidize editor with clipboard text. +
+ }
diff --git a/frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts b/frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts new file mode 100644 index 0000000..e30ee8b --- /dev/null +++ b/frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { ShortcutRecorderComponent } from './shortcut-recorder.component'; +import { WailsService } from '../../../core/wails.service'; +import { createWailsMock } from '../../../../testing/wails-mock'; + +describe('ShortcutRecorderComponent', () => { + let fixture: ComponentFixture; + let component: ShortcutRecorderComponent; + let el: HTMLElement; + let wailsMock: ReturnType; + + beforeEach(async () => { + wailsMock = createWailsMock(); + await TestBed.configureTestingModule({ + imports: [ShortcutRecorderComponent], + providers: [ + { provide: WailsService, useValue: wailsMock }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ShortcutRecorderComponent); + component = fixture.componentInstance; + component.value = 'ctrl+g'; + fixture.detectChanges(); + await fixture.whenStable(); + el = fixture.nativeElement; + }); + + it('renders the formatted key combo', () => { + const display = el.querySelector('[data-testid="combo-display"]'); + expect(display?.textContent?.trim()).toBe('Ctrl + G'); + }); + + it('shows Record button', () => { + expect(el.querySelector('[data-testid="record-btn"]')).toBeTruthy(); + }); + + it('enters recording mode on Record click', () => { + el.querySelector('[data-testid="record-btn"]')?.click(); + fixture.detectChanges(); + expect(component.recording).toBe(true); + expect(el.querySelector('[data-testid="recording-indicator"]')).toBeTruthy(); + }); + + it('exits recording mode on Escape', () => { + component.recording = true; + fixture.detectChanges(); + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + el.querySelector('[data-testid="recorder-field"]')?.dispatchEvent(event); + fixture.detectChanges(); + expect(component.recording).toBe(false); + }); + + it('captures a key combo and emits valueChange', () => { + const spy = vi.fn(); + component.valueChange.subscribe(spy); + component.recording = true; + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, shiftKey: false, altKey: false, metaKey: false }); + el.querySelector('[data-testid="recorder-field"]')?.dispatchEvent(event); + fixture.detectChanges(); + + expect(spy).toHaveBeenCalledWith('ctrl+k'); + expect(component.recording).toBe(false); + }); + + it('ignores modifier-only keypresses during recording', () => { + const spy = vi.fn(); + component.valueChange.subscribe(spy); + component.recording = true; + fixture.detectChanges(); + + const event = new KeyboardEvent('keydown', { key: 'Control', ctrlKey: true }); + el.querySelector('[data-testid="recorder-field"]')?.dispatchEvent(event); + fixture.detectChanges(); + + expect(spy).not.toHaveBeenCalled(); + expect(component.recording).toBe(true); + }); +}); diff --git a/frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.ts b/frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.ts new file mode 100644 index 0000000..e0aee3b --- /dev/null +++ b/frontend/src/app/features/settings/shortcut-recorder/shortcut-recorder.component.ts @@ -0,0 +1,135 @@ +import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ButtonModule } from 'primeng/button'; +import { WailsService } from '../../../core/wails.service'; + +const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']); + +const KEY_TO_NAME: Record = { + ' ': 'space', 'Enter': 'enter', 'Tab': 'tab', 'Escape': 'escape', + 'Backspace': 'backspace', 'Delete': 'delete', 'Insert': 'insert', + 'Home': 'home', 'End': 'end', 'PageUp': 'pageup', 'PageDown': 'pagedown', + 'ArrowUp': 'up', 'ArrowDown': 'down', 'ArrowLeft': 'left', 'ArrowRight': 'right', +}; + +function formatCombo(combo: string): string { + if (!combo) return ''; + const parts = combo.split('+'); + return parts.map(p => { + if (p === 'ctrl') return 'Ctrl'; + if (p === 'shift') return 'Shift'; + if (p === 'alt') return 'Alt'; + if (p === 'win') return 'Win'; + return p.length === 1 ? p.toUpperCase() : p.charAt(0).toUpperCase() + p.slice(1).toUpperCase(); + }).join(' + '); +} + +@Component({ + selector: 'app-shortcut-recorder', + standalone: true, + imports: [CommonModule, ButtonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ @if (recording) { + Press a key combo... + } @else { + {{ displayValue }} + } + +
+ `, + styles: [` + .recorder-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid var(--p-content-border-color); + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: var(--p-content-hover-background); + min-height: 2.5rem; + outline: none; + } + .recorder-wrapper.recording { + border-color: var(--p-primary-color); + box-shadow: 0 0 0 1px var(--p-primary-color); + } + .combo-text { + font-family: monospace; + font-size: 0.9rem; + color: var(--p-text-color); + } + .recording-text { + font-size: 0.85rem; + color: var(--p-text-muted-color); + animation: pulse 1.5s ease-in-out infinite; + } + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + `], +}) +export class ShortcutRecorderComponent { + @Input() value = ''; + @Output() valueChange = new EventEmitter(); + + private readonly wails = inject(WailsService); + recording = false; + + get displayValue(): string { + return formatCombo(this.value); + } + + toggleRecording(): void { + this.recording = !this.recording; + // Pause/resume the global shortcut hook so it doesn't intercept keypresses during recording. + void this.wails.setShortcutPaused(this.recording); + } + + onKeyDown(event: KeyboardEvent): void { + if (!this.recording) return; + + event.preventDefault(); + event.stopPropagation(); + + if (event.key === 'Escape') { + this.recording = false; + void this.wails.setShortcutPaused(false); + return; + } + + // Ignore modifier-only presses — wait for a trigger key. + if (MODIFIER_KEYS.has(event.key)) return; + + const parts: string[] = []; + if (event.ctrlKey) parts.push('ctrl'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + if (event.metaKey) parts.push('win'); + + // Map the key to our canonical name. + let keyName = KEY_TO_NAME[event.key] ?? event.key.toLowerCase(); + // Function keys come as "F1", "F12" etc. + if (/^f\d{1,2}$/i.test(event.key)) { + keyName = event.key.toLowerCase(); + } + + parts.push(keyName); + const combo = parts.join('+'); + + this.value = combo; + this.valueChange.emit(combo); + this.recording = false; + void this.wails.setShortcutPaused(false); + } +} diff --git a/frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts b/frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts index efeac35..1e1a0ce 100644 --- a/frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts +++ b/frontend/src/app/features/text-enhancement/text-enhancement.component.spec.ts @@ -250,28 +250,28 @@ describe('TextEnhancementComponent (Pyramidize)', () => { expect(wailsMock.writeClipboard).toHaveBeenCalledWith('# Hello\n\nWorld'); }); - // ── 13. shortcutTriggered$ with empty originalText sets originalText from clipboard ── + // ── 13. shortcutPyramidize$ with empty originalText sets originalText from clipboard ── - it('shortcutTriggered$ with empty originalText sets originalText from clipboard', async () => { + it('shortcutPyramidize$ with empty originalText sets originalText from clipboard', async () => { component.originalTextView = ''; wailsMock.readClipboard.mockResolvedValue('clipboard hotkey content'); wailsMock.getSourceApp.mockResolvedValue('TestApp'); - wailsMock._shortcutTriggered$.next('hotkey'); + wailsMock._shortcutPyramidize$.next('hotkey'); await new Promise(r => setTimeout(r, 0)); expect(wailsMock.readClipboard).toHaveBeenCalled(); expect(component.originalTextView).toBe('clipboard hotkey content'); }); - // ── 14. shortcutTriggered$ with existing originalText shows confirm dialog ── + // ── 14. shortcutPyramidize$ with existing originalText shows confirm dialog ── - it('shortcutTriggered$ with existing originalText shows confirm dialog', async () => { + it('shortcutPyramidize$ with existing originalText shows confirm dialog', async () => { component.originalTextView = 'existing content'; wailsMock.readClipboard.mockResolvedValue('new clipboard content'); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); - wailsMock._shortcutTriggered$.next('hotkey'); + wailsMock._shortcutPyramidize$.next('hotkey'); await new Promise(r => setTimeout(r, 0)); expect(confirmSpy).toHaveBeenCalled(); @@ -279,12 +279,12 @@ describe('TextEnhancementComponent (Pyramidize)', () => { expect(component.originalTextView).toBe('existing content'); }); - it('shortcutTriggered$ with existing originalText and confirm=true replaces content', async () => { + it('shortcutPyramidize$ with existing originalText and confirm=true replaces content', async () => { component.originalTextView = 'existing content'; wailsMock.readClipboard.mockResolvedValue('new clipboard content'); vi.spyOn(window, 'confirm').mockReturnValue(true); - wailsMock._shortcutTriggered$.next('hotkey'); + wailsMock._shortcutPyramidize$.next('hotkey'); await new Promise(r => setTimeout(r, 0)); expect(component.originalTextView).toBe('new clipboard content'); @@ -325,7 +325,7 @@ describe('TextEnhancementComponent (Pyramidize)', () => { it('ngOnDestroy unsubscribes from shortcut events', async () => { component.ngOnDestroy(); const prevReadCount = (wailsMock.readClipboard as ReturnType).mock.calls.length; - wailsMock._shortcutTriggered$.next('hotkey'); + wailsMock._shortcutPyramidize$.next('hotkey'); await new Promise(r => setTimeout(r, 0)); expect((wailsMock.readClipboard as ReturnType).mock.calls.length).toBe(prevReadCount); }); diff --git a/frontend/src/app/features/text-enhancement/text-enhancement.component.ts b/frontend/src/app/features/text-enhancement/text-enhancement.component.ts index e638edd..9c3897e 100644 --- a/frontend/src/app/features/text-enhancement/text-enhancement.component.ts +++ b/frontend/src/app/features/text-enhancement/text-enhancement.component.ts @@ -1093,7 +1093,7 @@ export class TextEnhancementComponent implements OnInit, OnDestroy { this.cdr.detectChanges(); - this.sub = this.wails.shortcutTriggered$.subscribe(async () => { + this.sub = this.wails.shortcutPyramidize$.subscribe(async () => { const clipboardContent = await this.wails.readClipboard(); sourceApp = await this.wails.getSourceApp(); diff --git a/frontend/src/app/layout/shell.component.spec.ts b/frontend/src/app/layout/shell.component.spec.ts index f40a14c..508ebaf 100644 --- a/frontend/src/app/layout/shell.component.spec.ts +++ b/frontend/src/app/layout/shell.component.spec.ts @@ -110,4 +110,29 @@ describe('ShellComponent — theme / body class', () => { fixture.componentInstance.goToAbout(); expect(navigateSpy).toHaveBeenCalledWith(['/settings'], { queryParams: { tab: 'about' } }); }); + + it('navigates to /enhance on shortcutPyramidize$', async () => { + const fixture = await createAndWait('dark'); + const router = TestBed.inject(Router); + const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + wailsMock._shortcutPyramidize$.next('hotkey'); + await fixture.whenStable(); + + expect(navigateSpy).toHaveBeenCalledWith(['/enhance']); + }); + + it('shortcutFix$ triggers silent fix (enhance + paste)', async () => { + wailsMock.readClipboard.mockResolvedValue('bad grammer'); + wailsMock.enhance.mockResolvedValue('bad grammar'); + await createAndWait('dark'); + + wailsMock._shortcutFix$.next('hotkey'); + await new Promise(r => setTimeout(r, 0)); + + expect(wailsMock.readClipboard).toHaveBeenCalled(); + expect(wailsMock.enhance).toHaveBeenCalledWith('bad grammer'); + expect(wailsMock.writeClipboard).toHaveBeenCalledWith('bad grammar'); + expect(wailsMock.pasteToForeground).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/layout/shell.component.ts b/frontend/src/app/layout/shell.component.ts index c14dd74..b148a4d 100644 --- a/frontend/src/app/layout/shell.component.ts +++ b/frontend/src/app/layout/shell.component.ts @@ -4,6 +4,7 @@ import { isDevMode } from '@angular/core'; import { Subscription } from 'rxjs'; import { TooltipModule } from 'primeng/tooltip'; import { WailsService } from '../core/wails.service'; +import { LogService } from '../core/log.service'; // Persists across navigation let sidebarCollapsed = false; @@ -99,7 +100,7 @@ export class ShellComponent implements OnInit, OnDestroy { readonly dev = isDevMode(); appVersion = ''; updateAvailable = false; - private sub?: Subscription; + private subs: Subscription[] = []; get collapsedView(): boolean { return sidebarCollapsed; } get hoverExpanded(): boolean { return sidebarCollapsed && sidebarHovered; } @@ -108,12 +109,21 @@ export class ShellComponent implements OnInit, OnDestroy { private readonly wails: WailsService, private readonly router: Router, private readonly cdr: ChangeDetectorRef, + private readonly log: LogService, ) {} ngOnInit(): void { void this.applyTheme(); void this.loadVersionInfo(); - this.sub = this.wails.settingsChanged$.subscribe(() => void this.applyTheme()); + this.subs.push( + this.wails.settingsChanged$.subscribe(() => void this.applyTheme()), + this.wails.shortcutFix$.subscribe(() => { + void this.silentFix(); + }), + this.wails.shortcutPyramidize$.subscribe(() => { + void this.router.navigate(['/enhance']); + }), + ); } goToAbout(): void { @@ -151,7 +161,21 @@ export class ShellComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this.sub?.unsubscribe(); + this.subs.forEach(s => s.unsubscribe()); + } + + private async silentFix(): Promise { + this.log.info('shell: silent fix started'); + try { + const text = await this.wails.readClipboard(); + if (!text.trim()) return; + const result = await this.wails.enhance(text); + await this.wails.writeClipboard(result); + await this.wails.pasteToForeground(); + this.log.info('shell: silent fix done'); + } catch (e: unknown) { + this.log.error('shell: silent fix failed: ' + (e instanceof Error ? e.message : String(e))); + } } private async applyTheme(): Promise { diff --git a/frontend/src/testing/wails-mock.ts b/frontend/src/testing/wails-mock.ts index d2403e1..f9c0a74 100644 --- a/frontend/src/testing/wails-mock.ts +++ b/frontend/src/testing/wails-mock.ts @@ -9,6 +9,10 @@ export const defaultSettings: Settings = { aws_region: '', }, shortcut_key: 'ctrl+g', + shortcut_mode: 'double_tap', + shortcut_fix: 'ctrl+g', + shortcut_pyramidize: 'ctrl+shift+g', + shortcut_double_tap_delay: 200, start_on_boot: false, theme_preference: 'dark', completed_setup: false, @@ -31,14 +35,17 @@ export const defaultUpdateInfo: UpdateInfo = { }; export function createWailsMock() { - const shortcutTriggered$ = new Subject(); + const shortcutFix$ = new Subject(); + const shortcutPyramidize$ = new Subject(); const settingsChanged$ = new Subject(); return { - shortcutTriggered$: shortcutTriggered$.asObservable(), + shortcutFix$: shortcutFix$.asObservable(), + shortcutPyramidize$: shortcutPyramidize$.asObservable(), settingsChanged$: settingsChanged$.asObservable(), // Expose subjects so tests can trigger events - _shortcutTriggered$: shortcutTriggered$, + _shortcutFix$: shortcutFix$, + _shortcutPyramidize$: shortcutPyramidize$, _settingsChanged$: settingsChanged$, loadSettings: vi.fn().mockResolvedValue({ ...defaultSettings }), @@ -48,6 +55,7 @@ export function createWailsMock() { readClipboard: vi.fn().mockResolvedValue('clipboard text'), writeClipboard: vi.fn().mockResolvedValue(undefined), enhance: vi.fn().mockResolvedValue('Enhanced text.'), + setShortcutPaused: vi.fn().mockResolvedValue(undefined), simulateShortcut: vi.fn().mockResolvedValue(undefined), getKeyStatus: vi.fn().mockResolvedValue({ ...defaultKeyStatus }), getKey: vi.fn().mockResolvedValue(''), diff --git a/internal/features/clipboard/paste_windows.go b/internal/features/clipboard/paste_windows.go index 9182b61..b1728d2 100644 --- a/internal/features/clipboard/paste_windows.go +++ b/internal/features/clipboard/paste_windows.go @@ -12,15 +12,18 @@ import ( ) var ( - clipUser32 = syscall.NewLazyDLL("user32.dll") - clipSendInput = clipUser32.NewProc("SendInput") - clipGetForegroundWindow = clipUser32.NewProc("GetForegroundWindow") + clipUser32 = syscall.NewLazyDLL("user32.dll") + clipSendInput = clipUser32.NewProc("SendInput") + clipGetForegroundWindow = clipUser32.NewProc("GetForegroundWindow") + clipGetClipboardSeqNumber = clipUser32.NewProc("GetClipboardSequenceNumber") ) const ( inputKeyboard = 1 keyEventKeyUp = 0x0002 + vkShift = 0x10 vkControl = 0x11 + vkMenu = 0x12 // Alt vkC = 0x43 vkV = 0x56 ) @@ -42,15 +45,25 @@ type pasteInput struct { } // CopyFromForeground sends Ctrl+C to the foreground window via Win32 SendInput, -// then waits 150 ms for the clipboard to be populated by the source app. +// then polls GetClipboardSequenceNumber until the clipboard changes (or timeout). +// This handles slow clipboard providers like Outlook that use delayed rendering. func (s *Service) CopyFromForeground() error { hwnd, _, _ := clipGetForegroundWindow.Call() logger.Info("clipboard: CopyFromForeground sending Ctrl+C", "foreground_hwnd", hwnd) - inputs := [4]pasteInput{ - {inputType: inputKeyboard, wVk: vkControl}, - {inputType: inputKeyboard, wVk: vkC}, - {inputType: inputKeyboard, wVk: vkC, dwFlags: keyEventKeyUp}, - {inputType: inputKeyboard, wVk: vkControl, dwFlags: keyEventKeyUp}, + + // Snapshot the clipboard sequence number before sending Ctrl+C. + seqBefore, _, _ := clipGetClipboardSeqNumber.Call() + + // Release Shift and Alt before Ctrl+C — the user may still be holding modifier keys + // from the shortcut combo (e.g. Ctrl+Shift+G). Without this, the target app sees + // Ctrl+Shift+C instead of Ctrl+C, which Outlook and other apps ignore. + inputs := [6]pasteInput{ + {inputType: inputKeyboard, wVk: vkShift, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkMenu, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkControl, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkC, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkC, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkControl, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, } ret, _, err := clipSendInput.Call( uintptr(len(inputs)), @@ -61,8 +74,23 @@ func (s *Service) CopyFromForeground() error { logger.Error("clipboard: CopyFromForeground SendInput failed", "err", err) return fmt.Errorf("SendInput (Ctrl+C) failed: %w", err) } - time.Sleep(150 * time.Millisecond) - logger.Info("clipboard: CopyFromForeground ok") + + // Poll until the clipboard sequence number changes, up to 1s. + // Apps like Outlook use delayed rendering and may take 200-500ms. + const pollInterval = 25 * time.Millisecond + const timeout = 1 * time.Second + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + time.Sleep(pollInterval) + seqNow, _, _ := clipGetClipboardSeqNumber.Call() + if seqNow != seqBefore { + logger.Info("clipboard: CopyFromForeground ok", "wait_ms", time.Since(deadline.Add(-timeout)).Milliseconds()) + return nil + } + } + + // Timeout — clipboard didn't change. Might still work (some apps don't update the sequence number). + logger.Warn("clipboard: CopyFromForeground timed out waiting for clipboard change") return nil } @@ -72,11 +100,14 @@ func (s *Service) PasteToForeground() error { time.Sleep(150 * time.Millisecond) hwnd, _, _ := clipGetForegroundWindow.Call() logger.Info("clipboard: PasteToForeground sending Ctrl+V", "foreground_hwnd", hwnd) - inputs := [4]pasteInput{ - {inputType: inputKeyboard, wVk: vkControl}, - {inputType: inputKeyboard, wVk: vkV}, - {inputType: inputKeyboard, wVk: vkV, dwFlags: keyEventKeyUp}, - {inputType: inputKeyboard, wVk: vkControl, dwFlags: keyEventKeyUp}, + // Release Shift and Alt before Ctrl+V (same reason as CopyFromForeground). + inputs := [6]pasteInput{ + {inputType: inputKeyboard, wVk: vkShift, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkMenu, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkControl, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkV, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkV, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, + {inputType: inputKeyboard, wVk: vkControl, dwFlags: keyEventKeyUp, dwExtraInfo: 0x4B4C}, } ret, _, err := clipSendInput.Call( uintptr(len(inputs)), diff --git a/internal/features/settings/model.go b/internal/features/settings/model.go index 70bf7b2..1092625 100644 --- a/internal/features/settings/model.go +++ b/internal/features/settings/model.go @@ -27,7 +27,11 @@ const DefaultQualityThreshold = 0.65 type Settings struct { ActiveProvider string `json:"active_provider"` // "openai" | "claude" | "ollama" | "bedrock" Providers Provider `json:"providers"` - ShortcutKey string `json:"shortcut_key"` // e.g. "ctrl+g" + ShortcutKey string `json:"shortcut_key"` // LEGACY — migrated to ShortcutFix on load + ShortcutMode string `json:"shortcut_mode"` // "double_tap" | "independent" + ShortcutFix string `json:"shortcut_fix"` // e.g. "ctrl+g" + ShortcutPyramidize string `json:"shortcut_pyramidize"` // e.g. "ctrl+shift+g" (independent mode only) + ShortcutDoubleTapDelay int `json:"shortcut_double_tap_delay"` // ms, 100-500, default 200 StartOnBoot bool `json:"start_on_boot"` ThemePreference string `json:"theme_preference"` // "light" | "dark" | "system" CompletedSetup bool `json:"completed_setup"` @@ -45,6 +49,10 @@ func Default() Settings { return Settings{ ActiveProvider: "openai", ShortcutKey: "ctrl+g", + ShortcutMode: "double_tap", + ShortcutFix: "ctrl+g", + ShortcutPyramidize: "ctrl+shift+g", + ShortcutDoubleTapDelay: 200, ThemePreference: "dark", LogLevel: "off", PyramidizeQualityThreshold: DefaultQualityThreshold, diff --git a/internal/features/settings/service.go b/internal/features/settings/service.go index 1224210..4510ac4 100644 --- a/internal/features/settings/service.go +++ b/internal/features/settings/service.go @@ -81,6 +81,15 @@ func (s *Service) load() error { } } + // Migrate legacy shortcut_key → shortcut_fix + defaults. + if s.current.ShortcutFix == "" && s.current.ShortcutKey != "" { + s.current.ShortcutFix = s.current.ShortcutKey + s.current.ShortcutMode = "double_tap" + s.current.ShortcutPyramidize = "ctrl+shift+g" + s.current.ShortcutDoubleTapDelay = 200 + logger.Info("settings: migrated shortcut_key to shortcut_fix", "key", s.current.ShortcutFix) + } + logger.Info("settings: loaded", "path", s.filePath) return nil } diff --git a/internal/features/shortcut/keycombo.go b/internal/features/shortcut/keycombo.go new file mode 100644 index 0000000..97daeb0 --- /dev/null +++ b/internal/features/shortcut/keycombo.go @@ -0,0 +1,139 @@ +package shortcut + +import ( + "fmt" + "strings" +) + +// Modifier bitmask flags matching Win32 modifier virtual key codes. +type Modifier uint8 + +const ( + ModCtrl Modifier = 1 << iota // VK_CONTROL (0x11) + ModShift // VK_SHIFT (0x10) + ModAlt // VK_MENU (0x12) + ModWin // VK_LWIN (0x5B) +) + +// KeyCombo represents a parsed keyboard shortcut (modifier bitmask + trigger key). +type KeyCombo struct { + Modifiers Modifier + VK uint16 // Windows virtual key code + KeyName string // lowercase key name, e.g. "g", "f8" +} + +// modifierNames maps string names to modifier flags. +var modifierNames = map[string]Modifier{ + "ctrl": ModCtrl, + "shift": ModShift, + "alt": ModAlt, + "win": ModWin, +} + +// modifierDisplay is the display-order list of modifiers. +var modifierDisplay = []struct { + Flag Modifier + Name string +}{ + {ModCtrl, "Ctrl"}, + {ModShift, "Shift"}, + {ModAlt, "Alt"}, + {ModWin, "Win"}, +} + +// keyNames maps lowercase key names to Windows virtual key codes. +var keyNames = map[string]uint16{ + // Letters A-Z + "a": 0x41, "b": 0x42, "c": 0x43, "d": 0x44, "e": 0x45, + "f": 0x46, "g": 0x47, "h": 0x48, "i": 0x49, "j": 0x4A, + "k": 0x4B, "l": 0x4C, "m": 0x4D, "n": 0x4E, "o": 0x4F, + "p": 0x50, "q": 0x51, "r": 0x52, "s": 0x53, "t": 0x54, + "u": 0x55, "v": 0x56, "w": 0x57, "x": 0x58, "y": 0x59, "z": 0x5A, + // Digits 0-9 + "0": 0x30, "1": 0x31, "2": 0x32, "3": 0x33, "4": 0x34, + "5": 0x35, "6": 0x36, "7": 0x37, "8": 0x38, "9": 0x39, + // Function keys F1-F12 + "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73, + "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77, + "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B, + // Punctuation / special + ";": 0xBA, "=": 0xBB, ",": 0xBC, "-": 0xBD, ".": 0xBE, + "/": 0xBF, "`": 0xC0, "[": 0xDB, "\\": 0xDC, "]": 0xDD, "'": 0xDE, + "space": 0x20, "enter": 0x0D, "tab": 0x09, "escape": 0x1B, + "backspace": 0x08, "delete": 0x2E, "insert": 0x2D, + "home": 0x24, "end": 0x23, "pageup": 0x21, "pagedown": 0x22, + "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27, +} + +// vkToName is the reverse map, built on init. +var vkToName map[uint16]string + +func init() { + vkToName = make(map[uint16]string, len(keyNames)) + for name, vk := range keyNames { + if existing, ok := vkToName[vk]; !ok || len(name) < len(existing) { + vkToName[vk] = name + } + } +} + +// ParseKeyCombo parses a shortcut string like "ctrl+g" or "f8" into a KeyCombo. +func ParseKeyCombo(s string) (KeyCombo, error) { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return KeyCombo{}, fmt.Errorf("empty key combo") + } + parts := strings.Split(s, "+") + var mods Modifier + var triggerKey string + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return KeyCombo{}, fmt.Errorf("empty part in key combo %q", s) + } + if mod, ok := modifierNames[part]; ok { + mods |= mod + } else { + if triggerKey != "" { + return KeyCombo{}, fmt.Errorf("multiple trigger keys in %q: %q and %q", s, triggerKey, part) + } + triggerKey = part + } + } + + if triggerKey == "" { + return KeyCombo{}, fmt.Errorf("no trigger key in %q (modifiers only)", s) + } + + vk, ok := keyNames[triggerKey] + if !ok { + return KeyCombo{}, fmt.Errorf("unknown key name %q", triggerKey) + } + + return KeyCombo{Modifiers: mods, VK: vk, KeyName: triggerKey}, nil +} + +// DisplayString returns a human-readable representation like "Ctrl + Shift + G". +func (kc KeyCombo) DisplayString() string { + var parts []string + for _, md := range modifierDisplay { + if kc.Modifiers&md.Flag != 0 { + parts = append(parts, md.Name) + } + } + parts = append(parts, strings.ToUpper(kc.KeyName)) + return strings.Join(parts, " + ") +} + +// String returns the canonical lowercase format like "ctrl+shift+g". +func (kc KeyCombo) String() string { + var parts []string + for _, md := range modifierDisplay { + if kc.Modifiers&md.Flag != 0 { + parts = append(parts, strings.ToLower(md.Name)) + } + } + parts = append(parts, kc.KeyName) + return strings.Join(parts, "+") +} diff --git a/internal/features/shortcut/keycombo_test.go b/internal/features/shortcut/keycombo_test.go new file mode 100644 index 0000000..781b790 --- /dev/null +++ b/internal/features/shortcut/keycombo_test.go @@ -0,0 +1,104 @@ +package shortcut + +import "testing" + +func TestParseKeyCombo_CtrlG(t *testing.T) { + kc, err := ParseKeyCombo("ctrl+g") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != ModCtrl { + t.Fatalf("expected ModCtrl (%d), got %d", ModCtrl, kc.Modifiers) + } + if kc.VK != 0x47 { + t.Fatalf("expected VK 0x47, got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_CtrlShiftE(t *testing.T) { + kc, err := ParseKeyCombo("ctrl+shift+e") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != ModCtrl|ModShift { + t.Fatalf("expected ModCtrl|ModShift (%d), got %d", ModCtrl|ModShift, kc.Modifiers) + } + if kc.VK != 0x45 { + t.Fatalf("expected VK 0x45 (E), got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_StandaloneF8(t *testing.T) { + kc, err := ParseKeyCombo("f8") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != 0 { + t.Fatalf("expected no modifiers, got %d", kc.Modifiers) + } + if kc.VK != 0x77 { + t.Fatalf("expected VK 0x77 (F8), got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_TripleModifier(t *testing.T) { + kc, err := ParseKeyCombo("ctrl+shift+alt+k") + if err != nil { + t.Fatal(err) + } + if kc.Modifiers != ModCtrl|ModShift|ModAlt { + t.Fatalf("expected all three modifiers, got %d", kc.Modifiers) + } + if kc.VK != 0x4B { + t.Fatalf("expected VK 0x4B (K), got 0x%X", kc.VK) + } +} + +func TestParseKeyCombo_Invalid(t *testing.T) { + _, err := ParseKeyCombo("") + if err == nil { + t.Fatal("expected error for empty string") + } + _, err = ParseKeyCombo("ctrl+") + if err == nil { + t.Fatal("expected error for trailing +") + } + _, err = ParseKeyCombo("ctrl+shift") + if err == nil { + t.Fatal("expected error for modifiers-only combo") + } + _, err = ParseKeyCombo("ctrl+unknownkey") + if err == nil { + t.Fatal("expected error for unknown key name") + } +} + +func TestKeyCombo_DisplayString(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"ctrl+g", "Ctrl + G"}, + {"ctrl+shift+e", "Ctrl + Shift + E"}, + {"f8", "F8"}, + {"alt+f4", "Alt + F4"}, + } + for _, tt := range tests { + kc, err := ParseKeyCombo(tt.input) + if err != nil { + t.Fatalf("parse %q: %v", tt.input, err) + } + got := kc.DisplayString() + if got != tt.expected { + t.Errorf("DisplayString(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestKeyCombo_String(t *testing.T) { + kc, _ := ParseKeyCombo("ctrl+shift+g") + got := kc.String() + if got != "ctrl+shift+g" { + t.Errorf("String() = %q, want %q", got, "ctrl+shift+g") + } +} diff --git a/internal/features/shortcut/service.go b/internal/features/shortcut/service.go index 0576807..271a9ff 100644 --- a/internal/features/shortcut/service.go +++ b/internal/features/shortcut/service.go @@ -1,17 +1,32 @@ package shortcut -// ShortcutEvent carries the payload emitted when the global shortcut fires. +import "time" + +// ShortcutEvent carries the payload emitted when a shortcut fires. type ShortcutEvent struct { Source string // "hotkey" | "simulate" + Action string // "fix" | "pyramidize" +} + +// ShortcutConfig holds the configuration for shortcut detection. +type ShortcutConfig struct { + Mode string // "double_tap" | "independent" + FixCombo string // e.g. "ctrl+g" + PyramidizeCombo string // e.g. "ctrl+shift+g" + DoubleTapDelay time.Duration // e.g. 200ms } // Service is the platform-agnostic interface for global shortcut handling. // Platform-specific implementations are in service_windows.go / service_linux.go. type Service interface { - // Register activates the global hotkey listener. - Register() error + // Register activates the global shortcut listener with the given configuration. + Register(cfg ShortcutConfig) error // Unregister deactivates the listener. Unregister() - // Triggered returns a channel that receives an event each time the shortcut fires. + // Triggered returns a channel that receives an event each time a shortcut fires. Triggered() <-chan ShortcutEvent + // UpdateConfig hot-reloads the shortcut configuration without restarting the app. + UpdateConfig(cfg ShortcutConfig) error + // SetPaused temporarily disables shortcut detection (e.g. while recording a new shortcut). + SetPaused(paused bool) } diff --git a/internal/features/shortcut/service_linux.go b/internal/features/shortcut/service_linux.go index 1d12517..a22a8cb 100644 --- a/internal/features/shortcut/service_linux.go +++ b/internal/features/shortcut/service_linux.go @@ -2,6 +2,8 @@ package shortcut +import "keylint/internal/logger" + // linuxService is a no-op shortcut service for Linux. // On Linux, shortcuts are simulated via --simulate-shortcut CLI flag or the // dev-tools UI button, which manually sends on the channel. @@ -16,11 +18,20 @@ func NewPlatformService() Service { } } -func (s *linuxService) Register() error { return nil } -func (s *linuxService) Unregister() {} +func (s *linuxService) Register(cfg ShortcutConfig) error { + logger.Info("shortcut: register (no-op on Linux)", "fix", cfg.FixCombo) + return nil +} +func (s *linuxService) Unregister() {} func (s *linuxService) Triggered() <-chan ShortcutEvent { return s.ch } +func (s *linuxService) UpdateConfig(cfg ShortcutConfig) error { + logger.Info("shortcut: config updated (no-op on Linux)", "fix", cfg.FixCombo) + return nil +} + +func (s *linuxService) SetPaused(paused bool) {} // Simulate fires a synthetic shortcut event (used by --simulate-shortcut and dev UI). func (s *linuxService) Simulate() { - s.ch <- ShortcutEvent{Source: "simulate"} + s.ch <- ShortcutEvent{Source: "simulate", Action: "fix"} } diff --git a/internal/features/shortcut/service_windows.go b/internal/features/shortcut/service_windows.go index 73e06b6..4ae4258 100644 --- a/internal/features/shortcut/service_windows.go +++ b/internal/features/shortcut/service_windows.go @@ -5,6 +5,8 @@ package shortcut import ( "fmt" "runtime" + "sync" + "sync/atomic" "syscall" "time" "unsafe" @@ -13,70 +15,316 @@ import ( ) const ( - modifierCtrl = 0x0002 - vkG = 0x47 - wmHotkey = 0x0312 - hotkeyID = 1 + whKeyboardLL = 13 + wmKeyDown = 0x0100 + wmKeyUp = 0x0101 + wmSysKeyDown = 0x0104 + wmSysKeyUp = 0x0105 + wmTimer = 0x0113 + + vkLControl = 0xA2 + vkRControl = 0xA3 + vkLShift = 0xA0 + vkRShift = 0xA1 + vkLMenu = 0xA4 // Left Alt + vkRMenu = 0xA5 // Right Alt + vkLWin = 0x5B + vkRWin = 0x5C + + // Tag for self-generated input (CopyFromForeground sends Ctrl+C). + // Checked in the hook to avoid intercepting our own synthetic keypresses. + extraInfoTag = 0x4B4C // "KL" in hex ) var ( user32 = syscall.NewLazyDLL("user32.dll") - registerHotKey = user32.NewProc("RegisterHotKey") - unregisterHotKey = user32.NewProc("UnregisterHotKey") + setWindowsHookEx = user32.NewProc("SetWindowsHookExW") + unhookWindowsHookEx = user32.NewProc("UnhookWindowsHookEx") + callNextHookEx = user32.NewProc("CallNextHookEx") getMessage = user32.NewProc("GetMessageW") - getForegroundWindow = user32.NewProc("GetForegroundWindow") + setTimer = user32.NewProc("SetTimer") + killTimer = user32.NewProc("KillTimer") + postThreadMessage = user32.NewProc("PostThreadMessageW") + getThreadId = syscall.NewLazyDLL("kernel32.dll").NewProc("GetCurrentThreadId") +) + +// kbdLLHookStruct mirrors the Win32 KBDLLHOOKSTRUCT. +type kbdLLHookStruct struct { + VKCode uint32 + ScanCode uint32 + Flags uint32 + Time uint32 + DwExtraInfo uintptr +} + +type msg struct { + HWnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt [2]int32 +} + +// doubleTapTimerID is the SetTimer ID for the double-tap detection window. +const doubleTapTimerID = 1 + +// wmApp is the base for custom messages posted to the message loop. +const wmApp = 0x8000 + +const ( + wmAction = wmApp + 1 // WParam: 0=fix, 1=pyramidize +) + +// Double-tap state phases. +type tapPhase int + +const ( + tapIdle tapPhase = iota // no tap in progress + tapWaitRelease // first tap received, waiting for trigger keyup (ignore auto-repeat) + tapWaitSecond // trigger released, waiting for second tap or timer expiry ) type windowsService struct { - ch chan ShortcutEvent + ch chan ShortcutEvent + hookH uintptr // hook handle + threadID uint32 + paused atomic.Bool // true = shortcut detection disabled (e.g. during recording) + + // Configuration — guarded by mu for hot-reload from UpdateConfig. + mu sync.Mutex + mode string + fixCombo KeyCombo + pyramidizeCombo KeyCombo + doubleTapDelay uint32 // milliseconds + + // State — only accessed on the hook thread, no lock needed. + tapState tapPhase // double-tap detection phase + mods Modifier // currently held modifier keys + indepPending int // independent mode: -1=none, 0=fix, 1=pyramidize (fire on keyup) + indepPendingVK uint16 // trigger VK we're waiting for keyup on } -// NewPlatformService returns the Windows Win32 RegisterHotKey implementation. +// NewPlatformService returns the Windows WH_KEYBOARD_LL implementation. func NewPlatformService() Service { - return &windowsService{ch: make(chan ShortcutEvent, 1)} + return &windowsService{ch: make(chan ShortcutEvent, 2), indepPending: -1} } -func (s *windowsService) Register() error { - // RegisterHotKey is thread-affine: WM_HOTKEY is only delivered to the thread - // that called RegisterHotKey. Lock both registration and the message loop to - // the same OS thread so GetMessageW sees the hotkey messages. +func (s *windowsService) Register(cfg ShortcutConfig) error { + if err := s.applyConfig(cfg); err != nil { + return err + } + ready := make(chan error, 1) go func() { runtime.LockOSThread() - // Retry once on failure (dev-mode restart race: previous process may not - // have fully exited and released the hotkey yet). - ret, _, err := registerHotKey.Call(0, hotkeyID, modifierCtrl, vkG) - if ret == 0 { - logger.Warn("shortcut: RegisterHotKey first attempt failed, retrying in 500ms", "err", err) - time.Sleep(500 * time.Millisecond) - ret, _, err = registerHotKey.Call(0, hotkeyID, modifierCtrl, vkG) - if ret == 0 { - logger.Error("shortcut: RegisterHotKey failed", "err", err) - ready <- fmt.Errorf("RegisterHotKey failed: %w", err) - return - } + + tid, _, _ := getThreadId.Call() + s.threadID = uint32(tid) + + hookProc := syscall.NewCallback(s.hookCallback) + h, _, err := setWindowsHookEx.Call(whKeyboardLL, hookProc, 0, 0) + if h == 0 { + logger.Error("shortcut: SetWindowsHookEx failed", "err", err) + ready <- fmt.Errorf("SetWindowsHookEx failed: %w", err) + return } - logger.Info("shortcut: RegisterHotKey ok", "hotkey", "ctrl+g") + s.hookH = h + logger.Info("shortcut: WH_KEYBOARD_LL hook installed") ready <- nil + s.messageLoop() }() return <-ready } func (s *windowsService) Unregister() { - unregisterHotKey.Call(0, hotkeyID) - logger.Info("shortcut: UnregisterHotKey called") + if s.hookH != 0 { + unhookWindowsHookEx.Call(s.hookH) + s.hookH = 0 + logger.Info("shortcut: hook uninstalled") + } } func (s *windowsService) Triggered() <-chan ShortcutEvent { return s.ch } -type msg struct { - HWnd uintptr - Message uint32 - WParam uintptr - LParam uintptr - Time uint32 - Pt [2]int32 +func (s *windowsService) UpdateConfig(cfg ShortcutConfig) error { + if err := s.applyConfig(cfg); err != nil { + return err + } + // Reset any in-progress detection state. + if s.threadID != 0 { + killTimer.Call(0, doubleTapTimerID) + } + s.tapState = tapIdle + s.indepPending = -1 + logger.Info("shortcut: config updated", "mode", cfg.Mode, "fix", cfg.FixCombo) + return nil +} + +func (s *windowsService) SetPaused(paused bool) { + s.paused.Store(paused) + if paused { + // Reset detection state when pausing. + if s.threadID != 0 { + killTimer.Call(0, doubleTapTimerID) + } + s.tapState = tapIdle + s.indepPending = -1 + } + logger.Info("shortcut: paused", "paused", paused) +} + +func (s *windowsService) applyConfig(cfg ShortcutConfig) error { + fixKC, err := ParseKeyCombo(cfg.FixCombo) + if err != nil { + return fmt.Errorf("invalid fix combo %q: %w", cfg.FixCombo, err) + } + + var pyrKC KeyCombo + if cfg.Mode == "independent" { + pyrKC, err = ParseKeyCombo(cfg.PyramidizeCombo) + if err != nil { + return fmt.Errorf("invalid pyramidize combo %q: %w", cfg.PyramidizeCombo, err) + } + } + + delay := uint32(cfg.DoubleTapDelay / time.Millisecond) + if delay < 100 { + delay = 100 + } + if delay > 500 { + delay = 500 + } + + s.mu.Lock() + s.mode = cfg.Mode + s.fixCombo = fixKC + s.pyramidizeCombo = pyrKC + s.doubleTapDelay = delay + s.mu.Unlock() + + return nil +} + +// hookCallback is called by Windows for every keyboard event system-wide. +// It must return quickly. Returning 1 suppresses the key; returning CallNextHookEx passes it. +func (s *windowsService) hookCallback(nCode int, wParam uintptr, lParam uintptr) uintptr { + if nCode < 0 { + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + kb := (*kbdLLHookStruct)(unsafe.Pointer(lParam)) + + // Pass through when paused (e.g. during shortcut recording in settings). + if s.paused.Load() { + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + // Pass through our own synthetic keypresses (from CopyFromForeground / PasteToForeground). + if kb.DwExtraInfo == extraInfoTag { + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + vk := uint16(kb.VKCode) + isDown := wParam == wmKeyDown || wParam == wmSysKeyDown + isUp := wParam == wmKeyUp || wParam == wmSysKeyUp + + // Track modifier state. + if mod := vkToModifier(vk); mod != 0 { + if isDown { + s.mods |= mod + } else if isUp { + s.mods &^= mod + // If modifier released during double-tap detection, fire fix immediately. + if s.tapState != tapIdle { + logger.Debug("shortcut: modifier released during double-tap, firing fix") + s.tapState = tapIdle + killTimer.Call(0, doubleTapTimerID) + s.postAction(0) // fix + } + } + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret + } + + // Read current config. + s.mu.Lock() + mode := s.mode + fixKC := s.fixCombo + pyrKC := s.pyramidizeCombo + delay := s.doubleTapDelay + s.mu.Unlock() + + if mode == "independent" { + if isDown { + if vk == fixKC.VK && s.mods == fixKC.Modifiers { + logger.Debug("shortcut: independent fix match (keydown)", "vk", vk, "mods", s.mods) + s.indepPending = 0 + s.indepPendingVK = vk + return 1 // suppress — fire on keyup + } + if vk == pyrKC.VK && s.mods == pyrKC.Modifiers { + logger.Debug("shortcut: independent pyramidize match (keydown)", "vk", vk, "mods", s.mods) + s.indepPending = 1 + s.indepPendingVK = vk + return 1 // suppress — fire on keyup + } + } + if isUp && s.indepPending >= 0 && vk == s.indepPendingVK { + action := s.indepPending + s.indepPending = -1 + logger.Debug("shortcut: independent action (keyup)", "action", action) + s.postAction(uintptr(action)) + return 1 // suppress + } + } else { + // Double-tap mode: match fix combo's trigger key + modifiers. + isTrigger := vk == fixKC.VK && s.mods == fixKC.Modifiers + + switch s.tapState { + case tapIdle: + if isDown && isTrigger { + // First tap → suppress, start timer, wait for keyup before accepting second tap. + logger.Debug("shortcut: first tap, starting timer", "delay", delay) + s.tapState = tapWaitRelease + setTimer.Call(0, doubleTapTimerID, uintptr(delay), 0) + return 1 // suppress + } + + case tapWaitRelease: + if isUp && vk == fixKC.VK { + // Trigger key released after first tap → now accept second tap. + logger.Debug("shortcut: trigger released, waiting for second tap") + s.tapState = tapWaitSecond + } + // Suppress all trigger key events (including auto-repeat keydowns) during this phase. + if vk == fixKC.VK { + return 1 // suppress + } + + case tapWaitSecond: + if isDown && isTrigger { + // Second tap → pyramidize! + logger.Debug("shortcut: second tap detected, firing pyramidize") + s.tapState = tapIdle + killTimer.Call(0, doubleTapTimerID) + s.postAction(1) // pyramidize + return 1 // suppress + } + } + } + + // Not a match — pass through. + ret, _, _ := callNextHookEx.Call(s.hookH, uintptr(nCode), wParam, lParam) + return ret +} + +func (s *windowsService) postAction(action uintptr) { + postThreadMessage.Call(uintptr(s.threadID), wmAction, action, 0) } func (s *windowsService) messageLoop() { @@ -87,10 +335,43 @@ func (s *windowsService) messageLoop() { if ret == 0 { break } - if m.Message == wmHotkey && m.WParam == hotkeyID { - hwnd, _, _ := getForegroundWindow.Call() - logger.Info("shortcut: hotkey fired", "foreground_hwnd", hwnd) - s.ch <- ShortcutEvent{Source: "hotkey"} + switch m.Message { + case wmTimer: + // Double-tap timer expired → fire fix. + if s.tapState != tapIdle { + logger.Debug("shortcut: timer expired, firing fix") + s.tapState = tapIdle + killTimer.Call(0, doubleTapTimerID) + s.postAction(0) // fix + } + case wmAction: + action := "fix" + if m.WParam == 1 { + action = "pyramidize" + } + logger.Info("shortcut: action detected", "action", action) + s.ch <- ShortcutEvent{Source: "hotkey", Action: action} } } } + +// Simulate fires a synthetic shortcut event (used by --simulate-shortcut and dev UI). +func (s *windowsService) Simulate() { + s.ch <- ShortcutEvent{Source: "simulate", Action: "fix"} +} + +// vkToModifier maps virtual key codes to modifier flags. +func vkToModifier(vk uint16) Modifier { + switch vk { + case vkLControl, vkRControl: + return ModCtrl + case vkLShift, vkRShift: + return ModShift + case vkLMenu, vkRMenu: + return ModAlt + case vkLWin, vkRWin: + return ModWin + default: + return 0 + } +} diff --git a/main.go b/main.go index 33f9e50..d4b974c 100644 --- a/main.go +++ b/main.go @@ -31,7 +31,8 @@ var assets embed.FS var appIcon []byte func init() { - application.RegisterEvent[string]("shortcut:triggered") + application.RegisterEvent[string]("shortcut:fix") + application.RegisterEvent[string]("shortcut:pyramidize") application.RegisterEvent[string]("settings:changed") } @@ -120,29 +121,49 @@ func main() { // Register the global shortcut (no-op on Linux). // Unregister on shutdown so dev-mode restarts don't leave a stale registration. - if err := services.Shortcut.Register(); err != nil { + shortcutCfg := shortcut.ShortcutConfig{ + Mode: cfg.ShortcutMode, + FixCombo: cfg.ShortcutFix, + PyramidizeCombo: cfg.ShortcutPyramidize, + DoubleTapDelay: time.Duration(cfg.ShortcutDoubleTapDelay) * time.Millisecond, + } + if err := services.Shortcut.Register(shortcutCfg); err != nil { log.Printf("warn: shortcut registration failed: %v", err) logger.Warn("shortcut: registration failed", "err", err) } else { - logger.Info("shortcut: registered", "key", cfg.ShortcutKey) + logger.Info("shortcut: registered", "mode", cfg.ShortcutMode, "fix", cfg.ShortcutFix) } wailsApp.OnShutdown(func() { services.Shortcut.Unregister() }) - // Forward shortcut events to the frontend. - // First send Ctrl+C to copy selected text from the source app, then notify - // the frontend so it can read the clipboard and enhance the text. - // Show the window so the frontend can receive and process the event. + // Hot-reload shortcuts when settings change. + wailsApp.Event.On("settings:changed", func(ev *application.CustomEvent) { + newCfg := services.Settings.Get() + newShortcutCfg := shortcut.ShortcutConfig{ + Mode: newCfg.ShortcutMode, + FixCombo: newCfg.ShortcutFix, + PyramidizeCombo: newCfg.ShortcutPyramidize, + DoubleTapDelay: time.Duration(newCfg.ShortcutDoubleTapDelay) * time.Millisecond, + } + if err := services.Shortcut.UpdateConfig(newShortcutCfg); err != nil { + logger.Warn("shortcut: hot-reload failed", "err", err) + } + }) + + // Forward classified shortcut events to the frontend. go func() { - ch := services.Shortcut.Triggered() - for event := range ch { - logger.Info("shortcut: triggered", "source", event.Source) - // Capture the source app window BEFORE copying from foreground, - // so SendBack() can restore focus to the correct window later. + for event := range services.Shortcut.Triggered() { + logger.Info("shortcut: action", "action", event.Action, "source", event.Source) pyramidizeSvc.CaptureSourceApp() if err := services.Clipboard.CopyFromForeground(); err != nil { logger.Warn("shortcut: CopyFromForeground failed", "err", err) } - wailsApp.Event.Emit("shortcut:triggered", event.Source) + switch event.Action { + case "fix": + wailsApp.Event.Emit("shortcut:fix", event.Source) + case "pyramidize": + window.Show().Focus() + wailsApp.Event.Emit("shortcut:pyramidize", event.Source) + } } }() @@ -168,3 +189,8 @@ func (s *simulateService) SimulateShortcut() { sim.Simulate() } } + +// SetShortcutPaused temporarily disables shortcut detection (e.g. while recording a new shortcut in settings). +func (s *simulateService) SetShortcutPaused(paused bool) { + s.shortcut.SetPaused(paused) +}