Files
StarPilot/openpilot/selfdrive/ui/mici/widgets/dialog.py
T
firestar5683 a82935f326 User interfaces and Android Auto
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services.

Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com>
Co-authored-by: Daniel Koepping <elkoled@gmail.com>
Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com>
Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: Shane Smiskol <shane@smiskol.com>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>
Co-authored-by: stef <19478336+stefpi@users.noreply.github.com>
Co-authored-by: whoisdomi <dcantu83@gmail.com>

Co-authored-by: Intelli <contact@intelli.software>
Co-authored-by: pharmacomaniac <blittle65@gmail.com>

Co-authored-by: prabhaavp <prabhaav@msu.edu>
2026-10-14 20:02:39 -05:00

262 lines
12 KiB
Python

import abc
import math
import re
import pyray as rl
from typing import Union
from collections.abc import Callable
from openpilot.system.ui.widgets.nav_widget import NavWidget
from openpilot.system.ui.widgets.scroller import NavScroller
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, GreyBigButton
DEBUG = False
PADDING = 20
class BigDialogBase(NavWidget, abc.ABC):
def __init__(self):
super().__init__()
self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
class BigDialog(BigDialogBase):
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
super().__init__()
self._card = GreyBigButton(title, description, icon)
def _render(self, _):
self._card.render(rl.Rectangle(
self._rect.x + self._rect.width / 2 - self._card.rect.width / 2,
self._rect.y + self._rect.height / 2 - self._card.rect.height / 2,
self._card.rect.width,
self._card.rect.height,
))
class SettingDescriptionDialog(NavScroller):
def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None):
super().__init__()
cards = [GreyBigButton(title, "scroll for details", icon or gui_app.texture("icons_mici/setup/green_info.png", 64, 64))]
# Explicit lines are authored cards; otherwise prefer sentence boundaries.
paragraphs = description.splitlines() if "\n" in description else re.split(r"(?<=[.!?])\s+", description.strip())
# Measure each card so longer text still fits with the actual font and padding.
for sentence in paragraphs:
card = GreyBigButton("", "")
words: list[str] = []
for word in sentence.split():
card.set_value(" ".join([*words, word]))
height = card._sub_label.get_content_height(card._subtitle_width_hint())
if words and height > card.rect.height - 2 * card.LABEL_VERTICAL_PADDING:
card.set_value(" ".join(words))
cards.append(card)
card = GreyBigButton("", "")
words = []
words.append(word)
if words:
card.set_value(" ".join(words))
cards.append(card)
self._scroller.add_widgets(cards)
class BigConfirmationDialog(BigDialogBase):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None],
exit_on_confirm: bool = True, red: bool = False):
super().__init__()
self._confirm_callback = confirm_callback
self._exit_on_confirm = exit_on_confirm
self._slider: BigSlider | RedBigSlider
if red:
self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm))
else:
self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm))
self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
def _on_confirm(self):
if self._exit_on_confirm:
self.dismiss(self._confirm_callback)
elif self._confirm_callback:
self._confirm_callback()
def _update_state(self):
super()._update_state()
if self.is_dismissing and not self._slider.confirmed:
self._slider.reset()
def _render(self, _):
self._slider.render(self._rect)
class BigInputDialog(BigDialogBase):
BACK_TOUCH_AREA_PERCENTAGE = 0.2
BACKSPACE_RATE = 25 # hz
TEXT_INPUT_SIZE = 35
def __init__(self,
hint: str,
default_text: str = "",
minimum_length: int = 1,
confirm_callback: Callable[[str], None] | None = None,
auto_return_to_letters: str = "",
text_validator: Callable[[str], bool] | None = None,
password_mode: bool = False):
super().__init__()
self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)),
font_weight=FontWeight.MEDIUM)
self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters)
self._password_mode = password_mode
self._keyboard.set_text(default_text)
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
self._text_valid = lambda text: len(text) >= minimum_length and (text_validator is None or text_validator(text))
self._backspace_held_time: float | None = None
self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36)
self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._enter_img = gui_app.texture("icons_mici/settings/keyboard/enter.png", 76, 62)
self._enter_disabled_img = gui_app.texture("icons_mici/settings/keyboard/enter_disabled.png", 76, 62)
self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
# rects for top buttons
self._top_left_button_rect = rl.Rectangle(0, 0, 0, 0)
self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0)
def confirm_callback_wrapper():
text = self._keyboard.text()
if self._text_valid(text):
self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None)
self._confirm_callback = confirm_callback_wrapper
def _update_state(self):
super()._update_state()
if self.is_dismissing:
self._backspace_held_time = None
return
last_mouse_event = gui_app.last_mouse_event
if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1:
if self._backspace_held_time is None:
self._backspace_held_time = rl.get_time()
if rl.get_time() - self._backspace_held_time > 0.5:
if gui_app.frame % round(gui_app.target_fps / self.BACKSPACE_RATE) == 0:
self._keyboard.backspace()
else:
self._backspace_held_time = None
def _render(self, _):
# draw current text so far below everything. text floats left but always stays in view
text = self._keyboard.text()
candidate_char = self._keyboard.get_candidate_character()
displayed_text = "•" * len(text) if self._password_mode else text
displayed_candidate = "•" if candidate_char and self._password_mode else candidate_char
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), displayed_text + displayed_candidate or self._hint_label.text, self.TEXT_INPUT_SIZE)
bg_block_margin = 5
text_x = PADDING / 2 + self._enter_img.width + PADDING
text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin,
self._rect.width - text_x * 2,
text_size.y)
# draw text input
# push text left with a gradient on left side if too long
if text_size.x > text_field_rect.width:
text_x -= text_size.x - text_field_rect.width
rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height))
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), displayed_text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0, rl.WHITE)
# draw grayed out character user is hovering over
if candidate_char:
candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), displayed_candidate, self.TEXT_INPUT_SIZE)
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), displayed_candidate,
rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y),
self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, 128))
rl.end_scissor_mode()
# draw gradient on left side to indicate more text
if text_size.x > text_field_rect.width:
rl.draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height),
rl.BLACK, rl.BLACK, rl.BLANK, rl.BLANK)
# draw cursor
blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2
if text:
cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width)
else:
cursor_x = text_field_rect.x - 6
rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y),
1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha)))
# draw backspace icon with nice fade
self._backspace_img_alpha.update(255 * bool(text))
if self._backspace_img_alpha.x > 1:
color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x))
rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14), 0.0, 1.0, color)
if not text and self._hint_label.text and not candidate_char:
# draw description if no text entered yet and not drawing candidate char
hint_rect = rl.Rectangle(text_field_rect.x, text_field_rect.y,
self._rect.width - text_field_rect.x - PADDING,
text_field_rect.height)
self._hint_label.render(hint_rect)
# TODO: move to update state
# make rect take up entire area so it's easier to click
self._top_left_button_rect = rl.Rectangle(self._rect.x, self._rect.y, text_field_rect.x, self._rect.height - self._keyboard.get_keyboard_height())
self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y,
self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height)
# draw enter button
self._enter_img_alpha.update(255 if self._text_valid(text) else 0)
color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x))
rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color)
color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x))
rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color)
# keyboard goes over everything
self._keyboard.render(self._rect)
# draw debugging rect bounds
if DEBUG:
rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255))
rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255))
rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255))
def _handle_mouse_press(self, mouse_pos: MousePos):
super()._handle_mouse_press(mouse_pos)
# TODO: need to track where press was so enter and back can activate on release rather than press
# or turn into icon widgets :eyes_open:
if self.is_dismissing:
return
# handle backspace icon click
if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254:
self._keyboard.backspace()
elif rl.check_collision_point_rec(mouse_pos, self._top_left_button_rect) and self._enter_img_alpha.x > 254:
# handle enter icon click
self._confirm_callback()
class BigConfirmationCircleButton(BigCircleButton):
def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True,
red: bool = False, icon_offset: tuple[int, int] = (0, 0)):
super().__init__(icon, red, icon_offset)
def show_confirm_dialog():
gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback,
exit_on_confirm=exit_on_confirm, red=red))
self.set_click_callback(show_confirm_dialog)