Files
carrotpilot/selfdrive/ui/mici/widgets/dialog.py
2026-03-21 16:12:45 +09:00

241 lines
10 KiB
Python

import abc
import math
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.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, BigButton, 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 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 = ""):
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._keyboard.set_text(default_text)
self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget
self._minimum_length = minimum_length
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()
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()
text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char 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), 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), candidate_char, self.TEXT_INPUT_SIZE)
rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char,
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.BLANK, rl.BLANK, rl.BLACK)
# 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 len(text) >= self._minimum_length 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 BigDialogButton(BigButton):
def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""):
super().__init__(text, value, icon)
self._description = description
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
dlg = BigDialog(self.text, self._description)
gui_app.push_widget(dlg)
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)