diff --git a/system/ui/lib/inputbox.py b/system/ui/lib/inputbox.py new file mode 100644 index 0000000000..9bac5251aa --- /dev/null +++ b/system/ui/lib/inputbox.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)) diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index d496c5f749..d81bf3090e 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -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) diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index 49e2dd8296..a265111a46 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -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()