system/ui: Full-Featured InputBox Component (#35178)

custom input box component
pull/35188/head
Dean Lee 1 week ago committed by GitHub
parent 35dbdf9124
commit 2b32325be1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 157
      system/ui/lib/inputbox.py
  2. 37
      system/ui/widgets/keyboard.py
  3. 4
      system/ui/widgets/network.py

@ -0,0 +1,157 @@
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
class InputBox:
def __init__(self, max_text_size=255, password_mode=False):
self._max_text_size = max_text_size
self._input_text = ""
self._cursor_position = 0
self._password_mode = password_mode
self._blink_counter = 0
self._show_cursor = False
self._last_key_pressed = 0
self._key_press_time = 0
self._repeat_delay = 30
self._repeat_rate = 5
@property
def text(self):
return self._input_text
@text.setter
def text(self, value):
self._input_text = value[: self._max_text_size]
self._cursor_position = len(self._input_text)
def set_password_mode(self, password_mode):
self._password_mode = password_mode
def clear(self):
self._input_text = ''
self._cursor_position = 0
def set_cursor_position(self, position):
"""Set the cursor position and reset the blink counter."""
if 0 <= position <= len(self._input_text):
self._cursor_position = position
self._blink_counter = 0
self._show_cursor = True
def add_char_at_cursor(self, char):
"""Add a character at the current cursor position."""
if len(self._input_text) < self._max_text_size:
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position :]
self.set_cursor_position(self._cursor_position + 1)
return True
return False
def delete_char_before_cursor(self):
"""Delete the character before the cursor position (backspace)."""
if self._cursor_position > 0:
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position :]
self.set_cursor_position(self._cursor_position - 1)
return True
return False
def delete_char_at_cursor(self):
"""Delete the character at the cursor position (delete)."""
if self._cursor_position < len(self._input_text):
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1 :]
self.set_cursor_position(self._cursor_position)
return True
return False
def render(self, rect, color=rl.LIGHTGRAY, border_color=rl.DARKGRAY, text_color=rl.BLACK, font_size=80):
# Handle mouse input
self._handle_mouse_input(rect, font_size)
# Draw input box
rl.draw_rectangle_rec(rect, color)
rl.draw_rectangle_lines_ex(rect, 1, border_color)
# Process keyboard input
self._handle_keyboard_input()
# Update cursor blink
self._blink_counter += 1
if self._blink_counter >= 30:
self._show_cursor = not self._show_cursor
self._blink_counter = 0
# Display text
font = gui_app.font()
display_text = "" * len(self._input_text) if self._password_mode else self._input_text
padding = 10
rl.draw_text_ex(
font,
display_text,
rl.Vector2(int(rect.x + padding), int(rect.y + rect.height / 2 - font_size / 2)),
font_size,
0,
text_color,
)
# Draw cursor
if self._show_cursor:
cursor_x = rect.x + padding
if len(display_text) > 0 and self._cursor_position > 0:
cursor_x += rl.measure_text_ex(font, display_text[: self._cursor_position], font_size, 0).x
cursor_height = font_size + 4
cursor_y = rect.y + rect.height / 2 - cursor_height / 2
rl.draw_line(int(cursor_x), int(cursor_y), int(cursor_x), int(cursor_y + cursor_height), rl.BLACK)
def _handle_mouse_input(self, rect, font_size):
"""Handle mouse clicks to position cursor."""
mouse_pos = rl.get_mouse_position()
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON) and rl.check_collision_point_rec(mouse_pos, rect):
# Calculate cursor position from click
if len(self._input_text) > 0:
text_width = rl.measure_text(self._input_text, font_size)
text_pos_x = rect.x + 10
if mouse_pos.x - text_pos_x > text_width:
self.set_cursor_position(len(self._input_text))
else:
click_ratio = (mouse_pos.x - text_pos_x) / text_width
self.set_cursor_position(int(len(self._input_text) * click_ratio))
self.set_cursor_position(0)
def _handle_keyboard_input(self):
"""Process keyboard input."""
key = rl.get_key_pressed()
# Handle key repeats
if key == self._last_key_pressed and key != 0:
self._key_press_time += 1
if self._key_press_time > self._repeat_delay and self._key_press_time % self._repeat_rate == 0:
# Process repeated key
pass
else:
return # Skip processing until repeat triggers
else:
self._last_key_pressed = key
self._key_press_time = 0
# Handle navigation keys
if key == rl.KEY_LEFT:
if self._cursor_position > 0:
self.set_cursor_position(self._cursor_position - 1)
elif key == rl.KEY_RIGHT:
if self._cursor_position < len(self._input_text):
self.set_cursor_position(self._cursor_position + 1)
elif key == rl.KEY_BACKSPACE:
self.delete_char_before_cursor()
elif key == rl.KEY_DELETE:
self.delete_char_at_cursor()
elif key == rl.KEY_HOME:
self.set_cursor_position(0)
elif key == rl.KEY_END:
self.set_cursor_position(len(self._input_text))
# Handle text input
char = rl.get_char_pressed()
if char != 0 and char >= 32: # Filter out control characters
self.add_char_at_cursor(chr(char))

@ -1,6 +1,7 @@
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.button import gui_button
from openpilot.system.ui.lib.inputbox import InputBox
from openpilot.system.ui.lib.label import gui_label
# Constants for special keys
@ -47,28 +48,25 @@ class Keyboard:
def __init__(self, max_text_size: int = 255):
self._layout = keyboard_layouts["lowercase"]
self._max_text_size = max_text_size
self._string_pointer = rl.ffi.new("char[]", max_text_size)
self._input_text = ""
self._clear()
self._input_box = InputBox(max_text_size)
@property
def text(self):
result = rl.ffi.string(self._string_pointer).decode("utf-8")
self._clear()
return result
return self._input_box.text
def clear(self):
self._input_box.clear()
def render(self, title, sub_title):
rect = rl.Rectangle(CONTENT_MARGIN, CONTENT_MARGIN, gui_app.width - 2 * CONTENT_MARGIN, gui_app.height - 2 * CONTENT_MARGIN)
gui_label(rl.Rectangle(rect.x, rect.y, rect.width, 95), title, 90)
gui_label(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60), sub_title, 55, rl.GRAY)
if gui_button(rl.Rectangle(rect.x + rect.width - 300, rect.y, 300, 100), "Cancel"):
self._clear()
self.clear()
return 0
# Text box for input
self._sync_string_pointer()
rl.gui_text_box(rl.Rectangle(rect.x, rect.y + 160, rect.width, 100), self._string_pointer, self._max_text_size, True)
self._input_text = rl.ffi.string(self._string_pointer).decode("utf-8")
self._input_box.render(rl.Rectangle(rect.x, rect.y + 160, rect.width, 100))
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
@ -104,18 +102,7 @@ class Keyboard:
self._layout = keyboard_layouts["numbers"]
elif key == SYMBOL_KEY:
self._layout = keyboard_layouts["specials"]
elif key == BACKSPACE_KEY and len(self._input_text) > 0:
self._input_text = self._input_text[:-1]
elif key != BACKSPACE_KEY and len(self._input_text) < self._max_text_size:
self._input_text += key
def _clear(self):
self._input_text = ''
self._string_pointer[0] = b'\0'
def _sync_string_pointer(self):
"""Sync the C-string pointer with the internal Python string."""
encoded = self._input_text.encode("utf-8")[:self._max_text_size - 1] # Leave room for the null terminator
buffer = rl.ffi.buffer(self._string_pointer)
buffer[:len(encoded)] = encoded
self._string_pointer[len(encoded)] = b'\0' # Null terminator
elif key == BACKSPACE_KEY:
self._input_box.delete_char_before_cursor()
else:
self._input_box.add_char_at_cursor(key)

@ -64,7 +64,9 @@ class WifiManagerUI:
case StateNeedsAuth(network):
result = self.keyboard.render("Enter password", f"for {network.ssid}")
if result == 1:
self.connect_to_network(network, self.keyboard.text)
password = self.keyboard.text
self.keyboard.clear()
self.connect_to_network(network, password)
elif result == 0:
self.state = StateIdle()

Loading…
Cancel
Save