ui: Initial UI rewrite using pyray (spinner and text window) (#34583)

* pyray init version

* remove c++ code

* cleanup

* restruct the directory layout

* improve GuiApplication

* smooth out the texture after resize

* use atexit to close app

* rename FontSize->FontWeight

* make files executable

* use Inter Regular for FrontWeight.NORMAL

* set FLAG_VSYNC_HINT to avoid tearing while scrolling

* smoother scrolling

* mange textures in gui_app
pull/34590/head
Dean Lee 2 months ago committed by GitHub
parent 958c8d1ce3
commit ce7ff5c0e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      SConstruct
  2. 1
      scripts/lint/lint.sh
  3. 1
      system/ui/.gitignore
  4. 20
      system/ui/SConscript
  5. 0
      system/ui/lib/__init__.py
  6. 100
      system/ui/lib/application.py
  7. 14
      system/ui/lib/button.py
  8. 13
      system/ui/lib/label.py
  9. 40
      system/ui/lib/scroll_panel.py
  10. 17
      system/ui/lib/utils.py
  11. 5
      system/ui/raylib/raylib.h
  12. 66
      system/ui/raylib/spinner.cc
  13. 65
      system/ui/raylib/util.cc
  14. 33
      system/ui/raylib/util.h
  15. 76
      system/ui/spinner.py
  16. 67
      system/ui/text.py

@ -352,7 +352,6 @@ SConscript(['rednose/SConscript'])
# Build system services # Build system services
SConscript([ SConscript([
'system/ui/SConscript',
'system/proclogd/SConscript', 'system/proclogd/SConscript',
'system/ubloxd/SConscript', 'system/ubloxd/SConscript',
'system/loggerd/SConscript', 'system/loggerd/SConscript',

@ -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_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_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES
run "check_nomerge_comments" $DIR/check_nomerge_comments.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 if [[ -z "$FAST" ]]; then
run "mypy" mypy $PYTHON_FILES run "mypy" mypy $PYTHON_FILES

@ -1 +0,0 @@
spinner

@ -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)

@ -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)

@ -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)

@ -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)

@ -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

@ -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)

@ -1,5 +0,0 @@
#pragma once
#define OPENPILOT_RAYLIB
#include "third_party/raylib/include/raylib.h"

@ -1,66 +0,0 @@
#include <algorithm>
#include <cmath>
#include <iostream>
#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;
}

@ -1,65 +0,0 @@
#include "system/ui/raylib/util.h"
#include <array>
#include <filesystem>
#undef GREEN
#undef RED
#undef YELLOW
#include "common/swaglog.h"
#include "system/hardware/hw.h"
constexpr std::array<const char *, static_cast<int>(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<int>(weight)];
}

@ -1,33 +0,0 @@
#pragma once
#include <string>
#include <vector>
#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<Font> fonts_;
};
// Global pointer to the App instance
extern App *pApp;

@ -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()

@ -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()
Loading…
Cancel
Save