From b32bcf0ce461495190650e596389f692368f81cb Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:49:24 -0600 Subject: [PATCH] keys --- system/ui/lib/usb_keyboard.py | 194 ++++++++++++++++++++++++++++++++++ system/ui/widgets/inputbox.py | 14 +++ system/ui/widgets/keyboard.py | 2 + 3 files changed, 210 insertions(+) create mode 100644 system/ui/lib/usb_keyboard.py diff --git a/system/ui/lib/usb_keyboard.py b/system/ui/lib/usb_keyboard.py new file mode 100644 index 000000000..b062a9366 --- /dev/null +++ b/system/ui/lib/usb_keyboard.py @@ -0,0 +1,194 @@ +import atexit +import os +import struct +import time +from collections import deque +from glob import glob + +import pyray as rl + +EVENT_FORMAT = "llHHi" +EVENT_SIZE = struct.calcsize(EVENT_FORMAT) +EV_KEY = 0x01 + +KEY_LEFTSHIFT = 42 +KEY_RIGHTSHIFT = 54 +KEY_CAPSLOCK = 58 + +LINUX_TO_RL_KEY = { + 1: rl.KEY_ESCAPE, + 14: rl.KEY_BACKSPACE, + 28: rl.KEY_ENTER, + 96: rl.KEY_ENTER, # keypad enter + 102: rl.KEY_HOME, + 103: rl.KEY_UP, + 105: rl.KEY_LEFT, + 106: rl.KEY_RIGHT, + 107: rl.KEY_END, + 108: rl.KEY_DOWN, + 111: rl.KEY_DELETE, +} + +LETTER_BY_CODE = { + 16: "q", 17: "w", 18: "e", 19: "r", 20: "t", 21: "y", 22: "u", 23: "i", 24: "o", 25: "p", + 30: "a", 31: "s", 32: "d", 33: "f", 34: "g", 35: "h", 36: "j", 37: "k", 38: "l", + 44: "z", 45: "x", 46: "c", 47: "v", 48: "b", 49: "n", 50: "m", +} + +UNSHIFTED_BY_CODE = { + 2: "1", 3: "2", 4: "3", 5: "4", 6: "5", 7: "6", 8: "7", 9: "8", 10: "9", 11: "0", + 12: "-", 13: "=", + 26: "[", 27: "]", + 39: ";", 40: "'", 41: "`", 43: "\\", + 51: ",", 52: ".", 53: "/", + 57: " ", +} + +SHIFTED_BY_CODE = { + 2: "!", 3: "@", 4: "#", 5: "$", 6: "%", 7: "^", 8: "&", 9: "*", 10: "(", 11: ")", + 12: "_", 13: "+", + 26: "{", 27: "}", + 39: ":", 40: "\"", 41: "~", 43: "|", + 51: "<", 52: ">", 53: "?", + 57: " ", +} + + +class USBKeyboard: + """Fallback keyboard reader for platforms where raylib keyboard input is unavailable.""" + def __init__(self): + self._fds: dict[str, int] = {} + self._last_scan_t = 0.0 + self._left_shift_down = False + self._right_shift_down = False + self._caps_lock_on = False + self._key_queue: deque[int] = deque(maxlen=128) + self._char_queue: deque[int] = deque(maxlen=256) + atexit.register(self.close) + + def _device_paths(self) -> list[str]: + paths = [] + paths.extend(glob("/dev/input/by-id/*-kbd")) + paths.extend(glob("/dev/input/by-path/*-kbd")) + # Keep deterministic order and de-duplicate paths if both globs resolve to same target. + return sorted(set(paths)) + + def _refresh_devices(self) -> None: + now = time.monotonic() + if (now - self._last_scan_t) < 1.0: + return + + self._last_scan_t = now + current_paths = set(self._device_paths()) + + for path in list(self._fds): + if path not in current_paths: + try: + os.close(self._fds[path]) + except OSError: + pass + del self._fds[path] + + for path in current_paths: + if path in self._fds: + continue + try: + fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) + except OSError: + continue + self._fds[path] = fd + + def _key_to_char(self, code: int) -> str | None: + shift = self._left_shift_down or self._right_shift_down + + if code in LETTER_BY_CODE: + c = LETTER_BY_CODE[code] + return c.upper() if (self._caps_lock_on ^ shift) else c + + if code in UNSHIFTED_BY_CODE: + if shift and code in SHIFTED_BY_CODE: + return SHIFTED_BY_CODE[code] + return UNSHIFTED_BY_CODE[code] + + return None + + def _process_event(self, event_type: int, code: int, value: int) -> None: + if event_type != EV_KEY: + return + + if code == KEY_LEFTSHIFT: + self._left_shift_down = value != 0 + return + if code == KEY_RIGHTSHIFT: + self._right_shift_down = value != 0 + return + if code == KEY_CAPSLOCK and value == 1: + self._caps_lock_on = not self._caps_lock_on + return + + # Linux key events: 0=release, 1=press, 2=repeat + if value not in (1, 2): + return + + key = LINUX_TO_RL_KEY.get(code) + if key is not None: + self._key_queue.append(key) + + char = self._key_to_char(code) + if char is not None: + self._char_queue.append(ord(char)) + + def _poll(self) -> None: + self._refresh_devices() + + for path, fd in list(self._fds.items()): + while True: + try: + data = os.read(fd, EVENT_SIZE * 32) + except BlockingIOError: + break + except OSError: + try: + os.close(fd) + except OSError: + pass + del self._fds[path] + break + + if not data: + break + + valid_bytes = len(data) - (len(data) % EVENT_SIZE) + for i in range(0, valid_bytes, EVENT_SIZE): + _, _, event_type, code, value = struct.unpack_from(EVENT_FORMAT, data, i) + self._process_event(event_type, code, value) + + if len(data) < (EVENT_SIZE * 32): + break + + def get_key_pressed(self) -> int: + self._poll() + return self._key_queue.popleft() if self._key_queue else 0 + + def get_char_pressed(self) -> int: + self._poll() + return self._char_queue.popleft() if self._char_queue else 0 + + def close(self) -> None: + for fd in self._fds.values(): + try: + os.close(fd) + except OSError: + pass + self._fds.clear() + + +_usb_keyboard = USBKeyboard() + + +def get_usb_key_pressed() -> int: + return _usb_keyboard.get_key_pressed() + + +def get_usb_char_pressed() -> int: + return _usb_keyboard.get_char_pressed() diff --git a/system/ui/widgets/inputbox.py b/system/ui/widgets/inputbox.py index f53e3f0eb..d04c0190f 100644 --- a/system/ui/widgets/inputbox.py +++ b/system/ui/widgets/inputbox.py @@ -2,6 +2,7 @@ import pyray as rl import time from openpilot.system.ui.lib.application import gui_app, MousePos, FONT_SCALE from openpilot.system.ui.lib.text_measure import measure_text_cached +from openpilot.system.ui.lib.usb_keyboard import get_usb_char_pressed, get_usb_key_pressed from openpilot.system.ui.widgets import Widget PASSWORD_MASK_CHAR = "•" @@ -25,6 +26,7 @@ class InputBox(Widget): self._visible_width = 0 self._last_char_time = 0 # Track when last character was added self._masked_length = 0 # How many characters are currently masked + self._submit_requested = False @property def text(self): @@ -43,6 +45,12 @@ class InputBox(Widget): self._input_text = '' self._cursor_position = 0 self._text_offset = 0 + self._submit_requested = False + + def consume_submit_requested(self) -> bool: + requested = self._submit_requested + self._submit_requested = False + return requested def set_cursor_position(self, position): """Set the cursor position and reset the blink counter.""" @@ -191,6 +199,8 @@ class InputBox(Widget): def _handle_keyboard_input(self): # Handle navigation keys key = rl.get_key_pressed() + if key == 0: + key = get_usb_key_pressed() if key != 0: self._process_key(key) if key in (rl.KEY_LEFT, rl.KEY_RIGHT, rl.KEY_BACKSPACE, rl.KEY_DELETE): @@ -208,6 +218,8 @@ class InputBox(Widget): # Handle text input char = rl.get_char_pressed() + if char == 0: + char = get_usb_char_pressed() if char != 0 and char >= 32: # Filter out control characters self.add_char_at_cursor(chr(char)) @@ -226,3 +238,5 @@ class InputBox(Widget): self.set_cursor_position(0) elif key == rl.KEY_END: self.set_cursor_position(len(self._input_text)) + elif key == rl.KEY_ENTER: + self._submit_requested = True diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index 4ec92f507..80bbbb2fe 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -145,6 +145,8 @@ class Keyboard(Widget): input_margin = 25 input_box_rect = rl.Rectangle(rect.x + input_margin, rect.y + 160, rect.width - input_margin, 100) self._render_input_area(input_box_rect) + if self._input_box.consume_submit_requested() and len(self._input_box.text) >= self._min_text_size: + self._render_return_status = 1 # Process backspace key repeat if it's held down if not self._all_keys[BACKSPACE_KEY].is_pressed: