"""Offline two-profile StarPilot presentation and request-only input boundary. The host supplies immutable snapshots, including the explicit onroad mode. This module owns no Params, SubMaster, camera IPC, updater, or device operation. """ from collections.abc import Callable from contextlib import ExitStack from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path import pyray as rl from openpilot.starpilot.ui.device import DeviceView from openpilot.starpilot.ui.feature_settings import FeatureSettingsView from openpilot.starpilot.ui.feature_settings_state import FeatureInput, FeatureSettingsState, FeatureUiAction from openpilot.starpilot.ui.device_state import DeviceAction, DeviceInput, DeviceState from openpilot.starpilot.ui.home import HomeView from openpilot.starpilot.ui.home_state import HomeAction, HomeInput, HomeState from openpilot.starpilot.ui.onroad import CameraLayer, OnroadView, OverlayLayer, PipLayer from openpilot.starpilot.ui.onroad_state import OnroadInput, OnroadRequest, OnroadState, SlcUiRequest from openpilot.starpilot.ui.presentation import BitmapFonts, Profile from openpilot.starpilot.ui.settings import SettingsView from openpilot.starpilot.ui.settings_state import Destination, SettingsAction, SettingsInput, SettingsState from openpilot.starpilot.ui.software import SoftwareView from openpilot.starpilot.ui.software_state import SoftwareAction, SoftwareInput, SoftwareState from openpilot.starpilot.ui.toggles import TogglesView from openpilot.starpilot.ui.toggles_state import ToggleRequest, TogglesInput, TogglesState class ShellMode(StrEnum): HOME = "home" SETTINGS = "settings" ONROAD = "onroad" @dataclass(frozen=True) class ShellSnapshot: mode: ShellMode home: HomeState settings: SettingsState onroad: OnroadState device: DeviceState = field(default_factory=DeviceState) software: SoftwareState = field(default_factory=SoftwareState) toggles: TogglesState = field(default_factory=TogglesState) selected: Destination = Destination.STAR features: FeatureSettingsState = field(default_factory=FeatureSettingsState) sounds: FeatureSettingsState = field(default_factory=FeatureSettingsState) appearance: FeatureSettingsState = field(default_factory=FeatureSettingsState) display: FeatureSettingsState = field(default_factory=FeatureSettingsState) models: FeatureSettingsState = field(default_factory=FeatureSettingsState) @dataclass(frozen=True) class ShellRequest: source: str action: HomeAction | SettingsAction | DeviceAction | SoftwareAction | ToggleRequest | OnroadRequest | SlcUiRequest | FeatureUiAction class ShellView: """The established profile views composed over one supplied shell snapshot.""" def __init__(self, fonts: BitmapFonts, asset_directory: Path, *, camera_layer: CameraLayer | None = None, extra_overlays: OverlayLayer | None = None, pip_layer: PipLayer | None = None): self.profile = fonts.profile with ExitStack() as acquired: self.home = HomeView(fonts, asset_directory) acquired.callback(self.home.close) self.settings = SettingsView(fonts, asset_directory) acquired.callback(self.settings.close) self.onroad = OnroadView(fonts, asset_directory, camera_layer=camera_layer, extra_overlays=extra_overlays, pip_layer=pip_layer) acquired.callback(self.onroad.close) self.device = DeviceView(fonts) if self.profile == Profile.LARGE else None self.software = SoftwareView(fonts) if self.profile == Profile.LARGE else None self.toggles = TogglesView(fonts, asset_directory) if self.profile == Profile.LARGE else None self.features = FeatureSettingsView(fonts) if self.profile == Profile.LARGE else None self.sounds = FeatureSettingsView(fonts) if self.profile == Profile.LARGE else None self.appearance = FeatureSettingsView(fonts) if self.profile == Profile.LARGE else None self.display = FeatureSettingsView(fonts) if self.profile == Profile.LARGE else None self.models = FeatureSettingsView(fonts) if self.profile == Profile.LARGE else None if self.toggles is not None: acquired.callback(self.toggles.close) self.settings.prepare() acquired.pop_all() def render(self, snapshot: ShellSnapshot, drag_x: float = 0.0) -> None: if snapshot.mode == ShellMode.HOME: self.home.render(snapshot.home) elif snapshot.mode == ShellMode.ONROAD: self.onroad.render(snapshot.onroad) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DEVICE: self.device.render(snapshot.device) self.settings.render_rail(snapshot.settings, selected=Destination.DEVICE) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOFTWARE: self.software.render(snapshot.software) self.settings.render_rail(snapshot.settings, selected=Destination.SOFTWARE) elif self.profile == Profile.LARGE and snapshot.selected == Destination.TOGGLES: self.toggles.render(snapshot.toggles) self.settings.render_rail(snapshot.settings, selected=Destination.TOGGLES) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_CONTROLS: self.features.render(snapshot.features, drag_x) self.settings.render_rail(snapshot.settings, selected=Destination.STAR) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOUNDS: self.sounds.render(snapshot.sounds, drag_x) self.settings.render_rail(snapshot.settings, selected=Destination.STAR) elif self.profile == Profile.LARGE and snapshot.selected == Destination.APPEARANCE: self.appearance.render(snapshot.appearance, drag_x) self.settings.render_rail(snapshot.settings, selected=Destination.STAR) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SYSTEM: self.display.render(snapshot.display, drag_x) self.settings.render_rail(snapshot.settings, selected=Destination.STAR) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_MODEL: self.models.render(snapshot.models, drag_x) self.settings.render_rail(snapshot.settings, selected=Destination.STAR) elif self.profile == Profile.LARGE and snapshot.selected == Destination.NETWORK: rail_width = 500 if snapshot.settings.sidebar_expanded else 0 rl.draw_rectangle_rounded(rl.Rectangle(rail_width + 10, 10, 2140 - rail_width, 1060), 0.04, 30, rl.Color(41, 41, 41, 255)) self.settings.render_rail(snapshot.settings, selected=Destination.NETWORK) else: self.settings.render(snapshot.settings) def close(self) -> None: if self.toggles is not None: self.toggles.close() self.home.close() self.settings.close() self.onroad.close() class ShellInput: """One pointer, one pane: transitions cancel pending presses before reuse.""" def __init__(self, profile: Profile, emit: Callable[[ShellRequest], None], *, native_panels: tuple[Destination, ...] = ()): self.profile = profile self.emit = emit self.native_panels = native_panels self._pane: tuple[ShellMode, Destination] | None = None self._selected = Destination.STAR self._settings_emitted = False self.home = HomeInput(profile, lambda action: emit(ShellRequest("home", action))) self.settings = SettingsInput(profile, self._settings_action, lambda: self._selected) self.device = DeviceInput(lambda action: emit(ShellRequest("device", action))) self.software = SoftwareInput(lambda action: emit(ShellRequest("software", action))) self.toggles = TogglesInput(lambda action: emit(ShellRequest("toggles", action))) self.features = FeatureInput(lambda action: emit(ShellRequest("features", action))) self.sounds = FeatureInput(lambda action: emit(ShellRequest("sounds", action))) self.appearance = FeatureInput(lambda action: emit(ShellRequest("appearance", action))) self.display = FeatureInput(lambda action: emit(ShellRequest("display", action))) self.models = FeatureInput(lambda action: emit(ShellRequest("models", action))) self.onroad = OnroadInput(lambda action: emit(ShellRequest("slc" if isinstance(action, SlcUiRequest) else "onroad", action)), profile) def _settings_action(self, action: SettingsAction) -> None: self._settings_emitted = True self.emit(ShellRequest("settings", action)) def cancel(self, *, close_prompt: bool = True) -> None: self.home.cancel() self.settings.cancel() self.device.cancel() self.software.cancel() self.toggles.cancel() self.features.cancel() self.sounds.cancel() self.appearance.cancel() self.display.cancel() self.models.cancel() self.onroad.cancel(close_prompt=close_prompt) self._pane = None def _sync(self, snapshot: ShellSnapshot) -> None: pane = (snapshot.mode, snapshot.selected) if self._pane is not None and self._pane != pane: self.cancel() self._selected = snapshot.selected def press(self, x: float, y: float, now: float, snapshot: ShellSnapshot) -> None: self._sync(snapshot) self._pane = (snapshot.mode, snapshot.selected) if snapshot.mode == ShellMode.HOME: self.home.press(x, y, now) elif snapshot.mode == ShellMode.ONROAD: self.onroad.press(x, y, snapshot.onroad) else: self.settings.press(x, y, snapshot.settings) if snapshot.selected in self.native_panels: return if self.profile == Profile.LARGE and snapshot.selected == Destination.DEVICE: self.device.press(x, y, snapshot.device) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOFTWARE: self.software.press(x, y, snapshot.software) elif self.profile == Profile.LARGE and snapshot.selected == Destination.TOGGLES: self.toggles.press(x, y, snapshot.toggles) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_CONTROLS: self.features.press(x, y, snapshot.features) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOUNDS: self.sounds.press(x, y, snapshot.sounds) elif self.profile == Profile.LARGE and snapshot.selected == Destination.APPEARANCE: self.appearance.press(x, y, snapshot.appearance) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SYSTEM: self.display.press(x, y, snapshot.display) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_MODEL: self.models.press(x, y, snapshot.models) def move(self, x: float, y: float, now: float | None, snapshot: ShellSnapshot) -> None: self._sync(snapshot) if self._pane is None: return if snapshot.mode == ShellMode.HOME: if now is None: self.home.cancel() return self.home.move(x, y, now) self.home.tick(now, snapshot.home) elif snapshot.mode == ShellMode.ONROAD: self.onroad.move(x, y, snapshot.onroad) else: self.settings.move(x, y, snapshot.settings) if snapshot.selected in self.native_panels: return if self.profile == Profile.LARGE and snapshot.selected == Destination.DEVICE: self.device.move(x, y, snapshot.device) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOFTWARE: self.software.move(x, y, snapshot.software) elif self.profile == Profile.LARGE and snapshot.selected == Destination.TOGGLES: self.toggles.move(x, y, snapshot.toggles) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_CONTROLS: self.features.move(x, y, snapshot.features, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOUNDS: self.sounds.move(x, y, snapshot.sounds, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.APPEARANCE: self.appearance.move(x, y, snapshot.appearance, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SYSTEM: self.display.move(x, y, snapshot.display, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_MODEL: self.models.move(x, y, snapshot.models, now) def release(self, x: float, y: float, now: float | None, snapshot: ShellSnapshot) -> None: self._sync(snapshot) if self._pane is None: return if snapshot.mode == ShellMode.HOME: if now is None: self.home.cancel() return self.home.release(x, y, now, snapshot.home) elif snapshot.mode == ShellMode.ONROAD: self.onroad.release(x, y, snapshot.onroad) else: # Navigation may synchronously change the selected pane in the host; # the press that caused it must never also activate a leaf control. self._settings_emitted = False self.settings.release(x, y, snapshot.settings) if (not self._settings_emitted and self._pane == (snapshot.mode, snapshot.selected) and snapshot.selected not in self.native_panels): if self.profile == Profile.LARGE and snapshot.selected == Destination.DEVICE: self.device.release(x, y, snapshot.device) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOFTWARE: self.software.release(x, y, snapshot.software) elif self.profile == Profile.LARGE and snapshot.selected == Destination.TOGGLES: self.toggles.release(x, y, snapshot.toggles) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_CONTROLS: self.features.release(x, y, snapshot.features, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SOUNDS: self.sounds.release(x, y, snapshot.sounds, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.APPEARANCE: self.appearance.release(x, y, snapshot.appearance, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.SYSTEM: self.display.release(x, y, snapshot.display, now) elif self.profile == Profile.LARGE and snapshot.selected == Destination.DRIVING_MODEL: self.models.release(x, y, snapshot.models, now) self.cancel(close_prompt=snapshot.mode != ShellMode.ONROAD)