diff --git a/selfdrive/ui/layouts/settings/starpilot/maps.py b/selfdrive/ui/layouts/settings/starpilot/maps.py index d0848a9c7..4b847ba36 100644 --- a/selfdrive/ui/layouts/settings/starpilot/maps.py +++ b/selfdrive/ui/layouts/settings/starpilot/maps.py @@ -10,49 +10,37 @@ import pyray as rl from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.system.ui.lib.application import FontWeight, MouseEvent, MousePos, gui_app +from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.lib.multilang import tr, tr_noop, trn -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 DialogResult, Widget +from openpilot.system.ui.widgets import DialogResult from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog from openpilot.system.ui.widgets.label import gui_label, gui_text_box from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( - AETHER_COMPACT_ROW_HEIGHT, AETHER_LIST_METRICS, AetherButton, AetherListColors, - AetherScrollbar, + AetherSegmentedControl, DEFAULT_PANEL_STYLE, - SPACING, - build_list_panel_frame, + PanelManagerView, + aether_begin_scissor_mode, + aether_end_scissor_mode, draw_action_pill, draw_busy_ring, draw_empty_state_card, draw_list_group_shell, - draw_list_panel_shell, draw_metric_strip, draw_section_header, - draw_tab_card, draw_selection_list_row, - draw_list_scroll_fades, - draw_settings_panel_header, - draw_soft_card, - init_list_panel, - point_hits, with_alpha, - wrap_text, - aether_begin_scissor_mode, - aether_end_scissor_mode, ) -from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel +from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import _SettingsPage from openpilot.starpilot.common.maps_catalog import ( MAPS_CATALOG, MAP_TOKEN_LABELS, MAP_SCHEDULE_LABELS, - normalize_schedule_value, sanitize_selected_locations_csv, schedule_label, ) @@ -61,35 +49,8 @@ from openpilot.starpilot.common.maps_selection import COUNTRY_PREFIX, STATE_PREF NetworkType = log.DeviceState.NetworkType -NETWORK_TYPE_LABELS = { - NetworkType.none: tr_noop("Offline"), - NetworkType.wifi: tr_noop("Wi-Fi"), - NetworkType.ethernet: tr_noop("Ethernet"), - NetworkType.cell2G: tr_noop("2G"), - NetworkType.cell3G: tr_noop("3G"), - NetworkType.cell4G: tr_noop("LTE"), - NetworkType.cell5G: tr_noop("5G"), -} - OFFLINE_MAPS_PATH = Path("/data/media/0/osm/offline") CANCEL_REQUEST_TIMEOUT = 3.0 -HEADER_TOP_OFFSET = 4 -HEADER_TITLE_HEIGHT = 40 -HEADER_SUBTITLE_HEIGHT = 24 -HEADER_BOTTOM_GAP = 12 -BROWSER_SECTION_HEADER_HEIGHT = AETHER_LIST_METRICS.section_header_height -BROWSER_SECTION_HEADER_GAP = AETHER_LIST_METRICS.section_header_gap -BROWSER_INSET = 10 -BROWSER_TAB_GAP = AETHER_LIST_METRICS.section_header_gap -BROWSER_CONTEXT_TAB_HEIGHT = 68 -BROWSER_REGION_ROW_HEIGHT = AETHER_LIST_METRICS.row_height -BROWSER_EMPTY_STATE_HEIGHT = 128 -STATUS_CARD_INSET = BROWSER_INSET -STATUS_BUTTON_HEIGHT = 52 -STATUS_BUTTON_GAP = 8 -STATUS_REMOVE_HEIGHT = 40 -STATUS_METRIC_GAP = 18 -STATUS_SELECTION_CHIP_HEIGHT = 30 PANEL_STYLE = DEFAULT_PANEL_STYLE MAPS_METRICS = replace(AETHER_LIST_METRICS, header_height=0) @@ -97,6 +58,51 @@ COUNTRIES_SECTION = next(section for section in MAPS_CATALOG if section["key"] = STATES_SECTION = next(section for section in MAPS_CATALOG if section["key"] == "states") US_COUNTRY_TOKEN = f"{COUNTRY_PREFIX}US" +ALL_US_STATE_TOKENS = frozenset({ + region["token"] for group in STATES_SECTION["groups"] for region in group["regions"] +}) + +REGIONAL_PACKAGES = { + "pkg:midwest": { + "title": tr_noop("U.S. Midwest Region (12 States)"), + "subtitle": tr_noop("IL, IN, IA, KS, MI, MN, MO, NE, ND, OH, SD, WI"), + "tokens": frozenset({r["token"] for g in STATES_SECTION["groups"] if g["key"] == "midwest" for r in g["regions"]}), + }, + "pkg:northeast": { + "title": tr_noop("U.S. Northeast Region (9 States)"), + "subtitle": tr_noop("CT, ME, MA, NH, NJ, NY, PA, RI, VT"), + "tokens": frozenset({r["token"] for g in STATES_SECTION["groups"] if g["key"] == "northeast" for r in g["regions"]}), + }, + "pkg:south": { + "title": tr_noop("U.S. South Region (17 States)"), + "subtitle": tr_noop("AL, AR, DE, DC, FL, GA, KY, LA, MD, MS, NC, OK, SC, TN, TX, VA, WV"), + "tokens": frozenset({r["token"] for g in STATES_SECTION["groups"] if g["key"] == "south" for r in g["regions"]}), + }, + "pkg:west": { + "title": tr_noop("U.S. West Region (13 States)"), + "subtitle": tr_noop("AK, AZ, CA, CO, HI, ID, MT, NV, NM, OR, UT, WA, WY"), + "tokens": frozenset({r["token"] for g in STATES_SECTION["groups"] if g["key"] == "west" for r in g["regions"]}), + }, + "pkg:territories": { + "title": tr_noop("U.S. Territories (5 Territories)"), + "subtitle": tr_noop("AS, GU, MP, PR, VI"), + "tokens": frozenset({r["token"] for g in STATES_SECTION["groups"] if g["key"] == "territories" for r in g["regions"]}), + }, +} + +STATUS_CARD_HEIGHT = 232.0 +SEGMENTED_CONTROL_HEIGHT = 68.0 +BROWSER_SECTION_HEADER_HEIGHT = 56.0 +BROWSER_REGION_ROW_HEIGHT = 104.0 +BROWSER_EMPTY_STATE_HEIGHT = 128.0 +BROWSER_INSET = 18.0 + +HEADER_GAP = 16.0 +SUBHEADER_GAP = 12.0 +LIST_TOP_GAP = 12.0 + +FIXED_HEADER_HEIGHT = STATUS_CARD_HEIGHT + HEADER_GAP + SEGMENTED_CONTROL_HEIGHT + SUBHEADER_GAP + BROWSER_SECTION_HEADER_HEIGHT + LIST_TOP_GAP + def _format_mb(size_bytes: int) -> str: mb = size_bytes / (1024 * 1024) @@ -136,8 +142,7 @@ def _localized_schedule_label(value) -> str: def _selected_token_set(selected_raw: str | bytes | None) -> set[str]: normalized = sanitize_selected_locations_csv(selected_raw or "") - tokens = {token for token in normalized.split(",") if token} - return tokens + return {token for token in normalized.split(",") if token} @dataclass(slots=True) @@ -147,293 +152,146 @@ class MapsDownloadState: total_files: int = 0 downloaded_files: int = 0 primary_location: str = "" - location_count: int = 0 percent: int = 0 progress_text: str = "" -class MapStatusCard(Widget): - def __init__(self, controller: "StarPilotMapsLayout"): +class MapsManagerView(PanelManagerView): + METRICS = MAPS_METRICS + PANEL_STYLE = PANEL_STYLE + + def __init__(self, controller: StarPilotMapsLayout): super().__init__() self._controller = controller self._remove_rect = rl.Rectangle(0, 0, 0, 0) - self._pressed_remove = False - self._primary_button = self._child(controller._download_button) - self._secondary_button = self._child(controller._schedule_button) - def set_touch_valid_callback(self, touch_callback): - super().set_touch_valid_callback(touch_callback) - self._primary_button.set_touch_valid_callback(touch_callback) - self._secondary_button.set_touch_valid_callback(touch_callback) - - def _handle_mouse_press(self, mouse_pos: MousePos): - if not self._touch_valid(): - return - if point_hits(mouse_pos, self._remove_rect, pad_x=10, pad_y=6) and self._controller._remove_enabled(): - self._pressed_remove = True - - def _handle_mouse_release(self, mouse_pos: MousePos): - if self._pressed_remove: - self._pressed_remove = False - if self._touch_valid() and point_hits(mouse_pos, self._remove_rect, pad_x=10, pad_y=6) and self._controller._remove_enabled(): - self._controller._on_remove() - - def _handle_mouse_event(self, mouse_event: MouseEvent): - if self._pressed_remove and not point_hits(mouse_event.pos, self._remove_rect, pad_x=10, pad_y=6): - self._pressed_remove = False - - def _render(self, rect: rl.Rectangle): - draw_soft_card(rect, PANEL_STYLE.surface_fill, PANEL_STYLE.surface_border) - - inset = STATUS_CARD_INSET - content_x = rect.x + inset - content_w = rect.width - inset * 2 - actions_w = max(330.0, min(386.0, rect.width * 0.31)) - actions_x = rect.x + rect.width - inset - actions_w - summary_w = max(250.0, actions_x - 18 - content_x) - footer_w = min(208.0, max(176.0, summary_w * 0.38)) - metric_gap = 18.0 - metrics_x = content_x + footer_w + metric_gap - metrics_w = summary_w - footer_w - metric_gap - - title_y = rect.y + 6 - title_text = self._controller._progress_title() - selection_chip_rect = self._controller._selection_chip_rect(content_x, title_y, summary_w) - title_w = summary_w - if selection_chip_rect is not None: - title_w = selection_chip_rect.x - content_x - 12 - title_width = measure_text_cached(gui_app.font(FontWeight.SEMI_BOLD), title_text, 24, spacing=1).x - if title_w < 210 or title_width > title_w: - selection_chip_rect = None - title_w = summary_w - gui_label(rl.Rectangle(content_x, title_y, title_w, 24), title_text, 24, AetherListColors.HEADER, FontWeight.SEMI_BOLD) - if selection_chip_rect is not None: - draw_action_pill( - selection_chip_rect, - self._controller._selected_summary_text(), - with_alpha(AetherListColors.SUCCESS_SOFT, 22), - AetherListColors.SUCCESS_SOFT, - AetherListColors.HEADER, - font_size=26, + self._source_segmented_control = self._child( + AetherSegmentedControl( + [tr("U.S. States & Regions"), tr("Other Countries")], + self._controller._get_source_segment_index, + self._controller._on_source_segment_change, + style=PANEL_STYLE, + suppress_background=True, ) - gui_text_box( - rl.Rectangle(content_x, title_y + 28, summary_w, 44), - self._controller._progress_body(), - 20, - AetherListColors.SUBTEXT, - font_weight=FontWeight.NORMAL, - line_scale=0.94, ) - footer_y = rect.y + rect.height - 28 - footer_text = f"{self._controller._network_label()} • {tr('Parked') if self._controller._is_parked() else tr('Not parked')}" - if metrics_w >= 220.0: - gui_label(rl.Rectangle(content_x, footer_y, footer_w, 16), footer_text, 18, AetherListColors.MUTED, FontWeight.MEDIUM) - draw_metric_strip( - rl.Rectangle(metrics_x, rect.y + 82, metrics_w, 30), - [ - (tr("Storage"), self._controller._storage_text), - (tr("Last Updated"), self._controller._last_updated_text()), - ], - gap=STATUS_METRIC_GAP, - style=PANEL_STYLE, - label_top_offset=0, - value_top_offset=14, - divider_top_offset=2, - divider_bottom_offset=16, - ) - else: - draw_metric_strip( - rl.Rectangle(content_x, rect.y + 82, summary_w, 30), - [ - (tr("Storage"), self._controller._storage_text), - (tr("Last Updated"), self._controller._last_updated_text()), - ], - gap=STATUS_METRIC_GAP, - style=PANEL_STYLE, - label_top_offset=0, - value_top_offset=14, - divider_top_offset=2, - divider_bottom_offset=16, - ) + def _draw_header(self, rect: rl.Rectangle): + pass - action_top = rect.y + 12 - col_gap = 10 - action_h = rect.height - 24 - left_col_w = (actions_w - col_gap) / 2 - right_col_w = (actions_w - col_gap) / 2 - left_btn_gap = 6 - left_btn_h = (action_h - left_btn_gap) / 2 + def _measure_content_height(self, content_width: float) -> float: + downloaded, downloadable = self._controller._browse_regions_for_active_view() + total_rows = len(downloaded) + len(downloadable) + if total_rows <= 0: + return BROWSER_EMPTY_STATE_HEIGHT + h = total_rows * BROWSER_REGION_ROW_HEIGHT + if downloaded and downloadable: + h += BROWSER_SECTION_HEADER_HEIGHT + 16.0 + return h - self._remove_rect = rl.Rectangle(actions_x, action_top, left_col_w, left_btn_h) - schedule_rect = rl.Rectangle(actions_x, action_top + left_btn_h + left_btn_gap, left_col_w, left_btn_h) - primary_rect = rl.Rectangle(actions_x + left_col_w + col_gap, action_top, right_col_w, action_h) + def _draw_static_elements(self, scroll_rect: rl.Rectangle, content_width: float): + # 1. Pinned Status Card + status_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y, content_width, STATUS_CARD_HEIGHT) + self._draw_status_card(status_rect) - self._primary_button.render(primary_rect) - self._secondary_button.render(schedule_rect) + # 2. Pinned Source Segmented Control ([ U.S. States & Regions | Other Countries ]) + seg_y = scroll_rect.y + STATUS_CARD_HEIGHT + HEADER_GAP + seg_rect = rl.Rectangle(scroll_rect.x + BROWSER_INSET, seg_y, content_width - BROWSER_INSET * 2, SEGMENTED_CONTROL_HEIGHT) + self._source_segmented_control.render(seg_rect) - enabled = self._controller._remove_enabled() - draw_action_pill( - self._remove_rect, - tr("Remove Maps"), - with_alpha(AetherListColors.DANGER_SOFT, 26 if enabled else 12), - with_alpha(AetherListColors.DANGER, 58 if enabled else 24), - AetherListColors.HEADER if enabled else AetherListColors.MUTED, - font_size=26, - ) - - if self._controller._download_state.active: - center = rl.Vector2(primary_rect.x + primary_rect.width - 22, primary_rect.y + primary_rect.height / 2) - draw_busy_ring(center, rl.get_time() * 160, PANEL_STYLE.accent, inner_radius=9, outer_radius=13, sweep=210, thickness=20) - - -class MapBrowserCard(Widget): - def __init__(self, controller: "StarPilotMapsLayout"): - super().__init__() - self._controller = controller - self._pressed_target: str | None = None - self._source_rects: dict[str, rl.Rectangle] = {} - self._context_tab_rects: dict[str, rl.Rectangle] = {} - self._region_row_rects: dict[str, rl.Rectangle] = {} - - def set_touch_valid_callback(self, touch_callback): - super().set_touch_valid_callback(touch_callback) - - def _handle_mouse_press(self, mouse_pos: MousePos): - if not self._touch_valid(): - return - self._pressed_target = self._target_at(mouse_pos) - - def _handle_mouse_release(self, mouse_pos: MousePos): - target = self._target_at(mouse_pos) if self._touch_valid() else None - pressed_target = self._pressed_target - self._pressed_target = None - if pressed_target is not None and pressed_target == target: - self._activate_target(pressed_target) - - def _handle_mouse_event(self, mouse_event: MouseEvent): - if self._pressed_target is not None and self._target_at(mouse_event.pos) != self._pressed_target: - self._pressed_target = None - - def _target_at(self, mouse_pos: MousePos) -> str | None: - parent_rect = getattr(self, "_parent_rect", None) - if parent_rect is not None and not rl.check_collision_point_rec(mouse_pos, parent_rect): - return None - for source_key, rect in self._source_rects.items(): - if point_hits(mouse_pos, rect, parent_rect, pad_x=6, pad_y=6): - return f"source:{source_key}" - for group_key, rect in self._context_tab_rects.items(): - if point_hits(mouse_pos, rect, parent_rect, pad_x=4, pad_y=4): - return f"group:{group_key}" - for token, rect in self._region_row_rects.items(): - if point_hits(mouse_pos, rect, parent_rect, pad_x=6, pad_y=0): - return f"region:{token}" - return None - - def _activate_target(self, target: str): - if target.startswith("source:"): - self._controller._select_browse_source(target.split(":", 1)[1]) - return - if target.startswith("group:"): - self._controller._set_active_group(target.split(":", 1)[1]) - return - if target.startswith("region:"): - self._controller._toggle_region(target.split(":", 1)[1]) - - def _render_source_picker(self, rect: rl.Rectangle): - self._source_rects.clear() - - mouse_pos = gui_app.last_mouse_event.pos - button_w = (rect.width - BROWSER_TAB_GAP) / 2 - sources = [ - ("us", tr("United States")), - ("other", tr("Other Countries")), - ] - - for index, (source_key, title) in enumerate(sources): - button_rect = rl.Rectangle(rect.x + index * (button_w + BROWSER_TAB_GAP), rect.y, button_w, BROWSER_CONTEXT_TAB_HEIGHT) - self._source_rects[source_key] = button_rect - hovered = point_hits(mouse_pos, button_rect, self._parent_rect, pad_x=6, pad_y=6) - pressed = self._pressed_target == f"source:{source_key}" - current = self._controller._active_source_key() == source_key - - draw_tab_card( - button_rect, - title, - current=current, - hovered=hovered, - pressed=pressed, - title_size=28, - style=PANEL_STYLE, - ) - - def _row_height(self, count: int, row_height: float) -> float: - return 0.0 if count <= 0 else count * row_height - - def _measure_context_tabs_height(self, width: float) -> float: - del width - groups = self._controller._visible_browser_tabs() - if not groups: - return 0.0 - return BROWSER_CONTEXT_TAB_HEIGHT - - def _render_context_tabs(self, rect: rl.Rectangle): - groups = self._controller._visible_browser_tabs() - self._context_tab_rects.clear() - if not groups: - return - - mouse_pos = gui_app.last_mouse_event.pos - available_w = max(1.0, rect.width) - tab_gap = BROWSER_TAB_GAP - tab_w = (available_w - tab_gap * max(0, len(groups) - 1)) / max(1, len(groups)) - - for index, group in enumerate(groups): - tab_rect = rl.Rectangle(rect.x + index * (tab_w + tab_gap), rect.y, tab_w, BROWSER_CONTEXT_TAB_HEIGHT) - self._context_tab_rects[group["key"]] = tab_rect - current = self._controller._active_tab_key() == group["key"] - hovered = point_hits(mouse_pos, tab_rect, self._parent_rect, pad_x=4, pad_y=4) - pressed = self._pressed_target == f"group:{group['key']}" - - draw_tab_card( - tab_rect, - tr(group["title"]), - self._controller._group_primary_text(group), - current=current, - hovered=hovered, - pressed=pressed, - title_size=24, - subtitle_size=18, - show_underline=True, - style=PANEL_STYLE, - ) - - def _render_empty_state(self, rect: rl.Rectangle, title: str, body: str): - draw_empty_state_card( - rect, - title, - body, - title_size=32, - body_size=24, - border=with_alpha(PANEL_STYLE.surface_border, 10), + # 3. Pinned Master Section Header + header_y = seg_y + SEGMENTED_CONTROL_HEIGHT + SUBHEADER_GAP + draw_section_header( + rl.Rectangle(scroll_rect.x + BROWSER_INSET, header_y, content_width - BROWSER_INSET * 2, BROWSER_SECTION_HEADER_HEIGHT), + tr("Map Packages & Regions"), + trailing_text=self._controller._active_view_count_text(), + title_size=36, + trailing_size=24, style=PANEL_STYLE, ) - def _render_region_rows(self, rect: rl.Rectangle, regions: list[dict]): - if not regions: + def _draw_scroll_content(self, scroll_rect: rl.Rectangle, content_width: float): + rows_top_y = scroll_rect.y + FIXED_HEADER_HEIGHT + rows_visible_h = max(100.0, scroll_rect.height - FIXED_HEADER_HEIGHT) + rows_scissor_rect = rl.Rectangle(scroll_rect.x, rows_top_y, content_width, rows_visible_h) + + aether_begin_scissor_mode( + int(rows_scissor_rect.x), + int(rows_scissor_rect.y), + int(rows_scissor_rect.width), + int(rows_scissor_rect.height), + ) + + downloaded, downloadable = self._controller._browse_regions_for_active_view() + if not downloaded and not downloadable: title, body = self._controller._browse_empty_state() - self._render_empty_state(rect, title, body) + draw_empty_state_card( + rl.Rectangle(scroll_rect.x + BROWSER_INSET, rows_top_y + self._scroll_offset, content_width - BROWSER_INSET * 2, BROWSER_EMPTY_STATE_HEIGHT), + title, + body, + title_size=32, + body_size=24, + border=with_alpha(PANEL_STYLE.surface_border, 10), + style=PANEL_STYLE, + ) + aether_end_scissor_mode() return - mouse_pos = gui_app.last_mouse_event.pos - self._region_row_rects.clear() - for index, region in enumerate(regions): + y = rows_top_y + self._scroll_offset + + # ── TOP SECTION: Downloaded / Installed Maps ── + if downloaded: + for index, region in enumerate(downloaded): + token = region["token"] + row_rect = rl.Rectangle(scroll_rect.x + BROWSER_INSET, y, content_width - BROWSER_INSET * 2, BROWSER_REGION_ROW_HEIGHT) + target_id = f"region:{token}" + hovered, pressed = self._interactive_state(target_id, row_rect, pad_y=0) + + draw_selection_list_row( + row_rect, + title=tr(region["label"]), + subtitle=tr("Downloaded on device"), + action_text=tr("Downloaded"), + current=True, + hovered=hovered, + pressed=pressed, + is_last=index == len(downloaded) - 1 and not downloadable, + action_width=164, + action_pill=True, + action_text_size=26, + action_pill_height=56, + action_pill_width=154, + title_size=32, + subtitle_size=22, + row_separator=PANEL_STYLE.divider_color, + current_bg=PANEL_STYLE.current_fill, + current_border=PANEL_STYLE.current_border, + action_fill=with_alpha(AetherListColors.SUCCESS_SOFT, 36), + action_border=with_alpha(AetherListColors.SUCCESS, 60), + action_text_color=AetherListColors.HEADER, + ) + y += BROWSER_REGION_ROW_HEIGHT + + # ── THIN DIVIDER LINE + SECTION HEADER: Separating Downloaded from Downloadable ── + if downloadable: + y += 8.0 + draw_section_header( + rl.Rectangle(scroll_rect.x + BROWSER_INSET, y, content_width - BROWSER_INSET * 2, BROWSER_SECTION_HEADER_HEIGHT), + tr("Available for Download"), + trailing_text=tr("{} available").format(len(downloadable)), + title_size=30, + trailing_size=22, + style=PANEL_STYLE, + ) + y += BROWSER_SECTION_HEADER_HEIGHT + 8.0 + + # ── BOTTOM SECTION: Available / Downloadable Maps ── + for index, region in enumerate(downloadable): token = region["token"] selected = self._controller._get_map_state(token) - row_rect = rl.Rectangle(rect.x, rect.y + index * BROWSER_REGION_ROW_HEIGHT, rect.width, BROWSER_REGION_ROW_HEIGHT) - self._region_row_rects[token] = row_rect - hovered = point_hits(mouse_pos, row_rect, self._parent_rect, pad_x=6, pad_y=0) - target_key = f"region:{token}" + row_rect = rl.Rectangle(scroll_rect.x + BROWSER_INSET, y, content_width - BROWSER_INSET * 2, BROWSER_REGION_ROW_HEIGHT) + target_id = f"region:{token}" + hovered, pressed = self._interactive_state(target_id, row_rect, pad_y=0) + action_text = self._controller._region_action_text(token) draw_selection_list_row( row_rect, @@ -442,83 +300,140 @@ class MapBrowserCard(Widget): action_text=action_text, current=selected, hovered=hovered, - pressed=self._pressed_target == target_key, - is_last=index == len(regions) - 1, - action_width=AETHER_LIST_METRICS.action_width, + pressed=pressed, + is_last=index == len(downloadable) - 1, + action_width=164, action_pill=True, action_text_size=26, - action_pill_height=64, - action_pill_width=191 if selected else 156, - title_size=49, - subtitle_size=32, + action_pill_height=56, + action_pill_width=154 if selected else 128, + title_size=32, + subtitle_size=22, row_separator=PANEL_STYLE.divider_color, current_bg=PANEL_STYLE.current_fill, current_border=PANEL_STYLE.current_border, - action_fill=with_alpha(AetherListColors.SUCCESS_SOFT, 18) if selected else rl.Color(255, 255, 255, 8), - action_border=with_alpha(AetherListColors.SUCCESS, 38) if selected else rl.Color(255, 255, 255, 24), + action_fill=with_alpha(AetherListColors.SUCCESS_SOFT, 24) if selected else rl.Color(255, 255, 255, 8), + action_border=with_alpha(AetherListColors.SUCCESS, 48) if selected else rl.Color(255, 255, 255, 20), action_text_color=AetherListColors.HEADER, ) + y += BROWSER_REGION_ROW_HEIGHT - def _active_browse_regions(self) -> list[dict]: - return self._controller._browse_regions_for_active_group() + aether_end_scissor_mode() - def _render_section_header(self, rect: rl.Rectangle, title: str, *, count_text: str | None = None): - draw_section_header(rect, title, trailing_text=count_text or "", title_size=49, trailing_size=32, style=PANEL_STYLE) + if self._content_height > rows_visible_h: + self._scrollbar.render(rows_scissor_rect, self._content_height, self._scroll_offset) - def _measure_height(self, width: float) -> float: - total = 10.0 - total += BROWSER_CONTEXT_TAB_HEIGHT + BROWSER_SECTION_HEADER_GAP # source picker - total += self._measure_context_tabs_height(width) + BROWSER_SECTION_HEADER_GAP - total += BROWSER_SECTION_HEADER_HEIGHT + BROWSER_SECTION_HEADER_GAP - region_count = len(self._active_browse_regions()) - total += self._row_height(region_count, BROWSER_REGION_ROW_HEIGHT) if region_count else BROWSER_EMPTY_STATE_HEIGHT - return total + 10 - - def _render(self, rect: rl.Rectangle): - self.set_rect(rect) - if not self._touch_valid(): - self._pressed_target = None + def _draw_status_card(self, rect: rl.Rectangle): draw_list_group_shell(rect, style=PANEL_STYLE) - self._source_rects.clear() - self._context_tab_rects.clear() - self._region_row_rects.clear() - content_x = rect.x + BROWSER_INSET - content_w = rect.width - BROWSER_INSET * 2 - y = rect.y + 10 + inset = 24.0 + actions_w = min(400.0, max(320.0, rect.width * 0.36)) + actions_x = rect.x + rect.width - inset - actions_w + content_x = rect.x + inset + summary_w = max(240.0, actions_x - 20.0 - content_x) - self._render_source_picker(rl.Rectangle(content_x, y, content_w, BROWSER_CONTEXT_TAB_HEIGHT)) - y += BROWSER_CONTEXT_TAB_HEIGHT + BROWSER_SECTION_HEADER_GAP + # Title (32pt Bold) + title_text = self._controller._progress_title() + gui_label(rl.Rectangle(content_x, rect.y + 18, summary_w, 34), title_text, 32, AetherListColors.HEADER, FontWeight.SEMI_BOLD) - context_tabs_h = self._measure_context_tabs_height(content_w) - self._render_context_tabs(rl.Rectangle(content_x, y, content_w, context_tabs_h)) - y += context_tabs_h + BROWSER_SECTION_HEADER_GAP + # Selection Summary Badge next to title if selected (24pt, height 38) + if self._controller._selected_count() > 0: + chip_text = self._controller._selected_summary_text() + title_measured_w = measure_text_cached(gui_app.font(FontWeight.SEMI_BOLD), title_text, 32, spacing=1).x + chip_width = measure_text_cached(gui_app.font(FontWeight.SEMI_BOLD), chip_text, 22, spacing=1).x + 30.0 + chip_x = content_x + title_measured_w + 16.0 + if chip_x + chip_width <= content_x + summary_w: + draw_action_pill( + rl.Rectangle(chip_x, rect.y + 16, chip_width, 38), + chip_text, + with_alpha(AetherListColors.SUCCESS_SOFT, 30), + AetherListColors.SUCCESS_SOFT, + AetherListColors.HEADER, + font_size=24, + ) - self._render_section_header( - rl.Rectangle(content_x, y, content_w, BROWSER_SECTION_HEADER_HEIGHT), - tr("Regions"), - count_text=self._controller._active_group_count_text(), + # Subtitle / Body Progress Description (26pt) + gui_text_box( + rl.Rectangle(content_x, rect.y + 58, summary_w, 54), + self._controller._progress_body(), + 26, + AetherListColors.SUBTEXT, + font_weight=FontWeight.NORMAL, + line_scale=0.95, ) - y += BROWSER_SECTION_HEADER_HEIGHT + BROWSER_SECTION_HEADER_GAP - regions = self._active_browse_regions() - region_h = self._row_height(len(regions), BROWSER_REGION_ROW_HEIGHT) if regions else BROWSER_EMPTY_STATE_HEIGHT - self._render_region_rows(rl.Rectangle(content_x, y, content_w, region_h), regions) + # Telemetry Strip (Storage & Last Updated — Label 24pt, Value 32pt, value_top_offset=30 for zero overlap) + metrics = [ + (tr("Storage"), self._controller._storage_text), + (tr("Last Updated"), self._controller._last_updated_text()), + ] + draw_metric_strip( + rl.Rectangle(content_x, rect.y + 128, summary_w, 72), + metrics, + gap=36.0, + min_col_width=140.0, + label_size=24, + value_size=32, + style=PANEL_STYLE, + label_top_offset=0, + value_top_offset=30, + divider_top_offset=4, + divider_bottom_offset=54, + ) + + # Action Column + primary_h = 84.0 + primary_rect = rl.Rectangle(actions_x, rect.y + 20, actions_w, primary_h) + self._controller._download_button.render(primary_rect) + + if self._controller._download_state.active: + center = rl.Vector2(primary_rect.x + primary_rect.width - 28, primary_rect.y + primary_rect.height / 2) + draw_busy_ring(center, rl.get_time() * 160, PANEL_STYLE.accent, inner_radius=10, outer_radius=14, sweep=210, thickness=20) + + # Bottom Row Actions: Schedule Button & Remove Maps Pill (64px height) + bottom_y = rect.y + 116.0 + bottom_h = 64.0 + col_gap = 10.0 + sched_w = (actions_w - col_gap) * 0.58 + remove_w = (actions_w - col_gap) * 0.42 + + sched_rect = rl.Rectangle(actions_x, bottom_y, sched_w, bottom_h) + self._controller._schedule_button.render(sched_rect) + + self._remove_rect = rl.Rectangle(actions_x + sched_w + col_gap, bottom_y, remove_w, bottom_h) + enabled = self._controller._remove_enabled() + hovered, pressed = self._interactive_state("action:remove_maps", self._remove_rect, pad_y=4) + + remove_bg = with_alpha(AetherListColors.DANGER_SOFT, 36 if (pressed or hovered) else (24 if enabled else 12)) + remove_border = with_alpha(AetherListColors.DANGER, 90 if (pressed or hovered) else (56 if enabled else 24)) + draw_action_pill( + self._remove_rect, + tr("Remove"), + remove_bg, + remove_border, + AetherListColors.HEADER if enabled else AetherListColors.MUTED, + font_size=26, + ) + + def _activate_target(self, target_id: str | None): + if not target_id: + return + if target_id == "action:remove_maps": + self._controller._on_remove() + return + if target_id.startswith("region:"): + self._controller._toggle_region(target_id.split(":", 1)[1]) -class StarPilotMapsLayout(StarPilotPanel): - VIEW_COUNTRIES = 0 - VIEW_STATES = 1 +class StarPilotMapsLayout(_SettingsPage): + VIEW_STATES = 0 + VIEW_COUNTRIES = 1 def __init__(self): super().__init__() self._params_memory = Params(memory=True) self._worker_params = Params() self._map_sm = messaging.SubMaster(["mapdExtendedOut", "starpilotCarState"]) - self._scroll_panel = GuiScrollPanel2(horizontal=False) - self._scrollbar = AetherScrollbar() - self._scroll_offset = 0.0 - self._content_height = 0.0 self._storage_text = "0 MB" self._has_downloaded_data = False @@ -532,11 +447,6 @@ class StarPilotMapsLayout(StarPilotPanel): self._cancel_visual_until = 0.0 self._download_state = MapsDownloadState() self._view_index = self.VIEW_STATES - self._show_source_picker = True - self._browse_source_key = "us" - self._active_state_group_key = "whole_us" - self._full_us_mode = True - self._whole_us_context_initialized = False self._cached_selected_tokens: set[str] = set() self._download_button = self._child( @@ -555,22 +465,21 @@ class StarPilotMapsLayout(StarPilotPanel): ) ) - self._status_card = self._child(MapStatusCard(self)) - self._browser_card = self._child(MapBrowserCard(self)) + self._manager_view = MapsManagerView(self) - self._browser_card.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) - self._status_card.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid()) - - self._sync_whole_us_view_state() self._refresh_storage_cache(force=True) self._sync_download_state(force=True) def show_event(self): super().show_event() - self._scroll_offset = 0.0 - self._whole_us_context_initialized = False - self._status_card._pressed_remove = False - self._sync_whole_us_view_state() + # On panel launch, if no offline map files exist on disk and no download is actively running, + # reset any stale or default MapsSelected parameter so 0 regions show as selected on clean boot! + if not self._has_downloaded_data and not self._download_in_flight(): + raw_selected = self._params.get("MapsSelected", encoding="utf-8") or "" + if raw_selected: + self._params.put("MapsSelected", "") + self._cached_selected_tokens = set() + if self._cancel_requested() and self._cancel_requested_at is None: self._cancel_requested_at = rl.get_time() if self._cancel_requested() and self._cancel_visual_until <= rl.get_time(): @@ -580,9 +489,6 @@ class StarPilotMapsLayout(StarPilotPanel): def hide_event(self): super().hide_event() - self._scroll_offset = 0.0 - self._whole_us_context_initialized = False - self._status_card._pressed_remove = False device.set_override_interactive_timeout(None) def _update_state(self): @@ -631,7 +537,6 @@ class StarPilotMapsLayout(StarPilotPanel): total_files = int(progress.totalFiles) if progress is not None else 0 downloaded_files = int(progress.downloadedFiles) if progress is not None else 0 percent = int((downloaded_files * 100) / max(total_files, 1)) if total_files > 0 else 0 - location_count = (len(progress.locationDetails) or len(progress.locations)) if progress is not None else 0 primary_location = "" if progress is not None and len(progress.locationDetails) > 0: primary_location = str(progress.locationDetails[0].location) @@ -650,7 +555,6 @@ class StarPilotMapsLayout(StarPilotPanel): total_files=total_files, downloaded_files=downloaded_files, primary_location=primary_location, - location_count=location_count, percent=percent, progress_text=progress_text, ) @@ -700,18 +604,17 @@ class StarPilotMapsLayout(StarPilotPanel): return self._cached_selected_tokens def _selected_count(self) -> int: - return len(self._selected_tokens()) - - def _selected_section_count_text(self) -> str: - count = self._selected_count() - if count == 0: - return tr("None selected") - return tr("{} selected").format(count) + tokens = self._selected_tokens() + if US_COUNTRY_TOKEN in tokens: + return len(ALL_US_STATE_TOKENS) + return len(tokens) def _selected_summary_text(self) -> str: count = self._selected_count() if count == 0: return tr("No regions selected") + if US_COUNTRY_TOKEN in self._selected_tokens(): + return tr("Whole U.S. (56 states)") return trn("{} region selected", "{} regions selected", count).format(count) def _selected_primary_token(self) -> str | None: @@ -724,214 +627,159 @@ class StarPilotMapsLayout(StarPilotPanel): token = self._selected_primary_token() if not token: return "" + if token == US_COUNTRY_TOKEN: + return tr("Whole U.S.") return tr(MAP_TOKEN_LABELS.get(token, token)) - def _selection_chip_rect(self, content_x: float, title_y: float, summary_w: float) -> rl.Rectangle | None: - if self._selected_count() <= 0: - return None - text = self._selected_summary_text() - text_width = measure_text_cached(gui_app.font(FontWeight.SEMI_BOLD), text, 18, spacing=1).x - chip_w = min(220.0, max(128.0, text_width + 28.0)) - chip_x = content_x + max(0.0, summary_w - chip_w) - return rl.Rectangle(chip_x, title_y - 2, chip_w, STATUS_SELECTION_CHIP_HEIGHT) - def _selection_preview_text(self) -> str: count = self._selected_count() if count <= 0: return tr("No regions selected yet") - + if US_COUNTRY_TOKEN in self._selected_tokens(): + return tr("Whole U.S. package") primary_label = self._selected_primary_label() if count == 1: return primary_label return tr("{} + {} more").format(primary_label, count - 1) def _has_full_us_selected(self) -> bool: - return US_COUNTRY_TOKEN in self._selected_tokens() - - def _sync_whole_us_view_state(self): - if self._whole_us_context_initialized: - return - if self._has_full_us_selected(): - self._active_state_group_key = "whole_us" - self._full_us_mode = self._is_states_view() - self._whole_us_context_initialized = True + tokens = self._selected_tokens() + return (US_COUNTRY_TOKEN in tokens) or ALL_US_STATE_TOKENS.issubset(tokens) def _is_states_view(self) -> bool: return self._view_index == self.VIEW_STATES - def _showing_source_picker(self) -> bool: - return False + def _get_source_segment_index(self) -> int: + return 0 if self._is_states_view() else 1 - def _active_source_key(self) -> str: - return self._browse_source_key - - def _select_browse_source(self, source_key: str): - self._browse_source_key = source_key - if source_key == "us": - self._set_active_group("whole_us") - return - if source_key == "other": - self._set_active_group("countries") - - def _is_full_us_mode(self) -> bool: - return self._is_states_view() and self._full_us_mode - - def _set_full_us_mode(self, enabled: bool): - self._full_us_mode = bool(enabled) and self._is_states_view() - - def _current_section(self) -> dict: - return STATES_SECTION if self._is_states_view() else COUNTRIES_SECTION - - def _current_groups(self) -> list[dict]: - return self._current_section()["groups"] + def _on_source_segment_change(self, idx: int): + self._view_index = self.VIEW_STATES if idx == 0 else self.VIEW_COUNTRIES def _all_country_regions(self) -> list[dict]: - return [region for group in COUNTRIES_SECTION["groups"] for region in group["regions"]] + regions = [region for group in COUNTRIES_SECTION["groups"] for region in group["regions"]] + return sorted(regions, key=lambda r: tr(r["label"])) - def _full_us_regions(self) -> list[dict]: - return [{"token": US_COUNTRY_TOKEN, "label": tr(MAP_TOKEN_LABELS[US_COUNTRY_TOKEN])}] + def _all_us_states_regions(self) -> list[dict]: + regions = [region for group in STATES_SECTION["groups"] for region in group["regions"]] + return sorted(regions, key=lambda r: tr(r["label"])) - def _whole_us_group(self) -> dict: - return {"key": "whole_us", "title": tr("Whole U.S."), "regions": self._full_us_regions()} + def _bulk_package_regions(self) -> list[dict]: + return [ + {"token": US_COUNTRY_TOKEN, "label": tr("Whole U.S. (All 50 States & Territories)")}, + *[ + {"token": key, "label": tr(spec["title"])} + for key, spec in REGIONAL_PACKAGES.items() + ] + ] - def _visible_browser_tabs(self) -> list[dict]: - return [self._whole_us_group(), *STATES_SECTION["groups"], {"key": "countries", "title": tr("Countries"), "regions": []}] - - def _active_tab_key(self) -> str: - if not self._is_states_view(): - return "countries" - if self._is_full_us_mode(): - return "whole_us" - return self._active_state_group_key - - def _set_active_group(self, group_key: str): - if group_key == "countries": - self._view_index = self.VIEW_COUNTRIES - self._full_us_mode = False - self._browse_source_key = "other" - return - - self._view_index = self.VIEW_STATES - self._browse_source_key = "us" - if group_key == "whole_us": - self._active_state_group_key = "whole_us" - self._set_full_us_mode(True) - return - - group_keys = {group["key"] for group in STATES_SECTION["groups"]} - if group_key not in group_keys: - return - self._full_us_mode = False - self._active_state_group_key = group_key - - def _active_group(self) -> dict: - if not self._is_states_view(): - return {"key": "countries", "title": tr("Countries"), "regions": self._all_country_regions()} - - if self._is_full_us_mode(): - return self._whole_us_group() - - group_key = self._active_state_group_key - for group in STATES_SECTION["groups"]: - if group["key"] == group_key: - return group - fallback = STATES_SECTION["groups"][0] - self._active_state_group_key = fallback["key"] - return fallback - - def _active_group_regions(self) -> list[dict]: - return self._active_group()["regions"] - - def _group_selected_count(self, group: dict) -> int: - if self._is_states_view() and self._has_full_us_selected() and group["key"] != "whole_us": - return len(group["regions"]) - return sum(1 for region in group["regions"] if self._get_map_state(region["token"])) - - def _group_primary_text(self, group: dict) -> str: - if group["key"] == "countries": - count = sum(1 for region in self._all_country_regions() if self._get_map_state(region["token"])) - return tr("None selected") if count == 0 else tr("{} selected").format(count) - if group["key"] == "whole_us": - return tr("One-tap full set") if not self._get_map_state(US_COUNTRY_TOKEN) else tr("Selected") - count = self._group_selected_count(group) - if self._is_states_view(): - return tr("{}/{} states").format(count, len(group["regions"])) - return tr("None selected") if count == 0 else tr("{} selected").format(count) - - def _active_group_count_text(self) -> str: + def _active_view_count_text(self) -> str: if not self._is_states_view(): total = len(self._all_country_regions()) selected_count = sum(1 for region in self._all_country_regions() if self._get_map_state(region["token"])) - if selected_count <= 0: - return tr("{} available").format(total) - return tr("{} selected • {} available").format(selected_count, max(0, total - selected_count)) - if self._is_full_us_mode(): - selected_count = 1 if self._get_map_state(US_COUNTRY_TOKEN) else 0 - return tr("Whole U.S. selected") if selected_count else tr("1 available") - group = self._active_group() - total = len(group["regions"]) - selected_count = self._group_selected_count(group) + else: + total = len(self._all_us_states_regions()) + selected_count = self._selected_count() + if self._has_full_us_selected(): + return tr("Whole U.S. Selected") + if selected_count <= 0: - return tr("{} available").format(total) - return tr("{} selected • {} available").format(selected_count, max(0, total - selected_count)) + return tr("{} available - Tap to select").format(total) + return tr("{} selected | {} available").format(selected_count, max(0, total - selected_count)) def _toggle_region(self, token: str): self._set_map_state(token, not self._get_map_state(token)) + def _is_region_downloaded(self, token: str) -> bool: + """Returns True ONLY if map data is actually downloaded on disk AND token is part of installed set.""" + if not self._has_downloaded_data: + return False + return self._get_map_state(token) + def _region_primary_text(self, token: str) -> str: + if token == US_COUNTRY_TOKEN: + return tr("Master Package - Full country map data") + if token in REGIONAL_PACKAGES: + return tr(REGIONAL_PACKAGES[token]["subtitle"]) if self._get_map_state(token): - return tr("Tap to remove") - if token == US_COUNTRY_TOKEN and self._is_full_us_mode(): - return tr("Full country package") - return tr("Tap to add") + return tr("Selected for download") + return tr("Tap to select state") def _region_action_text(self, token: str) -> str: if self._get_map_state(token): return tr("Selected") - return tr("Add U.S.") if token == US_COUNTRY_TOKEN and self._is_full_us_mode() else tr("Add") + return tr("Add") def _browse_empty_state(self) -> tuple[str, str]: - if self._is_states_view() and self._has_full_us_selected(): - return tr("Whole U.S. already selected"), tr("Switch back to the full package above, or remove it if you want to pick individual states.") - return tr("No regions available"), tr("Switch groups or sources to keep browsing maps.") + return tr("No regions available"), tr("Switch sources to keep browsing maps.") - def _browse_regions_for_active_group(self) -> list[dict]: - if self._is_states_view() and self._has_full_us_selected() and not self._is_full_us_mode(): - return [] - return self._full_us_regions() if self._is_full_us_mode() else self._active_group_regions() + def _browse_regions_for_active_view(self) -> tuple[list[dict], list[dict]]: + """Partition active view regions into (downloaded_on_device, available_for_download).""" + if not self._is_states_view(): + all_regions = self._all_country_regions() + else: + all_regions = [*self._bulk_package_regions(), *self._all_us_states_regions()] + + # If NO map data exists on disk, ZERO regions are downloaded on device! + if not self._has_downloaded_data: + return [], all_regions + + downloaded = [] + downloadable = [] + for region in all_regions: + token = region["token"] + if self._is_region_downloaded(token): + downloaded.append(region) + else: + downloadable.append(region) + + return downloaded, downloadable def _get_map_state(self, token: str) -> bool: - return token in self._selected_tokens() + tokens = self._selected_tokens() + if not tokens: + return False + if US_COUNTRY_TOKEN in tokens: + return True + if token == US_COUNTRY_TOKEN: + return ALL_US_STATE_TOKENS.issubset(tokens) + if token in REGIONAL_PACKAGES: + return REGIONAL_PACKAGES[token]["tokens"].issubset(tokens) + return token in tokens def _set_map_state(self, token: str, state: bool): selected = set(self._cached_selected_tokens) + + # Expand Whole U.S. to individual states first if modifying a subset + if US_COUNTRY_TOKEN in selected: + selected.remove(US_COUNTRY_TOKEN) + selected.update(ALL_US_STATE_TOKENS) + if state: if token == US_COUNTRY_TOKEN: - selected = {item for item in selected if not item.startswith(STATE_PREFIX)} - self._active_state_group_key = "whole_us" - if self._is_states_view(): - self._full_us_mode = True - elif token.startswith(STATE_PREFIX): - selected.discard(US_COUNTRY_TOKEN) - self._full_us_mode = False - selected.add(token) + selected = {US_COUNTRY_TOKEN} + elif token in REGIONAL_PACKAGES: + selected.update(REGIONAL_PACKAGES[token]["tokens"]) + else: + selected.add(token) else: - selected.discard(token) if token == US_COUNTRY_TOKEN: - self._full_us_mode = False - if self._active_state_group_key == "whole_us": - self._active_state_group_key = STATES_SECTION["groups"][0]["key"] + selected.difference_update(ALL_US_STATE_TOKENS) + selected.discard(US_COUNTRY_TOKEN) + elif token in REGIONAL_PACKAGES: + selected.difference_update(REGIONAL_PACKAGES[token]["tokens"]) + else: + selected.discard(token) + + # Auto-consolidate to c:US if all 56 states are selected + if ALL_US_STATE_TOKENS.issubset(selected): + selected = {US_COUNTRY_TOKEN} + self._params.put("MapsSelected", sanitize_selected_locations_csv(sorted(selected))) self._cached_selected_tokens = selected - def _network_type(self): - return ui_state.sm["deviceState"].networkType if ui_state.sm.valid.get("deviceState", False) else NetworkType.none - - def _network_label(self) -> str: - return tr(NETWORK_TYPE_LABELS.get(self._network_type(), tr_noop("Offline"))) - def _is_online(self) -> bool: - return self._network_type() != NetworkType.none + network_type = ui_state.sm["deviceState"].networkType if ui_state.sm.valid.get("deviceState", False) else NetworkType.none + return network_type != NetworkType.none def _is_parked(self) -> bool: if ui_state.is_offroad(): @@ -959,8 +807,6 @@ class StarPilotMapsLayout(StarPilotPanel): reasons = [] if self._download_in_flight(): reasons.append(tr("Download in progress")) - if self._selected_count() == 0: - reasons.append(tr("Choose at least one region")) if not self._is_online(): reasons.append(tr("Connect to the internet")) if not self._is_parked(): @@ -979,11 +825,15 @@ class StarPilotMapsLayout(StarPilotPanel): return tr("Cancelling...") if self._download_in_flight(): return tr("Cancel Download") + if self._selected_count() == 0: + return tr("Select Regions Below") return tr("Download Offline Maps") def _on_primary_action(self): if self._download_in_flight(): self._on_cancel() + elif self._selected_count() == 0: + gui_app.push_widget(alert_dialog(tr("Please select 'Whole U.S.' or pick individual states from the list below."))) else: self._on_download() @@ -1013,7 +863,7 @@ class StarPilotMapsLayout(StarPilotPanel): if selected_raw != current_selected: self._params.put("MapsSelected", selected_raw) if not selected_raw: - gui_app.push_widget(alert_dialog(tr("Select at least one region before downloading maps."))) + gui_app.push_widget(alert_dialog(tr("Please select 'Whole U.S.' or pick individual states from the list below."))) return def on_confirm(res): @@ -1042,9 +892,6 @@ class StarPilotMapsLayout(StarPilotPanel): gui_app.push_widget(ConfirmDialog(tr("Cancel the current map download?"), tr("Cancel Download"), callback=on_confirm)) - def _has_downloaded_maps(self) -> bool: - return self._has_downloaded_data - def _on_remove(self): if not self._remove_enabled(): if not self._is_parked(): @@ -1061,6 +908,7 @@ class StarPilotMapsLayout(StarPilotPanel): def remove_worker(): if OFFLINE_MAPS_PATH.exists(): shutil.rmtree(OFFLINE_MAPS_PATH, ignore_errors=True) + OFFLINE_MAPS_PATH.mkdir(parents=True, exist_ok=True) self._storage_refresh_generation += 1 self._pending_storage_state = (self._storage_refresh_generation, "0 MB", False) self._storage_refresh_pending = False @@ -1082,8 +930,10 @@ class StarPilotMapsLayout(StarPilotPanel): return tr("Downloading Maps") if self._download_requested(): return tr("Starting Download") - if self._has_downloaded_maps(): + if self._has_downloaded_data: return tr("Offline Maps") + if self._selected_count() == 0: + return tr("Select Map Data") return tr("Download Readiness") def _progress_body(self) -> str: @@ -1107,49 +957,11 @@ class StarPilotMapsLayout(StarPilotPanel): if self._download_requested(): return tr("Preparing the selected regions for download.") + if self._selected_count() == 0: + return tr("No regions selected yet. Tap 'Whole U.S.' or pick specific states below to get started.") + gate_reason = self._download_gate_reason() if gate_reason: - if self._selected_count() == 0: - return tr("Pick regions below, then start the first offline download.") return gate_reason + return tr("Ready to download {}.").format(self._selection_preview_text()) - - def _measure_content_height(self, width: float) -> float: - RELOCATED_HEADER_HEIGHT = 180.0 - return self._browser_card._measure_height(width) + RELOCATED_HEADER_HEIGHT - - def _draw_scroll_content(self, rect: rl.Rectangle, width: float): - self._browser_card.set_parent_rect(rect) - self._status_card.set_parent_rect(rect) - - y = rect.y + self._scroll_offset - - # 1. Draw Status Card - status_rect = rl.Rectangle(rect.x, y, width, 168.0) - self._status_card.render(status_rect) - - RELOCATED_HEADER_HEIGHT = 180.0 - y += RELOCATED_HEADER_HEIGHT - - browser_height = self._browser_card._measure_height(width) - self._browser_card.render(rl.Rectangle(rect.x, y, width, browser_height)) - - def _render(self, rect: rl.Rectangle): - self._update_state() - self.set_rect(rect) - frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE, MAPS_METRICS) - - self._status_card.set_parent_rect(scroll_rect) - - scroll_content_rect = rl.Rectangle(scroll_rect.x, scroll_rect.y, scroll_rect.width, scroll_rect.height) - self._content_height = self._measure_content_height(content_width) - self._scroll_panel.set_enabled(self.is_visible) - self._scroll_offset = self._scroll_panel.update(scroll_content_rect, max(self._content_height, scroll_content_rect.height)) - - aether_begin_scissor_mode(int(scroll_content_rect.x), int(scroll_content_rect.y), int(scroll_content_rect.width), int(scroll_content_rect.height)) - self._draw_scroll_content(scroll_content_rect, content_width) - aether_end_scissor_mode() - - if self._content_height > scroll_content_rect.height: - self._scrollbar.render(scroll_content_rect, self._content_height, self._scroll_offset) - draw_list_scroll_fades(scroll_content_rect, self._content_height, self._scroll_offset, AetherListColors.PANEL_BG) diff --git a/selfdrive/ui/layouts/settings/starpilot/system_settings.py b/selfdrive/ui/layouts/settings/starpilot/system_settings.py index eadb85fd3..b87f78ec1 100644 --- a/selfdrive/ui/layouts/settings/starpilot/system_settings.py +++ b/selfdrive/ui/layouts/settings/starpilot/system_settings.py @@ -894,7 +894,7 @@ class StarPilotSystemLayout(_SettingsPage): def backup_status_text(self) -> str: system_count = len(self._get_backups("backups")) toggle_count = len(self._get_backups("toggle_backups")) - return tr("{} full • {} toggle").format(system_count, toggle_count) + return tr("{} full | {} toggle").format(system_count, toggle_count) def open_backup_manager(self, backup_kind: str): if backup_kind == "system":