TSK Manager v0.10.0

This commit is contained in:
Calvin Park
2025-05-29 14:07:28 -07:00
parent 9f648e0963
commit a128236026
22 changed files with 2063 additions and 0 deletions
View File
+59
View File
@@ -0,0 +1,59 @@
# tsk/ui/button.py
from typing import Callable, List, Dict, Any
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.button import gui_button
from tsk.ui.layout import Theme # Import Theme
class Button:
"""Represents a button with text labels and an action."""
def __init__(self, action: Callable[[], None], labels: List[Dict[str, Any]], width: int, height: int, x: int, y: int,
font_size: int):
"""
Initializes a Button object.
Args:
key: The key associated with the button.
action: The function to be called when the button is pressed.
labels: A list of dictionaries containing text and offset information for the button labels.
width: The width of the button.
height: The height of the button.
x: The x-coordinate of the button.
y: The y-coordinate of the button.
font_size: The font size of the button labels.
"""
self.action: Callable[[], None] = action
self.labels: List[Dict[str, Any]] = labels
self.width: int = width
self.height: int = height
self.x: int = x
self.y: int = y
self.font_size: int = font_size
self.last_pressed_time: float = 0.0
self.debounce_delay: float = 0.2 # seconds
def render(self) -> None:
"""Renders the button."""
current_time = rl.get_time()
if gui_button(rl.Rectangle(self.x, self.y, self.width, self.height), ""):
if current_time - self.last_pressed_time > self.debounce_delay:
self.action()
self.last_pressed_time = current_time
self._draw_labels()
def _draw_labels(self) -> None:
"""Draws the labels on the button."""
for label_data in self.labels:
rl.draw_text_ex(
gui_app.font(),
label_data["text"],
rl.Vector2(self.x + label_data["x_offset"], self.y + label_data["y_offset"]),
self.font_size,
1.0,
Theme.brighten_color(rl.Color(100, 100, 100, 255), Theme.brighten_amount), # Use the brightened color here
)
+178
View File
@@ -0,0 +1,178 @@
# tsk/ui/dialog.py
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.lib.button import gui_button, DEFAULT_BUTTON_FONT_SIZE
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
class BaseDialog:
"""Base class for full-screen dialogs with a scrollable text area."""
BORDER_SIZE = 20
BUTTON_HEIGHT = 80
BUTTON_WIDTH = 310
BUTTON_SPACING = 20
FONT_SIZE = 100
LINE_HEIGHT = FONT_SIZE * 1.1
TEXT_PADDING = 10
def __init__(self, body_text: str, font_size: int = FONT_SIZE, scroll_to_bottom: bool = False):
self.body_text = body_text
self.font_size = font_size
self.scroll_to_bottom = scroll_to_bottom
self.LINE_HEIGHT = self.font_size * 1.1
self.textarea_rect = rl.Rectangle(
self.BORDER_SIZE,
self.BORDER_SIZE,
gui_app.width - 2 * self.BORDER_SIZE,
gui_app.height - 3 * self.BORDER_SIZE - self.BUTTON_HEIGHT # Account for buttons and spacing
)
self.wrapped_lines = self._wrap_text(self.body_text, self.font_size, self.textarea_rect.width - 2 * self.TEXT_PADDING)
self.content_height = len(self.wrapped_lines) * self.LINE_HEIGHT
self.content_rect = rl.Rectangle(0, 0, self.textarea_rect.width - 2 * self.TEXT_PADDING, self.content_height)
self.scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True)
self.scroll_offset = rl.Vector2(0, 0) # Store the scroll offset
self.initial_scroll_applied = False # Flag to track initial scroll application
def render_text_area(self):
"""Renders the scrollable text area."""
scroll = self.scroll_panel.handle_scroll(self.textarea_rect, self.content_rect)
self.scroll_offset = scroll # Update the scroll offset after handling user input
# Apply initial scroll to bottom after the first render
if self.scroll_to_bottom and not self.initial_scroll_applied:
self.scroll_offset.y = min(0, self.textarea_rect.height - self.content_height - 2 * self.TEXT_PADDING)
self.initial_scroll_applied = True
rl.begin_scissor_mode(int(self.textarea_rect.x), int(self.textarea_rect.y), int(self.textarea_rect.width), int(self.textarea_rect.height))
y_offset = 0
for line in self.wrapped_lines:
position = rl.Vector2(self.textarea_rect.x + self.TEXT_PADDING + self.scroll_offset.x, self.textarea_rect.y + self.TEXT_PADDING + self.scroll_offset.y + y_offset)
if position.y + self.LINE_HEIGHT < self.textarea_rect.y + self.TEXT_PADDING or position.y > self.textarea_rect.y + self.textarea_rect.height - self.TEXT_PADDING:
y_offset += self.LINE_HEIGHT
continue
rl.draw_text_ex(gui_app.font(), line.strip(), position, self.font_size, 0, rl.WHITE)
y_offset += self.LINE_HEIGHT
rl.end_scissor_mode()
def _wrap_text(self, text, font_size, max_width):
lines = []
font = gui_app.font()
# Split the text by newline characters
for block in text.splitlines():
if not block: # Handle empty lines (consecutive newlines)
lines.append("") # Add an empty line
continue
current_line = ""
for word in block.split():
test_line = current_line + word + " "
if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width:
current_line = test_line
else:
lines.append(current_line)
current_line = word + " "
if current_line:
lines.append(current_line)
return lines
class OkayDialog(BaseDialog):
"""A full-screen dialog with a scrollable text area and an Okay button."""
@staticmethod
def ask(body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, okay_text: str = "Okay") -> None:
"""Displays a full-screen Okay dialog."""
okay_pressed = False
dialog = OkayDialog(body_text, font_size, scroll_to_bottom)
def render_dialog():
nonlocal okay_pressed # Allow modification of the okay_pressed variable
dialog.render_text_area()
# Calculate the available height for the button area
button_area_height = gui_app.height - dialog.textarea_rect.height - dialog.textarea_rect.y
# Button position (centered horizontally, vertically centered in the button area)
button_x = (gui_app.width - BaseDialog.BUTTON_WIDTH) / 2
button_y = dialog.textarea_rect.y + dialog.textarea_rect.height + (button_area_height - BaseDialog.BUTTON_HEIGHT) / 2
# Okay Button
if gui_button(rl.Rectangle(button_x, button_y, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT), okay_text):
okay_pressed = True
# Main loop
while not okay_pressed and not rl.window_should_close():
rl.begin_drawing()
rl.clear_background(rl.BLACK)
render_dialog()
rl.end_drawing()
class YesNoDialog(BaseDialog):
"""A full-screen dialog with a scrollable text area and Yes/No buttons."""
@staticmethod
def ask(body_text: str, font_size: int = BaseDialog.FONT_SIZE, scroll_to_bottom: bool = False, yes_text: str = "Yes", no_text: str = "No") -> bool | None:
"""Displays a full-screen Yes/No dialog and returns a boolean based on the user's choice."""
result = None # Use None to indicate the dialog is still active
dialog = YesNoDialog(body_text, font_size, scroll_to_bottom)
def render_dialog():
nonlocal result # Allow modification of the result variable
dialog.render_text_area()
button_top = gui_app.height - BaseDialog.BORDER_SIZE - BaseDialog.BUTTON_HEIGHT
no_button_x = BaseDialog.BORDER_SIZE
yes_button_x = gui_app.width - BaseDialog.BORDER_SIZE - BaseDialog.BUTTON_WIDTH
no_button_rect = rl.Rectangle(no_button_x, button_top, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT)
yes_button_rect = rl.Rectangle(yes_button_x, button_top, BaseDialog.BUTTON_WIDTH, BaseDialog.BUTTON_HEIGHT)
if draw_custom_button(no_button_rect, no_text, rl.Color(100, 20, 20, 255)):
result = False
if draw_custom_button(yes_button_rect, yes_text, rl.Color(20, 100, 20, 255)):
result = True
# Main loop
while result is None and not rl.window_should_close():
rl.begin_drawing()
rl.clear_background(rl.BLACK)
render_dialog()
rl.end_drawing()
return result
def draw_custom_button(rect: rl.Rectangle, text: str, color: rl.Color) -> bool:
"""Draws a custom button with specified color and handles click detection."""
mouse_pos = rl.get_mouse_position()
is_hovering = rl.check_collision_point_rec(mouse_pos, rect)
is_pressed = is_hovering and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
result = False
rl.draw_rectangle_rec(rect, color)
# Draw button text (centered)
font = gui_app.font()
text_size = rl.measure_text_ex(font, text, DEFAULT_BUTTON_FONT_SIZE, 0)
text_x = rect.x + (rect.width - text_size.x) / 2
text_y = rect.y + (rect.height - text_size.y) / 2
rl.draw_text_ex(font, text, rl.Vector2(text_x, text_y), DEFAULT_BUTTON_FONT_SIZE, 0, rl.WHITE)
if is_pressed:
result = True
return result
+119
View File
@@ -0,0 +1,119 @@
# tsk/ui/header.py
from typing import Optional
import pyray as rl
from openpilot.system.ui.lib.application import gui_app
from tsk.common.key_file_manager import KeyFileManager
from tsk.ui.layout import Theme # Import Theme
class Header:
"""Renders the top navigation bar with title and key status."""
def __init__(self):
"""Initializes the Header."""
self.key_manager = KeyFileManager()
def _draw_title(self, rect: rl.Rectangle, current_menu: int) -> None:
"""Draws the title strip."""
title_text_color = Theme.brighten_color(Theme.title_bg_color, Theme.brighten_amount)
menu_name = Theme.menu_names.get(current_menu, "Unknown Menu")
full_title_text = "TSK Manager: " + menu_name
full_title_text_size = rl.measure_text_ex(gui_app.font(), full_title_text, Theme.title_font_size, 1.0)
title_text_prefix_size = rl.measure_text_ex(gui_app.font(), "TSK Manager: ", Theme.title_font_size, 1.0)
menu_name_x = gui_app.width * Theme.title_prefix_x_offset_percent + title_text_prefix_size.x + Theme.title_x_offset
text_y = (self._calculate_height() - full_title_text_size.y) / 2
rl.draw_text_ex(gui_app.font(), "TSK Manager: ", rl.Vector2(gui_app.width * Theme.title_prefix_x_offset_percent + Theme.title_x_offset, text_y),
Theme.title_font_size, 1.0, title_text_color)
rl.draw_text_ex(gui_app.font(), menu_name, rl.Vector2(menu_name_x, text_y), Theme.title_font_size, 1.0, title_text_color)
def _draw_navigation_buttons(self, rect: rl.Rectangle, current_menu: int) -> Optional[int]:
"""Draws the navigation buttons and handles button clicks."""
nav_button_width = self._calculate_nav_button_width()
left_button_x = 0
left_button_rect = rl.Rectangle(left_button_x, 0, nav_button_width, rect.height)
right_button_x = gui_app.width - nav_button_width
right_button_rect = rl.Rectangle(right_button_x, 0, nav_button_width, rect.height)
new_menu = None
def handle_button(button_rect: rl.Rectangle, text: str, target_menu: int) -> Optional[int]:
"""Handles drawing, input, and logic for a single navigation button."""
mouse_pos = rl.get_mouse_position()
is_hovering = rl.check_collision_point_rec(mouse_pos, button_rect)
is_pressed = is_hovering and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
rl.draw_rectangle_rec(button_rect, Theme.button_color)
lines = text.splitlines()
total_text_height = len(lines) * rl.measure_text_ex(gui_app.font(), "A", Theme.nav_button_font_size, 1.0).y
start_y = rect.y + (rect.height - total_text_height) / 2
for i, line in enumerate(lines):
text_size = rl.measure_text_ex(gui_app.font(), line, Theme.nav_button_font_size, 1.0)
text_x = button_rect.x + (button_rect.width - text_size.x) / 2
text_y = start_y + i * text_size.y
font_color = Theme.brighten_color(rl.Color(100, 100, 100, 255), Theme.brighten_amount) # Use the same color as other buttons
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(text_x, text_y), Theme.nav_button_font_size, 1.0, font_color)
if is_pressed:
return target_menu
return None
if current_menu == Theme.menu_reboot:
new_menu = handle_button(left_button_rect, Theme.nav_button_text_left, Theme.menu_tools)
# --- Handle Right Button ---
if current_menu == Theme.menu_tools:
right_menu = handle_button(right_button_rect, Theme.nav_button_text_right, Theme.menu_reboot)
if new_menu is None:
new_menu = right_menu
return new_menu
def _draw_key_status(self, rect: rl.Rectangle) -> None:
"""Draws the key status label."""
key_status_text = f"Key installed: {self.key_manager.installed_key}" if self.key_manager.installed_key else "Key not installed"
status_text_x = (gui_app.width - rl.measure_text_ex(gui_app.font(), key_status_text, Theme.key_status_font_size, 1.0).x) / 2
status_text_y = rect.y + (rect.height - rl.measure_text_ex(gui_app.font(), key_status_text, Theme.key_status_font_size, 1.0).y) / 2 # Use rect.y
rl.draw_text_ex(gui_app.font(), key_status_text, rl.Vector2(status_text_x, status_text_y), Theme.key_status_font_size, 1.0, Theme.key_text_color)
def _calculate_height(self) -> float:
"""Calculates the height of the header."""
title_text_prefix = "TSK Manager: "
return rl.measure_text_ex(gui_app.font(), title_text_prefix, Theme.title_font_size, 1.0).y * 1.5
def _calculate_nav_button_width(self) -> float:
"""Calculates the width of the navigation buttons."""
return max(rl.measure_text_ex(gui_app.font(), Theme.nav_button_text_left, Theme.nav_button_font_size, 1.0).x,
rl.measure_text_ex(gui_app.font(), Theme.nav_button_text_right, Theme.nav_button_font_size, 1.0).x) + 10
def render(self, rect: rl.Rectangle, current_menu: int) -> Optional[int]:
"""Renders the top strip with the title and menu navigation."""
title_height = self._calculate_height()
key_status_height = Theme.key_status_font_size * 1.5 # Approximate height
# --- Draw Title Strip ---
title_rect = rl.Rectangle(0, 0, gui_app.width, title_height)
rl.draw_rectangle_rec(title_rect, Theme.title_bg_color)
self._draw_title(rect, current_menu)
# --- Draw Key Status Strip ---
key_status_y = title_height
key_status_rect = rl.Rectangle(0, key_status_y, gui_app.width, key_status_height)
rl.draw_rectangle_rec(key_status_rect, Theme.nav_bg_color) # Use nav_bg_color for key status
self._draw_key_status(key_status_rect)
# --- Draw Navigation Buttons (Overlapping) ---
nav_rect = rl.Rectangle(0, 0, gui_app.width, title_height + key_status_height) # Overlap both
new_menu = self._draw_navigation_buttons(nav_rect, current_menu)
if new_menu is not None:
return new_menu
return None
+67
View File
@@ -0,0 +1,67 @@
# tsk/ui/layout.py
import pyray as rl
class Theme:
"""Defines the visual theme and constants for the TSK Manager."""
# --- Colors ---
nav_bg_color = rl.Color(20, 20, 20, 255)
key_text_color = rl.Color(255, 255, 255, 255)
button_color = rl.Color(51, 51, 51, 255)
title_bg_color = rl.Color(40, 40, 40, 255)
nav_text_color = rl.Color(255, 255, 255, 255) # Added nav_text_color
# --- Font Sizes ---
title_font_size = 90
nav_button_font_size = 80
key_status_font_size = 65
# --- Text ---
nav_button_text_left = "< Tools\n< Menu"
nav_button_text_right = "Reboot >\n Menu >"
nav_button_text_to_tools = "< Tools\n< Menu"
# --- Layout ---
title_prefix_x_offset_percent = 0.2
title_x_offset = 180
brighten_amount = 0.6
status_update_interval = 1 # seconds
# --- Menu Identifiers ---
menu_tools = 1
menu_reboot = 2
menu_names = {
menu_tools: "Tools Menu",
menu_reboot: "Reboot Menu",
}
@staticmethod
def brighten_color(color: rl.Color, amount: float) -> rl.Color:
"""Brightens a color by a given amount (0.0 to 1.0)."""
r = int(min(color.r + (255 - color.r) * amount, 255))
g = int(min(color.g + (255 - color.g) * amount, 255))
b = int(min(color.b + (255 - color.b) * amount, 255))
return rl.Color(r, g, b, color.a)
class Layout:
"""Provides layout calculation functions."""
@staticmethod
def calculate_button_dimensions(rect_height: int, header_height: int) -> int:
"""Calculates button height based on available screen space and desired spacing."""
available_height = rect_height - header_height
guide_button_height = 200
num_spacers = 3 # Top, between buttons, and below guide button
remaining_height = available_height - guide_button_height - (num_spacers * 80)
button_height = remaining_height
return int(button_height)
@staticmethod
def calculate_button_positions(rect: rl.Rectangle, header_height: int, num_buttons: int) -> tuple:
"""Calculates the starting positions for a row of buttons."""
total_width = (num_buttons * 600) + ((num_buttons - 1) * 80)
start_x = (rect.width - total_width) / 2 + rect.x
start_y = rect.y + header_height + 80
return start_x, start_y