From aac395ccd238aeeb406f47a5e4fc11711579d4f3 Mon Sep 17 00:00:00 2001 From: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:57:27 -0400 Subject: [PATCH] BigUI WIP: Mapsssssss --- .../layouts/settings/starpilot/aethergrid.py | 260 ++++++- .../ui/layouts/settings/starpilot/maps.py | 733 +++++++++--------- 2 files changed, 609 insertions(+), 384 deletions(-) diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index ecb98a797..c292841aa 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -157,14 +157,14 @@ def _draw_text_fit_common( rl.draw_text_ex(font, text, rl.Vector2(round(draw_x), round(pos.y + nudge_y)), actual_font_size, spacing, color) -def _draw_rounded_fill(rect: rl.Rectangle, color: rl.Color, radius_px: float = TILE_RADIUS_PX): +def _draw_rounded_fill(rect: rl.Rectangle, color: rl.Color, radius_px: float = TILE_RADIUS_PX, segments: int | None = None): snapped = _snap_rect(rect) - rl.draw_rectangle_rounded(snapped, _roundness_for(snapped, radius_px), _segments_for(snapped, radius_px), color) + rl.draw_rectangle_rounded(snapped, _roundness_for(snapped, radius_px), segments or _segments_for(snapped, radius_px), color) -def _draw_rounded_stroke(rect: rl.Rectangle, color: rl.Color, thickness: int = 1, radius_px: float = TILE_RADIUS_PX): +def _draw_rounded_stroke(rect: rl.Rectangle, color: rl.Color, thickness: int = 1, radius_px: float = TILE_RADIUS_PX, segments: int | None = None): snapped = _snap_rect(rect) - rl.draw_rectangle_rounded_lines_ex(snapped, _roundness_for(snapped, radius_px), _segments_for(snapped, radius_px), thickness, color) + rl.draw_rectangle_rounded_lines_ex(snapped, _roundness_for(snapped, radius_px), segments or _segments_for(snapped, radius_px), thickness, color) class AetherListColors: @@ -234,6 +234,56 @@ class AetherListFrame: AETHER_LIST_METRICS = AetherListMetrics() +@dataclass(frozen=True, slots=True) +class PanelStyle: + shell_bg: rl.Color + shell_border: rl.Color + shell_glow: rl.Color + surface_fill: rl.Color + surface_border: rl.Color + current_fill: rl.Color + current_border: rl.Color + title_color: rl.Color + subtitle_color: rl.Color + muted_color: rl.Color + divider_color: rl.Color + underline_color: rl.Color + accent: rl.Color + + +DEFAULT_PANEL_STYLE = PanelStyle( + shell_bg=AetherListColors.PANEL_BG, + shell_border=AetherListColors.PANEL_BORDER, + shell_glow=AetherListColors.PANEL_GLOW, + surface_fill=rl.Color(255, 255, 255, 4), + surface_border=rl.Color(255, 255, 255, 14), + current_fill=rl.Color(255, 255, 255, 12), + current_border=rl.Color(255, 255, 255, 20), + title_color=AetherListColors.HEADER, + subtitle_color=AetherListColors.SUBTEXT, + muted_color=AetherListColors.MUTED, + divider_color=rl.Color(255, 255, 255, 14), + underline_color=rl.Color(116, 136, 168, 150), + accent=AetherListColors.PRIMARY, +) + + +def _inflate_rect(rect: rl.Rectangle, pad_x: float = 10, pad_y: float = 6) -> rl.Rectangle: + return rl.Rectangle(rect.x - pad_x, rect.y - pad_y, rect.width + pad_x * 2, rect.height + pad_y * 2) + + +def _hit_rect(rect: rl.Rectangle, parent_rect: rl.Rectangle | None = None, pad_x: float = 10, pad_y: float = 6) -> rl.Rectangle: + hit = _inflate_rect(rect, pad_x, pad_y) + if parent_rect is not None: + return rl.get_collision_rec(hit, parent_rect) + return hit + + +def _point_hits(mouse_pos: MousePos, rect: rl.Rectangle, parent_rect: rl.Rectangle | None = None, pad_x: float = 10, pad_y: float = 6) -> bool: + hit = _hit_rect(rect, parent_rect, pad_x, pad_y) + return hit.width > 0 and hit.height > 0 and rl.check_collision_point_rec(mouse_pos, hit) + + def build_list_panel_frame(rect: rl.Rectangle, metrics: AetherListMetrics = AETHER_LIST_METRICS) -> AetherListFrame: shell_w = min(rect.width - metrics.outer_margin_x * 2, metrics.max_content_width) shell_x = rect.x + (rect.width - shell_w) / 2 @@ -258,7 +308,11 @@ def build_list_panel_frame(rect: rl.Rectangle, metrics: AetherListMetrics = AETH return AetherListFrame(shell_rect, header_rect, scroll_rect) -def draw_list_panel_shell(frame: AetherListFrame, *, bg: rl.Color = AetherListColors.PANEL_BG, border: rl.Color = AetherListColors.PANEL_BORDER, glow: rl.Color = AetherListColors.PANEL_GLOW): +def draw_list_panel_shell(frame: AetherListFrame, style: PanelStyle | None = None, *, bg: rl.Color = AetherListColors.PANEL_BG, border: rl.Color = AetherListColors.PANEL_BORDER, glow: rl.Color = AetherListColors.PANEL_GLOW): + if style is not None: + bg = style.shell_bg + border = style.shell_border + glow = style.shell_glow shell = _snap_rect(frame.shell) _draw_rounded_fill(shell, bg, radius_px=22) _draw_rounded_stroke(shell, border, radius_px=22) @@ -268,8 +322,9 @@ def draw_list_panel_shell(frame: AetherListFrame, *, bg: rl.Color = AetherListCo def draw_soft_card(rect: rl.Rectangle, fill: rl.Color, border: rl.Color, radius: float = 0.08, segments: int = 18): - _draw_rounded_fill(rect, fill) - _draw_rounded_stroke(rect, border) + radius_px = radius * min(rect.width, rect.height) + _draw_rounded_fill(rect, fill, radius_px=radius_px, segments=segments) + _draw_rounded_stroke(rect, border, radius_px=radius_px, segments=segments) def draw_list_row_shell( @@ -400,7 +455,131 @@ def draw_action_pill( rl.draw_rectangle_rounded(rect, roundness, segments, fill) rl.draw_rectangle_rounded_lines_ex(rect, roundness, segments, 1, border) rl.draw_rectangle_rec(rl.Rectangle(rect.x, rect.y, rect.width, 1), _with_alpha(text_color, 18)) - gui_label(rect, text, font_size, text_color, FontWeight.SEMI_BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + _draw_text_fit_common( + gui_app.font(FontWeight.SEMI_BOLD), + text, + rl.Vector2(rect.x + 12, rect.y + (rect.height - font_size) / 2), + max(1.0, rect.width - 24), + font_size, + align_center=True, + color=text_color, + ) + + +def draw_tab_card( + rect: rl.Rectangle, + title: str, + subtitle: str = "", + *, + current: bool = False, + hovered: bool = False, + pressed: bool = False, + title_size: int = 19, + subtitle_size: int = 14, + show_underline: bool = False, + underline_inset: int = 18, + title_color: rl.Color | None = None, + subtitle_color: rl.Color | None = None, + style: PanelStyle = DEFAULT_PANEL_STYLE, +): + fill = style.current_fill if current else style.surface_fill + border = style.current_border if current else style.surface_border + if not current and hovered: + fill = rl.Color(fill.r, fill.g, fill.b, min(fill.a + 3, 255)) + if pressed: + fill = rl.Color(fill.r, fill.g, fill.b, min(fill.a + 6, 22)) + + draw_soft_card(rect, fill, border) + + if subtitle: + resolved_title_color = title_color or (style.title_color if current else style.subtitle_color) + resolved_subtitle_color = subtitle_color or (style.title_color if current else style.muted_color) + _draw_text_fit_common( + gui_app.font(FontWeight.MEDIUM), + title, + rl.Vector2(rect.x + 12, rect.y + 7), + max(1.0, rect.width - 24), + title_size, + align_center=True, + color=resolved_title_color, + ) + _draw_text_fit_common( + gui_app.font(FontWeight.NORMAL), + subtitle, + rl.Vector2(rect.x + 12, rect.y + 26), + max(1.0, rect.width - 24), + subtitle_size, + align_center=True, + color=resolved_subtitle_color, + ) + else: + resolved_title_color = title_color or (style.title_color if current else style.subtitle_color) + _draw_text_fit_common( + gui_app.font(FontWeight.MEDIUM), + title, + rl.Vector2(rect.x + 12, rect.y + (rect.height - title_size) / 2), + max(1.0, rect.width - 24), + title_size, + align_center=True, + color=resolved_title_color, + ) + + if show_underline and current: + rl.draw_rectangle_rec( + rl.Rectangle(rect.x + underline_inset, rect.y + rect.height - 4, rect.width - underline_inset * 2, 2), + style.underline_color, + ) + + +def draw_metric_strip( + rect: rl.Rectangle, + metrics: list[tuple[str, str]], + *, + gap: int = 18, + min_col_width: float = 72.0, + label_size: int = 14, + value_size: int = 18, + style: PanelStyle = DEFAULT_PANEL_STYLE, + label_top_offset: int = 0, + value_top_offset: int = 14, + divider_top_offset: int = 2, + divider_bottom_offset: int = 16, +): + if not metrics: + return + + available_w = max(1.0, rect.width) + col_w = max(min_col_width, (available_w - gap * max(0, len(metrics) - 1)) / max(1, len(metrics))) + label_font = gui_app.font(FontWeight.MEDIUM) + value_font = gui_app.font(FontWeight.SEMI_BOLD) + + for index, (label, value) in enumerate(metrics): + metric_x = rect.x + index * (col_w + gap) + _draw_text_fit_common( + label_font, + label, + rl.Vector2(metric_x, rect.y + label_top_offset), + col_w, + label_size, + color=style.muted_color, + ) + _draw_text_fit_common( + value_font, + value, + rl.Vector2(metric_x, rect.y + value_top_offset), + col_w, + value_size, + color=style.title_color, + ) + if index < len(metrics) - 1: + divider_x = metric_x + col_w + gap / 2 + rl.draw_line( + int(divider_x), + int(rect.y + divider_top_offset), + int(divider_x), + int(rect.y + value_top_offset + divider_bottom_offset), + style.divider_color, + ) def draw_selection_list_row( @@ -416,8 +595,12 @@ def draw_selection_list_row( alpha: int = 255, action_width: int = AETHER_LIST_METRICS.action_width, action_chip: bool = False, + action_pill: bool = False, title_size: int = 30, subtitle_size: int = 20, + action_text_size: int = 18, + action_pill_height: int = 44, + action_pill_width: float | None = None, title_color: rl.Color = AetherListColors.HEADER, subtitle_color: rl.Color = AetherListColors.SUBTEXT, action_fill: rl.Color = AetherListColors.CURRENT_BG, @@ -426,9 +609,12 @@ def draw_selection_list_row( ): draw_rect = _snap_rect(rect) draw_list_row_shell(draw_rect, current=current, hovered=hovered, pressed=pressed, is_last=is_last, alpha=alpha) - action_rect = draw_action_rail(draw_rect, action_width, current=current, alpha=alpha) + action_rect = rl.Rectangle(draw_rect.x + draw_rect.width - action_width, draw_rect.y, action_width, draw_rect.height) + if not action_pill: + action_rect = draw_action_rail(draw_rect, action_width, current=current, alpha=alpha) - info_rect = rl.Rectangle(draw_rect.x + 24, draw_rect.y + 16, max(0.0, draw_rect.width - action_width - 42), draw_rect.height - 32) + info_gap = 36 if action_pill else 42 + info_rect = rl.Rectangle(draw_rect.x + 24, draw_rect.y + 16, max(0.0, draw_rect.width - action_width - info_gap), draw_rect.height - 32) title_font = gui_app.font(FontWeight.MEDIUM) subtitle_font = gui_app.font(FontWeight.NORMAL) @@ -460,6 +646,20 @@ def draw_selection_list_row( ) if action_text: + if action_pill: + available_w = max(96.0, action_rect.width - 28) + chip_w = min(available_w, action_pill_width) if action_pill_width is not None else min(available_w, max(96.0, 42 + len(action_text) * 9)) + chip_h = min(float(action_pill_height), max(36.0, action_rect.height - 28)) + chip_rect = rl.Rectangle(action_rect.x + action_rect.width - chip_w - 18, action_rect.y + (action_rect.height - chip_h) / 2, chip_w, chip_h) + draw_action_pill( + chip_rect, + action_text, + _with_alpha(action_fill, alpha), + _with_alpha(action_border, alpha), + _with_alpha(action_text_color, alpha), + font_size=action_text_size, + ) + return chip_rect if action_chip: available_w = max(74.0, action_rect.width - 24) chip_w = min(available_w, max(74.0, 44 + len(action_text) * 9)) @@ -477,7 +677,7 @@ def draw_selection_list_row( action_text, rl.Vector2(action_rect.x + 16, action_rect.y + (action_rect.height - 18) / 2), max(1.0, action_rect.width - 32), - 18, + action_text_size, align_center=True, color=_with_alpha(action_text_color, alpha), ) @@ -547,11 +747,13 @@ class AetherButton(Widget): enabled: bool | Callable[[], bool] = True, emphasized: bool = False, font_size: int = 24, + accent_color: rl.Color | None = None, ): super().__init__() self._text = text self._emphasized = emphasized self._font_size = font_size + self._accent_color = accent_color self.set_click_callback(click_callback) self.set_enabled(enabled) @@ -571,8 +773,9 @@ class AetherButton(Widget): pressed = enabled and self.is_pressed if self._emphasized: - bg = AetherListColors.PRIMARY if enabled else rl.Color(AetherListColors.PRIMARY.r, AetherListColors.PRIMARY.g, AetherListColors.PRIMARY.b, 80) - border = _with_alpha(AetherListColors.PRIMARY, 190 if enabled else 70) + accent = self._accent_color or AetherListColors.PRIMARY + bg = accent if enabled else rl.Color(accent.r, accent.g, accent.b, 80) + border = _with_alpha(accent, 190 if enabled else 70) else: bg = rl.Color(255, 255, 255, 10 if enabled else 5) border = rl.Color(255, 255, 255, 22 if enabled else 10) @@ -585,13 +788,14 @@ class AetherButton(Widget): rl.draw_rectangle_rounded(rect, 0.18, 12, bg) rl.draw_rectangle_rounded_lines_ex(rect, 0.18, 12, 1, border) rl.draw_rectangle_rec(rl.Rectangle(rect.x, rect.y, rect.width, 1), _with_alpha(AetherListColors.HEADER, 18 if enabled else 8)) - gui_label( - rect, + _draw_text_fit_common( + gui_app.font(FontWeight.MEDIUM), self.text, + rl.Vector2(rect.x + 18, rect.y + (rect.height - self._font_size) / 2), + max(1.0, rect.width - 36), self._font_size, - AetherListColors.HEADER if enabled else AetherListColors.MUTED, - FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, + align_center=True, + color=AetherListColors.HEADER if enabled else AetherListColors.MUTED, ) @@ -612,7 +816,15 @@ class AetherChip: roundness = 1.0 if self._pill else 0.4 rl.draw_rectangle_rounded(rect, roundness, 18, self._fill) rl.draw_rectangle_rounded_lines_ex(rect, roundness, 18, 1, _with_alpha(self._border, 110)) - gui_label(rect, self.text, 20 if self._pill else self._font_size, self._text_color, FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + _draw_text_fit_common( + gui_app.font(FontWeight.MEDIUM), + self.text, + rl.Vector2(rect.x + 12, rect.y + (rect.height - self._font_size) / 2), + max(1.0, rect.width - 24), + self._font_size, + align_center=True, + color=self._text_color, + ) class AetherScrollbar: @@ -1118,7 +1330,7 @@ class ValueTile(AetherTile): self.title = title self.desc = desc self.get_value = get_value - self._enabled = is_enabled or (lambda: True) + self.set_enabled(is_enabled or (lambda: True)) self._icon = starpilot_texture(icon_path, 80, 80) if icon_path else None self._font = gui_app.font(FontWeight.BOLD) self._font_desc = gui_app.font(FontWeight.NORMAL) @@ -1808,6 +2020,14 @@ class TileGrid(Widget): def add_tile(self, tile: Widget): self.tiles.append(tile) + if self._touch_valid_callback is not None and hasattr(tile, "set_touch_valid_callback"): + tile.set_touch_valid_callback(self._touch_valid_callback) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(touch_callback) + for tile in self.tiles: + if hasattr(tile, "set_touch_valid_callback"): + tile.set_touch_valid_callback(touch_callback) def clear(self): self.tiles.clear() diff --git a/selfdrive/ui/layouts/settings/starpilot/maps.py b/selfdrive/ui/layouts/settings/starpilot/maps.py index f6f514f55..9f8797d67 100644 --- a/selfdrive/ui/layouts/settings/starpilot/maps.py +++ b/selfdrive/ui/layouts/settings/starpilot/maps.py @@ -2,7 +2,7 @@ from __future__ import annotations import shutil import threading -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from cereal import log, messaging @@ -11,7 +11,7 @@ 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.multilang import tr, tr_noop +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 @@ -23,22 +23,24 @@ from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import ( AetherButton, AetherChip, AetherListColors, - AetherSegmentedControl, AetherScrollbar, + DEFAULT_PANEL_STYLE, build_list_panel_frame, draw_action_pill, draw_busy_ring, draw_list_panel_shell, + draw_metric_strip, + draw_tab_card, draw_selection_list_row, draw_list_scroll_fades, draw_soft_card, + _point_hits, ) from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanel from openpilot.starpilot.common.maps_catalog import ( MAPS_CATALOG, MAP_TOKEN_LABELS, MAP_SCHEDULE_LABELS, - get_selected_map_entries, normalize_schedule_value, sanitize_selected_locations_csv, schedule_label, @@ -64,17 +66,22 @@ HEADER_TOP_OFFSET = 10 HEADER_TITLE_HEIGHT = 40 HEADER_SUBTITLE_HEIGHT = 28 HEADER_BOTTOM_GAP = 12 -SECTION_CARD_GAP = 18 -BROWSER_TOOLBAR_HEIGHT = 78 -BROWSER_SECTION_HEADER_HEIGHT = 44 -BROWSER_INSET = 16 +BROWSER_TOOLBAR_HEIGHT = 64 +BROWSER_SECTION_HEADER_HEIGHT = 34 +BROWSER_INSET = 18 BROWSER_TAB_GAP = 12 BROWSER_CONTEXT_TAB_GAP = 10 -BROWSER_CONTEXT_TAB_HEIGHT = 54 -BROWSER_CONTEXT_MIN_TAB_WIDTH = 132 -BROWSER_CONTEXT_MAX_TAB_WIDTH = 228 -BROWSER_REGION_ROW_HEIGHT = 94 -BROWSER_EMPTY_STATE_HEIGHT = 140 +BROWSER_CONTEXT_TAB_HEIGHT = 52 +BROWSER_REGION_ROW_HEIGHT = 88 +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 +MAPS_TILE_GREEN = rl.Color(16, 185, 129, 255) +MAPS_PANEL_STYLE = replace(DEFAULT_PANEL_STYLE, accent=MAPS_TILE_GREEN) COUNTRIES_SECTION = next(section for section in MAPS_CATALOG if section["key"] == "countries") STATES_SECTION = next(section for section in MAPS_CATALOG if section["key"] == "states") @@ -113,6 +120,10 @@ def _format_eta_ms(elapsed_ms: int, downloaded_files: int, total_files: int) -> return _format_elapsed_ms(remaining_ms) +def _localized_schedule_label(value) -> str: + return tr(schedule_label(value)) + + 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} @@ -148,57 +159,99 @@ class MapStatusCard(Widget): def _handle_mouse_press(self, mouse_pos: MousePos): if not self._touch_valid(): return - if rl.check_collision_point_rec(mouse_pos, self._remove_rect) and self._controller._remove_enabled(): + 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 rl.check_collision_point_rec(mouse_pos, self._remove_rect) and self._controller._remove_enabled(): + 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 _render(self, rect: rl.Rectangle): - draw_soft_card(rect, rl.Color(255, 255, 255, 4), rl.Color(255, 255, 255, 16)) + draw_soft_card(rect, MAPS_PANEL_STYLE.surface_fill, MAPS_PANEL_STYLE.surface_border) - inset = 20 + inset = STATUS_CARD_INSET + content_x = rect.x + inset content_w = rect.width - inset * 2 - actions_w = max(248.0, min(304.0, rect.width * 0.22)) - left_w = max(360.0, min(520.0, content_w * 0.42)) - metrics_x = rect.x + inset + left_w + 24 - metrics_w = max(280.0, rect.x + rect.width - inset - actions_w - 24 - metrics_x) + actions_w = max(330.0, min(386.0, rect.width * 0.31)) actions_x = rect.x + rect.width - inset - actions_w - top_y = rect.y + 18 + 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 - gui_label(rl.Rectangle(rect.x + inset, top_y, left_w, 24), self._controller._progress_title(), 24, AetherListColors.HEADER, FontWeight.SEMI_BOLD) + 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(), + rl.Color(94, 168, 130, 22), + rl.Color(94, 168, 130, 44), + AetherListColors.HEADER, + font_size=15, + ) gui_text_box( - rl.Rectangle(rect.x + inset, top_y + 30, left_w, 38), + rl.Rectangle(content_x, title_y + 28, summary_w, 38), self._controller._progress_body(), 17, AetherListColors.SUBTEXT, font_weight=FontWeight.NORMAL, line_scale=0.94, ) - gui_label(rl.Rectangle(rect.x + inset, rect.y + rect.height - 26, left_w, 18), self._controller._status_footer_text(), 16, AetherListColors.MUTED, FontWeight.MEDIUM) - metric_gap = 20 - metric_w = max(108.0, (metrics_w - metric_gap) / 2) - metric_y = top_y + 2 - gui_label(rl.Rectangle(metrics_x, metric_y, metric_w, 18), tr("Storage"), 16, AetherListColors.MUTED, FontWeight.MEDIUM) - gui_label(rl.Rectangle(metrics_x, metric_y + 16, metric_w, 24), self._controller._storage_text, 22, AetherListColors.HEADER, FontWeight.SEMI_BOLD) - gui_label(rl.Rectangle(metrics_x + metric_w + metric_gap, metric_y, metric_w, 18), tr("Last Updated"), 16, AetherListColors.MUTED, FontWeight.MEDIUM) - gui_label(rl.Rectangle(metrics_x + metric_w + metric_gap, metric_y + 16, metric_w, 24), self._controller._last_updated_text(), 20, AetherListColors.HEADER, FontWeight.SEMI_BOLD) - gui_label(rl.Rectangle(metrics_x, metric_y + 38, metrics_w, 18), tr("Selected Regions"), 16, AetherListColors.MUTED, FontWeight.MEDIUM) - gui_label(rl.Rectangle(metrics_x, metric_y + 56, metrics_w, 22), self._controller._selection_summary_title(), 20, AetherListColors.HEADER, FontWeight.SEMI_BOLD) + 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, 15, AetherListColors.MUTED, FontWeight.MEDIUM) + draw_metric_strip( + rl.Rectangle(metrics_x, rect.y + 72, metrics_w, 30), + [ + (tr("Storage"), self._controller._storage_text), + (tr("Last Updated"), self._controller._last_updated_text()), + ], + gap=STATUS_METRIC_GAP, + style=MAPS_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 + 72, summary_w, 30), + [ + (tr("Storage"), self._controller._storage_text), + (tr("Last Updated"), self._controller._last_updated_text()), + ], + gap=STATUS_METRIC_GAP, + style=MAPS_PANEL_STYLE, + label_top_offset=0, + value_top_offset=14, + divider_top_offset=2, + divider_bottom_offset=16, + ) - button_h = 46 - button_gap = 8 - button_y = rect.y + 14 - self._primary_button.render(rl.Rectangle(actions_x, button_y, actions_w, button_h)) - self._secondary_button.render(rl.Rectangle(actions_x, button_y + button_h + button_gap, actions_w, button_h)) + primary_rect = rl.Rectangle(actions_x, rect.y + 10, actions_w, STATUS_BUTTON_HEIGHT) + bottom_y = primary_rect.y + primary_rect.height + STATUS_BUTTON_GAP + remove_w = max(132.0, min(154.0, actions_w * 0.42)) + self._remove_rect = rl.Rectangle(actions_x, bottom_y, remove_w, STATUS_REMOVE_HEIGHT) + schedule_rect = rl.Rectangle(actions_x + remove_w + 10, bottom_y, actions_w - remove_w - 10, STATUS_REMOVE_HEIGHT) + + self._primary_button.render(primary_rect) + self._secondary_button.render(schedule_rect) - action_w = 150 - action_h = 36 - self._remove_rect = rl.Rectangle(metrics_x + metrics_w - action_w, rect.y + rect.height - action_h - 14, action_w, action_h) enabled = self._controller._remove_enabled() draw_action_pill( self._remove_rect, @@ -206,12 +259,12 @@ class MapStatusCard(Widget): rl.Color(173, 78, 90, 26 if enabled else 12), rl.Color(173, 78, 90, 58 if enabled else 24), AetherListColors.HEADER if enabled else AetherListColors.MUTED, - font_size=17, + font_size=16, ) if self._controller._download_state.active: - center = rl.Vector2(rect.x + rect.width - 34, rect.y + 34) - draw_busy_ring(center, rl.get_time() * 160, AetherListColors.PRIMARY, inner_radius=10, outer_radius=14, sweep=210, thickness=20) + center = rl.Vector2(actions_x + actions_w - 22, primary_rect.y + primary_rect.height / 2) + draw_busy_ring(center, rl.get_time() * 160, AetherListColors.PRIMARY, inner_radius=9, outer_radius=13, sweep=210, thickness=20) class MapBrowserCard(Widget): @@ -219,24 +272,12 @@ class MapBrowserCard(Widget): super().__init__() self._controller = controller self._pressed_target: str | None = None - self._selected_action_rects: dict[str, rl.Rectangle] = {} + self._source_rects: dict[str, rl.Rectangle] = {} self._context_tab_rects: dict[str, rl.Rectangle] = {} self._region_row_rects: dict[str, rl.Rectangle] = {} - self._source_selector = self._child( - AetherSegmentedControl( - [tr_noop("United States"), tr_noop("Countries")], - lambda: 0 if self._controller._is_states_view() else 1, - lambda index: self._controller._set_view_index(self._controller.VIEW_STATES if index == 0 else self._controller.VIEW_COUNTRIES), - statuses=[ - lambda: self._controller._browser_source_primary(self._controller.VIEW_STATES), - lambda: self._controller._browser_source_primary(self._controller.VIEW_COUNTRIES), - ], - ) - ) def set_touch_valid_callback(self, touch_callback): super().set_touch_valid_callback(touch_callback) - self._source_selector.set_touch_valid_callback(touch_callback) def _handle_mouse_press(self, mouse_pos: MousePos): if not self._touch_valid(): @@ -258,20 +299,20 @@ class MapBrowserCard(Widget): 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 token, rect in self._selected_action_rects.items(): - if rl.check_collision_point_rec(mouse_pos, rect): - return f"selected-remove:{token}" + 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 rl.check_collision_point_rec(mouse_pos, rect): + 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 rl.check_collision_point_rec(mouse_pos, rect): + 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("selected-remove:"): - self._controller._set_map_state(target.split(":", 1)[1], False) + 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]) @@ -279,142 +320,98 @@ class MapBrowserCard(Widget): 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=22, + style=MAPS_PANEL_STYLE, + ) + def _row_height(self, count: int, row_height: float) -> float: return 0.0 if count <= 0 else count * row_height - def _context_tab_width(self, label: str) -> float: - font = gui_app.font(FontWeight.MEDIUM) - size = measure_text_cached(font, label, 22, spacing=1) - return max(BROWSER_CONTEXT_MIN_TAB_WIDTH, min(BROWSER_CONTEXT_MAX_TAB_WIDTH, size.x + 54)) - def _measure_context_tabs_height(self, width: float) -> float: - groups = self._controller._current_groups_with_full_us() + del width + groups = self._controller._visible_browser_tabs() if not groups: return 0.0 - - rows = 1 - row_w = 0.0 - available_w = max(1.0, width) - for group in groups: - tab_w = self._context_tab_width(group["title"]) - next_w = tab_w if row_w <= 0 else row_w + BROWSER_CONTEXT_TAB_GAP + tab_w - if next_w > available_w and row_w > 0: - rows += 1 - row_w = tab_w - else: - row_w = next_w - return rows * BROWSER_CONTEXT_TAB_HEIGHT + max(0, rows - 1) * BROWSER_CONTEXT_TAB_GAP + return BROWSER_CONTEXT_TAB_HEIGHT def _render_context_tabs(self, rect: rl.Rectangle): - groups = self._controller._current_groups_with_full_us() + groups = self._controller._visible_browser_tabs() self._context_tab_rects.clear() if not groups: return mouse_pos = gui_app.last_mouse_event.pos - x = rect.x - y = rect.y 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 group in groups: - tab_w = self._context_tab_width(group["title"]) - if x > rect.x and (x - rect.x + tab_w) > available_w: - x = rect.x - y += BROWSER_CONTEXT_TAB_HEIGHT + BROWSER_CONTEXT_TAB_GAP - - tab_rect = rl.Rectangle(x, y, tab_w, BROWSER_CONTEXT_TAB_HEIGHT) + 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_group_key() == group["key"] - hovered = rl.check_collision_point_rec(mouse_pos, 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']}" - fill = rl.Color(255, 255, 255, 12 if current else (8 if hovered else 4)) - border = rl.Color(255, 255, 255, 28 if current else 14) - text_color = AetherListColors.HEADER if current else AetherListColors.SUBTEXT - meta_color = AetherListColors.HEADER if current else AetherListColors.MUTED - if pressed: - fill = rl.Color(255, 255, 255, min(fill.a + 6, 24)) - - draw_soft_card(tab_rect, fill, border) - gui_label( - rl.Rectangle(tab_rect.x + 16, tab_rect.y + 9, tab_rect.width - 32, 22), - group["title"], - 22, - text_color, - FontWeight.MEDIUM, - ) - gui_label( - rl.Rectangle(tab_rect.x + 16, tab_rect.y + 30, tab_rect.width - 32, 16), + draw_tab_card( + tab_rect, + tr(group["title"]), self._controller._group_primary_text(group), - 15, - meta_color, - FontWeight.NORMAL, + current=current, + hovered=hovered, + pressed=pressed, + title_size=19, + subtitle_size=14, + show_underline=True, + style=MAPS_PANEL_STYLE, ) - if current: - rl.draw_rectangle_rec(rl.Rectangle(tab_rect.x + 16, tab_rect.y + tab_rect.height - 4, tab_rect.width - 32, 2), rl.Color(116, 136, 168, 170)) - - x += tab_w + BROWSER_CONTEXT_TAB_GAP def _render_empty_state(self, rect: rl.Rectangle, title: str, body: str): state_rect = rl.Rectangle(rect.x, rect.y, rect.width, rect.height) - draw_soft_card(state_rect, rl.Color(255, 255, 255, 4), rl.Color(255, 255, 255, 10)) + draw_soft_card(state_rect, MAPS_PANEL_STYLE.surface_fill, rl.Color(255, 255, 255, 10)) gui_label( - rl.Rectangle(state_rect.x, state_rect.y + 28, state_rect.width, 34), + rl.Rectangle(state_rect.x, state_rect.y + 24, state_rect.width, 32), title, - 28, + 26, AetherListColors.HEADER, FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, ) gui_label( - rl.Rectangle(state_rect.x + 44, state_rect.y + 68, state_rect.width - 88, 48), + rl.Rectangle(state_rect.x + 40, state_rect.y + 60, state_rect.width - 80, 44), body, - 20, + 19, AetherListColors.SUBTEXT, FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, ) - def _render_selected_rows(self, rect: rl.Rectangle, entries: list[dict[str, str]]): - self._selected_action_rects.clear() - if not entries: - self._render_empty_state( - rect, - tr("No regions selected"), - tr("Choose the areas you actually drive in, then start the download when you are parked."), - ) - return - - mouse_pos = gui_app.last_mouse_event.pos - for index, entry in enumerate(entries): - token = entry["token"] - row_rect = rl.Rectangle(rect.x, rect.y + index * BROWSER_REGION_ROW_HEIGHT, rect.width, BROWSER_REGION_ROW_HEIGHT) - row_hovered = rl.check_collision_point_rec(mouse_pos, row_rect) - target_key = f"selected-remove:{token}" - action_width = 132 - draw_selection_list_row( - row_rect, - title=entry["label"], - subtitle=self._controller._selected_entry_subtitle(entry), - action_text=tr("Remove"), - current=True, - hovered=row_hovered, - pressed=self._pressed_target == target_key, - is_last=index == len(entries) - 1, - action_width=action_width, - action_chip=False, - action_text_color=AetherListColors.MUTED, - ) - action_rect = rl.Rectangle(row_rect.x + row_rect.width - action_width, row_rect.y, action_width, row_rect.height) - self._selected_action_rects[token] = action_rect - def _render_region_rows(self, rect: rl.Rectangle, regions: list[dict]): if not regions: - self._render_empty_state( - rect, - tr("Everything in this view is selected"), - tr("Switch source or choose another tab below to add more regions."), - ) + title, body = self._controller._browse_empty_state() + self._render_empty_state(rect, title, body) return mouse_pos = gui_app.last_mouse_event.pos @@ -424,12 +421,12 @@ class MapBrowserCard(Widget): 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 = rl.check_collision_point_rec(mouse_pos, row_rect) + hovered = _point_hits(mouse_pos, row_rect, self._parent_rect, pad_x=6, pad_y=0) target_key = f"region:{token}" action_text = self._controller._region_action_text(token) draw_selection_list_row( row_rect, - title=region["label"], + title=tr(region["label"]), subtitle=self._controller._region_primary_text(token), action_text=action_text, current=selected, @@ -437,109 +434,71 @@ class MapBrowserCard(Widget): pressed=self._pressed_target == target_key, is_last=index == len(regions) - 1, action_width=142, - action_chip=selected, - action_fill=rl.Color(94, 168, 130, 34), - action_border=rl.Color(94, 168, 130, 62), + action_pill=True, + action_text_size=15, + action_pill_height=36, + action_pill_width=112 if selected else 84, + title_size=26, + subtitle_size=17, + action_fill=rl.Color(94, 168, 130, 18) if selected else rl.Color(255, 255, 255, 8), + action_border=rl.Color(94, 168, 130, 38) if selected else rl.Color(255, 255, 255, 24), + action_text_color=AetherListColors.SUBTEXT if selected else AetherListColors.HEADER, ) def _active_browse_regions(self) -> list[dict]: return self._controller._browse_regions_for_active_group() - def _measure_toolbar_height(self) -> float: - return BROWSER_TOOLBAR_HEIGHT - - def _render_section_header(self, rect: rl.Rectangle, title: str, *, count_text: str | None = None, action_text: str | None = None): - self._header_action_rect = rl.Rectangle(0, 0, 0, 0) - title_right = rect.x + rect.width - gap = 10 - control_y = rect.y + (rect.height - 38) / 2 - - if action_text: - action_w = max(112.0, min(154.0, 54 + len(action_text) * 8)) - self._header_action_rect = rl.Rectangle(rect.x + rect.width - action_w, control_y, action_w, 38) - draw_action_pill( - self._header_action_rect, - action_text, - rl.Color(255, 255, 255, 8), - rl.Color(255, 255, 255, 22), - AetherListColors.HEADER, - font_size=17, - ) - title_right = self._header_action_rect.x - gap - + def _render_section_header(self, rect: rl.Rectangle, title: str, *, count_text: str | None = None): + del title if count_text: - chip_w = max(122.0, min(184.0, 62 + len(count_text) * 9)) - chip_rect = rl.Rectangle(title_right - chip_w, control_y, chip_w, 38) - AetherChip(count_text, rl.Color(89, 116, 151, 18), rl.Color(116, 136, 168, 38), AetherListColors.HEADER, pill=True, font_size=16).render(chip_rect) - title_right = chip_rect.x - gap - - gui_label( - rl.Rectangle(rect.x + 4, rect.y + (rect.height - 28) / 2, max(0.0, title_right - rect.x - 8), 28), - title, - 24, - AetherListColors.HEADER, - FontWeight.SEMI_BOLD, - ) - - def _render_toolbar(self, rect: rl.Rectangle): - draw_soft_card(rect, rl.Color(255, 255, 255, 3), rl.Color(255, 255, 255, 10)) - self._header_action_rect = rl.Rectangle(0, 0, 0, 0) - source_rect = rl.Rectangle(rect.x, rect.y, rect.width, BROWSER_TOOLBAR_HEIGHT) - self._source_selector.set_parent_rect(self._parent_rect or rect) - self._source_selector.render(source_rect) + gui_label( + rl.Rectangle(rect.x, rect.y + (rect.height - 22) / 2, rect.width, 22), + count_text, + 20, + AetherListColors.SUBTEXT, + FontWeight.NORMAL, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + ) def _measure_height(self, width: float) -> float: - total = 0.0 - selected_count = len(self._controller._selected_entries_for_display()) - total += BROWSER_SECTION_HEADER_HEIGHT - total += self._row_height(selected_count, BROWSER_REGION_ROW_HEIGHT) if selected_count else BROWSER_EMPTY_STATE_HEIGHT - total += SECTION_CARD_GAP - total += self._measure_toolbar_height() + 10 - total += self._measure_context_tabs_height(width) - total += 10 + BROWSER_SECTION_HEADER_HEIGHT - browse_count = len(self._active_browse_regions()) - total += self._row_height(browse_count, BROWSER_REGION_ROW_HEIGHT) if browse_count else BROWSER_EMPTY_STATE_HEIGHT - return total + if self._controller._showing_source_picker(): + del width + return BROWSER_CONTEXT_TAB_HEIGHT + 20 + + total = 10.0 + total += self._measure_context_tabs_height(width) + 12 + total += BROWSER_SECTION_HEADER_HEIGHT + 2 + 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 - draw_soft_card(rect, rl.Color(255, 255, 255, 4), rl.Color(255, 255, 255, 14)) - self._selected_action_rects.clear() + draw_soft_card(rect, MAPS_PANEL_STYLE.surface_fill, MAPS_PANEL_STYLE.surface_border) + 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 + y = rect.y + 10 - selected_entries = self._controller._selected_entries_for_display() - self._render_section_header( - rl.Rectangle(content_x, y, content_w, BROWSER_SECTION_HEADER_HEIGHT), - tr("Selected Regions"), - count_text=self._controller._selected_section_count_text(), - ) - y += BROWSER_SECTION_HEADER_HEIGHT - - selected_h = self._row_height(len(selected_entries), BROWSER_REGION_ROW_HEIGHT) if selected_entries else BROWSER_EMPTY_STATE_HEIGHT - self._render_selected_rows(rl.Rectangle(content_x, y, content_w, selected_h), selected_entries) - y += selected_h + SECTION_CARD_GAP - - toolbar_h = self._measure_toolbar_height() - self._render_toolbar(rl.Rectangle(content_x, y, content_w, toolbar_h)) - y += toolbar_h + 10 + if self._controller._showing_source_picker(): + self._render_source_picker(rl.Rectangle(content_x, y, content_w, BROWSER_CONTEXT_TAB_HEIGHT)) + return 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 + 10 + y += context_tabs_h + 12 self._render_section_header( rl.Rectangle(content_x, y, content_w, BROWSER_SECTION_HEADER_HEIGHT), - tr("Browse More"), + "", count_text=self._controller._active_group_count_text(), ) - y += BROWSER_SECTION_HEADER_HEIGHT + y += BROWSER_SECTION_HEADER_HEIGHT + 2 regions = self._active_browse_regions() region_h = self._row_height(len(regions), BROWSER_REGION_ROW_HEIGHT) if regions else BROWSER_EMPTY_STATE_HEIGHT @@ -565,14 +524,17 @@ class StarPilotMapsLayout(StarPilotPanel): self._storage_updated_at = 0.0 self._storage_refresh_thread: threading.Thread | None = None self._storage_refresh_pending = False + self._storage_refresh_generation = 0 + self._pending_storage_state: tuple[int, str, bool] | None = None self._download_started_at: float | None = None self._cancel_requested_at: float | None = None self._cancel_visual_until = 0.0 self._download_state = MapsDownloadState() self._view_index = self.VIEW_STATES - self._active_country_group_key = COUNTRIES_SECTION["groups"][0]["key"] - self._active_state_group_key = STATES_SECTION["groups"][0]["key"] - self._full_us_mode = False + 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._download_button = self._child( @@ -582,14 +544,15 @@ class StarPilotMapsLayout(StarPilotPanel): enabled=self._primary_action_enabled, emphasized=True, font_size=24, + accent_color=MAPS_TILE_GREEN, ) ) self._schedule_button = self._child( AetherButton( - lambda: tr(f"Auto Update: {schedule_label(self._params.get('PreferredSchedule'))}"), + lambda: tr("Update: {}").format(_localized_schedule_label(self._params.get('PreferredSchedule'))), self._on_schedule, emphasized=False, - font_size=22, + font_size=20, ) ) @@ -624,6 +587,14 @@ class StarPilotMapsLayout(StarPilotPanel): def _update_state(self): super()._update_state() self._sync_download_state() + if self._pending_storage_state is not None: + generation, storage_text, has_downloaded_data = self._pending_storage_state + self._pending_storage_state = None + if generation == self._storage_refresh_generation: + self._storage_text = storage_text + self._has_downloaded_data = has_downloaded_data + self._storage_updated_at = rl.get_time() + self._storage_refresh_pending = False self._refresh_storage_cache() if self._download_state.active: @@ -665,7 +636,7 @@ class StarPilotMapsLayout(StarPilotPanel): progress_text = "" if active: - progress_text = tr(f"{downloaded_files} / {total_files} ({percent}%)") + progress_text = tr("{} / {} ({}%)").format(downloaded_files, total_files, percent) if primary_location: progress_text = f"{progress_text} {primary_location}" @@ -687,14 +658,18 @@ class StarPilotMapsLayout(StarPilotPanel): if not force and (now - self._storage_updated_at) < 4.0: return + generation = self._storage_refresh_generation + 1 + self._storage_refresh_generation = generation + def refresh_worker(): + result: tuple[str, bool] | None = None try: - storage_text, has_downloaded_data = self._calculate_storage_state() - self._storage_text = storage_text - self._has_downloaded_data = has_downloaded_data - self._storage_updated_at = rl.get_time() + result = self._calculate_storage_state() finally: - self._storage_refresh_pending = False + if result is None: + self._storage_refresh_pending = False + else: + self._pending_storage_state = (generation, result[0], result[1]) self._storage_refresh_pending = True self._storage_updated_at = now @@ -720,58 +695,51 @@ class StarPilotMapsLayout(StarPilotPanel): def _selected_tokens(self) -> set[str]: return _selected_token_set(self._params.get("MapsSelected", encoding="utf-8") or "") - def _selected_entries(self) -> list[dict[str, str]]: - entries = get_selected_map_entries(self._params.get("MapsSelected", encoding="utf-8") or "") - for entry in entries: - entry["kind"] = "country" if entry["token"].startswith(COUNTRY_PREFIX) else "state" - return entries - - def _selected_entries_for_display(self) -> list[dict[str, str]]: - entries = self._selected_entries() - - def sort_key(entry: dict[str, str]) -> tuple[int, str]: - token = entry["token"] - if token == US_COUNTRY_TOKEN: - return (0, entry["label"]) - if token.startswith(STATE_PREFIX): - return (1, entry["label"]) - return (2, entry["label"]) - - return sorted(entries, key=sort_key) - 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("Nothing selected") - if count == 1: - return tr("1 region") - return tr(f"{count} regions") + return tr("None selected") + return tr("{} selected").format(count) - def _selection_summary_title(self) -> str: + def _selected_summary_text(self) -> str: count = self._selected_count() if count == 0: return tr("No regions selected") + return trn("{} region selected", "{} regions selected", count).format(count) + + def _selected_primary_token(self) -> str | None: + selected = sorted(self._selected_tokens()) + if not selected: + return None + return selected[0] + + def _selected_primary_label(self) -> str: + token = self._selected_primary_token() + if not token: + return "" + 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, 15, 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") + + primary_label = self._selected_primary_label() if count == 1: - return tr("1 region selected") - return tr(f"{count} regions selected") - - def _selected_entry_subtitle(self, entry: dict[str, str]) -> str: - token = entry["token"] - if token == US_COUNTRY_TOKEN: - return tr("Whole U.S. package") - if token.startswith(STATE_PREFIX): - return tr("Included in next download") - return tr("Country map selected") - - def _set_view_index(self, index: int): - self._view_index = max(self.VIEW_COUNTRIES, min(self.VIEW_STATES, index)) - if self._is_states_view(): - self._full_us_mode = self._active_state_group_key == "whole_us" - else: - self._full_us_mode = False + 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() @@ -787,6 +755,24 @@ class StarPilotMapsLayout(StarPilotPanel): def _is_states_view(self) -> bool: return self._view_index == self.VIEW_STATES + def _showing_source_picker(self) -> bool: + return self._show_source_picker + + def _active_source_key(self) -> str: + if self._showing_source_picker(): + return self._browse_source_key + return "other" if not self._is_states_view() else "us" + + def _select_browse_source(self, source_key: str): + self._browse_source_key = source_key + if source_key == "us": + self._show_source_picker = False + self._set_active_group("whole_us") + return + if source_key == "other": + self._show_source_picker = False + self._set_active_group("countries") + def _is_full_us_mode(self) -> bool: return self._is_states_view() and self._full_us_mode @@ -799,48 +785,58 @@ class StarPilotMapsLayout(StarPilotPanel): def _current_groups(self) -> list[dict]: return self._current_section()["groups"] + def _all_country_regions(self) -> list[dict]: + return [region for group in COUNTRIES_SECTION["groups"] for region in group["regions"]] + def _full_us_regions(self) -> list[dict]: - return [{"token": US_COUNTRY_TOKEN, "label": MAP_TOKEN_LABELS[US_COUNTRY_TOKEN]}] + return [{"token": US_COUNTRY_TOKEN, "label": tr(MAP_TOKEN_LABELS[US_COUNTRY_TOKEN])}] def _whole_us_group(self) -> dict: return {"key": "whole_us", "title": tr("Whole U.S."), "regions": self._full_us_regions()} - def _current_groups_with_full_us(self) -> list[dict]: - groups = list(self._current_groups()) - if self._is_states_view(): - return [self._whole_us_group(), *groups] - return groups + def _visible_browser_tabs(self) -> list[dict]: + return [self._whole_us_group(), *STATES_SECTION["groups"], {"key": "countries", "title": tr("Countries"), "regions": []}] - def _active_group_key(self) -> str: + 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 if self._is_states_view() else self._active_country_group_key + return self._active_state_group_key def _set_active_group(self, group_key: str): - if self._is_states_view() and group_key == "whole_us": + 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 self._current_groups()} + group_keys = {group["key"] for group in STATES_SECTION["groups"]} if group_key not in group_keys: return - if self._is_states_view(): - self._full_us_mode = False - self._active_state_group_key = group_key - else: - self._active_country_group_key = group_key + 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_group_key() - for group in self._current_groups(): + group_key = self._active_state_group_key + for group in STATES_SECTION["groups"]: if group["key"] == group_key: return group - fallback = self._current_groups()[0] - self._set_active_group(fallback["key"]) + fallback = STATES_SECTION["groups"][0] + self._active_state_group_key = fallback["key"] return fallback def _active_group_regions(self) -> list[dict]: @@ -852,56 +848,57 @@ class StarPilotMapsLayout(StarPilotPanel): 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("Full country package") if self._get_map_state(US_COUNTRY_TOKEN) else tr("One-tap full set") + 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(f"{count}/{len(group['regions'])} states") - return tr(f"{count}/{len(group['regions'])} selected") + 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: + 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(): - return tr("Already selected") if self._get_map_state(US_COUNTRY_TOKEN) else tr("Whole U.S. package") + 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() - browse_count = len(self._browse_regions_for_active_group()) total = len(group["regions"]) - if self._is_states_view(): - return tr(f"{browse_count} of {total} available") - return tr(f"{browse_count} of {total} available") + selected_count = self._group_selected_count(group) + if selected_count <= 0: + return tr("{} available").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 _region_primary_text(self, token: str) -> str: if self._get_map_state(token): - return tr("Included") + return tr("Tap to remove") if token == US_COUNTRY_TOKEN and self._is_full_us_mode(): - return tr("Whole country") + return tr("Full country package") return tr("Tap to add") def _region_action_text(self, token: str) -> str: if self._get_map_state(token): - return tr("Included") + return tr("Selected") return tr("Add U.S.") if token == US_COUNTRY_TOKEN and self._is_full_us_mode() else tr("Add") - def _browser_source_primary(self, view_index: int) -> str: - active = self._view_index == view_index - if view_index == self.VIEW_COUNTRIES: - country_count = sum(1 for entry in self._selected_entries() if entry["token"].startswith(COUNTRY_PREFIX) and entry["token"] != US_COUNTRY_TOKEN) - return tr("Current view") if active else tr(f"{country_count} selected") - state_count = sum(1 for entry in self._selected_entries() if entry["token"].startswith(STATE_PREFIX)) - if self._has_full_us_selected(): - state_count += 1 - return tr("Current view") if active else tr(f"{state_count} selected") + 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.") def _browse_regions_for_active_group(self) -> list[dict]: - selected_tokens = self._selected_tokens() - if self._is_states_view() and self._has_full_us_selected(): - if self._is_full_us_mode(): - return [] + if self._is_states_view() and self._has_full_us_selected() and not self._is_full_us_mode(): return [] - regions = self._full_us_regions() if self._is_full_us_mode() else self._active_group_regions() - return [region for region in regions if region["token"] not in selected_tokens] + return self._full_us_regions() if self._is_full_us_mode() else self._active_group_regions() def _get_map_state(self, token: str) -> bool: return token in self._selected_tokens() @@ -911,12 +908,19 @@ class StarPilotMapsLayout(StarPilotPanel): 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) 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"] self._params.put("MapsSelected", sanitize_selected_locations_csv(sorted(selected))) def _network_type(self): @@ -954,11 +958,11 @@ class StarPilotMapsLayout(StarPilotPanel): if self._download_in_flight(): return tr("Download in progress") if self._selected_count() == 0: - return tr("Select regions first") + return tr("Choose at least one region") if not self._is_online(): return tr("Connect to the internet") if not self._is_parked(): - return tr("Park to download") + return tr("Park the vehicle to download") return "" def _primary_action_enabled(self) -> bool: @@ -982,12 +986,16 @@ class StarPilotMapsLayout(StarPilotPanel): self._on_download() def _on_schedule(self): - options = list(MAP_SCHEDULE_LABELS.values()) - current = schedule_label(self._params.get("PreferredSchedule")) + localized_options = [(value, tr(label)) for value, label in MAP_SCHEDULE_LABELS.items()] + options = [label for _, label in localized_options] + current = _localized_schedule_label(self._params.get("PreferredSchedule")) + value_by_label = {label: value for value, label in localized_options} def on_select(res): if res == DialogResult.CONFIRM and dialog.selection: - self._params.put_int("PreferredSchedule", normalize_schedule_value(dialog.selection)) + selected_value = value_by_label.get(dialog.selection) + if selected_value is not None: + self._params.put_int("PreferredSchedule", selected_value) dialog = MultiOptionDialog(tr("Auto Update Schedule"), options, current, callback=on_select) gui_app.push_widget(dialog) @@ -1051,7 +1059,10 @@ class StarPilotMapsLayout(StarPilotPanel): def remove_worker(): if OFFLINE_MAPS_PATH.exists(): shutil.rmtree(OFFLINE_MAPS_PATH, ignore_errors=True) - self._refresh_storage_cache(force=True) + self._storage_refresh_generation += 1 + self._pending_storage_state = (self._storage_refresh_generation, "0 MB", False) + self._storage_refresh_pending = False + self._storage_updated_at = 0.0 threading.Thread(target=remove_worker, daemon=True).start() gui_app.push_widget(alert_dialog(tr("Removing offline maps..."))) @@ -1066,11 +1077,11 @@ class StarPilotMapsLayout(StarPilotPanel): if self._is_visually_cancelling(): return tr("Cancelling Download") if self._download_state.active: - return tr("Downloading Now") + return tr("Download Readiness") if self._download_requested(): - return tr("Preparing Download") + return tr("Download Readiness") if self._has_downloaded_maps(): - return tr("Offline Maps Ready") + return tr("Offline Maps") return tr("Download Readiness") def _progress_body(self) -> str: @@ -1079,33 +1090,27 @@ class StarPilotMapsLayout(StarPilotPanel): elapsed_text = _format_elapsed_ms(elapsed_ms) eta_text = _format_eta_ms(elapsed_ms, self._download_state.downloaded_files, self._download_state.total_files) if self._download_state.primary_location: - return tr(f"{self._download_state.progress_text}\nElapsed {elapsed_text} | ETA {eta_text}") - return tr(f"{self._download_state.downloaded_files} / {self._download_state.total_files} ({self._download_state.percent}%)\nElapsed {elapsed_text} | ETA {eta_text}") + return tr("{}\nElapsed {} | ETA {}").format(self._download_state.progress_text, elapsed_text, eta_text) + return tr("{} / {} ({}%)\nElapsed {} | ETA {}").format( + self._download_state.downloaded_files, + self._download_state.total_files, + self._download_state.percent, + elapsed_text, + eta_text, + ) if self._is_visually_cancelling(): return tr("Stop request sent. The current transfer will wind down safely.") if self._download_requested(): - return tr("Contacting the background downloader and preparing the selected regions.") + return tr("Preparing the selected regions for download.") 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 refresh speed-limit map data.") - - def _status_footer_text(self) -> str: - parts = [self._network_label(), tr("Parked") if self._is_parked() else tr("Not parked")] - if self._download_state.active: - parts.append(tr(f"{self._download_state.percent}% complete")) - elif self._is_visually_cancelling(): - parts.append(tr("Cancelling")) - elif self._download_requested(): - parts.append(tr("Starting")) - else: - parts.append(schedule_label(self._params.get("PreferredSchedule"))) - return " • ".join(parts) + return tr("Ready to download {}.").format(self._selection_preview_text()) def _measure_content_height(self, width: float) -> float: return self._browser_card._measure_height(width) @@ -1121,7 +1126,7 @@ class StarPilotMapsLayout(StarPilotPanel): def _render(self, rect: rl.Rectangle): self.set_rect(rect) frame = build_list_panel_frame(rect) - draw_list_panel_shell(frame) + draw_list_panel_shell(frame, MAPS_PANEL_STYLE) hdr = frame.header title_y = hdr.y + HEADER_TOP_OFFSET