diff --git a/SConstruct b/SConstruct index 0f25a4253d5344..c40f1189858da9 100644 --- a/SConstruct +++ b/SConstruct @@ -352,7 +352,6 @@ SConscript(['rednose/SConscript']) # Build system services SConscript([ - 'system/ui/SConscript', 'system/proclogd/SConscript', 'system/ubloxd/SConscript', 'system/loggerd/SConscript', diff --git a/scripts/lint/lint.sh b/scripts/lint/lint.sh index d4660facef3277..578c63cd1894d4 100755 --- a/scripts/lint/lint.sh +++ b/scripts/lint/lint.sh @@ -53,7 +53,6 @@ function run_tests() { run "check_shebang_scripts_are_executable" python3 -m pre_commit_hooks.check_shebang_scripts_are_executable $ALL_FILES run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES - run "check_raylib_includes" $DIR/check_raylib_includes.sh $ALL_FILES if [[ -z "$FAST" ]]; then run "mypy" mypy $PYTHON_FILES diff --git a/system/ui/.gitignore b/system/ui/.gitignore deleted file mode 100644 index 1d32f7d8777bcf..00000000000000 --- a/system/ui/.gitignore +++ /dev/null @@ -1 +0,0 @@ -spinner diff --git a/system/ui/SConscript b/system/ui/SConscript deleted file mode 100644 index eccdb7c7e21932..00000000000000 --- a/system/ui/SConscript +++ /dev/null @@ -1,20 +0,0 @@ -import subprocess - -Import('env', 'arch', 'common') - -renv = env.Clone() - -rayutil = env.Library("rayutil", ['raylib/util.cc'], LIBS='raylib') -linked_libs = ['raylib', rayutil, common] -renv['LIBPATH'] += [f'#third_party/raylib/{arch}/'] - -mac_frameworks = [] -if arch == "Darwin": - mac_frameworks += ['OpenCL', 'CoreVideo', 'Cocoa', 'GLUT', 'CoreFoundation', 'OpenGL', 'IOKit'] -elif arch == 'larch64': - linked_libs += ['GLESv2', 'GL', 'EGL', 'wayland-client', 'wayland-egl'] -else: - linked_libs += ['OpenCL', 'dl', 'pthread'] - -if arch != 'aarch64': - renv.Program("spinner", ["raylib/spinner.cc"], LIBS=linked_libs, FRAMEWORKS=mac_frameworks) diff --git a/system/ui/lib/__init__.py b/system/ui/lib/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py new file mode 100644 index 00000000000000..908b8e0cd49a77 --- /dev/null +++ b/system/ui/lib/application.py @@ -0,0 +1,100 @@ +import atexit +import os +import pyray as rl +from enum import IntEnum +from openpilot.common.basedir import BASEDIR + +DEFAULT_TEXT_SIZE = 60 +DEFAULT_FPS = 60 +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] = [] + + 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._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): + for texture in self._textures: + rl.unload_texture(texture) + + for font in self._fonts.values(): + rl.unload_font(font) + + rl.close_window() + + def font(self, font_wight: FontWeight=FontWeight.NORMAL): + return self._fonts[font_wight] + + @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(rl.Color(200, 200, 200, 255))) + rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.Color(30, 30, 30, 255))) + rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255))) + + +gui_app = GuiApplication(2160, 1080) diff --git a/system/ui/lib/button.py b/system/ui/lib/button.py new file mode 100644 index 00000000000000..87a11b6b43da33 --- /dev/null +++ b/system/ui/lib/button.py @@ -0,0 +1,14 @@ + +import pyray as rl +from openpilot.system.ui.lib.utils import GuiStyleContext + +BUTTON_DEFAULT_BG_COLOR = rl.Color(51, 51, 51, 255) + +def gui_button(rect, text, bg_color=BUTTON_DEFAULT_BG_COLOR): + styles = [ + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE), + (rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(bg_color)) + ] + + with GuiStyleContext(styles): + return rl.gui_button(rect, text) diff --git a/system/ui/lib/label.py b/system/ui/lib/label.py new file mode 100644 index 00000000000000..37b66582f9df79 --- /dev/null +++ b/system/ui/lib/label.py @@ -0,0 +1,13 @@ +import pyray as rl +from openpilot.system.ui.lib.utils import GuiStyleContext + +def gui_label(rect, text, font_size): + styles = [ + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, font_size), + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_LINE_SPACING, font_size), + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP), + (rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD) + ] + + with GuiStyleContext(styles): + rl.gui_label(rect, text) diff --git a/system/ui/lib/scroll_panel.py b/system/ui/lib/scroll_panel.py new file mode 100644 index 00000000000000..b8fa211ec17000 --- /dev/null +++ b/system/ui/lib/scroll_panel.py @@ -0,0 +1,40 @@ +import pyray as rl +from cffi import FFI + +MOUSE_WHEEL_SCROLL_SPEED = 30 + +class GuiScrollPanel: + def __init__(self, bounds: rl.Rectangle, content: rl.Rectangle, show_vertical_scroll_bar: bool = False): + self._dragging: bool = False + self._last_mouse_y: float = 0.0 + self._bounds = bounds + self._content = content + self._scroll = rl.Vector2(0, 0) + self._view = rl.Rectangle(0, 0, 0, 0) + self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar + + def handle_scroll(self)-> rl.Vector2: + mouse_pos = rl.get_mouse_position() + if rl.check_collision_point_rec(mouse_pos, self._bounds) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT): + if not self._dragging: + self._dragging = True + self._last_mouse_y = mouse_pos.y + + if self._dragging: + if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT): + delta_y = mouse_pos.y - self._last_mouse_y + self._scroll.y += delta_y + self._last_mouse_y = mouse_pos.y + else: + self._dragging = False + + wheel_move = rl.get_mouse_wheel_move() + if self._show_vertical_scroll_bar: + self._scroll.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20) + rl.gui_scroll_panel(self._bounds, FFI().NULL, self._content, self._scroll, self._view) + else: + self._scroll.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED + max_scroll_y = self._content.height - self._bounds.height + self._scroll.y = max(min(self._scroll.y, 0), -max_scroll_y) + + return self._scroll diff --git a/system/ui/lib/utils.py b/system/ui/lib/utils.py new file mode 100644 index 00000000000000..e6fc2ee0da27a7 --- /dev/null +++ b/system/ui/lib/utils.py @@ -0,0 +1,17 @@ +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) diff --git a/system/ui/raylib/raylib.h b/system/ui/raylib/raylib.h deleted file mode 100644 index bdd07a33124506..00000000000000 --- a/system/ui/raylib/raylib.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#define OPENPILOT_RAYLIB - -#include "third_party/raylib/include/raylib.h" diff --git a/system/ui/raylib/spinner.cc b/system/ui/raylib/spinner.cc deleted file mode 100644 index 71909536314af1..00000000000000 --- a/system/ui/raylib/spinner.cc +++ /dev/null @@ -1,66 +0,0 @@ -#include -#include -#include - -#include "system/ui/raylib/util.h" - -constexpr int kProgressBarWidth = 1000; -constexpr int kProgressBarHeight = 20; -constexpr float kRotationRate = 12.0f; -constexpr int kMargin = 200; -constexpr int kTextureSize = 360; -constexpr int kFontSize = 80; - -int main(int argc, char *argv[]) { - App app("spinner", 30); - - // Turn off input buffering for std::cin - std::cin.sync_with_stdio(false); - std::cin.tie(nullptr); - - Texture2D commaTexture = LoadTextureResized("../../selfdrive/assets/img_spinner_comma.png", kTextureSize); - Texture2D spinnerTexture = LoadTextureResized("../../selfdrive/assets/img_spinner_track.png", kTextureSize); - - float rotation = 0.0f; - std::string userInput; - - while (!WindowShouldClose()) { - BeginDrawing(); - ClearBackground(RAYLIB_BLACK); - - rotation = fmod(rotation + kRotationRate, 360.0f); - Vector2 center = {GetScreenWidth() / 2.0f, GetScreenHeight() / 2.0f}; - const Vector2 spinnerOrigin{kTextureSize / 2.0f, kTextureSize / 2.0f}; - const Vector2 commaPosition{center.x - kTextureSize / 2.0f, center.y - kTextureSize / 2.0f}; - - // Draw rotating spinner and static comma logo - DrawTexturePro(spinnerTexture, {0, 0, (float)kTextureSize, (float)kTextureSize}, - {center.x, center.y, (float)kTextureSize, (float)kTextureSize}, - spinnerOrigin, rotation, RAYLIB_WHITE); - DrawTextureV(commaTexture, commaPosition, RAYLIB_WHITE); - - // Check for user input - if (std::cin.rdbuf()->in_avail() > 0) { - std::getline(std::cin, userInput); - } - - // Display either a progress bar or user input text based on input - if (!userInput.empty()) { - float yPos = GetScreenHeight() - kMargin - kProgressBarHeight; - if (std::all_of(userInput.begin(), userInput.end(), ::isdigit)) { - Rectangle bar = {center.x - kProgressBarWidth / 2.0f, yPos, kProgressBarWidth, kProgressBarHeight}; - DrawRectangleRounded(bar, 0.5f, 10, RAYLIB_GRAY); - - int progress = std::clamp(std::stoi(userInput), 0, 100); - bar.width *= progress / 100.0f; - DrawRectangleRounded(bar, 0.5f, 10, RAYLIB_RAYWHITE); - } else { - Vector2 textSize = MeasureTextEx(app.getFont(), userInput.c_str(), kFontSize, 1.0); - DrawTextEx(app.getFont(), userInput.c_str(), {center.x - textSize.x / 2, yPos}, kFontSize, 1.0, RAYLIB_WHITE); - } - } - - EndDrawing(); - } - return 0; -} diff --git a/system/ui/raylib/util.cc b/system/ui/raylib/util.cc deleted file mode 100644 index 32904bd724f902..00000000000000 --- a/system/ui/raylib/util.cc +++ /dev/null @@ -1,65 +0,0 @@ -#include "system/ui/raylib/util.h" - -#include -#include - -#undef GREEN -#undef RED -#undef YELLOW -#include "common/swaglog.h" -#include "system/hardware/hw.h" - -constexpr std::array(FontWeight::Count)> FONT_FILE_PATHS = { - "../../selfdrive/assets/fonts/Inter-Black.ttf", - "../../selfdrive/assets/fonts/Inter-Bold.ttf", - "../../selfdrive/assets/fonts/Inter-ExtraBold.ttf", - "../../selfdrive/assets/fonts/Inter-ExtraLight.ttf", - "../../selfdrive/assets/fonts/Inter-Medium.ttf", - "../../selfdrive/assets/fonts/Inter-Regular.ttf", - "../../selfdrive/assets/fonts/Inter-SemiBold.ttf", - "../../selfdrive/assets/fonts/Inter-Thin.ttf", -}; - -Texture2D LoadTextureResized(const char *fileName, int size) { - Image img = LoadImage(fileName); - ImageResize(&img, size, size); - Texture2D texture = LoadTextureFromImage(img); - return texture; -} - -App *pApp = nullptr; - -App::App(const char *title, int fps) { - // Ensure the current dir matches the exectuable's directory - auto self_path = util::readlink("/proc/self/exe"); - auto exe_dir = std::filesystem::path(self_path).parent_path(); - chdir(exe_dir.c_str()); - - Hardware::set_display_power(true); - Hardware::set_brightness(65); - - // SetTraceLogLevel(LOG_NONE); - InitWindow(2160, 1080, title); - SetTargetFPS(fps); - - // Load fonts - fonts_.reserve(FONT_FILE_PATHS.size()); - for (int i = 0; i < FONT_FILE_PATHS.size(); ++i) { - fonts_.push_back(LoadFontEx(FONT_FILE_PATHS[i], 120, nullptr, 250)); - } - - pApp = this; -} - -App::~App() { - for (auto &font : fonts_) { - UnloadFont(font); - } - - CloseWindow(); - pApp = nullptr; -} - -const Font &App::getFont(FontWeight weight) const { - return fonts_[static_cast(weight)]; -} diff --git a/system/ui/raylib/util.h b/system/ui/raylib/util.h deleted file mode 100644 index 5bc05ddc25f154..00000000000000 --- a/system/ui/raylib/util.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include -#include - -#include "system/ui/raylib/raylib.h" - -enum class FontWeight { - Normal, - Bold, - ExtraBold, - ExtraLight, - Medium, - Regular, - SemiBold, - Thin, - Count // To represent the total number of fonts -}; - -Texture2D LoadTextureResized(const char *fileName, int size); - -class App { -public: - App(const char *title, int fps); - ~App(); - const Font &getFont(FontWeight weight = FontWeight::Normal) const; - -protected: - std::vector fonts_; -}; - -// Global pointer to the App instance -extern App *pApp; diff --git a/system/ui/spinner.py b/system/ui/spinner.py new file mode 100755 index 00000000000000..24193ff3dfec1d --- /dev/null +++ b/system/ui/spinner.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +import pyray as rl +import os +import select +import sys + +from openpilot.common.basedir import BASEDIR +from openpilot.system.ui.lib.application import gui_app + +# Constants +PROGRESS_BAR_WIDTH = 1000 +PROGRESS_BAR_HEIGHT = 20 +ROTATION_RATE = 12.0 +MARGIN = 200 +TEXTURE_SIZE = 360 +FONT_SIZE = 80 + +def clamp(value, min_value, max_value): + return max(min(value, max_value), min_value) + +def check_input_non_blocking(): + if sys.stdin in select.select([sys.stdin], [], [], 0)[0]: + return sys.stdin.readline().strip() + return "" + +def main(): + gui_app.init_window("Spinner") + + # Load textures + comma_texture = gui_app.load_texture_from_image(os.path.join(BASEDIR, "selfdrive/assets/img_spinner_comma.png"), TEXTURE_SIZE, TEXTURE_SIZE) + spinner_texture = gui_app.load_texture_from_image(os.path.join(BASEDIR, "selfdrive/assets/img_spinner_track.png"), TEXTURE_SIZE, TEXTURE_SIZE) + + # Initial values + rotation = 0.0 + user_input = "" + 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) + + while not rl.window_should_close(): + rl.begin_drawing() + rl.clear_background(rl.BLACK) + + # Update rotation + rotation = (rotation + ROTATION_RATE) % 360.0 + + # Draw rotating spinner and static comma logo + rl.draw_texture_pro(spinner_texture, rl.Rectangle(0, 0, TEXTURE_SIZE, TEXTURE_SIZE), + rl.Rectangle(center.x, center.y, TEXTURE_SIZE, TEXTURE_SIZE), + spinner_origin, rotation, rl.WHITE) + rl.draw_texture_v(comma_texture, comma_position, rl.WHITE) + + # Read user input + if input_str := check_input_non_blocking(): + user_input = input_str + + # Display progress bar or text based on user input + if user_input: + y_pos = rl.get_screen_height() - MARGIN - PROGRESS_BAR_HEIGHT + if user_input.isdigit(): + progress = clamp(int(user_input), 0, 100) + bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT) + rl.draw_rectangle_rounded(bar, 0.5, 10, rl.GRAY) + + bar.width *= progress / 100.0 + rl.draw_rectangle_rounded(bar, 0.5, 10, rl.WHITE) + else: + text_size = rl.measure_text_ex(gui_app.font(), user_input, FONT_SIZE, 1.0) + rl.draw_text_ex(gui_app.font(), user_input, + rl.Vector2(center.x - text_size.x / 2, y_pos), FONT_SIZE, 1.0, rl.WHITE) + + rl.end_drawing() + + +if __name__ == "__main__": + main() diff --git a/system/ui/text.py b/system/ui/text.py new file mode 100755 index 00000000000000..82c19e5b5dd566 --- /dev/null +++ b/system/ui/text.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +import sys +import pyray as rl + +from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.button import gui_button +from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel +from openpilot.system.ui.lib.application import gui_app + +MARGIN = 50 +SPACING = 50 +FONT_SIZE = 60 +LINE_HEIGHT = 64 +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.""" * 20 + +def wrap_text(text, font_size, max_width): + lines = [] + current_line = "" + font = gui_app.font() + + for word in text.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 + " " + if current_line: + lines.append(current_line) + + return lines + + +def main(): + gui_app.init_window("Text") + + text_content = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT + + textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2 - BUTTON_SIZE.y - SPACING) + wrapped_lines = wrap_text(text_content, FONT_SIZE, textarea_rect.width - 20) + content_rect = rl.Rectangle(0, 0, textarea_rect.width - 20, len(wrapped_lines) * LINE_HEIGHT) + scroll_panel = GuiScrollPanel(textarea_rect, content_rect, show_vertical_scroll_bar=True) + + while not rl.window_should_close(): + rl.begin_drawing() + rl.clear_background(rl.BLACK) + + scroll = scroll_panel.handle_scroll() + + rl.begin_scissor_mode(int(textarea_rect.x), int(textarea_rect.y), int(textarea_rect.width), int(textarea_rect.height)) + for i, line in enumerate(wrapped_lines): + position = rl.Vector2(textarea_rect.x + scroll.x, textarea_rect.y + scroll.y + i * LINE_HEIGHT) + rl.draw_text_ex(gui_app.font(), line.strip(), 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) + if gui_button(button_bounds, "Reboot"): + HARDWARE.reboot() + + rl.end_drawing() + + +if __name__ == "__main__": + main()