From 234b2dec841ea1a72621c36aa791a92e6898aaba Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:17:49 -0700 Subject: [PATCH 1/4] Fix browser keyboard and text callbacks --- .github/workflows/build.yml | 6 + .gitignore | 1 + src/windy/platforms/emscripten/platform.nim | 160 ++++++++++++++- tests/run_browser_inputs.cjs | 3 + tests/test_browser_inputs.nim | 214 ++++++++++++++++++++ tests/test_browser_inputs.nims | 16 ++ 6 files changed, 392 insertions(+), 8 deletions(-) create mode 100644 tests/run_browser_inputs.cjs create mode 100644 tests/test_browser_inputs.nim create mode 100644 tests/test_browser_inputs.nims diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27b6ab84..e9af7af0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,6 +63,12 @@ jobs: - uses: mymindstorm/setup-emsdk@v14 if: matrix.os == 'ubuntu-latest' + - name: Test browser input callbacks in WASM + if: matrix.os == 'ubuntu-latest' + run: | + nim c -d:emscripten tests/test_browser_inputs.nim + node tests/run_browser_inputs.cjs + # Build Emscripten examples on Ubuntu only. - run: nim c -d:emscripten examples/basic.nim if: matrix.os == 'ubuntu-latest' diff --git a/.gitignore b/.gitignore index 8fb8824c..e0df8423 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.wasm *.html *.js +tests/test_browser_inputs.cjs *.data nimcache *.pdb diff --git a/src/windy/platforms/emscripten/platform.nim b/src/windy/platforms/emscripten/platform.nim index ec8b7942..44774784 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,119 @@ 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: + 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 +773,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 +826,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/run_browser_inputs.cjs b/tests/run_browser_inputs.cjs new file mode 100644 index 00000000..c957f2e0 --- /dev/null +++ b/tests/run_browser_inputs.cjs @@ -0,0 +1,3 @@ +// Supply the browser platform queried when the WASM backend initializes. +globalThis.navigator ??= {platform: 'Linux'}; +require('./test_browser_inputs.cjs'); diff --git a/tests/test_browser_inputs.nim b/tests/test_browser_inputs.nim new file mode 100644 index 00000000..a8b6f367 --- /dev/null +++ b/tests/test_browser_inputs.nim @@ -0,0 +1,214 @@ +include ../src/windy/platforms/emscripten/platform + +type InputRecord = object + name: string + down: set[Button] + +var + testWindow: Window + records: seq[InputRecord] + +proc record(name: string) = + ## Records callback order and the state visible to application code. + records.add(InputRecord(name: name, down: testWindow.state.buttonDown)) + +proc reset() = + ## Creates an input-only window using the actual WASM backend. + records.setLen(0) + testWindow = Window() + testWindow.onButtonPress = proc(button: Button) = + ## Records a normal application press callback. + record("down " & $button) + testWindow.onButtonRelease = proc(button: Button) = + ## Records a normal application release callback. + record("up " & $button) + testWindow.onRune = proc(rune: Rune) = + ## Records the rune supplied by the browser. + record("text " & $rune) + testWindow.onFocusChange = proc() = + ## Records focus changes after key cleanup. + record("focus") + +proc keyboard( + key, code: string, + control = false, + shift = false, + alt = false, + super = false, + charCode = 0 +): EmscriptenKeyboardEvent = + ## Constructs the browser event fields used by the backend. + doAssert key.len < result.key.len and code.len < result.code.len + for i, character in key: + result.key[i] = character + for i, character in code: + result.code[i] = character + result.ctrlKey = control + result.shiftKey = shift + result.altKey = alt + result.metaKey = super + result.charCode = charCode.culong + +proc down(event: var EmscriptenKeyboardEvent): EM_BOOL = + ## Dispatches keydown and returns whether the browser should cancel it. + onKeyDown(0, addr event, cast[pointer](testWindow)) + +proc up(event: var EmscriptenKeyboardEvent) = + ## Dispatches a browser key release. + discard onKeyUp(0, addr event, cast[pointer](testWindow)) + +proc text(event: var EmscriptenKeyboardEvent) = + ## Dispatches the browser's text-producing keypress. + doAssert onKeyPress(0, addr event, cast[pointer](testWindow)) == 1 + +proc names(): seq[string] = + ## Returns callback names for compact order assertions. + for entry in records: + result.add(entry.name) + +echo "Testing text enablement and keydown cancellation" +reset() +var character = keyboard("a", "KeyA", charCode = 'a'.ord) +doAssert character.down() == 1 +character.text() +character.up() +doAssert names() == @["down KeyA", "up KeyA"] +testWindow.runeInputEnabled = true +records.setLen(0) +doAssert character.down() == 0 +doAssert names() == @["down KeyA"] +character.text() +character.up() +doAssert names() == @["down KeyA", "text a", "up KeyA"] +doAssert KeyA in records[0].down and KeyA in records[1].down +doAssert KeyA notin records[2].down +doAssert testWindow.buttonPressed[KeyA] +doAssert testWindow.buttonReleased[KeyA] + +echo "Testing Unicode, spaces, repeat callbacks, and physical keys" +reset() +testWindow.runeInputEnabled = true +for (key, code, rune) in [ + ("é", "KeyE", 0xe9), ("😀", "KeyQ", 0x1f600), + (" ", "Space", 32), ("z", "KeyW", 'z'.ord) +]: + var event = keyboard(key, code, charCode = rune) + let button = keyEventToButton(addr event) + doAssert event.down() == 0 + event.text() + event.repeat = true + doAssert event.down() == 0 + event.text() + event.up() + doAssert names()[^5 .. ^1] == @[ + "down " & $button, "text " & key, + "down " & $button, "text " & key, "up " & $button + ] + +echo "Testing shortcut modifiers and complete between-frame chords" +for (key, code, modifier) in [ + ("Control", "ControlLeft", KeyLeftControl), + ("Control", "ControlRight", KeyRightControl), + ("Meta", "MetaLeft", KeyLeftSuper), + ("Meta", "MetaRight", KeyRightSuper) +]: + reset() + testWindow.runeInputEnabled = true + let super = modifier in {KeyLeftSuper, KeyRightSuper} + var + press = keyboard(key, code, control = not super, super = super) + shortcut = keyboard( + "a", "KeyA", control = not super, super = super, charCode = 'a'.ord + ) + release = keyboard(key, code) + doAssert press.down() == 1 + doAssert shortcut.down() == 1 + shortcut.text() + shortcut.up() + release.up() + doAssert names() == @[ + "down " & $modifier, "down KeyA", "up KeyA", "up " & $modifier + ] + doAssert modifier in records[1].down + doAssert modifier notin records[^1].down + doAssert testWindow.state.buttonDown == {} + +echo "Testing modifiers held before canvas focus and both Shift keys" +reset() +var shifted = keyboard("A", "KeyA", shift = true, super = true) +discard shifted.down() +doAssert records[^1].name == "down KeyA" +doAssert {KeyLeftShift, KeyLeftSuper, KeyA} <= records[^1].down +var plain = keyboard("a", "KeyA") +plain.up() +doAssert testWindow.state.buttonDown == {} +reset() +var + leftShift = keyboard("Shift", "ShiftLeft", shift = true) + rightShift = keyboard("Shift", "ShiftRight", shift = true) +discard leftShift.down() +discard rightShift.down() +leftShift.up() +doAssert testWindow.buttonDown[KeyRightShift] +doAssert not testWindow.buttonDown[KeyLeftShift] +rightShift.shiftKey = false +rightShift.up() +doAssert testWindow.state.buttonDown == {} + +echo "Testing AltGr and dead keys without shortcut runes" +reset() +testWindow.runeInputEnabled = true +var + altGraph = keyboard("@", "KeyQ", control = true, alt = true, charCode = 64) + dead = keyboard("Dead", "Quote") +doAssert altGraph.down() == 0 +altGraph.text() +doAssert records[^1].name == "text @" +doAssert dead.down() == 0 +for (key, code) in [ + ("Enter", "Enter"), ("Tab", "Tab"), ("ArrowLeft", "ArrowLeft") +]: + var event = keyboard(key, code) + doAssert event.down() == 1 + +echo "Testing key mapping, unknown keys, and legacy modifiers" +for (code, expected) in [ + ("ShiftRight", KeyRightShift), ("AltRight", KeyRightAlt), + ("ControlRight", KeyRightControl), ("MetaRight", KeyRightSuper), + ("Numpad0", Numpad0), ("Numpad9", Numpad9), + ("NumpadEnter", NumpadEnter), ("NumpadAdd", NumpadAdd), + ("F1", KeyF1), ("F12", KeyF12), ("ContextMenu", KeyMenu) +]: + var event = keyboard("", code) + doAssert keyEventToButton(addr event) == expected +reset() +var unknown = keyboard("Unidentified", "Unidentified") +discard unknown.down() +unknown.up() +doAssert records.len == 0 +unknown.keyCode = 17 +unknown.location = 2 +doAssert keyEventToButton(addr unknown) == KeyRightControl + +echo "Testing missing Command releases and focus loss" +reset() +var + command = keyboard("Meta", "MetaLeft", super = true) + shortcut = keyboard("a", "KeyA", super = true) +discard command.down() +discard shortcut.down() +command.metaKey = false +command.up() +doAssert testWindow.state.buttonDown == {} +doAssert names() == @[ + "down KeyLeftSuper", "down KeyA", "up KeyLeftSuper", "up KeyA" +] +reset() +discard shifted.down() +discard onBlur(0, nil, cast[pointer](testWindow)) +doAssert records[^1].name == "focus" and records[^1].down == {} +doAssert testWindow.state.buttonDown == {} +discard onBlur(0, nil, cast[pointer](testWindow)) +doAssert records[^2].name == "focus" + +echo "Windy browser input tests passed" diff --git a/tests/test_browser_inputs.nims b/tests/test_browser_inputs.nims new file mode 100644 index 00000000..ee665af4 --- /dev/null +++ b/tests/test_browser_inputs.nims @@ -0,0 +1,16 @@ +when defined(emscripten): + switch("os", "linux") + switch("cpu", "wasm32") + switch("cc", "clang") + switch("clang.exe", "emcc") + switch("clang.linkerexe", "emcc") + switch("mm", "arc") + switch("exceptions", "goto") + switch("define", "noSignalHandler") + switch("nimcache", "tmp/browser-inputs") + switch( + "passL", + "-o tests/test_browser_inputs.cjs -s SINGLE_FILE=1 " & + "-s ENVIRONMENT=node -s EXIT_RUNTIME=1 -s ALLOW_MEMORY_GROWTH " & + "-s EXPORTED_FUNCTIONS=_main,_malloc,_free" + ) From d43a2f16ff951c0306758e7e4ad20d6411b55020 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:22:12 -0700 Subject: [PATCH 2/4] Simplify browser test execution --- .github/workflows/build.yml | 2 +- .gitignore | 1 - tests/run_browser_inputs.cjs | 3 --- tests/test_browser_inputs.nims | 2 +- 4 files changed, 2 insertions(+), 6 deletions(-) delete mode 100644 tests/run_browser_inputs.cjs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9af7af0..d7ac6f6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,7 +67,7 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | nim c -d:emscripten tests/test_browser_inputs.nim - node tests/run_browser_inputs.cjs + node tmp/test_browser_inputs.cjs # Build Emscripten examples on Ubuntu only. - run: nim c -d:emscripten examples/basic.nim diff --git a/.gitignore b/.gitignore index e0df8423..8fb8824c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,6 @@ *.wasm *.html *.js -tests/test_browser_inputs.cjs *.data nimcache *.pdb diff --git a/tests/run_browser_inputs.cjs b/tests/run_browser_inputs.cjs deleted file mode 100644 index c957f2e0..00000000 --- a/tests/run_browser_inputs.cjs +++ /dev/null @@ -1,3 +0,0 @@ -// Supply the browser platform queried when the WASM backend initializes. -globalThis.navigator ??= {platform: 'Linux'}; -require('./test_browser_inputs.cjs'); diff --git a/tests/test_browser_inputs.nims b/tests/test_browser_inputs.nims index ee665af4..7d12c0a6 100644 --- a/tests/test_browser_inputs.nims +++ b/tests/test_browser_inputs.nims @@ -10,7 +10,7 @@ when defined(emscripten): switch("nimcache", "tmp/browser-inputs") switch( "passL", - "-o tests/test_browser_inputs.cjs -s SINGLE_FILE=1 " & + "-o tmp/test_browser_inputs.cjs -s SINGLE_FILE=1 " & "-s ENVIRONMENT=node -s EXIT_RUNTIME=1 -s ALLOW_MEMORY_GROWTH " & "-s EXPORTED_FUNCTIONS=_main,_malloc,_free" ) From d363d10d8219b247f559979ce136a8c660d61c08 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:23:51 -0700 Subject: [PATCH 3/4] Let Nim create the test output directory --- tests/test_browser_inputs.nims | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_browser_inputs.nims b/tests/test_browser_inputs.nims index 7d12c0a6..2ee823dc 100644 --- a/tests/test_browser_inputs.nims +++ b/tests/test_browser_inputs.nims @@ -8,9 +8,10 @@ when defined(emscripten): switch("exceptions", "goto") switch("define", "noSignalHandler") switch("nimcache", "tmp/browser-inputs") + switch("out", "tmp/test_browser_inputs.cjs") switch( "passL", - "-o tmp/test_browser_inputs.cjs -s SINGLE_FILE=1 " & + "-s SINGLE_FILE=1 " & "-s ENVIRONMENT=node -s EXIT_RUNTIME=1 -s ALLOW_MEMORY_GROWTH " & "-s EXPORTED_FUNCTIONS=_main,_malloc,_free" ) From d3d92f5decf22f98c61d198d6285427c83f67b60 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 9 Sep 2026 16:36:54 -0700 Subject: [PATCH 4/4] Use a manual browser input test --- .github/workflows/build.yml | 9 +- docs/browser_inputs.md | 42 ++++ src/windy/platforms/emscripten/platform.nim | 1 + tests/config.nims | 3 + tests/manual_inputs.nim | 79 ++++++++ tests/test_browser_inputs.nim | 214 -------------------- tests/test_browser_inputs.nims | 17 -- 7 files changed, 128 insertions(+), 237 deletions(-) create mode 100644 docs/browser_inputs.md create mode 100644 tests/manual_inputs.nim delete mode 100644 tests/test_browser_inputs.nim delete mode 100644 tests/test_browser_inputs.nims diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7ac6f6f..e75fc90c 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' @@ -63,13 +64,9 @@ jobs: - uses: mymindstorm/setup-emsdk@v14 if: matrix.os == 'ubuntu-latest' - - name: Test browser input callbacks in WASM - if: matrix.os == 'ubuntu-latest' - run: | - nim c -d:emscripten tests/test_browser_inputs.nim - node tmp/test_browser_inputs.cjs - # 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 00000000..9b572fa4 --- /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 44774784..cfd36f35 100644 --- a/src/windy/platforms/emscripten/platform.nim +++ b/src/windy/platforms/emscripten/platform.nim @@ -668,6 +668,7 @@ proc keyEventToButton(event: ptr EmscriptenKeyboardEvent): Button = 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 diff --git a/tests/config.nims b/tests/config.nims index a1192088..2105526e 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 00000000..bec9dcc0 --- /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() diff --git a/tests/test_browser_inputs.nim b/tests/test_browser_inputs.nim deleted file mode 100644 index a8b6f367..00000000 --- a/tests/test_browser_inputs.nim +++ /dev/null @@ -1,214 +0,0 @@ -include ../src/windy/platforms/emscripten/platform - -type InputRecord = object - name: string - down: set[Button] - -var - testWindow: Window - records: seq[InputRecord] - -proc record(name: string) = - ## Records callback order and the state visible to application code. - records.add(InputRecord(name: name, down: testWindow.state.buttonDown)) - -proc reset() = - ## Creates an input-only window using the actual WASM backend. - records.setLen(0) - testWindow = Window() - testWindow.onButtonPress = proc(button: Button) = - ## Records a normal application press callback. - record("down " & $button) - testWindow.onButtonRelease = proc(button: Button) = - ## Records a normal application release callback. - record("up " & $button) - testWindow.onRune = proc(rune: Rune) = - ## Records the rune supplied by the browser. - record("text " & $rune) - testWindow.onFocusChange = proc() = - ## Records focus changes after key cleanup. - record("focus") - -proc keyboard( - key, code: string, - control = false, - shift = false, - alt = false, - super = false, - charCode = 0 -): EmscriptenKeyboardEvent = - ## Constructs the browser event fields used by the backend. - doAssert key.len < result.key.len and code.len < result.code.len - for i, character in key: - result.key[i] = character - for i, character in code: - result.code[i] = character - result.ctrlKey = control - result.shiftKey = shift - result.altKey = alt - result.metaKey = super - result.charCode = charCode.culong - -proc down(event: var EmscriptenKeyboardEvent): EM_BOOL = - ## Dispatches keydown and returns whether the browser should cancel it. - onKeyDown(0, addr event, cast[pointer](testWindow)) - -proc up(event: var EmscriptenKeyboardEvent) = - ## Dispatches a browser key release. - discard onKeyUp(0, addr event, cast[pointer](testWindow)) - -proc text(event: var EmscriptenKeyboardEvent) = - ## Dispatches the browser's text-producing keypress. - doAssert onKeyPress(0, addr event, cast[pointer](testWindow)) == 1 - -proc names(): seq[string] = - ## Returns callback names for compact order assertions. - for entry in records: - result.add(entry.name) - -echo "Testing text enablement and keydown cancellation" -reset() -var character = keyboard("a", "KeyA", charCode = 'a'.ord) -doAssert character.down() == 1 -character.text() -character.up() -doAssert names() == @["down KeyA", "up KeyA"] -testWindow.runeInputEnabled = true -records.setLen(0) -doAssert character.down() == 0 -doAssert names() == @["down KeyA"] -character.text() -character.up() -doAssert names() == @["down KeyA", "text a", "up KeyA"] -doAssert KeyA in records[0].down and KeyA in records[1].down -doAssert KeyA notin records[2].down -doAssert testWindow.buttonPressed[KeyA] -doAssert testWindow.buttonReleased[KeyA] - -echo "Testing Unicode, spaces, repeat callbacks, and physical keys" -reset() -testWindow.runeInputEnabled = true -for (key, code, rune) in [ - ("é", "KeyE", 0xe9), ("😀", "KeyQ", 0x1f600), - (" ", "Space", 32), ("z", "KeyW", 'z'.ord) -]: - var event = keyboard(key, code, charCode = rune) - let button = keyEventToButton(addr event) - doAssert event.down() == 0 - event.text() - event.repeat = true - doAssert event.down() == 0 - event.text() - event.up() - doAssert names()[^5 .. ^1] == @[ - "down " & $button, "text " & key, - "down " & $button, "text " & key, "up " & $button - ] - -echo "Testing shortcut modifiers and complete between-frame chords" -for (key, code, modifier) in [ - ("Control", "ControlLeft", KeyLeftControl), - ("Control", "ControlRight", KeyRightControl), - ("Meta", "MetaLeft", KeyLeftSuper), - ("Meta", "MetaRight", KeyRightSuper) -]: - reset() - testWindow.runeInputEnabled = true - let super = modifier in {KeyLeftSuper, KeyRightSuper} - var - press = keyboard(key, code, control = not super, super = super) - shortcut = keyboard( - "a", "KeyA", control = not super, super = super, charCode = 'a'.ord - ) - release = keyboard(key, code) - doAssert press.down() == 1 - doAssert shortcut.down() == 1 - shortcut.text() - shortcut.up() - release.up() - doAssert names() == @[ - "down " & $modifier, "down KeyA", "up KeyA", "up " & $modifier - ] - doAssert modifier in records[1].down - doAssert modifier notin records[^1].down - doAssert testWindow.state.buttonDown == {} - -echo "Testing modifiers held before canvas focus and both Shift keys" -reset() -var shifted = keyboard("A", "KeyA", shift = true, super = true) -discard shifted.down() -doAssert records[^1].name == "down KeyA" -doAssert {KeyLeftShift, KeyLeftSuper, KeyA} <= records[^1].down -var plain = keyboard("a", "KeyA") -plain.up() -doAssert testWindow.state.buttonDown == {} -reset() -var - leftShift = keyboard("Shift", "ShiftLeft", shift = true) - rightShift = keyboard("Shift", "ShiftRight", shift = true) -discard leftShift.down() -discard rightShift.down() -leftShift.up() -doAssert testWindow.buttonDown[KeyRightShift] -doAssert not testWindow.buttonDown[KeyLeftShift] -rightShift.shiftKey = false -rightShift.up() -doAssert testWindow.state.buttonDown == {} - -echo "Testing AltGr and dead keys without shortcut runes" -reset() -testWindow.runeInputEnabled = true -var - altGraph = keyboard("@", "KeyQ", control = true, alt = true, charCode = 64) - dead = keyboard("Dead", "Quote") -doAssert altGraph.down() == 0 -altGraph.text() -doAssert records[^1].name == "text @" -doAssert dead.down() == 0 -for (key, code) in [ - ("Enter", "Enter"), ("Tab", "Tab"), ("ArrowLeft", "ArrowLeft") -]: - var event = keyboard(key, code) - doAssert event.down() == 1 - -echo "Testing key mapping, unknown keys, and legacy modifiers" -for (code, expected) in [ - ("ShiftRight", KeyRightShift), ("AltRight", KeyRightAlt), - ("ControlRight", KeyRightControl), ("MetaRight", KeyRightSuper), - ("Numpad0", Numpad0), ("Numpad9", Numpad9), - ("NumpadEnter", NumpadEnter), ("NumpadAdd", NumpadAdd), - ("F1", KeyF1), ("F12", KeyF12), ("ContextMenu", KeyMenu) -]: - var event = keyboard("", code) - doAssert keyEventToButton(addr event) == expected -reset() -var unknown = keyboard("Unidentified", "Unidentified") -discard unknown.down() -unknown.up() -doAssert records.len == 0 -unknown.keyCode = 17 -unknown.location = 2 -doAssert keyEventToButton(addr unknown) == KeyRightControl - -echo "Testing missing Command releases and focus loss" -reset() -var - command = keyboard("Meta", "MetaLeft", super = true) - shortcut = keyboard("a", "KeyA", super = true) -discard command.down() -discard shortcut.down() -command.metaKey = false -command.up() -doAssert testWindow.state.buttonDown == {} -doAssert names() == @[ - "down KeyLeftSuper", "down KeyA", "up KeyLeftSuper", "up KeyA" -] -reset() -discard shifted.down() -discard onBlur(0, nil, cast[pointer](testWindow)) -doAssert records[^1].name == "focus" and records[^1].down == {} -doAssert testWindow.state.buttonDown == {} -discard onBlur(0, nil, cast[pointer](testWindow)) -doAssert records[^2].name == "focus" - -echo "Windy browser input tests passed" diff --git a/tests/test_browser_inputs.nims b/tests/test_browser_inputs.nims deleted file mode 100644 index 2ee823dc..00000000 --- a/tests/test_browser_inputs.nims +++ /dev/null @@ -1,17 +0,0 @@ -when defined(emscripten): - switch("os", "linux") - switch("cpu", "wasm32") - switch("cc", "clang") - switch("clang.exe", "emcc") - switch("clang.linkerexe", "emcc") - switch("mm", "arc") - switch("exceptions", "goto") - switch("define", "noSignalHandler") - switch("nimcache", "tmp/browser-inputs") - switch("out", "tmp/test_browser_inputs.cjs") - switch( - "passL", - "-s SINGLE_FILE=1 " & - "-s ENVIRONMENT=node -s EXIT_RUNTIME=1 -s ALLOW_MEMORY_GROWTH " & - "-s EXPORTED_FUNCTIONS=_main,_malloc,_free" - )