mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-07 17:35:41 +08:00
BigUI WIP: Simpleton
This commit is contained in:
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):
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user