diff --git a/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py b/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py index 33c0313750..f56af6d147 100644 --- a/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py +++ b/selfdrive/ui/onroad/starpilot/favorite_radial_menu.py @@ -9,6 +9,7 @@ from typing import Any import pyray as rl +from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED from openpilot.starpilot.common.favorite_slots import ( FAVORITE_SLOT_COUNT, get_favorite_enum_state, @@ -257,6 +258,9 @@ class FavoriteRadialMenu: key = slot.get("key") if key in self._available_option_labels: slot["label"] = self._available_option_labels[key] + if key == CONTROLLER_ACTION_SET_SPEED and slot.get("value") is not None: + unit = "km/h" if self._params.get_bool("IsMetric") else "mph" + slot["label"] = f"Set Speed To {slot['value']:g} {unit}" self._layout_slot_rects() self._layout_picker_rects() @@ -590,7 +594,9 @@ class FavoriteRadialMenu: def _open_picker(self, slot_index: int) -> None: options = self._refresh_option_catalog() - self._picker_options = options or [] + # This on-road picker has no numeric editor. Keep speed actions in the + # catalogue for activating saved slots, but configure their value in Galaxy. + self._picker_options = [option for option in (options or []) if option.get("value_type") != "speed"] self._selected_slot = slot_index self._editing_slot = None self._picker_page = 0 @@ -602,7 +608,7 @@ class FavoriteRadialMenu: return key = str(option.get("key") or "").strip() - if not key: + if not key or key == CONTROLLER_ACTION_SET_SPEED or option.get("value_type") == "speed": return slots = load_favorite_slots(self._params, eligible_keys=self._available_option_keys) @@ -1013,7 +1019,7 @@ class FavoriteRadialMenu: title = f"Assign Favorite {self._selected_slot + 1}" if self._selected_slot is not None else "Assign Favorite" title_pos = rl.Vector2(self._drawer_rect.x + 36 * scale, self._drawer_rect.y + 26 * scale) self._draw_text(self._font(bold=True), title, title_pos, int(50 * scale), self._TEXT) - subtitle = "Choose a shortcut" + subtitle = "Set Speed: configure in New Galaxy" self._draw_text( self._font(bold=False), subtitle, rl.Vector2(title_pos.x, title_pos.y + 72 * scale), int(26 * scale), self._MUTED_TEXT, diff --git a/selfdrive/ui/tests/test_favorite_radial_menu.py b/selfdrive/ui/tests/test_favorite_radial_menu.py index b645385eb3..53debdfdcc 100644 --- a/selfdrive/ui/tests/test_favorite_radial_menu.py +++ b/selfdrive/ui/tests/test_favorite_radial_menu.py @@ -933,3 +933,53 @@ def test_render_boolean_toggle_switch_and_picker_badges(monkeypatch): assert "TOGGLE" in drawn_texts assert "2 STATES" in drawn_texts assert "ACTION" in drawn_texts + + + +def test_speed_assignment_requires_new_galaxy_without_disabling_existing_slot(): + from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED + + speed_option = {"key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value_type": "speed", "default_value": 30} + menu, params, _memory = _menu([0.0], options=[speed_option, {"key": "FeatureToggle", "label": "Feature Toggle"}]) + params.put(FAVORITE_SLOTS_PARAM, [{"enabled": True, "show_onroad": True, "key": "FeatureToggle", "label": "Feature Toggle"}]) + menu._open_picker(0) + + assert CONTROLLER_ACTION_SET_SPEED in menu._available_option_keys + assert [option["key"] for option in menu._picker_options] == ["FeatureToggle"] + menu._assign_option(speed_option) + assert params.get(FAVORITE_SLOTS_PARAM)[0]["key"] == "FeatureToggle" + assert menu.state == FavoriteRadialMenu.STATE_PICKER + + +def test_saved_speed_favorite_retains_value_and_displays_current_unit(): + from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED + + menu, params, _memory = _menu([0.0], options=[{"key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value_type": "speed"}]) + params.put(FAVORITE_SLOTS_PARAM, [{"enabled": True, "show_onroad": True, "key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value": 42}]) + menu._open_radial() + for metric, unit in [(False, "mph"), (True, "km/h")]: + params.put_bool("IsMetric", metric) + menu._layout(rl.Rectangle(0, 0, 2160, 1080)) + slot = menu._slots[0] + assert menu._slot_is_configured(slot) + assert slot["value"] == 42 + assert slot["label"] == f"Set Speed To 42 {unit}" + assert params.get(FAVORITE_SLOTS_PARAM)[0]["label"] == "Set Speed To" + + + +def test_native_saved_speed_favorite_tap_dispatches_configured_value(monkeypatch): + from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_SET_SPEED + from openpilot.starpilot.system.wheel_controls import wheel_controlsd + + menu, params, memory = _menu([0.0], options=[{"key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value_type": "speed"}]) + slot = {"enabled": True, "show_onroad": True, "key": CONTROLLER_ACTION_SET_SPEED, "label": "Set Speed To", "value": 42} + params.put(FAVORITE_SLOTS_PARAM, [slot]) + dispatched = [] + monkeypatch.setattr(wheel_controlsd, "execute_controller_key", lambda key, params, memory, **kwargs: dispatched.append((key, kwargs["value"])) or True) + rect = rl.Rectangle(0, 0, 2160, 1080) + _tap(menu, rect, menu.corner_center(rect)) + _tap(menu, rect, menu.slot_centers(rect)[0]) + + assert dispatched == [(CONTROLLER_ACTION_SET_SPEED, 42)] + assert params.get(FAVORITE_SLOTS_PARAM) == [slot] diff --git a/starpilot/common/controller_actions.py b/starpilot/common/controller_actions.py new file mode 100644 index 0000000000..602d2f1155 --- /dev/null +++ b/starpilot/common/controller_actions.py @@ -0,0 +1,81 @@ +"""One action catalogue for native favourites and Bluetooth controls.""" +CONTROLLER_ACTION_CYCLE_PERSONALITY = "__starpilot_controller_action__:cycle_driving_personality" +CONTROLLER_ACTION_SET_SPEED = "__starpilot_controller_action__:set_speed" +CONTROLLER_ACTION_SELFIE = "__starpilot_controller_action__:selfie" +CONTROLLER_ACTION_BOOKMARK = "__starpilot_controller_action__:bookmark" +CONTROLLER_ACTION_PULSE_AND_GLIDE = "__starpilot_controller_action__:pulse_and_glide" +CONTROLLER_ACTION_FORCE_COAST = "__starpilot_controller_action__:force_coast" +CONTROLLER_ACTION_TOGGLE_AOL = "__starpilot_controller_action__:toggle_aol" +CONTROLLER_ACTION_ENGAGE = "__starpilot_controller_action__:engage_openpilot" +CONTROLLER_ACTION_DISENGAGE = "__starpilot_controller_action__:disengage_openpilot" +CONTROLLER_ACTION_COUNTERS = { + CONTROLLER_ACTION_BOOKMARK: "WheelButtonBookmarkCounter", + CONTROLLER_ACTION_PULSE_AND_GLIDE: "WheelControlPulseGlideCounter", + CONTROLLER_ACTION_FORCE_COAST: "WheelControlForceCoastCounter", + CONTROLLER_ACTION_TOGGLE_AOL: "WheelControlAOLCounter", + CONTROLLER_ACTION_ENGAGE: "WheelControlEngageCounter", + CONTROLLER_ACTION_DISENGAGE: "WheelControlDisengageCounter", +} +CONTROLLER_ACTION_OPTIONS = ( + { + "key": CONTROLLER_ACTION_CYCLE_PERSONALITY, + "label": "Cycle Driving Personality", + "description": "Cycles Aggressive → Standard → Relaxed. Requires longitudinal control and Safe Mode off; leaves Traffic Mode unchanged.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_SET_SPEED, + "label": "Set Speed To", + "description": "Immediately changes the software-controlled cruise set speed while engaged.", + "section": "Controller Actions", + "value_type": "speed", + "default_value": 30, + }, + { + "key": CONTROLLER_ACTION_SELFIE, + "label": "Take Comma Selfie", + "description": "Captures the driver camera and saves it in Sentry history.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_BOOKMARK, + "label": "Bookmark", + "description": "Creates a driving bookmark without changing the on-screen Favorites.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_PULSE_AND_GLIDE, + "label": "Pulse and Glide", + "description": "Toggles Pulse and Glide using the same transient control as a mapped vehicle button.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_FORCE_COAST, + "label": "Force Coasting", + "description": "Toggles forced coasting using the same transient control as a mapped vehicle button.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_TOGGLE_AOL, + "label": "Toggle AOL", + "description": "Toggles Always On Lateral like the vehicle LKAS button; it does not change the AOL setting.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_ENGAGE, + "label": "Engage Openpilot", + "description": "Requests engagement through the normal openpilot readiness and safety checks.", + "section": "Controller Actions", + }, + { + "key": CONTROLLER_ACTION_DISENGAGE, + "label": "Disengage Openpilot", + "description": "Immediately disengages openpilot like the vehicle cancel button.", + "section": "Controller Actions", + }, +) +CONTROLLER_ACTION_KEYS = {option["key"] for option in CONTROLLER_ACTION_OPTIONS} + + +def controller_speed_bounds(is_metric: bool) -> tuple[int, int]: + return (8, 145) if is_metric else (5, 90) diff --git a/starpilot/common/favorite_slots.py b/starpilot/common/favorite_slots.py index 59b05b99d4..17d706e8dd 100644 --- a/starpilot/common/favorite_slots.py +++ b/starpilot/common/favorite_slots.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools import json +import math from collections.abc import Callable, Iterable, Mapping from pathlib import Path from typing import Any @@ -14,6 +15,9 @@ from openpilot.starpilot.common.longitudinal_personality_profiles import ( ) +from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_OPTIONS, CONTROLLER_ACTION_KEYS, CONTROLLER_ACTION_SET_SPEED + + FAVORITE_SLOTS_PARAM = "StarPilotFavoriteSlots" FAVORITE_SLOT_COUNT = 3 FAVORITE_ACTION_PREFIX = "__starpilot_favorite_action__:" @@ -24,6 +28,7 @@ FAVORITE_ACTION_DECEL_COUNTER = "FavoriteVirtualDecelCruiseCounter" FAVORITE_ACTION_ACCEL_COUNTER = "FavoriteVirtualAccelCruiseCounter" FAVORITE_ACTION_TRAFFIC_MODE_COUNTER = "FavoriteTrafficModeCounter" FAVORITE_ACTION_OPTIONS = ( + *({**option, "action": "controller"} for option in CONTROLLER_ACTION_OPTIONS), { "key": FAVORITE_ACTION_DISTANCE_DECREASE, "label": "Distance - / SET", @@ -442,16 +447,27 @@ def normalize_favorite_slots(raw_slots: Any, params: Params | None = None, ): key = None - label = str(raw_slot.get("label") or FAVORITE_ACTION_LABELS.get(key, "")).strip() + label = str(raw_slot.get("label") or FAVORITE_ACTION_LABELS.get(key or "", "")).strip() if len(label) > 32: label = label[:32].rstrip() + speed_value = None + if key == CONTROLLER_ACTION_SET_SPEED: + try: + candidate = float(raw_slot.get("value")) + if math.isfinite(candidate) and candidate > 0: + speed_value = candidate + except (TypeError, ValueError): + pass + slots[idx] = { - "enabled": bool(raw_slot.get("enabled", False)), + "enabled": bool(raw_slot.get("enabled", False)) and (key != CONTROLLER_ACTION_SET_SPEED or speed_value is not None), "show_onroad": bool(raw_slot.get("show_onroad", False)), "key": key, "label": label if key else "", } + if key == CONTROLLER_ACTION_SET_SPEED: + slots[idx]["value"] = speed_value return slots @@ -478,7 +494,10 @@ def request_starpilot_toggle_refresh(params_memory: Params | None = None) -> Non params_memory.put_bool("StarPilotTogglesUpdated", True) -def trigger_favorite_action(key: str | None, params_memory: Params | None = None) -> bool: +def trigger_favorite_action(key: str | None, params_memory: Params | None = None, *, params: Params | None = None, value=None) -> bool: + if key in CONTROLLER_ACTION_KEYS: + from openpilot.starpilot.system.wheel_controls.wheel_controlsd import execute_controller_key + return execute_controller_key(key, params or Params(return_defaults=True), params_memory or Params(memory=True), value=value) if not is_favorite_action_key(key): return False @@ -493,7 +512,7 @@ def trigger_favorite_action(key: str | None, params_memory: Params | None = None def execute_favorite_key(key: str | None, params: Params | None = None, params_memory: Params | None = None, *, - eligible_keys: Iterable[str] | None = None) -> bool: + eligible_keys: Iterable[str] | None = None, value=None) -> bool: params = params or Params(return_defaults=True) eligible_keys = set(eligible_keys) if eligible_keys is not None else None if not favorite_key_is_valid(params, key, eligible_keys=eligible_keys): @@ -502,7 +521,7 @@ def execute_favorite_key(key: str | None, params: Params | None = None, params_m return False if is_favorite_action_key(key): - return trigger_favorite_action(key, params_memory) + return trigger_favorite_action(key, params_memory, params=params, value=value) if is_enum_param(key): return cycle_enum_parameter(key, params, params_memory) @@ -533,7 +552,7 @@ def toggle_favorite_slot(slot_index: int, params: Params | None = None, params_m key = slot.get("key") if not slot.get("enabled") or not key: return False - return execute_favorite_key(key, params, params_memory, eligible_keys=eligible_keys) + return execute_favorite_key(key, params, params_memory, eligible_keys=eligible_keys, value=slot.get("value")) def unassign_favorite_slot(slot_index: int, params: Params | None = None, params_memory: Params | None = None, *, diff --git a/starpilot/common/tests/test_shared_control_actions.py b/starpilot/common/tests/test_shared_control_actions.py new file mode 100644 index 0000000000..4e0ef4be4a --- /dev/null +++ b/starpilot/common/tests/test_shared_control_actions.py @@ -0,0 +1,43 @@ +import ast +from pathlib import Path + +import pytest +from openpilot.starpilot.common import favorite_slots as favorites +from openpilot.starpilot.common.controller_actions import CONTROLLER_ACTION_KEYS, CONTROLLER_ACTION_SET_SPEED +from openpilot.starpilot.common.tests.test_favorite_slots import FakeParams +from openpilot.starpilot.system.wheel_controls import wheel_controlsd as wheel + + +def test_favourites_and_bluetooth_have_identical_unique_options(): + options = favorites.build_favorite_slot_options(lambda _: True, alpha_longitudinal_available=True) + source = Path(__file__).parents[2] / 'system/the_galaxy/the_galaxy.py' + fn = next(n for n in ast.parse(source.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == '_get_available_controller_action_options') + env = {'_get_available_favorite_slot_options': lambda: options} + exec(compile(ast.Module(body=[fn], type_ignores=[]), str(source), 'exec'), env) + assert env[fn.name]() == options + keys = [o['key'] for o in options] + assert len(keys) == len(set(keys)) + assert CONTROLLER_ACTION_KEYS <= set(keys) + + +@pytest.mark.parametrize('key', sorted(CONTROLLER_ACTION_KEYS)) +def test_every_controller_action_works_through_native_favourite_dispatch(key, monkeypatch): + params, memory, calls = FakeParams(), FakeParams(), [] + slot = {'key': key, 'label': 'Assigned', 'enabled': True, 'show_onroad': True} + if key == CONTROLLER_ACTION_SET_SPEED: slot['value'] = 42 + params.put(favorites.FAVORITE_SLOTS_PARAM, [slot]) + monkeypatch.setattr(wheel, 'execute_controller_key', lambda k, p, m, **kw: calls.append((k, p, m, kw['value'])) or True) + assert favorites.toggle_favorite_slot(0, params, memory) + assert calls == [(key, params, memory, slot.get('value'))] + assert params.get(favorites.FAVORITE_SLOTS_PARAM) == [slot] + slot['enabled'] = False + assert not favorites.toggle_favorite_slot(0, params, memory) + assert len(calls) == 1 + + +@pytest.mark.parametrize('onroad,engaged,value,success', [(False,False,30,False),(True,False,30,False),(True,True,30,True),(True,True,500,False),(True,True,float('nan'),False)]) +def test_favourite_set_speed_keeps_controller_bounds_and_engagement_guard(onroad,engaged,value,success): + from openpilot.starpilot.system.wheel_controls.tests.test_wheel_controlsd import FakeParams as WheelParams + params, memory = WheelParams({'IsOnroad':onroad,'IsEngaged':engaged,'IsMetric':False}), WheelParams() + assert favorites.trigger_favorite_action(CONTROLLER_ACTION_SET_SPEED,memory,params=params,value=value) is success + assert bool(memory.values) is success diff --git a/starpilot/system/the_galaxy/assets/components/tools/controller_action_picker.css b/starpilot/system/the_galaxy/assets/components/tools/controller_action_picker.css new file mode 100644 index 0000000000..bed8cada29 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/controller_action_picker.css @@ -0,0 +1,84 @@ +.controllerActionDialog { + --picker-bg: var(--sidebar-bg, #191923); + --picker-field: var(--main-bg, #101018); + --picker-border: var(--sidebar-border-color, #454052); + --picker-radius: var(--border-radius-lg, 12px); + --picker-accent: #bda2ff; + box-sizing: border-box; + width: min(720px, calc(100% - 24px)); + max-width: calc(100% - 24px); + max-height: calc(100dvh - 24px); + padding: 20px; + border: 1px solid var(--picker-border); + border-radius: var(--picker-radius); + background: var(--picker-bg); + color: var(--text-color, #e8e8f0); + font: inherit; + overflow: hidden; +} +.controllerActionDialog[open] { display: flex; flex-direction: column; gap: 12px; } +.controllerActionDialog::backdrop { background: rgba(0, 0, 0, .65); backdrop-filter: blur(4px); } +.controllerActionDialog--dipper { + --picker-bg: var(--surface-container-high, #211b32); + --picker-field: var(--surface-container); + --picker-border: var(--outline-variant); + --picker-radius: var(--radius-lg); + --picker-accent: var(--primary); + color: var(--on-surface); +} +.controllerActionDialog header { display: flex; justify-content: space-between; align-items: center; gap: 12px; } +.controllerActionDialog h3, .controllerActionDialog p { margin: 0; } +.controllerActionDialog h3 { font-size: 1.15em; } +.controllerActionDialog [data-current] { overflow-wrap: anywhere; } +.controllerActionDialog label { display: grid; gap: 6px; font-size: .9em; } +.controllerActionDialog input, .controllerActionDialog select { + box-sizing: border-box; width: 100%; min-width: 0; min-height: 44px; + background: var(--picker-field); color: inherit; border: 1px solid var(--picker-border); + border-radius: var(--border-radius-md, var(--radius-sm, 8px)); padding: 10px; font: inherit; +} +/* Native option popups do not consistently inherit the select background. */ +.controllerActionDialog select { color-scheme: dark; } +.controllerActionDialog select option { + background-color: var(--picker-bg, #211b32); + color: var(--on-surface, var(--text-color, #e8e8f0)); +} +.controllerActionDialog button { cursor: pointer; font: inherit; min-height: 44px; } +.controllerActionDialog--classic button { + border: 1px solid var(--picker-border); border-radius: var(--border-radius-md, 8px); + background: var(--picker-field); color: inherit; padding: 10px 14px; +} +.controllerActionResults { min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 8px; } +.controllerActionDialog .controllerActionOption { + flex: none; display: flex; flex-direction: column; align-items: stretch; white-space: normal; + text-align: left; width: 100%; height: auto; gap: 5px; padding: 12px; overflow-wrap: anywhere; +} +.controllerActionOption small { font-size: .85em; opacity: .72; font-weight: normal; } +.controllerActionDialog--dipper .controllerActionOption { color: var(--on-surface); border-radius: var(--radius-sm); } +.controllerActionDialog .controllerActionOption[aria-pressed="true"] { border: 2px solid var(--picker-accent); } +.controllerActionDialog :focus-visible { outline: 2px solid var(--picker-accent); outline-offset: -2px; } +.controllerActionDialog button:disabled { cursor: not-allowed; opacity: .5; } +.controllerActionDialog [data-count] { font-size: .85em; opacity: .72; } +@media (max-width: 480px), (max-height: 500px) { + .controllerActionDialog { padding: 12px; } + .controllerActionDialog[open] { gap: 8px; } +} + +.controllerActionDialog .controllerActionCategory[hidden] { display: none; } +.controllerActionGroup { flex: none; border: 1px solid var(--picker-border); border-radius: var(--radius-sm, 8px); } +.controllerActionGroup summary { padding: 12px; cursor: pointer; min-height: 44px; box-sizing: border-box; overflow-wrap: anywhere; } +.controllerActionGroupItems { display: grid; gap: 8px; padding: 0 8px 8px; } + +.controllerActionDialog--dipper .controllerActionSubgroup { background: var(--surface-container, #211c31); } +.controllerActionDialog--dipper .controllerActionSubgroup > summary { font-size: .95em; } + +/* Clear parent/child hierarchy in both shared New Galaxy pickers. */ +.controllerActionDialog--dipper .controllerActionResults > .controllerActionGroup > summary { font-weight: 650; } +.controllerActionDialog--dipper .controllerActionGroupItems { padding-inline-start: 20px; } +.controllerActionDialog--dipper .controllerActionSubgroup { + border-inline-start: 3px solid color-mix(in srgb, var(--picker-accent) 48%, var(--picker-border)); + background: var(--surface-container-low, #191522); +} +.controllerActionDialog--dipper .controllerActionSubgroup > summary { font-weight: 450; color: var(--on-surface-variant); } +@media (max-width: 480px) { + .controllerActionDialog--dipper .controllerActionGroupItems { padding-inline-start: 14px; } +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/controller_action_picker.js b/starpilot/system/the_galaxy/assets/components/tools/controller_action_picker.js new file mode 100644 index 0000000000..bd04f3e2e5 --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/tools/controller_action_picker.js @@ -0,0 +1,267 @@ +import { longitudinalModeLayout, LONGITUDINAL_MODE_KEY } from "./longitudinal_mode.mjs" + +// Presentation-only: the caller owns eligibility, persistence and speed values. +const stylesheet = new URL("./controller_action_picker.css", import.meta.url).href + +// Use the same catalogue as both Toggles menus, not the API's alphabetic order. +const layoutUrl = "/assets/components/tools/device_settings_layout.json?v=settings-tier-1" + +function settingsAnchor(key) { + if (["ExperimentalMode", "ConditionalExperimental", "ConditionalChill"].includes(key)) return LONGITUDINAL_MODE_KEY + if (["__starpilot_controller_action__:cycle_driving_personality", "__starpilot_favorite_action__:toggle_traffic_mode"].includes(key)) return "CustomPersonalities" + return key +} + +function settingOwner(option, layout) { + const key = settingsAnchor(option.key || "") + const sections = longitudinalModeLayout(layout) + const section = sections.findLast(section => section.params?.some(param => param.key === key)) + return { key, section } +} + +export function actionCategory(option, layout = []) { + const { section } = settingOwner(option, layout) + if (section) return section.name + return option.section === "Actions" ? "Controller Actions" : String(option.section || "Other") +} + +export function actionHierarchy(option, layout = []) { + const { key, section } = settingOwner(option, layout) + if (!section) return [] + const byKey = new Map(section.params.map(param => [param.key, param])) + const own = byKey.get(key) + let node = own?.is_parent_toggle || key !== option.key ? own : byKey.get(own?.parent_key) + const path = [], seen = new Set() + while (node && !seen.has(node.key)) { + seen.add(node.key) + path.unshift({ key: node.key, label: node.label || node.key }) + node = byKey.get(node.parent_key) + } + return path +} + +export function actionGroups(options, layout = []) { + const root = { items: [], groups: [] } + for (const option of options) { + let node = root + for (const part of actionHierarchy(option, layout)) { + let child = node.groups.find(group => group.key === part.key) + if (!child) { child = { ...part, count: 0, items: [], groups: [] }; node.groups.push(child) } + child.count++ + node = child + } + node.items.push(option) + } + const order = new Map(longitudinalModeLayout(layout).flatMap(section => section.params).map((param, i) => [param.key, i])) + order.set(LONGITUDINAL_MODE_KEY, -1) + const sort = node => { + node.groups.sort((a, b) => (order.get(a.key) ?? Infinity) - (order.get(b.key) ?? Infinity)) + node.groups.forEach(sort) + } + sort(root) + return root +} + +export function actionCategories(options, layout = []) { + const available = new Set(options.map(option => actionCategory(option, layout))) + const ordered = layout.map(section => section.name).filter(name => available.has(name)) + return [...new Set([...ordered, ...available])] +} + +export function filterActions(options, query = "", category = "", layout = []) { + const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) + return options.filter(option => (!category || actionCategory(option, layout) === category) && + words.every(word => [option.label, option.key, option.description, option.picker_label, + option.picker_description, actionCategory(option, layout), ...actionHierarchy(option, layout).map(part => part.label)].join(" ").toLocaleLowerCase().includes(word))) +} + +// Native modal supplies focus containment, inert background and Escape handling. +// Read options/guard/selection live: polling may change availability while open. +export function openControllerActionPicker({ theme = "classic", index, trigger, getOptions, getSlot, isDisabled, onSelect, title = `Controller Action #${index + 1}`, slotAttribute = "data-controller-action-slot", noun = "actions" }) { + if (isDisabled()) return () => {} + if (!document.querySelector('link[data-controller-picker]')) { + const link = document.createElement("link") + link.rel = "stylesheet" + link.href = stylesheet + link.dataset.controllerPicker = "" + document.head.append(link) + } + const dialog = document.createElement("dialog") + dialog.className = `controllerActionDialog ${theme === "dipper" ? "controllerActionDialog--dipper" : "controllerActionDialog--classic"}` + dialog.setAttribute("aria-labelledby", "controllerActionTitle") + dialog.innerHTML = `

Controller Action #${index + 1}

+

+ + +

+
` + dialog.querySelector("h3").textContent = title + dialog.querySelector(".controllerActionSearch").firstChild.textContent = `Search all ${noun}` + const grouped = theme === "dipper" + const expanded = new Set() + if (grouped) dialog.querySelector(".controllerActionCategory").hidden = true + const input = dialog.querySelector("input") + const category = dialog.querySelector("select") + const results = dialog.querySelector(".controllerActionResults") + const current = dialog.querySelector("[data-current]") + const count = dialog.querySelector("[data-count]") + const buttonClass = theme === "dipper" ? "gx-btn gx-btn--tonal" : "" + dialog.querySelector("[data-cancel]").className = buttonClass + if (theme === "dipper") { + input.className = "gx-field" + category.className = "gx-field" + } + let signature = "" + let layout = [] + let closed = false + let timer + const fitViewport = () => { + // CSS zoom scales vh units too. Bound the modal in unzoomed layout pixels. + let zoom = 1 + for (let node = dialog; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1 + const viewport = window.visualViewport + dialog.style.maxHeight = `${(viewport?.height || window.innerHeight) / zoom - 24}px` + dialog.style.maxWidth = `${(viewport?.width || window.innerWidth) / zoom - 24}px` + } + const close = () => { + if (closed) return + closed = true + clearInterval(timer) + window.removeEventListener("resize", fitViewport) + window.visualViewport?.removeEventListener("resize", fitViewport) + dialog.close() + dialog.remove() + // Classic polling can replace the original card while the modal is open. + const target = trigger?.isConnected ? trigger : document.querySelector(`[${slotAttribute}="${index}"]`) + target?.focus({ preventScroll: true }) + } + dialog.addEventListener("cancel", event => { event.preventDefault(); close() }) + dialog.addEventListener("keydown", event => { + if (event.key !== "Tab") return + const controls = [...dialog.querySelectorAll("button:not(:disabled), input, select, summary")].filter(node => node.getClientRects().length) + const first = controls[0] + const last = controls[controls.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); first.focus() + } + }) + dialog.querySelector("[data-cancel]").addEventListener("click", close) + const render = () => { + const options = getOptions() + const slot = getSlot() || {} + const key = slot.key || "" + const disabled = isDisabled() + const next = JSON.stringify([options, key, slot.label, disabled, input.value, category.value]) + if (signature === next) return + signature = next + const categories = actionCategories(options, layout) + const selectedCategory = category.value + category.replaceChildren(new Option("All categories", ""), ...categories.map(name => new Option(name, name))) + category.value = categories.includes(selectedCategory) ? selectedCategory : "" + const selected = options.find(option => option.key === key) + current.textContent = `Current: ${selected?.label || slot.label || key || "Not configured"}${key && !selected ? " (unavailable)" : ""}` + const matches = filterActions(options, input.value, category.value, layout) + count.textContent = disabled ? "Selection is currently unavailable." : `${matches.length} ${matches.length === 1 ? noun.replace(/s$/, "") : noun}` + const focusedKey = results.contains(document.activeElement) ? document.activeElement.dataset.actionKey : null + results.replaceChildren() + const add = (option, categoryName, container = results) => { + const button = document.createElement("button") + button.type = "button" + button.className = `controllerActionOption ${buttonClass}` + button.dataset.actionKey = option.key + button.setAttribute("aria-pressed", String(option.key === key)) + button.disabled = disabled + const title = document.createElement("strong") + title.textContent = `${option.key === key ? "✓ " : ""}${option.label}` + const detail = document.createElement("small") + detail.textContent = [categoryName, option.description].filter(Boolean).join(" · ") + button.append(title, detail) + button.addEventListener("click", () => { + if (isDisabled() || (option.key && !getOptions().some(item => item.key === option.key))) { render(); return } + close() + onSelect(option.key) + }) + container.append(button) + if (focusedKey === option.key) button.focus({ preventScroll: true }) + } + // The original empty option remains available independently of filters. + add({ key: "", label: "Not configured", description: "Clear this assignment" }, "") + if (grouped) { + for (const name of categories) { + const items = matches.filter(option => actionCategory(option, layout) === name) + if (!items.length) continue + const group = document.createElement("details") + group.className = "controllerActionGroup" + group.open = !!input.value.trim() || expanded.has(name) + const summary = document.createElement("summary") + summary.textContent = `${name} (${items.length})` + const body = document.createElement("div") + body.className = "controllerActionGroupItems" + group.append(summary, body) + group.addEventListener("toggle", () => { + if (!input.value.trim()) group.open ? expanded.add(name) : expanded.delete(name) + }) + results.append(group) + const populate = (node, container, path) => { + for (const option of node.items) add(option, path.join(" · "), container) + for (const child of node.groups) { + const id = [...path, child.key].join("/") + const subgroup = document.createElement("details") + subgroup.className = "controllerActionGroup controllerActionSubgroup" + subgroup.open = !!input.value.trim() || expanded.has(id) + const heading = document.createElement("summary") + heading.textContent = `${child.label} (${child.count})` + const content = document.createElement("div") + content.className = "controllerActionGroupItems" + subgroup.append(heading, content) + subgroup.addEventListener("toggle", () => { + if (!input.value.trim()) subgroup.open ? expanded.add(id) : expanded.delete(id) + }) + container.append(subgroup) + populate(child, content, [...path, child.label]) + } + } + populate(actionGroups(items, layout), body, [name]) + } + } else { + for (const option of matches) add(option, actionCategory(option, layout)) + } + if (!matches.length) { + const empty = document.createElement("p") + empty.textContent = "No matching actions. Try another search or category." + results.append(empty) + } + } + input.addEventListener("input", () => { + // Search always covers the complete eligible catalogue, not the last category. + category.value = "" + render() + results.scrollTop = 0 + }) + category.addEventListener("change", () => { render(); results.scrollTop = 0 }) + document.body.append(dialog) + fitViewport() + window.addEventListener("resize", fitViewport) + window.visualViewport?.addEventListener("resize", fitViewport) + render() + dialog.showModal() + input.focus() + // A missing layout must never hide eligible actions or block assignment. + // Retry on the next open; don't cache a transient failure or stale catalogue. + fetch(layoutUrl, { cache: "no-store" }).then(response => { + if (!response.ok) throw new Error("Settings catalogue unavailable") + return response.json() + }).then(data => { + if (closed || !Array.isArray(data)) return + layout = data.filter(section => section && typeof section.name === "string" && Array.isArray(section.params)) + signature = "" + render() + }).catch(() => {}) + timer = setInterval(() => { + if (!document.querySelector(`[${slotAttribute}="${index}"]`)) close() + else render() + }, 250) + return close +} diff --git a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js index 984c1c5375..25028ca7fe 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/device_settings.js +++ b/starpilot/system/the_galaxy/assets/components/tools/device_settings.js @@ -1,4 +1,5 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" +import { api } from "/assets/mobile/js/api.js" import { formatProfileSpeed, personalityProfileParamKey, @@ -765,6 +766,7 @@ function normalizeFavoriteSlots(slots) { if (!slot || typeof slot !== "object") return const key = slot.key ? String(slot.key) : null normalized[index] = { + ...slot, enabled: !!slot.enabled, show_onroad: !!slot.show_onroad, key, @@ -803,7 +805,9 @@ function isFavoriteActionOption(option) { function filteredFavoriteOptions(index) { const filter = state.favoriteFilters[index] || "" - return normalizeFavoriteOptions(state.favoriteOptions).filter(opt => favoriteOptionMatchesFilter(opt, filter)) + const selectedKey = state.favoriteSlots[index]?.key + return normalizeFavoriteOptions(state.favoriteOptions).filter(opt => + (opt.value_type !== "speed" || opt.key === selectedKey) && favoriteOptionMatchesFilter(opt, filter)) } function populateFavoriteSelect(index, selectEl = null) { @@ -1005,7 +1009,14 @@ async function saveFavoriteSlots(slots) { function updateFavoriteSlot(index, patch) { const slots = normalizeFavoriteSlots(state.favoriteSlots) const current = slots[index] || defaultFavoriteSlots()[0] + const option = state.favoriteOptions.find(opt => opt.key === patch.key) + if (patch.key !== current.key && option?.value_type === "speed") { + showParamSnackbar("Configure Set Speed favorites in New Galaxy.", "error") + scheduleSyncInputs() + return + } const nextSlot = { ...current, ...patch } + if (patch.key !== undefined && patch.key !== current.key) delete nextSlot.value if (!nextSlot.key) { nextSlot.label = "" @@ -1075,22 +1086,12 @@ async function updateFavoriteValue(key, checked, sourceEl = null) { } } -async function activateFavoriteAction(key) { +async function activateFavoriteAction(key, value) { try { - const res = await fetch("/api/favorites/action", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), - }) - const data = await res.json() - - if (res.ok) { - showParamSnackbar(data.message || "Favorite action sent.") - } else { - showParamSnackbar(data.error || "Failed to send favorite action", "error") - } - } catch (e) { - showParamSnackbar("Network error — is the device reachable?", "error") + const data = await api.activateFavoriteAction(key, value) + showParamSnackbar(data.message || "Favorite action sent.") + } catch (error) { + showParamSnackbar(error.message || "Failed to send favorite action", "error") } } @@ -2339,6 +2340,7 @@ function renderFavoriteSlotsPanel() { return html`
+
Configure Set Speed favorites in New Galaxy. Saved speeds use the current mph/km/h setting.
${quickFavorites.length ? html`
${quickFavorites.map(favorite => { @@ -2349,7 +2351,7 @@ function renderFavoriteSlotsPanel() { const quickCopy = html`
Favorite #${favorite.index + 1} - ${selectedOption.label || favorite.slot.label || selectedKey} + ${selectedOption.label || favorite.slot.label || selectedKey}${selectedOption.value_type === "speed" ? ` ${favorite.slot.value} ${vehicleSpeedUnit(state.values)}` : ""} ${selectedOption.section ? html`${selectedOption.section}` : ""} ${selectedOption.description ? html`${selectedOption.description}` : ""}
@@ -2360,7 +2362,7 @@ function renderFavoriteSlotsPanel() { @@ -2386,14 +2388,14 @@ function renderFavoriteSlotsPanel() { const selectedOption = optionByKey.get(slot.key) const selectedKey = slot.key || "" const favoriteFilter = state.favoriteFilters[index] || "" - const filteredOptions = options.filter(opt => favoriteOptionMatchesFilter(opt, favoriteFilter)) + const filteredOptions = filteredFavoriteOptions(index) return html`
Favorite #${index + 1}
-
${selectedOption?.section || "No toggle selected"}
+
${selectedOption?.section || "No toggle selected"}${selectedOption?.value_type === "speed" ? ` · ${slot.value} ${vehicleSpeedUnit(state.values)}` : ""}