Files
2026-01-20 19:29:46 -07:00

1102 lines
38 KiB
Python

import math
import os
import pyray as rl
from collections.abc import Callable
from openpilot.common.params import Params
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.widgets import DialogResult, Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog
from openpilot.system.ui.widgets.label import Label
from openpilot.system.ui.widgets.list_view import ITEM_DESC_FONT_SIZE, ITEM_DESC_TEXT_COLOR
from openpilot.system.ui.widgets.toggle import Toggle
__all__ = [
"CONTROL_HEIGHT",
"DEFAULT_BUTTON_HEIGHT",
"DEFAULT_BUTTON_WIDTH",
"FROGPILOT_VALUE_COLOR",
"ITEM_SPACING",
"SEPARATOR_MARGIN",
"FrogPilotButtonControl",
"FrogPilotButtonsControl",
"FrogPilotButtonToggleControl",
"FrogPilotConfirmationDialog",
"FrogPilotDualParamValueControl",
"FrogPilotListWidget",
"FrogPilotManageControl",
"FrogPilotParamValueButtonControl",
"FrogPilotParamValueControl",
"GifAnimation",
"clear_gif",
"load_gif",
"load_image",
"load_texture_cached",
"open_descriptions",
]
CONTROL_HEIGHT = 120
DEFAULT_BUTTON_HEIGHT = 100
DEFAULT_BUTTON_WIDTH = 225
FROGPILOT_VALUE_COLOR = rl.Color(224, 232, 121, 255)
ITEM_SPACING = 25
SEPARATOR_MARGIN = 40
_gif_cache: dict[str, "GifAnimation"] = {}
_texture_cache: dict[str, rl.Texture] = {}
class GifAnimation:
def __init__(self, gif_path: str, size: tuple[int, int] | None = None):
self._current_frame = 0
self._frame_count = 0
self._frame_delay = 0.1
self._frames: list[rl.Texture] = []
self._last_update = 0.0
self._loaded = False
self._path = gif_path
self._running = False
self._size = size
self._load_gif()
def _load_gif(self) -> None:
if not os.path.exists(self._path):
return
try:
frame_count = rl.ffi.new("int *")
image = rl.load_image_anim(self._path.encode(), frame_count)
self._frame_count = frame_count[0]
if self._frame_count <= 0:
rl.unload_image(image)
return
frame_height = image.height // self._frame_count
for i in range(self._frame_count):
frame_rect = rl.Rectangle(0, i * frame_height, image.width, frame_height)
frame_image = rl.image_from_image(image, frame_rect)
if self._size:
rl.image_resize(rl.ffi.addressof(frame_image), self._size[0], self._size[1])
texture = rl.load_texture_from_image(frame_image)
self._frames.append(texture)
rl.unload_image(frame_image)
rl.unload_image(image)
self._loaded = True
except Exception:
self._loaded = False
@property
def file_name(self) -> str:
return self._path
@property
def is_loaded(self) -> bool:
return self._loaded and len(self._frames) > 0
@property
def is_running(self) -> bool:
return self._running
@property
def scaled_size(self) -> tuple[int, int] | None:
return self._size
def get_current_texture(self) -> rl.Texture | None:
if not self.is_loaded:
return None
return self._frames[self._current_frame]
def render(self, x: int, y: int, tint: rl.Color = rl.WHITE) -> None:
self.update()
texture = self.get_current_texture()
if texture:
rl.draw_texture(texture, x, y, tint)
def set_scaled_size(self, size: tuple[int, int]) -> None:
if self._size == size:
return
self._size = size
def start(self) -> None:
self._running = True
self._last_update = rl.get_time()
def stop(self) -> None:
self._running = False
def unload(self) -> None:
for texture in self._frames:
rl.unload_texture(texture)
self._frames.clear()
self._loaded = False
def update(self) -> None:
if not self._running or not self.is_loaded:
return
current_time = rl.get_time()
if current_time - self._last_update >= self._frame_delay:
self._current_frame = (self._current_frame + 1) % self._frame_count
self._last_update = current_time
def clear_gif(gif: GifAnimation | None) -> None:
if gif is None:
return
gif.stop()
gif.unload()
def load_gif(gif_path: str, size: tuple[int, int], use_cache: bool = True) -> GifAnimation | None:
if not gif_path or not os.path.exists(gif_path):
return None
cache_key = f"{gif_path}_{size[0]}x{size[1]}"
if use_cache and cache_key in _gif_cache:
cached = _gif_cache[cache_key]
if cached.is_loaded:
if not cached.is_running:
cached.start()
return cached
gif = GifAnimation(gif_path, size)
if not gif.is_loaded:
return None
gif.start()
if use_cache:
_gif_cache[cache_key] = gif
return gif
def load_image(base_path: str, size: tuple[int, int]) -> tuple[rl.Texture | None, GifAnimation | None]:
gif_path = base_path + ".gif"
if os.path.exists(gif_path):
gif = load_gif(gif_path, size)
return (None, gif)
png_path = base_path + ".png"
texture = load_texture_cached(png_path, size[0], size[1])
return (texture, None)
def load_texture_cached(path: str, width: int = 0, height: int = 0) -> rl.Texture | None:
if not path:
return None
cache_key = f"{path}_{width}_{height}"
if cache_key in _texture_cache:
return _texture_cache[cache_key]
actual_path = path
if not os.path.exists(path):
for ext in [".png", ".gif", ".jpg", ".jpeg"]:
test_path = path + ext
if os.path.exists(test_path):
actual_path = test_path
break
else:
return None
texture = gui_app.texture(actual_path, width, height) if width > 0 and height > 0 else rl.load_texture(actual_path)
_texture_cache[cache_key] = texture
return texture
def open_descriptions(force_open: bool, toggles: dict) -> None:
if force_open:
for key, toggle in toggles.items():
if key != "CESpeed" and hasattr(toggle, "show_description"):
toggle.show_description()
class FrogPilotButtonControl(Widget):
def __init__(self,
param: str,
title: str,
description: str,
icon: str = "",
button_texts: list[str] = None,
checkable: bool = False,
exclusive: bool = False,
minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
super().__init__()
self._button_checked: list[bool] = []
self._button_enabled: list[bool] = []
self._buttons: list[Button] = []
self._checkable = checkable
self._click_callback: Callable[[int], None] | None = None
self._description = description
self._disabled_click_callback: Callable[[int], None] | None = None
self._exclusive = exclusive
self._minimum_button_width = minimum_button_width
self._param_key = param
self._params = Params()
self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=ITEM_DESC_TEXT_COLOR)
self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._toggle = Toggle(initial_state=self._params.get_bool(param),
callback=self._on_toggle_changed)
button_texts = button_texts or []
for i, text in enumerate(button_texts):
btn = Button(text, click_callback=lambda idx=i: self._on_button_click(idx),
button_style=ButtonStyle.LIST_ACTION)
self._buttons.append(btn)
self._button_checked.append(False)
self._button_enabled.append(True)
def _on_button_click(self, button_id: int) -> None:
if not self._button_enabled[button_id]:
if self._disabled_click_callback:
self._disabled_click_callback(button_id)
return
if self._checkable:
if self._exclusive:
for i in range(len(self._button_checked)):
self._button_checked[i] = (i == button_id)
else:
self._button_checked[button_id] = not self._button_checked[button_id]
if self._click_callback:
self._click_callback(button_id)
def _on_toggle_changed(self, state: bool) -> None:
self._params.put_bool(self._param_key, state)
self.refresh()
def _render(self, rect: rl.Rectangle) -> None:
desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
title_height = 50
toggle_width = 160
visible_buttons = [(i, btn) for i, btn in enumerate(self._buttons) if btn.is_visible]
total_button_width = sum(self._minimum_button_width for _ in visible_buttons) + 10 * max(0, len(visible_buttons) - 1)
text_width = rect.width - total_button_width - toggle_width - 60
title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
self._title_label.render(title_rect)
if self._description:
desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
self._desc_label.render(desc_rect)
if visible_buttons:
button_x = rect.x + text_width + 30
button_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
toggle_state = self._toggle.get_state()
for i, btn in visible_buttons:
btn.set_enabled(self._button_enabled[i] and toggle_state)
if self._checkable and self._button_checked[i]:
btn.set_button_style(ButtonStyle.PRIMARY)
else:
btn.set_button_style(ButtonStyle.LIST_ACTION)
btn_rect = rl.Rectangle(button_x, button_y, self._minimum_button_width, DEFAULT_BUTTON_HEIGHT)
btn.render(btn_rect)
button_x += self._minimum_button_width + 10
toggle_x = rect.x + rect.width - toggle_width - 20
toggle_y = rect.y + (rect.height - 80) // 2
self._toggle.render(rl.Rectangle(toggle_x, toggle_y, toggle_width, 80))
def clear_checked_buttons(self) -> None:
for i in range(len(self._button_checked)):
self._button_checked[i] = False
def refresh(self) -> None:
state = self._params.get_bool(self._param_key)
self._toggle.set_state(state)
for i in range(len(self._button_enabled)):
self._button_enabled[i] = state
def set_checked_button(self, button_id: int) -> None:
if 0 <= button_id < len(self._button_checked):
self._button_checked[button_id] = True
def set_click_callback(self, callback: Callable[[int], None]) -> None:
self._click_callback = callback
def set_disabled_click_callback(self, callback: Callable[[int], None]) -> None:
self._disabled_click_callback = callback
def set_enabled(self, enable: bool) -> None:
for i in range(len(self._button_enabled)):
self._button_enabled[i] = enable
def set_enabled_buttons(self, button_id: int, enable: bool) -> None:
if 0 <= button_id < len(self._button_enabled):
self._button_enabled[button_id] = enable
def set_text(self, button_id: int, text: str) -> None:
if 0 <= button_id < len(self._buttons):
self._buttons[button_id].set_text(text)
def set_visible_button(self, button_id: int, visible: bool) -> None:
if 0 <= button_id < len(self._buttons):
self._buttons[button_id].set_visible(visible)
def show_event(self) -> None:
self.refresh()
class FrogPilotButtonsControl(Widget):
def __init__(self,
title: str,
description: str,
icon: str = "",
button_texts: list[str] = None,
checkable: bool = False,
exclusive: bool = True,
minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
super().__init__()
self._button_checked: list[bool] = []
self._button_enabled: list[bool] = []
self._buttons: list[Button] = []
self._checkable = checkable
self._click_callback: Callable[[int], None] | None = None
self._description = description
self._disabled_click_callback: Callable[[int], None] | None = None
self._exclusive = exclusive
self._minimum_button_width = minimum_button_width
self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=ITEM_DESC_TEXT_COLOR)
self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
button_texts = button_texts or []
for i, text in enumerate(button_texts):
btn = Button(text, click_callback=lambda idx=i: self._on_button_click(idx),
button_style=ButtonStyle.LIST_ACTION)
self._buttons.append(btn)
self._button_checked.append(False)
self._button_enabled.append(True)
def _on_button_click(self, button_id: int) -> None:
if not self._button_enabled[button_id]:
if self._disabled_click_callback:
self._disabled_click_callback(button_id)
return
if self._checkable:
if self._exclusive:
for i in range(len(self._button_checked)):
self._button_checked[i] = (i == button_id)
else:
self._button_checked[button_id] = not self._button_checked[button_id]
if self._click_callback:
self._click_callback(button_id)
def _render(self, rect: rl.Rectangle) -> None:
desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
text_width = rect.width * 0.5
title_height = 50
title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
self._title_label.render(title_rect)
if self._description:
desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
self._desc_label.render(desc_rect)
visible_buttons = [(i, btn) for i, btn in enumerate(self._buttons) if btn.is_visible]
if visible_buttons:
total_button_width = sum(self._minimum_button_width for _ in visible_buttons) + 10 * (len(visible_buttons) - 1)
button_x = rect.x + rect.width - total_button_width - 20
button_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
for i, btn in visible_buttons:
btn.set_enabled(self._button_enabled[i])
if self._checkable and self._button_checked[i]:
btn.set_button_style(ButtonStyle.PRIMARY)
else:
btn.set_button_style(ButtonStyle.LIST_ACTION)
btn_rect = rl.Rectangle(button_x, button_y, self._minimum_button_width, DEFAULT_BUTTON_HEIGHT)
btn.render(btn_rect)
button_x += self._minimum_button_width + 10
def clear_checked_buttons(self) -> None:
for i in range(len(self._button_checked)):
self._button_checked[i] = False
def set_checked_button(self, button_id: int) -> None:
if 0 <= button_id < len(self._button_checked):
self._button_checked[button_id] = True
def set_click_callback(self, callback: Callable[[int], None]) -> None:
self._click_callback = callback
def set_disabled_click_callback(self, callback: Callable[[int], None]) -> None:
self._disabled_click_callback = callback
def set_enabled(self, enable: bool) -> None:
for i in range(len(self._button_enabled)):
self._button_enabled[i] = enable
def set_enabled_buttons(self, button_id: int, enable: bool) -> None:
if 0 <= button_id < len(self._button_enabled):
self._button_enabled[button_id] = enable
def set_text(self, button_id: int, text: str) -> None:
if 0 <= button_id < len(self._buttons):
self._buttons[button_id].set_text(text)
def set_visible_button(self, button_id: int, visible: bool) -> None:
if 0 <= button_id < len(self._buttons):
self._buttons[button_id].set_visible(visible)
class FrogPilotButtonToggleControl(FrogPilotButtonControl):
def __init__(self,
param: str,
title: str,
description: str,
icon: str = "",
button_params: list[str] = None,
button_texts: list[str] = None,
exclusive: bool = False,
minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
super().__init__(param, title, description, icon, button_texts, True, exclusive, minimum_button_width)
self._button_params = button_params or []
for i, bp in enumerate(self._button_params):
if i < len(self._button_checked):
self._button_checked[i] = self._params.get_bool(bp)
def _on_button_click(self, button_id: int) -> None:
if not self._button_enabled[button_id]:
if self._disabled_click_callback:
self._disabled_click_callback(button_id)
return
if button_id < len(self._button_params):
new_state = not self._button_checked[button_id]
self._button_checked[button_id] = new_state
self._params.put_bool(self._button_params[button_id], new_state)
if self._click_callback:
self._click_callback(button_id)
def refresh(self) -> None:
super().refresh()
for i, bp in enumerate(self._button_params):
if i < len(self._button_checked):
self._button_checked[i] = self._params.get_bool(bp)
class FrogPilotConfirmationDialog(ConfirmDialog):
def __init__(self, prompt_text: str, confirm_text: str, cancel_text: str, rich: bool = False):
super().__init__(prompt_text, confirm_text, cancel_text, rich)
@staticmethod
def create_toggle_reboot() -> "FrogPilotConfirmationDialog":
return FrogPilotConfirmationDialog(
"Reboot required to take effect.",
"Reboot Now",
"Reboot Later"
)
@staticmethod
def create_yesorno(prompt_text: str) -> "FrogPilotConfirmationDialog":
return FrogPilotConfirmationDialog(prompt_text, "Yes", "No")
@staticmethod
def toggle_reboot(parent=None) -> bool:
return False
@staticmethod
def yesorno(prompt_text: str, parent=None) -> bool:
return False
class FrogPilotDualParamValueControl(Widget):
def __init__(self, control1: "FrogPilotParamValueControl", control2: "FrogPilotParamValueControl"):
super().__init__()
self._control1 = control1
self._control2 = control2
def _render(self, rect: rl.Rectangle) -> None:
half_width = rect.width // 2 - 5
control1_rect = rl.Rectangle(rect.x, rect.y, half_width, rect.height)
control2_rect = rl.Rectangle(rect.x + half_width + 10, rect.y, half_width, rect.height)
self._control1.render(control1_rect)
self._control2.render(control2_rect)
def refresh(self) -> None:
self._control1.refresh()
self._control2.refresh()
def update_control(self, min_value: float, max_value: float, value_labels: dict[float, str] = None) -> None:
self._control1.update_control(min_value, max_value, value_labels)
self._control2.update_control(min_value, max_value, value_labels)
class FrogPilotListWidget(Widget):
def __init__(self, spacing: int = ITEM_SPACING):
super().__init__()
self._items: list[Widget] = []
self._spacing = spacing
def _render(self, rect: rl.Rectangle) -> None:
if not self._items:
return
current_y = rect.y
visible_items = [item for item in self._items if item.is_visible]
for i, item in enumerate(visible_items):
item_height = item.rect.height if item.rect.height > 0 else CONTROL_HEIGHT
item_rect = rl.Rectangle(rect.x, current_y, rect.width, item_height)
item.render(item_rect)
if i < len(visible_items) - 1:
separator_y = current_y + item_height + self._spacing // 2
rl.draw_line(
int(rect.x + SEPARATOR_MARGIN),
int(separator_y),
int(rect.x + rect.width - SEPARATOR_MARGIN),
int(separator_y),
ITEM_DESC_TEXT_COLOR
)
current_y += item_height + self._spacing
def add_item(self, widget: Widget, expanding: bool = False) -> None:
self._items.append(widget)
def clear(self) -> None:
self._items.clear()
def insert_item(self, index: int, widget: Widget, expanding: bool = False) -> None:
self._items.insert(index, widget)
def set_spacing(self, spacing: int) -> None:
self._spacing = spacing
class FrogPilotManageControl(Widget):
def __init__(self, param: str, title: str, description: str, icon: str = ""):
super().__init__()
self._description = description
self._manage_callback: Callable[[], None] | None = None
self._manage_visible = True
self._param_key = param
self._params = Params()
self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=ITEM_DESC_TEXT_COLOR)
self._manage_button = Button("MANAGE", click_callback=self._on_manage_clicked,
button_style=ButtonStyle.LIST_ACTION)
self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._toggle = Toggle(initial_state=self._params.get_bool(param),
callback=self._on_toggle_changed)
def _on_manage_clicked(self) -> None:
if self._manage_callback:
self._manage_callback()
def _on_toggle_changed(self, state: bool) -> None:
self._params.put_bool(self._param_key, state)
self.refresh()
def _render(self, rect: rl.Rectangle) -> None:
desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
manage_width = 150 if self._manage_visible else 0
title_height = 50
toggle_width = 160
text_width = rect.width - toggle_width - manage_width - 60
title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
self._title_label.render(title_rect)
if self._description:
desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
self._desc_label.render(desc_rect)
if self._manage_visible:
manage_x = rect.x + text_width + 30
manage_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
self._manage_button.render(rl.Rectangle(manage_x, manage_y, manage_width, DEFAULT_BUTTON_HEIGHT))
toggle_x = rect.x + rect.width - toggle_width - 20
toggle_y = rect.y + (rect.height - 80) // 2
self._toggle.render(rl.Rectangle(toggle_x, toggle_y, toggle_width, 80))
def refresh(self) -> None:
state = self._params.get_bool(self._param_key)
self._toggle.set_state(state)
self._manage_button.set_enabled(state)
def set_manage_callback(self, callback: Callable[[], None]) -> None:
self._manage_callback = callback
def set_manage_visibility(self, visible: bool) -> None:
self._manage_visible = visible
def show_event(self) -> None:
self.refresh()
class FrogPilotParamValueButtonControl(Widget):
def __init__(self,
param: str,
title: str,
description: str,
icon: str = "",
min_value: float = 0,
max_value: float = 100,
label: str = "",
value_labels: dict[float, str] = None,
interval: float = 1.0,
fast_increase: bool = False,
button_params: list[str] = None,
button_texts: list[str] = None,
left_button: bool = False,
checkable: bool = True,
minimum_button_width: int = DEFAULT_BUTTON_WIDTH):
super().__init__()
self._button_checked: list[bool] = []
self._button_click_callback: Callable[[int], None] | None = None
self._button_enabled: list[bool] = []
self._button_params = button_params or []
self._buttons: list[Button] = []
self._checkable = checkable
self._decrement_repeating = False
self._description = description
self._display_warning = False
self._factor = 10 ** math.ceil(-math.log10(interval)) if interval > 0 else 1
self._fast_increase = fast_increase
self._increment_repeating = False
self._interval = interval
self._label_suffix = label
self._label_width = 200
self._last_action_time = 0.0
self._left_button = left_button
self._max_value = max_value
self._min_value = min_value
self._minimum_button_width = minimum_button_width
self._param_key = param
self._params = Params()
self._value_changed_callback: Callable[[float], None] | None = None
self._value_labels = value_labels or {}
self._warning_shown = False
self._value = self._read_param_value()
self._previous_value = self._value
self._decrement_button = Button("-", click_callback=self._on_decrement,
font_size=50, button_style=ButtonStyle.LIST_ACTION)
self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=ITEM_DESC_TEXT_COLOR)
self._increment_button = Button("+", click_callback=self._on_increment,
font_size=50, button_style=ButtonStyle.LIST_ACTION)
self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._value_label = Label(self._format_value(), font_size=50,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
text_color=FROGPILOT_VALUE_COLOR)
button_texts = button_texts or []
for i, text in enumerate(button_texts):
checked = checkable and i < len(self._button_params) and self._params.get_bool(self._button_params[i])
btn = Button(text, click_callback=lambda idx=i: self._on_button_click(idx),
button_style=ButtonStyle.PRIMARY if checked else ButtonStyle.LIST_ACTION)
self._buttons.append(btn)
self._button_checked.append(checked)
self._button_enabled.append(True)
def _format_value(self) -> str:
for val, label in self._value_labels.items():
if round(val * self._factor) == round(self._value * self._factor):
return label
if self._interval >= 1:
return f"{int(self._value)}{self._label_suffix}"
else:
decimals = max(0, int(math.ceil(-math.log10(self._interval))))
return f"{self._value:.{decimals}f}{self._label_suffix}"
def _on_button_click(self, button_id: int) -> None:
if self._checkable and button_id < len(self._button_params):
new_state = not self._button_checked[button_id]
self._button_checked[button_id] = new_state
self._params.put_bool(self._button_params[button_id], new_state)
self._buttons[button_id].set_button_style(
ButtonStyle.PRIMARY if new_state else ButtonStyle.LIST_ACTION
)
if self._button_click_callback:
self._button_click_callback(button_id)
def _on_decrement(self) -> None:
if self._display_warning and not self._warning_shown:
self._show_warning()
current_time = rl.get_time()
if current_time - self._last_action_time > 0.65:
self._decrement_repeating = False
delta = self._interval * 5 if self._decrement_repeating and self._fast_increase else self._interval
self._value = max(self._value - delta, self._min_value)
self._update_value()
if round(self._value / self._interval) % 5 == 0:
self._decrement_repeating = True
self._last_action_time = current_time
def _on_increment(self) -> None:
if self._display_warning and not self._warning_shown:
self._show_warning()
current_time = rl.get_time()
if current_time - self._last_action_time > 0.65:
self._increment_repeating = False
delta = self._interval * 5 if self._increment_repeating and self._fast_increase else self._interval
self._value = min(self._value + delta, self._max_value)
self._update_value()
if round(self._value / self._interval) % 5 == 0:
self._increment_repeating = True
self._last_action_time = current_time
def _read_param_value(self) -> float:
try:
return float(self._params.get_int(self._param_key))
except (ValueError, TypeError):
try:
return self._params.get_float(self._param_key)
except (ValueError, TypeError):
return self._min_value
def _render(self, rect: rl.Rectangle) -> None:
button_size = 100
total_btn_width = sum(self._minimum_button_width for _ in self._buttons) + 10 * max(0, len(self._buttons) - 1)
if self._left_button:
btn_start_x = rect.x + 20
value_start_x = btn_start_x + total_btn_width + 20
else:
value_start_x = rect.x + rect.width - button_size * 2 - self._label_width - 40
btn_start_x = rect.x + rect.width - total_btn_width - button_size * 2 - 60
text_width = value_start_x - rect.x - 40 if not self._left_button else rect.width - total_btn_width - self._label_width - button_size * 2 - 100
desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
title_height = 50
title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
self._title_label.render(title_rect)
if self._description:
desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
self._desc_label.render(desc_rect)
button_y = rect.y + (rect.height - DEFAULT_BUTTON_HEIGHT) // 2
current_btn_x = btn_start_x
for i, btn in enumerate(self._buttons):
btn.set_enabled(self._button_enabled[i])
btn.render(rl.Rectangle(current_btn_x, button_y, self._minimum_button_width, DEFAULT_BUTTON_HEIGHT))
current_btn_x += self._minimum_button_width + 10
value_x = rect.x + rect.width - button_size * 2 - self._label_width - 40
value_y = rect.y + (rect.height - 50) // 2
self._value_label.render(rl.Rectangle(value_x, value_y, self._label_width, 50))
button_y = rect.y + (rect.height - button_size) // 2
dec_x = rect.x + rect.width - button_size * 2 - 30
self._decrement_button.render(rl.Rectangle(dec_x, button_y, button_size, button_size))
inc_x = rect.x + rect.width - button_size - 20
self._increment_button.render(rl.Rectangle(inc_x, button_y, button_size, button_size))
def _show_warning(self) -> None:
self._warning_shown = True
def _update_value(self) -> None:
self._value = round(self._value * self._factor) / self._factor
self._value_label.set_text(self._format_value())
self._decrement_button.set_enabled(self._value > self._min_value)
self._increment_button.set_enabled(self._value < self._max_value)
if self._value_changed_callback:
self._value_changed_callback(self._value)
def _write_param_value(self) -> None:
if self._value == self._previous_value:
return
if self._interval >= 1 and self._interval == int(self._interval):
self._params.put_int(self._param_key, int(self._value))
else:
self._params.put_float(self._param_key, self._value)
self._previous_value = self._value
def hide_event(self) -> None:
self._warning_shown = False
self._write_param_value()
def refresh(self) -> None:
self._value = self._read_param_value()
self._value = max(self._min_value, min(self._max_value, self._value))
self._previous_value = self._value
self._update_value()
self._write_param_value()
if self._checkable:
for i, bp in enumerate(self._button_params):
if i < len(self._button_checked):
checked = self._params.get_bool(bp)
self._button_checked[i] = checked
self._buttons[i].set_button_style(
ButtonStyle.PRIMARY if checked else ButtonStyle.LIST_ACTION
)
def set_button_click_callback(self, callback: Callable[[int], None]) -> None:
self._button_click_callback = callback
def set_enabled_buttons(self, button_id: int, enable: bool) -> None:
if 0 <= button_id < len(self._button_enabled):
self._button_enabled[button_id] = enable
def set_value_changed_callback(self, callback: Callable[[float], None]) -> None:
self._value_changed_callback = callback
def set_warning(self, warning: str) -> None:
self._display_warning = True
def show_event(self) -> None:
self.refresh()
def update_control(self, min_value: float, max_value: float, value_labels: dict[float, str] = None) -> None:
self._min_value = min_value
self._max_value = max_value
if value_labels is not None:
self._value_labels = value_labels
self.refresh()
class FrogPilotParamValueControl(Widget):
def __init__(self,
param: str,
title: str,
description: str,
icon: str = "",
min_value: float = 0,
max_value: float = 100,
label: str = "",
value_labels: dict[float, str] = None,
interval: float = 1.0,
fast_increase: bool = False,
label_width: int = 350):
super().__init__()
self._decrement_repeating = False
self._description = description
self._display_warning = False
self._factor = 10 ** math.ceil(-math.log10(interval)) if interval > 0 else 1
self._fast_increase = fast_increase
self._increment_repeating = False
self._interval = interval
self._label_suffix = label
self._label_width = label_width
self._last_action_time = 0.0
self._max_value = max_value
self._min_value = min_value
self._param_key = param
self._params = Params()
self._value_changed_callback: Callable[[float], None] | None = None
self._value_labels = value_labels or {}
self._warning_shown = False
self._value = self._read_param_value()
self._previous_value = self._value
self._decrement_button = Button("-", click_callback=self._on_decrement,
font_size=50, button_style=ButtonStyle.LIST_ACTION)
self._desc_label = Label(description, font_size=ITEM_DESC_FONT_SIZE,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
text_color=ITEM_DESC_TEXT_COLOR)
self._increment_button = Button("+", click_callback=self._on_increment,
font_size=50, button_style=ButtonStyle.LIST_ACTION)
self._title_label = Label(title, font_size=50, font_weight=FontWeight.MEDIUM,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._value_label = Label(self._format_value(), font_size=50,
text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
text_color=FROGPILOT_VALUE_COLOR)
def _format_value(self) -> str:
for val, label in self._value_labels.items():
if round(val * self._factor) == round(self._value * self._factor):
return label
if self._interval >= 1:
return f"{int(self._value)}{self._label_suffix}"
else:
decimals = max(0, int(math.ceil(-math.log10(self._interval))))
return f"{self._value:.{decimals}f}{self._label_suffix}"
def _on_decrement(self) -> None:
if self._display_warning and not self._warning_shown:
self._show_warning()
current_time = rl.get_time()
if current_time - self._last_action_time > 0.65:
self._decrement_repeating = False
delta = self._interval * 5 if self._decrement_repeating and self._fast_increase else self._interval
self._value = max(self._value - delta, self._min_value)
self._update_value()
if round(self._value / self._interval) % 5 == 0:
self._decrement_repeating = True
self._last_action_time = current_time
def _on_increment(self) -> None:
if self._display_warning and not self._warning_shown:
self._show_warning()
current_time = rl.get_time()
if current_time - self._last_action_time > 0.65:
self._increment_repeating = False
delta = self._interval * 5 if self._increment_repeating and self._fast_increase else self._interval
self._value = min(self._value + delta, self._max_value)
self._update_value()
if round(self._value / self._interval) % 5 == 0:
self._increment_repeating = True
self._last_action_time = current_time
def _read_param_value(self) -> float:
try:
return float(self._params.get_int(self._param_key))
except (ValueError, TypeError):
try:
return self._params.get_float(self._param_key)
except (ValueError, TypeError):
return self._min_value
def _render(self, rect: rl.Rectangle) -> None:
button_size = 100
desc_height = ITEM_DESC_FONT_SIZE if self._description else 0
text_width = rect.width - self._label_width - button_size * 2 - 60
title_height = 50
value_width = self._label_width
title_rect = rl.Rectangle(rect.x + 20, rect.y + 10, text_width, title_height)
self._title_label.render(title_rect)
if self._description:
desc_rect = rl.Rectangle(rect.x + 20, rect.y + 10 + title_height + 5, text_width, desc_height)
self._desc_label.render(desc_rect)
value_x = rect.x + text_width + 20
value_y = rect.y + (rect.height - 50) // 2
self._value_label.render(rl.Rectangle(value_x, value_y, value_width, 50))
button_y = rect.y + (rect.height - button_size) // 2
dec_x = rect.x + rect.width - button_size * 2 - 30
self._decrement_button.render(rl.Rectangle(dec_x, button_y, button_size, button_size))
inc_x = rect.x + rect.width - button_size - 20
self._increment_button.render(rl.Rectangle(inc_x, button_y, button_size, button_size))
def _show_warning(self) -> None:
self._warning_shown = True
def _update_value(self) -> None:
self._value = round(self._value * self._factor) / self._factor
self._value_label.set_text(self._format_value())
self._decrement_button.set_enabled(self._value > self._min_value)
self._increment_button.set_enabled(self._value < self._max_value)
if self._value_changed_callback:
self._value_changed_callback(self._value)
def _write_param_value(self) -> None:
if self._value == self._previous_value:
return
if self._interval >= 1 and self._interval == int(self._interval):
self._params.put_int(self._param_key, int(self._value))
else:
self._params.put_float(self._param_key, self._value)
self._previous_value = self._value
def hide_event(self) -> None:
self._warning_shown = False
self._write_param_value()
def refresh(self) -> None:
self._value = self._read_param_value()
self._value = max(self._min_value, min(self._max_value, self._value))
self._previous_value = self._value
self._update_value()
self._write_param_value()
def set_value_changed_callback(self, callback: Callable[[float], None]) -> None:
self._value_changed_callback = callback
def set_warning(self, warning: str) -> None:
self._display_warning = True
def show_event(self) -> None:
self.refresh()
def update_control(self, min_value: float, max_value: float, value_labels: dict[float, str] = None) -> None:
self._min_value = min_value
self._max_value = max_value
if value_labels is not None:
self._value_labels = value_labels
self.refresh()