Release 260111
This commit is contained in:
9
system/ui/README.md
Normal file
9
system/ui/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# ui
|
||||
|
||||
The user interfaces here are built with [raylib](https://www.raylib.com/).
|
||||
|
||||
Quick start:
|
||||
* set `DEBUG_FPS=1` to show the FPS
|
||||
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
|
||||
* https://www.raylib.com/cheatsheet/cheatsheet.html
|
||||
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
|
||||
0
system/ui/lib/__init__.py
Normal file
0
system/ui/lib/__init__.py
Normal file
146
system/ui/lib/application.py
Normal file
146
system/ui/lib/application.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import atexit
|
||||
import os
|
||||
import time
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
DEFAULT_FPS = 60
|
||||
FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops
|
||||
FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning
|
||||
FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions
|
||||
|
||||
DEBUG_FPS = os.getenv("DEBUG_FPS") == '1'
|
||||
STRICT_MODE = os.getenv("STRICT_MODE") == '1'
|
||||
|
||||
DEFAULT_TEXT_SIZE = 60
|
||||
DEFAULT_TEXT_COLOR = rl.Color(200, 200, 200, 255)
|
||||
FONT_DIR = os.path.join(BASEDIR, "selfdrive/assets/fonts")
|
||||
|
||||
|
||||
class FontWeight(IntEnum):
|
||||
BLACK = 0
|
||||
BOLD = 1
|
||||
EXTRA_BOLD = 2
|
||||
EXTRA_LIGHT = 3
|
||||
MEDIUM = 4
|
||||
NORMAL = 5
|
||||
SEMI_BOLD = 6
|
||||
THIN = 7
|
||||
|
||||
|
||||
class GuiApplication:
|
||||
def __init__(self, width: int, height: int):
|
||||
self._fonts: dict[FontWeight, rl.Font] = {}
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._textures: list[rl.Texture] = []
|
||||
self._target_fps: int = DEFAULT_FPS
|
||||
self._last_fps_log_time: float = time.monotonic()
|
||||
|
||||
def init_window(self, title: str, fps: int=DEFAULT_FPS):
|
||||
atexit.register(self.close) # Automatically call close() on exit
|
||||
|
||||
rl.set_config_flags(rl.ConfigFlags.FLAG_MSAA_4X_HINT | rl.ConfigFlags.FLAG_VSYNC_HINT)
|
||||
rl.init_window(self._width, self._height, title)
|
||||
rl.set_target_fps(fps)
|
||||
|
||||
self._target_fps = fps
|
||||
self._set_styles()
|
||||
self._load_fonts()
|
||||
|
||||
def load_texture_from_image(self, file_name: str, width: int, height: int):
|
||||
"""Load and resize a texture, storing it for later automatic unloading."""
|
||||
image = rl.load_image(file_name)
|
||||
rl.image_resize(image, width, height)
|
||||
texture = rl.load_texture_from_image(image)
|
||||
# Set texture filtering to smooth the result
|
||||
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
|
||||
rl.unload_image(image)
|
||||
|
||||
self._textures.append(texture)
|
||||
return texture
|
||||
|
||||
def close(self):
|
||||
if not rl.is_window_ready():
|
||||
return
|
||||
|
||||
for texture in self._textures:
|
||||
rl.unload_texture(texture)
|
||||
self._textures = []
|
||||
|
||||
for font in self._fonts.values():
|
||||
rl.unload_font(font)
|
||||
self._fonts = {}
|
||||
|
||||
rl.close_window()
|
||||
|
||||
def render(self):
|
||||
while not rl.window_should_close():
|
||||
rl.begin_drawing()
|
||||
rl.clear_background(rl.BLACK)
|
||||
|
||||
yield
|
||||
|
||||
if DEBUG_FPS:
|
||||
rl.draw_fps(10, 10)
|
||||
|
||||
rl.end_drawing()
|
||||
self._monitor_fps()
|
||||
|
||||
def font(self, font_weight: FontWeight=FontWeight.NORMAL):
|
||||
return self._fonts[font_weight]
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self._width
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self._height
|
||||
|
||||
def _load_fonts(self):
|
||||
font_files = (
|
||||
"Inter-Black.ttf",
|
||||
"Inter-Bold.ttf",
|
||||
"Inter-ExtraBold.ttf",
|
||||
"Inter-ExtraLight.ttf",
|
||||
"Inter-Medium.ttf",
|
||||
"Inter-Regular.ttf",
|
||||
"Inter-SemiBold.ttf",
|
||||
"Inter-Thin.ttf"
|
||||
)
|
||||
|
||||
for index, font_file in enumerate(font_files):
|
||||
font = rl.load_font_ex(os.path.join(FONT_DIR, font_file), 120, None, 0)
|
||||
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
self._fonts[index] = font
|
||||
|
||||
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
|
||||
|
||||
def _set_styles(self):
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0)
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE)
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK))
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR))
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255)))
|
||||
|
||||
def _monitor_fps(self):
|
||||
fps = rl.get_fps()
|
||||
|
||||
# Log FPS drop below threshold at regular intervals
|
||||
if fps < self._target_fps * FPS_DROP_THRESHOLD:
|
||||
current_time = time.monotonic()
|
||||
if current_time - self._last_fps_log_time >= FPS_LOG_INTERVAL:
|
||||
cloudlog.warning(f"FPS dropped below {self._target_fps}: {fps}")
|
||||
self._last_fps_log_time = current_time
|
||||
|
||||
# Strict mode: terminate UI if FPS drops too much
|
||||
if STRICT_MODE and fps < self._target_fps * FPS_CRITICAL_THRESHOLD:
|
||||
cloudlog.error(f"FPS dropped critically below {fps}. Shutting down UI.")
|
||||
os._exit(1)
|
||||
|
||||
|
||||
gui_app = GuiApplication(2160, 1080)
|
||||
71
system/ui/lib/button.py
Normal file
71
system/ui/lib/button.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
|
||||
|
||||
class ButtonStyle(IntEnum):
|
||||
NORMAL = 0 # Most common, neutral buttons
|
||||
PRIMARY = 1 # For main actions
|
||||
DANGER = 2 # For critical actions, like reboot or delete
|
||||
TRANSPARENT = 3 # For buttons with transparent background and border
|
||||
|
||||
|
||||
DEFAULT_BUTTON_FONT_SIZE = 60
|
||||
BUTTON_ENABLED_TEXT_COLOR = rl.Color(228, 228, 228, 255)
|
||||
BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51)
|
||||
|
||||
|
||||
BUTTON_BACKGROUND_COLORS = {
|
||||
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
|
||||
ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255),
|
||||
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
}
|
||||
|
||||
BUTTON_PRESSED_BACKGROUND_COLORS = {
|
||||
ButtonStyle.NORMAL: rl.Color(74, 74, 74, 255),
|
||||
ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255),
|
||||
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
}
|
||||
|
||||
|
||||
def gui_button(
|
||||
rect: rl.Rectangle,
|
||||
text: str,
|
||||
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
|
||||
font_weight: FontWeight = FontWeight.MEDIUM,
|
||||
button_style: ButtonStyle = ButtonStyle.NORMAL,
|
||||
is_enabled: bool = True,
|
||||
border_radius: int = 10, # Corner rounding in pixels
|
||||
) -> int:
|
||||
result = 0
|
||||
|
||||
# Set background color based on button type
|
||||
bg_color = BUTTON_BACKGROUND_COLORS[button_style]
|
||||
if is_enabled and rl.check_collision_point_rec(rl.get_mouse_position(), rect):
|
||||
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[button_style]
|
||||
elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
result = 1
|
||||
|
||||
# Draw the button with rounded corners
|
||||
roundness = border_radius / (min(rect.width, rect.height) / 2)
|
||||
if button_style != ButtonStyle.TRANSPARENT:
|
||||
rl.draw_rectangle_rounded(rect, roundness, 20, bg_color)
|
||||
else:
|
||||
rl.draw_rectangle_rounded(rect, roundness, 20, rl.BLACK)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, roundness, 20, 2, rl.WHITE)
|
||||
|
||||
font = gui_app.font(font_weight)
|
||||
# Center text in the button
|
||||
text_size = rl.measure_text_ex(font, text, font_size, 0)
|
||||
text_pos = rl.Vector2(
|
||||
rect.x + (rect.width - text_size.x) // 2, rect.y + (rect.height - text_size.y) // 2
|
||||
)
|
||||
|
||||
# Draw the button text
|
||||
text_color = BUTTON_ENABLED_TEXT_COLOR if is_enabled else BUTTON_DISABLED_TEXT_COLOR
|
||||
rl.draw_text_ex(font, text, text_pos, font_size, 0, text_color)
|
||||
|
||||
return result
|
||||
57
system/ui/lib/label.py
Normal file
57
system/ui/lib/label.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_SIZE, DEFAULT_TEXT_COLOR
|
||||
from openpilot.system.ui.lib.utils import GuiStyleContext
|
||||
|
||||
|
||||
def gui_label(
|
||||
rect: rl.Rectangle,
|
||||
text: str,
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE
|
||||
):
|
||||
# Set font based on the provided weight
|
||||
font = gui_app.font(font_weight)
|
||||
|
||||
# Measure text size
|
||||
text_size = rl.measure_text_ex(font, text, font_size, 0)
|
||||
|
||||
# Calculate horizontal position based on alignment
|
||||
text_x = rect.x + {
|
||||
rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0,
|
||||
rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2,
|
||||
rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x,
|
||||
}.get(alignment, 0)
|
||||
|
||||
# Calculate vertical position based on alignment
|
||||
text_y = rect.y + {
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0,
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2,
|
||||
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y,
|
||||
}.get(alignment_vertical, 0)
|
||||
|
||||
# Draw the text in the specified rectangle
|
||||
rl.draw_text_ex(font, text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
||||
|
||||
|
||||
def gui_text_box(
|
||||
rect: rl.Rectangle,
|
||||
text: str,
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP
|
||||
):
|
||||
styles = [
|
||||
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, font_size),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, font_size),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_ALIGNMENT, alignment),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
|
||||
]
|
||||
|
||||
with GuiStyleContext(styles):
|
||||
rl.gui_label(rect, text)
|
||||
75
system/ui/lib/scroll_panel.py
Normal file
75
system/ui/lib/scroll_panel.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
|
||||
MOUSE_WHEEL_SCROLL_SPEED = 30
|
||||
INERTIA_FRICTION = 0.95 # The rate at which the inertia slows down
|
||||
MIN_VELOCITY = 0.1 # Minimum velocity before stopping the inertia
|
||||
|
||||
|
||||
class ScrollState(IntEnum):
|
||||
IDLE = 0
|
||||
DRAGGING_CONTENT = 1
|
||||
DRAGGING_SCROLLBAR = 2
|
||||
|
||||
|
||||
class GuiScrollPanel:
|
||||
def __init__(self, show_vertical_scroll_bar: bool = False):
|
||||
self._scroll_state: ScrollState = ScrollState.IDLE
|
||||
self._last_mouse_y: float = 0.0
|
||||
self._offset = rl.Vector2(0, 0)
|
||||
self._view = rl.Rectangle(0, 0, 0, 0)
|
||||
self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar
|
||||
self._velocity_y = 0.0 # Velocity for inertia
|
||||
|
||||
def handle_scroll(self, bounds: rl.Rectangle, content: rl.Rectangle) -> rl.Vector2:
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
|
||||
# Handle dragging logic
|
||||
if rl.check_collision_point_rec(mouse_pos, bounds) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
if self._scroll_state == ScrollState.IDLE:
|
||||
self._scroll_state = ScrollState.DRAGGING_CONTENT
|
||||
if self._show_vertical_scroll_bar:
|
||||
scrollbar_width = rl.gui_get_style(rl.GuiControl.LISTVIEW, rl.GuiListViewProperty.SCROLLBAR_WIDTH)
|
||||
scrollbar_x = bounds.x + bounds.width - scrollbar_width
|
||||
if mouse_pos.x >= scrollbar_x:
|
||||
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR
|
||||
|
||||
self._last_mouse_y = mouse_pos.y
|
||||
self._velocity_y = 0.0 # Reset velocity when drag starts
|
||||
|
||||
if self._scroll_state != ScrollState.IDLE:
|
||||
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
delta_y = mouse_pos.y - self._last_mouse_y
|
||||
|
||||
if self._scroll_state == ScrollState.DRAGGING_CONTENT:
|
||||
self._offset.y += delta_y
|
||||
else:
|
||||
delta_y = -delta_y
|
||||
|
||||
self._last_mouse_y = mouse_pos.y
|
||||
self._velocity_y = delta_y # Update velocity during drag
|
||||
else:
|
||||
self._scroll_state = ScrollState.IDLE
|
||||
|
||||
# Handle mouse wheel scrolling
|
||||
wheel_move = rl.get_mouse_wheel_move()
|
||||
if self._show_vertical_scroll_bar:
|
||||
self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20)
|
||||
rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view)
|
||||
else:
|
||||
self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED
|
||||
|
||||
# Apply inertia (continue scrolling after mouse release)
|
||||
if self._scroll_state == ScrollState.IDLE:
|
||||
self._offset.y += self._velocity_y
|
||||
self._velocity_y *= INERTIA_FRICTION # Slow down velocity over time
|
||||
|
||||
# Stop scrolling when velocity is low
|
||||
if abs(self._velocity_y) < MIN_VELOCITY:
|
||||
self._velocity_y = 0.0
|
||||
|
||||
# Ensure scrolling doesn't go beyond bounds
|
||||
max_scroll_y = max(content.height - bounds.height, 0)
|
||||
self._offset.y = max(min(self._offset.y, 0), -max_scroll_y)
|
||||
|
||||
return self._offset
|
||||
18
system/ui/lib/utils.py
Normal file
18
system/ui/lib/utils.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import pyray as rl
|
||||
|
||||
|
||||
class GuiStyleContext:
|
||||
def __init__(self, styles: list[tuple[int, int, int]]):
|
||||
"""styles is a list of tuples (control, prop, new_value)"""
|
||||
self.styles = styles
|
||||
self.prev_styles: list[tuple[int, int, int]] = []
|
||||
|
||||
def __enter__(self):
|
||||
for control, prop, new_value in self.styles:
|
||||
prev_value = rl.gui_get_style(control, prop)
|
||||
self.prev_styles.append((control, prop, prev_value))
|
||||
rl.gui_set_style(control, prop, new_value)
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
for control, prop, prev_value in self.prev_styles:
|
||||
rl.gui_set_style(control, prop, prev_value)
|
||||
118
system/ui/reset.py
Executable file
118
system/ui/reset.py
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import pyray as rl
|
||||
import sys
|
||||
import threading
|
||||
from enum import IntEnum
|
||||
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_label, gui_text_box
|
||||
|
||||
NVME = "/dev/nvme0n1"
|
||||
USERDATA = "/dev/disk/by-partlabel/userdata"
|
||||
|
||||
|
||||
class ResetMode(IntEnum):
|
||||
USER_RESET = 0 # user initiated a factory reset from openpilot
|
||||
RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover
|
||||
FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata
|
||||
|
||||
|
||||
class ResetState(IntEnum):
|
||||
NONE = 0
|
||||
CONFIRM = 1
|
||||
RESETTING = 2
|
||||
FAILED = 3
|
||||
|
||||
|
||||
class Reset:
|
||||
def __init__(self, mode):
|
||||
self.mode = mode
|
||||
self.reset_state = ResetState.NONE
|
||||
|
||||
def do_reset(self):
|
||||
# Best effort to wipe NVME
|
||||
os.system(f"sudo umount {NVME}")
|
||||
os.system(f"yes | sudo mkfs.ext4 {NVME}")
|
||||
|
||||
# Removing data and formatting
|
||||
rm = os.system("sudo rm -rf /data/*")
|
||||
os.system(f"sudo umount {USERDATA}")
|
||||
fmt = os.system(f"yes | sudo mkfs.ext4 {USERDATA}")
|
||||
|
||||
if rm == 0 or fmt == 0:
|
||||
os.system("sudo reboot")
|
||||
else:
|
||||
self.reset_state = ResetState.FAILED
|
||||
|
||||
def start_reset(self):
|
||||
self.reset_state = ResetState.RESETTING
|
||||
threading.Timer(0.1, self.do_reset).start()
|
||||
|
||||
def render(self, rect: rl.Rectangle):
|
||||
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100)
|
||||
gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD)
|
||||
|
||||
text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100)
|
||||
gui_text_box(text_rect, self.get_body_text(), 90)
|
||||
|
||||
button_height = 160
|
||||
button_spacing = 50
|
||||
button_top = rect.y + rect.height - button_height
|
||||
button_width = (rect.width - button_spacing) / 2.0
|
||||
|
||||
if self.reset_state != ResetState.RESETTING:
|
||||
if self.mode == ResetMode.RECOVER or self.reset_state == ResetState.FAILED:
|
||||
if gui_button(rl.Rectangle(rect.x, button_top, button_width, button_height), "Reboot"):
|
||||
os.system("sudo reboot")
|
||||
elif self.mode == ResetMode.USER_RESET:
|
||||
if gui_button(rl.Rectangle(rect.x, button_top, button_width, button_height), "Cancel"):
|
||||
return False
|
||||
|
||||
if self.reset_state != ResetState.FAILED:
|
||||
if gui_button(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height),
|
||||
"Confirm", button_style=ButtonStyle.PRIMARY):
|
||||
self.confirm()
|
||||
|
||||
return True
|
||||
|
||||
def confirm(self):
|
||||
if self.reset_state == ResetState.CONFIRM:
|
||||
self.start_reset()
|
||||
else:
|
||||
self.reset_state = ResetState.CONFIRM
|
||||
|
||||
def get_body_text(self):
|
||||
if self.reset_state == ResetState.CONFIRM:
|
||||
return "Are you sure you want to reset your device?"
|
||||
if self.reset_state == ResetState.RESETTING:
|
||||
return "Resetting device...\nThis may take up to a minute."
|
||||
if self.reset_state == ResetState.FAILED:
|
||||
return "Reset failed. Reboot to try again."
|
||||
if self.mode == ResetMode.RECOVER:
|
||||
return "Unable to mount data partition. Partition may be corrupted. Press confirm to erase and reset your device."
|
||||
return "System reset triggered. Press confirm to erase all content and settings. Press cancel to resume boot."
|
||||
|
||||
|
||||
def main():
|
||||
mode = ResetMode.USER_RESET
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == '--recover':
|
||||
mode = ResetMode.RECOVER
|
||||
elif sys.argv[1] == "--format":
|
||||
mode = ResetMode.FORMAT
|
||||
|
||||
gui_app.init_window("System Reset")
|
||||
reset = Reset(mode)
|
||||
|
||||
if mode == ResetMode.FORMAT:
|
||||
reset.start_reset()
|
||||
|
||||
for _ in gui_app.render():
|
||||
if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)):
|
||||
break
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
78
system/ui/spinner.py
Executable file
78
system/ui/spinner.py
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
import pyray as rl
|
||||
import os
|
||||
import threading
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
# Constants
|
||||
PROGRESS_BAR_WIDTH = 1000
|
||||
PROGRESS_BAR_HEIGHT = 20
|
||||
DEGREES_PER_SECOND = 360.0 # one full rotation per second
|
||||
MARGIN = 200
|
||||
TEXTURE_SIZE = 360
|
||||
FONT_SIZE = 80
|
||||
DARKGRAY = (55, 55, 55, 255)
|
||||
|
||||
|
||||
def clamp(value, min_value, max_value):
|
||||
return max(min(value, max_value), min_value)
|
||||
|
||||
|
||||
class Spinner:
|
||||
def __init__(self):
|
||||
self._comma_texture = gui_app.load_texture_from_image(os.path.join(BASEDIR, "selfdrive/assets/img_spinner_comma.png"), TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
self._spinner_texture = gui_app.load_texture_from_image(os.path.join(BASEDIR, "selfdrive/assets/img_spinner_track.png"), TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
self._rotation = 0.0
|
||||
self._text: str = ""
|
||||
self._progress: int | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
with self._lock:
|
||||
if text.isdigit():
|
||||
self._progress = clamp(int(text), 0, 100)
|
||||
self._text = ""
|
||||
else:
|
||||
self._progress = None
|
||||
self._text = text
|
||||
|
||||
def render(self):
|
||||
center = rl.Vector2(gui_app.width / 2.0, gui_app.height / 2.0)
|
||||
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
|
||||
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
|
||||
|
||||
delta_time = rl.get_frame_time()
|
||||
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
|
||||
|
||||
# Draw rotating spinner and static comma logo
|
||||
rl.draw_texture_pro(self._spinner_texture, rl.Rectangle(0, 0, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
rl.Rectangle(center.x, center.y, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
spinner_origin, self._rotation, rl.WHITE)
|
||||
rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE)
|
||||
|
||||
# Display progress bar or text based on user input
|
||||
y_pos = rl.get_screen_height() - MARGIN - PROGRESS_BAR_HEIGHT
|
||||
with self._lock:
|
||||
progress = self._progress
|
||||
text = self._text
|
||||
|
||||
if progress is not None:
|
||||
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
|
||||
|
||||
bar.width *= progress / 100.0
|
||||
rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
|
||||
elif text:
|
||||
text_size = rl.measure_text_ex(gui_app.font(), text, FONT_SIZE, 1.0)
|
||||
rl.draw_text_ex(gui_app.font(), text,
|
||||
rl.Vector2(center.x - text_size.x / 2, y_pos), FONT_SIZE, 1.0, rl.WHITE)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("Spinner")
|
||||
spinner = Spinner()
|
||||
spinner.set_text("Spinner text")
|
||||
for _ in gui_app.render():
|
||||
spinner.render()
|
||||
73
system/ui/text.py
Executable file
73
system/ui/text.py
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
import pyray as rl
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
MARGIN = 50
|
||||
SPACING = 50
|
||||
FONT_SIZE = 72
|
||||
LINE_HEIGHT = 80
|
||||
BUTTON_SIZE = rl.Vector2(310, 160)
|
||||
|
||||
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
|
||||
The text is long enough to demonstrate scrolling and word wrapping.""" * 30
|
||||
|
||||
def wrap_text(text, font_size, max_width):
|
||||
lines = []
|
||||
font = gui_app.font()
|
||||
|
||||
for paragraph in text.split("\n"):
|
||||
if not paragraph.strip():
|
||||
lines.append("")
|
||||
continue
|
||||
indent = re.match(r"^\s*", paragraph).group()
|
||||
current_line = indent
|
||||
for word in paragraph.split():
|
||||
test_line = current_line + word + " "
|
||||
if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
lines.append(current_line)
|
||||
current_line = word + " "
|
||||
current_line = current_line.rstrip()
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines
|
||||
|
||||
class TextWindow:
|
||||
def __init__(self, text: str):
|
||||
self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2)
|
||||
self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20)
|
||||
self._content_rect = rl.Rectangle(0, 0, self._textarea_rect.width - 20, len(self._wrapped_lines) * LINE_HEIGHT)
|
||||
self._scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True)
|
||||
|
||||
def render(self):
|
||||
scroll = self._scroll_panel.handle_scroll(self._textarea_rect, self._content_rect)
|
||||
rl.begin_scissor_mode(int(self._textarea_rect.x), int(self._textarea_rect.y), int(self._textarea_rect.width), int(self._textarea_rect.height))
|
||||
for i, line in enumerate(self._wrapped_lines):
|
||||
position = rl.Vector2(self._textarea_rect.x + scroll.x, self._textarea_rect.y + scroll.y + i * LINE_HEIGHT)
|
||||
if position.y + LINE_HEIGHT < self._textarea_rect.y or position.y > self._textarea_rect.y + self._textarea_rect.height:
|
||||
continue
|
||||
rl.draw_text_ex(gui_app.font(), line, position, FONT_SIZE, 0, rl.WHITE)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
button_bounds = rl.Rectangle(gui_app.width - MARGIN - BUTTON_SIZE.x, gui_app.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y)
|
||||
ret = gui_button(button_bounds, "Reboot", button_style=ButtonStyle.TRANSPARENT)
|
||||
if ret:
|
||||
HARDWARE.reboot()
|
||||
return ret
|
||||
|
||||
|
||||
def show_text_in_window(text: str):
|
||||
gui_app.init_window("Text")
|
||||
text_window = TextWindow(text)
|
||||
for _ in gui_app.render():
|
||||
text_window.render()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
show_text_in_window(DEMO_TEXT)
|
||||
197
system/ui/updater.py
Executable file
197
system/ui/updater.py
Executable file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_text_box, gui_label
|
||||
|
||||
# Constants
|
||||
MARGIN = 50
|
||||
BUTTON_HEIGHT = 160
|
||||
BUTTON_WIDTH = 400
|
||||
PROGRESS_BAR_HEIGHT = 72
|
||||
TITLE_FONT_SIZE = 80
|
||||
BODY_FONT_SIZE = 65
|
||||
BACKGROUND_COLOR = rl.BLACK
|
||||
PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
|
||||
PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
|
||||
|
||||
|
||||
class Screen(IntEnum):
|
||||
PROMPT = 0
|
||||
WIFI = 1
|
||||
PROGRESS = 2
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self, updater_path, manifest_path):
|
||||
self.updater = updater_path
|
||||
self.manifest = manifest_path
|
||||
self.current_screen = Screen.PROMPT
|
||||
|
||||
self.progress_value = 0
|
||||
self.progress_text = "Loading..."
|
||||
self.show_reboot_button = False
|
||||
self.process = None
|
||||
self.update_thread = None
|
||||
|
||||
def install_update(self):
|
||||
self.current_screen = Screen.PROGRESS
|
||||
self.progress_value = 0
|
||||
self.progress_text = "Downloading..."
|
||||
self.show_reboot_button = False
|
||||
|
||||
# Start the update process in a separate thread
|
||||
self.update_thread = threading.Thread(target=self._run_update_process)
|
||||
self.update_thread.daemon = True
|
||||
self.update_thread.start()
|
||||
|
||||
def _run_update_process(self):
|
||||
# TODO: just import it and run in a thread without a subprocess
|
||||
cmd = [self.updater, "--swap", self.manifest]
|
||||
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, universal_newlines=True)
|
||||
|
||||
for line in self.process.stdout:
|
||||
parts = line.strip().split(":")
|
||||
if len(parts) == 2:
|
||||
self.progress_text = parts[0]
|
||||
try:
|
||||
self.progress_value = int(float(parts[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
exit_code = self.process.wait()
|
||||
if exit_code == 0:
|
||||
HARDWARE.reboot()
|
||||
else:
|
||||
self.progress_text = "Update failed"
|
||||
self.show_reboot_button = True
|
||||
|
||||
def render_prompt_screen(self):
|
||||
# Title
|
||||
title_rect = rl.Rectangle(MARGIN + 50, 250, gui_app.width - MARGIN * 2 - 100, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
|
||||
|
||||
# Description
|
||||
desc_text = "An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. \
|
||||
The download size is approximately 1GB."
|
||||
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE + 75, gui_app.width - MARGIN * 2 - 100, BODY_FONT_SIZE * 3)
|
||||
gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
|
||||
|
||||
# Buttons at the bottom
|
||||
button_y = gui_app.height - MARGIN - BUTTON_HEIGHT
|
||||
button_width = (gui_app.width - MARGIN * 3) // 2
|
||||
|
||||
# WiFi button
|
||||
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
|
||||
if gui_button(wifi_button_rect, "Connect to Wi-Fi"):
|
||||
self.current_screen = Screen.WIFI
|
||||
return # Return to avoid processing other buttons after screen change
|
||||
|
||||
# Install button
|
||||
install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
|
||||
if gui_button(install_button_rect, "Install", button_style=ButtonStyle.PRIMARY):
|
||||
self.install_update()
|
||||
return # Return to avoid further processing after action
|
||||
|
||||
def render_wifi_screen(self):
|
||||
# Title and back button
|
||||
title_rect = rl.Rectangle(MARGIN + 50, MARGIN, gui_app.width - MARGIN * 2 - 100, 60)
|
||||
gui_label(title_rect, "Wi-Fi Networks", 60, font_weight=FontWeight.BOLD)
|
||||
|
||||
back_button_rect = rl.Rectangle(MARGIN, gui_app.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
if gui_button(back_button_rect, "Back"):
|
||||
self.current_screen = Screen.PROMPT
|
||||
return # Return to avoid processing other interactions after screen change
|
||||
|
||||
# Draw placeholder for WiFi implementation
|
||||
placeholder_rect = rl.Rectangle(
|
||||
MARGIN,
|
||||
title_rect.y + title_rect.height + MARGIN,
|
||||
gui_app.width - MARGIN * 2,
|
||||
gui_app.height - title_rect.height - MARGIN * 3 - BUTTON_HEIGHT
|
||||
)
|
||||
|
||||
# Draw rounded rectangle background
|
||||
rl.draw_rectangle_rounded(
|
||||
placeholder_rect,
|
||||
0.1,
|
||||
10,
|
||||
rl.Color(41, 41, 41, 255)
|
||||
)
|
||||
|
||||
# Draw placeholder text
|
||||
placeholder_text = "WiFi Implementation Placeholder"
|
||||
text_size = rl.measure_text_ex(gui_app.font(), placeholder_text, 80, 1)
|
||||
text_pos = rl.Vector2(
|
||||
placeholder_rect.x + (placeholder_rect.width - text_size.x) / 2,
|
||||
placeholder_rect.y + (placeholder_rect.height - text_size.y) / 2
|
||||
)
|
||||
rl.draw_text_ex(gui_app.font(), placeholder_text, text_pos, 80, 1, rl.WHITE)
|
||||
|
||||
# Draw instructions
|
||||
instructions_text = "Real WiFi functionality would be implemented here"
|
||||
instructions_size = rl.measure_text_ex(gui_app.font(), instructions_text, 40, 1)
|
||||
instructions_pos = rl.Vector2(
|
||||
placeholder_rect.x + (placeholder_rect.width - instructions_size.x) / 2,
|
||||
text_pos.y + text_size.y + 20
|
||||
)
|
||||
rl.draw_text_ex(gui_app.font(), instructions_text, instructions_pos, 40, 1, rl.GRAY)
|
||||
|
||||
def render_progress_screen(self):
|
||||
title_rect = rl.Rectangle(MARGIN + 100, 330, gui_app.width - MARGIN * 2 - 200, 100)
|
||||
gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
|
||||
|
||||
# Progress bar
|
||||
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, gui_app.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
|
||||
|
||||
# Calculate the width of the progress chunk
|
||||
progress_width = (bar_rect.width * self.progress_value) / 100
|
||||
if progress_width > 0:
|
||||
progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
|
||||
rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
|
||||
|
||||
# Show reboot button if needed
|
||||
if self.show_reboot_button:
|
||||
reboot_rect = rl.Rectangle(MARGIN + 100, gui_app.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
if gui_button(reboot_rect, "Reboot"):
|
||||
# Return True to signal main loop to exit before rebooting
|
||||
HARDWARE.reboot()
|
||||
return
|
||||
|
||||
def render(self):
|
||||
if self.current_screen == Screen.PROMPT:
|
||||
self.render_prompt_screen()
|
||||
elif self.current_screen == Screen.WIFI:
|
||||
self.render_wifi_screen()
|
||||
elif self.current_screen == Screen.PROGRESS:
|
||||
self.render_progress_screen()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: updater.py <updater_path> <manifest_path>")
|
||||
sys.exit(1)
|
||||
|
||||
updater_path = sys.argv[1]
|
||||
manifest_path = sys.argv[2]
|
||||
|
||||
try:
|
||||
gui_app.init_window("System Update")
|
||||
updater = Updater(updater_path, manifest_path)
|
||||
for _ in gui_app.render():
|
||||
updater.render()
|
||||
finally:
|
||||
# Make sure we clean up even if there's an error
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
62
system/ui/widgets/confirm_dialog.py
Normal file
62
system/ui/widgets/confirm_dialog.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_text_box
|
||||
|
||||
# Constants for dialog dimensions and styling
|
||||
DIALOG_WIDTH = 1520
|
||||
DIALOG_HEIGHT = 600
|
||||
BUTTON_HEIGHT = 160
|
||||
MARGIN = 50
|
||||
TEXT_AREA_HEIGHT_REDUCTION = 200
|
||||
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
|
||||
|
||||
|
||||
def confirm_dialog(rect: rl.Rectangle, message: str, confirm_text: str, cancel_text: str = "Cancel") -> int:
|
||||
# Calculate dialog position and size, centered within the parent rectangle
|
||||
dialog_x = rect.x + (rect.width - DIALOG_WIDTH) / 2
|
||||
dialog_y = rect.y + (rect.height - DIALOG_HEIGHT) / 2
|
||||
dialog_rect = rl.Rectangle(dialog_x, dialog_y, DIALOG_WIDTH, DIALOG_HEIGHT)
|
||||
|
||||
# Calculate button positions at the bottom of the dialog
|
||||
bottom = dialog_rect.y + dialog_rect.height
|
||||
button_width = (dialog_rect.width - 3 * MARGIN) // 2
|
||||
no_button_x = dialog_rect.x + MARGIN
|
||||
yes_button_x = dialog_rect.x + dialog_rect.width - button_width - MARGIN
|
||||
button_y = bottom - BUTTON_HEIGHT - MARGIN
|
||||
no_button = rl.Rectangle(no_button_x, button_y, button_width, BUTTON_HEIGHT)
|
||||
yes_button = rl.Rectangle(yes_button_x, button_y, button_width, BUTTON_HEIGHT)
|
||||
|
||||
# Draw the dialog background
|
||||
rl.draw_rectangle(
|
||||
int(dialog_rect.x),
|
||||
int(dialog_rect.y),
|
||||
int(dialog_rect.width),
|
||||
int(dialog_rect.height),
|
||||
BACKGROUND_COLOR,
|
||||
)
|
||||
|
||||
# Draw the message in the dialog, centered
|
||||
text_rect = rl.Rectangle(dialog_rect.x, dialog_rect.y, dialog_rect.width, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION)
|
||||
gui_text_box(
|
||||
text_rect,
|
||||
message,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
)
|
||||
|
||||
# Initialize result; -1 means no action taken yet
|
||||
result = -1
|
||||
|
||||
# Check for keyboard input for accessibility
|
||||
if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
|
||||
result = 1 # Confirm
|
||||
elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
||||
result = 0 # Cancel
|
||||
|
||||
# Check for button clicks
|
||||
if gui_button(yes_button, confirm_text, button_style=ButtonStyle.PRIMARY):
|
||||
result = 1 # Confirm
|
||||
if gui_button(no_button, cancel_text):
|
||||
result = 0 # Cancel
|
||||
|
||||
return result
|
||||
104
system/ui/widgets/keyboard.py
Normal file
104
system/ui/widgets/keyboard.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.button import gui_button
|
||||
from openpilot.system.ui.lib.label import gui_label
|
||||
|
||||
# Constants for special keys
|
||||
BACKSPACE_KEY = "<-"
|
||||
ENTER_KEY = "Enter"
|
||||
SPACE_KEY = " "
|
||||
SHIFT_KEY = "↑"
|
||||
SHIFT_DOWN_KEY = "↓"
|
||||
NUMERIC_KEY = "123"
|
||||
SYMBOL_KEY = "#+="
|
||||
ABC_KEY = "ABC"
|
||||
|
||||
# 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_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_DOWN_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:
|
||||
def __init__(self, max_text_size: int = 255):
|
||||
self._layout = keyboard_layouts["lowercase"]
|
||||
self._input_text = ""
|
||||
self._max_text_size = max_text_size
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._input_text
|
||||
|
||||
def clear(self):
|
||||
self._input_text = ""
|
||||
|
||||
def render(self, rect, title, sub_title):
|
||||
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"):
|
||||
return -1
|
||||
|
||||
# Text box for input
|
||||
rl.gui_text_box(rl.Rectangle(rect.x, rect.y + 160, rect.width, 100), self._input_text, self._max_text_size, True)
|
||||
|
||||
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(self._layout[2]) - 1) * h_space) / len(self._layout[2])
|
||||
|
||||
# Iterate over the rows of keys in the current layout
|
||||
for row, keys in enumerate(self._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
|
||||
|
||||
if gui_button(key_rect, key):
|
||||
if key == ENTER_KEY:
|
||||
return 1
|
||||
else:
|
||||
self.handle_key_press(key)
|
||||
|
||||
return 0
|
||||
|
||||
def handle_key_press(self, key):
|
||||
if key in (SHIFT_DOWN_KEY, ABC_KEY):
|
||||
self._layout = keyboard_layouts["lowercase"]
|
||||
elif key == SHIFT_KEY:
|
||||
self._layout = keyboard_layouts["uppercase"]
|
||||
elif key == NUMERIC_KEY:
|
||||
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
|
||||
Reference in New Issue
Block a user