BigUI WIP: Simpleton

This commit is contained in:
firestarsdog
2026-04-25 17:17:13 -04:00
parent a2d51807c7
commit ed695fab1b
4 changed files with 1072 additions and 398 deletions
File diff suppressed because it is too large Load Diff
@@ -5,10 +5,11 @@ from dataclasses import dataclass
import pyray as rl
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import SPACING, TileGrid
from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import SPACING, TileGrid, AetherScrollbar
@dataclass(frozen=True)
@@ -34,6 +35,10 @@ class SectionedTileLayout(Widget):
self._title_font_size = 26
self._font_title = gui_app.font(FontWeight.BOLD)
self._is_active = False
self._scroll_panel = GuiScrollPanel2(horizontal=False)
self._scrollbar = AetherScrollbar()
self._scroll_offset = 0.0
self._content_height = 0.0
def set_sections(self, sections: list[TileSection]):
if self._is_active:
@@ -50,6 +55,7 @@ class SectionedTileLayout(Widget):
def show_event(self):
self._is_active = True
super().show_event()
self._scroll_offset = 0.0
for section in self._sections:
section.grid.show_event()
@@ -59,11 +65,22 @@ class SectionedTileLayout(Widget):
for section in self._sections:
section.grid.hide_event()
def _title_block_height(self, section: TileSection) -> int:
return (self._title_height + self._title_gap) if section.title else 0
def _content_height_for_layout(self, rect: rl.Rectangle, sections: list[TileSection], title_height: int, title_gap: int, section_gap: int, row_height: float) -> float:
total = self._top_padding
for index, section in enumerate(sections):
total += self._title_block_height(section, title_height, title_gap)
total += (section.grid.get_row_count(available_width=rect.width) * row_height) + section.grid.get_internal_gap_height(available_width=rect.width)
if index < len(sections) - 1:
total += section_gap
return total
def _title_block_height(self, section: TileSection, title_height: int | None = None, title_gap: int | None = None) -> int:
resolved_title_height = self._title_height if title_height is None else title_height
resolved_title_gap = self._title_gap if title_gap is None else title_gap
return (resolved_title_height + resolved_title_gap) if section.title else 0
def _section_band_height(self, section: TileSection, row_height: float) -> float:
rows = section.grid.get_row_count()
rows = section.grid.get_row_count(available_width=self._content_rect(self._rect).width if self._rect.width else None)
if rows <= 0:
return 0.0
return (rows * row_height) + (section.grid.gap * max(0, rows - 1))
@@ -76,28 +93,36 @@ class SectionedTileLayout(Widget):
content_x = rect.x + (rect.width - content_w) / 2
return rl.Rectangle(content_x, rect.y, content_w, rect.height)
def _compute_row_height(self, rect: rl.Rectangle, sections: list[TileSection]) -> float:
total_rows = sum(section.grid.get_row_count() for section in sections)
def _layout_profile(self, rect: rl.Rectangle, sections: list[TileSection]) -> tuple[int, int, int, float]:
total_rows = sum(section.grid.get_row_count(available_width=rect.width) for section in sections)
if total_rows <= 0:
return 0.0
return self._title_height, self._title_gap, self._section_gap, 0.0
total_title_height = sum(self._title_block_height(section) for section in sections)
total_section_gaps = self._section_gap * max(0, len(sections) - 1)
total_internal_gaps = sum(section.grid.get_internal_gap_height() for section in sections)
# Clamp oversized sections so tiles keep a touch-friendly shape instead of stretching to fill the full panel.
fit_row_height = max(0.0, (rect.height - self._top_padding - total_title_height - total_section_gaps - total_internal_gaps) / total_rows)
if fit_row_height >= self._min_row_height:
return min(fit_row_height, self._max_row_height)
return fit_row_height
total_internal_gaps = sum(section.grid.get_internal_gap_height(available_width=rect.width) for section in sections)
title_height = self._title_height
title_gap = self._title_gap
section_gap = self._section_gap
for compact_pass in range(2):
total_title_height = sum(self._title_block_height(section, title_height, title_gap) for section in sections)
total_section_gaps = section_gap * max(0, len(sections) - 1)
fit_row_height = max(0.0, (rect.height - self._top_padding - total_title_height - total_section_gaps - total_internal_gaps) / total_rows)
min_row_height = min(self._min_row_height, 124) if rect.width < 960 or compact_pass else self._min_row_height
if fit_row_height >= min_row_height:
return title_height, title_gap, section_gap, min(fit_row_height, self._max_row_height)
title_height = max(24, min(title_height, 28))
title_gap = max(4, min(title_gap, 6))
section_gap = max(12, min(section_gap, 16))
return title_height, title_gap, section_gap, fit_row_height
def _draw_section_title(self, rect: rl.Rectangle, title: str):
title_text = title.upper()
title_text = title
spacing = round(self._title_font_size * 0.08)
size = measure_text_cached(self._font_title, title_text, self._title_font_size, spacing=spacing)
text_y = rect.y + (rect.height - size.y) / 2
text_pos = rl.Vector2(round(rect.x), round(text_y))
rl.draw_text_ex(self._font_title, title_text, rl.Vector2(text_pos.x + 1, text_pos.y + 1), self._title_font_size, spacing, rl.Color(0, 0, 0, 90))
rl.draw_text_ex(self._font_title, title_text, text_pos, self._title_font_size, spacing, rl.Color(255, 255, 255, 215))
rl.draw_text_ex(self._font_title, title_text, text_pos, self._title_font_size, spacing, rl.Color(236, 242, 250, 215))
line_x = rect.x + size.x + SPACING.lg
line_w = rect.width - (line_x - rect.x)
@@ -113,20 +138,34 @@ class SectionedTileLayout(Widget):
return
content_rect = self._content_rect(rect)
row_height = self._compute_row_height(content_rect, sections)
title_height, title_gap, section_gap, row_height = self._layout_profile(content_rect, sections)
if row_height <= 0:
return
y = content_rect.y + self._top_padding
self._content_height = self._content_height_for_layout(content_rect, sections, title_height, title_gap, section_gap, row_height)
self._scroll_panel.set_enabled(self.is_visible)
self._scroll_offset = self._scroll_panel.update(content_rect, max(self._content_height, content_rect.height)) if self._content_height > content_rect.height else 0.0
rl.begin_scissor_mode(int(content_rect.x), int(content_rect.y), int(content_rect.width), int(content_rect.height))
y = content_rect.y + self._top_padding + self._scroll_offset
for index, section in enumerate(sections):
if section.title:
title_rect = rl.Rectangle(content_rect.x, y, content_rect.width, self._title_height)
title_rect = rl.Rectangle(content_rect.x, y, content_rect.width, title_height)
self._draw_section_title(title_rect, section.title)
y += self._title_height + self._title_gap
y += title_height + title_gap
active_grid_height = (section.grid.get_row_count() * row_height) + section.grid.get_internal_gap_height()
active_grid_height = (section.grid.get_row_count(available_width=content_rect.width) * row_height) + section.grid.get_internal_gap_height(available_width=content_rect.width)
if hasattr(section.grid, "set_touch_valid_callback"):
section.grid.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
section.grid.set_parent_rect(content_rect)
section.grid.render(rl.Rectangle(content_rect.x, y, content_rect.width, active_grid_height))
y += self._section_band_height(section, row_height)
if index < len(sections) - 1:
y += self._section_gap
y += section_gap
rl.end_scissor_mode()
if self._content_height > content_rect.height:
self._scrollbar.render(content_rect, self._content_height, self._scroll_offset)
@@ -188,13 +188,21 @@ class SystemSettingsManagerView(Widget):
content_w = frame.scroll.width
section_gap = 24
col_w = (content_w - section_gap) / 2
left_col = rl.Rectangle(frame.scroll.x, content_y, col_w, content_h)
right_col = rl.Rectangle(frame.scroll.x + col_w + section_gap, content_y, col_w, content_h)
self._sync_slider_values()
self._draw_left_column(left_col)
self._draw_right_column(right_col)
if content_w < 1260:
left_ratio = 0.56 if content_w < 1080 else 0.52
col_w = (content_w - section_gap) / 2
left_col = rl.Rectangle(frame.scroll.x, content_y, col_w * left_ratio + col_w * 0.44, content_h)
right_col_x = left_col.x + left_col.width + section_gap
right_col = rl.Rectangle(right_col_x, content_y, frame.scroll.x + content_w - right_col_x, content_h)
self._draw_left_column(left_col)
self._draw_right_column(right_col, compact=True)
else:
col_w = (content_w - section_gap) / 2
left_col = rl.Rectangle(frame.scroll.x, content_y, col_w, content_h)
right_col = rl.Rectangle(frame.scroll.x + col_w + section_gap, content_y, col_w, content_h)
self._draw_left_column(left_col)
self._draw_right_column(right_col, compact=False)
def _sync_slider_values(self):
params = self._controller._params
@@ -208,11 +216,11 @@ class SystemSettingsManagerView(Widget):
# --- Left column: slider bands + toggles ---
def _draw_left_column(self, rect: rl.Rectangle):
band_zone_h = rect.height * 0.45
band_zone_h = rect.height * (0.52 if rect.width < 760 else 0.45)
toggle_zone_h = rect.height - band_zone_h - 16
display_h = band_zone_h * 0.60
power_h = band_zone_h * 0.40 - 16
display_h = band_zone_h * 0.58
power_h = band_zone_h - display_h - 16
self._draw_slider_band(rl.Rectangle(rect.x, rect.y, rect.width, display_h), tr("Display"), self._display_band)
self._draw_slider_band(rl.Rectangle(rect.x, rect.y + display_h + 16, rect.width, power_h), tr("Power"), self._power_band)
@@ -232,40 +240,50 @@ class SystemSettingsManagerView(Widget):
slider_h = rect.height - label_h - 32
slider_area_w = rect.width - 32
gap = 12
# Force 4 columns mathematically so 2-item bands don't stretch into fat horizontal boxes
standard_n = 4
col_w = (slider_area_w - (standard_n - 1) * gap) / standard_n
n = len(slider_keys)
content_w = n * col_w + (n - 1) * gap
cols = max(1, min(n, int((slider_area_w + gap) / (120 + gap))))
rows = (n + cols - 1) // cols
col_w = (slider_area_w - (cols - 1) * gap) / cols
row_gap = 12
total_slider_h = max(0, slider_h - row_gap * max(0, rows - 1))
cell_h = total_slider_h / max(1, rows)
content_w = min(n, cols) * col_w + max(0, min(n, cols) - 1) * gap
start_x = rect.x + 16 + (slider_area_w - content_w) / 2
for i, key in enumerate(slider_keys):
col_x = start_x + i * (col_w + gap)
self._vsliders[key].render(rl.Rectangle(col_x, slider_top, col_w, slider_h))
row = i // cols
col = i % cols
col_x = start_x + col * (col_w + gap)
col_y = slider_top + row * (cell_h + row_gap)
self._vsliders[key].render(rl.Rectangle(col_x, col_y, col_w, cell_h))
# --- Right column: action cards ---
def _draw_right_column(self, rect: rl.Rectangle):
def _draw_right_column(self, rect: rl.Rectangle, compact: bool = False):
gap = 16
total_h = rect.height - 2 * gap
h1 = total_h * 0.30
h2 = total_h * 0.40
h3 = total_h * 0.30
if compact:
h1 = total_h * 0.26
h2 = total_h * 0.42
h3 = total_h * 0.32
else:
h1 = total_h * 0.30
h2 = total_h * 0.40
h3 = total_h * 0.30
card1_rect = rl.Rectangle(rect.x, rect.y, rect.width, h1)
card2_rect = rl.Rectangle(rect.x, rect.y + h1 + gap, rect.width, h2)
card3_rect = rl.Rectangle(rect.x, rect.y + h1 + h2 + 2*gap, rect.width, h3)
self._draw_card_background(card1_rect, tr("Drive & Actions"))
self._draw_card1_content(card1_rect)
self._draw_card1_content(card1_rect, compact=compact)
self._draw_card_background(card2_rect, tr("Backups Management"))
self._draw_card2_content(card2_rect)
self._draw_card2_content(card2_rect, compact=compact)
self._draw_card_background(card3_rect, tr("Maintenance (Caution)"))
self._draw_card3_content(card3_rect)
self._draw_card3_content(card3_rect, compact=compact)
def _draw_card_background(self, rect, title):
rl.draw_rectangle_rounded(rect, 0.15, 16, rl.Color(28, 30, 36, 255))
@@ -291,65 +309,99 @@ class SystemSettingsManagerView(Widget):
rl.draw_rectangle_rounded(rect, 0.25, 16, color)
rl.draw_rectangle_rounded_lines_ex(rect, 0.25, 16, 1, border_color)
text_color = rl.Color(255, 200, 200, 255) if danger else rl.WHITE
gui_label(rect, label, 24, text_color, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
font_size = max(14, min(22, int(min(rect.width, rect.height) * 0.24)))
gui_label(rect, label, font_size, text_color, FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
def _draw_card1_content(self, rect):
ACTION_BTN_H = 64
def _draw_button_row(self, rect: rl.Rectangle, buttons: list[tuple[str, str, bool]], columns: int, *, gap: int = 16, button_h: int | None = None):
cols = max(1, min(columns, len(buttons)))
rows = (len(buttons) + cols - 1) // cols
resolved_button_h = button_h if button_h is not None else max(34, min(64, int((rect.height - gap * max(0, rows - 1)) / max(1, rows))))
button_w = (rect.width - gap * max(0, cols - 1)) / cols
for idx, (action_id, label, danger) in enumerate(buttons):
row = idx // cols
col = idx % cols
bx = rect.x + col * (button_w + gap)
by = rect.y + row * (resolved_button_h + gap)
self._draw_action_button(rl.Rectangle(bx, by, button_w, resolved_button_h), action_id, label, danger=danger)
return rows * resolved_button_h + gap * max(0, rows - 1)
def _draw_card1_content(self, rect, compact: bool = False):
content_y = rect.y + 44
content_h = rect.height - 44 - 16
num_rows = 2
gap = (content_h - (ACTION_BTN_H * num_rows)) / (num_rows + 1)
if compact:
stacked = rect.width < 760
units = 3 if stacked else 2
gap = 10
control_h = max(30, min(64, int((content_h - gap * (units + 1)) / units)))
else:
ACTION_BTN_H = 64
gap = max(12, (content_h - (ACTION_BTN_H * 2)) / 3)
control_h = ACTION_BTN_H
ry = content_y + gap
radio_rect = rl.Rectangle(rect.x + 16, ry, rect.width - 32, ACTION_BTN_H)
radio_rect = rl.Rectangle(rect.x + 16, ry, rect.width - 32, control_h)
self._drive_mode_radio.current_index = self._get_drive_mode_index()
self._drive_mode_radio.render(radio_rect)
by = ry + ACTION_BTN_H + gap
by = ry + control_h + gap
btn_w = (rect.width - 32 - 16) / 2
self._draw_action_button(rl.Rectangle(rect.x + 16, by, btn_w, ACTION_BTN_H), "FlashPanda", tr("Flash Panda"))
self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, by, btn_w, ACTION_BTN_H), "ReportIssue", tr("Report Issue"))
if compact and rect.width < 760:
self._draw_button_row(rl.Rectangle(rect.x + 16, by, rect.width - 32, control_h * 2 + gap), [("FlashPanda", tr("Flash Panda"), False), ("ReportIssue", tr("Report Issue"), False)], 1, gap=gap, button_h=control_h)
else:
self._draw_action_button(rl.Rectangle(rect.x + 16, by, btn_w, control_h), "FlashPanda", tr("Flash Panda"))
self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, by, btn_w, control_h), "ReportIssue", tr("Report Issue"))
def _draw_card2_content(self, rect):
ACTION_BTN_H = 64
def _draw_card2_content(self, rect, compact: bool = False):
content_y = rect.y + 44
content_h = rect.height - 44 - 16
num_rows = 2
gap = (content_h - (ACTION_BTN_H * num_rows)) / (num_rows + 1)
if compact:
gap = 8
label_h = 18
cols = 2 if rect.width >= 560 else 1
rows_per_group = (3 + cols - 1) // cols
total_rows = rows_per_group * 2
available_h = rect.height - 44 - 16 - label_h * 2 - gap * 5
button_h = max(28, min(56, int(available_h / max(1, total_rows))))
else:
ACTION_BTN_H = 64
gap = max(12, (rect.height - 44 - 16 - (ACTION_BTN_H * 2)) / 3)
label_h = ACTION_BTN_H
cols = 3
rows_per_group = 1
button_h = ACTION_BTN_H
sys_y = content_y + gap
gui_label(rl.Rectangle(rect.x + 16, sys_y, 110, ACTION_BTN_H), tr("System:"), 22, rl.WHITE, FontWeight.SEMI_BOLD)
btn_w = (rect.width - 130 - 32 - 32) / 3
start_x = rect.x + 130
self._draw_action_button(rl.Rectangle(start_x, sys_y, btn_w, ACTION_BTN_H), "CreateBackup", tr("Create"))
self._draw_action_button(rl.Rectangle(start_x + btn_w + 16, sys_y, btn_w, ACTION_BTN_H), "RestoreBackup", tr("Restore"))
self._draw_action_button(rl.Rectangle(start_x + 2*(btn_w + 16), sys_y, btn_w, ACTION_BTN_H), "DeleteBackup", tr("Delete"), danger=True)
label_w = 110 if rect.width >= 760 and not compact else 92
label_size = 22 if not compact else 18
gui_label(rl.Rectangle(rect.x + 16, sys_y, label_w, label_h), tr("System:"), label_size, rl.WHITE, FontWeight.SEMI_BOLD)
start_x = rect.x + label_w + 20
row_w = rect.width - (start_x - rect.x) - 16
system_h = self._draw_button_row(rl.Rectangle(start_x, sys_y, row_w, rows_per_group * button_h + gap * max(0, rows_per_group - 1)), [("CreateBackup", tr("Create"), False), ("RestoreBackup", tr("Restore"), False), ("DeleteBackup", tr("Delete"), True)], cols, gap=gap, button_h=button_h)
tog_y = sys_y + ACTION_BTN_H + gap
gui_label(rl.Rectangle(rect.x + 16, tog_y, 110, ACTION_BTN_H), tr("Toggles:"), 22, rl.WHITE, FontWeight.SEMI_BOLD)
self._draw_action_button(rl.Rectangle(start_x, tog_y, btn_w, ACTION_BTN_H), "CreateToggleBackup", tr("Create"))
self._draw_action_button(rl.Rectangle(start_x + btn_w + 16, tog_y, btn_w, ACTION_BTN_H), "RestoreToggleBackup", tr("Restore"))
self._draw_action_button(rl.Rectangle(start_x + 2*(btn_w + 16), tog_y, btn_w, ACTION_BTN_H), "DeleteToggleBackup", tr("Delete"), danger=True)
tog_y = sys_y + max(label_h, system_h) + gap
gui_label(rl.Rectangle(rect.x + 16, tog_y, label_w, label_h), tr("Toggles:"), label_size, rl.WHITE, FontWeight.SEMI_BOLD)
self._draw_button_row(rl.Rectangle(start_x, tog_y, row_w, rows_per_group * button_h + gap * max(0, rows_per_group - 1)), [("CreateToggleBackup", tr("Create"), False), ("RestoreToggleBackup", tr("Restore"), False), ("DeleteToggleBackup", tr("Delete"), True)], cols, gap=gap, button_h=button_h)
def _draw_card3_content(self, rect):
ACTION_BTN_H = 64
def _draw_card3_content(self, rect, compact: bool = False):
content_y = rect.y + 44
content_h = rect.height - 44 - 16
num_rows = 2
gap = (content_h - (ACTION_BTN_H * num_rows)) / (num_rows + 1)
btn_w = (rect.width - 32 - 16) / 2
if compact:
gap = 10
columns = 2 if rect.width >= 520 else 1
rows_per_group = (2 + columns - 1) // columns
available_h = rect.height - 44 - 16 - gap * 3
button_h = max(30, min(56, int(available_h / max(2, rows_per_group * 2))))
else:
ACTION_BTN_H = 64
gap = max(12, (rect.height - 44 - 16 - (ACTION_BTN_H * 2)) / 3)
columns = 2
rows_per_group = 1
button_h = ACTION_BTN_H
sys_y = content_y + gap
self._draw_action_button(rl.Rectangle(rect.x + 16, sys_y, btn_w, ACTION_BTN_H), "Storage", tr("Clear Data"), danger=True)
self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, sys_y, btn_w, ACTION_BTN_H), "ErrorLogs", tr("Clear Logs"), danger=True)
group_h = rows_per_group * button_h + gap * max(0, rows_per_group - 1)
system_h = self._draw_button_row(rl.Rectangle(rect.x + 16, sys_y, rect.width - 32, group_h), [("Storage", tr("Clear Data"), True), ("ErrorLogs", tr("Clear Logs"), True)], columns, gap=gap, button_h=button_h)
tog_y = sys_y + ACTION_BTN_H + gap
self._draw_action_button(rl.Rectangle(rect.x + 16, tog_y, btn_w, ACTION_BTN_H), "ResetDefaults", tr("Reset Toggles"), danger=True)
self._draw_action_button(rl.Rectangle(rect.x + 16 + btn_w + 16, tog_y, btn_w, ACTION_BTN_H), "ResetStock", tr("Stock OP"), danger=True)
tog_y = sys_y + system_h + gap
self._draw_button_row(rl.Rectangle(rect.x + 16, tog_y, rect.width - 32, group_h), [("ResetDefaults", tr("Reset Toggles"), True), ("ResetStock", tr("Stock OP"), True)], columns, gap=gap, button_h=button_h)
class StarPilotSystemLayout(StarPilotPanel):
def __init__(self):
+359
View File
@@ -0,0 +1,359 @@
import importlib
import sys
import types
import unittest
from unittest.mock import MagicMock
MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid"
PANEL_MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.panel"
SECTIONED_PANEL_MODULE_NAME = "openpilot.selfdrive.ui.layouts.settings.starpilot.sectioned_panel"
def _clear_modules(*module_names):
for module_name in module_names:
sys.modules.pop(module_name, None)
def _register_modules(module_map):
for name, module in module_map.items():
sys.modules[name] = module
def _make_texture(width=80, height=80):
return types.SimpleNamespace(width=width, height=height)
def _install_aethergrid_stubs():
rl = types.SimpleNamespace(
Color=lambda r, g, b, a=255: types.SimpleNamespace(r=r, g=g, b=b, a=a),
Rectangle=lambda x=0, y=0, width=0, height=0: types.SimpleNamespace(x=x, y=y, width=width, height=height),
Vector2=lambda x=0, y=0: types.SimpleNamespace(x=x, y=y),
Texture2D=type("Texture2D", (), {}),
Font=type("Font", (), {}),
GuiTextAlignment=types.SimpleNamespace(TEXT_ALIGN_CENTER=0),
WHITE=types.SimpleNamespace(r=255, g=255, b=255, a=255),
draw_rectangle_rounded=lambda *a, **k: None,
draw_rectangle_rounded_lines_ex=lambda *a, **k: None,
draw_rectangle_rec=lambda *a, **k: None,
draw_rectangle=lambda *a, **k: None,
draw_rectangle_gradient_v=lambda *a, **k: None,
draw_ring=lambda *a, **k: None,
draw_circle=lambda *a, **k: None,
draw_line=lambda *a, **k: None,
draw_line_ex=lambda *a, **k: None,
draw_triangle=lambda *a, **k: None,
draw_texture_pro=lambda *a, **k: None,
draw_text_ex=lambda *a, **k: None,
check_collision_point_rec=lambda *a, **k: False,
get_frame_time=lambda: 0.016,
get_mouse_position=lambda: types.SimpleNamespace(x=0, y=0),
)
sys.modules["pyray"] = rl
app_mod = types.ModuleType("openpilot.system.ui.lib.application")
app_mod.FontWeight = types.SimpleNamespace(BOLD=700, NORMAL=400, MEDIUM=500, SEMI_BOLD=600)
app_mod.MousePos = type("MousePos", (), {})
app_mod.MouseEvent = type("MouseEvent", (), {})
app_mod.gui_app = types.SimpleNamespace(
width=1920,
height=1080,
last_mouse_event=types.SimpleNamespace(pos=types.SimpleNamespace(x=0, y=0)),
font=lambda *_a, **_k: object(),
texture=lambda *_a, **_k: _make_texture(),
pop_widget=lambda: None,
)
sys.modules["openpilot.system.ui.lib.application"] = app_mod
scroll_panel_mod = types.ModuleType("openpilot.system.ui.lib.scroll_panel2")
class GuiScrollPanel2:
def __init__(self, horizontal=True, handle_out_of_bounds=True):
self._enabled = True
self._offset = 0.0
def set_enabled(self, enabled):
self._enabled = enabled
def update(self, bounds, content_size):
return 0.0
def is_touch_valid(self):
return True
scroll_panel_mod.GuiScrollPanel2 = GuiScrollPanel2
sys.modules["openpilot.system.ui.lib.scroll_panel2"] = scroll_panel_mod
multilang_mod = types.ModuleType("openpilot.system.ui.lib.multilang")
multilang_mod.tr = lambda text: text
sys.modules["openpilot.system.ui.lib.multilang"] = multilang_mod
text_measure_mod = types.ModuleType("openpilot.system.ui.lib.text_measure")
text_measure_mod.measure_text_cached = lambda *_a, **_k: types.SimpleNamespace(x=100, y=20)
sys.modules["openpilot.system.ui.lib.text_measure"] = text_measure_mod
widgets_mod = types.ModuleType("openpilot.system.ui.widgets")
class Widget:
def __init__(self):
self._rect = rl.Rectangle()
self._parent_rect = None
self._enabled = True
self.is_pressed = False
@property
def enabled(self):
return self._enabled() if callable(self._enabled) else self._enabled
def set_rect(self, rect):
self._rect = rect
def set_parent_rect(self, rect):
self._parent_rect = rect
def render(self, rect):
self._rect = rect
return self._render(rect)
def set_click_callback(self, callback):
self.on_click = callback
def set_enabled(self, enabled):
self._enabled = enabled
def _render(self, rect):
return None
widgets_mod.Widget = Widget
widgets_mod.DialogResult = types.SimpleNamespace(CONFIRM=1, CANCEL=0, NO_ACTION=-1)
sys.modules["openpilot.system.ui.widgets"] = widgets_mod
label_mod = types.ModuleType("openpilot.system.ui.widgets.label")
label_mod.gui_label = lambda *a, **k: None
sys.modules["openpilot.system.ui.widgets.label"] = label_mod
asset_loader_mod = types.ModuleType("openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader")
asset_loader_mod.starpilot_texture = lambda *_a, **_k: _make_texture()
sys.modules["openpilot.selfdrive.ui.layouts.settings.starpilot.asset_loader"] = asset_loader_mod
def _install_panel_stubs(aethergrid):
sectioned_mod = types.ModuleType(SECTIONED_PANEL_MODULE_NAME)
sectioned_mod.SectionedTileLayout = type("SectionedTileLayout", (), {})
sectioned_mod.TileSection = type("TileSection", (), {})
_register_modules({
SECTIONED_PANEL_MODULE_NAME: sectioned_mod,
"openpilot.common.params": types.SimpleNamespace(Params=type("Params", (), {})),
MODULE_NAME: aethergrid,
})
def _import_module(module_name):
_install_aethergrid_stubs()
_clear_modules(module_name)
return importlib.import_module(module_name)
def _import_aethergrid():
return _import_module(MODULE_NAME)
def _import_panel(monkeypatch_module=None):
aethergrid = _import_aethergrid()
_install_panel_stubs(aethergrid)
_clear_modules(PANEL_MODULE_NAME)
return importlib.import_module(PANEL_MODULE_NAME)
def _import_real_sectioned_panel():
_install_aethergrid_stubs()
_clear_modules(SECTIONED_PANEL_MODULE_NAME)
return importlib.import_module(SECTIONED_PANEL_MODULE_NAME)
class RenderSpy:
def __init__(self):
self.rects = []
self.parent_rect = None
def render(self, rect):
self.rects.append(rect)
def show_event(self):
pass
def hide_event(self):
pass
def set_parent_rect(self, rect):
self.parent_rect = rect
class TestAethergridContracts(unittest.TestCase):
def test_aethergrid_module_imports_with_headless_stubs(self):
mod = _import_aethergrid()
self.assertTrue(hasattr(mod, "TileGrid"))
self.assertTrue(hasattr(mod, "HubTile"))
self.assertTrue(hasattr(mod, "ToggleTile"))
self.assertTrue(hasattr(mod, "ValueTile"))
self.assertTrue(hasattr(mod, "RadioTileGroup"))
self.assertTrue(hasattr(mod, "AetherSliderDialog"))
def test_tile_grid_column_contract_stays_stable(self):
mod = _import_aethergrid()
grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap)
self.assertEqual(grid.get_column_count(1), 1)
self.assertEqual(grid.get_column_count(2), 2)
self.assertEqual(grid.get_column_count(3), 3)
self.assertEqual(grid.get_column_count(4), 2)
self.assertEqual(grid.get_column_count(5), 3)
self.assertEqual(grid.get_column_count(7), 4)
def test_build_list_panel_frame_contract(self):
mod = _import_aethergrid()
frame = mod.build_list_panel_frame(mod.rl.Rectangle(0, 0, 1920, 1080))
self.assertGreater(frame.shell.width, 0)
self.assertEqual(frame.header.height, mod.AETHER_LIST_METRICS.header_height)
self.assertGreater(frame.scroll.height, 0)
def test_hit_rect_keeps_touch_slop_after_surface_flattening(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
tile.set_rect(mod.rl.Rectangle(100, 200, 300, 150))
hit = tile._hit_rect
self.assertGreater(hit.width, tile._rect.width)
self.assertGreater(hit.height, tile._rect.height)
def test_aether_tile_uses_single_planar_face_contract(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
face = tile._surface_rect(mod.rl.Rectangle(0, 0, 320, 160))
self.assertLess(face.width, 320)
self.assertLess(face.height, 160)
self.assertGreaterEqual(face.x, 0)
self.assertGreaterEqual(face.y, 0)
def test_aether_tile_surface_rect_snaps_to_integer_pixels(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
face = tile._surface_rect(mod.rl.Rectangle(0.5, 0.5, 320.25, 160.75))
self.assertEqual(face.x, round(face.x))
self.assertEqual(face.y, round(face.y))
self.assertEqual(face.width, round(face.width))
self.assertEqual(face.height, round(face.height))
def test_aether_tile_preserves_substrate_color_attribute_for_compatibility(self):
mod = _import_aethergrid()
substrate = mod.hex_to_color("#101820")
tile = mod.AetherTile(surface_color="#3B82F6", substrate_color=substrate)
self.assertIs(tile.substrate_color, substrate)
def test_hub_tile_preserves_status_progress_api(self):
mod = _import_aethergrid()
tile = mod.HubTile("Driving Controls", "Desc", "", bg_color="#3B82F6", get_status=lambda: "Download 50%")
self.assertEqual(tile.get_status(), "Download 50%")
def test_tile_stack_layout_keeps_full_content_block_inside_face(self):
mod = _import_aethergrid()
tile = mod.AetherTile(surface_color="#3B82F6")
face = mod.rl.Rectangle(0, 0, 320, 180)
layout = tile._measure_tile_stack(face, icon_height=60, title_lines=2, title_size=28, primary_size=30, desc_lines=2, desc_size=18)
self.assertGreaterEqual(layout["top"], 0)
self.assertLessEqual(layout["desc_bottom"], face.height)
def test_tile_grid_reflows_to_wider_tiles_when_width_is_tight(self):
mod = _import_aethergrid()
grid = mod.TileGrid(columns=None, padding=mod.SPACING.tile_gap)
spies = [RenderSpy() for _ in range(7)]
for spy in spies:
grid.add_tile(spy)
grid.render(mod.rl.Rectangle(0, 0, 700, 500))
self.assertTrue(spies[0].rects)
self.assertGreater(spies[0].rects[0].width, 300)
def test_toggle_and_value_tiles_keep_enabled_contract(self):
mod = _import_aethergrid()
toggle = mod.ToggleTile("Feature", lambda: True, lambda _v: None, is_enabled=lambda: False)
value = mod.ValueTile("Value", lambda: "42", lambda: None, is_enabled=lambda: False)
self.assertFalse(toggle.enabled)
self.assertFalse(value.enabled)
def test_disabled_value_tile_does_not_trigger_click(self):
mod = _import_aethergrid()
on_click = MagicMock()
value = mod.ValueTile("Value", lambda: "42", on_click, is_enabled=lambda: False)
value.set_rect(mod.rl.Rectangle(0, 0, 300, 150))
value._handle_mouse_press(types.SimpleNamespace(x=10, y=10))
value._handle_mouse_release(types.SimpleNamespace(x=10, y=10))
on_click.assert_not_called()
def test_slider_dialog_keeps_color_and_callback_contract(self):
mod = _import_aethergrid()
captured = []
dialog = mod.AetherSliderDialog("Test", 0, 10, 1, 5, lambda result, value: captured.append((result, value)), color="#8B5CF6")
self.assertGreaterEqual(dialog._color.r, 0)
self.assertEqual(dialog._current_val, 5)
def test_radio_tile_group_preserves_selection_api(self):
mod = _import_aethergrid()
group = mod.RadioTileGroup("", ["A", "B", "C"], 1, lambda idx: None)
group.set_index(2)
self.assertEqual(group.current_index, 2)
def test_zero_span_sliders_do_not_divide_by_zero(self):
mod = _import_aethergrid()
slider = mod.AetherSlider(5, 5, 1, 5, lambda _v: None)
thumb_x = slider._get_thumb_x(mod.rl.Rectangle(0, 0, 320, 80))
self.assertGreaterEqual(thumb_x, 0)
def test_panel_module_can_import_against_refactored_aethergrid(self):
panel_mod = _import_panel()
self.assertTrue(hasattr(panel_mod, "StarPilotPanel"))
self.assertTrue(hasattr(panel_mod, "create_tile_panel"))
def test_sectioned_layout_short_height_uses_scrollable_compact_profile(self):
mod = _import_aethergrid()
sectioned_mod = _import_real_sectioned_panel()
layout = sectioned_mod.SectionedTileLayout()
grid = mod.TileGrid(columns=2, padding=mod.SPACING.tile_gap, uniform_width=True)
for _ in range(6):
grid.add_tile(RenderSpy())
layout.set_sections([sectioned_mod.TileSection("Test", grid)])
title_h, title_gap, section_gap, row_h = layout._layout_profile(mod.rl.Rectangle(0, 0, 800, 220), layout._sections)
self.assertGreaterEqual(row_h, 0)
self.assertLessEqual(title_h, layout._title_height)
if __name__ == "__main__":
unittest.main()