mirror of
https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
synced 2026-07-23 04:22:09 +08:00
317 lines
13 KiB
Python
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()
|