Skip to content

WiVRnKeyboardMap.kt

src/jvmMain/kotlin/afield/media/browser/WiVRnKeyboardMap.kt · 167 lines

@file:afield.runtime.completion.CompletionCategory("Internal")
package afield.media.browser

import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemoryLayout
import java.lang.foreign.MemorySegment
import java.lang.foreign.SymbolLookup
import java.lang.foreign.ValueLayout.*

internal class WiVRnKeyboardMap

Layout-aware evdev keys, including AltGr, dead keys and Compose, without a desktop seat.

internal class WiVRnKeyboardMap(
    layout: String = System.getenv("FIELD_VR_KEYBOARD_LAYOUT") ?: "us",
    variant: String = System.getenv("FIELD_VR_KEYBOARD_VARIANT") ?: "",
    options: String = System.getenv("FIELD_VR_KEYBOARD_OPTIONS") ?: "",
) : AutoCloseable {
    private val linker = Linker.nativeLinker()
    private val library = SymbolLookup.libraryLookup("libxkbcommon.so.0", Arena.global())
    private fun fn(name: String, ret: MemoryLayout?, vararg args: MemoryLayout) = linker.downcallHandle(
        library.find(name).orElseThrow(), if (ret == null) FunctionDescriptor.ofVoid(*args) else FunctionDescriptor.of(ret, *args))
    private val contextNew = fn("xkb_context_new", ADDRESS, JAVA_INT)
    private val contextFree = fn("xkb_context_unref", null, ADDRESS)
    private val keymapNew = fn("xkb_keymap_new_from_names", ADDRESS, ADDRESS, ADDRESS, JAVA_INT)
    private val keymapFree = fn("xkb_keymap_unref", null, ADDRESS)
    private val stateNew = fn("xkb_state_new", ADDRESS, ADDRESS)
    private val stateFree = fn("xkb_state_unref", null, ADDRESS)
    private val update = fn("xkb_state_update_key", JAVA_INT, ADDRESS, JAVA_INT, JAVA_INT)
    private val keySym = fn("xkb_state_key_get_one_sym", JAVA_INT, ADDRESS, JAVA_INT)
    private val keyText = fn("xkb_state_key_get_utf8", JAVA_INT, ADDRESS, JAVA_INT, ADDRESS, JAVA_LONG)
    private val modActive = fn("xkb_state_mod_name_is_active", JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)
    private val symName = fn("xkb_keysym_get_name", JAVA_INT, JAVA_INT, ADDRESS, JAVA_LONG)
    private val symUnicode = fn("xkb_keysym_to_utf32", JAVA_INT, JAVA_INT)
    private val composeTableNew = fn("xkb_compose_table_new_from_locale", ADDRESS, ADDRESS, ADDRESS, JAVA_INT)
    private val composeTableFree = fn("xkb_compose_table_unref", null, ADDRESS)
    private val composeNew = fn("xkb_compose_state_new", ADDRESS, ADDRESS, JAVA_INT)
    private val composeFree = fn("xkb_compose_state_unref", null, ADDRESS)
    private val composeFeed = fn("xkb_compose_state_feed", JAVA_INT, ADDRESS, JAVA_INT)
    private val composeStatus = fn("xkb_compose_state_get_status", JAVA_INT, ADDRESS)
    private val composeText = fn("xkb_compose_state_get_utf8", JAVA_INT, ADDRESS, ADDRESS, JAVA_LONG)
    private val composeReset = fn("xkb_compose_state_reset", null, ADDRESS)
    private val context = (contextNew.invokeExact(0) as MemorySegment).also { check(it != MemorySegment.NULL) }
    private val keymap: MemorySegment
    private var state: MemorySegment
    private val table: MemorySegment
    private val compose: MemorySegment
    private var closed = false

init

    init {
        keymap = Arena.ofConfined().use { arena ->
            val names = arena.allocate(5 * ADDRESS.byteSize(), ADDRESS.byteAlignment())
            listOf("evdev", "pc105", layout, variant, options).forEachIndexed { index, value ->
                names.set(ADDRESS, index * ADDRESS.byteSize(), arena.allocateFrom(value))
            }
            keymapNew.invokeExact(context, names, 0) as MemorySegment
        }
        if (keymap == MemorySegment.NULL) {
            contextFree.invokeExact(context)
            throw IllegalArgumentException("Cannot load XKB layout $layout ($variant, $options)")
        }
        state = stateNew.invokeExact(keymap) as MemorySegment
        check(state != MemorySegment.NULL)
        table = Arena.ofConfined().use { arena ->
            val locale = listOf("LC_ALL", "LC_CTYPE", "LANG").firstNotNullOfOrNull { System.getenv(it)?.takeIf(String::isNotBlank) }
                ?: "C.UTF-8"
            composeTableNew.invokeExact(context, arena.allocateFrom(locale), 0) as MemorySegment
        }
        compose = if (table == MemorySegment.NULL) MemorySegment.NULL else composeNew.invokeExact(table, 0) as MemorySegment
    }

fun update(code: Int, down: Boolean)

    fun update(code: Int, down: Boolean) { update.invokeExact(state, code + 8, if (down) 1 else 0) as Int }

fun modifiers(): Set<BrowserModifier>

    fun modifiers(): Set<BrowserModifier> = Arena.ofConfined().use { arena ->
        buildSet {
            for ((name, modifier) in listOf("Shift" to BrowserModifier.SHIFT, "Control" to BrowserModifier.CONTROL,
                "Mod1" to BrowserModifier.ALT, "Mod4" to BrowserModifier.META)) {
                if ((modActive.invokeExact(state, arena.allocateFrom(name), 8) as Int) > 0) add(modifier)
            }
        }
    }

fun key(code: Int, repeat: Boolean, modifiers: Set<BrowserModifier>): BrowserKeyEvent

Return keydown metadata and printable text together, so Chromium honors preventDefault.

    fun key(code: Int, repeat: Boolean, modifiers: Set<BrowserModifier>): BrowserKeyEvent = Arena.ofConfined().use { arena ->
        val physical = evdevKeys[code] ?: Key("Unidentified", "Unidentified", 0)
        val buffer = arena.allocate(256)
        val sym = keySym.invokeExact(state, code + 8) as Int
        val named = (symName.invokeExact(sym, buffer, buffer.byteSize()) as Int).let { if (it > 0) buffer.getString(0) else "" }
        val utf8 = (keyText.invokeExact(state, code + 8, buffer, buffer.byteSize()) as Int).let { if (it > 0) buffer.getString(0) else "" }
        val unicode = symUnicode.invokeExact(sym) as Int
        val logical = if (unicode in 32..0x10ffff && unicode != 127) String(Character.toChars(unicode)) else ""
        var text = utf8.takeIf { it.isNotEmpty() && it.first() >= ' ' } ?: ""
        val key = when {
            named.startsWith("dead_") -> "Dead"
            named == "ISO_Level3_Shift" -> "AltGraph"
            named == "Multi_key" -> "Compose"
            logical.isNotEmpty() -> logical
            else -> physical.key
        }
        if (compose != MemorySegment.NULL) {
            composeFeed.invokeExact(compose, sym) as Int
            when (composeStatus.invokeExact(compose) as Int) {
                1 -> text = "" // composing
                2 -> {
                    text = (composeText.invokeExact(compose, buffer, buffer.byteSize()) as Int)
                        .let { if (it > 0) buffer.getString(0) else "" }
                    composeReset.invokeExact(compose)
                }
                3 -> { text = ""; composeReset.invokeExact(compose) }
            }
        }
        if (physical.key == "Enter") text = "\r"
        if (modifiers.any { it == BrowserModifier.CONTROL || it == BrowserModifier.META || it == BrowserModifier.ALT }) text = ""
        val virtualKey = if (key.length == 1 && key[0].isLetter() && key[0].code < 128) key[0].uppercaseChar().code else physical.virtualKey
        BrowserKeyEvent(if (text.isEmpty()) BrowserKeyEventType.RAW_KEY_DOWN else BrowserKeyEventType.KEY_DOWN,
            key, physical.code, modifiers, windowsVirtualKeyCode = virtualKey, nativeVirtualKeyCode = code,
            autoRepeat = repeat, keypad = physical.code.startsWith("Numpad"), text = text.takeIf(String::isNotEmpty))
    }

fun reset()

    fun reset() {
        stateFree.invokeExact(state)
        state = stateNew.invokeExact(keymap) as MemorySegment
        check(state != MemorySegment.NULL)
        if (compose != MemorySegment.NULL) composeReset.invokeExact(compose)
    }

override fun close()

    override fun close() {
        if (closed) return
        closed = true
        if (compose != MemorySegment.NULL) composeFree.invokeExact(compose)
        if (table != MemorySegment.NULL) composeTableFree.invokeExact(table)
        stateFree.invokeExact(state)
        keymapFree.invokeExact(keymap)
        contextFree.invokeExact(context)
    }

    private data class Key(val key: String, val code: String, val virtualKey: Int)

companion object

    companion object {
        private val evdevKeys: Map<Int, Key> = buildMap {
            fun add(scan: Int, key: String, code: String = key, vk: Int = 0) { put(scan, Key(key, code, vk)) }
            add(1,"Escape",vk=27)
            "1234567890".forEachIndexed { i,c -> add(i+2,"$c","Digit$c",c.code) }
            add(12,"-","Minus",189); add(13,"=","Equal",187); add(14,"Backspace",vk=8); add(15,"Tab",vk=9)
            listOf(16 to "qwertyuiop",30 to "asdfghjkl",44 to "zxcvbnm").forEach { (start,row) ->
                row.forEachIndexed { i,c -> add(start+i,"$c","Key${c.uppercaseChar()}",c.uppercaseChar().code) }
            }
            add(26,"[","BracketLeft",219);add(27,"]","BracketRight",221);add(28,"Enter",vk=13)
            add(29,"Control","ControlLeft",17);add(39,";","Semicolon",186);add(40,"'","Quote",222)
            add(41,"`","Backquote",192);add(42,"Shift","ShiftLeft",16);add(43,"\\","Backslash",220)
            add(51,",","Comma",188);add(52,".","Period",190);add(53,"/","Slash",191)
            add(54,"Shift","ShiftRight",16);add(55,"*","NumpadMultiply",106);add(56,"Alt","AltLeft",18)
            add(57," ","Space",32);add(58,"CapsLock",vk=20)
            for (i in 1..10) add(58+i,"F$i",vk=111+i)
            add(69,"NumLock",vk=144);add(70,"ScrollLock",vk=145)
            for ((scan,digit) in listOf(71 to 7,72 to 8,73 to 9,75 to 4,76 to 5,77 to 6,79 to 1,80 to 2,81 to 3,82 to 0))
                add(scan,"$digit","Numpad$digit",96+digit)
            add(74,"-","NumpadSubtract",109);add(78,"+","NumpadAdd",107);add(83,".","NumpadDecimal",110)
            add(86,"\\","IntlBackslash",226);add(87,"F11",vk=122);add(88,"F12",vk=123)
            add(96,"Enter","NumpadEnter",13);add(97,"Control","ControlRight",17);add(98,"/","NumpadDivide",111)
            add(99,"PrintScreen",vk=44);add(100,"Alt","AltRight",18);add(102,"Home",vk=36)
            add(103,"ArrowUp",vk=38);add(104,"PageUp",vk=33);add(105,"ArrowLeft",vk=37);add(106,"ArrowRight",vk=39)
            add(107,"End",vk=35);add(108,"ArrowDown",vk=40);add(109,"PageDown",vk=34)
            add(110,"Insert",vk=45);add(111,"Delete",vk=46);add(119,"Pause",vk=19)
            add(125,"Meta","MetaLeft",91);add(126,"Meta","MetaRight",92);add(127,"ContextMenu",vk=93)
        }
    }
}