fix(host/linux,clients/android): honor the host/device keyboard layout in keymaps
apple / swift (push) Successful in 1m5s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 56s
ci / rust (push) Successful in 7m4s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m16s
windows-host / package (push) Successful in 7m53s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m22s
release / apple (push) Successful in 8m22s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m20s
ci / bench (push) Successful in 4m43s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 3m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m9s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m11s
apple / screenshots (push) Successful in 5m34s
flatpak / build-publish (push) Successful in 4m33s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m33s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m12s

wlroots injector: the virtual keyboard keymap now defers to the standard
XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS env vars (libxkbcommon
built-ins as fallback) instead of hardcoding evdev/pc105/us, matching the
libei path where the session compositor's own keymap applies. Android:
Keymap gains the same positional-key coverage for non-US layouts (+ tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:25:07 +02:00
parent a4c84ac620
commit 5ef63756ea
4 changed files with 134 additions and 20 deletions
@@ -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
@@ -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) // AZ
in KeyEvent.KEYCODE_0..KeyEvent.KEYCODE_9 -> 0x30 + (keyCode - KeyEvent.KEYCODE_0) // 09 row
@@ -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
}
}
+17 -14
View File
@@ -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);