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}