Files
onepilot/tsk/c4/ui.py
T
2026-04-10 13:49:54 -07:00

324 lines
12 KiB
Python

from typing import Callable
import pyray as rl
from openpilot.common.filter_simple import BounceFilter
from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialogBase
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.wrap_text import wrap_text
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from tsk.common.widget import Scroller
class Layout:
# Button dimensions
button_width = 240
button_height = 140
# Font sizes
tools_row_button_font_size = 38
reboot_row_button_font_size = 35
# Scroller configuration
scroller_spacing = 10
scroller_padding = 10
# Banner
banner_height = 50
class ScalableBigButton(Widget):
"""
Button that uses BigButton graphics but scales to any size.
"""
def __init__(self,
text: str,
click_callback: Callable = None,
font_size: int = Layout.tools_row_button_font_size,
text_offset: tuple[int, int] = (15, 15),
button_width = Layout.button_width,
button_height = Layout.button_height,
):
super().__init__()
self._text = text
self._font_size = font_size
self._text_offset = text_offset # (x, y) offset from top-left of button
self.set_click_callback(click_callback)
# Load BigButton textures
self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180)
self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180)
# Scale animation
self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps)
# Label for text
self._label = UnifiedLabel(
text,
font_size=font_size,
font_weight=FontWeight.BOLD,
text_color=rl.WHITE,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
wrap_text=True
)
self.set_rect(rl.Rectangle(0, 0, button_width, button_height))
def _render(self, rect: rl.Rectangle):
"""Render the button with scaled BigButton graphics."""
# Choose texture based on press state
txt_bg = self._txt_pressed_bg if self.is_pressed else self._txt_default_bg
# Scale animation
scale = self._scale_filter.update(1.07 if self.is_pressed else 1.0)
# Calculate scaled position (center the scaled button)
scaled_width = rect.width * scale
scaled_height = rect.height * scale
btn_x = rect.x + (rect.width - scaled_width) / 2
btn_y = rect.y + (rect.height - scaled_height) / 2
# Draw background texture scaled to button size
source_rect = rl.Rectangle(0, 0, self._txt_default_bg.width, self._txt_default_bg.height)
dest_rect = rl.Rectangle(btn_x, btn_y, scaled_width, scaled_height)
rl.draw_texture_pro(txt_bg, source_rect, dest_rect, rl.Vector2(0, 0), 0, rl.WHITE)
# Draw text at specified offset from button top-left
text_x = rect.x + self._text_offset[0]
text_y = rect.y + self._text_offset[1]
label_rect = rl.Rectangle(text_x, text_y, int(rect.width - self._text_offset[0] * 2), int(rect.height - self._text_offset[1]))
self._label.render(label_rect)
return True
class ScrollableBigDialog(BigDialogBase):
"""
A BigDialog variant that supports scrolling for long text content.
Features:
- Optional title (no space wasted if title is empty string or None)
- Configurable alignment for title and description (left or center)
- Configurable font sizes for title and description
- Scrollable description area
Usage:
dialog = ScrollableBigDialog(
title="Title",
description="Very long text that needs scrolling...",
title_font_size=45,
title_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
desc_font_size=25,
desc_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT
)
gui_app.push_widget(dialog)
"""
PADDING = 20
def __init__(self,
title: str = "",
description: str = "",
title_font_size: int = 45,
title_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
desc_font_size: int = 45,
desc_alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
scroll_to_bottom: bool = False):
super().__init__()
self._title = title
self._description = description
self._title_font_size = title_font_size
self._title_alignment = title_alignment
self._desc_font_size = desc_font_size
self._desc_alignment = desc_alignment
self._scroll_to_bottom = scroll_to_bottom
self._initial_scroll_done = False
# Create label for description text
max_width = self._rect.width - self.PADDING * 2
self._desc_label = UnifiedLabel(
description,
font_size=desc_font_size,
text_color=rl.WHITE,
font_weight=FontWeight.MEDIUM,
alignment=desc_alignment
)
# Create scroller for the description with scroll indicator
self._scroller = Scroller(
[self._desc_label],
horizontal=False,
snap_items=False,
spacing=0,
pad=10
)
def _bottom_reserved_height(self) -> float:
"""Height reserved at the bottom for subclass widgets (e.g., slider). Override in subclasses."""
return 0
def _back_enabled(self) -> bool:
"""Only allow swipe-to-dismiss when scroller is at the top."""
return self._scroller.scroll_panel.get_offset() >= -20
def _render(self, _):
super()._render(_)
# Calculate available width
max_width = self._rect.width - self.PADDING * 2
# Draw title (if provided and not empty)
title_bottom = self._rect.y + self.PADDING
if self._title:
title_wrapped = '\n'.join(wrap_text(gui_app.font(FontWeight.BOLD), self._title, self._title_font_size, int(max_width)))
title_size = measure_text_cached(gui_app.font(FontWeight.BOLD), title_wrapped, self._title_font_size)
title_rect = rl.Rectangle(
int(self._rect.x + self.PADDING),
int(self._rect.y + self.PADDING),
int(max_width),
int(title_size.y)
)
from openpilot.system.ui.widgets.label import gui_label
gui_label(title_rect, title_wrapped, self._title_font_size, font_weight=FontWeight.BOLD,
alignment=self._title_alignment)
title_bottom = title_rect.y + title_rect.height + self.PADDING
# Scroller uses full width so wide items (e.g., slider) aren't clipped.
# Text padding is handled separately via set_max_width.
scroller_rect = rl.Rectangle(
int(self._rect.x),
int(title_bottom),
int(self._rect.width),
int(self._rect.y + self._rect.height - title_bottom - self.PADDING - self._bottom_reserved_height())
)
# Update label width for proper text wrapping
self._desc_label.set_max_width(int(max_width))
# Check if content is scrollable
content_height = self._desc_label.get_content_height(int(max_width))
is_scrollable = content_height > scroller_rect.height
# Disable scrolling if content fits in viewport
self._scroller.set_scrolling_enabled(is_scrollable)
# Scroll to bottom on first render if flag is set
if self._scroll_to_bottom and not self._initial_scroll_done and is_scrollable:
scrollable_height = content_height - scroller_rect.height
self._scroller.scroll_panel.set_offset(-scrollable_height)
self._initial_scroll_done = True
# Render the scroller
self._scroller.render(scroller_rect)
# Draw scroll indicator if content is scrollable
if is_scrollable:
scroll_offset = self._scroller.scroll_panel.get_offset()
scrollable_height = content_height - scroller_rect.height
scroll_progress = -scroll_offset / scrollable_height if scrollable_height > 0 else 0
scroll_progress = max(0, min(1, scroll_progress))
# Draw a thin rounded scrollbar on the right edge
indicator_height = max(20, scroller_rect.height * (scroller_rect.height / content_height))
indicator_y = scroller_rect.y + scroll_progress * (scroller_rect.height - indicator_height)
indicator_rect = rl.Rectangle(self._rect.x + self._rect.width - 12, indicator_y, 8, indicator_height)
rl.draw_rectangle_rounded(indicator_rect, 1.0, 4, rl.Color(255, 255, 255, 178))
return
# ============================================================================
# ScrollableConfirmDialog
# ============================================================================
# A full-screen dialog with scrollable text and a confirm button that
# appears only after the user scrolls to the bottom of the content.
#
# PURPOSE:
# Used for sensitive/destructive operations (uninstall key, reboot to a
# different fork, etc.) where the user needs to:
# 1. Read important information (e.g., which key is installed, what
# will happen). This info can be many pages long and is scrollable.
# 2. Confirm by tapping a button that only appears after reading.
# Swipe down to cancel (standard NavWidget behavior).
#
# WHY THIS EXISTS:
# openpilot's upstream BigConfirmationDialog uses a slider that's too
# large for C4's 240px screen, and causes gesture conflicts when placed
# inside a scroller. This simpler approach uses a confirm button that
# appears at the bottom-right only after the user scrolls to the bottom.
#
# BEHAVIOR:
# 1. Full screen is used for scrollable description text
# 2. Confirm button appears only when bottom of text is reached
# 3. Scrolling back up hides the button
# 4. Swipe down from top to cancel
#
# USAGE:
# dialog = ScrollableConfirmDialog(
# description="Key installed: ABCD1234...\n\nThis will remove the key.",
# confirm_text="Uninstall",
# confirm_callback=do_the_thing
# )
# gui_app.push_widget(dialog)
# ============================================================================
class ScrollableConfirmDialog(ScrollableBigDialog):
BUTTON_WIDTH = 100
BUTTON_HEIGHT = 60
BUTTON_MARGIN = 10
BUTTON_FONT_SIZE = 35
BUTTON_COLOR = rl.Color(20, 120, 20, 255)
BUTTON_PRESSED_COLOR = rl.Color(30, 160, 30, 255)
def __init__(self,
confirm_text: str = "Yes",
confirm_callback: Callable | None = None,
**kwargs):
# Add padding at bottom so text scrolls past the confirm button
if 'description' in kwargs:
kwargs['description'] = kwargs['description'] + '\n'
super().__init__(**kwargs)
self._confirm_callback = confirm_callback
self._confirm_text = confirm_text
self._show_button = False
# Create confirm button — UnifiedLabel is a concrete Widget with click handling
self._confirm_button = self._child(UnifiedLabel(
confirm_text, font_size=self.BUTTON_FONT_SIZE, font_weight=FontWeight.BOLD,
text_color=rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE
))
self._confirm_button.set_click_callback(self._on_confirm)
self._confirm_button.set_enabled(lambda: self._show_button and self.enabled and not self.is_dismissing)
def _on_confirm(self):
self.dismiss(self._confirm_callback)
def _render(self, _):
super()._render(_)
# Check if content is scrolled to bottom (or fits on screen without scrolling)
scroll_offset = self._scroller.scroll_panel.get_offset()
max_width = self._rect.width - self.PADDING * 2
content_height = self._desc_label.get_content_height(int(max_width))
scroller_height = self._rect.height - self.PADDING * 2
if content_height <= scroller_height:
at_bottom = True
else:
scrollable_height = content_height - scroller_height
at_bottom = -scroll_offset >= scrollable_height - 20
self._show_button = at_bottom
if self._show_button:
# Draw button at bottom-right
btn_x = self._rect.x + self._rect.width - self.BUTTON_WIDTH - self.BUTTON_MARGIN
btn_y = self._rect.y + self._rect.height - self.BUTTON_HEIGHT - self.BUTTON_MARGIN
btn_rect = rl.Rectangle(btn_x, btn_y, self.BUTTON_WIDTH, self.BUTTON_HEIGHT)
color = self.BUTTON_PRESSED_COLOR if self._confirm_button.is_pressed else self.BUTTON_COLOR
rl.draw_rectangle_rounded(btn_rect, 0.3, 10, color)
self._confirm_button.render(btn_rect)