diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27b6ab8..e75fc90 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,7 @@ jobs: # Run tests. - run: nim c tests/test.nim + - run: nim c tests/manual_inputs.nim - run: nim r tests/test_openurl.nim - run: nim r tests/test_macos.nim if: matrix.os == 'macos-latest' @@ -64,6 +65,8 @@ jobs: if: matrix.os == 'ubuntu-latest' # Build Emscripten examples on Ubuntu only. + - run: nim c -d:emscripten tests/manual_inputs.nim + if: matrix.os == 'ubuntu-latest' - run: nim c -d:emscripten examples/basic.nim if: matrix.os == 'ubuntu-latest' - run: nim c -d:emscripten examples/basic_boxy.nim diff --git a/docs/browser_inputs.md b/docs/browser_inputs.md new file mode 100644 index 0000000..9b572fa --- /dev/null +++ b/docs/browser_inputs.md @@ -0,0 +1,42 @@ +# Manual browser input check + +Use `tests/manual_inputs.nim` to compare native and browser input. +Received text appears in the window or tab title. The console logs every +callback and the keys held when it runs. + +Run the native test from the repository root: + +```sh +nim r tests/manual_inputs.nim +``` + +Compile with the shared examples configuration, then open it with Emscripten: + +```sh +nim c -d:emscripten tests/manual_inputs.nim +emrun examples/emscripten/manual_inputs.html +``` + +Open the browser console, then click the canvas. The background turns red +while a modifier is held, flashes green for received text, and turns blue for +other held keys. F2 toggles rune input and Escape clears the displayed text. + +- Type `wasd`, spaces, uppercase letters, and characters from your keyboard + layout. Each character should appear once in the title and as one `onRune` + callback. Physical key callbacks should identify the key independently of + the character it produces. +- Hold a letter to repeat it. Each repeat should produce a press and a rune. +- Turn rune input off with F2. Keys should still report presses and releases, + but no text should be added. Turn it back on and check typing again. +- Quickly press and release Control-A or Command-A. The A callbacks should + show the modifier held, and the shortcut should not append text to the title. +- Try left and right Shift, Control, Alt, and Command/Super. Check their names + and held state in the log. Test both Shift keys held together and release + only one. The other should remain held. +- Try arrows, function keys, and numpad keys. They should report their own + buttons; navigation keys should not generate text or move browser focus. +- Hold a key and switch away from the window. Releases should occur before + the focus-change callback. Return and check that no keys remain stuck. + +These checks intentionally use real browser events. Compiling an example in +CI does not verify browser event delivery. diff --git a/src/windy/platforms/emscripten/platform.nim b/src/windy/platforms/emscripten/platform.nim index ec8b794..cfd36f3 100644 --- a/src/windy/platforms/emscripten/platform.nim +++ b/src/windy/platforms/emscripten/platform.nim @@ -527,6 +527,7 @@ proc loadExtensions*() = # Convert JavaScript key codes to windy Button enum proc keyCodeToButton(keyCode: culong): Button = + ## Maps legacy key codes when the browser has no physical key code. case keyCode: of 27: KeyEscape of 32: KeySpace @@ -572,6 +573,8 @@ proc keyCodeToButton(keyCode: culong): Button = of 88: KeyX of 89: KeyY of 90: KeyZ + of 91, 92: KeyLeftSuper + of 93: KeyRightSuper of 48: Key0 of 49: Key1 of 50: Key2 @@ -595,6 +598,120 @@ proc keyCodeToButton(keyCode: culong): Button = of 222: KeyApostrophe else: ButtonUnknown +proc keyEventToButton(event: ptr EmscriptenKeyboardEvent): Button = + ## Maps physical browser keys independently of the keyboard layout. + let code = $cast[cstring](addr event.code[0]) + if code.len == 4 and code.startsWith("Key") and code[3] in 'A' .. 'Z': + return Button(KeyA.ord + code[3].ord - 'A'.ord) + if code.len == 6 and code.startsWith("Digit") and code[5] in '0' .. '9': + return Button(Key0.ord + code[5].ord - '0'.ord) + if code.len == 7 and code.startsWith("Numpad") and code[6] in '0' .. '9': + return Button(Numpad0.ord + code[6].ord - '0'.ord) + case code + of "Backquote": KeyBacktick + of "Minus": KeyMinus + of "Equal": KeyEqual + of "Backspace": KeyBackspace + of "Tab": KeyTab + of "BracketLeft": KeyLeftBracket + of "BracketRight": KeyRightBracket + of "Backslash", "IntlBackslash": KeyBackslash + of "CapsLock": KeyCapsLock + of "Semicolon": KeySemicolon + of "Quote": KeyApostrophe + of "Enter": KeyEnter + of "ShiftLeft": KeyLeftShift + of "Comma": KeyComma + of "Period": KeyPeriod + of "Slash": KeySlash + of "ShiftRight": KeyRightShift + of "ControlLeft": KeyLeftControl + of "MetaLeft", "OSLeft": KeyLeftSuper + of "AltLeft": KeyLeftAlt + of "Space": KeySpace + of "AltRight": KeyRightAlt + of "MetaRight", "OSRight": KeyRightSuper + of "ContextMenu": KeyMenu + of "ControlRight": KeyRightControl + of "Delete": KeyDelete + of "Home": KeyHome + of "End": KeyEnd + of "Insert": KeyInsert + of "PageUp": KeyPageUp + of "PageDown": KeyPageDown + of "Escape": KeyEscape + of "ArrowUp": KeyUp + of "ArrowDown": KeyDown + of "ArrowLeft": KeyLeft + of "ArrowRight": KeyRight + of "PrintScreen": KeyPrintScreen + of "ScrollLock": KeyScrollLock + of "Pause": KeyPause + of "F1": KeyF1 + of "F2": KeyF2 + of "F3": KeyF3 + of "F4": KeyF4 + of "F5": KeyF5 + of "F6": KeyF6 + of "F7": KeyF7 + of "F8": KeyF8 + of "F9": KeyF9 + of "F10": KeyF10 + of "F11": KeyF11 + of "F12": KeyF12 + of "NumLock": KeyNumLock + of "NumpadDecimal": NumpadDecimal + of "NumpadEnter": NumpadEnter + of "NumpadAdd": NumpadAdd + of "NumpadSubtract": NumpadSubtract + of "NumpadMultiply": NumpadMultiply + of "NumpadDivide": NumpadDivide + of "NumpadEqual": NumpadEqual + else: + # Android browsers can omit event.code despite supporting WebGL2. + let button = keyCodeToButton(event.keyCode) + if event.location == 2: + case button + of KeyLeftShift: return KeyRightShift + of KeyLeftControl: return KeyRightControl + of KeyLeftAlt: return KeyRightAlt + of KeyLeftSuper: return KeyRightSuper + else: discard + button + +proc syncModifiers( + window: Window, + event: ptr EmscriptenKeyboardEvent, + button = ButtonUnknown, + pressed = false +) = + ## Makes the event's modifier state visible before any callbacks run. + let previous = window.state.buttonDown + for (left, right, held) in [ + (KeyLeftControl, KeyRightControl, event.ctrlKey), + (KeyLeftShift, KeyRightShift, event.shiftKey), + (KeyLeftAlt, KeyRightAlt, event.altKey), + (KeyLeftSuper, KeyRightSuper, event.metaKey) + ]: + if button in {left, right}: + if pressed: + window.state.buttonDown.incl(button) + else: + window.state.buttonDown.excl(button) + if not held: + window.state.buttonDown.excl(left) + window.state.buttonDown.excl(right) + elif left notin window.state.buttonDown and + right notin window.state.buttonDown: + window.state.buttonDown.incl(left) + let current = window.state.buttonDown + for changed in previous - current: + if changed != button: + window.handleButtonRelease(changed) + for changed in current - previous: + if changed != button: + window.handleButtonPress(changed) + proc mouseButtonToButton(button: cushort): Button = case button: of 0: MouseLeft @@ -657,21 +774,47 @@ proc onWheel(eventType: cint, wheelEvent: ptr EmscriptenWheelEvent, userData: po return 1 proc onKeyDown(eventType: cint, keyEvent: ptr EmscriptenKeyboardEvent, userData: pointer): EM_BOOL {.cdecl.} = - let window = cast[Window](userData) - let button = keyCodeToButton(keyEvent.keyCode) - window.handleButtonPress(button) + ## Delivers key presses and lets printable keys produce a text event. + let + window = cast[Window](userData) + button = keyEventToButton(keyEvent) + key = $cast[cstring](addr keyEvent.key[0]) + window.syncModifiers(keyEvent, button, pressed = true) + if button != ButtonUnknown: + window.handleButtonPress(button) + if window.runeInputEnabled and not keyEvent.metaKey and + (not keyEvent.ctrlKey or keyEvent.altKey) and + (key.runeLen == 1 or key == "Dead" or key == "Process"): + # Cancelling keydown also suppresses the browser's keypress event. + return 0 return 1 proc onKeyUp(eventType: cint, keyEvent: ptr EmscriptenKeyboardEvent, userData: pointer): EM_BOOL {.cdecl.} = - let window = cast[Window](userData) - let button = keyCodeToButton(keyEvent.keyCode) - window.handleButtonRelease(button) + ## Delivers releases with up-to-date modifier state. + let + window = cast[Window](userData) + button = keyEventToButton(keyEvent) + window.syncModifiers(keyEvent, button) + if button != ButtonUnknown: + window.handleButtonRelease(button) + if button in {KeyLeftSuper, KeyRightSuper} and not keyEvent.metaKey: + # Browsers can omit releases for keys used in Command shortcuts. + let held = window.state.buttonDown - { + KeyLeftControl, KeyRightControl, KeyLeftShift, KeyRightShift, + KeyLeftAlt, KeyRightAlt, KeyLeftSuper, KeyRightSuper + } + for released in held: + if released >= Key0: + window.handleButtonRelease(released) return 1 proc onKeyPress(eventType: cint, keyEvent: ptr EmscriptenKeyboardEvent, userData: pointer): EM_BOOL {.cdecl.} = + ## Delivers text once through the shared desktop rune handler. let window = cast[Window](userData) - if keyEvent.charCode > 0: - window.handleRune(Rune(keyEvent.charCode)) + window.syncModifiers(keyEvent) + if not keyEvent.metaKey and + (not keyEvent.ctrlKey or keyEvent.altKey) and keyEvent.charCode > 0: + window.handleRune(Rune(keyEvent.charCode)) return 1 proc onFocus(eventType: cint, focusEvent: ptr EmscriptenFocusEvent, userData: pointer): EM_BOOL {.cdecl.} = @@ -684,7 +827,9 @@ proc onFocus(eventType: cint, focusEvent: ptr EmscriptenFocusEvent, userData: po return 1 proc onBlur(eventType: cint, focusEvent: ptr EmscriptenFocusEvent, userData: pointer): EM_BOOL {.cdecl.} = + ## Releases held input before notifying the application of focus loss. let window = cast[Window](userData) + clearButtonsTemplate() if frameDispatchActive: pendingFocusChange = true return 1 diff --git a/tests/config.nims b/tests/config.nims index a119208..2105526 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -1 +1,4 @@ --path:"../src" + +when defined(emscripten): + include "../examples/config.nims" diff --git a/tests/manual_inputs.nim b/tests/manual_inputs.nim new file mode 100644 index 0000000..bec9dcc --- /dev/null +++ b/tests/manual_inputs.nim @@ -0,0 +1,79 @@ +import + std/[os, times, unicode], + opengl, windy + +const Modifiers = { + KeyLeftControl, KeyRightControl, KeyLeftShift, KeyRightShift, + KeyLeftAlt, KeyRightAlt, KeyLeftSuper, KeyRightSuper +} + +let window = newWindow("Windy Inputs", ivec2(1000, 600)) + +var + text: seq[Rune] + lastRuneTime: float64 + +proc updateTitle() = + ## Shows text and the current held keys without needing a font asset. + let mode = + if window.runeInputEnabled: + "on" + else: + "off" + window.title = "F2 text=" & mode & ", Esc clears | " & $text & + " | held: " & $set[Button](window.buttonDown) + +window.runeInputEnabled = true +window.makeContextCurrent() +loadExtensions() +updateTitle() + +window.onButtonPress = proc(button: Button) = + ## Logs the key and modifiers as seen by a normal application callback. + echo "press ", button, " held: ", set[Button](window.buttonDown) + case button + of KeyF2: + window.runeInputEnabled = not window.runeInputEnabled + of KeyEscape: + text.setLen(0) + else: + discard + updateTitle() + +window.onButtonRelease = proc(button: Button) = + ## Logs releases and updates the displayed held state. + echo "release ", button, " held: ", set[Button](window.buttonDown) + updateTitle() + +window.onRune = proc(rune: Rune) = + ## Displays each received rune and flashes the window green. + echo "rune ", rune, " held: ", set[Button](window.buttonDown) + text.add(rune) + if text.len > 80: + text.delete(0) + lastRuneTime = epochTime() + updateTitle() + +window.onFocusChange = proc() = + ## Makes stuck input visible after switching away and returning. + echo "focus ", window.focused, " held: ", set[Button](window.buttonDown) + updateTitle() + +window.onFrame = proc() = + ## Shows modifiers in red, received text in green, and other keys in blue. + let held = set[Button](window.buttonDown) + if (held * Modifiers) != {}: + glClearColor(0.35f, 0.08f, 0.08f, 1.0f) + elif epochTime() - lastRuneTime < 0.2: + glClearColor(0.08f, 0.35f, 0.12f, 1.0f) + elif (held * {Key0 .. Button.high}) != {}: + glClearColor(0.08f, 0.15f, 0.35f, 1.0f) + else: + glClearColor(0.08f, 0.08f, 0.08f, 1.0f) + glClear(GL_COLOR_BUFFER_BIT) + window.swapBuffers() + +while not window.closeRequested: + if window.minimized or not window.visible: + sleep(10) + pollEvents()