Files
2026-10-19 01:28:28 -04:00

244 lines
11 KiB
Python

"""Immutable Settings entry data and narrow, explicit preview navigation."""
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from enum import StrEnum
from openpilot.starpilot.ui.device_state import DeviceAction, DeviceState
from openpilot.starpilot.ui.feature_settings_state import FEATURE_BACK_WIDTH, FEATURE_HEADER_HEIGHT
from openpilot.starpilot.ui.home_state import HomeAction, HomeActionKind
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.software_state import SoftwareAction, SoftwareState
from openpilot.starpilot.ui.toggles_state import ToggleRequest, TogglesState
class Destination(StrEnum):
STAR = "star"
DEVICE = "device"
NETWORK = "network"
BLUETOOTH = "bluetooth"
TOGGLES = "toggles"
SOFTWARE = "software"
DEVELOPER = "developer"
SOUNDS = "sounds"
DRIVING_MODEL = "driving_model"
DRIVING_CONTROLS = "driving_controls"
SYSTEM = "system"
APPEARANCE = "appearance"
VEHICLE = "vehicle"
FIREHOSE = "firehose"
FORCE_DRIVE = "force_drive"
GALAXY = "galaxy"
PAIR = "pair"
@dataclass(frozen=True)
class DestinationAvailability:
destination: Destination
available: bool = False
reason: str = "This destination is not implemented in the development preview."
request_value: str = ""
request_revision: str | None = None
RAIL = ((Destination.STAR, "StarPilot"), (Destination.DEVICE, "Device"), (Destination.NETWORK, "Network"),
(Destination.BLUETOOTH, "Bluetooth"), (Destination.TOGGLES, "Toggles"), (Destination.SOFTWARE, "Software"),
(Destination.DEVELOPER, "Developer"))
TILES = ((Destination.SOUNDS, "Sounds & Alerts", "sound"), (Destination.DRIVING_MODEL, "Driving Model", "aicar"),
(Destination.DRIVING_CONTROLS, "Driving Controls", "steering"), (Destination.SYSTEM, "System", "system"),
(Destination.APPEARANCE, "Appearance", "display"), (Destination.VEHICLE, "Vehicle Settings", "vehicle"))
COMPACT_MENU = (
(Destination.TOGGLES, "Toggles", "icons_mici/settings.png", 64, 64),
(Destination.NETWORK, "Network", "icons_mici/settings/network/wifi_strength_full.png", 76, 56),
(Destination.BLUETOOTH, "Bluetooth", "icons_mici/settings/bluetooth.png", 64, 64),
(Destination.FORCE_DRIVE, "Force Drive State", None, 0, 0),
(Destination.VEHICLE, "Vehicle", "icons_mici/settings/vehicle.png", 64, 57),
(Destination.DEVICE, "Device", "icons_mici/settings/device_icon.png", 72, 58),
(Destination.SOFTWARE, "Software", "icons_mici/settings/device/update.png", 64, 75),
(Destination.DRIVING_MODEL, "Driving Model", "icons_mici/settings/device/lkas.png", 72, 56),
(Destination.DRIVING_CONTROLS, "Driving Controls", "icons_mici/settings/device/lkas.png", 72, 56),
(Destination.APPEARANCE, "Visuals", "icons_mici/settings/device/cameras.png", 64, 64),
(Destination.GALAXY, "Galaxy", "icons_mici/settings/galaxy.png", 64, 64),
(Destination.PAIR, "Pair to Connect", "icons_mici/settings/comma_icon.png", 33, 60),
(Destination.DEVELOPER, "Developer", "icons_mici/settings/developer_icon.png", 64, 60),
)
@dataclass(frozen=True)
class SettingsState:
sidebar_expanded: bool = True
# Compact rendering accepts a static position snapshot. It does not reproduce
# the original scroller, entry bounce or dismissal animation.
compact_y: float = 0.0
compact_scroll_x: float = 0.0
paired: bool = False
nav_bar_y: float = 6.0
nav_bar_alpha: float = 1.0
force_drive_label: str = "force drive state"
availability: tuple[DestinationAvailability, ...] = field(default_factory=lambda: tuple(DestinationAvailability(d) for d in Destination))
def destination(self, destination: Destination) -> DestinationAvailability:
return next(item for item in self.availability if item.destination == destination)
def compact_menu(state: SettingsState):
return tuple((item[0], state.force_drive_label, *item[2:]) if item[0] == Destination.FORCE_DRIVE else item
for item in COMPACT_MENU if not (state.paired and item[0] == Destination.PAIR))
class SettingsActionKind(StrEnum):
CLOSE = "close"
TOGGLE_SIDEBAR = "toggle_sidebar"
REQUEST_DESTINATION = "request_destination"
@dataclass(frozen=True)
class SettingsAction:
kind: SettingsActionKind
destination: DestinationAvailability | None = None
def tile_rects(state: SettingsState) -> tuple[tuple[float, float, float, float], ...]:
x, width = (520.0, 1620.0) if state.sidebar_expanded else (20.0, 2120.0)
tile_width = (width - 32) / 3
return tuple((round(x + column * (tile_width + 16)), 112 + row * 477, round(tile_width), 461)
for row in range(2) for column in range(3))
class SettingsInput:
"""Single-pointer static entry hit testing; drags cancel and never scroll.
Compact back and motion belong to the eventual application adapter. The
development router exposes a separate Back control, never an invented swipe.
"""
def __init__(self, profile: Profile, emit: Callable[[SettingsAction], None],
selected: Callable[[], Destination] | None = None):
self.profile, self.emit = profile, emit
self.selected = selected or (lambda: Destination.STAR)
self._pressed: tuple[float, float, str] | None = None
def _target(self, x: float, y: float, state: SettingsState) -> str | None:
def inside(rect):
rx, ry, width, height = rect
return rx <= x <= rx + width and ry <= y <= ry + height
if not (0 <= x < self.profile.size[0] and 0 <= y < self.profile.size[1]):
return None
if self.profile == Profile.COMPACT:
for index, (destination, _, _, _, _) in enumerate(compact_menu(state)):
if inside((20 + 422 * index + state.compact_scroll_x, state.compact_y + 30, 402, 180)):
return destination
return None
if inside((0, 511, 40, 140)):
return "collapse"
if state.sidebar_expanded:
if inside((150, 60, 200, 200)):
return "close"
for index, (destination, _) in enumerate(RAIL):
if inside((50, 300 + index * 110, 350, 110)):
return destination
if self.selected() == Destination.STAR:
if (not state.sidebar_expanded and 20 <= x < 20 + FEATURE_BACK_WIDTH and
12 <= y <= 12 + FEATURE_HEADER_HEIGHT):
return "close"
for (destination, _, _), rect in zip(TILES, tile_rects(state), strict=True):
if inside(rect):
return destination
return None
def press(self, x: float, y: float, state: SettingsState) -> None:
target = self._target(x, y, state)
self._pressed = (x, y, target) if target is not None else None
def move(self, x: float, y: float, state: SettingsState) -> None:
if self._pressed is not None:
px, py, target = self._pressed
if ((self.profile == Profile.COMPACT and (abs(x - px) > 5 or abs(y - py) > 5)) or
self._target(x, y, state) != target):
self.cancel()
def release(self, x: float, y: float, state: SettingsState) -> None:
self.move(x, y, state)
if self._pressed is None:
return
_, _, target = self._pressed
self.cancel()
if target == "close":
self.emit(SettingsAction(SettingsActionKind.CLOSE))
elif target == "collapse":
self.emit(SettingsAction(SettingsActionKind.TOGGLE_SIDEBAR))
elif isinstance(target, Destination):
self.emit(SettingsAction(SettingsActionKind.REQUEST_DESTINATION, state.destination(Destination(target))))
def cancel(self) -> None:
self._pressed = None
class PreviewRouter:
"""Development-only Home/Settings/large leaf routing and inert requests."""
def __init__(self, profile: Profile = Profile.LARGE):
self.profile = profile
self.in_settings = False
self.settings = SettingsState()
if profile == Profile.LARGE:
self.settings = replace(self.settings, availability=tuple(
replace(item, available=True, reason="Available in the large development preview.")
if item.destination in (Destination.DEVICE, Destination.SOFTWARE, Destination.TOGGLES) else item for item in self.settings.availability))
self.selected = Destination.STAR
self.device = DeviceState()
self.software = SoftwareState()
self.toggles = TogglesState()
self.notice = "Development preview — large Device, Toggles and Software navigation is available; other leaves and compact scrolling are unavailable."
self.requests: list[SettingsAction] = []
self.device_requests: list[DeviceAction] = []
self.software_requests: list[SoftwareAction] = []
self.toggle_requests: list[ToggleRequest] = []
def home_action(self, action: HomeAction) -> None:
if action.kind == HomeActionKind.OPEN_SETTINGS:
self.in_settings = True
self.selected = Destination.STAR
else:
self.notice = "Development preview — this Home action has no connected backend."
def settings_action(self, action: SettingsAction) -> None:
if action.kind == SettingsActionKind.CLOSE:
if self.selected in (Destination.DEVICE, Destination.SOFTWARE, Destination.TOGGLES):
self.selected = Destination.STAR
else:
self.in_settings = False
elif action.kind == SettingsActionKind.TOGGLE_SIDEBAR:
self.settings = replace(self.settings, sidebar_expanded=not self.settings.sidebar_expanded)
else:
if action.destination is not None:
destination = action.destination.destination
if destination == Destination.STAR:
self.selected = Destination.STAR
elif destination == Destination.DEVICE and self.profile == Profile.LARGE:
self.selected = Destination.DEVICE
elif destination == Destination.SOFTWARE and self.profile == Profile.LARGE:
self.selected = Destination.SOFTWARE
elif destination == Destination.TOGGLES and self.profile == Profile.LARGE:
self.selected = Destination.TOGGLES
else:
self.requests.append(action)
self.notice = f"Preview request: {destination}. {action.destination.reason}"
def device_action(self, action: DeviceAction) -> None:
if self.in_settings and self.selected == Destination.DEVICE:
self.device_requests.append(action)
self.notice = f"Preview request: {action.request}. No device operation is connected."
def software_action(self, action: SoftwareAction) -> None:
if self.profile == Profile.LARGE and self.in_settings and self.selected == Destination.SOFTWARE:
self.software_requests.append(action)
unavailable = "No update, install, uninstall, configuration change or file read is connected."
self.notice = f"Preview request: {action.request}. {unavailable}"
def toggle_action(self, action: ToggleRequest) -> None:
if self.profile == Profile.LARGE and self.in_settings and self.selected == Destination.TOGGLES:
self.toggle_requests.append(action)
self.notice = f"Preview request: {action}. No setting change is connected."