mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-06 22:22:11 +08:00
bf8204e9a2
BigUI WIP: Hänsel und Gretel
1153 lines
43 KiB
Python
1153 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import shutil
|
|
import threading
|
|
from dataclasses import dataclass, replace
|
|
from pathlib import Path
|
|
|
|
from cereal import log, messaging
|
|
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, 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.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,
|
|
DEFAULT_PANEL_STYLE,
|
|
build_list_panel_frame,
|
|
draw_action_pill,
|
|
draw_busy_ring,
|
|
draw_empty_state_card,
|
|
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,
|
|
)
|
|
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,
|
|
normalize_schedule_value,
|
|
sanitize_selected_locations_csv,
|
|
schedule_label,
|
|
)
|
|
from openpilot.starpilot.common.maps_selection import COUNTRY_PREFIX, STATE_PREFIX
|
|
|
|
|
|
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 = AETHER_LIST_METRICS.content_right_gutter
|
|
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=274)
|
|
|
|
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")
|
|
US_COUNTRY_TOKEN = f"{COUNTRY_PREFIX}US"
|
|
|
|
|
|
def _format_mb(size_bytes: int) -> str:
|
|
mb = size_bytes / (1024 * 1024)
|
|
if mb >= 1024:
|
|
return f"{mb / 1024:.2f} GB"
|
|
return f"{mb:.2f} MB"
|
|
|
|
|
|
def _format_elapsed_ms(elapsed_ms: int) -> str:
|
|
if elapsed_ms <= 0:
|
|
return tr("Calculating...")
|
|
total_seconds = elapsed_ms // 1000
|
|
hours = total_seconds // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
seconds = total_seconds % 60
|
|
if hours > 0:
|
|
return f"{hours:d}:{minutes:02d}:{seconds:02d}"
|
|
return f"{minutes:d}:{seconds:02d}"
|
|
|
|
|
|
def _format_eta_ms(elapsed_ms: int, downloaded_files: int, total_files: int) -> str:
|
|
if elapsed_ms <= 0 or downloaded_files <= 0 or total_files <= 0 or downloaded_files >= total_files:
|
|
return tr("Calculating...")
|
|
remaining_files = total_files - downloaded_files
|
|
if remaining_files <= 0:
|
|
return tr("Almost done")
|
|
files_per_ms = downloaded_files / max(elapsed_ms, 1)
|
|
if files_per_ms <= 0:
|
|
return tr("Calculating...")
|
|
remaining_ms = int(remaining_files / files_per_ms)
|
|
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}
|
|
return tokens
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class MapsDownloadState:
|
|
active: bool = False
|
|
cancelled: bool = False
|
|
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"):
|
|
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 _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(),
|
|
rl.Color(94, 168, 130, 22),
|
|
AetherListColors.SUCCESS_SOFT,
|
|
AetherListColors.HEADER,
|
|
font_size=15,
|
|
)
|
|
gui_text_box(
|
|
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,
|
|
)
|
|
|
|
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=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=PANEL_STYLE,
|
|
label_top_offset=0,
|
|
value_top_offset=14,
|
|
divider_top_offset=2,
|
|
divider_bottom_offset=16,
|
|
)
|
|
|
|
action_top = rect.y + 12
|
|
col_gap = 10
|
|
action_h = rect.height - 24
|
|
left_col_w = actions_w * 0.42
|
|
right_col_w = actions_w - left_col_w - col_gap
|
|
left_btn_gap = 6
|
|
left_btn_h = (action_h - left_btn_gap) / 2
|
|
|
|
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)
|
|
|
|
self._primary_button.render(primary_rect)
|
|
self._secondary_button.render(schedule_rect)
|
|
|
|
enabled = self._controller._remove_enabled()
|
|
draw_action_pill(
|
|
self._remove_rect,
|
|
tr("Remove Maps"),
|
|
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=16,
|
|
)
|
|
|
|
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=17,
|
|
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,
|
|
body_inset_x=40,
|
|
title_top_padding=24,
|
|
body_height=48,
|
|
border=rl.Color(255, 255, 255, 10),
|
|
style=PANEL_STYLE,
|
|
)
|
|
|
|
def _render_region_rows(self, rect: rl.Rectangle, regions: list[dict]):
|
|
if not regions:
|
|
title, body = self._controller._browse_empty_state()
|
|
self._render_empty_state(rect, title, body)
|
|
return
|
|
|
|
mouse_pos = gui_app.last_mouse_event.pos
|
|
self._region_row_rects.clear()
|
|
for index, region in enumerate(regions):
|
|
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}"
|
|
action_text = self._controller._region_action_text(token)
|
|
draw_selection_list_row(
|
|
row_rect,
|
|
title=tr(region["label"]),
|
|
subtitle=self._controller._region_primary_text(token),
|
|
action_text=action_text,
|
|
current=selected,
|
|
hovered=hovered,
|
|
pressed=self._pressed_target == target_key,
|
|
is_last=index == len(regions) - 1,
|
|
action_width=188,
|
|
action_pill=True,
|
|
action_text_size=18,
|
|
action_pill_height=44,
|
|
action_pill_width=132 if selected else 108,
|
|
title_size=34,
|
|
subtitle_size=22,
|
|
row_separator=PANEL_STYLE.divider_color,
|
|
current_bg=PANEL_STYLE.current_fill,
|
|
current_border=PANEL_STYLE.current_border,
|
|
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.HEADER,
|
|
)
|
|
|
|
def _active_browse_regions(self) -> list[dict]:
|
|
return self._controller._browse_regions_for_active_group()
|
|
|
|
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=28, trailing_size=22, style=PANEL_STYLE)
|
|
|
|
def _measure_height(self, width: float) -> float:
|
|
if self._controller._showing_source_picker():
|
|
del width
|
|
return BROWSER_CONTEXT_TAB_HEIGHT + 20
|
|
|
|
total = 10.0
|
|
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
|
|
draw_soft_card(rect, PANEL_STYLE.surface_fill, 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 + 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 + BROWSER_SECTION_HEADER_GAP
|
|
|
|
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(),
|
|
)
|
|
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)
|
|
|
|
|
|
class StarPilotMapsLayout(StarPilotPanel):
|
|
VIEW_COUNTRIES = 0
|
|
VIEW_STATES = 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
|
|
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._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(
|
|
AetherButton(
|
|
self._primary_action_label,
|
|
self._on_primary_action,
|
|
enabled=self._primary_action_enabled,
|
|
emphasized=True,
|
|
font_size=24,
|
|
)
|
|
)
|
|
self._schedule_button = self._child(
|
|
AetherButton(
|
|
lambda: tr("Update: {}").format(_localized_schedule_label(self._params.get('PreferredSchedule'))),
|
|
self._on_schedule,
|
|
emphasized=False,
|
|
font_size=20,
|
|
)
|
|
)
|
|
|
|
self._status_card = self._child(MapStatusCard(self))
|
|
self._browser_card = self._child(MapBrowserCard(self))
|
|
|
|
self._browser_card.set_touch_valid_callback(lambda: self._scroll_panel.is_touch_valid())
|
|
self._status_card.set_touch_valid_callback(lambda: True)
|
|
|
|
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._sync_whole_us_view_state()
|
|
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():
|
|
self._cancel_visual_until = rl.get_time() + 2.5
|
|
self._refresh_storage_cache(force=True)
|
|
self._sync_download_state(force=True)
|
|
|
|
def hide_event(self):
|
|
super().hide_event()
|
|
self._scroll_offset = 0.0
|
|
self._whole_us_context_initialized = False
|
|
device.set_override_interactive_timeout(None)
|
|
|
|
def _update_state(self):
|
|
super()._update_state()
|
|
self._cached_selected_tokens = _selected_token_set(
|
|
self._params.get("MapsSelected", encoding="utf-8") or ""
|
|
)
|
|
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:
|
|
device.set_override_interactive_timeout(300)
|
|
else:
|
|
device.set_override_interactive_timeout(None)
|
|
|
|
if self._cancel_requested_at is not None and not self._download_state.active:
|
|
if (rl.get_time() - self._cancel_requested_at) >= CANCEL_REQUEST_TIMEOUT:
|
|
self._params_memory.remove("CancelDownloadMaps")
|
|
self._cancel_requested_at = None
|
|
|
|
if self._cancel_visual_until and rl.get_time() >= self._cancel_visual_until and not self._download_state.active:
|
|
self._cancel_visual_until = 0.0
|
|
|
|
def _sync_download_state(self, force: bool = False):
|
|
del force
|
|
self._map_sm.update(0)
|
|
progress = self._map_sm["mapdExtendedOut"].downloadProgress if self._map_sm.valid.get("mapdExtendedOut", False) else None
|
|
active = bool(progress.active) if progress is not None else False
|
|
|
|
if active and self._download_started_at is None:
|
|
self._download_started_at = rl.get_time()
|
|
if active:
|
|
self._cancel_requested_at = None
|
|
self._cancel_visual_until = 0.0
|
|
if not self._download_in_flight() and self._download_started_at is not None:
|
|
self._download_started_at = None
|
|
|
|
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)
|
|
elif progress is not None and len(progress.locations) > 0:
|
|
primary_location = str(progress.locations[0])
|
|
|
|
progress_text = ""
|
|
if active:
|
|
progress_text = tr("{} / {} ({}%)").format(downloaded_files, total_files, percent)
|
|
if primary_location:
|
|
progress_text = f"{progress_text} {primary_location}"
|
|
|
|
self._download_state = MapsDownloadState(
|
|
active=active,
|
|
cancelled=bool(progress.cancelled) if progress is not None else False,
|
|
total_files=total_files,
|
|
downloaded_files=downloaded_files,
|
|
primary_location=primary_location,
|
|
location_count=location_count,
|
|
percent=percent,
|
|
progress_text=progress_text,
|
|
)
|
|
|
|
def _refresh_storage_cache(self, force: bool = False):
|
|
now = rl.get_time()
|
|
if self._storage_refresh_pending:
|
|
return
|
|
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:
|
|
result = self._calculate_storage_state()
|
|
finally:
|
|
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
|
|
self._storage_refresh_thread = threading.Thread(target=refresh_worker, daemon=True)
|
|
self._storage_refresh_thread.start()
|
|
|
|
def _calculate_storage_state(self) -> tuple[str, bool]:
|
|
if not OFFLINE_MAPS_PATH.exists():
|
|
return "0 MB", False
|
|
|
|
total_size = 0
|
|
has_files = False
|
|
for path in OFFLINE_MAPS_PATH.rglob("*"):
|
|
try:
|
|
if not path.is_file():
|
|
continue
|
|
has_files = True
|
|
total_size += path.stat().st_size
|
|
except OSError:
|
|
continue
|
|
return _format_mb(total_size), has_files
|
|
|
|
def _selected_tokens(self) -> set[str]:
|
|
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)
|
|
|
|
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 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
|
|
|
|
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
|
|
|
|
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 _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": 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 _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:
|
|
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)
|
|
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("Tap to remove")
|
|
if token == US_COUNTRY_TOKEN and self._is_full_us_mode():
|
|
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("Selected")
|
|
return tr("Add U.S.") if token == US_COUNTRY_TOKEN and self._is_full_us_mode() else 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.")
|
|
|
|
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 _get_map_state(self, token: str) -> bool:
|
|
return token in self._selected_tokens()
|
|
|
|
def _set_map_state(self, token: str, state: bool):
|
|
selected = set(self._cached_selected_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)
|
|
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)))
|
|
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
|
|
|
|
def _is_parked(self) -> bool:
|
|
if ui_state.is_offroad():
|
|
return True
|
|
if self._map_sm.valid.get("starpilotCarState", False):
|
|
return bool(self._map_sm["starpilotCarState"].isParked)
|
|
return False
|
|
|
|
def _download_requested(self) -> bool:
|
|
return self._params_memory.get_bool("DownloadMaps")
|
|
|
|
def _cancel_requested(self) -> bool:
|
|
return self._params_memory.get_bool("CancelDownloadMaps")
|
|
|
|
def _is_visually_cancelling(self) -> bool:
|
|
return self._cancel_requested() or (self._cancel_visual_until > rl.get_time())
|
|
|
|
def _download_in_flight(self) -> bool:
|
|
return self._download_state.active or self._download_requested()
|
|
|
|
def _remove_enabled(self) -> bool:
|
|
return self._has_downloaded_data and self._is_parked() and not self._download_in_flight() and not self._is_visually_cancelling()
|
|
|
|
def _download_gate_reason(self) -> str:
|
|
if self._download_in_flight():
|
|
return tr("Download in progress")
|
|
if self._selected_count() == 0:
|
|
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 the vehicle to download")
|
|
return ""
|
|
|
|
def _primary_action_enabled(self) -> bool:
|
|
if self._is_visually_cancelling():
|
|
return False
|
|
if self._download_in_flight():
|
|
return True
|
|
return self._download_gate_reason() == ""
|
|
|
|
def _primary_action_label(self) -> str:
|
|
if self._is_visually_cancelling():
|
|
return tr("Cancelling...")
|
|
if self._download_in_flight():
|
|
return tr("Cancel Download")
|
|
return tr("Download Offline Maps")
|
|
|
|
def _on_primary_action(self):
|
|
if self._download_in_flight():
|
|
self._on_cancel()
|
|
else:
|
|
self._on_download()
|
|
|
|
def _on_schedule(self):
|
|
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:
|
|
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)
|
|
|
|
def _on_download(self):
|
|
gate_reason = self._download_gate_reason()
|
|
if gate_reason:
|
|
gui_app.push_widget(alert_dialog(gate_reason))
|
|
return
|
|
|
|
current_selected = self._params.get("MapsSelected", encoding="utf-8") or ""
|
|
selected_raw = sanitize_selected_locations_csv(current_selected)
|
|
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.")))
|
|
return
|
|
|
|
def on_confirm(res):
|
|
if res == DialogResult.CONFIRM:
|
|
gate_reason = self._download_gate_reason()
|
|
if gate_reason:
|
|
gui_app.push_widget(alert_dialog(gate_reason))
|
|
return
|
|
self._params_memory.put_bool("DownloadMaps", True)
|
|
self._params_memory.remove("CancelDownloadMaps")
|
|
self._download_started_at = rl.get_time()
|
|
|
|
gui_app.push_widget(ConfirmDialog(tr("Start downloading offline maps for the selected regions?"), tr("Download"), callback=on_confirm))
|
|
|
|
def _on_cancel(self):
|
|
def on_confirm(res):
|
|
if res == DialogResult.CONFIRM:
|
|
if not self._download_in_flight():
|
|
self._cancel_requested_at = None
|
|
self._cancel_visual_until = 0.0
|
|
return
|
|
self._params_memory.put_bool("CancelDownloadMaps", True)
|
|
self._params_memory.remove("DownloadMaps")
|
|
self._cancel_requested_at = rl.get_time()
|
|
self._cancel_visual_until = rl.get_time() + 2.5
|
|
|
|
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():
|
|
gui_app.push_widget(alert_dialog(tr("Park to remove downloaded maps.")))
|
|
return
|
|
|
|
def on_confirm(res):
|
|
if res == DialogResult.CONFIRM:
|
|
if not self._remove_enabled():
|
|
if not self._is_parked():
|
|
gui_app.push_widget(alert_dialog(tr("Park to remove downloaded maps.")))
|
|
return
|
|
|
|
def remove_worker():
|
|
if OFFLINE_MAPS_PATH.exists():
|
|
shutil.rmtree(OFFLINE_MAPS_PATH, ignore_errors=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...")))
|
|
|
|
gui_app.push_widget(ConfirmDialog(tr("Delete all downloaded offline map data?"), tr("Remove Maps"), callback=on_confirm))
|
|
|
|
def _last_updated_text(self) -> str:
|
|
last_update = self._worker_params.get("LastMapsUpdate", encoding="utf-8")
|
|
return last_update or tr("Never")
|
|
|
|
def _progress_title(self) -> str:
|
|
if self._is_visually_cancelling():
|
|
return tr("Cancelling Download")
|
|
if self._download_state.active:
|
|
return tr("Download Readiness")
|
|
if self._download_requested():
|
|
return tr("Download Readiness")
|
|
if self._has_downloaded_maps():
|
|
return tr("Offline Maps")
|
|
return tr("Download Readiness")
|
|
|
|
def _progress_body(self) -> str:
|
|
if self._download_state.active:
|
|
elapsed_ms = int((rl.get_time() - (self._download_started_at or rl.get_time())) * 1000)
|
|
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("{}\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("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 download {}.").format(self._selection_preview_text())
|
|
|
|
def _measure_content_height(self, width: float) -> float:
|
|
return self._browser_card._measure_height(width)
|
|
|
|
def _draw_scroll_content(self, rect: rl.Rectangle, width: float):
|
|
self._browser_card.set_parent_rect(rect)
|
|
|
|
y = rect.y + self._scroll_offset
|
|
|
|
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.set_rect(rect)
|
|
frame, scroll_rect, content_width = init_list_panel(rect, PANEL_STYLE, MAPS_METRICS)
|
|
|
|
hdr = frame.header
|
|
draw_settings_panel_header(hdr, tr("Map Data"), tr("Use offline maps for speed-limit control and keep only the regions you need."),
|
|
max_title_width=1.0, max_subtitle_width=0.60)
|
|
|
|
header_status_y = hdr.y + 82 + HEADER_SUBTITLE_HEIGHT + 12
|
|
header_status_rect = rl.Rectangle(hdr.x, header_status_y, hdr.width, hdr.y + hdr.height - header_status_y - HEADER_BOTTOM_GAP)
|
|
self._status_card.render(header_status_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))
|
|
|
|
rl.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)
|
|
rl.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)
|