diff --git a/.golangci.yml b/.golangci.yml index 8b208c0..09aa873 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -123,6 +123,26 @@ linters: exclusions: rules: + # Key.String() — large switch is the standard Go pattern for enum stringers + # (same as net/http status codes, syscall signal names, etc.) + - path: events\.go + text: "cyclomatic complexity" + linters: + - gocyclo + - cyclop + - path: events\.go + text: "cognitive complexity" + linters: + - gocognit + - path: events\.go + text: "Function name: String" + linters: + - maintidx + - path: events\.go + text: "Function 'String'" + linters: + - funlen + # Test files - allow more flexibility - path: _test\.go linters: diff --git a/CHANGELOG.md b/CHANGELOG.md index 61069d3..7686e90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.26.0] - 2026-08-10 + +### Changed + +- **Key enum redesign** — flat `iota` (94 keys) replaced with **grouped explicit bases** (138 keys). Each group has its own `const` block with reserved gaps, following the `net/http` status code pattern. Adding keys within a group never shifts values in other groups — binary-stable for v1.0+. + - **New groups:** Media (5 keys), Volume (3), Browser (5), System (4) + - **Extended:** Function keys F13-F24 (12 new), Punctuation +IntlBackslash/IntlYen, Numpad +NumpadEqual/NumpadComma + - **New keys:** `KeyContextMenu`, `KeyCancel`, `KeyLaunchApp1`, `KeyLaunchApp2`, `KeyMediaPlayPause`, `KeyMediaStop`, `KeyMediaTrackNext`, `KeyMediaTrackPrevious`, `KeyMediaRecord`, `KeyAudioVolumeUp`, `KeyAudioVolumeDown`, `KeyAudioVolumeMute`, `KeyBrowserBack`, `KeyBrowserForward`, `KeyBrowserRefresh`, `KeyBrowserHome`, `KeyBrowserSearch` + - **Naming:** W3C UIEvents KeyboardEvent.code convention adapted to Go PascalCase + - **BREAKING:** Key numeric values changed (pre-v1.0, no external consumers store numeric values) + +### Added + +- **`KeyFromString`** function — thread-safe reverse lookup from string name to Key value. Enables W3C KeyboardEvent.code compatibility for browser/WASM platforms. Uses `sync.Once` initialization. Round-trips with `Key.String()`. +- **`Key.String()`** method — covers all 138 keys with human-readable names + ## [0.25.0] - 2026-08-09 ### Added diff --git a/events.go b/events.go index 8a176cc..62603d7 100644 --- a/events.go +++ b/events.go @@ -3,6 +3,8 @@ package gpucontext +import "sync" + // EventSource provides input events from the host application to UI frameworks. // // This interface enables UI frameworks (like gogpu/ui) to receive user input @@ -127,16 +129,35 @@ type IMEController interface { } // Key represents a keyboard key. -// Values follow a platform-independent virtual key code scheme. +// +// Values use grouped ranges with explicit base offsets (the net/http pattern). +// Each group reserves room for future expansion without shifting existing values. +// This is a platform-independent virtual key code scheme; platform code maps +// native scan codes / virtual keys to these values. +// +// Groups and ranges: +// +// KeyUnknown = 0 +// Letters [1..31] — A-Z (26 used, 5 reserved) +// Digits [33..47] — 0-9 (10 used, 5 reserved) +// Function [49..80] — F1-F24 (24 used, 8 reserved) +// Navigation [81..112] — arrows, home, end, etc. (15 used, 17 reserved) +// Modifiers [113..128] — shift, ctrl, alt, super (8 used, 8 reserved) +// Punctuation [129..160] — brackets, operators, intl (13 used, 19 reserved) +// Numpad [161..192] — numpad digits + operators (18 used, 14 reserved) +// Locks [193..208] — caps, scroll, num lock (5 used, 11 reserved) +// Media [209..240] — playback controls (5 used, 27 reserved) +// Volume [241..248] — volume up/down/mute (3 used, 5 reserved) +// Browser [249..264] — navigation keys (5 used, 11 reserved) +// System [265..280] — context menu, cancel, launch (4 used, 12 reserved) type Key uint16 -// Common key codes. -// These match typical USB HID usage codes for cross-platform compatibility. -const ( - KeyUnknown Key = iota +// KeyUnknown represents an unrecognized or unmapped key. +const KeyUnknown Key = 0 - // Letters - KeyA +// Letters [1..31] — 26 keys (A-Z), 5 reserved for future use. +const ( + KeyA Key = iota + 1 KeyB KeyC KeyD @@ -162,9 +183,11 @@ const ( KeyX KeyY KeyZ +) - // Numbers - Key0 +// Digits [33..47] — 10 keys (0-9), 5 reserved for future use. +const ( + Key0 Key = iota + 33 Key1 Key2 Key3 @@ -174,9 +197,11 @@ const ( Key7 Key8 Key9 +) - // Function keys - KeyF1 +// Function keys [49..80] — 24 keys (F1-F24), 8 reserved for future use. +const ( + KeyF1 Key = iota + 49 KeyF2 KeyF3 KeyF4 @@ -188,9 +213,23 @@ const ( KeyF10 KeyF11 KeyF12 + KeyF13 + KeyF14 + KeyF15 + KeyF16 + KeyF17 + KeyF18 + KeyF19 + KeyF20 + KeyF21 + KeyF22 + KeyF23 + KeyF24 +) - // Navigation - KeyEscape +// Navigation [81..112] — 15 keys, 17 reserved for future use. +const ( + KeyEscape Key = iota + 81 KeyTab KeyBackspace KeyEnter @@ -205,9 +244,13 @@ const ( KeyRight KeyUp KeyDown +) - // Modifiers (as keys, not modifiers) - KeyLeftShift +// Modifiers as keys [113..128] — 8 keys, 8 reserved for future use. +// These represent physical modifier keys. For modifier state in event +// callbacks, use [Modifiers] flags instead. +const ( + KeyLeftShift Key = iota + 113 KeyRightShift KeyLeftControl KeyRightControl @@ -215,9 +258,11 @@ const ( KeyRightAlt KeyLeftSuper KeyRightSuper +) - // Punctuation - KeyMinus +// Punctuation and symbols [129..160] — 13 keys, 19 reserved for future use. +const ( + KeyMinus Key = iota + 129 KeyEqual KeyLeftBracket KeyRightBracket @@ -228,9 +273,13 @@ const ( KeyComma KeyPeriod KeySlash + KeyIntlBackslash // ISO 102nd key (between left Shift and Z on ISO layouts). + KeyIntlYen // JIS Yen key. +) - // Numpad - KeyNumpad0 +// Numpad [161..192] — 18 keys, 14 reserved for future use. +const ( + KeyNumpad0 Key = iota + 161 KeyNumpad1 KeyNumpad2 KeyNumpad3 @@ -246,15 +295,385 @@ const ( KeyNumpadSubtract KeyNumpadAdd KeyNumpadEnter + KeyNumpadEqual // Numpad = (Mac keyboards, some international layouts). + KeyNumpadComma // Numpad , (Brazilian ABNT2 layout). +) - // Other - KeyCapsLock +// Lock keys [193..208] — 5 keys, 11 reserved for future use. +const ( + KeyCapsLock Key = iota + 193 KeyScrollLock KeyNumLock KeyPrintScreen KeyPause ) +// Media keys [209..240] — 5 keys, 27 reserved for future use. +// Names follow the W3C UIEvents KeyboardEvent.code convention. +const ( + KeyMediaPlayPause Key = iota + 209 + KeyMediaStop + KeyMediaTrackNext + KeyMediaTrackPrevious + KeyMediaRecord +) + +// Volume keys [241..248] — 3 keys, 5 reserved for future use. +// Names follow the W3C UIEvents KeyboardEvent.code convention. +const ( + KeyAudioVolumeUp Key = iota + 241 + KeyAudioVolumeDown + KeyAudioVolumeMute +) + +// Browser keys [249..264] — 5 keys, 11 reserved for future use. +const ( + KeyBrowserBack Key = iota + 249 + KeyBrowserForward + KeyBrowserRefresh + KeyBrowserHome + KeyBrowserSearch +) + +// System keys [265..280] — 4 keys, 12 reserved for future use. +const ( + KeyContextMenu Key = iota + 265 // Application/context menu key (not VK_MENU/Alt). + KeyCancel // Cancel key (Ctrl+Break on Windows). + KeyLaunchApp1 // Launch application 1 (typically My Computer). + KeyLaunchApp2 // Launch application 2 (typically Calculator). +) + +// String returns a human-readable name for the key. +func (k Key) String() string { + switch k { + case KeyUnknown: + return "Unknown" + case KeyA: + return "A" + case KeyB: + return "B" + case KeyC: + return "C" + case KeyD: + return "D" + case KeyE: + return "E" + case KeyF: + return "F" + case KeyG: + return "G" + case KeyH: + return "H" + case KeyI: + return "I" + case KeyJ: + return "J" + case KeyK: + return "K" + case KeyL: + return "L" + case KeyM: + return "M" + case KeyN: + return "N" + case KeyO: + return "O" + case KeyP: + return "P" + case KeyQ: + return "Q" + case KeyR: + return "R" + case KeyS: + return "S" + case KeyT: + return "T" + case KeyU: + return "U" + case KeyV: + return "V" + case KeyW: + return "W" + case KeyX: + return "X" + case KeyY: + return "Y" + case KeyZ: + return "Z" + case Key0: + return "0" + case Key1: + return "1" + case Key2: + return "2" + case Key3: + return "3" + case Key4: + return "4" + case Key5: + return "5" + case Key6: + return "6" + case Key7: + return "7" + case Key8: + return "8" + case Key9: + return "9" + case KeyF1: + return "F1" + case KeyF2: + return "F2" + case KeyF3: + return "F3" + case KeyF4: + return "F4" + case KeyF5: + return "F5" + case KeyF6: + return "F6" + case KeyF7: + return "F7" + case KeyF8: + return "F8" + case KeyF9: + return "F9" + case KeyF10: + return "F10" + case KeyF11: + return "F11" + case KeyF12: + return "F12" + case KeyF13: + return "F13" + case KeyF14: + return "F14" + case KeyF15: + return "F15" + case KeyF16: + return "F16" + case KeyF17: + return "F17" + case KeyF18: + return "F18" + case KeyF19: + return "F19" + case KeyF20: + return "F20" + case KeyF21: + return "F21" + case KeyF22: + return "F22" + case KeyF23: + return "F23" + case KeyF24: + return "F24" + case KeyEscape: + return "Escape" + case KeyTab: + return "Tab" + case KeyBackspace: + return "Backspace" + case KeyEnter: + return "Enter" + case KeySpace: + return "Space" + case KeyInsert: + return "Insert" + case KeyDelete: + return "Delete" + case KeyHome: + return "Home" + case KeyEnd: + return "End" + case KeyPageUp: + return "PageUp" + case KeyPageDown: + return "PageDown" + case KeyLeft: + return "Left" + case KeyRight: + return "Right" + case KeyUp: + return "Up" + case KeyDown: + return "Down" + case KeyLeftShift: + return "LeftShift" + case KeyRightShift: + return "RightShift" + case KeyLeftControl: + return "LeftControl" + case KeyRightControl: + return "RightControl" + case KeyLeftAlt: + return "LeftAlt" + case KeyRightAlt: + return "RightAlt" + case KeyLeftSuper: + return "LeftSuper" + case KeyRightSuper: + return "RightSuper" + case KeyMinus: + return "Minus" + case KeyEqual: + return "Equal" + case KeyLeftBracket: + return "LeftBracket" + case KeyRightBracket: + return "RightBracket" + case KeyBackslash: + return "Backslash" + case KeySemicolon: + return "Semicolon" + case KeyApostrophe: + return "Apostrophe" + case KeyGrave: + return "Grave" + case KeyComma: + return "Comma" + case KeyPeriod: + return "Period" + case KeySlash: + return "Slash" + case KeyIntlBackslash: + return "IntlBackslash" + case KeyIntlYen: + return "IntlYen" + case KeyNumpad0: + return "Numpad0" + case KeyNumpad1: + return "Numpad1" + case KeyNumpad2: + return "Numpad2" + case KeyNumpad3: + return "Numpad3" + case KeyNumpad4: + return "Numpad4" + case KeyNumpad5: + return "Numpad5" + case KeyNumpad6: + return "Numpad6" + case KeyNumpad7: + return "Numpad7" + case KeyNumpad8: + return "Numpad8" + case KeyNumpad9: + return "Numpad9" + case KeyNumpadDecimal: + return "NumpadDecimal" + case KeyNumpadDivide: + return "NumpadDivide" + case KeyNumpadMultiply: + return "NumpadMultiply" + case KeyNumpadSubtract: + return "NumpadSubtract" + case KeyNumpadAdd: + return "NumpadAdd" + case KeyNumpadEnter: + return "NumpadEnter" + case KeyNumpadEqual: + return "NumpadEqual" + case KeyNumpadComma: + return "NumpadComma" + case KeyCapsLock: + return "CapsLock" + case KeyScrollLock: + return "ScrollLock" + case KeyNumLock: + return "NumLock" + case KeyPrintScreen: + return "PrintScreen" + case KeyPause: + return "Pause" + case KeyMediaPlayPause: + return "MediaPlayPause" + case KeyMediaStop: + return "MediaStop" + case KeyMediaTrackNext: + return "MediaTrackNext" + case KeyMediaTrackPrevious: + return "MediaTrackPrevious" + case KeyMediaRecord: + return "MediaRecord" + case KeyAudioVolumeUp: + return "AudioVolumeUp" + case KeyAudioVolumeDown: + return "AudioVolumeDown" + case KeyAudioVolumeMute: + return "AudioVolumeMute" + case KeyBrowserBack: + return "BrowserBack" + case KeyBrowserForward: + return "BrowserForward" + case KeyBrowserRefresh: + return "BrowserRefresh" + case KeyBrowserHome: + return "BrowserHome" + case KeyBrowserSearch: + return "BrowserSearch" + case KeyContextMenu: + return "ContextMenu" + case KeyCancel: + return "Cancel" + case KeyLaunchApp1: + return "LaunchApp1" + case KeyLaunchApp2: + return "LaunchApp2" + default: + return "Key(" + uitoa(uint(k)) + ")" + } +} + +// uitoa converts a uint to its string representation without importing strconv. +func uitoa(val uint) string { + if val == 0 { + return "0" + } + var buf [20]byte // big enough for 64-bit uint + i := len(buf) - 1 + for val > 0 { + buf[i] = byte('0' + val%10) + val /= 10 + i-- + } + return string(buf[i+1:]) +} + +var ( + keyStringOnce sync.Once + keyStringMap map[string]Key +) + +func buildKeyStringMap() { + m := make(map[string]Key, 140) + for k := Key(0); k <= KeyLaunchApp2; k++ { + s := k.String() + if s != "" && s[0] != 'K' { + m[s] = k + } + } + keyStringMap = m +} + +// KeyFromString returns the Key for a given string name. +// The name must match the value returned by [Key.String] (e.g., "A", "F13", +// "MediaPlayPause", "ContextMenu"). Returns (KeyUnknown, false) if the name +// is not recognized. +// +// This function is safe for concurrent use and enables W3C +// KeyboardEvent.code compatibility: browser platforms can convert +// JavaScript event.code strings directly to Key values. +// +// key, ok := gpucontext.KeyFromString("A") // → KeyA, true +// key, ok := gpucontext.KeyFromString("MediaPlayPause") // → KeyMediaPlayPause, true +// key, ok := gpucontext.KeyFromString("nonexistent") // → KeyUnknown, false +func KeyFromString(name string) (Key, bool) { + keyStringOnce.Do(buildKeyStringMap) + k, ok := keyStringMap[name] + return k, ok +} + // Modifiers represents keyboard modifier keys. type Modifiers uint8 diff --git a/events_test.go b/events_test.go index e3914da..62a1d2a 100644 --- a/events_test.go +++ b/events_test.go @@ -59,13 +59,139 @@ func TestModifiers(t *testing.T) { } } +func TestKeyGroupedBases(t *testing.T) { + tests := []struct { + name string + key Key + want Key + }{ + {"KeyUnknown", KeyUnknown, 0}, + {"KeyA (letters base)", KeyA, 1}, + {"KeyZ (letters end)", KeyZ, 26}, + {"Key0 (digits base)", Key0, 33}, + {"Key9 (digits end)", Key9, 42}, + {"KeyF1 (function base)", KeyF1, 49}, + {"KeyF12", KeyF12, 60}, + {"KeyF13", KeyF13, 61}, + {"KeyF24 (function end)", KeyF24, 72}, + {"KeyEscape (navigation base)", KeyEscape, 81}, + {"KeyDown (navigation end)", KeyDown, 95}, + {"KeyLeftShift (modifiers base)", KeyLeftShift, 113}, + {"KeyRightSuper (modifiers end)", KeyRightSuper, 120}, + {"KeyMinus (punctuation base)", KeyMinus, 129}, + {"KeyIntlYen (punctuation end)", KeyIntlYen, 141}, + {"KeyNumpad0 (numpad base)", KeyNumpad0, 161}, + {"KeyNumpadComma (numpad end)", KeyNumpadComma, 178}, + {"KeyCapsLock (locks base)", KeyCapsLock, 193}, + {"KeyPause (locks end)", KeyPause, 197}, + {"KeyMediaPlayPause (media base)", KeyMediaPlayPause, 209}, + {"KeyMediaRecord (media end)", KeyMediaRecord, 213}, + {"KeyAudioVolumeUp (volume base)", KeyAudioVolumeUp, 241}, + {"KeyAudioVolumeMute (volume end)", KeyAudioVolumeMute, 243}, + {"KeyBrowserBack (browser base)", KeyBrowserBack, 249}, + {"KeyBrowserSearch (browser end)", KeyBrowserSearch, 253}, + {"KeyContextMenu (system base)", KeyContextMenu, 265}, + {"KeyLaunchApp2 (system end)", KeyLaunchApp2, 268}, + } + for _, tt := range tests { + if tt.key != tt.want { + t.Errorf("%s = %d, want %d", tt.name, tt.key, tt.want) + } + } +} + +func TestKeyGroupsNoOverlap(t *testing.T) { + groups := []struct { + name string + first Key + last Key + maxRange Key + }{ + {"Letters", KeyA, KeyZ, 31}, + {"Digits", Key0, Key9, 47}, + {"Function", KeyF1, KeyF24, 80}, + {"Navigation", KeyEscape, KeyDown, 112}, + {"Modifiers", KeyLeftShift, KeyRightSuper, 128}, + {"Punctuation", KeyMinus, KeyIntlYen, 160}, + {"Numpad", KeyNumpad0, KeyNumpadComma, 192}, + {"Locks", KeyCapsLock, KeyPause, 208}, + {"Media", KeyMediaPlayPause, KeyMediaRecord, 240}, + {"Volume", KeyAudioVolumeUp, KeyAudioVolumeMute, 248}, + {"Browser", KeyBrowserBack, KeyBrowserSearch, 264}, + {"System", KeyContextMenu, KeyLaunchApp2, 280}, + } + for i, g := range groups { + if g.last > g.maxRange { + t.Errorf("%s: last key %d exceeds group max range %d", g.name, g.last, g.maxRange) + } + if i > 0 { + prev := groups[i-1] + if g.first <= prev.maxRange { + t.Errorf("%s (base %d) overlaps with %s (max %d)", g.name, g.first, prev.name, prev.maxRange) + } + } + } +} + +func TestKeyStringRoundTrip(t *testing.T) { + keys := []Key{ + KeyA, KeyZ, Key0, Key9, KeyF1, KeyF12, KeyF13, KeyF24, + KeyEscape, KeyEnter, KeySpace, KeyLeft, KeyDown, + KeyLeftShift, KeyRightSuper, + KeyMinus, KeySlash, KeyIntlBackslash, KeyIntlYen, + KeyNumpad0, KeyNumpadEnter, KeyNumpadEqual, KeyNumpadComma, + KeyCapsLock, KeyPause, + KeyMediaPlayPause, KeyMediaStop, KeyMediaTrackNext, KeyMediaTrackPrevious, KeyMediaRecord, + KeyAudioVolumeUp, KeyAudioVolumeDown, KeyAudioVolumeMute, + KeyBrowserBack, KeyBrowserSearch, + KeyContextMenu, KeyCancel, KeyLaunchApp1, KeyLaunchApp2, + } + for _, k := range keys { + name := k.String() + got, ok := KeyFromString(name) + if !ok { + t.Errorf("KeyFromString(%q) returned false for valid key %d", name, k) + continue + } + if got != k { + t.Errorf("KeyFromString(%q) = %d, want %d", name, got, k) + } + } +} + +func TestKeyFromStringUnknown(t *testing.T) { + tests := []string{"", "nonexistent", "KEY_A", "keyA", "ArrowLeft"} + for _, name := range tests { + k, ok := KeyFromString(name) + if ok { + t.Errorf("KeyFromString(%q) = (%d, true), want (_, false)", name, k) + } + if k != KeyUnknown { + t.Errorf("KeyFromString(%q) = %d, want KeyUnknown (0)", name, k) + } + } +} + +func TestKeyStringNotEmpty(t *testing.T) { + keys := []Key{ + KeyA, KeyZ, Key0, Key9, KeyF1, KeyF24, KeyEscape, + KeyContextMenu, KeyCancel, KeyMediaPlayPause, KeyAudioVolumeMute, + } + for _, k := range keys { + if s := k.String(); s == "" { + t.Errorf("Key(%d).String() is empty", k) + } + } +} + func TestKeyConstants(t *testing.T) { - // Verify key codes are unique and sequential keys := []Key{ KeyA, KeyB, KeyC, KeyZ, Key0, Key1, Key9, - KeyF1, KeyF12, + KeyF1, KeyF12, KeyF13, KeyF24, KeyEscape, KeyEnter, KeySpace, + KeyContextMenu, KeyCancel, + KeyMediaPlayPause, KeyAudioVolumeUp, } seen := make(map[Key]bool)