diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index daf5768..a311b1f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -72,7 +72,9 @@ class MainActivity : ComponentActivity() { KeyEvent.ACTION_UP -> false else -> return super.dispatchKeyEvent(event) } - val vk = Keymap.toVk(event.keyCode) + // Full-event overload: evdev scancode first (positional under ANY selected + // physical-keyboard layout), keycode fallback — see Keymap docs. + val vk = Keymap.toVk(event) if (vk != 0) { NativeBridge.nativeSendKey(handle, vk, down, 0) return true // consumed — don't let the system also act on it diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt index f1b8a55..d534de5 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Keymap.kt @@ -3,13 +3,79 @@ package io.unom.punktfunk.kit import android.view.KeyEvent /** - * Android `KEYCODE_*` → Windows Virtual-Key code (the punktfunk wire contract; the host maps VK → - * evdev via `inject::vk_to_evdev`). The Android analogue of the Linux client's evdev→VK table - * (`punktfunk-client-linux/src/keymap.rs`) and the Apple client's `hidToVK`. Positional/US-layout — - * we forward the physical key, not the typed character. Unmapped keys → 0 (the Rust side drops them). - * Extend this alongside `punktfunk-host/src/inject.rs::vk_to_evdev` (emit only VKs the host knows). + * Hardware key → Windows Virtual-Key code (the punktfunk wire contract: **US-positional** — we + * forward the physical key, not the typed character; the host maps VK → evdev via + * `inject::vk_to_evdev`). The Android analogue of the Linux client's evdev→VK table + * (`punktfunk-client-linux/src/keymap.rs`) and the Apple client's `hidToVK`. + * + * Prefer [toVk] with the full [KeyEvent]: it reads the raw evdev scancode first, because + * `KeyEvent.keyCode` is only positional under the stock US key layout — a user-selected physical + * keyboard layout (Settings → Physical keyboard) remaps keycodes semantically (AOSP's German .kcm + * carries `map key 21 Z` / `map key 44 Y`), which would apply the layout twice: once here, once on + * the host (the y↔z / ü-on-ö scramble). Unmapped keys → 0 (the Rust side drops them). Extend this + * alongside `punktfunk-host/src/inject.rs::vk_to_evdev` (emit only VKs the host knows). */ object Keymap { + /** + * Positional wire VK for a hardware key event: the evdev scancode table first (immune to the + * selected physical-keyboard layout), falling back to the keycode table for events without a + * scancode (soft keyboards, synthetic events) and for everything outside the typing area + * (layout-invariant there, incl. gamepad buttons whose scancodes lie outside the table). + */ + fun toVk(event: KeyEvent): Int { + val positional = evdevToVk(event.scanCode) + return if (positional != 0) positional else toVk(event.keyCode) + } + + /** + * Linux evdev keycode (`KeyEvent.scanCode`) → US-positional VK for the layout-**variant** + * typing area — the same 48-key table as the Linux client's `evdev_to_vk` and the hosts' + * fixed tables. Everything else → 0 (the keycode path is already positional for those). + */ + fun evdevToVk(scan: Int): Int = when (scan) { + in 2..10 -> 0x31 + (scan - 2) // KEY_1..KEY_9 + 11 -> 0x30 // KEY_0 + 12 -> 0xBD // KEY_MINUS -_ VK_OEM_MINUS (DE: ß) + 13 -> 0xBB // KEY_EQUAL =+ VK_OEM_PLUS + 16 -> 0x51 // Q + 17 -> 0x57 // W + 18 -> 0x45 // E + 19 -> 0x52 // R + 20 -> 0x54 // T + 21 -> 0x59 // KEY_Y — US-Y position (QWERTZ: the Z key) + 22 -> 0x55 // U + 23 -> 0x49 // I + 24 -> 0x4F // O + 25 -> 0x50 // P + 26 -> 0xDB // KEY_LEFTBRACE [{ VK_OEM_4 (DE: ü) + 27 -> 0xDD // KEY_RIGHTBRACE ]} VK_OEM_6 + 30 -> 0x41 // A + 31 -> 0x53 // S + 32 -> 0x44 // D + 33 -> 0x46 // F + 34 -> 0x47 // G + 35 -> 0x48 // H + 36 -> 0x4A // J + 37 -> 0x4B // K + 38 -> 0x4C // L + 39 -> 0xBA // KEY_SEMICOLON ;: VK_OEM_1 (DE: ö) + 40 -> 0xDE // KEY_APOSTROPHE '" VK_OEM_7 (DE: ä) + 41 -> 0xC0 // KEY_GRAVE `~ VK_OEM_3 (DE: ^) + 43 -> 0xDC // KEY_BACKSLASH \| VK_OEM_5 + 44 -> 0x5A // KEY_Z — US-Z position (QWERTZ: the Y key) + 45 -> 0x58 // X + 46 -> 0x43 // C + 47 -> 0x56 // V + 48 -> 0x42 // B + 49 -> 0x4E // N + 50 -> 0x4D // M + 51 -> 0xBC // KEY_COMMA ,< VK_OEM_COMMA + 52 -> 0xBE // KEY_DOT .> VK_OEM_PERIOD + 53 -> 0xBF // KEY_SLASH /? VK_OEM_2 + 86 -> 0xE2 // KEY_102ND <>| VK_OEM_102 (ISO) + else -> 0 + } + fun toVk(keyCode: Int): Int = when (keyCode) { in KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z -> 0x41 + (keyCode - KeyEvent.KEYCODE_A) // A–Z in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 0x30 + (keyCode - KeyEvent.KEYCODE_0) // 0–9 row diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/KeymapTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/KeymapTest.kt new file mode 100644 index 0000000..90f07cd --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/KeymapTest.kt @@ -0,0 +1,43 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pure JVM test of the positional scancode table (`Keymap.evdevToVk`) — no Android runtime types + * (the `KeyEvent` constants in the keycode table are compile-time-inlined ints). Run: + * `./gradlew :kit:testDebugUnitTest`. + */ +class KeymapTest { + /** + * The German-scramble regression pins: the physical keys a QWERTZ board labels Z/Y/ö/ü/ä/ß + * must leave this client as their US-position VKs, regardless of the user-selected physical + * keyboard layout (which remaps `keyCode`, not `scanCode`). + */ + @Test + fun positionalPinsForTheQwertzScramble() { + assertEquals(0x59, Keymap.evdevToVk(21)) // KEY_Y (QWERTZ: Z key) → VK_Y + assertEquals(0x5A, Keymap.evdevToVk(44)) // KEY_Z (QWERTZ: Y key) → VK_Z + assertEquals(0xBA, Keymap.evdevToVk(39)) // KEY_SEMICOLON (QWERTZ: ö) → VK_OEM_1 + assertEquals(0xDB, Keymap.evdevToVk(26)) // KEY_LEFTBRACE (QWERTZ: ü) → VK_OEM_4 + assertEquals(0xDE, Keymap.evdevToVk(40)) // KEY_APOSTROPHE (QWERTZ: ä) → VK_OEM_7 + assertEquals(0xBD, Keymap.evdevToVk(12)) // KEY_MINUS (QWERTZ: ß) → VK_OEM_MINUS + } + + /** + * Exactly the 48 typing-area keys are covered (10 digits + 26 letters + 12 OEM) with unique + * VKs; everything else (nav, F-row, modifiers, gamepad buttons at 0x100+) falls through to + * the keycode table. + */ + @Test + fun tableCoversTheTypingAreaBijectively() { + val mapped = (0..0x200).mapNotNull { sc -> + Keymap.evdevToVk(sc).takeIf { it != 0 }?.let { sc to it } + } + assertEquals(48, mapped.size) + assertEquals(48, mapped.map { it.second }.toSet().size) + assertEquals(0, Keymap.evdevToVk(1)) // KEY_ESC — layout-invariant, keycode path + assertEquals(0, Keymap.evdevToVk(59)) // KEY_F1 + assertEquals(0, Keymap.evdevToVk(304)) // BTN_SOUTH — gamepad, never a typing key + } +} diff --git a/crates/punktfunk-host/src/inject/linux/wlr.rs b/crates/punktfunk-host/src/inject/linux/wlr.rs index 248a619..a9e6443 100644 --- a/crates/punktfunk-host/src/inject/linux/wlr.rs +++ b/crates/punktfunk-host/src/inject/linux/wlr.rs @@ -1,9 +1,10 @@ //! Input injection through the wlroots virtual-input Wayland protocols //! (`zwlr_virtual_pointer_manager_v1` + `zwp_virtual_keyboard_manager_v1`) — the headless-Sway //! path. We connect as an ordinary Wayland client (the host inherits Sway's -//! `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, upload a standard evdev/US xkb -//! keymap, and translate events into virtual pointer/keyboard requests, tracking modifier state -//! so the compositor resolves shifted keysyms correctly. +//! `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, upload an xkb keymap for the +//! virtual keyboard (the host's layout via the standard `XKB_DEFAULT_LAYOUT` et al., defaulting +//! to evdev/US), and translate events into virtual pointer/keyboard requests, tracking modifier +//! state so the compositor resolves shifted keysyms correctly. // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] @@ -133,18 +134,20 @@ impl WlrootsInjector { ); let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ()); - // A standard evdev/US keymap so raw evdev keycodes resolve to the right keysyms. + // The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to + // the standard `XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS` env vars, then to + // libxkbcommon's built-ins (evdev/pc105/us) — so a non-US host sets e.g. + // `XKB_DEFAULT_LAYOUT=de` and the positional wire keys render as its layout (parity with + // the libei path, where the session compositor's own keymap applies). Previously this + // hardcoded "us", which forced US characters for the OEM/umlaut keys on every layout. let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS); - let keymap = xkb::Keymap::new_from_names( - &ctx, - "evdev", - "pc105", - "us", - "", - None, - xkb::KEYMAP_COMPILE_NO_FLAGS, - ) - .context("compile xkb keymap")?; + let keymap = + xkb::Keymap::new_from_names(&ctx, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS) + .context("compile xkb keymap (check XKB_DEFAULT_LAYOUT/VARIANT/RULES if set)")?; + tracing::info!( + layout = %std::env::var("XKB_DEFAULT_LAYOUT").unwrap_or_else(|_| "us (default)".into()), + "virtual keyboard keymap compiled" + ); let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1); let xkb_state = xkb::State::new(&keymap);