diff --git a/docs/design.md b/docs/design.md index fb2ef2f..faccfd4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -117,6 +117,10 @@ An ungrabbed keyboard reaches both sides at once. In VR, gamescope reads every i Volume keys must never reach gamescope. With the openvr backend, gamescope sends volume up and down to Steam by moving keyboard focus to Steam for the key and then back to the previously focused surface. When nothing had focus, the one it moves back to is null, and wlroots aborts on a null focus surface (`wlr_seat_keyboard_notify_enter: Assertion 'surface' failed`), which ends the whole VR session. Keyboard focus is often empty while you work in VR, so one press of the headset's volume button could take everything down. gamescope reads the headset's buttons and every keyboard itself (`InputStealer`), as do SteamVR's processes, so the relay has to stop volume keys at the device. Grabbing `gpio-keys` would also take the headset's click button, so the relay remaps the volume entries in each device's keymap (`EVIOCSKEYCODE`) and handles the stand-in codes itself. That fix covers every device at once, including keyboards that aren't grabbed. +Frametop's keyboard opens by itself for a text field on the desktop. The apps run inside the nested KWin, so only KWin knows when a text field has focus, and the way it tells anyone is its input method protocol (`zwp_input_method_v1`): KWin starts one input method program and activates it whenever the focused app turns on text input. `input/ft-textinput` is that program, speaking the Wayland wire protocol directly so it needs nothing but Python on the host. It only reports focus. The gamescope session puts `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` in the systemd user environment; with those, Qt and GTK apps use X input methods and never turn on Wayland text input, so the session script drops them. + +The keyboard itself is ft-screens' own panel (`screens/keyboard.cpp`). We tried SteamVR's first (`ShowKeyboardForOverlay`), and on the Frame it doesn't fit a desktop. It's Steam's own panel (`valve.steam.gamepadui.keyboard`), which SteamVR mounts in the dashboard's scene, so with the dashboard closed it opened but wasn't drawn. Placing it in the room ourselves (`SetKeyboardTransformAbsolute`) made it show, but SteamVR moves it to whichever overlay the laser goes to and mounts it again, and while it's open the controllers switch to SteamVR's own laser. Our panel is an overlay like the screens' controls: any laser or the 3D mouse clicks it, nothing moves it, and its keys go out as key presses on ft-screens' seat rather than as text handed back to the input method. So nothing typed leaves ft-screens (a socket to the input method could be claimed by any local process, like `@frametop_keys`), apps without text input (X11, Electron) take the keys too, and they mean what the desktop's keyboard layout says. It's drawn on the CPU and uploaded with `SetOverlayRaw` when a key's look changes; the labels come from stb_truetype, so the container needs no text rendering stack. + The Frame controllers can be mapped like mouse buttons, but they aren't input devices on the host: they reach SteamVR over the headset's own radio, and no evdev or hidraw node exists for them. So only a SteamVR client can read them. Overlay apps normally get controller input only while they have input focus, which a background helper never has. SteamVR's experimental global action set priority (`steamvr/globalActionSetPriority`, "Enable global input from overlays") lets an overlay's action set receive input anyway, and takes the inputs it binds from the scene app. Binding every button would take them all from games, so the helper's action manifest puts each button in an action set of its own, and it activates only the sets of mapped buttons. The mapping itself stays in the relay, which does the action, so mice and controllers share one list of actions. ## The desktop session diff --git a/docs/reference.md b/docs/reference.md index a260ec2..f44f5f3 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -55,6 +55,8 @@ In the last three modes the hotkey shows the screens anyway. Two more settings o Input from the lasers reaches KWin through ft-screens' own seat. Keys come from the input relay, from pass-through keyboards and any key a pointer device passes through. Typing follows your last click: after a click on a screen it goes to the desktop, even with the SteamVR dashboard open, and after a mouse click on any other panel (the dashboard, Steam, an app like Spotify) it goes there instead. While it goes to the desktop, the relay grabs pass-through keyboards so gamescope, which reads every keyboard itself, doesn't type them into the Steam app too. A program that watches every keyboard for a hotkey loses a grabbed one; with `SHARE_KEYS=1` in `~/.config/frametop.conf`, their keys also go to `@frametop_keys` for it. That's off by default, since any local process that binds the name first would get everything typed into the desktop. Hidden screens don't take typing. +Frametop's keyboard opens by itself when a text field on the desktop gets focus, and stays open until its Close key, a layout reset, or a mapped button closes it (or, with Keep it open off in Frametop Input Settings, until the text field loses focus). While the Steam menu (the dashboard) or Steam's own keyboard is up, it steps aside, and it comes back where it was when they're gone; one asked for meanwhile appears then. In the "only with the dashboard" visibility mode, the dashboard doesn't count. It doesn't open without a head pose (the headset in standby). It's a panel of keys (a US laptop layout, with Esc where Caps Lock would be, arrows, and a Close key) that ft-screens shows 0.7 m in front of you and below your eyes, facing you. It stays where it opened, and its grab bar (the pill along the top) moves it like a screen's. Type on it with a controller's laser or the 3D mouse. Shift, Ctrl and Alt latch for the next key, and a held key repeats. KWin starts `input/ft-textinput` as the desktop's input method, and KWin activates it whenever the focused app turns on text input for a field. It tells the relay (`textfield 1` or `0`), the relay decides by the Keyboard setting in Frametop Input Settings, and ft-screens opens the keyboard for the screen that has keyboard focus (`vrkeyboard show`, `hide`, or `toggle` from a mapped button). Its keys reach the focused screen as key presses, so it works in every app, but only apps that use Wayland text input (Qt, GTK, Firefox) open it by themselves; Chromium, Electron and X11 apps need the button. The session drops the `QT_IM_MODULE=xim` and `GTK_IM_MODULE=xim` that the gamescope session sets, or Qt and GTK apps wouldn't use Wayland text input either. + KWin's nested backend doesn't undo a screen's scale on pointer input, so ft-screens divides panel positions (in pixels) by it. `ft-layout` sends it each screen's scale as KWin reports it (`scale N s`) whenever it applies scales: at desktop start and from Frametop Display Settings. A scale changed only in Plasma's own display settings is put back to the Frametop layout's the next time `ft-layout` runs. ft-screens listens for datagrams on the abstract socket `@ft_screens` and replies to the sender: @@ -62,7 +64,7 @@ ft-screens listens for datagrams on the abstract socket `@ft_screens` and replie ``` place N x y z yaw pitch roll width N metres curve N radius|on|off pin N|all left|right|head [matrix] unpin N|all size N w h -get N screens head state key code value scale N s +get N screens head state key code value scale N s vrkeyboard show|hide|toggle visibility always|dashboard|gesture|toggle wrist degrees gesture left|right degrees hide | show | toggle controllers always|outside_games|dashboard ingames hide|visible ``` @@ -106,11 +108,12 @@ The pointer settings are in `~/.config/frametop.conf`: `POINTER_SENSITIVITY`, `P ## Frametop Input Settings -A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has seven pages: +A Kirigami app with a Python backend, in the Plasma menu under Settings. It runs in the `dev` container and talks to the relay over its control socket, `@frametop_relay`. It has eight pages: - Devices lists every USB and Bluetooth mouse and keyboard, with a light that flashes when the device is used. Each device gets a role: 3D pointer (grabbed, drives the pointer; the default for anything with a mouse), Pass through (grabbed only while typing goes to the desktop; the default for keyboards, where a Meta tap toggles the dashboard if `META_DASHBOARD=1` is in `~/.config/frametop.conf`), or Ignore. A device is identified by its Bluetooth address, or its USB ids and name, so all of its input nodes share one role. Forget drops everything saved for a device. -- Buttons maps a pointer device's buttons. Choose Capture a button, press the button or key, then pick an action: a click, back, scroll, toggle dashboard, recenter, pointer on or off, head follow on or off, gaze pointer on or off, faster or slower, pass the key through, or nothing. Devices with saved mappings are listed even while they're asleep. +- Buttons maps a pointer device's buttons. Choose Capture a button, press the button or key, then pick an action: a click, back, scroll, toggle dashboard, recenter, pointer on or off, open or close the keyboard, head follow on or off, gaze pointer on or off, faster or slower, pass the key through, or nothing. Devices with saved mappings are listed even while they're asleep. - Controllers maps the Frame controllers' buttons (every button but the system button) to the same actions, except passing a key through. Capture a button and press it on a controller, or pick it from the list. The controllers aren't input devices on the host; only SteamVR sees them. So the pointer helper reads them with SteamVR input (`pointer/helper/vrbuttons.h`, `pointer/helper/actions/`) and sends presses to the relay (`vrbtn right/a 1`), which does the mapped action. The helper only takes the buttons that are mapped (the relay tells it with `vrbind`), at an overlay-global priority, and only while no game (scene application) runs, so games keep every button; with In games on (`controller_in_games`), a mapped button is taken from games too. That needs SteamVR's "Enable global input from overlays (Experimental)" setting (`steamvr/globalActionSetPriority`), which the page's Global input switch turns on and off. Mappings are saved as `controller_buttons` in `~/.config/frametop-input.json`. +- Keyboard sets when Frametop's keyboard opens: whenever a text field is selected; only while no pass-through keyboard is connected (the default; keyboards other programs make through uinput, like frame-voice's, don't count); only with a mouse or controller button mapped to Open/close keyboard; or never, which turns the button off too. Keep it open (on by default, `vr_keyboard_persist`) leaves it open after the text field loses focus. The mode is saved as `vr_keyboard` in `~/.config/frametop-input.json`, and the page lists the keyboards that count as connected. - Pointer has a Head follow switch and sliders for the pointer settings, which apply immediately, and a Recenter button. - Ignored panels lists the SteamVR overlays that are showing, grouped by app (the first two parts of the overlay key, such as `sasaken.frame-perf-overlay`), from the pointer helper (`overlays`). Tick a panel, or Ignore the whole app, and the pointer passes through it to what's behind. It's for panels you only look at, like a performance overlay that follows your view. The list is saved as `POINTER_IGNORE` in `~/.config/frametop.conf`: comma-separated overlay keys, where a shell pattern like `vendor.app*` covers a whole app, including panels it opens later. The helper reloads at once. Frametop's own screens aren't listed, and entries for apps that aren't open are listed below, to remove. - Gaze has the gaze pointer switch (on now and from now on; a mapped button toggles it until the helper restarts), the gaze mode sliders, the gaze service's state (headset, samples per second, how often the tracker is losing each eye, the calibration, the nudges learned), and Calibrate (opens the gaze probe), Check headset fit (opens the probe's Headset fit mode), Reload calibration, and Forget nudges. diff --git a/input-settings/ft_input_settings.py b/input-settings/ft_input_settings.py index 1f54192..ee7b4f7 100644 --- a/input-settings/ft_input_settings.py +++ b/input-settings/ft_input_settings.py @@ -62,9 +62,18 @@ ACTION_LABELS = { "follow_toggle": "Head follow on/off (experimental)", "gaze_toggle": "Gaze pointer on/off (experimental)", "sens_up": "Faster pointer", "sens_down": "Slower pointer", "layout_reset": "Reset desktop screen layout", - "screens_toggle": "Hide/show desktop screens", "key": "Pass through as key", + "screens_toggle": "Hide/show desktop screens", "keyboard_toggle": "Open/close keyboard", + "key": "Pass through as key", "none": "Do nothing", } +# When Frametop's keyboard opens ("vr_keyboard" in the rules; the relay's +# VR_KEYBOARD_MODES). "no_keyboard" is the default. +VR_KEYBOARD_MODES = { + "always": "When a text field is selected", + "no_keyboard": "When a text field is selected and no keyboard is connected", + "button": "Only with a mapped mouse or controller button", + "never": "Never", +} ROLE_LABELS = {"pointer": "3D pointer", "passthrough": "Pass through", "ignore": "Ignore"} # Frame controller buttons the pointer helper can read (pointer/helper/vrbuttons.h). The # system button stays SteamVR's. @@ -363,7 +372,8 @@ class Backend(QObject): grouped = {} for n in self._nodes: d = grouped.setdefault(n["id"], {"id": n["id"], "name": n["name"], "bus": n["bus"], "kinds": [], - "nodes": [], "role": n["role"], "grabbed": False}) + "nodes": [], "role": n["role"], "grabbed": False, + "uinput": n.get("uinput", False)}) d["nodes"].append(n["path"]) d["kinds"] = sorted(set(d["kinds"]) | set(n["kinds"])) d["grabbed"] = d["grabbed"] or n["grabbed"] @@ -601,6 +611,36 @@ class Backend(QObject): def removeIgnore(self, pattern): self._save_ignore([p for p in self._ignore_list() if p != pattern], f"{pattern}: no longer ignored") + # --- Frametop's keyboard --- + @Property("QVariantList", constant=True) + def vrKeyboardModes(self): + return [{"value": k, "text": v} for k, v in VR_KEYBOARD_MODES.items()] + + @Property(str, notify=mappingsChanged) + def vrKeyboard(self): + mode = read_json(RULES_PATH).get("vr_keyboard") + return mode if mode in VR_KEYBOARD_MODES else "no_keyboard" + + @Property(bool, notify=mappingsChanged) + def vrKeyboardPersist(self): + return bool(read_json(RULES_PATH).get("vr_keyboard_persist", True)) + + @Slot(bool) + def setVrKeyboardPersist(self, on): + rules = read_json(RULES_PATH) + rules["vr_keyboard_persist"] = bool(on) + self._save_rules(rules) + self.message.emit("Keyboard: " + ("stays open until you hide it" if on else "closes with the text field"), False) + + @Slot(str) + def setVrKeyboard(self, mode): + if mode not in VR_KEYBOARD_MODES: + return + rules = read_json(RULES_PATH) + rules["vr_keyboard"] = mode + self._save_rules(rules) + self.message.emit(f"Keyboard: {VR_KEYBOARD_MODES[mode].lower()}", False) + # --- controllers --- @Property("QVariantList", constant=True) def controllerActions(self): diff --git a/input-settings/main.qml b/input-settings/main.qml index f7b9805..c59d557 100644 --- a/input-settings/main.qml +++ b/input-settings/main.qml @@ -19,6 +19,7 @@ Kirigami.ApplicationWindow { Kirigami.Action { text: "Devices"; icon.name: "input-mouse"; onTriggered: root.show(devicesPage) }, Kirigami.Action { text: "Buttons"; icon.name: "input-keyboard"; onTriggered: root.show(buttonsPage) }, Kirigami.Action { text: "Controllers"; icon.name: "input-gamepad"; onTriggered: root.show(controllersPage) }, + Kirigami.Action { text: "Keyboard"; icon.name: "input-keyboard-virtual"; onTriggered: root.show(keyboardPage) }, Kirigami.Action { text: "Pointer"; icon.name: "transform-move"; onTriggered: root.show(pointerPage) }, Kirigami.Action { text: "Ignored panels"; icon.name: "view-hidden"; onTriggered: root.show(ignorePage) }, Kirigami.Action { text: "Gaze"; icon.name: "view-visible"; onTriggered: root.show(gazePage) }, @@ -55,9 +56,10 @@ Kirigami.ApplicationWindow { pageStack.push(page) } - // FT_INPUT_PAGE=buttons|controllers|pointer|ignore|gaze|bluetooth opens the app on that page. - pageStack.initialPage: ({ buttons: buttonsPage, controllers: controllersPage, pointer: pointerPage, - ignore: ignorePage, gaze: gazePage, bluetooth: bluetoothPage })[startPage] || devicesPage + // FT_INPUT_PAGE=buttons|controllers|keyboard|pointer|ignore|gaze|bluetooth opens the app on that page. + pageStack.initialPage: ({ buttons: buttonsPage, controllers: controllersPage, keyboard: keyboardPage, + pointer: pointerPage, ignore: ignorePage, gaze: gazePage, + bluetooth: bluetoothPage })[startPage] || devicesPage Connections { target: backend @@ -505,6 +507,54 @@ Kirigami.ApplicationWindow { } } + // ---------------------------------------------------------------- Keyboard + Component { + id: keyboardPage + Kirigami.ScrollablePage { + id: kpage + title: "Keyboard" + // Pass-through keyboards connected now: with "no_keyboard", the keyboard waits for none. + // A program's uinput keyboard (frame-voice's, say) doesn't count. + property var keyboards: backend.devices.filter(d => d.connected && d.role === "passthrough" + && d.kinds.indexOf("keyboard") >= 0 && !d.uinput) + + Kirigami.FormLayout { + Controls.ComboBox { + Kirigami.FormData.label: "Show the keyboard:" + model: backend.vrKeyboardModes + textRole: "text" + valueRole: "value" + Component.onCompleted: currentIndex = indexOfValue(backend.vrKeyboard) + onActivated: backend.setVrKeyboard(currentValue) + } + Controls.Switch { + Kirigami.FormData.label: "Keep it open:" + text: "Until you press its Close key or your keyboard button, not only while the text field has focus" + checked: backend.vrKeyboardPersist + enabled: backend.vrKeyboard !== "never" + onToggled: backend.setVrKeyboardPersist(checked) + } + Controls.Label { + Kirigami.FormData.label: "Keyboards connected:" + text: kpage.keyboards.length ? kpage.keyboards.map(d => d.name).join(", ") : "none" + } + } + + footer: Controls.Label { + padding: Kirigami.Units.largeSpacing + wrapMode: Text.Wrap + opacity: 0.7 + text: "Frametop's keyboard opens in front of you, below your eyes, and closes with its Close key or a layout reset " + + "(or when the text field loses focus, with Keep it open off). It steps aside while the Steam " + + "menu or Steam's own keyboard is up. Type on it with a laser or the 3D mouse. It types into any " + + "app, but only Qt, GTK and Firefox apps say when a text field is selected; for the rest " + + "(Chromium, Electron and X11 apps), map Open/close keyboard to a button on the Buttons or " + + "Controllers page. Keyboards set to Ignore, and keyboards other programs make (like " + + "frame-voice's), don't count as connected." + } + } + } + // ---------------------------------------------------------------- Pointer Component { id: pointerPage diff --git a/input/ft-textinput b/input/ft-textinput new file mode 100755 index 0000000..4fe68ac --- /dev/null +++ b/input/ft-textinput @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""ft-textinput: Frametop's input method, which tells the input relay when a text field +on the desktop has keyboard focus. + +KWin starts it (--inputmethod, from session/frametop-session.sh) and activates it +whenever the focused app enables text input on a field (text-input v1/v2/v3: Qt, GTK, +and Firefox apps do; Xwayland apps and most Electron apps don't). This only passes that +on: "textfield 1" or "textfield 0" to the input relay (@frametop_relay), which opens or +closes Frametop's keyboard (ft-screens' own key panel), depending on the keyboard setting +in Frametop Input Settings. Typing on it reaches the app as key presses from ft-screens, +not through this input method, so nothing typed passes through here. + +Speaks the Wayland wire protocol itself (zwp_input_method_v1 only), so it needs nothing +but Python on the SteamOS host. +""" +import os +import socket +import struct +import sys + +RELAY = "\0frametop_relay" +DISPLAY, REGISTRY, INPUT_METHOD = 1, 2, 3 # our object ids + + +def string(text): + data = text.encode() + b"\0" + return struct.pack("=I", len(data)) + data + b"\0" * (-len(data) % 4) + + +def read_string(body, at): + """(text, offset after it) for a string argument at `at`.""" + (length,) = struct.unpack_from("=I", body, at) + text = body[at + 4:at + 4 + length - 1].decode(errors="replace") + return text, at + 4 + length + (-length % 4) + + +def main(): + fd = os.environ.pop("WAYLAND_SOCKET", None) + if fd is None: + sys.exit("ft-textinput: no WAYLAND_SOCKET (KWin starts this as its input method)") + conn = socket.socket(fileno=int(fd)) + relay = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_NONBLOCK) + + def request(obj, opcode, payload=b""): + conn.sendall(struct.pack("=II", obj, (8 + len(payload)) << 16 | opcode) + payload) + + def report(on): + try: + relay.sendto(f"textfield {int(on)}".encode(), RELAY) + except OSError: + pass # the relay isn't running + + request(DISPLAY, 1, struct.pack("=I", REGISTRY)) # wl_display.get_registry + contexts = set() # KWin makes one per activation + buf = b"" + while True: + data = conn.recv(65536) + if not data: + return # KWin went away + buf += data + while len(buf) >= 8: + obj, word = struct.unpack_from("=II", buf) + size, opcode = word >> 16, word & 0xFFFF + if size < 8 or len(buf) < size: + break + body, buf = buf[8:size], buf[size:] + if obj == DISPLAY and opcode == 0: # error + bad, code = struct.unpack_from("=II", body) + message, _ = read_string(body, 8) + sys.exit(f"ft-textinput: Wayland error {code} on object {bad}: {message}") + elif obj == REGISTRY and opcode == 0: # global + (name,) = struct.unpack_from("=I", body) + interface, _ = read_string(body, 4) + if interface == "zwp_input_method_v1": + request(REGISTRY, 0, struct.pack("=I", name) + string(interface) + struct.pack("=II", 1, INPUT_METHOD)) + elif obj == INPUT_METHOD and opcode == 0: # activate: a text field has focus + contexts.add(struct.unpack_from("=I", body)[0]) + report(True) + elif obj == INPUT_METHOD and opcode == 1: # deactivate: it lost focus + (context,) = struct.unpack_from("=I", body) + if context in contexts: + contexts.discard(context) + request(context, 0) # zwp_input_method_context_v1.destroy + report(bool(contexts)) + # Everything else (the contexts' surrounding text, content type, ...) is unused. + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass diff --git a/input/input-relay.py b/input/input-relay.py index cc7a04b..b3de670 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -21,8 +21,8 @@ Buttons and keys of pointer devices go through a per-device map to actions (left, right, middle, back, scroll_up, scroll_down, dashboard, recenter, pointer_toggle, follow_toggle = head follow on or off, gaze_toggle = gaze mode on or off (the pointer goes where you look; see pointer/helper/ft-pointer.cpp), sens_up, sens_down, -layout_reset = put the desktop screens back in their saved layout, screens_toggle = hide or show the desktop screens, key = pass -through as a key, none). +layout_reset = put the desktop screens back in their saved layout, screens_toggle = hide or show the desktop screens, +keyboard_toggle = open or close Frametop's keyboard, key = pass through as a key, none). Frame controller buttons can be mapped too ("controller_buttons": {"right/a": action} in the rules file; any action but key). The controllers aren't input devices here, only SteamVR sees @@ -47,6 +47,16 @@ a grabbed keyboard's keys also go out as "key " data It's off by default: any local process that binds that name first gets every key typed into the desktop. +Frametop's keyboard (ft-screens' key panel): the desktop's input method (input/ft-textinput) +says "textfield 1|0" when a text field on the desktop gains or loses keyboard focus, and +the relay tells ft-screens to open ("vrkeyboard show") or close ("vrkeyboard hide") the +keyboard, depending on "vr_keyboard" in the rules file: "always", "no_keyboard" (the +default: only while no pass-through keyboard is connected; a program's uinput keyboard +doesn't count), "button" (only the keyboard_toggle action opens it), or "never" +(keyboard_toggle does nothing either). With "vr_keyboard_persist" (the default), it stays +open when the text field loses focus, until its Close key, keyboard_toggle, or a layout reset +(ft-layout apply) closes it. + Volume keys, from every device that has them (the headset's own buttons included), are handled here: wpctl steps the default output. Nothing else may see a volume key, because gamescope aborts on one when no window has keyboard focus, which ends the @@ -67,6 +77,7 @@ Control socket (abstract datagram @frametop_relay, JSON replies to the sender): vrcapture take every controller button for s seconds (0: stop), so the settings app can capture one; watchers see them as events with id frame_controller vrbtn, vrhello, gazeawake from the pointer helper (above) + textfield 1|0 from the desktop's input method (above) Runs on the Frame host as a user service (frametop-input-relay.service). The virtual devices are parked in systemd's file descriptor store, so a relay @@ -156,7 +167,8 @@ VIRTUAL_PREFIX = "frametop virtual" RULES_PATH = os.path.expanduser("~/.config/frametop-input.json") ACTIONS = ("left", "right", "middle", "back", "scroll_up", "scroll_down", "dashboard", "recenter", "pointer_toggle", "follow_toggle", "gaze_toggle", "sens_up", "sens_down", "layout_reset", "screens_toggle", - "key", "none") + "keyboard_toggle", "key", "none") +VR_KEYBOARD_MODES = ("always", "no_keyboard", "button", "never") # when Frametop's keyboard opens HELPER = "\0ft_pointer_helper" # Frame controller buttons the pointer helper can read (pointer/helper/vrbuttons.h). VR_BUTTONS = ("left/view", "left/dpad_up", "left/dpad_down", "left/dpad_left", "left/dpad_right", "left/bumper", @@ -260,7 +272,8 @@ def read_config(path=os.path.expanduser("~/.config/frametop.conf")): def read_rules(path=RULES_PATH): """{"devices": {id: {"role", "name"}}, "buttons": {id: {"": action}}, - "controller_buttons": {"/