Files
IQ.Pilot/system/ui/widgets/keyboard.py
T
2026-07-19 18:18:20 -05:00

317 lines
13 KiB
Python

from functools import partial
import time
from typing import Literal
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import ButtonStyle, Button
from openpilot.system.ui.widgets.inputbox import InputBox
from openpilot.system.ui.widgets.label import Label
KEY_FONT_SIZE = 96
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
DELETE_REPEAT_DELAY = 0.5
DELETE_REPEAT_INTERVAL = 0.07
# Constants for special keys
CONTENT_MARGIN = 50
BACKSPACE_KEY = "<-"
ENTER_KEY = "->"
SPACE_KEY = " "
SHIFT_INACTIVE_KEY = "SHIFT_OFF"
SHIFT_ACTIVE_KEY = "SHIFT_ON"
CAPS_LOCK_KEY = "CAPS"
NUMERIC_KEY = "123"
SYMBOL_KEY = "#+="
ABC_KEY = "ABC"
# Theme
TEAL = rl.Color(16, 185, 169, 255)
KEY_LETTER = rl.Color(58, 61, 68, 255) # letters / space / punctuation
KEY_FUNCTION = rl.Color(38, 40, 46, 255) # shift, backspace, 123, ABC, #+=
KEY_DISABLED = rl.Color(34, 36, 42, 255)
KEY_RADIUS = 32
PRESS_SCALE = 1.045
FIELD_BG = rl.Color(30, 32, 38, 255)
FIELD_BORDER = rl.Color(255, 255, 255, 38)
FUNCTION_KEYS = {SHIFT_INACTIVE_KEY, SHIFT_ACTIVE_KEY, CAPS_LOCK_KEY, BACKSPACE_KEY, NUMERIC_KEY, SYMBOL_KEY, ABC_KEY}
# Define keyboard layouts as a dictionary for easier access
KEYBOARD_LAYOUTS = {
"lowercase": [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
[SHIFT_INACTIVE_KEY, "z", "x", "c", "v", "b", "n", "m", BACKSPACE_KEY],
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
],
"uppercase": [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
[SHIFT_ACTIVE_KEY, "Z", "X", "C", "V", "B", "N", "M", BACKSPACE_KEY],
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
],
"numbers": [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["-", "/", ":", ";", "(", ")", "$", "&", "@", "\""],
[SYMBOL_KEY, "_", ",", "?", "!", "`", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
"specials": [
["[", "]", "{", "}", "#", "%", "^", "*", "+", "="],
["_", "\\", "|", "~", "<", ">", "", "£", "¥", ""],
[NUMERIC_KEY, "-", ",", "?", "!", "'", BACKSPACE_KEY],
[ABC_KEY, SPACE_KEY, ".", ENTER_KEY],
],
}
class Keyboard(Widget):
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False):
super().__init__()
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
self._caps_lock = False
self._last_shift_press_time = 0
self._title = Label("", 90, FontWeight.BOLD, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._sub_title = Label("", 55, FontWeight.NORMAL, rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20)
self._max_text_size = max_text_size
self._min_text_size = min_text_size
self._input_box = InputBox(max_text_size)
self._password_mode = password_mode
self._show_password_toggle = show_password_toggle
# Backspace key repeat tracking
self._backspace_pressed: bool = False
self._backspace_press_time: float = 0.0
self._backspace_last_repeat: float = 0.0
self._render_return_status = -1
self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback)
self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT)
self._eye_open_texture = gui_app.texture("icons/eye_open.png", 81, 54)
self._eye_closed_texture = gui_app.texture("icons/eye_closed.png", 81, 54)
self._key_icons = {
BACKSPACE_KEY: gui_app.texture("icons/backspace.png", 80, 80),
SHIFT_INACTIVE_KEY: gui_app.texture("icons/shift.png", 80, 80),
SHIFT_ACTIVE_KEY: gui_app.texture("icons/shift-fill.png", 80, 80),
CAPS_LOCK_KEY: gui_app.texture("icons/capslock-fill.png", 80, 80),
ENTER_KEY: gui_app.texture("icons/arrow-right.png", 80, 80),
}
# All key buttons use a transparent style (draw no background); the keyboard paints each
# key's rounded background itself so it can shade by category and flash teal on press.
self._all_keys = {}
for l in KEYBOARD_LAYOUTS:
for keys in KEYBOARD_LAYOUTS[l]:
for key in keys:
if key in self._key_icons:
self._all_keys[key] = Button("", partial(self._key_callback, key), icon=self._key_icons[key],
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, multi_touch=True)
else:
self._all_keys[key] = Button(key, partial(self._key_callback, key),
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, font_size=85, multi_touch=True)
self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY],
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, multi_touch=True)
# Enter is a teal "Done" key
self._all_keys[ENTER_KEY] = Button(tr("Done"), partial(self._key_callback, ENTER_KEY),
button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT, font_size=64, multi_touch=True)
self._cancel_button._border_radius = 40
self._input_box.bg_color = rl.BLANK
self._input_box.caret_color = TEAL
def set_text(self, text: str):
self._input_box.text = text
@property
def text(self):
return self._input_box.text
def clear(self):
self._layout_name = "lowercase"
self._caps_lock = False
self._input_box.clear()
self._backspace_pressed = False
def set_title(self, title: str, sub_title: str = ""):
self._title.set_text(title)
self._sub_title.set_text(sub_title)
def _eye_button_callback(self):
self._password_mode = not self._password_mode
def _cancel_button_callback(self):
self.clear()
self._render_return_status = 0
def _key_callback(self, k):
if k == ENTER_KEY:
self._render_return_status = 1
else:
self.handle_key_press(k)
def _render(self, rect: rl.Rectangle):
rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN)
self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95))
self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60))
self._cancel_button.render(rl.Rectangle(rect.x + rect.width - 386, rect.y, 386, 125))
# Draw input box and password toggle
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)
# Process backspace key repeat if it's held down
if not self._all_keys[BACKSPACE_KEY].is_pressed:
self._backspace_pressed = False
if self._backspace_pressed:
current_time = time.monotonic()
time_since_press = current_time - self._backspace_press_time
# After initial delay, start repeating with shorter intervals
if time_since_press > DELETE_REPEAT_DELAY:
time_since_last_repeat = current_time - self._backspace_last_repeat
if time_since_last_repeat > DELETE_REPEAT_INTERVAL:
self._input_box.delete_char_before_cursor()
self._backspace_last_repeat = current_time
layout = KEYBOARD_LAYOUTS[self._layout_name]
h_space, v_space = 15, 15
row_y_start = rect.y + 300 # Starting Y position for the first row
key_height = (rect.height - 300 - 3 * v_space) / 4
key_max_width = (rect.width - (len(layout[2]) - 1) * h_space) / len(layout[2])
# Iterate over the rows of keys in the current layout
pressed_keys = []
for row, keys in enumerate(layout):
key_width = min((rect.width - (180 if row == 1 else 0) - h_space * (len(keys) - 1)) / len(keys), key_max_width)
start_x = rect.x + (90 if row == 1 else 0)
for i, key in enumerate(keys):
if i > 0:
start_x += h_space
new_width = (key_width * 3 + h_space * 2) if key == SPACE_KEY else (key_width * 2 + h_space if key == ENTER_KEY else key_width)
key_rect = rl.Rectangle(start_x, row_y_start + row * (key_height + v_space), new_width, key_height)
start_x += new_width
is_enabled = key != ENTER_KEY or len(self._input_box.text) >= self._min_text_size
if key == BACKSPACE_KEY and self._all_keys[BACKSPACE_KEY].is_pressed and not self._backspace_pressed:
self._backspace_pressed = True
self._backspace_press_time = time.monotonic()
self._backspace_last_repeat = time.monotonic()
draw_key = key
if key == SHIFT_ACTIVE_KEY and self._caps_lock:
draw_key = CAPS_LOCK_KEY
btn = self._all_keys[draw_key]
btn.set_enabled(is_enabled)
# Pressed keys are deferred so they grow + glow on top of their neighbors
if is_enabled and btn.is_pressed:
pressed_keys.append((btn, key_rect, new_width, key_height))
continue
if not is_enabled:
bg = KEY_DISABLED
elif key == ENTER_KEY or draw_key in (SHIFT_ACTIVE_KEY, CAPS_LOCK_KEY):
bg = TEAL # teal "Done" / shift / caps active
elif draw_key in FUNCTION_KEYS:
bg = KEY_FUNCTION # darker function keys
else:
bg = KEY_LETTER
roundness = min(1.0, KEY_RADIUS / (min(new_width, key_height) / 2))
rl.draw_rectangle_rounded(key_rect, roundness, 10, bg)
btn.render(key_rect)
# Draw pressed keys last: slightly grown with a teal glow
for btn, kr, w, h in pressed_keys:
gw, gh = w * PRESS_SCALE, h * PRESS_SCALE
grown = rl.Rectangle(kr.x - (gw - w) / 2, kr.y - (gh - h) / 2, gw, gh)
gr = min(1.0, KEY_RADIUS / (min(gw, gh) / 2))
glow = rl.Rectangle(grown.x - 10, grown.y - 10, grown.width + 20, grown.height + 20)
rl.draw_rectangle_rounded(glow, gr, 10, rl.Color(16, 185, 169, 70))
rl.draw_rectangle_rounded(grown, gr, 10, TEAL)
btn.render(grown)
return self._render_return_status
def _render_input_area(self, input_rect: rl.Rectangle):
# Filled rounded field behind the text
field = rl.Rectangle(input_rect.x, input_rect.y - 6, input_rect.width, input_rect.height + 24)
rl.draw_rectangle_rounded(field, 0.34, 16, FIELD_BG)
rl.draw_rectangle_rounded_lines_ex(field, 0.34, 16, 2, FIELD_BORDER)
if self._show_password_toggle:
self._input_box.set_password_mode(self._password_mode)
self._input_box.render(rl.Rectangle(input_rect.x + 24, input_rect.y, input_rect.width - 130, input_rect.height))
# render eye icon
eye_texture = self._eye_closed_texture if self._password_mode else self._eye_open_texture
eye_rect = rl.Rectangle(input_rect.x + input_rect.width - 100, input_rect.y, 80, input_rect.height)
self._eye_button.render(eye_rect)
eye_x = eye_rect.x + (eye_rect.width - eye_texture.width) / 2
eye_y = eye_rect.y + (eye_rect.height - eye_texture.height) / 2
rl.draw_texture_v(eye_texture, rl.Vector2(eye_x, eye_y), rl.WHITE)
else:
self._input_box.render(rl.Rectangle(input_rect.x + 24, input_rect.y, input_rect.width - 48, input_rect.height))
def handle_key_press(self, key):
if key in (CAPS_LOCK_KEY, ABC_KEY):
self._caps_lock = False
self._layout_name = "lowercase"
elif key == SHIFT_INACTIVE_KEY:
self._last_shift_press_time = time.monotonic()
self._layout_name = "uppercase"
elif key == SHIFT_ACTIVE_KEY:
if time.monotonic() - self._last_shift_press_time < DOUBLE_CLICK_THRESHOLD:
self._caps_lock = True
else:
self._layout_name = "lowercase"
elif key == NUMERIC_KEY:
self._layout_name = "numbers"
elif key == SYMBOL_KEY:
self._layout_name = "specials"
elif key == BACKSPACE_KEY:
self._input_box.delete_char_before_cursor()
else:
self._input_box.add_char_at_cursor(key)
if not self._caps_lock and self._layout_name == "uppercase":
self._layout_name = "lowercase"
def reset(self, min_text_size: int | None = None):
if min_text_size is not None:
self._min_text_size = min_text_size
self._render_return_status = -1
self._last_shift_press_time = 0
self._backspace_pressed = False
self._backspace_press_time = 0.0
self._backspace_last_repeat = 0.0
self.clear()
if __name__ == "__main__":
gui_app.init_window("Keyboard")
keyboard = Keyboard(min_text_size=8, show_password_toggle=True)
for _ in gui_app.render():
keyboard.set_title("Keyboard Input", "Type your text below")
result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
if result == 1:
print(f"You typed: {keyboard.text}")
gui_app.request_close()
elif result == 0:
print("Canceled")
gui_app.request_close()
gui_app.close()