diff --git a/AGENTS.md b/AGENTS.md index 55d088e..63320a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,26 +2,23 @@ ## Project Overview -KeySwift is a Linux keyboard remapping tool designed specifically for GNOME desktop environments. It enables application-specific key mappings using JavaScript configuration files, allowing users to customize keyboard shortcuts for different applications. +KeySwift is a Linux keyboard remapping tool designed specifically for GNOME desktop environments. It enables application-specific key mappings using a JSON configuration file, allowing users to customize keyboard shortcuts for different applications. ### Key Features - **Application-specific remapping**: Define custom keyboard mappings for specific applications based on window class -- **JavaScript configuration**: Flexible configuration using QuickJS JavaScript engine +- **JSON configuration**: Simple, static configuration using a JSON array of rules - **Low-level input handling**: Uses Linux evdev subsystem for direct keyboard input capture - **D-Bus integration**: Communicates with GNOME Shell extension for window focus tracking ## Technology Stack - **Language**: Go 1.22+ -- **JavaScript Engine**: QuickJS (via buke/quickjs-go) - **System Dependencies**: - libevdev-dev (Linux input event handling) - D-Bus (GNOME integration) - **Key Go Dependencies**: - `github.com/godbus/dbus/v5` - D-Bus bindings for Go - - `github.com/buke/quickjs-go` - QuickJS JavaScript engine bindings - `github.com/jialeicui/golibevdev` - Linux evdev wrapper - - `github.com/samber/lo` - Go utilities library - `github.com/stretchr/testify` - Testing framework ## Project Structure @@ -30,29 +27,28 @@ KeySwift is a Linux keyboard remapping tool designed specifically for GNOME desk . ├── cmd/keyswift/main.go # Application entry point and CLI ├── pkg/ -│ ├── bus/ # Event processing and coordination -│ │ ├── impl.go # Main bus implementation -│ │ ├── mode.go # Event type definitions -│ │ └── session.go # Per-event processing session -│ ├── engine/ # JavaScript engine integration -│ │ ├── interfaces.go # Engine and Bus interfaces -│ │ └── quickjs.go # QuickJS implementation +│ ├── config/ # Configuration loading and parsing +│ │ └── types.go # Config types and JSON loader +│ ├── statemachine/ # Key event processing +│ │ ├── machine.go # Core state machine +│ │ ├── config_engine.go # Config-based mapping engine +│ │ ├── config_integration.go # State machine + config integration +│ │ ├── handler_integration.go# Input device management +│ │ ├── adapter.go # Output device adapter +│ │ └── interfaces.go # Core interfaces │ ├── evdev/ # Input device management │ │ ├── evdev.go # Core types │ │ └── overview.go # Device enumeration -│ ├── handler/ # Input event handling -│ │ ├── handler.go # Main event processor -│ │ └── modifier.go # Modifier key state tracking │ ├── keys/ # Key code mappings │ │ └── keys.go # Key name to code conversion │ ├── utils/ # Utilities -│ │ ├── config.go # Configuration path helpers -│ │ └── cache/ # Caching utilities +│ │ └── config.go # Configuration path helpers │ └── wininfo/ # Window information │ ├── wininfo.go # Interface definitions │ └── dbus/ # D-Bus implementation ├── examples/ -│ └── config.js # Example configuration +│ ├── config.json # Example configuration (compact) +│ └── config.json5 # Example configuration (annotated) ├── Makefile # Build automation └── go.mod # Go module definition ``` @@ -61,25 +57,20 @@ KeySwift is a Linux keyboard remapping tool designed specifically for GNOME desk ### Data Flow -1. **Input Capture** (`pkg/handler/`) +1. **Input Capture** (`pkg/statemachine/handler_integration.go`) - Grabs physical keyboard devices via evdev - Processes raw key events - - Tracks modifier key states - - Manages key press/release sequences - -2. **Event Processing** (`pkg/bus/`) - - Receives key events from handler - - Creates isolated session per event - - Executes JavaScript configuration - - Routes output to virtual keyboard - -3. **JavaScript Engine** (`pkg/engine/`) - - Compiles user configuration to bytecode - - Exposes `KeySwift` global object with APIs: - - `getActiveWindowClass()` - Get current application - - `sendKeys(keys[])` - Send key combination - - `onKeyPress(keys[], callback)` - Register key handler - - Fast-path filtering for unregistered key combinations + - Manages device reconnection + +2. **Event Processing** (`pkg/statemachine/`) + - State machine receives key events + - Evaluates mapping rules against current key state and window class + - Routes output commands to virtual keyboard + +3. **Config Mapping Engine** (`pkg/statemachine/config_engine.go`) + - Loads static rules from `pkg/config/` + - Set-based matching: key combinations are unordered sets + - Filters by window class conditions (`window` / `notWindow`) 4. **Window Detection** (`pkg/wininfo/`) - D-Bus service receives window info from GNOME extension @@ -124,29 +115,34 @@ sudo go test -v ./pkg/utils/cache/ ## Configuration -### JavaScript API - -The configuration file (`~/.config/keyswift/config.js`) has access to: - -```javascript -const KeySwift = { - // Returns the window class of the currently focused application - getActiveWindowClass: () => string, - - // Sends a key combination (modifiers: ctrl, alt, cmd/meta/super, shift) - sendKeys: (keys: string[]) => void, - - // Registers a callback for specific key combination - // Must be called at top level, not inside callbacks or conditionals - onKeyPress: (keys: string[], callback: () => void) => void, -} +### JSON Format + +The configuration file (`~/.config/keyswift/config.json`) is a JSON array of rule objects. + +**Rule types:** + +```json +[ + {"type": "var", "name": "terminals", "value": ["kitty", "Gnome-terminal"]}, + { + "type": "map", + "input": ["cmd", "c"], + "output": ["ctrl", "c"], + "when": {"notWindow": "$terminals"} + } +] ``` +- **`var`**: Defines a named list of window classes (`$varname` reference) +- **`map`**: Maps an input key combo to an output combo, with optional `when` condition + - `when.window`: apply only when active window class matches + - `when.notWindow`: apply only when active window class does **not** match + ### Example Configuration -See `examples/config.js` for a comprehensive example including: +See `examples/config.json` and `examples/config.json5` (annotated) for comprehensive examples including: - Terminal-specific mappings (kitty, GNOME Terminal, Ghostty) -- IDE mappings (JetBrains, Cursor, Sublime Text) +- IDE mappings (JetBrains) - macOS-like shortcuts for Linux - Chrome tab switching shortcuts - Emacs-style navigation @@ -166,16 +162,16 @@ See `examples/config.js` for a comprehensive example including: ```bash # List available keyboards and filter by pattern -./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.js +./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.json # Multiple keyboards (comma-separated) -./keyswift -keyboards "HHKB,Logitech" -config ~/.config/keyswift/config.js +./keyswift -keyboards "HHKB,Logitech" -config ~/.config/keyswift/config.json # Verbose logging -./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.js -verbose +./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.json -verbose # Custom output device name -./keyswift -keyboards "HHKB" -output-device-name "my-keyboard" -config ~/.config/keyswift/config.js +./keyswift -keyboards "HHKB" -output-device-name "my-keyboard" -config ~/.config/keyswift/config.json ``` **Important**: Do not run with `sudo`. The application requires user-level permissions with `input` group membership. @@ -190,9 +186,9 @@ See `examples/config.js` for a comprehensive example including: - Structured logging using `log/slog` ### Naming -- Interfaces with `-er` suffix (e.g., `WinGetter`, `Engine`) -- Implementation types with descriptive names (e.g., `Impl`, `QuickJS`, `Receiver`) -- Constants for magic strings (e.g., `FuncSendKeys`, `KeySwiftObj`) +- Interfaces with `-er` suffix (e.g., `WinGetter`, `MappingEngine`) +- Implementation types with descriptive names (e.g., `Impl`, `ConfigMappingEngine`, `Receiver`) +- Constants for key codes and configuration field names ### Error Handling - Return errors with context @@ -203,11 +199,7 @@ See `examples/config.js` for a comprehensive example including: 1. **Input Device Access**: Requires membership in `input` group 2. **D-Bus Communication**: Exposes service on session bus -3. **JavaScript Execution**: User-provided scripts run in QuickJS sandbox with memory limits: - - Memory limit: 1280 KB - - GC threshold: 2560 KB - - Max stack size: 65534 - - Execution timeout: 0 (disabled) +3. **Configuration**: JSON config is parsed at startup; no code execution at runtime ## Key Implementation Details diff --git a/CLAUDE.md b/CLAUDE.md index 0c4a408..a865d05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # KeySwift Project Overview ## What is KeySwift -A Linux keyboard remapping tool for GNOME environments that allows application-specific key mappings using JavaScript configuration. +A Linux keyboard remapping tool for GNOME environments that allows application-specific key mappings using a JSON configuration file. ## Quick Start ```bash @@ -9,48 +9,46 @@ A Linux keyboard remapping tool for GNOME environments that allows application-s make # Run (after setup) -./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.js +./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.json ``` ## Architecture -- **Language**: Go 1.22 + QuickJS (JavaScript engine) +- **Language**: Go 1.22+ - **Core Components**: - `cmd/keyswift/main.go` - Entry point and CLI - - `pkg/bus/` - Event processing and JS engine integration - - `pkg/handler/` - Input device management + - `pkg/config/` - JSON configuration loading and parsing + - `pkg/statemachine/` - State machine for key event processing - `pkg/evdev/` - Linux input device handling - - `pkg/engine/` - QuickJS JavaScript engine - `pkg/wininfo/` - Window context detection via D-Bus + - `pkg/keys/` - Key name to evdev code mapping ## Key Files -- `examples/config.js` - Configuration examples +- `examples/config.json` - Configuration example +- `examples/config.json5` - Annotated configuration example - `README.md` - Complete setup guide - `Makefile` - Build configuration -- `pkg/handler/keystate.go` - New key state management system - -## Key Handling Improvements (July 2025) -- **KeyStateManager**: Added proper physical/virtual keyboard state synchronization -- **Debouncing**: 5ms threshold to prevent rapid key event issues -- **State Sync**: 100ms periodic synchronization to prevent stuck keys -- **Emergency Reset**: Automatic key release on shutdown -- **Event Ordering**: Improved modifier handling with proper press/release sequences -- **Duplicate Prevention**: Eliminates duplicate key events in remapped combinations - -## Key APIs (JavaScript) -```js -KeySwift.getActiveWindowClass() // Get current app -KeySwift.sendKeys(["ctrl", "c"]) // Send key combo -KeySwift.onKeyPress(["cmd", "v"], callback) // Bind keys + +## Configuration Format (JSON) +The config is a JSON array of rule objects: +```json +[ + {"type": "var", "name": "terminals", "value": ["kitty", "Gnome-terminal"]}, + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "c"], "when": {"notWindow": "$terminals"}} +] ``` +Rule types: +- `var` - Named list of window classes for reuse (`$varname`) +- `map` - Key mapping with optional `when.window` / `when.notWindow` conditions + ## Setup Requirements 1. Install GNOME extension: `keyswift-gnome-ext` 2. Add user to `input` group 3. Configure udev rules for input access -4. Create `~/.config/keyswift/config.js` +4. Create `~/.config/keyswift/config.json` ## Dependencies - libevdev-dev - golang 1.22+ - godbus/dbus/v5 -- QuickJS-go \ No newline at end of file +- jialeicui/golibevdev \ No newline at end of file diff --git a/README.md b/README.md index 9c13fb3..b24ab0d 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # KeySwift -KeySwift is a keyboard remapping tool designed for Linux gnome desktop environments. It allows you to customize keyboard mappings for different applications, enhancing your productivity and typing experience. +KeySwift is a keyboard remapping tool designed for Linux GNOME desktop environments. It allows you to customize keyboard mappings for different applications, enhancing your productivity and typing experience. ## Features - **Application-specific remapping**: Define custom keyboard mappings for specific applications -- **Flexible configuration**: Simple and intuitive configuration format +- **Flexible configuration**: Simple and intuitive JSON configuration format ## Installation @@ -26,36 +26,43 @@ make ## Configuration -Create a configuration file at `~/.config/keyswift/config.js`. Here's an example: +Create a configuration file at `~/.config/keyswift/config.json`. The configuration is a JSON array of rule objects. -```js -const curWindowClass = KeySwift.getActiveWindowClass(); -const Terminals = ["kitty", "Gnome-terminal", "org.gnome.Terminal"]; -const inTerminal = Terminals.includes(curWindowClass); +Each rule has a `type` field: +- `"var"` — define a named variable (list of window classes) for reuse +- `"map"` — define a key mapping with optional window conditions -KeySwift.onKeyPress(["cmd", "v"], () => { - if (curWindowClass === "com.mitchellh.ghostty") { - KeySwift.sendKeys(["shift", "ctrl", "v"]); - return - } +### Example - if (inTerminal) { - KeySwift.sendKeys(["cmd", "shift", "v"]); - } -}); +```json +[ + {"type": "var", "name": "terminals", "value": ["kitty", "Gnome-terminal", "org.gnome.Terminal"]}, + + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "c"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "shift", "c"], "when": {"window": "$terminals"}}, + {"type": "map", "input": ["cmd", "v"], "output": ["ctrl", "v"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "v"], "output": ["ctrl", "shift", "v"], "when": {"window": "$terminals"}} +] ``` -You can also see more examples in the [examples](examples) directory. +See the [examples](examples) directory for a more comprehensive configuration. -KeySwift's config is implemented based on [QuickJS](https://bellard.org/quickjs), and all available objects and functions are as follows: +### Key Names (case-insensitive) -```js -const KeySwift = { - getActiveWindowClass: () => string, - sendKeys: (keys: string[]) => void, - onKeyPress: (keys: string[], callback: () => void) => void, -} -``` +| Category | Names | +|------------|-------| +| Modifiers | `ctrl`, `alt`, `cmd`/`meta`/`super`, `shift` | +| Letters | `a`–`z` | +| Numbers | `0`–`9` | +| Function | `f1`–`f12` | +| Navigation | `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right` | +| Special | `esc`, `tab`, `space`, `enter`, `backspace`, `delete`, `insert` | + +### Conditions (`when`) + +- `window`: mapping applies only when the active window class matches (string or array) +- `notWindow`: mapping applies when the active window class does **not** match (string or array) +- Use `$varname` to reference a previously-defined `var` rule ## Acknowledgments @@ -67,20 +74,19 @@ KeySwift was inspired by several excellent projects: Thank you to the maintainers of these projects for your contributions to open-source keyboard customization tools! -This project also draws inspiration from [AutoHotkey](https://www.autohotkey.com)'s design philosophy. Thanks to this amazing project +This project also draws inspiration from [AutoHotkey](https://www.autohotkey.com)'s design philosophy. Thanks to this amazing project. ## Tips ### How to get the active window class -You can use the `cmd+i` shortcut to print the active window class to the console with the following configuration: +Run the following command to print all visible window classes so you can find the right name for your application: -```js -KeySwift.onKeyPress(["cmd", "i"], () => { - const curWindowClass = KeySwift.getActiveWindowClass(); - console.log(curWindowClass); -}); +```bash +xprop WM_CLASS | awk '{print $NF}' +# Then click the window you want to identify ``` + ### How to run the program 1. Install the keyswift gnome extension @@ -105,7 +111,7 @@ echo 'KERNEL=="uinput", GROUP="input", TAG+="uaccess"' | sudo tee /etc/udev/rule ```bash # XXX is the substring of the keyboard device name -./keyswift -keyboards XXX -config ~/.config/keyswift/config.js +./keyswift -keyboards XXX -config ~/.config/keyswift/config.json ``` - if you have multiple keyboards, you can use comma to separate them - if you don't know the device name, you can leave it blank and the program will print all the keyboard device names and you can select one of them diff --git a/cmd/keyswift/main.go b/cmd/keyswift/main.go index 400faa6..43b1ccd 100644 --- a/cmd/keyswift/main.go +++ b/cmd/keyswift/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "log/slog" @@ -8,21 +9,17 @@ import ( "os/signal" "strings" "syscall" - "time" - "github.com/jialeicui/golibevdev" - "github.com/samber/lo" - - "github.com/jialeicui/keyswift/pkg/bus" + "github.com/jialeicui/keyswift/pkg/config" "github.com/jialeicui/keyswift/pkg/evdev" - "github.com/jialeicui/keyswift/pkg/handler" + "github.com/jialeicui/keyswift/pkg/statemachine" "github.com/jialeicui/keyswift/pkg/utils" "github.com/jialeicui/keyswift/pkg/wininfo/dbus" ) var ( flagKeyboards = flag.String("keyboards", "HHKB", "Comma-separated list of keyboard device name substrings") - flagConfig = flag.String("config", "", "Configuration file path (defaults to $XDG_CONFIG_HOME/keyswift/config.js)") + flagConfig = flag.String("config", "", "Configuration file path (defaults to $XDG_CONFIG_HOME/keyswift/config.json)") flagVerbose = flag.Bool("verbose", false, "Enable verbose logging") flagOutputDeviceName = flag.String("output-device-name", "keyswift", "Name of the virtual keyboard device") flagVersion = flag.Bool("version", false, "Print version information and exit") @@ -43,11 +40,13 @@ func main() { } // Configure logging + logLevel := slog.LevelInfo if *flagVerbose { - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, - }))) + logLevel = slog.LevelDebug } + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: logLevel, + }))) // Load configuration configPath := *flagConfig @@ -55,6 +54,13 @@ func main() { configPath = utils.DefaultConfigPath() } + cfg, err := config.LoadConfig(configPath) + if err != nil { + slog.Error("Failed to load configuration", "error", err, "path", configPath) + os.Exit(1) + } + slog.Info("Configuration loaded", "mappings", len(cfg.Mappings), "vars", len(cfg.Vars)) + // Initialize window info service windowMonitor, err := dbus.New() if err != nil { @@ -65,26 +71,11 @@ func main() { slog.Info("Window Monitor service is running...") // Initialize virtual keyboard for output - out, err := golibevdev.NewVirtualKeyboard(*flagOutputDeviceName) + out, err := statemachine.NewRecoveringOutputDevice(*flagOutputDeviceName) if err != nil { slog.Error("Failed to create virtual keyboard", "error", err) os.Exit(1) } - defer out.Close() - - script, err := os.ReadFile(configPath) - if err != nil { - slog.Error("Failed to read configuration file", "error", err) - os.Exit(1) - } - - // Initialize bus manager - busMgr, err := bus.New(string(script), windowMonitor, out) - if err != nil { - slog.Error("Failed to initialize bus manager", "error", err) - os.Exit(1) - } - slog.Info("bus manager initialized") // Find input devices devs, err := evdev.NewOverviewImpl().ListInputDevices() @@ -95,25 +86,7 @@ func main() { // Parse keyboard patterns and find matching devices keyboardPatterns := strings.Split(*flagKeyboards, ",") - var matchedDevices []*evdev.InputDevice - - for _, pattern := range keyboardPatterns { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - - matches := lo.Filter(devs, func(item *evdev.InputDevice, _ int) bool { - return strings.Contains(item.Name, pattern) && item.Name != *flagOutputDeviceName - }) - - matchedDevices = append(matchedDevices, matches...) - } - - // Remove duplicates - matchedDevices = lo.UniqBy(matchedDevices, func(dev *evdev.InputDevice) string { - return dev.Path - }) + matchedDevices := findMatchingDevices(devs, keyboardPatterns) if len(matchedDevices) == 0 { slog.Info("Available keyboards:") @@ -124,62 +97,61 @@ func main() { os.Exit(1) } - // Initialize and set up the device manager - deviceManager := handler.New() - defer deviceManager.Close() + // Initialize handler with state machine + handler := statemachine.NewHandlerWithStateMachine(cfg) + defer handler.Close() - // Add all matched devices to the manager for _, d := range matchedDevices { - slog.Info("Using keyboard: ", d.Name, d.Path) - if err := deviceManager.AddDevice(d.Name, d.Path); err != nil { + slog.Info("Using keyboard", "name", d.Name, "path", d.Path) + if err := handler.AddDevice(d.Name, d.Path); err != nil { slog.Warn("Failed to add device", "device", d.Name, "error", err) continue } } - // Handle signals + // Setup signal handling with context cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - // Try to reconnect DBus in the background - go func() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if _, ok := windowMonitor.(*dbus.DegradedReceiver); ok { - slog.Info("Attempting to reconnect to DBus...") - newMonitor, err := dbus.New() - if err == nil { - slog.Info("Successfully reconnected to DBus") - windowMonitor = newMonitor - // Update bus manager's window monitor - busMgr.UpdateWindowMonitor(windowMonitor) - // success, break - return - } - } - case <-sigChan: - return - } - } - }() - go func() { <-sigChan slog.Info("Shutting down...") - deviceManager.Close() - out.Close() - windowMonitor.Close() - os.Exit(0) + cancel() + handler.Close() }() - // Start processing events from all devices - slog.Info(fmt.Sprintf("Processing events from %d devices... Press Ctrl+C to exit", len(deviceManager.GetDevices()))) - deviceManager.ProcessEvents(out, busMgr) + // Start processing events + slog.Info(fmt.Sprintf("Processing events from %d devices... Press Ctrl+C to exit", len(matchedDevices))) + handler.ProcessEvents(out, windowMonitor) + + // Wait for context cancellation or processing to complete + <-ctx.Done() + slog.Info("Shutdown complete") + + handler.Wait() +} + +// findMatchingDevices filters input devices by pattern, removing duplicates +func findMatchingDevices(devs []*evdev.InputDevice, patterns []string) []*evdev.InputDevice { + seen := make(map[string]bool) + var matched []*evdev.InputDevice + + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + + for _, dev := range devs { + if strings.Contains(dev.Name, pattern) && dev.Name != *flagOutputDeviceName && !seen[dev.Path] { + seen[dev.Path] = true + matched = append(matched, dev) + } + } + } - // Wait for all processing to complete (typically won't reach here except on error) - deviceManager.Wait() + return matched } diff --git a/docs/CONFIG_V2.md b/docs/CONFIG_V2.md new file mode 100644 index 0000000..2ecfcb4 --- /dev/null +++ b/docs/CONFIG_V2.md @@ -0,0 +1,196 @@ +# KeySwift Configuration Format V2 + +## Overview + +KeySwift now uses a **declarative JSON-based configuration** that is loaded once at startup and compiled into static rules for the State Machine. This provides: + +- **Better performance**: No JavaScript runtime overhead during key processing +- **No sticky keys**: State Machine architecture with proper modifier handling +- **Type safety**: Configuration is validated at load time +- **Predictable behavior**: All rules are known at startup + +## Quick Start + +1. Create `~/.config/keyswift/config.json`: +```json +[ + {"type": "var", "name": "terminals", "value": ["kitty", "Gnome-terminal"]}, + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "c"]}, + {"type": "map", "input": ["cmd", "v"], "output": ["ctrl", "v"]} +] +``` + +2. Run KeySwift with state machine enabled (default): +```bash +./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.json +``` + +## Configuration Format + +Configuration is a JSON array of rule objects. Each rule has a `type` field. + +### Variable Definition (`type: "var"`) + +Define reusable groups of window classes: + +```json +{ + "type": "var", + "name": "terminals", + "value": ["kitty", "Gnome-terminal", "org.gnome.Terminal"] +} +``` + +Variables can reference other variables using `$varname`: + +```json +{ + "type": "var", + "name": "vimMode", + "value": ["Cursor", "$jetbrains"] +} +``` + +### Key Mapping (`type: "map"`) + +Define key remapping rules: + +```json +{ + "type": "map", + "input": ["cmd", "c"], + "output": ["ctrl", "c"], + "when": { + "window": "Google-chrome", + "notWindow": ["$terminals", "kitty"] + } +} +``` + +Fields: +- `input` (required): Array of key names to match +- `output` (required): Array of key names to send +- `when` (optional): Conditions for when this mapping applies + +### Conditions (`when`) + +- `window`: Only match when window class is in the list +- `notWindow`: Only match when window class is NOT in the list + +Both accept a single string or an array of strings. Variable references (`$varname`) are supported. + +## Key Names + +Key names are case-insensitive. Common names: + +### Modifiers +- `ctrl`, `ctrl-l`, `lctrl`, `ctrl-r`, `rctrl` - Control keys +- `alt`, `alt-l`, `lalt`, `alt-r`, `ralt` - Alt keys +- `cmd`, `meta`, `super`, `lcmd`, `lmeta`, `lsuper` - Left Meta/Super/Cmd +- `rcmd`, `rmeta`, `rsuper` - Right Meta/Super/Cmd +- `shift`, `shift-l`, `lshift`, `shift-r`, `rshift` - Shift keys + +### Alphanumeric +- Letters: `a` through `z` +- Numbers: `0` through `9` +- Function keys: `f1` through `f12` + +### Special Keys +- `esc`, `escape` - Escape +- `tab` - Tab +- `space` - Spacebar +- `enter`, `return` - Enter/Return +- `backspace` - Backspace +- `delete`, `del` - Delete +- `insert`, `ins` - Insert +- `home` - Home +- `end` - End +- `pageup`, `pgup` - Page Up +- `pagedown`, `pgdn` - Page Down +- `up`, `down`, `left`, `right` - Arrow keys +- `capslock` - Caps Lock + +### Numpad +- `kp0` through `kp9` - Numpad numbers +- `kpenter` - Numpad Enter +- `kpplus`, `kpminus`, `kpasterisk`, `kpslash` - Numpad operators +- `kpdot` - Numpad Dot + +### Media Keys (if supported by your keyboard) +- `mute`, `volumedown`, `volumeup` - Volume control +- `playpause`, `stop`, `previoussong`, `nextsong` - Media control + +## Complete Example + +See `examples/config.json5` for a comprehensive, commented example configuration. + +## Migration from Old JavaScript Config + +Old config (JavaScript): +```javascript +const Terminals = ["kitty", "Gnome-terminal"]; + +KeySwift.onKeyPress(["cmd", "c"], () => { + if (Terminals.includes(curWindowClass)) { + KeySwift.sendKeys(["ctrl", "shift", "c"]); + } else { + KeySwift.sendKeys(["ctrl", "c"]); + } +}); +``` + +New config (JSON): +```json +[ + {"type": "var", "name": "terminals", "value": ["kitty", "Gnome-terminal"]}, + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "c"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "shift", "c"], "when": {"window": "$terminals"}} +] +``` + +Key differences: +1. No JavaScript code, pure declarative JSON +2. Multiple rules with conditions instead of if-else +3. Order matters: first matching rule wins + +## Debugging + +Use `-verbose` flag to see detailed matching: + +```bash +./keyswift -keyboards "HHKB" -config ~/.config/keyswift/config.json -verbose +``` + +You'll see logs like: +``` +msg="Mapping matched" input=[KeyLeftMeta,KeyC] output=[KeyLeftCtrl,KeyC] window=Google-chrome +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ KeySwift Process │ +├─────────────────────────────────────────────────────────────┤ +│ Load Phase (Once at startup) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ config.json │───▶│ Parser │───▶│ Static │ │ +│ │ │ │ │ │ Rules │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +├─────────────────────────────────────────────────────────────┤ +│ Runtime Phase (Per key event) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Input Device │───▶│ State Machine│───▶│ Output Device│ │ +│ │ │ │ + Rules │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +The State Machine handles: +- Modifier state tracking (pending/active/independent) +- Debouncing +- Key combination matching +- Window class filtering +- Output command generation + +No JavaScript is executed at runtime, ensuring consistent low latency and no sticky keys. diff --git a/docs/design/state-machine-architecture.md b/docs/design/state-machine-architecture.md new file mode 100644 index 0000000..35549e5 --- /dev/null +++ b/docs/design/state-machine-architecture.md @@ -0,0 +1,876 @@ +# KeySwift State Machine Architecture Design + +## Table of Contents + +1. [Background and Problem Definition](#1-background-and-problem-definition) +2. [Core Challenges](#2-core-challenges) +3. [Special Cases](#3-special-cases) +4. [Architecture Design](#4-architecture-design) +5. [Detailed Design](#5-detailed-design) +6. [State Transitions](#6-state-transitions) +7. [Implementation Roadmap](#7-implementation-roadmap) + +--- + +## 1. Background and Problem Definition + +### 1.1 Problems in Current Architecture + +KeySwift's existing event-driven architecture has fundamental flaws when handling complex input scenarios: + +``` +Current Architecture: +Physical Keyboard → Event Capture → JavaScript Decision → Direct Output → Virtual Device + ↓ + No State Management +``` + +**Core Issues**: +- **State Desynchronization**: Physical key states and output device states are not modeled uniformly +- **Race Conditions**: Event ordering cannot be guaranteed during rapid key combinations +- **Sticky Keys**: Modifier keys (Ctrl/Alt/Super) easily enter a "continuously pressed" state after remapping +- **Intent Ambiguity**: Cannot distinguish between "pressing Super alone" and "Super+C combination" intents + +### 1.2 Target Scenarios + +#### Scenario A: Basic Mapping +``` +User Action: Cmd + C +System Output: Ctrl + C +``` + +#### Scenario B: Delayed Combination (Critical Scenario) +``` +T0: Press Cmd +T+0.5s: Press C + +Requirements: +- During T0~T+0.5s, Cmd is available for mouse operations (e.g., Cmd+Click) +- At T+0.5s, convert to Ctrl+C +- Conversion is transparent to the user +``` + +#### Scenario C: Rapid Switching +``` +User Action: Cmd+C (quick) → Release C immediately → Press V immediately +Requirements: +- Ctrl+C triggers correctly +- Ctrl doesn't get stuck, preventing accidental Ctrl+V trigger +``` + +#### Scenario D: Mixed Scenario +``` +User Actions: +1. Hold Cmd (preparing for mouse operation) +2. Change mind, press C (want to copy) +3. Release C +4. Continue using Cmd+mouse operation + +Requirements: +- Step 2 correctly triggers Ctrl+C +- Step 4 Cmd remains effective +``` + +--- + +## 2. Core Challenges + +### 2.1 Challenge 1: The Delayed Decision Paradox + +``` +Problem Essence: +- When Cmd is pressed, the system doesn't know user intent +- If pass-through immediately, subsequent conversion needs to "reclaim" the sent key +- If no pass-through, standalone Cmd scenario fails + +Deficiencies in Traditional Solutions: +1. Timeout Mechanism: Set fixed threshold (e.g., 200ms) + - Problem: Threshold is hard to determine, varies by user habit + +2. Compensation Mechanism: Send release then resend + - Problem: User is still holding the key, causing physical/system state mismatch +``` + +### 2.2 Challenge 2: State Consistency + +``` +Problem Essence: +Physical World System State + Cmd pressed → Cmd released (after compensation) + +When user releases Cmd: +- System receives Release event +- But system thinks Cmd is already released +- Causes state tracking chaos +``` + +### 2.3 Challenge 3: Atomicity Guarantee + +``` +Problem Essence: +Key combination mapping is not atomic: +Cmd↓ C↓ → [Convert] → Ctrl↓ C↓ C↑ Ctrl↑ + +Any step failure or interruption in between leads to sticky keys. +``` + +### 2.4 Challenge 4: Cross-Device Synchronization + +``` +Problem Essence: +- Multiple physical keyboards input simultaneously +- Virtual output device needs to synchronize with physical device states +- System-level shortcuts (e.g., Ctrl+Alt+T) need special handling +``` + +--- + +## 3. Special Cases + +### 3.1 Case 1: Repeat Trigger Protection + +``` +User Action: +Cmd↓ C↓ C↑ C↓ C↑ ... (rapid consecutive presses) + +Risk: +- Each C↓ triggers a new mapping +- Ctrl is repeatedly pressed/released, potentially causing state chaos + +Solution: +- Same combination only triggers once before Cmd is released +- Or: Support repeat triggering but maintain state consistency +``` + +### 3.2 Case 2: Nested Combinations + +``` +User Action: +Cmd↓ Shift↓ C↓ + +Mapping Configuration: +- Cmd+C → Ctrl+C +- Cmd+Shift+C → Ctrl+Shift+C + +Challenge: +- Need to handle hierarchical relationships of combinations +- Avoid false triggers from partial matching +``` + +### 3.3 Case 3: Partial Release + +``` +User Action: +Cmd↓ C↓ → Release Cmd (while holding C) → Release C + +Risk: +- After Ctrl+C triggers, Cmd release should release Ctrl +- But C is still held, potentially causing C to stick + +Solution: +- Binding relationship tracking: Ctrl binds to [Cmd, C] +- Release triggered when any bound key is released +``` + +### 3.4 Case 4: System-level Shortcut Passthrough + +``` +Scenario: +User configures Cmd+Space to map to Ctrl+Space +But system needs Cmd+Space to switch input methods + +Solution: +- Whitelist mechanism: Certain combinations are never mapped +- Or: Send original combination after mapping (compatibility mode) +``` + +### 3.5 Case 5: Multi-keyboard Input + +``` +Scenario: +- Keyboard A presses Cmd +- Keyboard B presses C + +Challenge: +- Requires global state management, not per-device +- Avoid one keyboard's release affecting another keyboard's state +``` + +--- + +## 4. Architecture Design + +### 4.1 Core Philosophy + +**Layered State Machines + Explicit State Binding + Speculative Execution** + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ KeySwift State Machine │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ Input Layer │───▶│ Semantic Layer │───▶│ Mapping Engine │ │ +│ │ (Physical Input)│ │ (Semantic Abstr)│ │ (Mapping Decision) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │ +│ │ Input State │ │ Semantic State │ │ Output Commands │ │ +│ │ Machine │ │ Machine │ │ (State Transition) │ │ +│ │ (ISM) │ │ (SSM) │ │ │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════════ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Output State Machine (OSM) │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Desired │───▶│ Aligner │───▶│ Actual │ │ │ +│ │ │ State │ │(State Align) │ │ State │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ │ │ │ │ │ +│ │ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ │ +│ │ │ State Binder (State Binding) │ │ │ +│ │ │ • Output Key ↔ Input Key Binding │ │ │ +│ │ │ • Lifecycle Management │ │ │ +│ │ │ • Auto-release Tracking │ │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════════ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Speculative Executor │ │ +│ │ │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Speculative │───▶│ Confirmation │───▶│ Compensation │ │ │ +│ │ │ Layer │ │ Window │ │ Engine │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Key Components + +#### 4.2.1 Input State Machine (ISM) + +```go +type ISM struct { + // Current physical state + current InputState + + // Historical states (for debounce detection, double-tap recognition, etc.) + history *RingBuffer[InputState] + + // Multi-device aggregation + devices map[DeviceID]*DeviceState +} + +type InputState struct { + Timestamp time.Time + KeyStates map[KeyCode]KeyState + + // Raw event sequence (order-preserving) + EventSeq []KeyEvent +} + +type KeyState struct { + Pressed bool + PressedAt time.Time + Device DeviceID + + // For debounce elimination + PressCount int // Number of presses in short time +} +``` + +#### 4.2.2 Semantic State Machine (SSM) + +```go +type SSM struct { + // Semantic state after transformation + current SemanticState + + // Configuration parameters + config SemanticConfig +} + +type SemanticState struct { + // Modifier semantics (for combination shortcuts) + Modifiers ModifierSemantic + + // Independent key semantics (for mouse operations, etc.) + Independents map[KeyCode]IndependentState + + // Combination state + Combo ComboState +} + +type ModifierSemantic struct { + // Which keys currently act as modifiers + Active map[KeyCode]ModifierInfo + + // Which keys are in "pending" state (can become independent) + Pending map[KeyCode]*PendingModifier +} + +type PendingModifier struct { + Key KeyCode + PressedAt time.Time + + // Key: Downgrade deadline + DowngradeDeadline time.Time + + // Whether already passed-through + PassedThrough bool +} + +type IndependentState struct { + Key KeyCode + IsPressed bool + + // Flag: Whether downgraded from Pending + DowngradedFrom bool +} +``` + +#### 4.2.3 Output State Machine (OSM) + +```go +type OSM struct { + // Desired state (determined by mapping engine) + desired OutputState + + // Actual state (already sent to virtual device) + actual OutputState + + // State binder + binder *StateBinder + + // Aligner + aligner *StateAligner + + // Output device + output OutputDevice +} + +type OutputState struct { + PressedKeys map[KeyCode]OutputKeyInfo +} + +type OutputKeyInfo struct { + PressedAt time.Time + SourceMapping MappingID + + // Core: Which input keys this binds to + BoundTo []KeyCode + + // Release strategy + ReleaseStrategy ReleaseStrategy +} + +type ReleaseStrategy int +const ( + ReleaseOnAnyBoundKeyReleased ReleaseStrategy = iota + ReleaseOnAllBoundKeysReleased + ReleaseOnTimeout +) +``` + +#### 4.2.4 State Binder (Core Innovation) + +```go +type StateBinder struct { + // Input key → Output reverse index + // For fast lookup: when an input key is released, which outputs need release + inputToOutput map[KeyCode]map[KeyCode]struct{} + + // Binding relationship graph + bindings map[BindingID]*Binding +} + +type Binding struct { + ID BindingID + InputKeys []KeyCode + OutputKeys []KeyCode + + // Creation time (for debugging) + CreatedAt time.Time + + // Binding status + Status BindingStatus +} + +// When input key is released, automatically release bound output keys +func (sb *StateBinder) OnInputKeyReleased(key KeyCode) []KeyCode { + affectedOutputs := sb.inputToOutput[key] + + var toRelease []KeyCode + for outKey := range affectedOutputs { + binding := sb.findBinding(outKey) + + // Check release conditions + if sb.shouldRelease(binding, key) { + toRelease = append(toRelease, outKey) + sb.unbind(outKey) + } + } + + return toRelease +} +``` + +#### 4.2.5 Speculative Executor + +```go +type SpeculativeExecutor struct { + // Speculative layer + layer *SpeculativeLayer + + // Compensation engine + compensator *Compensator + + // Confirmation window configuration + config SpeculativeConfig +} + +type SpeculativeLayer struct { + // Pending speculative operations + pending map[KeyCode]*SpeculativeOp + + // Confirmed operations (irrevocable) + confirmed map[KeyCode]*SpeculativeOp +} + +type SpeculativeOp struct { + ID OpID + Key KeyCode + Action KeyAction // Press or Release + + // Execution time + ExecutedAt time.Time + + // Confirmation deadline + ConfirmDeadline time.Time + + // Status + Status SpeculativeStatus + + // Compensation strategy + Compensation CompensationStrategy +} + +type CompensationStrategy struct { + // How to revoke this operation + RevertActions []OutputCommand + + // Alignment actions after revocation + AlignmentActions []OutputCommand +} +``` + +--- + +## 5. Detailed Design + +### 5.1 Data Flow + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Event │────▶│ ISM │────▶│ SSM │────▶│ Mapping │────▶│ OSM │ +│ Source │ │ │ │ │ │ Engine │ │ │ +└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ │ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + Raw Event Input State Semantic State Output Cmds Device Output +(Physical) (Physical) (Semantic) (Commands) (Virtual) +``` + +### 5.2 Key Algorithms + +#### 5.2.1 Semantic Translation Algorithm + +```go +func (ssm *SSM) Translate(input InputState) SemanticState { + semantic := SemanticState{ + Modifiers: ModifierSemantic{ + Active: make(map[KeyCode]ModifierInfo), + Pending: make(map[KeyCode]*PendingModifier), + }, + Independents: make(map[KeyCode]IndependentState), + } + + for key, state := range input.KeyStates { + if !isModifier(key) { + // Non-modifier keys pass through directly + semantic.Combo.ActiveKeys = append(semantic.Combo.ActiveKeys, key) + continue + } + + // Check for potential matches + potentialMatches := ssm.mappingEngine.PrefixMatch([]KeyCode{key}) + + if len(potentialMatches) == 0 { + // No potential matches, treat as independent key + semantic.Independents[key] = IndependentState{ + Key: key, + IsPressed: true, + } + continue + } + + // Calculate hold duration + holdDuration := time.Since(state.PressedAt) + + if holdDuration < ssm.config.ModifierPendingThreshold { + // Within threshold, mark as Pending + semantic.Modifiers.Pending[key] = &PendingModifier{ + Key: key, + PressedAt: state.PressedAt, + DowngradeDeadline: state.PressedAt.Add(ssm.config.ModifierPendingThreshold), + PassedThrough: false, // Don't pass-through yet + } + } else { + // Exceeds threshold, convert to independent key + semantic.Independents[key] = IndependentState{ + Key: key, + IsPressed: true, + DowngradedFrom: true, + } + } + } + + return semantic +} +``` + +#### 5.2.2 Speculative Execution and Compensation + +```go +func (se *SpeculativeExecutor) ExecuteSpeculative(cmd OutputCommand) error { + // 1. Execute operation + se.output.Execute(cmd) + + // 2. Create speculative operation record + op := &SpeculativeOp{ + ID: generateID(), + Key: cmd.Key, + Action: cmd.Action, + ExecutedAt: time.Now(), + ConfirmDeadline: time.Now().Add(se.config.ConfirmationWindow), + Status: SpeculativePending, + Compensation: se.buildCompensation(cmd), + } + + // 3. Add to pending queue + se.layer.pending[cmd.Key] = op + + // 4. Start confirmation timer + se.startConfirmationTimer(op) + + return nil +} + +func (se *SpeculativeExecutor) ConfirmOrCompensate(key KeyCode, matchFound bool) error { + op, ok := se.layer.pending[key] + if !ok { + return nil // Not a speculative operation + } + + if matchFound { + // Confirm: convert to irrevocable + op.Status = SpeculativeConfirmed + se.layer.confirmed[key] = op + delete(se.layer.pending, key) + return nil + } + + // Needs compensation + return se.compensate(op) +} + +func (se *SpeculativeExecutor) compensate(op *SpeculativeOp) error { + // 1. Execute compensation operations + for _, cmd := range op.Compensation.RevertActions { + se.output.Execute(cmd) + } + + // 2. Execute alignment operations + for _, cmd := range op.Compensation.AlignmentActions { + se.output.Execute(cmd) + } + + // 3. Mark status + op.Status = SpeculativeCompensated + delete(se.layer.pending, op.Key) + + // 4. Record compensation history (for subsequent alignment) + se.compensator.Record(op) + + return nil +} +``` + +#### 5.2.3 State Alignment + +```go +func (sa *StateAligner) Align() error { + diff := sa.calculateDiff() + + // Process keys that should be released + for _, key := range diff.ShouldRelease { + // Check if it's a compensated key + if sa.compensator.IsCompensated(key) { + // Special handling: user might still be holding this key + sa.handleCompensatedKeyRelease(key) + } else { + sa.forceRelease(key) + } + } + + // Process keys that should be pressed + for _, key := range diff.ShouldPress { + sa.forcePress(key) + } + + return nil +} + +func (sa *StateAligner) handleCompensatedKeyRelease(key KeyCode) { + // Strategy: Don't send release, wait for physical release + // But mark as "expected release" to prevent duplicate press + sa.pendingReleases[key] = PendingRelease{ + Key: key, + ExpectedAt: time.Now(), + } +} +``` + +### 5.3 Configuration Design + +```go +type StateMachineConfig struct { + // ISM configuration + Input InputConfig + + // SSM configuration + Semantic SemanticConfig + + // OSM configuration + Output OutputConfig + + // Speculative execution configuration + Speculative SpeculativeConfig +} + +type SemanticConfig struct { + // Modifier pending threshold + // After this time without forming a combination, convert to independent + ModifierPendingThreshold time.Duration // Default 200ms + + // Whether to allow downgrade (independent → modifier) + AllowDowngrade bool // Default true + + // Downgrade time window + DowngradeWindow time.Duration // Default 100ms + + // Debounce threshold + DebounceThreshold time.Duration // Default 5ms +} + +type SpeculativeConfig struct { + // Confirmation window + ConfirmationWindow time.Duration // Default 50ms + + // Whether to enable speculative execution + Enabled bool // Default true + + // Compensation strategy + CompensationMode CompensationMode +} + +type CompensationMode int +const ( + // Immediate compensation: send release + CompensationImmediate CompensationMode = iota + + // Deferred compensation: wait for physical release + CompensationDeferred + + // Smart compensation: decide based on context + CompensationSmart +) +``` + +--- + +## 6. State Transitions + +### 6.1 Normal Mapping Flow + +``` +[Idle] + │ + │ Cmd↓ + ▼ +[PendingModifier] ──(timeout 200ms)──▶ [Independent] ──(Cmd↑)──▶ [Idle] + │ │ + │ C↓ (within timeout) │ + ▼ │ +[ComboMatched] │ + │ │ + │ Convert: Ctrl↓ C↓ │ + ▼ │ +[MappingActive] ◀─────────────────────┘ + │ + │ C↑ + ▼ +[PartialRelease] + │ + │ Cmd↑ + ▼ +[Idle] (Send Ctrl↑) +``` + +### 6.2 Delayed Decision Flow + +``` +[Idle] + │ + │ Cmd↓ + ▼ +[SpeculativePending] ──(confirmation window 50ms)──▶ [SpeculativeConfirmed] + │ │ + │ C↓ (within window) │ Cmd↑ + ▼ ▼ +[MatchConfirmed] [Idle] + │ + │ Compensate Cmd↓, send Ctrl↓ C↓ + ▼ +[MappingActive] +``` + +### 6.3 Downgrade Flow + +``` +[PendingModifier] (Cmd↓, waiting) + │ + │ (Exceeds 200ms, no combination) + ▼ +[Downgrading] + │ + │ Send Cmd↓ (pass-through) + ▼ +[IndependentActive] + │ + │ C↓ (within downgrade window 100ms) + ▼ +[AttemptDowngrade] + │ + ├─ Success ──▶ [MatchConfirmed] ──▶ [MappingActive] + │ + └─ Failure ──▶ [IndependentActive] (C as normal key) +``` + +--- + +## 7. Implementation Roadmap + +### Phase 1: Foundation (2-3 weeks) + +1. **ISM Implementation** + - Input state tracking + - Multi-device aggregation + - Historical state management + +2. **OSM Foundation** + - Desired/actual state separation + - Basic output device interface + - State alignment mechanism + +3. **State Binder** + - Binding relationship management + - Auto-release tracking + +### Phase 2: Semantic Layer (2 weeks) + +1. **SSM Implementation** + - Semantic translation logic + - Pending/Active/Independent state management + - Configuration integration + +2. **Downgrade Mechanism** + - Timeout detection + - Downgrade decision + - State recovery + +### Phase 3: Speculative Execution (2 weeks) + +1. **Speculative Layer** + - Speculative operation management + - Confirmation window implementation + - Timer management + +2. **Compensator** + - Compensation strategies + - Alignment mechanism + - Compensation history + +### Phase 4: Integration and Optimization (2 weeks) + +1. **Integration with Existing Code** + - JavaScript engine adaptation + - Configuration format compatibility + - Migration tools + +2. **Performance Optimization** + - Memory pools + - Batching + - Lock optimization + +3. **Test Coverage** + - Unit tests + - Integration tests + - Fuzzing tests + +### Phase 5: Advanced Features (Optional) + +1. **Machine Learning Assistance** + - User behavior learning + - Dynamic threshold adjustment + +2. **Visual Debugging** + - State machine visualization + - Real-time state monitoring + +--- + +## Appendix + +### A. Glossary + +| Term | Description | +|------|-------------| +| ISM | Input State Machine | +| SSM | Semantic State Machine | +| OSM | Output State Machine | +| Pending Modifier | Modifier key waiting to be determined as modifier or independent | +| Speculative Execution | Execute in advance but revocable | +| Compensation | Revoke an already executed operation | +| State Binding | Association between output keys and input keys | + +### B. Reference Implementations + +- [QMK](https://qmk.fm/): Keyboard firmware state machine implementation +- [Kanata](https://github.com/jtroo/kanata): Keyboard remapping tool in Rust +- [KMonad](https://github.com/kmonad/kmonad): Keyboard manager in Haskell + +--- + +*Document Version: v1.0* +*Last Updated: 2026-02-11* diff --git a/examples/config.js b/examples/config.js index 05e1686..e69de29 100644 --- a/examples/config.js +++ b/examples/config.js @@ -1,147 +0,0 @@ -/** - * @typedef {Object} KeySwift - * @property {function(): string} getActiveWindowClass - * @property {function([string]): void} sendKeys - * Note that this function needs to be called at the outermost level, not in callback functions or if statements - * Otherwise, the script will not work - * @property {function([string], function(): void): void} onKeyPress - */ - - -// KeySwift script for key mapping - -const Terminals = ["kitty", "Gnome-terminal", "org.gnome.Terminal", "com.mitchellh.ghostty"]; -const JetBrains = ["jetbrains-goland", "jetbrains-pycharm"] -const VimModeEnabled = ["Cursor"] + JetBrains - -const curWindowClass = KeySwift.getActiveWindowClass(); -const inTerminal = Terminals.includes(curWindowClass); -const inVimMode = VimModeEnabled.includes(curWindowClass) -const inJetBrains = JetBrains.includes(curWindowClass) - -const chromeChangeTabShortcuts = { - "cmd,1": ["ctrl", "1"], - "cmd,2": ["ctrl", "2"], - "cmd,3": ["ctrl", "3"], - "cmd,4": ["ctrl", "4"], - "cmd,5": ["ctrl", "5"], - "cmd,6": ["ctrl", "6"], - "cmd,7": ["ctrl", "7"], - "cmd,8": ["ctrl", "8"], - "cmd,9": ["ctrl", "9"], -} - -const emacsShortcuts = { - "ctrl,a": ["home"], - "ctrl,e": ["end"], - "ctrl,b": ["left"], - "ctrl,f": ["right"], - "ctrl,d": ["delete"], - "ctrl,h": ["backspace"], -} - -const macOSLikeShortcuts = { - "cmd,x": ["ctrl", "x"], - "cmd,a": ["ctrl", "a"], - "cmd,z": ["ctrl", "z"], - "cmd,w": ["ctrl", "w"], - "cmd,t": ["ctrl", "t"], - "cmd,f": ["ctrl", "f"], - "cmd,r": ["ctrl", "r"], -} - -const jetBrainsShortcuts = { - "cmd,1": ["alt", "1"], - "cmd,2": ["alt", "2"], - "cmd,3": ["alt", "3"], - "cmd,w": ["ctrl", "4"], - "cmd,c": ["ctrl", "insert"], - "cmd,v": ["shift", "insert"], -} - -const sublimeTextShortcuts = { - "cmd,1": ["alt", "1"], - "cmd,2": ["alt", "2"], - "cmd,3": ["alt", "3"], - "cmd,4": ["alt", "4"], - "cmd,5": ["alt", "5"], - "cmd,6": ["alt", "6"], - "cmd,7": ["alt", "7"], -} - -KeySwift.onKeyPress(["cmd", "c"], () => { - if (curWindowClass === "kitty") { - return - } - if (inTerminal) { - KeySwift.sendKeys(["ctrl", "shift", "c"]); - } else { - if (!inJetBrains) { - KeySwift.sendKeys(["ctrl", "c"]); - } - } -}); - -KeySwift.onKeyPress(["cmd", "v"], () => { - if (curWindowClass === "kitty") { - return - } - if (curWindowClass === "com.mitchellh.ghostty") { - KeySwift.sendKeys(["shift", "ctrl", "v"]); - return - } - - if (inTerminal) { - KeySwift.sendKeys(["cmd", "shift", "v"]); - } else { - if (!inJetBrains) { - KeySwift.sendKeys(["ctrl", "v"]); - } - } -}); - -KeySwift.onKeyPress(["cmd", "w"], () => { - if (curWindowClass === "Cursor") { - KeySwift.sendKeys(["ctrl", "4"]); - } -}); - -for (const [key, value] of Object.entries(macOSLikeShortcuts)) { - KeySwift.onKeyPress(key.split(","), () => { - if (!inTerminal && !inJetBrains) { - KeySwift.sendKeys(value); - } - }); -} - -for (const [key, value] of Object.entries(chromeChangeTabShortcuts)) { - KeySwift.onKeyPress(key.split(","), () => { - if (curWindowClass === "Google-chrome") { - KeySwift.sendKeys(value); - } - }); -} - -for (const [key, value] of Object.entries(emacsShortcuts)) { - KeySwift.onKeyPress(key.split(","), () => { - if (!inTerminal && !inVimMode) { - KeySwift.sendKeys(value); - } - }); -} - -for (const [key, value] of Object.entries(jetBrainsShortcuts)) { - KeySwift.onKeyPress(key.split(","), () => { - if (inJetBrains) { - KeySwift.sendKeys(value); - } - }); -} - -for (const [key, value] of Object.entries(sublimeTextShortcuts)) { - KeySwift.onKeyPress(key.split(","), () => { - if (curWindowClass === "sublime_text") { - KeySwift.sendKeys(value); - } - }); -} diff --git a/examples/config.json b/examples/config.json new file mode 100644 index 0000000..8626cbc --- /dev/null +++ b/examples/config.json @@ -0,0 +1,38 @@ +[ + {"type": "var", "name": "jetbrains", "value": ["jetbrains-goland", "jetbrains-pycharm", "jetbrains-idea"]}, + {"type": "var", "name": "terminals", "value": ["kitty", "Gnome-terminal", "org.gnome.Terminal", "com.mitchellh.ghostty"]}, + {"type": "var", "name": "vimMode", "value": ["Cursor", "$jetbrains"]}, + {"type": "var", "name": "chrome", "value": ["google-chrome", "Google-chrome"]}, + + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "c"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "c"], "output": ["ctrl", "shift", "c"], "when": {"window": "$terminals"}}, + + {"type": "map", "input": ["cmd", "v"], "output": ["ctrl", "v"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "v"], "output": ["ctrl", "shift", "v"], "when": {"window": "$terminals"}}, + + {"type": "map", "input": ["cmd", "x"], "output": ["ctrl", "x"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "a"], "output": ["ctrl", "a"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "z"], "output": ["ctrl", "z"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "shift", "z"], "output": ["ctrl", "shift", "z"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "w"], "output": ["ctrl", "f4"], "when": {"window": "$jetbrains"}}, + {"type": "map", "input": ["cmd", "w"], "output": ["ctrl", "w"], "when": {"notWindow": ["$terminals", "$jetbrains"]}}, + {"type": "map", "input": ["cmd", "t"], "output": ["ctrl", "t"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "f"], "output": ["ctrl", "f"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "r"], "output": ["ctrl", "r"], "when": {"notWindow": "$terminals"}}, + {"type": "map", "input": ["cmd", "1"], "output": ["ctrl", "1"], "when": {"window": "$chrome"}}, + {"type": "map", "input": ["cmd", "2"], "output": ["ctrl", "2"], "when": {"window": "$chrome"}}, + {"type": "map", "input": ["cmd", "3"], "output": ["ctrl", "3"], "when": {"window": "$chrome"}}, + {"type": "map", "input": ["cmd", "4"], "output": ["ctrl", "4"], "when": {"window": "$chrome"}}, + {"type": "map", "input": ["cmd", "5"], "output": ["ctrl", "5"], "when": {"window": "$chrome"}}, + {"type": "map", "input": ["cmd", "1"], "output": ["alt", "1"], "when": {"window": "$jetbrains"}}, + {"type": "map", "input": ["cmd", "2"], "output": ["alt", "2"], "when": {"window": "$jetbrains"}}, + {"type": "map", "input": ["cmd", "3"], "output": ["alt", "3"], "when": {"window": "$jetbrains"}}, + {"type": "map", "input": ["ctrl", "a"], "output": ["home"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "e"], "output": ["end"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "b"], "output": ["left"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "f"], "output": ["right"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "d"], "output": ["delete"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "h"], "output": ["backspace"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "n"], "output": ["down"], "when": {"notWindow": ["$terminals", "$vimMode"]}}, + {"type": "map", "input": ["ctrl", "p"], "output": ["up"], "when": {"notWindow": ["$terminals", "$vimMode"]}} +] diff --git a/examples/config.json5 b/examples/config.json5 new file mode 100644 index 0000000..b72c5b5 --- /dev/null +++ b/examples/config.json5 @@ -0,0 +1,279 @@ +// KeySwift Configuration - JSON5 format (with comments) +// Save as ~/.config/keyswift/config.json after removing comments, or use a JSON5 parser +// +// Configuration is an array of rule objects. Each rule has a "type" field: +// - type: "var" - Define a variable for reuse +// - type: "map" - Define a key mapping + +[ + // ========== Variables ========== + // Variables allow you to group window classes and reuse them in conditions. + // Use $varname to reference a variable. + + // Terminal applications + { + "type": "var", + "name": "terminals", + "value": [ + "kitty", + "Gnome-terminal", + "org.gnome.Terminal", + "com.mitchellh.ghostty" + ] + }, + + // JetBrains IDEs + { + "type": "var", + "name": "jetbrains", + "value": [ + "jetbrains-goland", + "jetbrains-pycharm", + "jetbrains-idea" + ] + }, + + // Applications with vim mode (need special handling) + { + "type": "var", + "name": "vimMode", + "value": ["Cursor", "$jetbrains"] // Can reference other variables + }, + + // ========== Key Mappings ========== + // Each mapping has: + // - input: Array of key names to match (e.g., ["cmd", "c"]) + // - output: Array of key names to send (e.g., ["ctrl", "c"]) + // - when (optional): Conditions for when this mapping applies + + // Key names (case-insensitive): + // Modifiers: ctrl/ctrl-l/ctrl-r, alt/alt-l/alt-r, cmd/meta/super (all left meta), shift/shift-l/shift-r + // Letters: a, b, c, ..., z + // Numbers: 0, 1, 2, ..., 9 + // Function keys: f1, f2, ..., f12 + // Special: esc, tab, space, enter/return, backspace, delete, home, end, + // pageup, pagedown, up, down, left, right, insert + // Numpad: kp0-kp9, kpenter, kpplus, kpminus, etc. + + // --- Basic macOS-like Shortcuts --- + + // Cmd+C: Copy + // Don't apply in terminals (use Ctrl+Shift+C instead) + { + "type": "map", + "input": ["cmd", "c"], + "output": ["ctrl", "c"], + "when": { "notWindow": "$terminals" } + }, + + // Cmd+C in terminals: Ctrl+Shift+C + { + "type": "map", + "input": ["cmd", "c"], + "output": ["ctrl", "shift", "c"], + "when": { "window": "$terminals" } + }, + + // Cmd+V: Paste + { + "type": "map", + "input": ["cmd", "v"], + "output": ["ctrl", "v"], + "when": { "notWindow": "$terminals" } + }, + + // Cmd+V in terminals: Ctrl+Shift+V + { + "type": "map", + "input": ["cmd", "v"], + "output": ["ctrl", "shift", "v"], + "when": { "window": "$terminals" } + }, + + // Cmd+X: Cut + { + "type": "map", + "input": ["cmd", "x"], + "output": ["ctrl", "x"] + }, + + // Cmd+A: Select All + { + "type": "map", + "input": ["cmd", "a"], + "output": ["ctrl", "a"] + }, + + // Cmd+Z: Undo + { + "type": "map", + "input": ["cmd", "z"], + "output": ["ctrl", "z"] + }, + + // Cmd+Shift+Z: Redo + { + "type": "map", + "input": ["cmd", "shift", "z"], + "output": ["ctrl", "shift", "z"] + }, + + // Cmd+S: Save + { + "type": "map", + "input": ["cmd", "s"], + "output": ["ctrl", "s"] + }, + + // Cmd+F: Find + { + "type": "map", + "input": ["cmd", "f"], + "output": ["ctrl", "f"] + }, + + // Cmd+W: Close tab/window + { + "type": "map", + "input": ["cmd", "w"], + "output": ["ctrl", "w"] + }, + + // Cmd+T: New tab + { + "type": "map", + "input": ["cmd", "t"], + "output": ["ctrl", "t"] + }, + + // --- Chrome Tab Switching --- + + { + "type": "map", + "input": ["cmd", "1"], + "output": ["ctrl", "1"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "2"], + "output": ["ctrl", "2"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "3"], + "output": ["ctrl", "3"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "4"], + "output": ["ctrl", "4"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "5"], + "output": ["ctrl", "5"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "6"], + "output": ["ctrl", "6"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "7"], + "output": ["ctrl", "7"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "8"], + "output": ["ctrl", "8"], + "when": { "window": "Google-chrome" } + }, + { + "type": "map", + "input": ["cmd", "9"], + "output": ["ctrl", "9"], + "when": { "window": "Google-chrome" } + }, + + // --- Emacs Navigation (disabled in terminals and vim-mode apps) --- + + // Ctrl+A: Home + { + "type": "map", + "input": ["ctrl", "a"], + "output": ["home"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+E: End + { + "type": "map", + "input": ["ctrl", "e"], + "output": ["end"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+B: Left arrow + { + "type": "map", + "input": ["ctrl", "b"], + "output": ["left"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+F: Right arrow + { + "type": "map", + "input": ["ctrl", "f"], + "output": ["right"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+N: Down arrow + { + "type": "map", + "input": ["ctrl", "n"], + "output": ["down"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+P: Up arrow + { + "type": "map", + "input": ["ctrl", "p"], + "output": ["up"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+D: Delete + { + "type": "map", + "input": ["ctrl", "d"], + "output": ["delete"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+H: Backspace + { + "type": "map", + "input": ["ctrl", "h"], + "output": ["backspace"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + }, + + // Ctrl+K: Kill to end of line (Shift+End, Delete) + { + "type": "map", + "input": ["ctrl", "k"], + "output": ["shift", "end", "delete"], + "when": { "notWindow": ["$terminals", "$vimMode"] } + } +] diff --git a/examples/config_v2.js b/examples/config_v2.js new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod index ddc6b5a..f61e032 100644 --- a/go.mod +++ b/go.mod @@ -8,14 +8,12 @@ require ( ) require ( - github.com/buke/quickjs-go v0.4.15 - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect ) -require ( - github.com/samber/lo v1.49.1 - golang.org/x/text v0.21.0 // indirect -) +require gopkg.in/yaml.v3 v3.0.1 // indirect require ( github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 4e5506c..9bae824 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,20 @@ -github.com/buke/quickjs-go v0.4.15 h1:VYs3OUaqoLzGXkXMqjh+MU2NVku883AlnMGwBr7sqpY= -github.com/buke/quickjs-go v0.4.15/go.mod h1:DTNBNlQc+GflC2iemHKotAjdR5cKsfmkF75UuhA4iLs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/jialeicui/golibevdev v0.0.2 h1:CQij28fpHE0Cd4tMH4udkiFWloxwSA2Z+Z67Gj146iY= github.com/jialeicui/golibevdev v0.0.2/go.mod h1:BIGi9TtzkWS9wHy3n+uGXeSfhiPWJUOVCvgg/Qh8tX8= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= -github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/bus/impl.go b/pkg/bus/impl.go deleted file mode 100644 index 90a7b32..0000000 --- a/pkg/bus/impl.go +++ /dev/null @@ -1,134 +0,0 @@ -package bus - -import ( - "fmt" - "log/slog" - "sort" - - "github.com/jialeicui/golibevdev" - - "github.com/jialeicui/keyswift/pkg/engine" - "github.com/jialeicui/keyswift/pkg/keys" - "github.com/jialeicui/keyswift/pkg/wininfo" -) - -// Impl processes events -type Impl struct { - curFocusWindow *wininfo.WinInfo - engine engine.Engine - windowInfo wininfo.WinGetter - out *golibevdev.UInputDev - - beforeSendKeysPerSession func() -} - -// New creates a new bus implementation -func New(script string, windowInfo wininfo.WinGetter, out *golibevdev.UInputDev) (*Impl, error) { - if script == "" { - return nil, fmt.Errorf("script is required") - } - - manager := &Impl{ - windowInfo: windowInfo, - out: out, - } - - e, err := engine.NewQuickJS(script) - if err != nil { - return nil, fmt.Errorf("failed to create engine: %w", err) - } - - manager.engine = e - - // Listen for window focus changes - if windowInfo != nil { - err := windowInfo.OnActiveWindowChange(manager.handleWindowFocus) - if err != nil { - return nil, fmt.Errorf("failed to register window focus handler: %w", err) - } - } - - return manager, nil -} - -func (m *Impl) SetBeforeSendKeysPerSession(fn func()) { - m.beforeSendKeysPerSession = fn -} - -// ProcessEvent processes an event through the current bus -func (m *Impl) ProcessEvent(event *Event) (bool, error) { - if event == nil || event.KeyPress == nil { - return false, nil - } - - s := newSession(m, event.KeyPress.Keys, m.beforeSendKeysPerSession) - err := m.engine.Run(s) - if err != nil { - return false, fmt.Errorf("failed to run engine: %w", err) - } - return s.Handled(), nil -} - -// handleWindowFocus handles window focus change events -func (m *Impl) handleWindowFocus(winInfo *wininfo.WinInfo) { - m.curFocusWindow = winInfo -} - -func (m *Impl) GetActiveWindowClass() string { - slog.Debug("GetActiveWindowClass", "curFocusWindow", m.curFocusWindow) - if m.curFocusWindow != nil { - return m.curFocusWindow.Class - } - return "" -} - -func (m *Impl) SendKeys(keyCodes []keys.Key) { - slog.Debug("SendKeys", "keyCodes", keyCodes) - - cloned := append([]keys.Key{}, keyCodes...) - sort.Slice(cloned, func(i, j int) bool { - return keys.IsModifier(cloned[i]) - }) - - // Ensure we don't have duplicate key events - seen := make(map[keys.Key]bool) - var uniqueKeys []keys.Key - for _, key := range cloned { - if !seen[key] { - seen[key] = true - uniqueKeys = append(uniqueKeys, key) - } - } - - // Press keys in order (modifiers first) - for _, key := range uniqueKeys { - err := m.out.WriteEvent(golibevdev.EvKey, key, 1) - if err != nil { - slog.Error("failed to send key press event", "error", err) - } - } - - // Send sync event - _ = m.out.WriteEvent(golibevdev.EvSyn, golibevdev.SynReport, 0) - - // Release keys in reverse order (regular keys first, then modifiers) - for i := len(uniqueKeys) - 1; i >= 0; i-- { - key := uniqueKeys[i] - _ = m.out.WriteEvent(golibevdev.EvKey, key, 0) - } - - // Send final sync event - _ = m.out.WriteEvent(golibevdev.EvSyn, golibevdev.SynReport, 0) - - slog.Debug("SendKeys done", "input", keyCodes) -} - -func (m *Impl) UpdateWindowMonitor(windowInfo wininfo.WinGetter) { - m.windowInfo = windowInfo - if windowInfo != nil { - err := windowInfo.OnActiveWindowChange(m.handleWindowFocus) - if err != nil { - slog.Error("failed to register window focus handler", "error", err) - } - } -} diff --git a/pkg/bus/mode.go b/pkg/bus/mode.go deleted file mode 100644 index 55d1cdc..0000000 --- a/pkg/bus/mode.go +++ /dev/null @@ -1,37 +0,0 @@ -package bus - -import ( - "github.com/jialeicui/golibevdev" - - "github.com/jialeicui/keyswift/pkg/wininfo" -) - -// Event represents any type of event that can trigger an action -type Event struct { - // KeyPress represents a keyboard key press event - KeyPress *KeyPressEvent - // MouseClick represents a mouse click event - MouseClick *MouseClickEvent - // WindowFocus represents a window focus change event - WindowFocus *WindowFocusEvent -} - -// KeyPressEvent represents a keyboard key press -type KeyPressEvent struct { - Keys []golibevdev.KeyEventCode - Pressed bool // true for press, false for release - Repeated bool // true if key repeat -} - -// MouseClickEvent represents a mouse click -type MouseClickEvent struct { - X float64 - Y float64 - Button int - Pressed bool // true for press, false for release -} - -// WindowFocusEvent represents a window focus change -type WindowFocusEvent struct { - Window *wininfo.WinInfo -} diff --git a/pkg/bus/session.go b/pkg/bus/session.go deleted file mode 100644 index 5603b09..0000000 --- a/pkg/bus/session.go +++ /dev/null @@ -1,44 +0,0 @@ -package bus - -import ( - "sync" - - "github.com/jialeicui/keyswift/pkg/engine" - "github.com/jialeicui/keyswift/pkg/keys" -) - -var _ engine.Bus = (*session)(nil) - -type session struct { - impl *Impl - handled bool - pressedKeys []keys.Key - once sync.Once - beforeSend func() -} - -func (s *session) GetPressedKeys() []keys.Key { - return s.pressedKeys -} - -func (s *session) GetActiveWindowClass() string { - return s.impl.GetActiveWindowClass() -} - -func (s *session) SendKeys(codes []keys.Key) { - s.once.Do(s.beforeSend) - s.handled = true - s.impl.SendKeys(codes) -} - -func (s *session) Handled() bool { - return s.handled -} - -func newSession(m *Impl, pressedKeys []keys.Key, beforeSend func()) *session { - return &session{ - impl: m, - pressedKeys: pressedKeys, - beforeSend: beforeSend, - } -} diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..3a538bc --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,275 @@ +// Package config provides configuration parsing and management for KeySwift. +// The configuration is a JSON array of rules that are loaded at startup +// to build static mappings for the state machine. +package config + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/jialeicui/keyswift/pkg/keys" +) + +// Config represents the parsed configuration +type Config struct { + Vars map[string][]string `json:"vars"` + Mappings []MappingRule `json:"mappings"` +} + +// MappingRule represents a single key mapping +type MappingRule struct { + Input []keys.Key `json:"input"` + Output []keys.Key `json:"output"` + When *WhenCondition `json:"when,omitempty"` +} + +// WhenCondition represents conditions for a mapping to apply +type WhenCondition struct { + Window interface{} `json:"window,omitempty"` // string or []string + NotWindow interface{} `json:"notWindow,omitempty"` // string or []string +} + +// RawRule represents a rule as parsed from JSON +type RawRule struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + Value interface{} `json:"value,omitempty"` + Input []string `json:"input,omitempty"` + Output []string `json:"output,omitempty"` + When *WhenCondition `json:"when,omitempty"` +} + +// LoadConfig loads and parses the configuration file +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var rawRules []RawRule + if err := json.Unmarshal(data, &rawRules); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + cfg := &Config{ + Vars: make(map[string][]string), + Mappings: make([]MappingRule, 0), + } + + // First pass: collect all vars + for _, rule := range rawRules { + if rule.Type == "var" { + values, err := parseStringSlice(rule.Value) + if err != nil { + return nil, fmt.Errorf("invalid var %s: %w", rule.Name, err) + } + cfg.Vars[rule.Name] = values + } + } + + // Second pass: process mappings + for i, rule := range rawRules { + if rule.Type != "map" { + continue + } + + mapping, err := cfg.processMappingRule(rule) + if err != nil { + return nil, fmt.Errorf("failed to process mapping %d: %w", i, err) + } + cfg.Mappings = append(cfg.Mappings, *mapping) + } + + return cfg, nil +} + +func (cfg *Config) processMappingRule(rule RawRule) (*MappingRule, error) { + if len(rule.Input) == 0 { + return nil, fmt.Errorf("mapping must have input keys") + } + if len(rule.Output) == 0 { + return nil, fmt.Errorf("mapping must have output keys") + } + + // Resolve input keys + input, err := cfg.resolveKeys(rule.Input) + if err != nil { + return nil, fmt.Errorf("invalid input: %w", err) + } + + // Resolve output keys + output, err := cfg.resolveKeys(rule.Output) + if err != nil { + return nil, fmt.Errorf("invalid output: %w", err) + } + + // Process when condition + when := rule.When + if when != nil { + if when.Window != nil { + windows, err := cfg.resolveVarRef(when.Window) + if err != nil { + return nil, fmt.Errorf("invalid window condition: %w", err) + } + when.Window = windows + } + if when.NotWindow != nil { + windows, err := cfg.resolveVarRef(when.NotWindow) + if err != nil { + return nil, fmt.Errorf("invalid notWindow condition: %w", err) + } + when.NotWindow = windows + } + } + + return &MappingRule{ + Input: input, + Output: output, + When: when, + }, nil +} + +// resolveKeys converts key names to key codes +func (cfg *Config) resolveKeys(keyNames []string) ([]keys.Key, error) { + result := make([]keys.Key, len(keyNames)) + for i, name := range keyNames { + key, err := keys.GetKeyCode(name) + if err != nil { + return nil, fmt.Errorf("unknown key '%s': %w", name, err) + } + result[i] = key + } + return result, nil +} + +// resolveVarRef resolves variable references like "$terminals" to their values +func (cfg *Config) resolveVarRef(v interface{}) ([]string, error) { + switch val := v.(type) { + case string: + // Check if it's a variable reference + if strings.HasPrefix(val, "$") { + varName := strings.TrimPrefix(val, "$") + if varValues, ok := cfg.Vars[varName]; ok { + return varValues, nil + } + return nil, fmt.Errorf("undefined variable: %s", varName) + } + // Single string value + return []string{val}, nil + case []interface{}: + // Array of strings (possibly with variable references) + result := make([]string, 0) + for _, item := range val { + itemStr, ok := item.(string) + if !ok { + return nil, fmt.Errorf("expected string in array, got %T", item) + } + if strings.HasPrefix(itemStr, "$") { + varName := strings.TrimPrefix(itemStr, "$") + if varValues, ok := cfg.Vars[varName]; ok { + result = append(result, varValues...) + } else { + return nil, fmt.Errorf("undefined variable: %s", varName) + } + } else { + result = append(result, itemStr) + } + } + return result, nil + default: + return nil, fmt.Errorf("expected string or array, got %T", v) + } +} + +func parseStringSlice(v interface{}) ([]string, error) { + switch val := v.(type) { + case string: + return []string{val}, nil + case []interface{}: + result := make([]string, len(val)) + for i, item := range val { + str, ok := item.(string) + if !ok { + return nil, fmt.Errorf("expected string, got %T", item) + } + result[i] = str + } + return result, nil + default: + return nil, fmt.Errorf("expected string or array, got %T", v) + } +} + +// String returns a unique string representation of the mapping rule +func (r *MappingRule) String() string { + // Create a deterministic string representation + inputStr := make([]string, len(r.Input)) + for i, k := range r.Input { + inputStr[i] = fmt.Sprintf("%d", k) + } + outputStr := make([]string, len(r.Output)) + for i, k := range r.Output { + outputStr[i] = fmt.Sprintf("%d", k) + } + data := fmt.Sprintf("%v->%v+%v", inputStr, outputStr, r.When) + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:8]) +} + +// MatchWindowClass checks if a mapping matches the current window class +func (r *MappingRule) MatchWindowClass(windowClass string) bool { + if r.When == nil { + // No condition means match all windows + return true + } + + // Check window condition + if r.When.Window != nil { + windows := toStringSlice(r.When.Window) + if !contains(windows, windowClass) { + return false + } + } + + // Check notWindow condition + if r.When.NotWindow != nil { + windows := toStringSlice(r.When.NotWindow) + if contains(windows, windowClass) { + return false + } + } + + return true +} + +func toStringSlice(v interface{}) []string { + switch val := v.(type) { + case string: + return []string{val} + case []string: + return val + case []interface{}: + result := make([]string, len(val)) + for i, item := range val { + if str, ok := item.(string); ok { + result[i] = str + } + } + return result + default: + return nil + } +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if strings.EqualFold(s, item) { + return true + } + } + return false +} diff --git a/pkg/engine/interfaces.go b/pkg/engine/interfaces.go deleted file mode 100644 index 3b8740a..0000000 --- a/pkg/engine/interfaces.go +++ /dev/null @@ -1,24 +0,0 @@ -package engine - -import ( - "github.com/jialeicui/keyswift/pkg/keys" -) - -const ( - FuncGetActiveWindowClass = "getActiveWindowClass" - FuncSendKeys = "sendKeys" - FuncOnKeyPress = "onKeyPress" - - KeySwiftObj = "KeySwift" -) - -type Engine interface { - Run(session Bus) error - Release() -} - -type Bus interface { - GetActiveWindowClass() string - GetPressedKeys() []keys.Key - SendKeys(keys []keys.Key) -} diff --git a/pkg/engine/quickjs.go b/pkg/engine/quickjs.go deleted file mode 100644 index f942b43..0000000 --- a/pkg/engine/quickjs.go +++ /dev/null @@ -1,222 +0,0 @@ -package engine - -import ( - "fmt" - "log/slog" - "slices" - "strings" - - "github.com/buke/quickjs-go" - "github.com/jialeicui/golibevdev" - "github.com/samber/lo" - - "github.com/jialeicui/keyswift/pkg/keys" - "github.com/jialeicui/keyswift/pkg/utils/cache" -) - -var _ Engine = (*QuickJS)(nil) - -const maxPressed = 16 - -type QuickJS struct { - rt quickjs.Runtime - - byteCode []byte - script string - - init bool - keysWatch map[[maxPressed]golibevdev.KeyEventCode]struct{} - - keyCache cache.Cache[string, []keys.Key] -} - -func newJsRuntime() quickjs.Runtime { - return quickjs.NewRuntime( - quickjs.WithExecuteTimeout(0), - quickjs.WithMemoryLimit(1280*1024), - quickjs.WithGCThreshold(2560*1024), - quickjs.WithMaxStackSize(65534), - quickjs.WithCanBlock(true), - ) -} - -func NewQuickJS(script string) (*QuickJS, error) { - rt := newJsRuntime() - - defer rt.Close() - - ctx := rt.NewContext() - defer ctx.Close() - - buf, err := ctx.Compile(script) - if err != nil { - return nil, err - } - - e := &QuickJS{ - rt: rt, - byteCode: buf, - script: script, - - init: false, - keysWatch: map[[maxPressed]golibevdev.KeyEventCode]struct{}{}, - keyCache: cache.New[string, []keys.Key](), - } - - return e, nil -} - -func (e *QuickJS) Run(session Bus) error { - if e.fastIgnore(session) { - return nil - } - - rt := newJsRuntime() - defer rt.Close() - - ctx := rt.NewContext() - defer ctx.Close() - - e.registerConsole(ctx) - e.registerKeySwift(ctx, session) - - ret, err := ctx.EvalBytecode(e.byteCode) - if err != nil { - return err - } - ret.Free() - e.init = true - return nil -} - -func (e *QuickJS) Release() { - e.rt.Close() -} - -func (e *QuickJS) fastIgnore(session Bus) bool { - if !e.init { - return false - } - - pressed := session.GetPressedKeys() - slices.Sort(pressed) - - k := [maxPressed]golibevdev.KeyEventCode{} - copy(k[:], pressed) - _, ok := e.keysWatch[k] - slog.Debug("check script rule hit", "keys", pressed, "yes", ok) - return !ok -} - -func (e *QuickJS) registerConsole(ctx *quickjs.Context) { - console := ctx.Object() - console.Set("log", ctx.Function(func(ctx *quickjs.Context, this quickjs.Value, args []quickjs.Value) quickjs.Value { - fmt.Printf("%s\n", strings.Join(lo.Map(args, func(v quickjs.Value, _ int) string { - return v.String() - }), " ")) - return ctx.Undefined() - })) - ctx.Globals().Set("console", console) -} - -func (e *QuickJS) registerKeySwift(ctx *quickjs.Context, session Bus) { - keySwift := ctx.Object() - ctx.Globals().Set(KeySwiftObj, keySwift) - - keySwift.Set(FuncGetActiveWindowClass, ctx.Function(func(ctx *quickjs.Context, this quickjs.Value, args []quickjs.Value) quickjs.Value { - return ctx.String(session.GetActiveWindowClass()) - })) - - keySwift.Set(FuncSendKeys, ctx.Function(func(ctx *quickjs.Context, this quickjs.Value, args []quickjs.Value) quickjs.Value { - if len(args) != 1 { - return ctx.Undefined() - } - - if !args[0].IsArray() { - return ctx.Undefined() - } - - jsKeys := args[0].ToArray() - keyStrArr := make([]string, 0, jsKeys.Len()) - for i := int64(0); i < jsKeys.Len(); i++ { - item, err := jsKeys.Get(i) - if err != nil { - return ctx.Undefined() - } - keyStrArr = append(keyStrArr, item.String()) - } - slices.Sort(keyStrArr) - - keyCodes, err := e.keyCache.Get(strings.Join(keyStrArr, ","), func() ([]keys.Key, error) { - return keys.GetKeyCodes(keyStrArr) - }) - - if err != nil { - slog.Error("failed to get key codes", "error", err) - return ctx.Undefined() - } - - session.SendKeys(keyCodes) - - return ctx.Undefined() - })) - - keySwift.Set(FuncOnKeyPress, ctx.Function(func(ctx *quickjs.Context, this quickjs.Value, args []quickjs.Value) quickjs.Value { - if len(args) != 2 { - slog.Error("OnKeyPress requires two arguments") - return ctx.Undefined() - } - - if !args[0].IsArray() { - slog.Error("keys must be an array") - return ctx.Undefined() - } - - if !args[1].IsFunction() { - slog.Error("OnKeyPress requires a function as the second argument") - return ctx.Undefined() - } - - jsKeys := args[0].ToArray() - - keyStrArr := make([]string, 0, jsKeys.Len()) - for i := int64(0); i < jsKeys.Len(); i++ { - item, err := jsKeys.Get(i) - if err != nil { - slog.Error("failed to get key by index", "error", err, "index", i) - return ctx.Undefined() - } - if !item.IsString() { - slog.Error("key is not a string", "key", item.String()) - return ctx.Undefined() - } - // TODO: if modifier key position is not fixed, we need to handle it - keyStrArr = append(keyStrArr, item.String()) - } - - slices.Sort(keyStrArr) - expected, err := e.keyCache.Get(strings.Join(keyStrArr, ","), func() ([]keys.Key, error) { - return keys.GetKeyCodes(keyStrArr) - }) - if err != nil { - slog.Error("failed to get key codes", "error", err) - return ctx.Undefined() - } - - if !e.init { - slices.Sort(expected) - slog.Debug("add keys watch", "keys", keyStrArr, "codes", expected) - k := [maxPressed]golibevdev.KeyEventCode{} - copy(k[:], expected) - e.keysWatch[k] = struct{}{} - } - - curKeys := session.GetPressedKeys() - - if a, b := lo.Difference(curKeys, expected); len(a) == 0 && len(b) == 0 { - ctx.Invoke(args[1], this) - } - - return ctx.Undefined() - })) -} diff --git a/pkg/evdev/overview.go b/pkg/evdev/overview.go index 577ab46..9594b46 100644 --- a/pkg/evdev/overview.go +++ b/pkg/evdev/overview.go @@ -30,11 +30,13 @@ func (o *OverviewImpl) ListInputDevices() ([]*InputDevice, error) { path := "/dev/input/" + device.Name() dev, err := golibevdev.NewInputDev(path) if err != nil { - return nil, err + // Skip devices we can't open (e.g., permission denied) + continue } + name := dev.Name() dev.Close() inputDevice := &InputDevice{ - Name: dev.Name(), + Name: name, Path: path, } ret = append(ret, inputDevice) @@ -46,3 +48,17 @@ func (o *OverviewImpl) ListInputDevices() ([]*InputDevice, error) { func NewOverviewImpl() *OverviewImpl { return &OverviewImpl{} } + +// FindDeviceByName returns the first device with an exact name match that is not excluded. +func FindDeviceByName(devices []*InputDevice, name string, excludedPaths map[string]struct{}) *InputDevice { + for _, device := range devices { + if device.Name != name { + continue + } + if _, excluded := excludedPaths[device.Path]; excluded { + continue + } + return device + } + return nil +} diff --git a/pkg/evdev/overview_test.go b/pkg/evdev/overview_test.go index a52d910..d3aef47 100644 --- a/pkg/evdev/overview_test.go +++ b/pkg/evdev/overview_test.go @@ -2,6 +2,7 @@ package evdev import ( "testing" + "github.com/stretchr/testify/require" ) @@ -15,10 +16,26 @@ func TestOverview(t *testing.T) { must.NoError(err) must.NotEmpty(devices) - for _, device := range devices { must.NotEmpty(device.Name) must.NotEmpty(device.Path) t.Logf("Name: %s, Path: %s", device.Name, device.Path) } -} \ No newline at end of file +} + +func TestFindDeviceByName(t *testing.T) { + devices := []*InputDevice{ + {Name: "Keyboard", Path: "/dev/input/event1"}, + {Name: "Keyboard", Path: "/dev/input/event2"}, + {Name: "Other", Path: "/dev/input/event3"}, + } + + selected := FindDeviceByName(devices, "Keyboard", map[string]struct{}{ + "/dev/input/event1": {}, + }) + require.NotNil(t, selected) + require.Equal(t, "/dev/input/event2", selected.Path) + + missing := FindDeviceByName(devices, "Missing", nil) + require.Nil(t, missing) +} diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go deleted file mode 100644 index f6cb7c3..0000000 --- a/pkg/handler/handler.go +++ /dev/null @@ -1,277 +0,0 @@ -package handler - -import ( - "fmt" - "log/slog" - "sync" - "time" - - "github.com/jialeicui/golibevdev" - - "github.com/jialeicui/keyswift/pkg/bus" -) - -const ( - KeyPressed = 1 - KeyReleased = 0 -) - -// InputDevice represents a grabbed input device -type InputDevice struct { - Device *golibevdev.InputDev - Name string - Path string -} - -// Handler manages multiple input devices -type Handler struct { - devices []*InputDevice - wg sync.WaitGroup - - out *golibevdev.UInputDev -} - -// New creates a new input device handler -func New() *Handler { - return &Handler{ - devices: make([]*InputDevice, 0), - } -} - -// GetDevices returns all input devices -func (m *Handler) GetDevices() []*InputDevice { - return m.devices -} - -// AddDevice adds and grabs a new input device -func (m *Handler) AddDevice(name, path string) error { - dev, err := golibevdev.NewInputDev(path) - if err != nil { - return fmt.Errorf("failed to open input device %s: %w", path, err) - } - - if err = dev.Grab(); err != nil { - dev.Close() - return fmt.Errorf("failed to grab input device %s: %w", path, err) - } - - m.devices = append(m.devices, &InputDevice{ - Device: dev, - Name: name, - Path: path, - }) - return nil -} - -// ProcessEvents starts processing events from all devices -func (m *Handler) ProcessEvents(virtualKeyboard *golibevdev.UInputDev, modeManager *bus.Impl) { - m.out = virtualKeyboard - for _, dev := range m.devices { - m.wg.Add(1) - go func(d *InputDevice) { - defer m.wg.Done() - m.processDeviceEvents(d, modeManager) - }(dev) - } -} - -// KeyState represents the state of a key -type KeyState struct { - Time time.Time -} - -// processDeviceEvents processes events from a single device -func (m *Handler) processDeviceEvents(dev *InputDevice, modeManager *bus.Impl) { - slog.Info("Starting event processing for device", "device", dev.Name) - mu := sync.Mutex{} - - var ( - keyStates = make(map[golibevdev.KeyEventCode]KeyState) - eventStack []golibevdev.Event - modifier = NewModifier() - passThroughKeys = make(map[golibevdev.KeyEventCode]struct{}) - byPassKeys = make(map[golibevdev.KeyEventCode]struct{}) - - lastKeyIsModifier = false - lastEventIsRelease = false - ) - - // modifier key(here we only consider ctrl, alt) always pass through - // when modifier key + other key hit the rules - // we simulate the related modifier key release event to output device - // e.g. When ctrl pressed, we send the press event of ctrl to output device - // and then if c pressed, and ctrl+c hit the rules, we send the release event of ctrl to output device - // this is useful for the scenario like holding ctrl and click mouse in browser to open new tab - - modeManager.SetBeforeSendKeysPerSession(func() { - if len(passThroughKeys) == 0 { - return - } - - for key := range passThroughKeys { - _ = m.out.WriteEvent(golibevdev.EvKey, key, 0) - } - _ = m.out.WriteEvent(golibevdev.EvSyn, golibevdev.SynReport, 0) - - passThroughKeys = make(map[golibevdev.KeyEventCode]struct{}) - }) - - for { - ev, err := dev.Device.NextEvent(golibevdev.ReadFlagNormal) - if err != nil { - slog.Error("Error reading from device", "device", dev.Name, "error", err) - return - } - - slog.Debug("event", "code", ev.Code, "value", ev.Value, "time", ev.Time.UnixMicro()) - - // Handle sync events - if ev.Type == golibevdev.EvSyn { - if len(keyStates) == 0 && len(passThroughKeys) > 0 { - passThroughKeys = make(map[golibevdev.KeyEventCode]struct{}) - } - eventStack = append(eventStack, ev) - // Process any pending events in the stack - forceNoPassThrough := lastKeyIsModifier && !lastEventIsRelease - handled := m.processEventStack(eventStack, keyStates, modeManager, forceNoPassThrough) - if !forceNoPassThrough { - eventStack = eventStack[:0] - } - if handled { - byPassKeys = make(map[golibevdev.KeyEventCode]struct{}) - for key := range keyStates { - byPassKeys[key] = struct{}{} - } - continue - } - for key := range keyStates { - _, ok := passThroughKeys[key] - if !ok && modifier.ShouldPassThrough(key) { - passThroughKeys[key] = struct{}{} - m.sendSingleKey(key, KeyPressed) - } - } - continue - } - - // Handle key events - if ev.Type == golibevdev.EvKey { - if ev.Value != KeyPressed && ev.Value != KeyReleased { - continue - } - - keyCode := ev.Code.(golibevdev.KeyEventCode) - isModifier := modifier.IsModifier(keyCode) - lastKeyIsModifier = isModifier - lastEventIsRelease = ev.Value == KeyReleased - - // Update key state - mu.Lock() - if ev.Value == KeyPressed { - keyStates[keyCode] = KeyState{ - Time: ev.Time, - } - if isModifier { - modifier.Press(keyCode) - } - } else { - delete(keyStates, keyCode) - if isModifier { - modifier.Release(keyCode) - } - } - mu.Unlock() - - if ev.Value == KeyReleased { - k := ev.Code.(golibevdev.KeyEventCode) - if _, ok := byPassKeys[k]; ok { - slog.Debug("drop key release event", "key", k.String()) - delete(byPassKeys, k) - // continue - } - } - - // Add event to stack - eventStack = append(eventStack, ev) - } - } -} - - -// Wait waits for all event processing to complete -func (m *Handler) Wait() { - m.wg.Wait() -} - -// processEventStack processes a stack of events and determines if they should be handled -// return true if the events should be handled, false if the events should be forwarded -func (m *Handler) processEventStack( - events []golibevdev.Event, - keyStates map[golibevdev.KeyEventCode]KeyState, - modeManager *bus.Impl, - forceNoPassThrough bool, -) bool { - // Get currently pressed keys - var pressedKeys []golibevdev.KeyEventCode - for key := range keyStates { - pressedKeys = append(pressedKeys, key) - } - - if len(pressedKeys) == 0 { - // No keys are pressed, just forward all events - for _, ev := range events { - _ = m.out.WriteEvent(ev.Type, ev.Code, ev.Value) - } - slog.Debug("forward all events", "events", events) - return false - } - - // Create event for bus processing - event := &bus.Event{ - KeyPress: &bus.KeyPressEvent{ - Keys: pressedKeys, - Pressed: true, - }, - } - - // Process through bus manager - handled, err := modeManager.ProcessEvent(event) - if err != nil { - slog.Error("Error processing event", "error", err) - return false - } - - if handled { - // If handled, we don't forward the events - return true - } - - if forceNoPassThrough { - slog.Debug("force no pass through", "events", events) - return false - } - - // If not handled, forward all events in order - for _, ev := range events { - if ev.Type == golibevdev.EvKey { - slog.Debug("Forwarding key event", "key", ev.Code.(golibevdev.KeyEventCode).String(), "pressed", ev.Value) - } - _ = m.out.WriteEvent(ev.Type, ev.Code, ev.Value) - } - - return false -} - -func (m *Handler) sendSingleKey(code golibevdev.KeyEventCode, value int32) { - _ = m.out.WriteEvent(golibevdev.EvKey, code, value) - _ = m.out.WriteEvent(golibevdev.EvSyn, golibevdev.SynReport, 0) - slog.Debug("send single key", "code", code, "value", value) -} - -// Close closes all input devices -func (m *Handler) Close() { - for _, dev := range m.devices { - slog.Info("Closing device", "device", dev.Name) - dev.Device.Close() - } -} diff --git a/pkg/handler/modifier.go b/pkg/handler/modifier.go deleted file mode 100644 index 0c7bcb4..0000000 --- a/pkg/handler/modifier.go +++ /dev/null @@ -1,97 +0,0 @@ -package handler - -import ( - "github.com/jialeicui/golibevdev" - - "github.com/jialeicui/keyswift/pkg/keys" -) - -var ( - passThroughKeys = map[golibevdev.KeyEventCode]struct{}{ - golibevdev.KeyLeftCtrl: {}, - golibevdev.KeyRightCtrl: {}, - golibevdev.KeyLeftAlt: {}, - golibevdev.KeyRightAlt: {}, - } -) - -type ModifierState struct { - pressed bool -} - -func (m *ModifierState) Press() { - m.pressed = true -} - -func (m *ModifierState) Release() { - m.pressed = false -} - -func (m *ModifierState) IsPressed() bool { - return m.pressed -} - -func (m *ModifierState) IsReleased() bool { - return !m.pressed -} - -type Modifier struct { - modifiers map[golibevdev.KeyEventCode]*ModifierState -} - -func NewModifier() *Modifier { - modifiers := map[golibevdev.KeyEventCode]*ModifierState{} - for key := range keys.Modifiers { - modifiers[key] = &ModifierState{} - } - return &Modifier{ - modifiers: modifiers, - } -} - -func (m *Modifier) Press(code golibevdev.KeyEventCode) { - if !m.IsModifier(code) { - return - } - m.modifiers[code].Press() -} - -func (m *Modifier) Release(code golibevdev.KeyEventCode) { - if !m.IsModifier(code) { - return - } - m.modifiers[code].Release() -} - -func (m *Modifier) IsPressed(code golibevdev.KeyEventCode) bool { - if !m.IsModifier(code) { - return false - } - return m.modifiers[code].IsPressed() -} - -func (m *Modifier) IsReleased(code golibevdev.KeyEventCode) bool { - if !m.IsModifier(code) { - return false - } - return m.modifiers[code].IsReleased() -} - -func (m *Modifier) IsModifier(code golibevdev.KeyEventCode) bool { - _, ok := m.modifiers[code] - return ok -} - -func (m *Modifier) IsAnyModifierPressed() bool { - for _, state := range m.modifiers { - if state.IsPressed() { - return true - } - } - return false -} - -func (m *Modifier) ShouldPassThrough(code golibevdev.KeyEventCode) bool { - _, ok := passThroughKeys[code] - return ok -} diff --git a/pkg/keys/keys.go b/pkg/keys/keys.go index 54073db..2927375 100644 --- a/pkg/keys/keys.go +++ b/pkg/keys/keys.go @@ -17,6 +17,7 @@ var Modifiers = map[Key]struct{}{ golibevdev.KeyRightShift: {}, golibevdev.KeyRightCtrl: {}, golibevdev.KeyRightAlt: {}, + golibevdev.KeyRightMeta: {}, } var keyMap = map[string]Key{ @@ -64,15 +65,23 @@ func init() { func GetKeyCodes(keys []string) ([]Key, error) { keyCodes := make([]Key, 0, len(keys)) for _, key := range keys { - c, ok := keyMap[strings.ToLower(key)] - if !ok { - return nil, fmt.Errorf("unknown key: %s", key) + c, err := GetKeyCode(key) + if err != nil { + return nil, err } keyCodes = append(keyCodes, c) } return keyCodes, nil } +func GetKeyCode(key string) (Key, error) { + c, ok := keyMap[strings.ToLower(key)] + if !ok { + return 0, fmt.Errorf("unknown key: %s", key) + } + return c, nil +} + func IsModifier(key golibevdev.KeyEventCode) bool { _, ok := Modifiers[key] return ok diff --git a/pkg/statemachine/adapter.go b/pkg/statemachine/adapter.go new file mode 100644 index 0000000..ff2b8d2 --- /dev/null +++ b/pkg/statemachine/adapter.go @@ -0,0 +1,293 @@ +package statemachine + +import ( + "errors" + "fmt" + "log/slog" + "sync" + + "github.com/jialeicui/golibevdev" + "github.com/jialeicui/keyswift/pkg/keys" +) + +// ErrOutputResetRequired signals that the output device was rebuilt and the +// state machine must discard its tracked state. +var ErrOutputResetRequired = errors.New("output device reset required") + +type lowLevelOutputDevice interface { + WriteKey(key KeyCode, value int32) error + Sync() error + Close() error +} + +type outputDeviceFactory func(name string) (lowLevelOutputDevice, error) + +type uinputLowLevelDevice struct { + device *golibevdev.UInputDev +} + +func (d *uinputLowLevelDevice) WriteKey(key KeyCode, value int32) error { + return d.device.WriteEvent(golibevdev.EvKey, key, value) +} + +func (d *uinputLowLevelDevice) Sync() error { + return d.device.WriteEvent(golibevdev.EvSyn, golibevdev.SynReport, 0) +} + +func (d *uinputLowLevelDevice) Close() error { + d.device.Close() + return nil +} + +func newDefaultOutputDeviceFactory(name string) (lowLevelOutputDevice, error) { + device, err := golibevdev.NewVirtualKeyboard(name) + if err != nil { + return nil, err + } + return &uinputLowLevelDevice{device: device}, nil +} + +// RecoveringOutputDevice rebuilds the virtual keyboard after write failures. +type RecoveringOutputDevice struct { + mu sync.Mutex + name string + factory outputDeviceFactory + device lowLevelOutputDevice +} + +// NewRecoveringOutputDevice creates an output device with automatic recovery. +func NewRecoveringOutputDevice(name string) (*RecoveringOutputDevice, error) { + return newRecoveringOutputDevice(name, newDefaultOutputDeviceFactory) +} + +func newRecoveringOutputDevice(name string, factory outputDeviceFactory) (*RecoveringOutputDevice, error) { + device, err := factory(name) + if err != nil { + return nil, fmt.Errorf("create virtual keyboard: %w", err) + } + + return &RecoveringOutputDevice{ + name: name, + factory: factory, + device: device, + }, nil +} + +// Execute implements OutputDevice. +func (d *RecoveringOutputDevice) Execute(cmd OutputCommand) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.device == nil { + return d.recoverLocked(fmt.Errorf("output device is not available")) + } + + value := int32(0) + if cmd.Action == KeyPress { + value = 1 + } + + if err := d.device.WriteKey(cmd.Key, value); err != nil { + return d.recoverLocked(fmt.Errorf("write key event: %w", err)) + } + if err := d.device.Sync(); err != nil { + return d.recoverLocked(fmt.Errorf("write sync event: %w", err)) + } + + return nil +} + +// Sync implements OutputDevice. +func (d *RecoveringOutputDevice) Sync() error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.device == nil { + return d.recoverLocked(fmt.Errorf("output device is not available")) + } + + if err := d.device.Sync(); err != nil { + return d.recoverLocked(fmt.Errorf("sync output device: %w", err)) + } + + return nil +} + +// Close implements OutputDevice. +func (d *RecoveringOutputDevice) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.device == nil { + return nil + } + + err := d.device.Close() + d.device = nil + return err +} + +func (d *RecoveringOutputDevice) recoverLocked(cause error) error { + slog.Warn("Output device write failed, rebuilding virtual keyboard", "name", d.name, "error", cause) + + if d.device != nil { + if err := d.device.Close(); err != nil { + slog.Warn("Failed to close broken output device", "name", d.name, "error", err) + } + d.device = nil + } + + device, err := d.factory(d.name) + if err != nil { + return fmt.Errorf("%w: recover virtual keyboard: %v (cause: %v)", ErrOutputResetRequired, err, cause) + } + + d.device = device + slog.Info("Recovered virtual keyboard device", "name", d.name) + + return fmt.Errorf("%w: %v", ErrOutputResetRequired, cause) +} + +// SimpleMappingEngine is a simple implementation of MappingEngine for testing +type SimpleMappingEngine struct { + mappings []MappingRule +} + +// MappingRule defines a single key mapping rule +type MappingRule struct { + // Input pattern (e.g., [Cmd, C]) + Input []keys.Key + // Output pattern (e.g., [Ctrl, C]) + Output []keys.Key +} + +// NewSimpleMappingEngine creates a new simple mapping engine +func NewSimpleMappingEngine() *SimpleMappingEngine { + return &SimpleMappingEngine{ + mappings: make([]MappingRule, 0), + } +} + +// AddMapping adds a mapping rule +func (e *SimpleMappingEngine) AddMapping(rule MappingRule) { + e.mappings = append(e.mappings, rule) +} + +// Match implements MappingEngine +func (e *SimpleMappingEngine) Match(state SemanticState) ([]OutputCommand, bool) { + // Build input set from semantic state + inputKeys := make([]KeyCode, 0) + + // Add active modifiers + for key := range state.Modifiers.Active { + inputKeys = append(inputKeys, key) + } + + // Add pending modifiers + for key := range state.Modifiers.Pending { + inputKeys = append(inputKeys, key) + } + + // Add combo keys + inputKeys = append(inputKeys, state.Combo.ActiveKeys...) + + // Check each mapping + for _, mapping := range e.mappings { + if e.matches(inputKeys, mapping.Input) { + commands := make([]OutputCommand, len(mapping.Output)) + for i, key := range mapping.Output { + commands[i] = OutputCommand{ + Key: key, + Action: KeyPress, + } + } + return commands, true + } + } + + return nil, false +} + +// PrefixMatch implements MappingEngine +func (e *SimpleMappingEngine) PrefixMatch(keys []KeyCode) []MappingID { + var matches []MappingID + + for i, mapping := range e.mappings { + if e.isPrefix(keys, mapping.Input) { + matches = append(matches, MappingID(fmt.Sprintf("mapping-%d", i))) + } + } + + return matches +} + +// matches checks if input matches the pattern (set equality) +func (e *SimpleMappingEngine) matches(input, pattern []KeyCode) bool { + if len(input) != len(pattern) { + return false + } + + inputMap := make(map[KeyCode]bool) + for _, key := range input { + inputMap[key] = true + } + + for _, key := range pattern { + if !inputMap[key] { + return false + } + } + + return true +} + +// isPrefix checks if keys is a prefix of pattern +func (e *SimpleMappingEngine) isPrefix(keys, pattern []KeyCode) bool { + if len(keys) > len(pattern) { + return false + } + + for i, key := range keys { + if key != pattern[i] { + return false + } + } + + return true +} + +// LoggingOutputDevice wraps an OutputDevice with logging +type LoggingOutputDevice struct { + wrapped OutputDevice + logger *slog.Logger +} + +// NewLoggingOutputDevice creates a new logging output device +func NewLoggingOutputDevice(wrapped OutputDevice) *LoggingOutputDevice { + return &LoggingOutputDevice{ + wrapped: wrapped, + logger: slog.Default(), + } +} + +// Execute implements OutputDevice with logging +func (d *LoggingOutputDevice) Execute(cmd OutputCommand) error { + action := "release" + if cmd.Action == KeyPress { + action = "press" + } + d.logger.Debug("Output command", "key", cmd.Key, "action", action) + return d.wrapped.Execute(cmd) +} + +// Sync implements OutputDevice with logging +func (d *LoggingOutputDevice) Sync() error { + d.logger.Debug("Sync output device") + return d.wrapped.Sync() +} + +// Close implements OutputDevice with logging. +func (d *LoggingOutputDevice) Close() error { + d.logger.Debug("Close output device") + return d.wrapped.Close() +} diff --git a/pkg/statemachine/binder.go b/pkg/statemachine/binder.go new file mode 100644 index 0000000..4328c94 --- /dev/null +++ b/pkg/statemachine/binder.go @@ -0,0 +1,284 @@ +package statemachine + +import ( + "fmt" + "log/slog" + "sync" + "time" +) + +// StateBinder manages the binding relationships between input and output keys +// This is the core component that prevents sticky keys +type StateBinder struct { + mu sync.RWMutex + bindings map[BindingID]*Binding + inputToOutput map[KeyCode]map[BindingID]struct{} + outputToInput map[KeyCode]BindingID + nextID int +} + +// NewStateBinder creates a new StateBinder +func NewStateBinder() *StateBinder { + return &StateBinder{ + bindings: make(map[BindingID]*Binding), + inputToOutput: make(map[KeyCode]map[BindingID]struct{}), + outputToInput: make(map[KeyCode]BindingID), + nextID: 1, + } +} + +// CreateBinding creates a new binding between input and output keys +func (sb *StateBinder) CreateBinding(inputKeys, outputKeys []KeyCode, strategy ReleaseStrategy) BindingID { + sb.mu.Lock() + defer sb.mu.Unlock() + + id := BindingID(fmt.Sprintf("%d", sb.nextID)) + sb.nextID++ + + binding := &Binding{ + ID: id, + InputKeys: append([]KeyCode{}, inputKeys...), + OutputKeys: append([]KeyCode{}, outputKeys...), + CreatedAt: time.Now(), + Status: BindingActive, + } + + sb.bindings[id] = binding + + // Build reverse index + for _, inKey := range inputKeys { + if sb.inputToOutput[inKey] == nil { + sb.inputToOutput[inKey] = make(map[BindingID]struct{}) + } + sb.inputToOutput[inKey][id] = struct{}{} + } + + for _, outKey := range outputKeys { + sb.outputToInput[outKey] = id + } + + slog.Debug("Created binding", + "id", id, + "inputKeys", inputKeys, + "outputKeys", outputKeys, + "strategy", strategy) + + return id +} + +// ReleaseBinding releases all output keys associated with a binding +func (sb *StateBinder) ReleaseBinding(id BindingID) []KeyCode { + sb.mu.Lock() + defer sb.mu.Unlock() + + binding, ok := sb.bindings[id] + if !ok || binding.Status != BindingActive { + return nil + } + + binding.Status = BindingReleased + + // Clean up indices + for _, inKey := range binding.InputKeys { + if bindings, ok := sb.inputToOutput[inKey]; ok { + delete(bindings, id) + if len(bindings) == 0 { + delete(sb.inputToOutput, inKey) + } + } + } + + for _, outKey := range binding.OutputKeys { + delete(sb.outputToInput, outKey) + } + + slog.Debug("Released binding", "id", id, "outputKeys", binding.OutputKeys) + + return binding.OutputKeys +} + +// OnInputKeyReleased handles an input key release event +// Returns the output keys that should be released +func (sb *StateBinder) OnInputKeyReleased(key KeyCode) []KeyCode { + sb.mu.Lock() + defer sb.mu.Unlock() + + affectedBindings, ok := sb.inputToOutput[key] + if !ok { + return nil + } + + var toRelease []KeyCode + bindingsToRelease := make([]BindingID, 0) + + for id := range affectedBindings { + binding, ok := sb.bindings[id] + if !ok || binding.Status != BindingActive { + continue + } + + // Check release strategy + strategy := sb.determineReleaseStrategy(binding) + + switch strategy { + case ReleaseOnAnyBoundKeyReleased: + // Release all output keys in this binding + for _, outKey := range binding.OutputKeys { + toRelease = append(toRelease, outKey) + } + bindingsToRelease = append(bindingsToRelease, id) + + case ReleaseOnAllBoundKeysReleased: + // Check if all input keys are released + allReleased := true + for _, inKey := range binding.InputKeys { + // We need external state to know if key is pressed + // For now, assume we're only called when a key is released + if inKey == key { + continue // This is the key being released + } + // Check if this binding is still indexed (meaning key is still held) + if _, ok := sb.inputToOutput[inKey]; ok { + if bindings, ok := sb.inputToOutput[inKey]; ok { + if _, hasBinding := bindings[id]; hasBinding { + allReleased = false + break + } + } + } + } + if allReleased { + for _, outKey := range binding.OutputKeys { + toRelease = append(toRelease, outKey) + } + bindingsToRelease = append(bindingsToRelease, id) + } + } + } + + // Release the bindings + for _, id := range bindingsToRelease { + if binding, ok := sb.bindings[id]; ok { + binding.Status = BindingReleased + // Clean up indices + for _, inKey := range binding.InputKeys { + if bindings, ok := sb.inputToOutput[inKey]; ok { + delete(bindings, id) + if len(bindings) == 0 { + delete(sb.inputToOutput, inKey) + } + } + } + for _, outKey := range binding.OutputKeys { + delete(sb.outputToInput, outKey) + } + } + } + + if len(toRelease) > 0 { + slog.Debug("Input key released, triggering output release", + "inputKey", key, + "outputKeys", toRelease) + } + + return toRelease +} + +// GetBindingForOutput returns the binding ID for an output key +func (sb *StateBinder) GetBindingForOutput(key KeyCode) (BindingID, bool) { + sb.mu.RLock() + defer sb.mu.RUnlock() + id, ok := sb.outputToInput[key] + return id, ok +} + +// GetBinding returns a binding by ID +func (sb *StateBinder) GetBinding(id BindingID) (*Binding, bool) { + sb.mu.RLock() + defer sb.mu.RUnlock() + binding, ok := sb.bindings[id] + if !ok { + return nil, false + } + // Return a copy + return &Binding{ + ID: binding.ID, + InputKeys: append([]KeyCode{}, binding.InputKeys...), + OutputKeys: append([]KeyCode{}, binding.OutputKeys...), + CreatedAt: binding.CreatedAt, + Status: binding.Status, + }, true +} + +// GetActiveBindings returns all active bindings +func (sb *StateBinder) GetActiveBindings() []*Binding { + sb.mu.RLock() + defer sb.mu.RUnlock() + + var active []*Binding + for _, binding := range sb.bindings { + if binding.Status == BindingActive { + active = append(active, &Binding{ + ID: binding.ID, + InputKeys: append([]KeyCode{}, binding.InputKeys...), + OutputKeys: append([]KeyCode{}, binding.OutputKeys...), + CreatedAt: binding.CreatedAt, + Status: binding.Status, + }) + } + } + return active +} + +// Clear releases all bindings +func (sb *StateBinder) Clear() []KeyCode { + sb.mu.Lock() + defer sb.mu.Unlock() + + var allOutputs []KeyCode + for _, binding := range sb.bindings { + if binding.Status == BindingActive { + allOutputs = append(allOutputs, binding.OutputKeys...) + } + } + + sb.bindings = make(map[BindingID]*Binding) + sb.inputToOutput = make(map[KeyCode]map[BindingID]struct{}) + sb.outputToInput = make(map[KeyCode]BindingID) + + return allOutputs +} + +// determineReleaseStrategy determines the release strategy for a binding +// This can be extended to support per-binding configuration +func (sb *StateBinder) determineReleaseStrategy(binding *Binding) ReleaseStrategy { + // Default strategy: release on any bound key release + // This is the safest option for preventing sticky keys + return ReleaseOnAnyBoundKeyReleased +} + +// IsOutputKeyBound checks if an output key is currently bound +func (sb *StateBinder) IsOutputKeyBound(key KeyCode) bool { + sb.mu.RLock() + defer sb.mu.RUnlock() + _, ok := sb.outputToInput[key] + return ok +} + +// GetBoundInputKeys returns the input keys bound to an output key +func (sb *StateBinder) GetBoundInputKeys(outputKey KeyCode) []KeyCode { + sb.mu.RLock() + defer sb.mu.RUnlock() + + id, ok := sb.outputToInput[outputKey] + if !ok { + return nil + } + + binding, ok := sb.bindings[id] + if !ok { + return nil + } + + return append([]KeyCode{}, binding.InputKeys...) +} diff --git a/pkg/statemachine/config_engine.go b/pkg/statemachine/config_engine.go new file mode 100644 index 0000000..f51c443 --- /dev/null +++ b/pkg/statemachine/config_engine.go @@ -0,0 +1,189 @@ +package statemachine + +import ( + "fmt" + "log/slog" + "sort" + "sync" + + "github.com/jialeicui/keyswift/pkg/config" + "github.com/jialeicui/keyswift/pkg/keys" +) + +// ConfigMappingEngine is a mapping engine that uses static configuration. +// It performs set-based matching: key combinations are unordered sets, +// so [cmd, 1] and [1, cmd] are equivalent. +type ConfigMappingEngine struct { + mu sync.RWMutex + mappings []config.MappingRule + windowClass string + + // inputKeySets stores sorted key sets for O(n) set comparison + inputKeySets [][]keys.Key + // keyToRules maps each key to the rules it appears in (for PrefixMatch) + keyToRules map[keys.Key][]int +} + +// NewConfigMappingEngine creates a new mapping engine from configuration +func NewConfigMappingEngine(cfg *config.Config) *ConfigMappingEngine { + engine := &ConfigMappingEngine{ + mappings: cfg.Mappings, + keyToRules: make(map[keys.Key][]int), + } + + // Build sorted input key sets and key-to-rule index + engine.inputKeySets = make([][]keys.Key, len(cfg.Mappings)) + for i := range cfg.Mappings { + sorted := make([]keys.Key, len(cfg.Mappings[i].Input)) + copy(sorted, cfg.Mappings[i].Input) + sort.Slice(sorted, func(a, b int) bool { return sorted[a] < sorted[b] }) + engine.inputKeySets[i] = sorted + + // Index each key to the rules it appears in + for _, key := range sorted { + engine.keyToRules[key] = append(engine.keyToRules[key], i) + } + } + + slog.Info("Config mapping engine initialized", "mappings", len(cfg.Mappings)) + return engine +} + +// SetWindowClass updates the current window class +func (e *ConfigMappingEngine) SetWindowClass(class string) { + e.mu.Lock() + defer e.mu.Unlock() + if e.windowClass != class { + slog.Debug("Config engine window class changed", "from", e.windowClass, "to", class) + } + e.windowClass = class +} + +// Match implements MappingEngine. +// It treats key combinations as unordered sets. +func (e *ConfigMappingEngine) Match(state SemanticState) ([]OutputCommand, bool) { + e.mu.RLock() + windowClass := e.windowClass + e.mu.RUnlock() + + // Build input set from semantic state + // Include: active modifiers + pending modifiers + independents (downgraded modifiers) + combo keys + inputKeys := make([]keys.Key, 0) + + for key := range state.Modifiers.Active { + inputKeys = append(inputKeys, key) + } + for key := range state.Modifiers.Pending { + inputKeys = append(inputKeys, key) + } + // Include independents that were downgraded from modifiers — + // they may still be part of a valid combo if the non-modifier key arrives later + for key, info := range state.Independents { + if info.DowngradedFrom { + inputKeys = append(inputKeys, key) + } + } + inputKeys = append(inputKeys, state.Combo.ActiveKeys...) + + sort.Slice(inputKeys, func(i, j int) bool { return inputKeys[i] < inputKeys[j] }) + + slog.Debug("Config engine matching", "inputKeys", inputKeys, "windowClass", windowClass) + + // Find matching rules (exact set match) + for i, sortedRule := range e.inputKeySets { + if keySetsEqual(inputKeys, sortedRule) { + mapping := &e.mappings[i] + if mapping.MatchWindowClass(windowClass) { + commands := e.buildOutputCommands(mapping.Output) + slog.Info("Mapping matched", "input", mapping.Input, "output", mapping.Output, "window", windowClass) + return commands, true + } + } + } + + return nil, false +} + +// PrefixMatch implements MappingEngine. +// Returns all mapping IDs where the given keys are a SUBSET of the mapping's input keys. +// This is used by SSM to determine if a modifier key could potentially be part of a combo. +func (e *ConfigMappingEngine) PrefixMatch(queryKeys []keys.Key) []MappingID { + e.mu.RLock() + defer e.mu.RUnlock() + + if len(queryKeys) == 0 { + return nil + } + + // For each query key, find rules that contain it + // Start with rules containing the first key, then intersect + candidates, ok := e.keyToRules[queryKeys[0]] + if !ok || len(candidates) == 0 { + return nil + } + + // If single key query (common case: modifier check), just return matching rules + if len(queryKeys) == 1 { + matches := make([]MappingID, len(candidates)) + for i, idx := range candidates { + matches[i] = MappingID(fmt.Sprintf("mapping-%d", idx)) + } + return matches + } + + // Multiple keys: intersect candidates + candidateSet := make(map[int]bool, len(candidates)) + for _, idx := range candidates { + candidateSet[idx] = true + } + + for _, key := range queryKeys[1:] { + rules, ok := e.keyToRules[key] + if !ok { + return nil + } + newSet := make(map[int]bool) + for _, idx := range rules { + if candidateSet[idx] { + newSet[idx] = true + } + } + candidateSet = newSet + if len(candidateSet) == 0 { + return nil + } + } + + matches := make([]MappingID, 0, len(candidateSet)) + for idx := range candidateSet { + matches = append(matches, MappingID(fmt.Sprintf("mapping-%d", idx))) + } + return matches +} + +func (e *ConfigMappingEngine) buildOutputCommands(output []keys.Key) []OutputCommand { + commands := make([]OutputCommand, 0, len(output)) + + // Press all output keys + for _, key := range output { + commands = append(commands, OutputCommand{ + Key: key, + Action: KeyPress, + }) + } + + return commands +} + +// keySetsEqual checks if two sorted key slices are equal +func keySetsEqual(a, b []keys.Key) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/statemachine/config_integration.go b/pkg/statemachine/config_integration.go new file mode 100644 index 0000000..dc84762 --- /dev/null +++ b/pkg/statemachine/config_integration.go @@ -0,0 +1,93 @@ +package statemachine + +import ( + "context" + "log/slog" + "time" + + "github.com/jialeicui/keyswift/pkg/config" +) + +// ConfigStateMachine wraps the state machine with config-based mapping +type ConfigStateMachine struct { + machine *Machine + engine *ConfigMappingEngine + output OutputDevice + config Config + windowClass string + windowClassGetter func() string +} + +// NewConfigStateMachine creates a new state machine from configuration +func NewConfigStateMachine( + output OutputDevice, + cfg *config.Config, + windowClassGetter func() string, + machineConfig Config, +) *ConfigStateMachine { + engine := NewConfigMappingEngine(cfg) + + machine := NewMachine(engine, output, machineConfig) + + return &ConfigStateMachine{ + machine: machine, + engine: engine, + output: output, + config: machineConfig, + windowClassGetter: windowClassGetter, + } +} + +// Start starts the state machine +func (c *ConfigStateMachine) Start(ctx context.Context) error { + return c.machine.Start(ctx) +} + +// Stop stops the state machine +func (c *ConfigStateMachine) Stop() error { + return c.machine.Stop() +} + +// ProcessEvent processes a key event +func (c *ConfigStateMachine) ProcessEvent(device DeviceID, key KeyCode, pressed bool) error { + // Update window class before processing each event + if c.windowClassGetter != nil { + newClass := c.windowClassGetter() + if newClass != c.windowClass { + c.SetWindowClass(newClass) + } + } + + action := KeyRelease + if pressed { + action = KeyPress + } + + event := KeyEvent{ + Key: key, + Action: action, + Timestamp: time.Now(), + Device: device, + } + + return c.machine.ProcessEvent(event) +} + +// SetWindowClass updates the current window class +func (c *ConfigStateMachine) SetWindowClass(class string) { + if c.windowClass != class { + slog.Info("State machine window class changed", "from", c.windowClass, "to", class) + } + c.windowClass = class + c.engine.SetWindowClass(class) +} + +// EmergencyRelease forces release of all output keys +func (c *ConfigStateMachine) EmergencyRelease() []KeyCode { + return c.machine.EmergencyRelease() +} + +// HandleDeviceLost releases any keys still associated with a device that went away. +func (c *ConfigStateMachine) HandleDeviceLost(device DeviceID) error { + return c.machine.HandleDeviceLost(device) +} diff --git a/pkg/statemachine/doc.go b/pkg/statemachine/doc.go new file mode 100644 index 0000000..7ef9a03 --- /dev/null +++ b/pkg/statemachine/doc.go @@ -0,0 +1,55 @@ +// Package statemachine provides a robust state machine architecture for key remapping. +// +// The architecture solves the "sticky key" problem through three key innovations: +// +// 1. Layered State Machines +// - Input State Machine (ISM): Tracks physical input state +// - Semantic State Machine (SSM): Transforms physical input into semantic meaning +// - Output State Machine (OSM): Manages desired and actual output states +// +// 2. Explicit State Binding +// - StateBinder creates explicit bindings between input and output keys +// - When input keys are released, bound output keys are automatically released +// - This prevents modifier keys from getting stuck +// +// 3. Speculative Execution +// - Allows "pass-through" of modifier keys before confirming the combination +// - If a combination is detected, compensates for the speculative operation +// - If no combination is detected within the timeout, confirms the pass-through +// +// Basic Usage: +// +// // Create components +// output := &UInputDeviceAdapter{device: uinputDev} +// engine := NewSimpleMappingEngine() +// engine.AddMapping(MappingRule{ +// Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, +// Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, +// }) +// +// // Create and start state machine +// machine := NewMachine(engine, output, DefaultConfig()) +// machine.Start(context.Background()) +// defer machine.Stop() +// +// // Process events +// machine.ProcessEvent(KeyEvent{ +// Key: golibevdev.KeyLeftMeta, +// Action: KeyPress, +// Timestamp: time.Now(), +// Device: "keyboard1", +// }) +// +// Architecture Overview: +// +// ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ +// │ Event │────▶│ ISM │────▶│ SSM │────▶│ Mapping │────▶│ OSM │ +// │ Source │ │ │ │ │ │ Engine │ │ │ +// └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ +// │ │ │ │ │ +// ▼ ▼ ▼ ▼ ▼ +// Raw Event Input State Semantic State Output Cmds Device Output +// (Physical) (Physical) (Semantic) (Commands) (Virtual) +// +// For more details, see the design document at docs/design/state-machine-architecture.md +package statemachine diff --git a/pkg/statemachine/handler_integration.go b/pkg/statemachine/handler_integration.go new file mode 100644 index 0000000..04dcb30 --- /dev/null +++ b/pkg/statemachine/handler_integration.go @@ -0,0 +1,382 @@ +package statemachine + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/jialeicui/golibevdev" + "github.com/jialeicui/keyswift/pkg/config" + "github.com/jialeicui/keyswift/pkg/evdev" + "github.com/jialeicui/keyswift/pkg/wininfo" +) + +const ( + initialReconnectDelay = 500 * time.Millisecond + maxReconnectDelay = 5 * time.Second +) + +// HandlerWithStateMachine manages input devices and processes events through the state machine. +type HandlerWithStateMachine struct { + devices []*handlerDevice + devicesMu sync.RWMutex + wg sync.WaitGroup + out OutputDevice + closeOnce sync.Once + + stateMachine *ConfigStateMachine + overview evdev.Overview + + ctx context.Context + cancel context.CancelFunc + cfg *config.Config +} + +// handlerDevice represents a managed input device that can reconnect. +type handlerDevice struct { + mu sync.RWMutex + id DeviceID + device *golibevdev.InputDev + name string + path string + online bool +} + +// NewHandlerWithStateMachine creates a new handler with state machine support. +func NewHandlerWithStateMachine(cfg *config.Config) *HandlerWithStateMachine { + ctx, cancel := context.WithCancel(context.Background()) + return &HandlerWithStateMachine{ + devices: make([]*handlerDevice, 0), + ctx: ctx, + cancel: cancel, + cfg: cfg, + overview: evdev.NewOverviewImpl(), + } +} + +// AddDevice adds and grabs a new input device. +func (h *HandlerWithStateMachine) AddDevice(name, path string) error { + dev, err := openGrabbedInputDevice(path) + if err != nil { + return err + } + + h.devicesMu.Lock() + defer h.devicesMu.Unlock() + + deviceID := DeviceID(fmt.Sprintf("%s#%d", name, len(h.devices)+1)) + h.devices = append(h.devices, &handlerDevice{ + id: deviceID, + device: dev, + name: name, + path: path, + online: true, + }) + return nil +} + +// ProcessEvents starts processing events from all devices. +func (h *HandlerWithStateMachine) ProcessEvents(output OutputDevice, windowMonitor wininfo.WinGetter) { + h.out = output + + if h.cfg == nil { + slog.Error("State machine requires configuration but none provided") + return + } + + var windowClassGetter func() string + if windowMonitor != nil { + windowClassGetter = func() string { + info, _ := windowMonitor.GetActiveWindow() + if info != nil { + return info.Class + } + return "" + } + } else { + windowClassGetter = func() string { return "" } + } + + h.stateMachine = NewConfigStateMachine( + output, + h.cfg, + windowClassGetter, + DefaultConfig(), + ) + + if err := h.stateMachine.Start(h.ctx); err != nil { + slog.Error("Failed to start state machine", "error", err) + return + } + + slog.Info("State machine enabled for event processing", "mappings", len(h.cfg.Mappings)) + + h.devicesMu.RLock() + devices := append([]*handlerDevice(nil), h.devices...) + h.devicesMu.RUnlock() + + for _, dev := range devices { + h.wg.Add(1) + go func(d *handlerDevice) { + defer h.wg.Done() + h.deviceSupervisor(d) + }(dev) + } +} + +func (h *HandlerWithStateMachine) deviceSupervisor(dev *handlerDevice) { + slog.Info("Starting event processing for device", "device", dev.name, "path", dev.currentPath()) + + for { + err := h.processDeviceEvents(dev) + if err == nil { + return + } + + select { + case <-h.ctx.Done(): + return + default: + } + + slog.Warn("Input device disconnected, attempting recovery", + "device", dev.name, + "path", dev.currentPath(), + "error", err) + + if h.stateMachine != nil { + if releaseErr := h.stateMachine.HandleDeviceLost(dev.id); releaseErr != nil { + slog.Error("Failed to release lost device state", "device", dev.name, "error", releaseErr) + } + } + + dev.closeCurrent() + + if err := h.waitForReconnect(dev); err != nil { + if h.ctx.Err() == nil { + slog.Error("Stopped reconnecting device", "device", dev.name, "error", err) + } + return + } + + slog.Info("Input device recovered", "device", dev.name, "path", dev.currentPath()) + } +} + +// processDeviceEvents processes events using the state machine until the device fails. +func (h *HandlerWithStateMachine) processDeviceEvents(dev *handlerDevice) error { + for { + select { + case <-h.ctx.Done(): + return nil + default: + } + + input := dev.currentDevice() + if input == nil { + return fmt.Errorf("device handle is not available") + } + + ev, err := input.NextEvent(golibevdev.ReadFlagNormal) + if err != nil { + select { + case <-h.ctx.Done(): + return nil + default: + } + return err + } + + if ev.Type == golibevdev.EvSyn || ev.Type != golibevdev.EvKey { + continue + } + + keyCode := ev.Code.(golibevdev.KeyEventCode) + pressed := ev.Value == 1 + released := ev.Value == 0 + + if !pressed && !released { + slog.Debug("Skipping key event with value", "value", ev.Value) + continue + } + + slog.Debug("Received key event", "key", keyCode, "pressed", pressed, "device", dev.name) + + if err := h.stateMachine.ProcessEvent(dev.id, keyCode, pressed); err != nil { + slog.Error("Failed to process event through state machine", + "device", dev.name, + "key", keyCode, + "error", err) + } + } +} + +func (h *HandlerWithStateMachine) waitForReconnect(dev *handlerDevice) error { + delay := initialReconnectDelay + + for { + if err := h.tryReconnect(dev); err == nil { + return nil + } else { + slog.Warn("Reconnect attempt failed", + "device", dev.name, + "path", dev.currentPath(), + "retryIn", delay, + "error", err) + } + + select { + case <-h.ctx.Done(): + return h.ctx.Err() + case <-time.After(delay): + } + + if delay < maxReconnectDelay { + delay *= 2 + if delay > maxReconnectDelay { + delay = maxReconnectDelay + } + } + } +} + +func (h *HandlerWithStateMachine) tryReconnect(dev *handlerDevice) error { + currentPath := dev.currentPath() + if currentPath != "" { + input, err := openGrabbedInputDevice(currentPath) + if err == nil { + dev.setConnection(currentPath, input) + return nil + } + } + + devices, err := h.overview.ListInputDevices() + if err != nil { + return fmt.Errorf("list input devices: %w", err) + } + + candidate := evdev.FindDeviceByName(devices, dev.name, h.activePaths(dev.id)) + if candidate == nil { + return fmt.Errorf("device %q not found during rescan", dev.name) + } + + input, err := openGrabbedInputDevice(candidate.Path) + if err != nil { + return fmt.Errorf("reopen device %s: %w", candidate.Path, err) + } + + dev.setConnection(candidate.Path, input) + return nil +} + +func (h *HandlerWithStateMachine) activePaths(exclude DeviceID) map[string]struct{} { + h.devicesMu.RLock() + defer h.devicesMu.RUnlock() + + paths := make(map[string]struct{}) + for _, dev := range h.devices { + if dev.id == exclude { + continue + } + path, online := dev.currentPathState() + if online && path != "" { + paths[path] = struct{}{} + } + } + return paths +} + +// Wait waits for all event processing to complete. +func (h *HandlerWithStateMachine) Wait() { + h.wg.Wait() +} + +// Close closes all input devices and stops the state machine. +func (h *HandlerWithStateMachine) Close() { + h.closeOnce.Do(func() { + h.cancel() + + if h.stateMachine != nil { + if err := h.stateMachine.Stop(); err != nil { + slog.Error("Error stopping state machine", "error", err) + } + } + + h.devicesMu.RLock() + devices := append([]*handlerDevice(nil), h.devices...) + h.devicesMu.RUnlock() + + for _, dev := range devices { + slog.Info("Closing device", "device", dev.name) + dev.closeCurrent() + } + + h.wg.Wait() + + if h.out != nil { + if err := h.out.Close(); err != nil { + slog.Error("Failed to close output device", "error", err) + } + } + }) +} + +// EmergencyRelease forces release of all keys. +func (h *HandlerWithStateMachine) EmergencyRelease() { + if h.stateMachine != nil { + h.stateMachine.EmergencyRelease() + } +} + +func openGrabbedInputDevice(path string) (*golibevdev.InputDev, error) { + dev, err := golibevdev.NewInputDev(path) + if err != nil { + return nil, err + } + + if err := dev.Grab(); err != nil { + dev.Close() + return nil, err + } + + return dev, nil +} + +func (d *handlerDevice) currentDevice() *golibevdev.InputDev { + d.mu.RLock() + defer d.mu.RUnlock() + return d.device +} + +func (d *handlerDevice) currentPath() string { + d.mu.RLock() + defer d.mu.RUnlock() + return d.path +} + +func (d *handlerDevice) currentPathState() (string, bool) { + d.mu.RLock() + defer d.mu.RUnlock() + return d.path, d.online +} + +func (d *handlerDevice) setConnection(path string, device *golibevdev.InputDev) { + d.mu.Lock() + defer d.mu.Unlock() + d.path = path + d.device = device + d.online = true +} + +func (d *handlerDevice) closeCurrent() { + d.mu.Lock() + defer d.mu.Unlock() + + if d.device != nil { + d.device.Close() + d.device = nil + } + d.online = false +} diff --git a/pkg/statemachine/interfaces.go b/pkg/statemachine/interfaces.go new file mode 100644 index 0000000..fda6844 --- /dev/null +++ b/pkg/statemachine/interfaces.go @@ -0,0 +1,114 @@ +package statemachine + +import ( + "context" +) + +// OutputDevice abstracts the virtual output device +type OutputDevice interface { + // Execute sends a command to the output device + Execute(cmd OutputCommand) error + // Sync synchronizes the device state + Sync() error + // Close releases the underlying output device + Close() error +} + +// MappingEngine abstracts the key mapping decision engine +type MappingEngine interface { + // Match checks if the current input matches any mapping + // Returns the output commands and true if matched + Match(state SemanticState) ([]OutputCommand, bool) + // PrefixMatch checks if the current input is a prefix of any mapping + // Returns potential matches + PrefixMatch(keys []KeyCode) []MappingID +} + +// RingBuffer is a circular buffer for storing historical states +type RingBuffer[T any] struct { + buffer []T + head int + tail int + size int + count int +} + +// NewRingBuffer creates a new ring buffer with the given capacity +func NewRingBuffer[T any](capacity int) *RingBuffer[T] { + return &RingBuffer[T]{ + buffer: make([]T, capacity), + size: capacity, + } +} + +// Push adds an element to the buffer +func (rb *RingBuffer[T]) Push(item T) { + if rb.count == rb.size { + // Buffer is full, overwrite oldest + rb.tail = (rb.tail + 1) % rb.size + } else { + rb.count++ + } + rb.buffer[rb.head] = item + rb.head = (rb.head + 1) % rb.size +} + +// Pop removes and returns the oldest element +func (rb *RingBuffer[T]) Pop() (T, bool) { + var zero T + if rb.count == 0 { + return zero, false + } + item := rb.buffer[rb.tail] + rb.tail = (rb.tail + 1) % rb.size + rb.count-- + return item, true +} + +// Peek returns the newest element without removing it +func (rb *RingBuffer[T]) Peek() (T, bool) { + var zero T + if rb.count == 0 { + return zero, false + } + idx := (rb.head - 1 + rb.size) % rb.size + return rb.buffer[idx], true +} + +// Len returns the number of elements in the buffer +func (rb *RingBuffer[T]) Len() int { + return rb.count +} + +// Clear removes all elements +func (rb *RingBuffer[T]) Clear() { + rb.head = 0 + rb.tail = 0 + rb.count = 0 +} + +// Slice returns a copy of all elements in order (oldest first) +func (rb *RingBuffer[T]) Slice() []T { + result := make([]T, 0, rb.count) + for i := 0; i < rb.count; i++ { + idx := (rb.tail + i) % rb.size + result = append(result, rb.buffer[idx]) + } + return result +} + +// StateMachine is the main interface for the state machine +type StateMachine interface { + // ProcessEvent processes a raw key event + ProcessEvent(event KeyEvent) error + // GetInputState returns the current input state + GetInputState() InputState + // GetSemanticState returns the current semantic state + GetSemanticState() SemanticState + // GetOutputState returns the current desired output state + GetOutputState() OutputState + // Start starts the state machine background tasks + Start(ctx context.Context) error + // Stop stops the state machine + Stop() error +} diff --git a/pkg/statemachine/ism.go b/pkg/statemachine/ism.go new file mode 100644 index 0000000..589e91d --- /dev/null +++ b/pkg/statemachine/ism.go @@ -0,0 +1,206 @@ +package statemachine + +import ( + "sync" + "time" +) + +// ISM (Input State Machine) tracks the physical state of all input devices +type ISM struct { + mu sync.RWMutex + current InputState + history *RingBuffer[InputState] + devices map[DeviceID]*DeviceState + config SemanticConfig +} + +// DeviceState tracks state for a single input device +type DeviceState struct { + ID DeviceID + KeyStates map[KeyCode]KeyState + LastEvent time.Time +} + +// NewISM creates a new Input State Machine +func NewISM(config SemanticConfig) *ISM { + return &ISM{ + current: InputState{ + Timestamp: time.Now(), + KeyStates: make(map[KeyCode]KeyState), + EventSeq: make([]KeyEvent, 0), + }, + history: NewRingBuffer[InputState](100), + devices: make(map[DeviceID]*DeviceState), + config: config, + } +} + +// ProcessEvent processes a raw key event and updates the input state +func (ism *ISM) ProcessEvent(event KeyEvent) (InputState, bool) { + ism.mu.Lock() + defer ism.mu.Unlock() + + // Save current state to history before modification + if ism.current.Timestamp.After(time.Time{}) { + ism.history.Push(ism.current.Clone()) + } + + // Update device state + device, exists := ism.devices[event.Device] + if !exists { + device = &DeviceState{ + ID: event.Device, + KeyStates: make(map[KeyCode]KeyState), + } + ism.devices[event.Device] = device + } + + // Check for debounce + if existing, ok := ism.current.KeyStates[event.Key]; ok { + if event.Action == KeyPress && existing.Pressed { + // Duplicate press — check debounce threshold + if event.Timestamp.Sub(existing.PressedAt) < ism.config.DebounceThreshold { + // Within debounce window, ignore + return ism.current.Clone(), false + } + // After debounce window, treat as key repeat + existing.PressCount++ + existing.PressedAt = event.Timestamp + ism.current.KeyStates[event.Key] = existing + return ism.current.Clone(), true + } + if event.Action == KeyRelease && !existing.Pressed { + // Duplicate release, ignore + return ism.current.Clone(), false + } + } + + // Update key state + switch event.Action { + case KeyPress: + ism.current.KeyStates[event.Key] = KeyState{ + Pressed: true, + PressedAt: event.Timestamp, + Device: event.Device, + PressCount: 1, + } + device.KeyStates[event.Key] = ism.current.KeyStates[event.Key] + + case KeyRelease: + // Keep the PressedAt for history tracking + if existing, ok := ism.current.KeyStates[event.Key]; ok { + ism.current.KeyStates[event.Key] = KeyState{ + Pressed: false, + PressedAt: existing.PressedAt, + Device: event.Device, + PressCount: 0, + } + } + delete(device.KeyStates, event.Key) + } + + // Update event sequence (limit to max 100 events to prevent memory leak) + const maxEventSeqLen = 100 + ism.current.EventSeq = append(ism.current.EventSeq, event) + if len(ism.current.EventSeq) > maxEventSeqLen { + ism.current.EventSeq = ism.current.EventSeq[len(ism.current.EventSeq)-maxEventSeqLen:] + } + ism.current.Timestamp = event.Timestamp + device.LastEvent = event.Timestamp + + return ism.current.Clone(), true +} + +// GetCurrent returns the current input state +func (ism *ISM) GetCurrent() InputState { + ism.mu.RLock() + defer ism.mu.RUnlock() + return ism.current.Clone() +} + +// GetHistory returns historical states +func (ism *ISM) GetHistory() []InputState { + ism.mu.RLock() + defer ism.mu.RUnlock() + return ism.history.Slice() +} + +// GetDeviceState returns the state for a specific device +func (ism *ISM) GetDeviceState(deviceID DeviceID) (DeviceState, bool) { + ism.mu.RLock() + defer ism.mu.RUnlock() + device, ok := ism.devices[deviceID] + if !ok { + return DeviceState{}, false + } + // Clone the device state + cloned := DeviceState{ + ID: device.ID, + KeyStates: make(map[KeyCode]KeyState), + LastEvent: device.LastEvent, + } + for k, v := range device.KeyStates { + cloned.KeyStates[k] = v + } + return cloned, true +} + +// GetAllPressedKeys returns all currently pressed keys across all devices +func (ism *ISM) GetAllPressedKeys() []KeyCode { + ism.mu.RLock() + defer ism.mu.RUnlock() + + keys := make([]KeyCode, 0, len(ism.current.KeyStates)) + for key, state := range ism.current.KeyStates { + if state.Pressed { + keys = append(keys, key) + } + } + return keys +} + +// IsKeyPressed checks if a specific key is currently pressed +func (ism *ISM) IsKeyPressed(key KeyCode) bool { + ism.mu.RLock() + defer ism.mu.RUnlock() + state, ok := ism.current.KeyStates[key] + return ok && state.Pressed +} + +// GetKeyHoldDuration returns how long a key has been held +func (ism *ISM) GetKeyHoldDuration(key KeyCode) time.Duration { + ism.mu.RLock() + defer ism.mu.RUnlock() + state, ok := ism.current.KeyStates[key] + if !ok || !state.Pressed { + return 0 + } + return time.Since(state.PressedAt) +} + +// RemoveDevice removes a device and all its associated key states +func (ism *ISM) RemoveDevice(deviceID DeviceID) { + ism.mu.Lock() + defer ism.mu.Unlock() + + for key, state := range ism.current.KeyStates { + if state.Device == deviceID { + delete(ism.current.KeyStates, key) + } + } + delete(ism.devices, deviceID) +} + +// Clear resets the input state machine +func (ism *ISM) Clear() { + ism.mu.Lock() + defer ism.mu.Unlock() + + ism.current = InputState{ + Timestamp: time.Now(), + KeyStates: make(map[KeyCode]KeyState), + EventSeq: make([]KeyEvent, 0), + } + ism.history.Clear() + ism.devices = make(map[DeviceID]*DeviceState) +} diff --git a/pkg/statemachine/machine.go b/pkg/statemachine/machine.go new file mode 100644 index 0000000..53905dd --- /dev/null +++ b/pkg/statemachine/machine.go @@ -0,0 +1,486 @@ +package statemachine + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "sync" + "time" +) + +// Machine is the main state machine that orchestrates all components +type Machine struct { + config Config + ism *ISM + ssm *SSM + osm *OSM + binder *StateBinder + executor *SpeculativeExecutor + engine MappingEngine + output OutputDevice + + // Lifecycle + stopCh chan struct{} + wg sync.WaitGroup + + // State + mu sync.RWMutex + running bool + modifierStates map[KeyCode]ModifierState + passthroughPressed map[KeyCode]struct{} + + // Callbacks + onMappingTriggered func([]OutputCommand) +} + +// NewMachine creates a new state machine with all components +func NewMachine(engine MappingEngine, output OutputDevice, config Config) *Machine { + binder := NewStateBinder() + + return &Machine{ + config: config, + ism: NewISM(config.Semantic), + ssm: NewSSM(config.Semantic, engine), + binder: binder, + osm: NewOSM(binder, output, config.Alignment), + executor: NewSpeculativeExecutor(config.Speculative, output), + engine: engine, + output: output, + modifierStates: make(map[KeyCode]ModifierState), + passthroughPressed: make(map[KeyCode]struct{}), + stopCh: make(chan struct{}), + } +} + +// ProcessEvent processes a key event synchronously through the state machine +func (m *Machine) ProcessEvent(event KeyEvent) error { + m.mu.RLock() + if !m.running { + m.mu.RUnlock() + return fmt.Errorf("state machine is not running") + } + m.mu.RUnlock() + + err := m.handleEvent(event) + if errors.Is(err, ErrOutputResetRequired) { + m.HandleOutputFailure() + return nil + } + return err +} + +// ProcessEventSync is an alias for ProcessEvent (kept for test compatibility) +func (m *Machine) ProcessEventSync(event KeyEvent) error { + return m.ProcessEvent(event) +} + +// Start starts the state machine background processing +func (m *Machine) Start(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.running { + return fmt.Errorf("state machine is already running") + } + + m.running = true + m.stopCh = make(chan struct{}) + + // Start sync ticker + m.wg.Add(1) + go m.syncLoop(ctx) + + // Start deadline checker + if m.config.Speculative.Enabled { + m.wg.Add(1) + go m.deadlineChecker(ctx) + } + + slog.Info("State machine started") + return nil +} + +// Stop stops the state machine +func (m *Machine) Stop() error { + m.mu.Lock() + if !m.running { + m.mu.Unlock() + return nil + } + m.running = false + close(m.stopCh) + m.mu.Unlock() + + // Wait for all goroutines to finish + done := make(chan struct{}) + go func() { + m.wg.Wait() + close(done) + }() + + select { + case <-done: + slog.Info("State machine stopped gracefully") + case <-time.After(5 * time.Second): + slog.Warn("State machine stop timed out, forcing emergency release") + m.osm.EmergencyRelease() + } + + return nil +} + +// GetInputState returns the current input state +func (m *Machine) GetInputState() InputState { + return m.ism.GetCurrent() +} + +// GetSemanticState returns the current semantic state +func (m *Machine) GetSemanticState() SemanticState { + return m.ssm.GetCurrent() +} + +// GetOutputState returns the current desired output state +func (m *Machine) GetOutputState() OutputState { + return m.osm.GetDesiredState() +} + +// SetMappingTriggeredCallback sets the callback for when a mapping is triggered +func (m *Machine) SetMappingTriggeredCallback(cb func([]OutputCommand)) { + m.mu.Lock() + defer m.mu.Unlock() + m.onMappingTriggered = cb +} + +// EmergencyRelease forces release of all output keys +func (m *Machine) EmergencyRelease() []KeyCode { + return m.osm.EmergencyRelease() +} + +// HandleDeviceLost synthesizes release events for all pressed keys from a device. +func (m *Machine) HandleDeviceLost(deviceID DeviceID) error { + deviceState, ok := m.ism.GetDeviceState(deviceID) + if !ok { + return nil + } + + lostKeys := make([]lostDeviceKey, 0, len(deviceState.KeyStates)) + for key, state := range deviceState.KeyStates { + if state.Pressed { + lostKeys = append(lostKeys, lostDeviceKey{key: key, state: state}) + } + } + + sort.Slice(lostKeys, func(i, j int) bool { + if lostKeys[i].state.PressedAt.Equal(lostKeys[j].state.PressedAt) { + return lostKeys[i].key < lostKeys[j].key + } + return lostKeys[i].state.PressedAt.After(lostKeys[j].state.PressedAt) + }) + + for _, lost := range lostKeys { + if err := m.ProcessEvent(KeyEvent{ + Key: lost.key, + Action: KeyRelease, + Timestamp: time.Now(), + Device: deviceID, + }); err != nil { + return err + } + } + + m.ism.RemoveDevice(deviceID) + m.cleanupLostDeviceState(lostKeys) + return nil +} + +// HandleOutputFailure clears tracked state after the output device is rebuilt. +func (m *Machine) HandleOutputFailure() { + slog.Warn("Resetting state machine after output device recovery") + + m.mu.Lock() + m.modifierStates = make(map[KeyCode]ModifierState) + m.passthroughPressed = make(map[KeyCode]struct{}) + m.mu.Unlock() + + m.ism.Clear() + m.ssm.Clear() + m.osm.Reset() + m.executor.Clear() +} + +// ModifierState tracks the lifecycle of an active modifier +type ModifierState int + +type lostDeviceKey struct { + key KeyCode + state KeyState +} + +const ( + ModifierStateAbsorbed ModifierState = iota + ModifierStateUsedInMapping + ModifierStateFlushed +) + +func (m *Machine) cleanupLostDeviceState(lostKeys []lostDeviceKey) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, lost := range lostKeys { + delete(m.modifierStates, lost.key) + delete(m.passthroughPressed, lost.key) + } +} + +func (m *Machine) executePassthrough(cmd OutputCommand) error { + if err := m.output.Execute(cmd); err != nil { + return err + } + + m.mu.Lock() + defer m.mu.Unlock() + + if cmd.Action == KeyPress { + m.passthroughPressed[cmd.Key] = struct{}{} + } else { + delete(m.passthroughPressed, cmd.Key) + } + + return nil +} + +// handleEvent handles a single key event +func (m *Machine) handleEvent(event KeyEvent) error { + slog.Debug("handleEvent called", "key", event.Key, "action", event.Action) + + // Step 1: Update ISM + inputState, changed := m.ism.ProcessEvent(event) + slog.Debug("ISM ProcessEvent result", "changed", changed, "keyStates", len(inputState.KeyStates), "eventSeqLen", len(inputState.EventSeq)) + if !changed { + return nil + } + + // Step 2: Handle key release in OSM (for binding cleanup) + if event.Action == KeyRelease { + if err := m.osm.HandleInputRelease(event.Key); err != nil { + return err + } + } + + // Step 3: Translate to semantic state + semanticState := m.ssm.Translate(inputState) + slog.Debug("SSM Translate result", "modifiersActive", len(semanticState.Modifiers.Active), "comboKeys", len(semanticState.Combo.ActiveKeys)) + + // Step 4: Check for mapping match + commands, matched := m.engine.Match(semanticState) + slog.Debug("Engine Match result", "matched", matched, "commands", commands) + if matched { + m.mu.Lock() + var toRelease []KeyCode + for modKey := range semanticState.Modifiers.Active { + if state, exists := m.modifierStates[modKey]; exists && state == ModifierStateFlushed { + // Previously flushed for an unmapped combo, but now used in a mapping. + // We must release it from the OS, otherwise it will remain stuck. + slog.Debug("Releasing previously flushed modifier before mapping", "key", modKey) + toRelease = append(toRelease, modKey) + } + m.modifierStates[modKey] = ModifierStateUsedInMapping + } + m.mu.Unlock() + + for _, rel := range toRelease { + if err := m.executePassthrough(OutputCommand{Key: rel, Action: KeyRelease}); err != nil { + return err + } + } + + return m.handleMapping(commands, inputState, semanticState) + } + + // Step 5: No mapping matched — decide whether to forward + + if event.Action == KeyPress { + if _, isActive := semanticState.Modifiers.Active[event.Key]; isActive { + slog.Debug("Active modifier absorbed (waiting for combo)", "key", event.Key) + m.mu.Lock() + m.modifierStates[event.Key] = ModifierStateAbsorbed + m.mu.Unlock() + return nil + } + + // Non-modifier key was pressed and didn't match any mappings. + // Flush absorbed modifiers to OS so native shortcuts work. + if !IsModifier(event.Key) { + m.mu.Lock() + var toFlush []KeyCode + for modKey, state := range m.modifierStates { + if state == ModifierStateAbsorbed { + toFlush = append(toFlush, modKey) + } + } + m.mu.Unlock() + + for _, modKey := range toFlush { + slog.Debug("Flushing absorbed modifier for unmapped combo", "key", modKey) + if err := m.executePassthrough(OutputCommand{Key: modKey, Action: KeyPress}); err != nil { + return err + } + m.mu.Lock() + m.modifierStates[modKey] = ModifierStateFlushed + m.mu.Unlock() + } + } + } + + if event.Action == KeyRelease && IsModifier(event.Key) { + m.mu.Lock() + state, exists := m.modifierStates[event.Key] + if exists { + delete(m.modifierStates, event.Key) + } + m.mu.Unlock() + + if exists { + if state == ModifierStateAbsorbed { + slog.Debug("Absorbed modifier released without combo, tapping", "key", event.Key) + if err := m.executePassthrough(OutputCommand{Key: event.Key, Action: KeyPress}); err != nil { + return err + } + return m.executePassthrough(OutputCommand{Key: event.Key, Action: KeyRelease}) + } else if state == ModifierStateFlushed { + slog.Debug("Flushed modifier released, passing through release", "key", event.Key) + return m.executePassthrough(OutputCommand{Key: event.Key, Action: KeyRelease}) + } else if state == ModifierStateUsedInMapping { + slog.Debug("Used modifier released, absorbing release", "key", event.Key) + return nil + } + } + } + + // Non-modifier keys or independent modifiers: forward directly + slog.Debug("Forwarding event", "key", event.Key, "action", event.Action) + cmd := OutputCommand{ + Key: event.Key, + Action: event.Action, + } + return m.executePassthrough(cmd) +} + +// handleMapping handles a successful mapping match +func (m *Machine) handleMapping(commands []OutputCommand, inputState InputState, semanticState SemanticState) error { + // Get all active input keys for binding + inputKeys := make([]KeyCode, 0) + for key, state := range inputState.KeyStates { + if state.Pressed { + inputKeys = append(inputKeys, key) + } + } + + // Check for speculative operations that need confirmation + for _, cmd := range commands { + if cmd.Action == KeyPress && m.executor.IsPending(cmd.Key) { + m.executor.Confirm(cmd.Key) + } + } + + // Compensate any pending modifiers that are not part of this mapping + for key := range semanticState.Modifiers.Pending { + // Check if this key is in the input keys + found := false + for _, inKey := range inputKeys { + if inKey == key { + found = true + break + } + } + if !found { + // This pending modifier is not part of the mapping + // Mark it as passed through and compensate if needed + m.ssm.MarkPendingPassedThrough(key) + } + } + + // Apply the mapping commands + if err := m.osm.ApplyCommands(commands, inputKeys, "mapping"); err != nil { + return fmt.Errorf("failed to apply commands: %w", err) + } + + // Trigger callback + if m.onMappingTriggered != nil { + m.onMappingTriggered(commands) + } + + slog.Debug("Mapping triggered", + "inputKeys", inputKeys, + "commands", commands) + + return nil +} + +// syncLoop periodically synchronizes output state +func (m *Machine) syncLoop(ctx context.Context) { + defer m.wg.Done() + + ticker := time.NewTicker(m.config.Alignment.SyncInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := m.osm.Sync(); err != nil { + if errors.Is(err, ErrOutputResetRequired) { + m.HandleOutputFailure() + continue + } + slog.Error("Failed to sync output state", "error", err) + } + + // Check for timed-out keys + if timedOut := m.osm.CheckTimeouts(); len(timedOut) > 0 { + slog.Warn("Released timed-out keys", "keys", timedOut) + } + + case <-ctx.Done(): + return + case <-m.stopCh: + return + } + } +} + +// deadlineChecker checks for expired speculative operation deadlines +func (m *Machine) deadlineChecker(ctx context.Context) { + defer m.wg.Done() + + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + expired := m.executor.CheckDeadlines() + if len(expired) > 0 { + slog.Debug("Auto-confirmed expired speculative operations", "keys", expired) + } + + // Also check for pending modifier timeouts + m.checkPendingModifierTimeouts() + + case <-ctx.Done(): + return + case <-m.stopCh: + return + } + } +} + +// checkPendingModifierTimeouts checks and handles pending modifier timeouts +func (m *Machine) checkPendingModifierTimeouts() { + // This triggers the SSM to downgrade pending modifiers + // The actual downgrade happens in the next Translate call + // But we can force it by getting the current state + inputState := m.ism.GetCurrent() + _ = m.ssm.Translate(inputState) +} diff --git a/pkg/statemachine/osm.go b/pkg/statemachine/osm.go new file mode 100644 index 0000000..51a0509 --- /dev/null +++ b/pkg/statemachine/osm.go @@ -0,0 +1,301 @@ +package statemachine + +import ( + "log/slog" + "sync" + "time" +) + +// OSM (Output State Machine) manages the desired and actual output states +type OSM struct { + mu sync.RWMutex + desired OutputState + actual OutputState + binder *StateBinder + output OutputDevice + config AlignmentConfig + // Track compensated keys for alignment + compensatedKeys map[KeyCode]time.Time + // Track pending releases + pendingReleases map[KeyCode]time.Time +} + +// NewOSM creates a new Output State Machine +func NewOSM(binder *StateBinder, output OutputDevice, config AlignmentConfig) *OSM { + return &OSM{ + desired: OutputState{ + PressedKeys: make(map[KeyCode]OutputKeyInfo), + }, + actual: OutputState{ + PressedKeys: make(map[KeyCode]OutputKeyInfo), + }, + binder: binder, + output: output, + config: config, + compensatedKeys: make(map[KeyCode]time.Time), + pendingReleases: make(map[KeyCode]time.Time), + } +} + +// ApplyCommands applies output commands from the mapping engine +func (osm *OSM) ApplyCommands(commands []OutputCommand, inputKeys []KeyCode, mappingID MappingID) error { + osm.mu.Lock() + defer osm.mu.Unlock() + + // Group commands by action + var toPress, toRelease []KeyCode + for _, cmd := range commands { + switch cmd.Action { + case KeyPress: + toPress = append(toPress, cmd.Key) + case KeyRelease: + toRelease = append(toRelease, cmd.Key) + } + } + + // Process releases first (for clean state transitions) + for _, key := range toRelease { + if _, ok := osm.desired.PressedKeys[key]; ok { + delete(osm.desired.PressedKeys, key) + if err := osm.executeRelease(key); err != nil { + return err + } + } + } + + // Process presses + for _, key := range toPress { + if _, ok := osm.desired.PressedKeys[key]; !ok { + osm.desired.PressedKeys[key] = OutputKeyInfo{ + PressedAt: time.Now(), + SourceMapping: mappingID, + BoundTo: append([]KeyCode{}, inputKeys...), + ReleaseStrategy: ReleaseOnAnyBoundKeyReleased, + } + if err := osm.executePress(key); err != nil { + return err + } + } + } + + // Create binding between input and output keys + if len(inputKeys) > 0 && len(toPress) > 0 { + osm.binder.CreateBinding(inputKeys, toPress, ReleaseOnAnyBoundKeyReleased) + } + + return nil +} + +// HandleInputRelease handles an input key release event +func (osm *OSM) HandleInputRelease(key KeyCode) error { + osm.mu.Lock() + defer osm.mu.Unlock() + + // Check if this triggers any output releases via binding + toRelease := osm.binder.OnInputKeyReleased(key) + + for _, outKey := range toRelease { + if _, ok := osm.desired.PressedKeys[outKey]; ok { + delete(osm.desired.PressedKeys, outKey) + if err := osm.executeRelease(outKey); err != nil { + return err + } + } + } + + // Check if this was a compensated key + if _, ok := osm.compensatedKeys[key]; ok { + delete(osm.compensatedKeys, key) + slog.Debug("Compensated key physically released", "key", key) + } + + return nil +} + +// Sync synchronizes desired state with actual state +func (osm *OSM) Sync() error { + osm.mu.Lock() + defer osm.mu.Unlock() + + diff := osm.calculateDiff() + + // Process releases first + for _, key := range diff.ShouldRelease { + // Check if this is a compensated key + if _, ok := osm.compensatedKeys[key]; ok { + // Don't release yet, wait for physical release + osm.pendingReleases[key] = time.Now() + continue + } + + if err := osm.executeRelease(key); err != nil { + return err + } + } + + // Process presses + for _, key := range diff.ShouldPress { + if err := osm.executePress(key); err != nil { + return err + } + } + + return nil +} + +// EmergencyRelease releases all output keys (safety mechanism) +func (osm *OSM) EmergencyRelease() []KeyCode { + osm.mu.Lock() + defer osm.mu.Unlock() + + var released []KeyCode + for key := range osm.desired.PressedKeys { + released = append(released, key) + if err := osm.executeRelease(key); err != nil { + slog.Error("Failed to emergency release key", "key", key, "error", err) + } + } + + osm.desired.PressedKeys = make(map[KeyCode]OutputKeyInfo) + osm.binder.Clear() + + slog.Warn("Emergency release executed", "releasedKeys", released) + return released +} + +// MarkCompensated marks a key as compensated +func (osm *OSM) MarkCompensated(key KeyCode) { + osm.mu.Lock() + defer osm.mu.Unlock() + osm.compensatedKeys[key] = time.Now() +} + +// IsCompensated checks if a key is compensated +func (osm *OSM) IsCompensated(key KeyCode) bool { + osm.mu.RLock() + defer osm.mu.RUnlock() + _, ok := osm.compensatedKeys[key] + return ok +} + +// GetDesiredState returns the desired output state +func (osm *OSM) GetDesiredState() OutputState { + osm.mu.RLock() + defer osm.mu.RUnlock() + return osm.desired.Clone() +} + +// GetActualState returns the actual output state +func (osm *OSM) GetActualState() OutputState { + osm.mu.RLock() + defer osm.mu.RUnlock() + return osm.actual.Clone() +} + +// GetDiff returns the current state difference +func (osm *OSM) GetDiff() StateDiff { + osm.mu.RLock() + defer osm.mu.RUnlock() + return osm.calculateDiff() +} + +// executePress executes a key press +func (osm *OSM) executePress(key KeyCode) error { + cmd := OutputCommand{Key: key, Action: KeyPress} + if err := osm.output.Execute(cmd); err != nil { + return err + } + osm.actual.PressedKeys[key] = osm.desired.PressedKeys[key] + slog.Debug("Executed key press", "key", key) + return nil +} + +// executeRelease executes a key release +func (osm *OSM) executeRelease(key KeyCode) error { + cmd := OutputCommand{Key: key, Action: KeyRelease} + if err := osm.output.Execute(cmd); err != nil { + return err + } + delete(osm.actual.PressedKeys, key) + delete(osm.pendingReleases, key) + slog.Debug("Executed key release", "key", key) + return nil +} + +// calculateDiff calculates the difference between desired and actual states +func (osm *OSM) calculateDiff() StateDiff { + var diff StateDiff + + // Find keys that should be pressed but aren't + for key := range osm.desired.PressedKeys { + if _, ok := osm.actual.PressedKeys[key]; !ok { + diff.ShouldPress = append(diff.ShouldPress, key) + } + } + + // Find keys that should be released but aren't + for key := range osm.actual.PressedKeys { + if _, ok := osm.desired.PressedKeys[key]; !ok { + diff.ShouldRelease = append(diff.ShouldRelease, key) + } + } + + return diff +} + +// CheckTimeouts checks for and handles timed-out keys +func (osm *OSM) CheckTimeouts() []KeyCode { + osm.mu.Lock() + defer osm.mu.Unlock() + + now := time.Now() + var timedOut []KeyCode + + for key, info := range osm.desired.PressedKeys { + if info.ReleaseStrategy == ReleaseOnTimeout { + if now.Sub(info.PressedAt) > osm.config.EmergencyTimeout { + timedOut = append(timedOut, key) + } + } + } + + // Release timed out keys + for _, key := range timedOut { + delete(osm.desired.PressedKeys, key) + if err := osm.executeRelease(key); err != nil { + slog.Error("Failed to release timed-out key", "key", key, "error", err) + } + } + + return timedOut +} + +// Clear resets the output state machine +func (osm *OSM) Clear() { + osm.mu.Lock() + defer osm.mu.Unlock() + + // Release all keys + for key := range osm.actual.PressedKeys { + if err := osm.executeRelease(key); err != nil { + slog.Error("Failed to release key during clear", "key", key, "error", err) + } + } + + osm.desired = OutputState{PressedKeys: make(map[KeyCode]OutputKeyInfo)} + osm.actual = OutputState{PressedKeys: make(map[KeyCode]OutputKeyInfo)} + osm.compensatedKeys = make(map[KeyCode]time.Time) + osm.pendingReleases = make(map[KeyCode]time.Time) +} + +// Reset clears tracked output state without writing to the device. +func (osm *OSM) Reset() { + osm.mu.Lock() + defer osm.mu.Unlock() + + osm.desired = OutputState{PressedKeys: make(map[KeyCode]OutputKeyInfo)} + osm.actual = OutputState{PressedKeys: make(map[KeyCode]OutputKeyInfo)} + osm.compensatedKeys = make(map[KeyCode]time.Time) + osm.pendingReleases = make(map[KeyCode]time.Time) + osm.binder.Clear() +} diff --git a/pkg/statemachine/speculative.go b/pkg/statemachine/speculative.go new file mode 100644 index 0000000..e079a30 --- /dev/null +++ b/pkg/statemachine/speculative.go @@ -0,0 +1,348 @@ +package statemachine + +import ( + "fmt" + "log/slog" + "sync" + "time" +) + +// SpeculativeExecutor handles speculative execution with compensation +type SpeculativeExecutor struct { + mu sync.RWMutex + layer *SpeculativeLayer + compensator *Compensator + config SpeculativeConfig + output OutputDevice + onConfirm func(KeyCode) + onCompensate func(KeyCode) + nextOpID int +} + +// SpeculativeLayer tracks speculative operations +type SpeculativeLayer struct { + pending map[KeyCode]*SpeculativeOp + confirmed map[KeyCode]*SpeculativeOp +} + +// Compensator handles compensation for speculative operations +type Compensator struct { + history []CompensationRecord + mu sync.RWMutex +} + +// CompensationRecord tracks a completed compensation +type CompensationRecord struct { + Op *SpeculativeOp + CompensatedAt time.Time +} + +// NewSpeculativeExecutor creates a new SpeculativeExecutor +func NewSpeculativeExecutor(config SpeculativeConfig, output OutputDevice) *SpeculativeExecutor { + return &SpeculativeExecutor{ + layer: &SpeculativeLayer{ + pending: make(map[KeyCode]*SpeculativeOp), + confirmed: make(map[KeyCode]*SpeculativeOp), + }, + compensator: &Compensator{ + history: make([]CompensationRecord, 0), + }, + config: config, + output: output, + nextOpID: 1, + } +} + +// SetCallbacks sets the confirmation and compensation callbacks +func (se *SpeculativeExecutor) SetCallbacks(onConfirm, onCompensate func(KeyCode)) { + se.mu.Lock() + defer se.mu.Unlock() + se.onConfirm = onConfirm + se.onCompensate = onCompensate +} + +// ExecuteSpeculative executes a command speculatively +func (se *SpeculativeExecutor) ExecuteSpeculative(cmd OutputCommand) (*SpeculativeOp, error) { + if !se.config.Enabled { + // Speculative execution disabled, execute directly + return nil, se.output.Execute(cmd) + } + + se.mu.Lock() + defer se.mu.Unlock() + + // Execute the command + if err := se.output.Execute(cmd); err != nil { + return nil, err + } + + // Create speculative operation + op := &SpeculativeOp{ + ID: OpID(fmt.Sprintf("%d", se.nextOpID)), + Key: cmd.Key, + Action: cmd.Action, + ExecutedAt: time.Now(), + ConfirmDeadline: time.Now().Add(se.config.ConfirmationWindow), + Status: SpeculativePending, + Compensation: se.buildCompensation(cmd), + } + se.nextOpID++ + + se.layer.pending[cmd.Key] = op + + slog.Debug("Executed speculative command", + "key", cmd.Key, + "action", cmd.Action, + "opID", op.ID, + "deadline", op.ConfirmDeadline) + + return op, nil +} + +// Confirm confirms a speculative operation +func (se *SpeculativeExecutor) Confirm(key KeyCode) bool { + se.mu.Lock() + defer se.mu.Unlock() + + op, ok := se.layer.pending[key] + if !ok { + return false + } + + op.Status = SpeculativeConfirmed + se.layer.confirmed[key] = op + delete(se.layer.pending, key) + + slog.Debug("Confirmed speculative operation", "key", key, "opID", op.ID) + + if se.onConfirm != nil { + se.onConfirm(key) + } + + return true +} + +// Compensate compensates for a speculative operation +func (se *SpeculativeExecutor) Compensate(key KeyCode) error { + se.mu.Lock() + defer se.mu.Unlock() + + op, ok := se.layer.pending[key] + if !ok { + return nil // Not a speculative operation + } + + return se.compensateOp(op) +} + +// CompensateAll compensates all pending speculative operations +func (se *SpeculativeExecutor) CompensateAll() error { + se.mu.Lock() + defer se.mu.Unlock() + + var lastErr error + for key, op := range se.layer.pending { + if err := se.compensateOp(op); err != nil { + lastErr = err + slog.Error("Failed to compensate operation", "key", key, "error", err) + } + } + + return lastErr +} + +// CheckDeadlines checks and handles expired confirmation deadlines +func (se *SpeculativeExecutor) CheckDeadlines() []KeyCode { + se.mu.Lock() + defer se.mu.Unlock() + + now := time.Now() + var expired []KeyCode + + for key, op := range se.layer.pending { + if now.After(op.ConfirmDeadline) { + expired = append(expired, key) + // Auto-confirm expired operations (safer than compensating) + op.Status = SpeculativeConfirmed + se.layer.confirmed[key] = op + delete(se.layer.pending, key) + + slog.Debug("Auto-confirmed expired speculative operation", + "key", key, + "opID", op.ID) + + if se.onConfirm != nil { + se.onConfirm(key) + } + } + } + + return expired +} + +// IsPending checks if a key has a pending speculative operation +func (se *SpeculativeExecutor) IsPending(key KeyCode) bool { + se.mu.RLock() + defer se.mu.RUnlock() + _, ok := se.layer.pending[key] + return ok +} + +// IsConfirmed checks if a key has a confirmed speculative operation +func (se *SpeculativeExecutor) IsConfirmed(key KeyCode) bool { + se.mu.RLock() + defer se.mu.RUnlock() + _, ok := se.layer.confirmed[key] + return ok +} + +// GetPendingOps returns all pending operations +func (se *SpeculativeExecutor) GetPendingOps() []*SpeculativeOp { + se.mu.RLock() + defer se.mu.RUnlock() + + ops := make([]*SpeculativeOp, 0, len(se.layer.pending)) + for _, op := range se.layer.pending { + ops = append(ops, se.cloneOp(op)) + } + return ops +} + +// Clear clears all speculative operations +func (se *SpeculativeExecutor) Clear() { + se.mu.Lock() + defer se.mu.Unlock() + + se.layer.pending = make(map[KeyCode]*SpeculativeOp) + se.layer.confirmed = make(map[KeyCode]*SpeculativeOp) + se.compensator.Clear() +} + +// compensateOp compensates for a single operation +func (se *SpeculativeExecutor) compensateOp(op *SpeculativeOp) error { + slog.Debug("Compensating speculative operation", + "key", op.Key, + "opID", op.ID, + "mode", se.config.CompensationMode) + + switch se.config.CompensationMode { + case CompensationImmediate: + // Execute compensation immediately + for _, cmd := range op.Compensation.RevertActions { + if err := se.output.Execute(cmd); err != nil { + return err + } + } + for _, cmd := range op.Compensation.AlignmentActions { + if err := se.output.Execute(cmd); err != nil { + return err + } + } + + case CompensationDeferred: + // Don't execute yet, just mark for later + // The actual compensation happens when physical key is released + + case CompensationSmart: + // Smart mode: decide based on context + // For now, same as immediate + for _, cmd := range op.Compensation.RevertActions { + if err := se.output.Execute(cmd); err != nil { + return err + } + } + } + + // Update status + op.Status = SpeculativeCompensated + delete(se.layer.pending, op.Key) + + // Record compensation + se.compensator.Record(CompensationRecord{ + Op: op, + CompensatedAt: time.Now(), + }) + + if se.onCompensate != nil { + se.onCompensate(op.Key) + } + + return nil +} + +// buildCompensation builds the compensation strategy for a command +func (se *SpeculativeExecutor) buildCompensation(cmd OutputCommand) CompensationStrategy { + strategy := CompensationStrategy{ + RevertActions: make([]OutputCommand, 0), + AlignmentActions: make([]OutputCommand, 0), + } + + switch cmd.Action { + case KeyPress: + // To compensate a press, we release + strategy.RevertActions = append(strategy.RevertActions, OutputCommand{ + Key: cmd.Key, + Action: KeyRelease, + }) + // Alignment: ensure clean state + strategy.AlignmentActions = append(strategy.AlignmentActions, OutputCommand{ + Key: cmd.Key, + Action: KeyRelease, + }) + + case KeyRelease: + // To compensate a release, we press + strategy.RevertActions = append(strategy.RevertActions, OutputCommand{ + Key: cmd.Key, + Action: KeyPress, + }) + } + + return strategy +} + +// cloneOp creates a copy of a speculative operation +func (se *SpeculativeExecutor) cloneOp(op *SpeculativeOp) *SpeculativeOp { + return &SpeculativeOp{ + ID: op.ID, + Key: op.Key, + Action: op.Action, + ExecutedAt: op.ExecutedAt, + ConfirmDeadline: op.ConfirmDeadline, + Status: op.Status, + Compensation: op.Compensation, + } +} + +const maxCompensationHistory = 1000 + +// Record records a compensation +func (c *Compensator) Record(record CompensationRecord) { + c.mu.Lock() + defer c.mu.Unlock() + c.history = append(c.history, record) + if len(c.history) > maxCompensationHistory { + c.history = c.history[len(c.history)-maxCompensationHistory:] + } +} + +// IsCompensated checks if a key was recently compensated +func (c *Compensator) IsCompensated(key KeyCode, within time.Duration) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + cutoff := time.Now().Add(-within) + for _, record := range c.history { + if record.Op.Key == key && record.CompensatedAt.After(cutoff) { + return true + } + } + return false +} + +// Clear clears compensation history +func (c *Compensator) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.history = make([]CompensationRecord, 0) +} diff --git a/pkg/statemachine/ssm.go b/pkg/statemachine/ssm.go new file mode 100644 index 0000000..69a47fb --- /dev/null +++ b/pkg/statemachine/ssm.go @@ -0,0 +1,280 @@ +package statemachine + +import ( + "log/slog" + "sync" + "time" +) + +// SSM (Semantic State Machine) transforms physical input into semantic meaning +type SSM struct { + mu sync.RWMutex + current SemanticState + previousActive map[KeyCode]ModifierInfo + config SemanticConfig + engine MappingEngine + // Callback for when a pending modifier times out + onPendingTimeout func(KeyCode) +} + +// NewSSM creates a new Semantic State Machine +func NewSSM(config SemanticConfig, engine MappingEngine) *SSM { + return &SSM{ + current: SemanticState{ + Modifiers: ModifierSemantic{ + Active: make(map[KeyCode]ModifierInfo), + Pending: make(map[KeyCode]*PendingModifier), + }, + Independents: make(map[KeyCode]IndependentState), + Combo: ComboState{}, + }, + config: config, + engine: engine, + } +} + +// SetPendingTimeoutCallback sets the callback for pending modifier timeout +func (ssm *SSM) SetPendingTimeoutCallback(cb func(KeyCode)) { + ssm.mu.Lock() + defer ssm.mu.Unlock() + ssm.onPendingTimeout = cb +} + +// Translate converts input state to semantic state +func (ssm *SSM) Translate(input InputState) SemanticState { + ssm.mu.Lock() + defer ssm.mu.Unlock() + + newState := SemanticState{ + Modifiers: ModifierSemantic{ + Active: make(map[KeyCode]ModifierInfo), + Pending: make(map[KeyCode]*PendingModifier), + }, + Independents: make(map[KeyCode]IndependentState), + Combo: ComboState{}, + } + + // Process each key in input state + for key, state := range input.KeyStates { + if !state.Pressed { + continue + } + + if !IsModifier(key) { + // Non-modifier keys go to combo + newState.Combo.ActiveKeys = append(newState.Combo.ActiveKeys, key) + continue + } + + // Check if this modifier appears in any mapping rules + potentialMatches := ssm.engine.PrefixMatch([]KeyCode{key}) + + if len(potentialMatches) == 0 { + // No mapping rules use this modifier — treat as independent (pass-through) + newState.Independents[key] = IndependentState{ + Key: key, + IsPressed: true, + } + slog.Debug("No potential matches for modifier, treating as independent", + "key", key) + continue + } + + // This modifier appears in mapping rules — classify as Active. + // It will be absorbed (not forwarded) until a combo key is pressed. + newState.Modifiers.Active[key] = ModifierInfo{ + Key: key, + PressedAt: state.PressedAt, + } + slog.Debug("Modifier classified as active (has mapping rules)", "key", key) + } + + // Save current active modifiers before overwriting + ssm.previousActive = make(map[KeyCode]ModifierInfo) + for k, v := range ssm.current.Modifiers.Active { + ssm.previousActive[k] = v + } + + ssm.current = newState + return newState +} + +// WasActiveModifier returns whether the given key was an Active modifier +// in the state just before the last Translate call. +// This is used to detect modifier release without combo. +func (ssm *SSM) WasActiveModifier(key KeyCode) (ModifierInfo, bool) { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + info, ok := ssm.previousActive[key] + return info, ok +} + +// GetCurrent returns the current semantic state +func (ssm *SSM) GetCurrent() SemanticState { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + return ssm.cloneState(ssm.current) +} + +// AttemptDowngrade attempts to downgrade an independent modifier back to pending +// This is used when a combination is detected after the modifier became independent +func (ssm *SSM) AttemptDowngrade(key KeyCode) bool { + ssm.mu.Lock() + defer ssm.mu.Unlock() + + if !ssm.config.AllowDowngrade { + return false + } + + independent, ok := ssm.current.Independents[key] + if !ok || !independent.IsPressed || !independent.DowngradedFrom { + return false + } + + // Check if within downgrade window + // We use the current time minus when it became independent + // Since we don't track that exactly, we use a heuristic + now := time.Now() + + ssm.current.Modifiers.Pending[key] = &PendingModifier{ + Key: key, + PressedAt: now.Add(-ssm.config.ModifierPendingThreshold), // Approximate + DowngradeDeadline: now.Add(ssm.config.DowngradeWindow), + PassedThrough: true, // Already passed through, mark as such + } + delete(ssm.current.Independents, key) + + slog.Debug("Successfully downgraded independent to pending", "key", key) + return true +} + +// ConfirmPending confirms a pending modifier as active +func (ssm *SSM) ConfirmPending(key KeyCode) bool { + ssm.mu.Lock() + defer ssm.mu.Unlock() + + pending, ok := ssm.current.Modifiers.Pending[key] + if !ok { + return false + } + + ssm.current.Modifiers.Active[key] = ModifierInfo{ + Key: key, + PressedAt: pending.PressedAt, + } + delete(ssm.current.Modifiers.Pending, key) + + slog.Debug("Pending modifier confirmed as active", "key", key) + return true +} + +// MarkPendingPassedThrough marks a pending modifier as already passed through +func (ssm *SSM) MarkPendingPassedThrough(key KeyCode) bool { + ssm.mu.Lock() + defer ssm.mu.Unlock() + + pending, ok := ssm.current.Modifiers.Pending[key] + if !ok { + return false + } + + pending.PassedThrough = true + slog.Debug("Pending modifier marked as passed through", "key", key) + return true +} + +// IsModifierActive checks if a modifier is in active state +func (ssm *SSM) IsModifierActive(key KeyCode) bool { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + _, ok := ssm.current.Modifiers.Active[key] + return ok +} + +// IsModifierPending checks if a modifier is in pending state +func (ssm *SSM) IsModifierPending(key KeyCode) bool { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + _, ok := ssm.current.Modifiers.Pending[key] + return ok +} + +// IsIndependent checks if a key is in independent state +func (ssm *SSM) IsIndependent(key KeyCode) bool { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + state, ok := ssm.current.Independents[key] + return ok && state.IsPressed +} + +// GetActiveModifiers returns all active modifiers +func (ssm *SSM) GetActiveModifiers() []KeyCode { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + + keys := make([]KeyCode, 0, len(ssm.current.Modifiers.Active)) + for key := range ssm.current.Modifiers.Active { + keys = append(keys, key) + } + return keys +} + +// GetPendingModifiers returns all pending modifiers +func (ssm *SSM) GetPendingModifiers() []KeyCode { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + + keys := make([]KeyCode, 0, len(ssm.current.Modifiers.Pending)) + for key := range ssm.current.Modifiers.Pending { + keys = append(keys, key) + } + return keys +} + +// GetComboKeys returns the current combo keys +func (ssm *SSM) GetComboKeys() []KeyCode { + ssm.mu.RLock() + defer ssm.mu.RUnlock() + return append([]KeyCode{}, ssm.current.Combo.ActiveKeys...) +} + +// Clear resets the semantic state machine +func (ssm *SSM) Clear() { + ssm.mu.Lock() + defer ssm.mu.Unlock() + + ssm.current = SemanticState{ + Modifiers: ModifierSemantic{ + Active: make(map[KeyCode]ModifierInfo), + Pending: make(map[KeyCode]*PendingModifier), + }, + Independents: make(map[KeyCode]IndependentState), + Combo: ComboState{}, + } +} + +// cloneState creates a deep copy of a semantic state +func (ssm *SSM) cloneState(state SemanticState) SemanticState { + cloned := SemanticState{ + Modifiers: ModifierSemantic{ + Active: make(map[KeyCode]ModifierInfo), + Pending: make(map[KeyCode]*PendingModifier), + }, + Independents: make(map[KeyCode]IndependentState), + Combo: ComboState{ + ActiveKeys: append([]KeyCode{}, state.Combo.ActiveKeys...), + }, + } + + for k, v := range state.Modifiers.Active { + cloned.Modifiers.Active[k] = v + } + for k, v := range state.Modifiers.Pending { + cloned.Modifiers.Pending[k] = v + } + for k, v := range state.Independents { + cloned.Independents[k] = v + } + + return cloned +} diff --git a/pkg/statemachine/statemachine_test.go b/pkg/statemachine/statemachine_test.go new file mode 100644 index 0000000..a04507b --- /dev/null +++ b/pkg/statemachine/statemachine_test.go @@ -0,0 +1,681 @@ +package statemachine + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jialeicui/golibevdev" + "github.com/jialeicui/keyswift/pkg/keys" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockOutputDevice is a mock implementation of OutputDevice for testing +type MockOutputDevice struct { + commands []OutputCommand +} + +func (m *MockOutputDevice) Execute(cmd OutputCommand) error { + m.commands = append(m.commands, cmd) + return nil +} + +func (m *MockOutputDevice) Sync() error { + return nil +} + +func (m *MockOutputDevice) Close() error { + return nil +} + +func (m *MockOutputDevice) GetCommands() []OutputCommand { + return m.commands +} + +func (m *MockOutputDevice) Clear() { + m.commands = nil +} + +type ResettingOutputDevice struct { + commands []OutputCommand + failOnce bool +} + +func (r *ResettingOutputDevice) Execute(cmd OutputCommand) error { + if r.failOnce { + r.failOnce = false + return ErrOutputResetRequired + } + r.commands = append(r.commands, cmd) + return nil +} + +func (r *ResettingOutputDevice) Sync() error { + return nil +} + +func (r *ResettingOutputDevice) Close() error { + return nil +} + +type fakeLowLevelOutput struct { + writes []OutputCommand + failNext error + closed bool +} + +func (f *fakeLowLevelOutput) WriteKey(key KeyCode, value int32) error { + if f.failNext != nil { + err := f.failNext + f.failNext = nil + return err + } + + action := KeyRelease + if value == 1 { + action = KeyPress + } + f.writes = append(f.writes, OutputCommand{Key: key, Action: action}) + return nil +} + +func (f *fakeLowLevelOutput) Sync() error { + if f.failNext != nil { + err := f.failNext + f.failNext = nil + return err + } + return nil +} + +func (f *fakeLowLevelOutput) Close() error { + f.closed = true + return nil +} + +// Test ISM +func TestISM_ProcessEvent(t *testing.T) { + ism := NewISM(DefaultSemanticConfig()) + + // Test key press + event := KeyEvent{ + Key: golibevdev.KeyLeftMeta, + Action: KeyPress, + Timestamp: time.Now(), + Device: "test-device", + } + + state, changed := ism.ProcessEvent(event) + require.True(t, changed) + assert.True(t, state.KeyStates[golibevdev.KeyLeftMeta].Pressed) + + // Test key release + event.Action = KeyRelease + state, changed = ism.ProcessEvent(event) + require.True(t, changed) + assert.False(t, state.KeyStates[golibevdev.KeyLeftMeta].Pressed) +} + +func TestISM_Debounce(t *testing.T) { + config := DefaultSemanticConfig() + config.DebounceThreshold = 50 * time.Millisecond + ism := NewISM(config) + + now := time.Now() + + // First press + event := KeyEvent{ + Key: golibevdev.KeyA, + Action: KeyPress, + Timestamp: now, + Device: "test-device", + } + _, changed := ism.ProcessEvent(event) + assert.True(t, changed) + + // Immediate duplicate press (should be debounced) + event.Timestamp = now.Add(1 * time.Millisecond) + _, changed = ism.ProcessEvent(event) + assert.False(t, changed) + + // Press after debounce threshold + event.Timestamp = now.Add(100 * time.Millisecond) + _, changed = ism.ProcessEvent(event) + assert.True(t, changed) +} + +func TestISM_GetAllPressedKeys(t *testing.T) { + ism := NewISM(DefaultSemanticConfig()) + + // Press multiple keys + keys := []golibevdev.KeyEventCode{ + golibevdev.KeyLeftMeta, + golibevdev.KeyC, + } + + for _, key := range keys { + event := KeyEvent{ + Key: key, + Action: KeyPress, + Timestamp: time.Now(), + Device: "test-device", + } + ism.ProcessEvent(event) + } + + pressed := ism.GetAllPressedKeys() + assert.Len(t, pressed, 2) +} + +// Test SSM +func TestSSM_Translate(t *testing.T) { + engine := NewSimpleMappingEngine() + // Add a mapping so KeyLeftMeta is recognized as a potential modifier + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + ssm := NewSSM(DefaultSemanticConfig(), engine) + + // Create input state with modifier + input := InputState{ + Timestamp: time.Now(), + KeyStates: map[KeyCode]KeyState{ + golibevdev.KeyLeftMeta: { + Pressed: true, + PressedAt: time.Now(), + }, + }, + } + + semantic := ssm.Translate(input) + + // Should be Active (modifier has mapping rules) + assert.Len(t, semantic.Modifiers.Active, 1) + assert.Len(t, semantic.Independents, 0) +} + +func TestSSM_Translate_LongHold(t *testing.T) { + config := DefaultSemanticConfig() + engine := NewSimpleMappingEngine() + // Add a mapping so KeyLeftMeta is recognized as a potential modifier + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + ssm := NewSSM(config, engine) + + // Create input state with modifier pressed long ago + input := InputState{ + Timestamp: time.Now(), + KeyStates: map[KeyCode]KeyState{ + golibevdev.KeyLeftMeta: { + Pressed: true, + PressedAt: time.Now().Add(-1 * time.Second), + }, + }, + } + + semantic := ssm.Translate(input) + + // Should STILL be Active — mapped modifiers stay Active regardless of hold duration + assert.Len(t, semantic.Modifiers.Active, 1) + assert.Len(t, semantic.Independents, 0) +} + +func TestSSM_UnmappedModifier(t *testing.T) { + engine := NewSimpleMappingEngine() + // Only add a mapping for KeyLeftMeta — KeyLeftCtrl has NO mapping + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + ssm := NewSSM(DefaultSemanticConfig(), engine) + + // Press KeyRightAlt which has no mapping rules + input := InputState{ + Timestamp: time.Now(), + KeyStates: map[KeyCode]KeyState{ + golibevdev.KeyRightAlt: { + Pressed: true, + PressedAt: time.Now(), + }, + }, + } + + semantic := ssm.Translate(input) + + // Should be Independent (no mapping rules for this modifier) + assert.Len(t, semantic.Modifiers.Active, 0) + assert.Len(t, semantic.Independents, 1) +} + +// Test StateBinder +func TestStateBinder_CreateBinding(t *testing.T) { + binder := NewStateBinder() + + inputKeys := []KeyCode{golibevdev.KeyLeftMeta, golibevdev.KeyC} + outputKeys := []KeyCode{golibevdev.KeyLeftCtrl, golibevdev.KeyC} + + id := binder.CreateBinding(inputKeys, outputKeys, ReleaseOnAnyBoundKeyReleased) + assert.NotEmpty(t, id) + + // Verify binding exists + binding, ok := binder.GetBinding(id) + require.True(t, ok) + assert.Equal(t, inputKeys, binding.InputKeys) + assert.Equal(t, outputKeys, binding.OutputKeys) +} + +func TestStateBinder_OnInputKeyReleased(t *testing.T) { + binder := NewStateBinder() + + inputKeys := []KeyCode{golibevdev.KeyLeftMeta, golibevdev.KeyC} + outputKeys := []KeyCode{golibevdev.KeyLeftCtrl, golibevdev.KeyC} + + binder.CreateBinding(inputKeys, outputKeys, ReleaseOnAnyBoundKeyReleased) + + // Release one input key + toRelease := binder.OnInputKeyReleased(golibevdev.KeyC) + + // Should release all output keys + assert.Len(t, toRelease, 2) + assert.Contains(t, toRelease, golibevdev.KeyLeftCtrl) + assert.Contains(t, toRelease, golibevdev.KeyC) +} + +// Test OSM +func TestOSM_ApplyCommands(t *testing.T) { + mock := &MockOutputDevice{} + binder := NewStateBinder() + osm := NewOSM(binder, mock, DefaultAlignmentConfig()) + + commands := []OutputCommand{ + {Key: golibevdev.KeyLeftCtrl, Action: KeyPress}, + {Key: golibevdev.KeyC, Action: KeyPress}, + } + + inputKeys := []KeyCode{golibevdev.KeyLeftMeta, golibevdev.KeyC} + + err := osm.ApplyCommands(commands, inputKeys, "test-mapping") + require.NoError(t, err) + + // Should have executed press commands + assert.Len(t, mock.GetCommands(), 2) + + // Verify desired state + state := osm.GetDesiredState() + assert.Len(t, state.PressedKeys, 2) +} + +func TestOSM_HandleInputRelease(t *testing.T) { + mock := &MockOutputDevice{} + binder := NewStateBinder() + osm := NewOSM(binder, mock, DefaultAlignmentConfig()) + + // First apply some commands + commands := []OutputCommand{ + {Key: golibevdev.KeyLeftCtrl, Action: KeyPress}, + {Key: golibevdev.KeyC, Action: KeyPress}, + } + inputKeys := []KeyCode{golibevdev.KeyLeftMeta, golibevdev.KeyC} + osm.ApplyCommands(commands, inputKeys, "test-mapping") + + mock.Clear() + + // Release an input key + err := osm.HandleInputRelease(golibevdev.KeyC) + require.NoError(t, err) + + // Should have released output keys + cmds := mock.GetCommands() + assert.Len(t, cmds, 2) + assert.Equal(t, KeyRelease, cmds[0].Action) + assert.Equal(t, KeyRelease, cmds[1].Action) +} + +// Test SpeculativeExecutor +func TestSpeculativeExecutor_ExecuteAndConfirm(t *testing.T) { + mock := &MockOutputDevice{} + config := DefaultSpeculativeConfig() + executor := NewSpeculativeExecutor(config, mock) + + cmd := OutputCommand{Key: golibevdev.KeyLeftMeta, Action: KeyPress} + + op, err := executor.ExecuteSpeculative(cmd) + require.NoError(t, err) + assert.NotNil(t, op) + assert.Equal(t, SpeculativePending, op.Status) + + // Should have executed + assert.Len(t, mock.GetCommands(), 1) + + // Confirm + confirmed := executor.Confirm(golibevdev.KeyLeftMeta) + assert.True(t, confirmed) + assert.True(t, executor.IsConfirmed(golibevdev.KeyLeftMeta)) +} + +func TestSpeculativeExecutor_Compensate(t *testing.T) { + mock := &MockOutputDevice{} + config := DefaultSpeculativeConfig() + executor := NewSpeculativeExecutor(config, mock) + + cmd := OutputCommand{Key: golibevdev.KeyLeftMeta, Action: KeyPress} + + _, err := executor.ExecuteSpeculative(cmd) + require.NoError(t, err) + + mock.Clear() + + // Compensate + err = executor.Compensate(golibevdev.KeyLeftMeta) + require.NoError(t, err) + + // Should have sent release + cmds := mock.GetCommands() + require.Len(t, cmds, 1) + assert.Equal(t, KeyRelease, cmds[0].Action) +} + +// Test Integration +func TestMachine_BasicMapping(t *testing.T) { + mock := &MockOutputDevice{} + engine := NewSimpleMappingEngine() + + // Add a simple mapping: Cmd+C -> Ctrl+C + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + + machine := NewMachine(engine, mock, DefaultConfig()) + require.NoError(t, machine.Start(context.Background())) + defer machine.Stop() + + // Press Cmd + event1 := KeyEvent{ + Key: golibevdev.KeyLeftMeta, + Action: KeyPress, + Timestamp: time.Now(), + Device: "test", + } + err := machine.ProcessEventSync(event1) + require.NoError(t, err) + + // After pressing just the modifier, the machine may speculatively pass it through + // This is expected behavior - clear the mock for the next phase + mock.Clear() + + // Press C + event2 := KeyEvent{ + Key: golibevdev.KeyC, + Action: KeyPress, + Timestamp: time.Now(), + Device: "test", + } + err = machine.ProcessEventSync(event2) + require.NoError(t, err) + + // Should have output (may include speculative modifier pass-through) + cmds := mock.GetCommands() + assert.True(t, len(cmds) >= 2, "Should have at least 2 output commands, got %d", len(cmds)) + + // Find the Ctrl+C press commands in the output + var foundCtrlPress, foundCPress bool + for _, cmd := range cmds { + if cmd.Key == golibevdev.KeyLeftCtrl && cmd.Action == KeyPress { + foundCtrlPress = true + } + if cmd.Key == golibevdev.KeyC && cmd.Action == KeyPress { + foundCPress = true + } + } + assert.True(t, foundCtrlPress, "Should have Ctrl press in output") + assert.True(t, foundCPress, "Should have C press in output") +} + +func TestMachine_StickyKeyPrevention(t *testing.T) { + mock := &MockOutputDevice{} + engine := NewSimpleMappingEngine() + + // Add mapping: Cmd+C -> Ctrl+C + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + + machine := NewMachine(engine, mock, DefaultConfig()) + require.NoError(t, machine.Start(context.Background())) + defer machine.Stop() + + now := time.Now() + + // Press Cmd + machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyLeftMeta, + Action: KeyPress, + Timestamp: now, + Device: "test", + }) + + // Press C + machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyC, + Action: KeyPress, + Timestamp: now.Add(10 * time.Millisecond), + Device: "test", + }) + + mock.Clear() + + // Release C + machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyC, + Action: KeyRelease, + Timestamp: now.Add(20 * time.Millisecond), + Device: "test", + }) + + // Should release C + cmds := mock.GetCommands() + assert.True(t, len(cmds) >= 1) + + // Release Cmd + machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyLeftMeta, + Action: KeyRelease, + Timestamp: now.Add(30 * time.Millisecond), + Device: "test", + }) + + // Should release Ctrl + cmds = mock.GetCommands() + foundCtrlRelease := false + for _, cmd := range cmds { + if cmd.Key == golibevdev.KeyLeftCtrl && cmd.Action == KeyRelease { + foundCtrlRelease = true + break + } + } + assert.True(t, foundCtrlRelease, "Ctrl should be released to prevent sticky key") +} + +func TestMachine_HandleDeviceLostReleasesMappedOutput(t *testing.T) { + mock := &MockOutputDevice{} + engine := NewSimpleMappingEngine() + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + + machine := NewMachine(engine, mock, DefaultConfig()) + require.NoError(t, machine.Start(context.Background())) + defer machine.Stop() + + now := time.Now() + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyLeftMeta, + Action: KeyPress, + Timestamp: now, + Device: "kbd-1", + })) + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyC, + Action: KeyPress, + Timestamp: now.Add(10 * time.Millisecond), + Device: "kbd-1", + })) + + mock.Clear() + require.NoError(t, machine.HandleDeviceLost("kbd-1")) + + cmds := mock.GetCommands() + assert.Contains(t, cmds, OutputCommand{Key: golibevdev.KeyLeftCtrl, Action: KeyRelease}) + assert.Contains(t, cmds, OutputCommand{Key: golibevdev.KeyC, Action: KeyRelease}) + assert.Empty(t, machine.GetInputState().KeyStates) +} + +func TestMachine_HandleDeviceLostReleasesPassthroughKey(t *testing.T) { + mock := &MockOutputDevice{} + engine := NewSimpleMappingEngine() + machine := NewMachine(engine, mock, DefaultConfig()) + require.NoError(t, machine.Start(context.Background())) + defer machine.Stop() + + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyA, + Action: KeyPress, + Timestamp: time.Now(), + Device: "kbd-1", + })) + + mock.Clear() + require.NoError(t, machine.HandleDeviceLost("kbd-1")) + assert.Contains(t, mock.GetCommands(), OutputCommand{Key: golibevdev.KeyA, Action: KeyRelease}) +} + +func TestMachine_HandleDeviceLostReleasesFlushedModifier(t *testing.T) { + mock := &MockOutputDevice{} + engine := NewSimpleMappingEngine() + engine.AddMapping(MappingRule{ + Input: []keys.Key{golibevdev.KeyLeftMeta, golibevdev.KeyC}, + Output: []keys.Key{golibevdev.KeyLeftCtrl, golibevdev.KeyC}, + }) + + machine := NewMachine(engine, mock, DefaultConfig()) + require.NoError(t, machine.Start(context.Background())) + defer machine.Stop() + + now := time.Now() + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyLeftMeta, + Action: KeyPress, + Timestamp: now, + Device: "kbd-1", + })) + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyA, + Action: KeyPress, + Timestamp: now.Add(10 * time.Millisecond), + Device: "kbd-1", + })) + + mock.Clear() + require.NoError(t, machine.HandleDeviceLost("kbd-1")) + + cmds := mock.GetCommands() + assert.Contains(t, cmds, OutputCommand{Key: golibevdev.KeyA, Action: KeyRelease}) + assert.Contains(t, cmds, OutputCommand{Key: golibevdev.KeyLeftMeta, Action: KeyRelease}) +} + +func TestMachine_ResetsStateAfterOutputFailure(t *testing.T) { + output := &ResettingOutputDevice{failOnce: true} + engine := NewSimpleMappingEngine() + machine := NewMachine(engine, output, DefaultConfig()) + require.NoError(t, machine.Start(context.Background())) + defer machine.Stop() + + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyA, + Action: KeyPress, + Timestamp: time.Now(), + Device: "kbd-1", + })) + + assert.Empty(t, machine.GetInputState().KeyStates) + assert.Empty(t, machine.GetSemanticState().Combo.ActiveKeys) + assert.Empty(t, machine.GetOutputState().PressedKeys) + + require.NoError(t, machine.ProcessEventSync(KeyEvent{ + Key: golibevdev.KeyA, + Action: KeyPress, + Timestamp: time.Now().Add(10 * time.Millisecond), + Device: "kbd-1", + })) + + assert.Contains(t, output.commands, OutputCommand{Key: golibevdev.KeyA, Action: KeyPress}) +} + +func TestRecoveringOutputDeviceRebuildsAfterFailure(t *testing.T) { + first := &fakeLowLevelOutput{failNext: errors.New("write failed")} + second := &fakeLowLevelOutput{} + callCount := 0 + + device, err := newRecoveringOutputDevice("keyswift-test", func(name string) (lowLevelOutputDevice, error) { + callCount++ + if callCount == 1 { + return first, nil + } + return second, nil + }) + require.NoError(t, err) + + err = device.Execute(OutputCommand{Key: golibevdev.KeyA, Action: KeyPress}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrOutputResetRequired) + assert.True(t, first.closed) + + require.NoError(t, device.Execute(OutputCommand{Key: golibevdev.KeyA, Action: KeyRelease})) + assert.Contains(t, second.writes, OutputCommand{Key: golibevdev.KeyA, Action: KeyRelease}) + require.NoError(t, device.Close()) +} + +func TestRingBuffer(t *testing.T) { + rb := NewRingBuffer[int](3) + + rb.Push(1) + rb.Push(2) + rb.Push(3) + + assert.Equal(t, 3, rb.Len()) + + // Push beyond capacity + rb.Push(4) + + assert.Equal(t, 3, rb.Len()) + + // Check order (oldest first) + slice := rb.Slice() + assert.Equal(t, []int{2, 3, 4}, slice) + + // Pop + val, ok := rb.Pop() + assert.True(t, ok) + assert.Equal(t, 2, val) + assert.Equal(t, 2, rb.Len()) +} + +func TestIsModifier(t *testing.T) { + assert.True(t, IsModifier(golibevdev.KeyLeftCtrl)) + assert.True(t, IsModifier(golibevdev.KeyRightCtrl)) + assert.True(t, IsModifier(golibevdev.KeyLeftAlt)) + assert.True(t, IsModifier(golibevdev.KeyLeftMeta)) + assert.True(t, IsModifier(golibevdev.KeyLeftShift)) + + assert.False(t, IsModifier(golibevdev.KeyA)) + assert.False(t, IsModifier(golibevdev.KeySpace)) + assert.False(t, IsModifier(golibevdev.KeyEnter)) +} diff --git a/pkg/statemachine/types.go b/pkg/statemachine/types.go new file mode 100644 index 0000000..6357322 --- /dev/null +++ b/pkg/statemachine/types.go @@ -0,0 +1,287 @@ +// Package statemachine provides a robust state machine architecture for key remapping. +// It solves the sticky key problem through layered state machines, explicit state binding, +// and speculative execution. +package statemachine + +import ( + "time" + + "github.com/jialeicui/golibevdev" +) + +// KeyCode is an alias for golibevdev key event codes +type KeyCode = golibevdev.KeyEventCode + +// DeviceID identifies an input device +type DeviceID string + +// MappingID identifies a key mapping configuration +type MappingID string + +// BindingID identifies a state binding +type BindingID string + +// OpID identifies a speculative operation +type OpID string + +// KeyAction represents a key press or release action +type KeyAction int + +const ( + KeyPress KeyAction = iota + KeyRelease +) + +// KeyEvent represents a raw key event from input devices +type KeyEvent struct { + Key KeyCode + Action KeyAction + Timestamp time.Time + Device DeviceID +} + +// OutputCommand represents a command to be sent to the output device +type OutputCommand struct { + Key KeyCode + Action KeyAction +} + +// InputState represents the current state of all physical keys +type InputState struct { + Timestamp time.Time + KeyStates map[KeyCode]KeyState + // EventSeq preserves the order of raw events + EventSeq []KeyEvent +} + +// KeyState tracks the state of a single key +type KeyState struct { + Pressed bool + PressedAt time.Time + Device DeviceID + PressCount int // For debounce detection +} + +// Clone creates a deep copy of InputState +func (is InputState) Clone() InputState { + cloned := InputState{ + Timestamp: is.Timestamp, + KeyStates: make(map[KeyCode]KeyState, len(is.KeyStates)), + EventSeq: make([]KeyEvent, len(is.EventSeq)), + } + for k, v := range is.KeyStates { + cloned.KeyStates[k] = v + } + copy(cloned.EventSeq, is.EventSeq) + return cloned +} + +// SemanticState represents the semantic interpretation of input +type SemanticState struct { + Modifiers ModifierSemantic + Independents map[KeyCode]IndependentState + Combo ComboState +} + +// ModifierSemantic tracks modifier keys and their semantic role +type ModifierSemantic struct { + Active map[KeyCode]ModifierInfo + Pending map[KeyCode]*PendingModifier +} + +// ModifierInfo tracks information about an active modifier +type ModifierInfo struct { + Key KeyCode + PressedAt time.Time +} + +// PendingModifier represents a modifier waiting to determine its semantic role +type PendingModifier struct { + Key KeyCode + PressedAt time.Time + DowngradeDeadline time.Time + PassedThrough bool +} + +// IndependentState represents a key acting independently (not as a modifier) +type IndependentState struct { + Key KeyCode + IsPressed bool + DowngradedFrom bool // Whether converted from Pending state +} + +// ComboState tracks active key combinations +type ComboState struct { + ActiveKeys []KeyCode +} + +// OutputState represents the desired or actual output state +type OutputState struct { + PressedKeys map[KeyCode]OutputKeyInfo +} + +// Clone creates a deep copy of OutputState +func (os OutputState) Clone() OutputState { + cloned := OutputState{ + PressedKeys: make(map[KeyCode]OutputKeyInfo, len(os.PressedKeys)), + } + for k, v := range os.PressedKeys { + cloned.PressedKeys[k] = v + } + return cloned +} + +// OutputKeyInfo tracks metadata about a pressed output key +type OutputKeyInfo struct { + PressedAt time.Time + SourceMapping MappingID + BoundTo []KeyCode + ReleaseStrategy ReleaseStrategy +} + +// ReleaseStrategy determines when an output key should be released +type ReleaseStrategy int + +const ( + // ReleaseOnAnyBoundKeyReleased releases when any bound input key is released + ReleaseOnAnyBoundKeyReleased ReleaseStrategy = iota + // ReleaseOnAllBoundKeysReleased releases only when all bound input keys are released + ReleaseOnAllBoundKeysReleased + // ReleaseOnTimeout releases after a maximum hold time + ReleaseOnTimeout +) + +// Binding represents a relationship between input and output keys +type Binding struct { + ID BindingID + InputKeys []KeyCode + OutputKeys []KeyCode + CreatedAt time.Time + Status BindingStatus +} + +// BindingStatus represents the current status of a binding +type BindingStatus int + +const ( + BindingActive BindingStatus = iota + BindingReleasing + BindingReleased +) + +// SpeculativeOp represents a speculatively executed operation +type SpeculativeOp struct { + ID OpID + Key KeyCode + Action KeyAction + ExecutedAt time.Time + ConfirmDeadline time.Time + Status SpeculativeStatus + Compensation CompensationStrategy +} + +// SpeculativeStatus represents the status of a speculative operation +type SpeculativeStatus int + +const ( + SpeculativePending SpeculativeStatus = iota + SpeculativeConfirmed + SpeculativeCompensated +) + +// CompensationStrategy defines how to compensate for a speculative operation +type CompensationStrategy struct { + RevertActions []OutputCommand + AlignmentActions []OutputCommand +} + +// StateDiff represents the difference between desired and actual states +type StateDiff struct { + ShouldPress []KeyCode + ShouldRelease []KeyCode +} + +// IsModifier returns true if the key is a modifier key +func IsModifier(key KeyCode) bool { + switch key { + case golibevdev.KeyLeftCtrl, golibevdev.KeyRightCtrl, + golibevdev.KeyLeftAlt, golibevdev.KeyRightAlt, + golibevdev.KeyLeftShift, golibevdev.KeyRightShift, + golibevdev.KeyLeftMeta, golibevdev.KeyRightMeta: + return true + } + return false +} + +// Config holds configuration for the state machine +type Config struct { + Semantic SemanticConfig + Speculative SpeculativeConfig + Alignment AlignmentConfig +} + +// SemanticConfig configures the semantic state machine +type SemanticConfig struct { + ModifierPendingThreshold time.Duration + AllowDowngrade bool + DowngradeWindow time.Duration + DebounceThreshold time.Duration +} + +// DefaultSemanticConfig returns the default semantic configuration +func DefaultSemanticConfig() SemanticConfig { + return SemanticConfig{ + ModifierPendingThreshold: 200 * time.Millisecond, + AllowDowngrade: true, + DowngradeWindow: 100 * time.Millisecond, + DebounceThreshold: 5 * time.Millisecond, + } +} + +// SpeculativeConfig configures speculative execution +type SpeculativeConfig struct { + Enabled bool + ConfirmationWindow time.Duration + CompensationMode CompensationMode +} + +// CompensationMode determines how to compensate for speculative operations +type CompensationMode int + +const ( + CompensationImmediate CompensationMode = iota + CompensationDeferred + CompensationSmart +) + +// DefaultSpeculativeConfig returns the default speculative configuration +func DefaultSpeculativeConfig() SpeculativeConfig { + return SpeculativeConfig{ + Enabled: true, + ConfirmationWindow: 50 * time.Millisecond, + CompensationMode: CompensationSmart, + } +} + +// AlignmentConfig configures state alignment +type AlignmentConfig struct { + SyncInterval time.Duration + EmergencyTimeout time.Duration +} + +// DefaultAlignmentConfig returns the default alignment configuration +func DefaultAlignmentConfig() AlignmentConfig { + return AlignmentConfig{ + SyncInterval: 100 * time.Millisecond, + EmergencyTimeout: 5 * time.Second, + } +} + +// DefaultConfig returns the default configuration +func DefaultConfig() Config { + return Config{ + Semantic: DefaultSemanticConfig(), + Speculative: DefaultSpeculativeConfig(), + Alignment: DefaultAlignmentConfig(), + } +} diff --git a/pkg/utils/cache/cache.go b/pkg/utils/cache/cache.go deleted file mode 100644 index 31fcc5d..0000000 --- a/pkg/utils/cache/cache.go +++ /dev/null @@ -1,62 +0,0 @@ -package cache - -import ( - "sync" -) - -type item[T any] struct { - done chan struct{} - err error - val T -} - -// Cache is interface for cache -type Cache[K comparable, V any] interface { - Get(key K, fn func() (V, error)) (V, error) -} - -type Impl[K comparable, T any] struct { - mu sync.RWMutex - m map[K]*item[T] -} - -// New makes a Impl instance. -func New[K comparable, T any]() *Impl[K, T] { - return &Impl[K, T]{ - m: make(map[K]*item[T]), - } -} - -// Get returns the initialized value by key, and make sure the value will be initialized only once. -func (g *Impl[K, T]) Get(key K, fn func() (T, error)) (T, error) { - g.mu.RLock() - v, ok := g.m[key] - g.mu.RUnlock() - if ok { - <-v.done - return v.val, v.err - } - - g.mu.Lock() - v, ok = g.m[key] - if !ok { - v = &item[T]{ - done: make(chan struct{}), - } - g.m[key] = v - } - g.mu.Unlock() - - if !ok { - v.val, v.err = fn() - if v.err != nil { - g.mu.Lock() - delete(g.m, key) - g.mu.Unlock() - } - close(v.done) - } else { - <-v.done - } - return v.val, v.err -} diff --git a/pkg/utils/cache/cache_test.go b/pkg/utils/cache/cache_test.go deleted file mode 100644 index 202d6e3..0000000 --- a/pkg/utils/cache/cache_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package cache - -import ( - "fmt" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const ( - key = "foo" - val = "bar" -) - -func TestDo(t *testing.T) { - g := New[string, string]() - v, err := g.Get(key, func() (string, error) { - return val, nil - }) - require.Nil(t, err) - require.Equal(t, val, v) -} - -func TestParallelDoSuccess(t *testing.T) { - var ( - wg sync.WaitGroup - g = New[string, string]() - count = int32(0) - ) - //nolint:unparam // no need to return error - fn := func() (string, error) { - atomic.AddInt32(&count, 1) - return val, nil - } - - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - defer wg.Done() - v, err := g.Get(key, fn) - assert.Nil(t, err) - assert.Equal(t, val, v) - }() - } - wg.Wait() - require.Equal(t, int32(1), count) -} - -func TestParallelDoFail(t *testing.T) { - var ( - wg sync.WaitGroup - g = New[string, string]() - count = int32(0) - aErr = fmt.Errorf("aha") - ) - - fn := func() (string, error) { - atomic.AddInt32(&count, 1) - return "", aErr - } - - const loop = 10000 - for i := 0; i < loop; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - time.Sleep(time.Millisecond * time.Duration(i%2)) - _, err := g.Get(key, fn) - assert.Equal(t, aErr, err) - }(i) - } - wg.Wait() - // count must bigger than 2 because of sleep - require.True(t, count >= 2 && count < loop, fmt.Sprintf("actual count: %v", count)) -} diff --git a/pkg/utils/config.go b/pkg/utils/config.go index 01b06c1..c5f5917 100644 --- a/pkg/utils/config.go +++ b/pkg/utils/config.go @@ -15,5 +15,5 @@ func DefaultConfigPath() string { } configDir = filepath.Join(homeDir, ".config") } - return filepath.Join(configDir, "keyswift", "config.js") + return filepath.Join(configDir, "keyswift", "config.json") } diff --git a/pkg/wininfo/dbus/dbus.go b/pkg/wininfo/dbus/dbus.go index 6649ad2..849a4fc 100644 --- a/pkg/wininfo/dbus/dbus.go +++ b/pkg/wininfo/dbus/dbus.go @@ -42,6 +42,11 @@ func (r *Receiver) UpdateActiveWindow(in string) *dbus.Error { return dbus.NewError("com.github.keyswift.WinInfoReceiver.Error", []any{err.Error()}) } + // Only log and trigger callback if window class actually changed + if r.current == nil || r.current.Class != info.Class { + slog.Info("Active window changed", "from", r.current, "to", info) + } + r.current = &wininfo.WinInfo{ Title: info.Title, Class: info.Class,