openpilot is an open source driver assistance system. openpilot performs the functions of Automated Lane Centering and Adaptive Cruise Control for over 200 supported car makes and models.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

497 lines
17 KiB

import atexit
import cffi
import os
import time
import signal
import sys
import pyray as rl
import threading
from collections.abc import Callable
from collections import deque
from dataclasses import dataclass
from enum import StrEnum
from typing import NamedTuple
from importlib.resources import as_file, files
from openpilot.common.swaglog import cloudlog
from openpilot.system.hardware import HARDWARE, PC, TICI
from openpilot.system.ui.lib.multilang import TRANSLATIONS_DIR, UNIFONT_LANGUAGES, multilang
from openpilot.common.realtime import Ratekeeper
_DEFAULT_FPS = int(os.getenv("FPS", 20 if TICI else 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
MOUSE_THREAD_RATE = 140 # touch controller runs at 140Hz
MAX_TOUCH_SLOTS = 2
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC", "0") == "1"
SHOW_FPS = os.getenv("SHOW_FPS") == "1"
SHOW_TOUCHES = os.getenv("SHOW_TOUCHES") == "1"
STRICT_MODE = os.getenv("STRICT_MODE") == "1"
SCALE = float(os.getenv("SCALE", "1.0"))
DEFAULT_TEXT_SIZE = 60
DEFAULT_TEXT_COLOR = rl.WHITE
# Qt draws fonts accounting for ascent/descent differently, so compensate to match old styles
# The real scales for the fonts below range from 1.212 to 1.266
FONT_SCALE = 1.242
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
FONT_DIR = ASSETS_DIR.joinpath("fonts")
class FontWeight(StrEnum):
THIN = "Inter-Thin.ttf"
EXTRA_LIGHT = "Inter-ExtraLight.ttf"
LIGHT = "Inter-Light.ttf"
NORMAL = "Inter-Regular.ttf"
MEDIUM = "Inter-Medium.ttf"
SEMI_BOLD = "Inter-SemiBold.ttf"
BOLD = "Inter-Bold.ttf"
EXTRA_BOLD = "Inter-ExtraBold.ttf"
BLACK = "Inter-Black.ttf"
UNIFONT = "unifont.otf"
def font_fallback(font: rl.Font) -> rl.Font:
"""Fall back to unifont for languages that require it."""
if multilang.requires_unifont():
return gui_app.font(FontWeight.UNIFONT)
return font
@dataclass
class ModalOverlay:
overlay: object = None
callback: Callable | None = None
class MousePos(NamedTuple):
x: float
y: float
class MouseEvent(NamedTuple):
pos: MousePos
slot: int
left_pressed: bool
left_released: bool
left_down: bool
t: float
class MouseState:
def __init__(self, scale: float = 1.0):
self._scale = scale
self._events: deque[MouseEvent] = deque(maxlen=MOUSE_THREAD_RATE) # bound event list
self._prev_mouse_event: list[MouseEvent | None] = [None] * MAX_TOUCH_SLOTS
self._rk = Ratekeeper(MOUSE_THREAD_RATE, print_delay_threshold=None)
self._lock = threading.Lock()
self._exit_event = threading.Event()
self._thread = None
def get_events(self) -> list[MouseEvent]:
with self._lock:
events = list(self._events)
self._events.clear()
return events
def start(self):
self._exit_event.clear()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._run_thread, daemon=True)
self._thread.start()
def stop(self):
self._exit_event.set()
if self._thread is not None and self._thread.is_alive():
self._thread.join()
def _run_thread(self):
while not self._exit_event.is_set():
rl.poll_input_events()
self._handle_mouse_event()
self._rk.keep_time()
def _handle_mouse_event(self):
for slot in range(MAX_TOUCH_SLOTS):
mouse_pos = rl.get_touch_position(slot)
x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x
y = mouse_pos.y / self._scale if self._scale != 1.0 else mouse_pos.y
ev = MouseEvent(
MousePos(x, y),
slot,
rl.is_mouse_button_pressed(slot), # noqa: TID251
rl.is_mouse_button_released(slot), # noqa: TID251
rl.is_mouse_button_down(slot),
time.monotonic(),
)
# Only add changes
if self._prev_mouse_event[slot] is None or ev[:-1] != self._prev_mouse_event[slot][:-1]:
with self._lock:
self._events.append(ev)
self._prev_mouse_event[slot] = ev
class GuiApplication:
def __init__(self, width: int, height: int):
self._fonts: dict[FontWeight, rl.Font] = {}
self._width = width
self._height = height
if PC and os.getenv("SCALE") is None:
self._scale = self._calculate_auto_scale()
else:
self._scale = SCALE
self._scaled_width = int(self._width * self._scale)
self._scaled_height = int(self._height * self._scale)
self._render_texture: rl.RenderTexture | None = None
self._textures: dict[str, rl.Texture] = {}
self._target_fps: int = _DEFAULT_FPS
self._last_fps_log_time: float = time.monotonic()
self._window_close_requested = False
self._trace_log_callback = None
self._modal_overlay = ModalOverlay()
self._mouse = MouseState(self._scale)
self._mouse_events: list[MouseEvent] = []
# Debug variables
self._mouse_history: deque[MousePos] = deque(maxlen=MOUSE_THREAD_RATE)
@property
def target_fps(self):
return self._target_fps
def request_close(self):
self._window_close_requested = True
def init_window(self, title: str, fps: int = _DEFAULT_FPS):
def _close(sig, frame):
self.close()
sys.exit(0)
signal.signal(signal.SIGINT, _close)
atexit.register(self.close)
HARDWARE.set_display_power(True)
HARDWARE.set_screen_brightness(65)
self._set_log_callback()
rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING)
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
if ENABLE_VSYNC:
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
rl.set_config_flags(flags)
rl.init_window(self._scaled_width, self._scaled_height, title)
if self._scale != 1.0:
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
self._render_texture = rl.load_render_texture(self._width, self._height)
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
rl.set_target_fps(fps)
self._target_fps = fps
self._set_styles()
self._load_fonts()
self._patch_text_functions()
if not PC:
self._mouse.start()
def set_modal_overlay(self, overlay, callback: Callable | None = None):
if self._modal_overlay.overlay is not None:
if self._modal_overlay.callback is not None:
self._modal_overlay.callback(-1)
self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback)
def texture(self, asset_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply=False, keep_aspect_ratio=True):
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
if cache_key in self._textures:
return self._textures[cache_key]
with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath:
image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
texture_obj = self._load_texture_from_image(image_obj)
self._textures[cache_key] = texture_obj
return texture_obj
def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None,
alpha_premultiply: bool = False, keep_aspect_ratio: bool = True) -> rl.Image:
"""Load and resize an image, storing it for later automatic unloading."""
image = rl.load_image(image_path)
if alpha_premultiply:
rl.image_alpha_premultiply(image)
if width is not None and height is not None:
# Resize with aspect ratio preservation if requested
if keep_aspect_ratio:
orig_width = image.width
orig_height = image.height
scale_width = width / orig_width
scale_height = height / orig_height
# Calculate new dimensions
scale = min(scale_width, scale_height)
new_width = int(orig_width * scale)
new_height = int(orig_height * scale)
rl.image_resize(image, new_width, new_height)
else:
rl.image_resize(image, width, height)
return image
def _load_texture_from_image(self, image: rl.Image) -> rl.Texture:
"""Send image to GPU and unload original image."""
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)
return texture
def close(self):
if not rl.is_window_ready():
return
for texture in self._textures.values():
rl.unload_texture(texture)
self._textures = {}
for font in self._fonts.values():
rl.unload_font(font)
self._fonts = {}
if self._render_texture is not None:
rl.unload_render_texture(self._render_texture)
self._render_texture = None
if not PC:
self._mouse.stop()
rl.close_window()
@property
def mouse_events(self) -> list[MouseEvent]:
return self._mouse_events
def render(self):
try:
while not (self._window_close_requested or rl.window_should_close()):
if PC:
# Thread is not used on PC, need to manually add mouse events
self._mouse._handle_mouse_event()
# Store all mouse events for the current frame
self._mouse_events = self._mouse.get_events()
if self._render_texture:
rl.begin_texture_mode(self._render_texture)
rl.clear_background(rl.BLACK)
else:
rl.begin_drawing()
rl.clear_background(rl.BLACK)
# Handle modal overlay rendering and input processing
if self._modal_overlay.overlay:
if hasattr(self._modal_overlay.overlay, 'render'):
result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height))
elif callable(self._modal_overlay.overlay):
result = self._modal_overlay.overlay()
else:
raise Exception
if result >= 0:
# Clear the overlay and execute the callback
original_modal = self._modal_overlay
self._modal_overlay = ModalOverlay()
if original_modal.callback is not None:
original_modal.callback(result)
yield True
else:
yield False
if self._render_texture:
rl.end_texture_mode()
rl.begin_drawing()
rl.clear_background(rl.BLACK)
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
rl.draw_texture_pro(self._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
if SHOW_FPS:
rl.draw_fps(10, 10)
if SHOW_TOUCHES:
for mouse_event in self._mouse_events:
if mouse_event.left_pressed:
self._mouse_history.clear()
self._mouse_history.append(mouse_event.pos)
if self._mouse_history:
mouse_pos = self._mouse_history[-1]
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 15, rl.RED)
for idx, mouse_pos in enumerate(self._mouse_history):
perc = idx / len(self._mouse_history)
color = rl.Color(min(int(255 * (1.5 - perc)), 255), int(min(255 * (perc + 0.5), 255)), 50, 255)
rl.draw_circle(int(mouse_pos.x), int(mouse_pos.y), 5, color)
rl.end_drawing()
self._monitor_fps()
except KeyboardInterrupt:
pass
def font(self, font_weight: FontWeight = FontWeight.NORMAL) -> rl.Font:
return self._fonts[font_weight]
@property
def width(self):
return self._width
@property
def height(self):
return self._height
def _load_fonts(self):
# Create a character set from our keyboard layouts
from openpilot.system.ui.widgets.keyboard import KEYBOARD_LAYOUTS
base_chars = set()
for layout in KEYBOARD_LAYOUTS.values():
base_chars.update(key for row in layout for key in row)
base_chars |= set("–‑✓×°§•")
raylib: multilang (#36195) * fix multilang dialog height * split to file * stash * Revert "stash" This reverts commit deb4239fe69f0260420fad03f2350e622e31542f. * add updater * add files * stuff * try rev * stash * works! * works! * this should be the flow? * cursor wrapping -- it missed entire sections, changed formatting, and didn't use trn properly!!!!!!!!!!!!!!!!! * update translations * learned my lesson * this should be the one thing it's good at * update trans * onroad wrap * spanish * rename * clean up * load all * Revert "load all" This reverts commit 6f2a45861c914ffb9d40a5edd15751afd798d614. * jp translations * try jp * Revert "try jp" This reverts commit d0524b10110104baafcdc1ec385c3d57bc5ef901. * remove languages we can't add rn * tr * pt and fr * ai cannot be trusted * ai cannot be trusted * missing trans * add fonts * Revert "remove languages we can't add rn" This reverts commit 73dc75fae2b9e347d867b6636dab6e2b5fe59da7. * painfully slow to startup * only load what we need * Reapply "remove languages we can't add rn" This reverts commit 52cb48f3b838520a421f9b90e5ea4409c27d4bd0. * add system * that's sick that this just works (dynamic) * fix description falling back to first str + support callable titles in list items * device is now live! * make firehose live * developer * network live * software live * and that * toggles live * regen * start to clean up gpt * revert op sans * bruh * update translations * rm old script * add noops for descriptions to fix translating away from non-english after startup * missing de * do filtering in multilang.py * clean up clean up * codespell: ignore po * fix update * should not depend * more live * sidebar and offroad alert panel live * fix issues with offroad alerts * fix firehose live * fix weird tr("") behavior * sh key live bugfix * setup.py live * update * update * no fuzzy matching -- breaks dynamic translations * rm this * fix calib desc live trans * change onroad * rm dfonts * clean up device * missing live * update * op lint * not true * add to gitignore * speed up startup by reducing chars by ~half * fix scons * fix crash going from qt * preserve original lang * cancel kb live translate * no preserve * fix lint
2 weeks ago
# Load only the characters used in translations
unifont_chars = set(base_chars)
for language, code in multilang.languages.items():
unifont_chars |= set(language)
raylib: multilang (#36195) * fix multilang dialog height * split to file * stash * Revert "stash" This reverts commit deb4239fe69f0260420fad03f2350e622e31542f. * add updater * add files * stuff * try rev * stash * works! * works! * this should be the flow? * cursor wrapping -- it missed entire sections, changed formatting, and didn't use trn properly!!!!!!!!!!!!!!!!! * update translations * learned my lesson * this should be the one thing it's good at * update trans * onroad wrap * spanish * rename * clean up * load all * Revert "load all" This reverts commit 6f2a45861c914ffb9d40a5edd15751afd798d614. * jp translations * try jp * Revert "try jp" This reverts commit d0524b10110104baafcdc1ec385c3d57bc5ef901. * remove languages we can't add rn * tr * pt and fr * ai cannot be trusted * ai cannot be trusted * missing trans * add fonts * Revert "remove languages we can't add rn" This reverts commit 73dc75fae2b9e347d867b6636dab6e2b5fe59da7. * painfully slow to startup * only load what we need * Reapply "remove languages we can't add rn" This reverts commit 52cb48f3b838520a421f9b90e5ea4409c27d4bd0. * add system * that's sick that this just works (dynamic) * fix description falling back to first str + support callable titles in list items * device is now live! * make firehose live * developer * network live * software live * and that * toggles live * regen * start to clean up gpt * revert op sans * bruh * update translations * rm old script * add noops for descriptions to fix translating away from non-english after startup * missing de * do filtering in multilang.py * clean up clean up * codespell: ignore po * fix update * should not depend * more live * sidebar and offroad alert panel live * fix issues with offroad alerts * fix firehose live * fix weird tr("") behavior * sh key live bugfix * setup.py live * update * update * no fuzzy matching -- breaks dynamic translations * rm this * fix calib desc live trans * change onroad * rm dfonts * clean up device * missing live * update * op lint * not true * add to gitignore * speed up startup by reducing chars by ~half * fix scons * fix crash going from qt * preserve original lang * cancel kb live translate * no preserve * fix lint
2 weeks ago
try:
with open(os.path.join(TRANSLATIONS_DIR, f"app_{code}.po")) as f:
lang_chars = set(f.read())
if code in UNIFONT_LANGUAGES:
unifont_chars |= lang_chars
else:
base_chars |= lang_chars
raylib: multilang (#36195) * fix multilang dialog height * split to file * stash * Revert "stash" This reverts commit deb4239fe69f0260420fad03f2350e622e31542f. * add updater * add files * stuff * try rev * stash * works! * works! * this should be the flow? * cursor wrapping -- it missed entire sections, changed formatting, and didn't use trn properly!!!!!!!!!!!!!!!!! * update translations * learned my lesson * this should be the one thing it's good at * update trans * onroad wrap * spanish * rename * clean up * load all * Revert "load all" This reverts commit 6f2a45861c914ffb9d40a5edd15751afd798d614. * jp translations * try jp * Revert "try jp" This reverts commit d0524b10110104baafcdc1ec385c3d57bc5ef901. * remove languages we can't add rn * tr * pt and fr * ai cannot be trusted * ai cannot be trusted * missing trans * add fonts * Revert "remove languages we can't add rn" This reverts commit 73dc75fae2b9e347d867b6636dab6e2b5fe59da7. * painfully slow to startup * only load what we need * Reapply "remove languages we can't add rn" This reverts commit 52cb48f3b838520a421f9b90e5ea4409c27d4bd0. * add system * that's sick that this just works (dynamic) * fix description falling back to first str + support callable titles in list items * device is now live! * make firehose live * developer * network live * software live * and that * toggles live * regen * start to clean up gpt * revert op sans * bruh * update translations * rm old script * add noops for descriptions to fix translating away from non-english after startup * missing de * do filtering in multilang.py * clean up clean up * codespell: ignore po * fix update * should not depend * more live * sidebar and offroad alert panel live * fix issues with offroad alerts * fix firehose live * fix weird tr("") behavior * sh key live bugfix * setup.py live * update * update * no fuzzy matching -- breaks dynamic translations * rm this * fix calib desc live trans * change onroad * rm dfonts * clean up device * missing live * update * op lint * not true * add to gitignore * speed up startup by reducing chars by ~half * fix scons * fix crash going from qt * preserve original lang * cancel kb live translate * no preserve * fix lint
2 weeks ago
except FileNotFoundError:
cloudlog.warning(f"Translation file for language '{code}' not found when loading fonts.")
raylib: multilang (#36195) * fix multilang dialog height * split to file * stash * Revert "stash" This reverts commit deb4239fe69f0260420fad03f2350e622e31542f. * add updater * add files * stuff * try rev * stash * works! * works! * this should be the flow? * cursor wrapping -- it missed entire sections, changed formatting, and didn't use trn properly!!!!!!!!!!!!!!!!! * update translations * learned my lesson * this should be the one thing it's good at * update trans * onroad wrap * spanish * rename * clean up * load all * Revert "load all" This reverts commit 6f2a45861c914ffb9d40a5edd15751afd798d614. * jp translations * try jp * Revert "try jp" This reverts commit d0524b10110104baafcdc1ec385c3d57bc5ef901. * remove languages we can't add rn * tr * pt and fr * ai cannot be trusted * ai cannot be trusted * missing trans * add fonts * Revert "remove languages we can't add rn" This reverts commit 73dc75fae2b9e347d867b6636dab6e2b5fe59da7. * painfully slow to startup * only load what we need * Reapply "remove languages we can't add rn" This reverts commit 52cb48f3b838520a421f9b90e5ea4409c27d4bd0. * add system * that's sick that this just works (dynamic) * fix description falling back to first str + support callable titles in list items * device is now live! * make firehose live * developer * network live * software live * and that * toggles live * regen * start to clean up gpt * revert op sans * bruh * update translations * rm old script * add noops for descriptions to fix translating away from non-english after startup * missing de * do filtering in multilang.py * clean up clean up * codespell: ignore po * fix update * should not depend * more live * sidebar and offroad alert panel live * fix issues with offroad alerts * fix firehose live * fix weird tr("") behavior * sh key live bugfix * setup.py live * update * update * no fuzzy matching -- breaks dynamic translations * rm this * fix calib desc live trans * change onroad * rm dfonts * clean up device * missing live * update * op lint * not true * add to gitignore * speed up startup by reducing chars by ~half * fix scons * fix crash going from qt * preserve original lang * cancel kb live translate * no preserve * fix lint
2 weeks ago
base_chars = "".join(base_chars)
cloudlog.debug(f"Loading fonts with {len(base_chars)} glyphs.")
unifont_chars = "".join(unifont_chars)
cloudlog.debug(f"Loading unifont with {len(unifont_chars)} glyphs.")
base_codepoint_count = rl.ffi.new("int *", 1)
base_codepoints = rl.load_codepoints(base_chars, base_codepoint_count)
unifont_codepoint_count = rl.ffi.new("int *", 1)
unifont_codepoints = rl.load_codepoints(unifont_chars, unifont_codepoint_count)
for font_weight_file in FontWeight:
with as_file(FONT_DIR.joinpath(font_weight_file)) as fspath:
if font_weight_file == FontWeight.UNIFONT:
font = rl.load_font_ex(fspath.as_posix(), 200, unifont_codepoints, unifont_codepoint_count[0])
else:
font = rl.load_font_ex(fspath.as_posix(), 200, base_codepoints, base_codepoint_count[0])
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._fonts[font_weight_file] = font
rl.unload_codepoints(base_codepoints)
rl.unload_codepoints(unifont_codepoints)
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 _patch_text_functions(self):
# Wrap pyray text APIs to apply a global text size scale so our px sizes match Qt
if not hasattr(rl, "_orig_draw_text_ex"):
rl._orig_draw_text_ex = rl.draw_text_ex
def _draw_text_ex_scaled(font, text, position, font_size, spacing, tint):
font = font_fallback(font)
return rl._orig_draw_text_ex(font, text, position, font_size * FONT_SCALE, spacing, tint)
rl.draw_text_ex = _draw_text_ex_scaled
def _set_log_callback(self):
ffi_libc = cffi.FFI()
ffi_libc.cdef("""
int vasprintf(char **strp, const char *fmt, void *ap);
void free(void *ptr);
""")
libc = ffi_libc.dlopen(None)
@rl.ffi.callback("void(int, char *, void *)")
def trace_log_callback(log_level, text, args):
try:
text_addr = int(rl.ffi.cast("uintptr_t", text))
args_addr = int(rl.ffi.cast("uintptr_t", args))
text_libc = ffi_libc.cast("char *", text_addr)
args_libc = ffi_libc.cast("void *", args_addr)
out = ffi_libc.new("char **")
if libc.vasprintf(out, text_libc, args_libc) >= 0 and out[0] != ffi_libc.NULL:
text_str = ffi_libc.string(out[0]).decode("utf-8", "replace")
libc.free(out[0])
else:
text_str = rl.ffi.string(text).decode("utf-8", "replace")
except Exception as e:
text_str = f"[Log decode error: {e}]"
if log_level == rl.TraceLogLevel.LOG_ERROR:
cloudlog.error(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_WARNING:
cloudlog.warning(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_INFO:
cloudlog.info(f"raylib: {text_str}")
elif log_level == rl.TraceLogLevel.LOG_DEBUG:
cloudlog.debug(f"raylib: {text_str}")
else:
cloudlog.error(f"raylib: Unknown level {log_level}: {text_str}")
# Store callback reference
self._trace_log_callback = trace_log_callback
rl.set_trace_log_callback(self._trace_log_callback)
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)
def _calculate_auto_scale(self) -> float:
# Create temporary window to query monitor info
rl.init_window(1, 1, "")
w, h = rl.get_monitor_width(0), rl.get_monitor_height(0)
rl.close_window()
if w == 0 or h == 0 or (w >= self._width and h >= self._height):
return 1.0
# Apply 0.95 factor for window decorations/taskbar margin
return max(0.3, min(w / self._width, h / self._height) * 0.95)
gui_app = GuiApplication(2160, 1080)