Skip to content

WiVRnBrowserInput.kt

src/jvmMain/kotlin/afield/media/browser/WiVRnBrowserInput.kt · 208 lines

package afield.media.browser

class WiVRnBrowserInput internal constructor

Exclusive, focus-independent Linux input from WiVRn's virtual keyboard and mouse (or, through the internal source constructor, from a paired headset app; see PairedInputSource). The mouse lives in browser coordinates, so moving a VR panel never moves its cursor onto another app. Call poll once per graphics frame; close releases keys, buttons and evdev grabs. See tools/setup_wivrn_input.py for the narrowly scoped device permissions.

class WiVRnBrowserInput internal constructor(
    private val browser: PuppetBrowserControl,
    viewport: ExternalBrowserSurfaceSpec,
    private val source: WiVRnEventSource,
    layout: String,
    variant: String,
    options: String,
    private val pointerActivity: () -> Unit,
    private val otherModifiers: () -> Set<BrowserModifier>,
) : AutoCloseable {

internal constructor

Another evdev-shaped source, such as PairedInputSource, through the same layout and cursor path.

    internal constructor(
        browser: PuppetBrowserControl,
        viewport: ExternalBrowserSurfaceSpec,
        source: WiVRnEventSource,
        pointerActivity: () -> Unit,
        otherModifiers: () -> Set<BrowserModifier>,
    ) : this(browser, viewport, source, System.getenv("FIELD_VR_KEYBOARD_LAYOUT") ?: "us",
        System.getenv("FIELD_VR_KEYBOARD_VARIANT") ?: "", System.getenv("FIELD_VR_KEYBOARD_OPTIONS") ?: "",
        pointerActivity, otherModifiers)

constructor

    constructor(
        browser: PuppetBrowserControl,
        viewport: ExternalBrowserSurfaceSpec,
        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") ?: "",
        pointerActivity: () -> Unit = {},
        otherModifiers: () -> Set<BrowserModifier> = { emptySet() },
    ) : this(browser, viewport, WiVRnDeviceReader(), layout, variant, options, pointerActivity, otherModifiers) {
        require(System.getProperty("os.name").startsWith("Linux")) { "WiVRn evdev input requires Linux" }
    }

val uv = PuppetBrowserUvInput(browser, viewport)

    private val mapDelegate = lazy { WiVRnKeyboardMap(layout, variant, options) }
    private val keymap by mapDelegate
    private val keys = linkedMapOf<Int, BrowserKeyEvent>()
    private val blockedKeys = mutableSetOf<Int>()
    private val mouseDown = mutableSetOf<Int>()
    private val blockedButtons = mutableSetOf<Int>()
    private var dx = 0.0
    private var dy = 0.0
    private var wheelX = 0.0
    private var wheelY = 0.0
    private var highWheelX: Double? = null
    private var highWheelY: Double? = null
    private var enabled = true
    private var closed = false
    private var failure: String? = null

val modifiers: Set<BrowserModifier> get()

    val modifiers: Set<BrowserModifier> get() = if (mapDelegate.isInitialized()) keymap.modifiers() else emptySet()

val capturedDevices: Set<String> get() = source.captured.map { it.deviceName }.toSet()

val status: String get()

    val status: String get() = when {
        closed -> "Headset input closed"
        failure != null -> "Headset input: $failure"
        source.status != null -> source.status!!
        source.problem != null -> source.problem!!
        source.captured.size == 2 -> "Headset keyboard + mouse captured · desktop focus independent"
        source.captured.isNotEmpty() -> "Captured ${capturedDevices.joinToString()} · waiting for the other device"
        else -> "Waiting for WiVRn keyboard / mouse · HID forwarding must be enabled"
    }

fun poll(deliver: Boolean = true)

Poll even during page loads: input stays captured, but is discarded until the page is ready.

    fun poll(deliver: Boolean = true) {
        if (closed || failure != null) return
        try {
            if (enabled != deliver) {
                blockedKeys += keys.keys
                releaseKeyboard()
                releasePointer()
                enabled = deliver
            }
            val events = source.poll()
            source.takeResets().forEach { (kind, held) ->
                when (kind) {
                    WiVRnDevice.KEYBOARD -> { releaseKeyboard(); blockedKeys.clear(); blockedKeys += held }
                    WiVRnDevice.MOUSE -> { releasePointer(); blockedButtons.clear(); blockedButtons += held; mouseDown.clear() }
                }
            }
            events.forEach { (kind, event) ->
                if (kind == WiVRnDevice.KEYBOARD) keyboard(event) else mouse(event)
            }
        } catch (error: Throwable) {
            if (error is VirtualMachineError) throw error
            failure = error.message ?: error.javaClass.simpleName
            try { releaseKeyboard(); releasePointer() } finally { source.close() }
        }
    }

fun placeCursor(u: Double, v: Double) { uv.move(u.coerceIn(0.0, 1.0), v.coerceIn(0.0, 1.0)) }

Adopt the controller's last hit when handing the relative mouse the shared cursor.

source — 68 lines, from line 102
    private fun keyboard(event: EvdevEvent) {
        if (event.type != 1 || event.value !in 0..2) return
        val code = event.code
        if (code in blockedKeys) {
            if (event.value == 0) blockedKeys.remove(code)
            return
        }
        if (!enabled) { if (event.value != 0) blockedKeys += code; return }
        if (event.value == 0) {
            val previous = keys.remove(code) ?: return
            keymap.update(code, false)
            browser.key(previous.copy(type = BrowserKeyEventType.KEY_UP, modifiers = modifiers + otherModifiers(),
                text = null, unmodifiedText = null, autoRepeat = false))
        } else {
            val repeat = event.value == 2
            if (repeat && code !in keys) return
            if (!repeat && code in keys) return
            if (!repeat) keymap.update(code, true)
            val key = keymap.key(code, repeat, modifiers + otherModifiers())
            keys[code] = key
            browser.focus()
            browser.key(key)
        }
    }

    private fun mouse(event: EvdevEvent) {
        when (event.type) {
            2 -> when (event.code) {
                0 -> dx += event.value
                1 -> dy += event.value
                6 -> wheelX += event.value
                8 -> wheelY += event.value
                11 -> highWheelY = (highWheelY ?: 0.0) + event.value
                12 -> highWheelX = (highWheelX ?: 0.0) + event.value
            }
            0 -> if (event.code == 0) flushMouse()
            1 -> {
                val button = mouseButton(event.code) ?: return
                flushMouse()
                if (event.value == 0) mouseDown.remove(event.code) else mouseDown += event.code
                if (event.code in blockedButtons) {
                    if (event.value == 0) blockedButtons.remove(event.code)
                    return
                }
                if (!enabled) { if (event.value != 0) blockedButtons += event.code; return }
                pointerActivity()
                uv.modifiers = modifiers + otherModifiers()
                if (event.value == 1 && uv.buttons and button.cdpBit == 0) uv.down(button)
                else if (event.value == 0 && uv.buttons and button.cdpBit != 0) uv.up(button)
            }
        }
    }

    private fun flushMouse() {
        if (enabled && (dx != 0.0 || dy != 0.0 || wheelX != 0.0 || wheelY != 0.0 || highWheelX != null || highWheelY != null)) {
            pointerActivity()
            uv.modifiers = modifiers + otherModifiers()
            val (u, v) = uv.position
            if (dx != 0.0 || dy != 0.0) uv.move(
                (u * uv.cssWidth + dx).coerceIn(0.0, (uv.cssWidth - 1).coerceAtLeast(0.0)) / uv.cssWidth,
                (v * uv.cssHeight + dy).coerceIn(0.0, (uv.cssHeight - 1).coerceAtLeast(0.0)) / uv.cssHeight)
            val x = highWheelX?.div(120) ?: wheelX
            val y = highWheelY?.div(120) ?: wheelY
            if (x != 0.0 || y != 0.0) uv.wheel(x * 40, -y * 40)
        }
        dx = 0.0; dy = 0.0; wheelX = 0.0; wheelY = 0.0
        highWheelX = null; highWheelY = null
    }

fun releasePointer()

Another explicit pointer press may take over; held buttons then require a fresh press.

    fun releasePointer() {
        blockedButtons += mouseDown
        uv.releaseAll()
        dx = 0.0; dy = 0.0; wheelX = 0.0; wheelY = 0.0
        highWheelX = null; highWheelY = null
    }

    private fun releaseKeyboard() {
        keys.values.toList().asReversed().forEach {
            browser.key(it.copy(type = BrowserKeyEventType.KEY_UP, modifiers = emptySet(), text = null,
                unmodifiedText = null, autoRepeat = false))
        }
        keys.clear()
        if (mapDelegate.isInitialized()) keymap.reset()
    }

override fun close()

    override fun close() {
        if (closed) return
        closed = true
        try {
            releaseKeyboard()
            releasePointer()
        } finally {
            try { source.close() } finally { if (mapDelegate.isInitialized()) keymap.close() }
        }
    }

    private fun mouseButton(code: Int): BrowserMouseButton? = when (code) {
        272 -> BrowserMouseButton.LEFT
        273 -> BrowserMouseButton.RIGHT
        274 -> BrowserMouseButton.MIDDLE
        275 -> BrowserMouseButton.BACK
        276 -> BrowserMouseButton.FORWARD
        else -> null
    }
}