diff --git a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py index 99a4fd46b..319d8f531 100644 --- a/selfdrive/ui/layouts/settings/starpilot/aethergrid.py +++ b/selfdrive/ui/layouts/settings/starpilot/aethergrid.py @@ -1161,31 +1161,36 @@ class BreadcrumbController: if not layout: return - if target == "action:home": - while len(gui_app._nav_stack) > 1: - gui_app.pop_widget() - layout._panel_stack.clear() - layout._update_sub_panel_visibility() - layout._current_category_idx = None - layout._set_current_panel(StarPilotPanelType.MAIN) - elif target == "action:category": - while len(gui_app._nav_stack) > 1: - gui_app.pop_widget() - layout._panel_stack.clear() + nav_stack = getattr(gui_app, "_nav_stack", []) - cat = layout.CATEGORIES[layout._current_category_idx] - if "buttons" in cat: - layout._set_current_panel(StarPilotPanelType.MAIN) - else: + if target == "action:home": + while len(nav_stack) > 1: + gui_app.pop_widget() + layout.reset_to_root() + elif target.startswith("action:hub:"): + target_depth = int(target.split(":")[-1]) + while len(nav_stack) > 1: + gui_app.pop_widget() + layout.navigate_to_hub_depth(target_depth) + elif target == "action:category": + # Compatibility with the former single-category breadcrumb action. + while len(nav_stack) > 1: + gui_app.pop_widget() + if getattr(layout, "_hub_path", None): + layout.navigate_to_hub_depth(1) + elif layout._current_panel != StarPilotPanelType.MAIN: + layout._panel_stack.clear() layout._commit_navigation() + else: + layout.reset_to_root() elif target == "action:panel": - while len(gui_app._nav_stack) > 1: + while len(nav_stack) > 1: gui_app.pop_widget() layout._panel_stack.clear() layout._commit_navigation() elif target.startswith("action:nav_stack:"): target_idx = int(target.split(":")[-1]) - while len(gui_app._nav_stack) > target_idx + 1: + while len(nav_stack) > target_idx + 1: gui_app.pop_widget() elif target.startswith("action:panel_stack:"): target_idx = int(target.split(":")[-1]) @@ -1203,22 +1208,20 @@ class BreadcrumbController: if not layout: return path - pushed_widgets = gui_app._nav_stack[1:] - - cat_title = "" - is_folder = False - if layout._current_category_idx is not None: - cat = layout.CATEGORIES[layout._current_category_idx] - cat_title = cat["title"] - is_folder = "buttons" in cat - path.append((cat_title, "action:category")) + hub_path = getattr(layout, "_hub_path", []) + for i, folder in enumerate(hub_path, start=1): + path.append((tr(folder["title"]), f"action:hub:{i}")) if layout._current_panel != StarPilotPanelType.MAIN: - panel_info = layout._panels[layout._current_panel] - if panel_info.name: - if is_folder or layout._current_category_idx is None: - panel_title = panel_info.name - path.append((panel_title, "action:panel")) + selected_leaf = getattr(layout, "_selected_leaf", None) + if selected_leaf is not None: + panel_title = selected_leaf["title"] + else: + panel_title = layout._panels[layout._current_panel].name + if panel_title: + path.append((tr(panel_title), "action:panel")) + + pushed_widgets = getattr(gui_app, "_nav_stack", [])[1:] for i, widget in enumerate(pushed_widgets): if hasattr(widget, '_header_title') and widget._header_title: @@ -5473,6 +5476,3 @@ class TileGrid(Widget): tile.render(snap_rect(rl.Rectangle(row_x + c * (row_tile_w + self._gap), rect.y + y_offset + r * (tile_h + self._gap), row_tile_w, tile_h))) tile_idx += 1 - - - diff --git a/selfdrive/ui/layouts/settings/starpilot/main_panel.py b/selfdrive/ui/layouts/settings/starpilot/main_panel.py index d864e8423..781eed9f6 100644 --- a/selfdrive/ui/layouts/settings/starpilot/main_panel.py +++ b/selfdrive/ui/layouts/settings/starpilot/main_panel.py @@ -27,14 +27,25 @@ class StarPilotLayout(Widget): "panel": "SOUNDS", }, { - "title": "Driving Controls", - "icon": "steering", - "buttons": [("Driving Model", "DRIVING_MODEL", "aicar"), ("Gas / Brake", "LONGITUDINAL", "road"), ("Steering", "LATERAL", "steering")], + "title": "Driving Model", + "icon": "aicar", + "panel": "DRIVING_MODEL", }, { - "title": "Navigation & Maps", - "icon": "navigate", - "buttons": [("Map Data", "MAPS", "navigate"), ("Navigation", "NAVIGATION", "road")], + "title": "Driving Controls", + "icon": "steering", + "children": [ + { + "title": "Navigation & Maps", + "icon": "navigate", + "children": [ + {"title": "Map Data", "panel": "MAPS", "icon": "navigate"}, + {"title": "Navigation", "panel": "NAVIGATION", "icon": "road"}, + ], + }, + {"title": "Gas / Brake", "panel": "LONGITUDINAL", "icon": "road"}, + {"title": "Steering", "panel": "LATERAL", "icon": "steering"}, + ], }, { "title": "System", @@ -53,11 +64,27 @@ class StarPilotLayout(Widget): }, ] + PANEL_TYPE_MAP = { + "SOUNDS": StarPilotPanelType.SOUNDS, + "SYSTEM": StarPilotPanelType.SYSTEM, + "DRIVING_MODEL": StarPilotPanelType.DRIVING_MODEL, + "LONGITUDINAL": StarPilotPanelType.LONGITUDINAL, + "LATERAL": StarPilotPanelType.LATERAL, + "MAPS": StarPilotPanelType.MAPS, + "NAVIGATION": StarPilotPanelType.NAVIGATION, + "VISUALS": StarPilotPanelType.VISUALS, + "VEHICLE": StarPilotPanelType.VEHICLE, + } + def __init__(self): super().__init__() self._params = FrameCachedParams() self._current_panel = StarPilotPanelType.MAIN + self._hub_path: list[dict] = [] + self._selected_leaf: dict | None = None + # Kept as a compatibility alias for callers that only need the top-level + # folder index. Nested hub navigation is represented by _hub_path. self._current_category_idx: int | None = None self._depth_callback: Callable | None = None self._settings_layout = None @@ -101,38 +128,69 @@ class StarPilotLayout(Widget): def set_settings_layout(self, settings_layout): self._settings_layout = settings_layout + @property + def hub_path(self) -> tuple[dict, ...]: + return tuple(self._hub_path) + def navigate_back(self): if self._panel_stack: self._panel_stack.pop() self._commit_navigation() elif self._current_panel != StarPilotPanelType.MAIN: - if self._current_category_idx is not None: - cat_info = self.CATEGORIES[self._current_category_idx] - if "buttons" in cat_info: - self._set_current_panel(StarPilotPanelType.MAIN) - else: - self._current_category_idx = None - self._set_current_panel(StarPilotPanelType.MAIN) - else: - self._set_current_panel(StarPilotPanelType.MAIN) - elif self._current_category_idx is not None: - self._current_category_idx = None + # A panel always returns to the folder that launched it. self._set_current_panel(StarPilotPanelType.MAIN) + elif self._hub_path: + # Once the grid is visible, each back step removes one hub folder. + self._hub_path.pop() + self._selected_leaf = None + self._sync_legacy_category_idx() + self._rebuild_grid() + self._commit_navigation() + + def reset_to_root(self): + """Close nested content and restore the primary six-tile hub.""" + self._hub_path.clear() + self._selected_leaf = None + self._sync_legacy_category_idx() + self._set_current_panel(StarPilotPanelType.MAIN) + + def navigate_to_hub_depth(self, depth: int): + """Jump to a folder in the current hub path from a breadcrumb.""" + depth = max(0, min(depth, len(self._hub_path))) + self._hub_path = self._hub_path[:depth] + self._selected_leaf = None + self._sync_legacy_category_idx() + self._set_current_panel(StarPilotPanelType.MAIN) + + def _sync_legacy_category_idx(self): + if self._hub_path: + self._current_category_idx = self.CATEGORIES.index(self._hub_path[0]) + else: + self._current_category_idx = None + + def _open_folder(self, folder: dict): + if "children" not in folder: + return + self._hub_path.append(folder) + self._selected_leaf = None + self._sync_legacy_category_idx() + self._set_current_panel(StarPilotPanelType.MAIN) + + def _open_leaf(self, leaf: dict): + panel_key = leaf.get("panel") + if panel_key is None: + return + self._selected_leaf = leaf + self._set_current_panel(self.PANEL_TYPE_MAP[panel_key]) def _update_depth(self): - depth = 0 + # Root = 0, each visible hub folder = 1, and an open backend panel adds + # one more level. Existing panel sub-pages remain below that panel. + depth = len(self._hub_path) if self._current_panel != StarPilotPanelType.MAIN: - if self._current_category_idx is not None: - cat_info = self.CATEGORIES[self._current_category_idx] - depth = 2 if "buttons" in cat_info else 1 - else: - depth = 1 - # Deep nesting check - if self._panel_stack: - depth += len(self._panel_stack) - elif self._current_category_idx is not None: - depth = 1 - + depth += 1 + depth += len(self._panel_stack) + if self._depth_callback: self._depth_callback(depth) @@ -166,70 +224,28 @@ class StarPilotLayout(Widget): panel.set_navigate_callback(self._push_sub_panel) def _rebuild_grid(self): - state = (self._current_category_idx,) + state = tuple(id(folder) for folder in self._hub_path) if getattr(self, "_last_grid_state", None) == state: return self._last_grid_state = state self._main_grid.clear() - - panel_type_map = { - "SOUNDS": StarPilotPanelType.SOUNDS, - "SYSTEM": StarPilotPanelType.SYSTEM, - "DRIVING_MODEL": StarPilotPanelType.DRIVING_MODEL, - "LONGITUDINAL": StarPilotPanelType.LONGITUDINAL, - "LATERAL": StarPilotPanelType.LATERAL, - "MAPS": StarPilotPanelType.MAPS, - "NAVIGATION": StarPilotPanelType.NAVIGATION, - "VISUALS": StarPilotPanelType.VISUALS, - "VEHICLE": StarPilotPanelType.VEHICLE, - } - if self._current_category_idx is None: - # Main Categories Grid - for i, cat in enumerate(self.CATEGORIES): - def on_click(idx=i): - cat_info = self.CATEGORIES[idx] - self._current_category_idx = idx - panel_key = cat_info.get("panel") - if panel_key is not None: - self._set_current_panel(panel_type_map[panel_key]) - else: - self._rebuild_grid() - if self._depth_callback: - self._depth_callback(1) - - tile = HubTile( - title=tr(cat["title"]), - desc=tr(cat.get("desc", "")), - icon_key=cat["icon"], - on_click=on_click, - bg_color=cat.get("color") - ) - self._main_grid.add_tile(tile) - else: - # Sub-buttons Grid for selected Category - cat = self.CATEGORIES[self._current_category_idx] - visible_buttons = cat["buttons"] - - for button_info in visible_buttons: - if len(button_info) == 3: - label, panel_key, btn_icon = button_info + visible_nodes = self.CATEGORIES if not self._hub_path else self._hub_path[-1]["children"] + for node in visible_nodes: + def on_click(item=node): + if "children" in item: + self._open_folder(item) else: - label, panel_key = button_info - btn_icon = cat["icon"] - - p_type = panel_type_map[panel_key] - def on_btn_click(p=p_type): - self._set_current_panel(p) + self._open_leaf(item) - tile = HubTile( - title=tr(label), - desc="", - icon_key=btn_icon, - on_click=on_btn_click, - bg_color=cat.get("color") - ) - self._main_grid.add_tile(tile) + tile = HubTile( + title=tr(node["title"]), + desc=tr(node.get("desc", "")), + icon_key=node["icon"], + on_click=on_click, + bg_color=node.get("color") + ) + self._main_grid.add_tile(tile) def _set_current_panel(self, panel_type: StarPilotPanelType): if panel_type != self._current_panel: @@ -244,8 +260,10 @@ class StarPilotLayout(Widget): if panel_type != StarPilotPanelType.MAIN: self._panels[panel_type].instance.show_event() else: + self._selected_leaf = None self._rebuild_grid() elif panel_type == StarPilotPanelType.MAIN: + self._selected_leaf = None self._rebuild_grid() self._panel_stack.clear() diff --git a/selfdrive/ui/tests/test_starpilot_navigation_structure.py b/selfdrive/ui/tests/test_starpilot_navigation_structure.py index 6fa66c800..225835cce 100644 --- a/selfdrive/ui/tests/test_starpilot_navigation_structure.py +++ b/selfdrive/ui/tests/test_starpilot_navigation_structure.py @@ -1,20 +1,241 @@ +from types import SimpleNamespace + from openpilot.selfdrive.ui.layouts.settings.starpilot.main_panel import StarPilotLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.navigation import StarPilotNavigationLayout from openpilot.selfdrive.ui.layouts.settings.starpilot.panel import StarPilotPanelType +from openpilot.selfdrive.ui.layouts.settings.starpilot.aethergrid import BreadcrumbController, gui_app -def test_navigation_and_maps_is_the_parent_folder_for_map_data_and_navigation(): - category = next(item for item in StarPilotLayout.CATEGORIES if item["title"] == "Navigation & Maps") - - assert "panel" not in category - assert category["buttons"] == [ - ("Map Data", "MAPS", "navigate"), - ("Navigation", "NAVIGATION", "road"), +def test_root_hub_contains_the_six_categories_in_order(): + assert [item["title"] for item in StarPilotLayout.CATEGORIES] == [ + "Sounds & Alerts", + "Driving Model", + "Driving Controls", + "System", + "Appearance", + "Vehicle Settings", ] - assert all(item["title"] != "Map Data" for item in StarPilotLayout.CATEGORIES) + assert len(StarPilotLayout.CATEGORIES) == 6 + assert all(item["title"] != "Navigation & Maps" for item in StarPilotLayout.CATEGORIES) + + +def test_driving_controls_contains_nested_navigation_folder_and_leaf_routes(): + controls = next(item for item in StarPilotLayout.CATEGORIES if item["title"] == "Driving Controls") + + assert "panel" not in controls + assert [item["title"] for item in controls["children"]] == [ + "Navigation & Maps", + "Gas / Brake", + "Steering", + ] + + navigation_maps = controls["children"][0] + assert "panel" not in navigation_maps + assert navigation_maps["children"] == [ + {"title": "Map Data", "panel": "MAPS", "icon": "navigate"}, + {"title": "Navigation", "panel": "NAVIGATION", "icon": "road"}, + ] + + assert controls["children"][1]["panel"] == "LONGITUDINAL" + assert controls["children"][2]["panel"] == "LATERAL" + + +def test_driving_model_is_a_root_leaf_and_existing_panel_routes_are_preserved(): + driving_model = next(item for item in StarPilotLayout.CATEGORIES if item["title"] == "Driving Model") + + assert driving_model["panel"] == "DRIVING_MODEL" + assert driving_model["icon"] == "aicar" + assert "children" not in driving_model + assert StarPilotLayout.PANEL_TYPE_MAP["DRIVING_MODEL"] == StarPilotPanelType.DRIVING_MODEL + assert StarPilotLayout.PANEL_TYPE_MAP["MAPS"] == StarPilotPanelType.MAPS + assert StarPilotLayout.PANEL_TYPE_MAP["NAVIGATION"] == StarPilotPanelType.NAVIGATION assert StarPilotPanelType.NAVIGATION.value == 13 +class _FakeHubTile: + def __init__(self, title, desc, icon_key, on_click, bg_color=None): + self.title = title + self.desc = desc + self.icon_key = icon_key + self.on_click = on_click + self.bg_color = bg_color + + +class _FakeGrid: + def __init__(self): + self.tiles = [] + + def clear(self): + self.tiles.clear() + + def add_tile(self, tile): + self.tiles.append(tile) + + +class _PanelSpy: + def __init__(self, name): + self.name = name + self.show_count = 0 + self.hide_count = 0 + self.current_sub_panel = "" + + def show_event(self): + self.show_count += 1 + + def hide_event(self): + self.hide_count += 1 + + def set_current_sub_panel(self, sub_panel): + self.current_sub_panel = sub_panel + + +def _make_layout(monkeypatch): + import openpilot.selfdrive.ui.layouts.settings.starpilot.main_panel as main_panel + + monkeypatch.setattr(main_panel, "HubTile", _FakeHubTile) + + layout = object.__new__(StarPilotLayout) + layout._current_panel = StarPilotPanelType.MAIN + layout._hub_path = [] + layout._selected_leaf = None + layout._current_category_idx = None + layout._panel_stack = [] + layout._depth_callback = None + layout._main_grid = _FakeGrid() + layout._panels = {} + for panel_type in StarPilotPanelType: + layout._panels[panel_type] = SimpleNamespace( + name=panel_type.name, + instance=None if panel_type == StarPilotPanelType.MAIN else _PanelSpy(panel_type.name), + ) + + depths = [] + layout.set_depth_callback(depths.append) + StarPilotLayout.active_instance = layout + layout._rebuild_grid() + return layout, depths + + +def _click_title(layout, title): + tile = next(tile for tile in layout._main_grid.tiles if tile.title == title) + tile.on_click() + + +def test_nested_hub_navigation_back_and_depth_values(monkeypatch): + layout, depths = _make_layout(monkeypatch) + assert len(layout._main_grid.tiles) == 6 + assert depths == [] + + _click_title(layout, "Driving Controls") + assert [folder["title"] for folder in layout._hub_path] == ["Driving Controls"] + assert layout._current_panel == StarPilotPanelType.MAIN + layout._update_depth() + assert depths[-1] == 1 + + _click_title(layout, "Navigation & Maps") + assert [folder["title"] for folder in layout._hub_path] == ["Driving Controls", "Navigation & Maps"] + layout._update_depth() + assert depths[-1] == 2 + + _click_title(layout, "Map Data") + assert layout._current_panel == StarPilotPanelType.MAPS + assert layout._selected_leaf["title"] == "Map Data" + assert depths[-1] == 3 + maps_panel = layout._panels[StarPilotPanelType.MAPS].instance + assert (maps_panel.show_count, maps_panel.hide_count) == (1, 0) + + layout.navigate_back() + assert layout._current_panel == StarPilotPanelType.MAIN + assert [folder["title"] for folder in layout._hub_path] == ["Driving Controls", "Navigation & Maps"] + assert depths[-1] == 2 + assert (maps_panel.show_count, maps_panel.hide_count) == (1, 1) + + layout.navigate_back() + assert [folder["title"] for folder in layout._hub_path] == ["Driving Controls"] + assert depths[-1] == 1 + + layout.navigate_back() + assert layout._hub_path == [] + assert depths[-1] == 0 + + +def test_root_driving_model_opens_directly_and_sub_panel_depth_is_additive(monkeypatch): + layout, depths = _make_layout(monkeypatch) + + _click_title(layout, "Driving Model") + assert layout._hub_path == [] + assert layout._current_panel == StarPilotPanelType.DRIVING_MODEL + assert depths[-1] == 1 + + layout._panel_stack.append((StarPilotPanelType.DRIVING_MODEL, "details")) + layout._commit_navigation() + assert depths[-1] == 2 + + layout.navigate_back() + assert layout._current_panel == StarPilotPanelType.DRIVING_MODEL + assert layout._panel_stack == [] + assert depths[-1] == 1 + + layout.navigate_back() + assert layout._current_panel == StarPilotPanelType.MAIN + assert depths[-1] == 0 + + +def test_breadcrumb_paths_and_folder_jump_back(monkeypatch): + layout, _ = _make_layout(monkeypatch) + monkeypatch.setattr(gui_app, "_nav_stack", [layout], raising=False) + + assert BreadcrumbController.build_path() == [("StarPilot", "action:home")] + + _click_title(layout, "Driving Controls") + assert BreadcrumbController.build_path() == [ + ("StarPilot", "action:home"), + ("Driving Controls", "action:hub:1"), + ] + + _click_title(layout, "Navigation & Maps") + assert BreadcrumbController.build_path() == [ + ("StarPilot", "action:home"), + ("Driving Controls", "action:hub:1"), + ("Navigation & Maps", "action:hub:2"), + ] + + _click_title(layout, "Map Data") + assert BreadcrumbController.build_path()[-1] == ("Map Data", "action:panel") + + nav_stack = [layout, object()] + monkeypatch.setattr(gui_app, "_nav_stack", nav_stack, raising=False) + monkeypatch.setattr(gui_app, "pop_widget", lambda: nav_stack.pop(), raising=False) + BreadcrumbController().handle_click("action:hub:1") + + assert nav_stack == [layout] + assert layout._current_panel == StarPilotPanelType.MAIN + assert [folder["title"] for folder in layout._hub_path] == ["Driving Controls"] + assert BreadcrumbController.build_path()[-1] == ("Driving Controls", "action:hub:1") + + +def test_home_breadcrumb_clears_hub_path_panel_stack_and_active_panel(monkeypatch): + layout, _ = _make_layout(monkeypatch) + _click_title(layout, "Driving Controls") + _click_title(layout, "Navigation & Maps") + _click_title(layout, "Map Data") + layout._panel_stack.append((StarPilotPanelType.MAPS, "details")) + + nav_stack = [layout, object(), object()] + monkeypatch.setattr(gui_app, "_nav_stack", nav_stack, raising=False) + monkeypatch.setattr(gui_app, "pop_widget", lambda: nav_stack.pop(), raising=False) + BreadcrumbController().handle_click("action:home") + + maps_panel = layout._panels[StarPilotPanelType.MAPS].instance + assert nav_stack == [layout] + assert layout._hub_path == [] + assert layout._selected_leaf is None + assert layout._panel_stack == [] + assert layout._current_panel == StarPilotPanelType.MAIN + assert maps_panel.hide_count == 1 + assert BreadcrumbController.build_path() == [("StarPilot", "action:home")] + + def test_navigation_start_is_the_summary_action_not_a_duplicate_rail_target(): layout = object.__new__(StarPilotNavigationLayout) layout._draft_destination = {