Files
DeeJanuz--frametop/input/ft-textinput
T
DeeJanuzandClaude Opus 5.5 5714978e65 Add a keyboard for text fields on the desktop
Fixes #7: SteamVR's keyboard never came up for the desktop's apps, and
opening it for our panels doesn't work well on the Frame (it's Steam's own
panel, mounted in the dashboard's scene, it follows the laser between
panels, and it takes the controllers over to SteamVR's laser).

- KWin starts input/ft-textinput as the desktop's input method. It tells
  the input relay when a text field gains or loses focus, and the relay
  asks ft-screens to open or close the keyboard. The session drops the
  QT_IM_MODULE=xim and GTK_IM_MODULE=xim that the gamescope session sets,
  or Qt and GTK apps never report text fields.
- The keyboard is ft-screens' own panel (screens/keyboard.cpp): a US laptop
  layout, typed with a controller's laser or the 3D mouse. It opens 0.7 m
  in front of you, below your eyes and facing you. It has a grab bar to
  move it, a Close key, latching Shift, Ctrl and Alt, and repeat on a held
  key. It's drawn into shared DMA-BUFs, so it doesn't flicker. Its keys
  reach the focused screen as key presses, so every app takes them.
- It steps aside while the Steam menu or Steam's own keyboard is up and
  comes back after. A layout reset closes it, and it doesn't open without
  a head pose.
- Frametop Input Settings has a Keyboard page: open it for every text
  field, only while no keyboard is connected (the default), only from a
  mapped button (the new Open/close keyboard action, for mice and
  controllers), or never. A switch keeps it open until you close it.
- The pointer helper treats every frametop.* overlay as a real panel. The
  keyboard's shared texture reports 0x0 like SteamVR's scene-graph
  controls, and the helper had given it their wide catch radius.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:41:47 -06:00

93 lines
3.8 KiB
Python
Executable File

#!/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