Files
2025-09-06 23:44:28 -07:00

359 lines
14 KiB
Python

from collections.abc import Callable
from enum import IntEnum
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import TextAlignment, Label
class ButtonStyle(IntEnum):
NORMAL = 0 # Most common, neutral buttons
PRIMARY = 1 # For main actions
DANGER = 2 # For critical actions, like reboot or delete
TRANSPARENT = 3 # For buttons with transparent background and border
ACTION = 4
LIST_ACTION = 5 # For list items with action buttons
NO_EFFECT = 6
KEYBOARD = 7
FORGET_WIFI = 8
ICON_PADDING = 15
DEFAULT_BUTTON_FONT_SIZE = 60
BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51)
BUTTON_DISABLED_BACKGROUND_COLOR = rl.Color(51, 51, 51, 255)
ACTION_BUTTON_FONT_SIZE = 48
BUTTON_TEXT_COLOR = {
ButtonStyle.NORMAL: rl.Color(228, 228, 228, 255),
ButtonStyle.PRIMARY: rl.Color(228, 228, 228, 255),
ButtonStyle.DANGER: rl.Color(228, 228, 228, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.BLACK,
ButtonStyle.LIST_ACTION: rl.Color(228, 228, 228, 255),
ButtonStyle.NO_EFFECT: rl.Color(228, 228, 228, 255),
ButtonStyle.KEYBOARD: rl.Color(221, 221, 221, 255),
ButtonStyle.FORGET_WIFI: rl.Color(51, 51, 51, 255),
}
BUTTON_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(51, 51, 51, 255),
ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255),
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
ButtonStyle.LIST_ACTION: rl.Color(57, 57, 57, 255),
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
ButtonStyle.KEYBOARD: rl.Color(68, 68, 68, 255),
ButtonStyle.FORGET_WIFI: rl.Color(189, 189, 189, 255),
}
BUTTON_PRESSED_BACKGROUND_COLORS = {
ButtonStyle.NORMAL: rl.Color(74, 74, 74, 255),
ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255),
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
ButtonStyle.TRANSPARENT: rl.BLACK,
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
ButtonStyle.LIST_ACTION: rl.Color(74, 74, 74, 74),
ButtonStyle.NO_EFFECT: rl.Color(51, 51, 51, 255),
ButtonStyle.KEYBOARD: rl.Color(51, 51, 51, 255),
ButtonStyle.FORGET_WIFI: rl.Color(130, 130, 130, 255),
}
# TOUCH RELIABILITY FIX: Simplified state tracking
#
# PROBLEM WITH ORIGINAL CODE:
# The original code used a global set `_pressed_buttons` to track mouse press state,
# but this approach had several critical issues that made touch input unreliable:
#
# 1. MOUSE-ONLY INPUT: Only handled mouse events, completely ignoring touch input
# 2. COMPLEX STATE MANAGEMENT: Used persistent global state that could get corrupted
# 3. RACE CONDITIONS: Multiple buttons could interfere with each other's state
# 4. INCONSISTENT CLEANUP: State cleanup logic was scattered and unreliable
#
# SOLUTION:
# Replace the complex persistent state system with a simple, reliable approach:
# - Track only which buttons are currently pressed (no persistent state)
# - Handle both mouse AND touch input uniformly
# - Use clean, predictable press/release detection
# - Eliminate race conditions between buttons
_pressed_buttons: set[str] = set()
# TODO: This should be a Widget class
def gui_button(
rect: rl.Rectangle,
text: str,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL,
is_enabled: bool = True,
border_radius: int = 10, # Corner rounding in pixels
text_alignment: TextAlignment = TextAlignment.CENTER,
text_padding: int = 20, # Padding for left/right alignment
icon=None,
) -> int:
"""
TOUCH RELIABILITY FIX: Completely rewritten button input handling
ORIGINAL PROBLEM:
The original implementation only handled mouse input and had complex, unreliable
state management that caused buttons to require multiple taps on touch devices.
ROOT CAUSE ANALYSIS:
1. NO TOUCH SUPPORT: Only checked mouse events (rl.is_mouse_button_*)
2. COMPLEX STATE: Used global _pressed_buttons set with inconsistent cleanup
3. TIMING ISSUES: Press/release detection was unreliable due to state corruption
4. RACE CONDITIONS: Multiple buttons could interfere with each other
NEW APPROACH:
1. UNIFIED INPUT: Handle both mouse and touch events in the same logic
2. SIMPLE STATE: Minimal state tracking, cleaned up reliably
3. PREDICTABLE FLOW: Clear press -> release -> click detection
4. ISOLATED BUTTONS: Each button's state is independent
HOW THE FIX WORKS:
- Detect input from BOTH mouse and touch sources
- Use simple boolean flags instead of complex persistent state
- Clean press/release cycle: press sets state, release triggers click
- Automatic cleanup prevents state corruption
"""
button_id = f"{rect.x}_{rect.y}_{rect.width}_{rect.height}"
result = 0
if button_style in (ButtonStyle.PRIMARY, ButtonStyle.DANGER) and not is_enabled:
button_style = ButtonStyle.NORMAL
if button_style == ButtonStyle.ACTION and font_size == DEFAULT_BUTTON_FONT_SIZE:
font_size = ACTION_BUTTON_FONT_SIZE
# Set background color based on button type
bg_color = BUTTON_BACKGROUND_COLORS[button_style]
# TOUCH RELIABILITY FIX: Check current pressed state
# Instead of complex state management, simply check if this button is currently pressed
is_pressed = button_id in _pressed_buttons
# TOUCH RELIABILITY FIX: Unified input handling for both mouse and touch
#
# ORIGINAL PROBLEM: Only handled mouse input, completely ignored touch
#
# NEW SOLUTION: Check both input sources and combine them into unified logic
# This ensures buttons work reliably on both desktop (mouse) and device (touch)
input_over_button = False
input_pressed = False
input_released = False
# MOUSE INPUT HANDLING (for development/desktop testing)
mouse_pos = rl.get_mouse_position()
mouse_over = is_enabled and rl.check_collision_point_rec(mouse_pos, rect)
if mouse_over:
input_over_button = True
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
input_pressed = True
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
input_released = True
# TOUCH INPUT HANDLING (for actual device usage)
#
# CRITICAL FIX: This was completely missing in the original code!
# Touch devices need different event handling than mouse devices.
#
# TOUCH vs MOUSE differences:
# - Touch: rl.get_touch_point_count() > 0 means finger is down
# - Touch: rl.get_touch_position(0) gets the touch coordinates
# - Touch: No separate "pressed" vs "down" events like mouse
# - Touch: Touch start = finger down, touch end = finger up
touch_count = rl.get_touch_point_count()
if touch_count > 0:
touch_pos = rl.get_touch_position(0)
touch_over = is_enabled and rl.check_collision_point_rec(touch_pos, rect)
if touch_over:
input_over_button = True
# TOUCH PRESS DETECTION: If touch is over button and button wasn't pressed before
# This is the key insight: touch "press" is when finger first touches the button
if not is_pressed:
input_pressed = True
else:
# TOUCH RELEASE DETECTION: No touch points means finger was lifted
# If button was pressed and now there's no touch, that's a release
if is_pressed:
input_released = True
# TOUCH RELIABILITY FIX: Handle button press with unified input
#
# ORIGINAL PROBLEM: Complex logic with mouse_over checks and scattered state updates
#
# NEW SOLUTION: Simple, unified logic that works for both mouse and touch
if input_pressed and input_over_button:
_pressed_buttons.add(button_id)
is_pressed = True
# TOUCH RELIABILITY FIX: Handle button release and click detection
#
# ORIGINAL PROBLEM: Click detection was tied to mouse release with complex conditions
#
# NEW SOLUTION: Clean press/release cycle - if button was pressed and input is released, it's a click
if input_released and is_pressed:
result = 1 # Button was clicked!
_pressed_buttons.discard(button_id) # Clean up state immediately
is_pressed = False
# TOUCH RELIABILITY FIX: Automatic state cleanup
#
# ORIGINAL PROBLEM: State cleanup was scattered and could miss edge cases
#
# NEW SOLUTION: Proactive cleanup to prevent state corruption
# If input is no longer active and button is pressed, clean it up
if not input_over_button and not touch_count and is_pressed:
_pressed_buttons.discard(button_id)
is_pressed = False
# Use pressed color when button is pressed
if is_pressed:
bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[button_style]
# Draw the button with rounded corners
roundness = border_radius / (min(rect.width, rect.height) / 2)
if button_style != ButtonStyle.TRANSPARENT:
rl.draw_rectangle_rounded(rect, roundness, 20, bg_color)
else:
rl.draw_rectangle_rounded(rect, roundness, 20, rl.BLACK)
rl.draw_rectangle_rounded_lines_ex(rect, roundness, 20, 2, rl.WHITE)
# Handle icon and text positioning
font = gui_app.font(font_weight)
text_size = measure_text_cached(font, text, font_size)
text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering
# Draw icon if provided
if icon:
icon_y = rect.y + (rect.height - icon.height) / 2
if text:
if text_alignment == TextAlignment.LEFT:
icon_x = rect.x + text_padding
text_pos.x = icon_x + icon.width + ICON_PADDING
elif text_alignment == TextAlignment.CENTER:
total_width = icon.width + ICON_PADDING + text_size.x
icon_x = rect.x + (rect.width - total_width) / 2
text_pos.x = icon_x + icon.width + ICON_PADDING
else: # RIGHT
text_pos.x = rect.x + rect.width - text_size.x - text_padding
icon_x = text_pos.x - ICON_PADDING - icon.width
else:
# Center icon when no text
icon_x = rect.x + (rect.width - icon.width) / 2
rl.draw_texture_v(icon, rl.Vector2(icon_x, icon_y), rl.WHITE if is_enabled else rl.Color(255, 255, 255, 100))
else:
# No icon, position text normally
if text_alignment == TextAlignment.LEFT:
text_pos.x = rect.x + text_padding
elif text_alignment == TextAlignment.CENTER:
text_pos.x = rect.x + (rect.width - text_size.x) // 2
elif text_alignment == TextAlignment.RIGHT:
text_pos.x = rect.x + rect.width - text_size.x - text_padding
# Draw the button text if any
if text:
color = BUTTON_TEXT_COLOR[button_style] if is_enabled else BUTTON_DISABLED_TEXT_COLOR
rl.draw_text_ex(font, text, text_pos, font_size, 0, color)
return result
class Button(Widget):
def __init__(self,
text: str,
click_callback: Callable[[], None] = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
font_weight: FontWeight = FontWeight.MEDIUM,
button_style: ButtonStyle = ButtonStyle.NORMAL,
border_radius: int = 10,
text_alignment: TextAlignment = TextAlignment.CENTER,
text_padding: int = 20,
icon = None,
multi_touch: bool = False,
):
super().__init__()
self._button_style = button_style
self._border_radius = border_radius
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
self._label = Label(text, font_size, font_weight, text_alignment, text_padding,
BUTTON_TEXT_COLOR[self._button_style], icon=icon)
self._click_callback = click_callback
self._multi_touch = multi_touch
def set_text(self, text):
self._label.set_text(text)
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._click_callback and self.enabled:
self._click_callback()
def _update_state(self):
if self.enabled:
self._label.set_text_color(BUTTON_TEXT_COLOR[self._button_style])
if self.is_pressed:
self._background_color = BUTTON_PRESSED_BACKGROUND_COLORS[self._button_style]
else:
self._background_color = BUTTON_BACKGROUND_COLORS[self._button_style]
elif self._button_style != ButtonStyle.NO_EFFECT:
self._background_color = BUTTON_DISABLED_BACKGROUND_COLOR
self._label.set_text_color(BUTTON_DISABLED_TEXT_COLOR)
def _render(self, _):
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
self._label.render(self._rect)
class ButtonRadio(Button):
def __init__(self,
text: str,
icon,
click_callback: Callable[[], None] = None,
font_size: int = DEFAULT_BUTTON_FONT_SIZE,
text_alignment: TextAlignment = TextAlignment.LEFT,
border_radius: int = 10,
text_padding: int = 20,
):
super().__init__(text, click_callback=click_callback, font_size=font_size,
border_radius=border_radius, text_padding=text_padding,
text_alignment=text_alignment)
self._text_padding = text_padding
self._icon = icon
self.selected = False
def _handle_mouse_release(self, mouse_pos: MousePos):
self.selected = not self.selected
if self._click_callback:
self._click_callback()
def _update_state(self):
if self.selected:
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.PRIMARY]
else:
self._background_color = BUTTON_BACKGROUND_COLORS[ButtonStyle.NORMAL]
def _render(self, _):
roundness = self._border_radius / (min(self._rect.width, self._rect.height) / 2)
rl.draw_rectangle_rounded(self._rect, roundness, 10, self._background_color)
self._label.render(self._rect)
if self._icon and self.selected:
icon_y = self._rect.y + (self._rect.height - self._icon.height) / 2
icon_x = self._rect.x + self._rect.width - self._icon.width - self._text_padding - ICON_PADDING
rl.draw_texture_v(self._icon, rl.Vector2(icon_x, icon_y), rl.WHITE if self.enabled else rl.Color(255, 255, 255, 100))