IQ.Pilot Release Commit @ bec7652
This commit is contained in:
470
iqpilot/selfdrive/ui/layouts/main.py
Normal file
470
iqpilot/selfdrive/ui/layouts/main.py
Normal file
@@ -0,0 +1,470 @@
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, IntEnum, auto
|
||||
import iqpilot.cereal.messaging as messaging
|
||||
from iqpilot.system.ui.lib.application import gui_app, MouseEvent
|
||||
from iqpilot.selfdrive.ui.layouts.sidebar import Sidebar, SIDEBAR_WIDTH
|
||||
from iqpilot.selfdrive.ui.layouts.home import HomeLayout
|
||||
from iqpilot.selfdrive.ui.layouts.stats import StatsLayout
|
||||
from iqpilot.selfdrive.ui.layouts.nav import NavLayout
|
||||
from iqpilot.selfdrive.ui.layouts.routes import RoutesLayout
|
||||
from iqpilot.selfdrive.ui.layouts.video_player import VideoPlayerLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings_hub import SettingsHubLayout
|
||||
from iqpilot.selfdrive.ui.layouts.settings.settings import PanelType
|
||||
from iqpilot.selfdrive.ui.onroad.augmented_road_view import AugmentedRoadView
|
||||
from iqpilot.selfdrive.ui.ui_state import device, ui_state
|
||||
from iqpilot.system.ui.widgets import Widget
|
||||
from iqpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow
|
||||
|
||||
|
||||
class MainState(IntEnum):
|
||||
HOME = 0
|
||||
SETTINGS = 1
|
||||
ONROAD = 2
|
||||
STATS = 3
|
||||
NAV = 4
|
||||
ROUTES = 5
|
||||
VIDEO = 6
|
||||
|
||||
|
||||
OFFROAD_TRANSITION_SECONDS = 0.30
|
||||
TRANSITION_SURFACE_OVERSCAN = 4
|
||||
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
|
||||
|
||||
# iOS-style swipe-from-the-left-edge-to-go-back, generalized across the whole offroad UI (settings
|
||||
# hub still owns its own panel->grid swipe; this covers the top-level pages).
|
||||
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back must start within
|
||||
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to a swipe
|
||||
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (under arm distance) that cancels it
|
||||
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of width dragged to complete the pop on release
|
||||
SWIPE_SETTLE_SECONDS = 0.16 # animate from the release point to done/cancelled (no snap)
|
||||
|
||||
|
||||
class MenuTransitionState(Enum):
|
||||
IDLE = auto()
|
||||
PUSHING = auto()
|
||||
POPPING = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuTransition:
|
||||
state: MenuTransitionState = MenuTransitionState.IDLE
|
||||
t: float = 0.0
|
||||
duration: float = OFFROAD_TRANSITION_SECONDS
|
||||
from_screen: object | None = None
|
||||
to_screen: object | None = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.state != MenuTransitionState.IDLE
|
||||
|
||||
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def _lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def _ease_out_cubic(x: float) -> float:
|
||||
x = _clamp(x)
|
||||
return 1.0 - pow(1.0 - x, 3.0)
|
||||
|
||||
|
||||
def _ease_emphasized(x: float) -> float:
|
||||
# ease-in-out-cubic: gentle acceleration into the slide, graceful deceleration into place
|
||||
x = _clamp(x)
|
||||
if x < 0.5:
|
||||
return 4.0 * x * x * x
|
||||
return 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0
|
||||
|
||||
|
||||
# Depth cues for the page push/pop (iOS-style): the underneath page parallaxes a fraction of the
|
||||
# way, dims into the background, and the top card casts a soft shadow off its leading edge.
|
||||
TRANSITION_PARALLAX = 0.28
|
||||
TRANSITION_MAX_DIM = 0.5
|
||||
TRANSITION_SHADOW_W = 32
|
||||
|
||||
|
||||
class MainLayout(Widget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self._pm = messaging.PubMaster(['bookmarkButton'])
|
||||
|
||||
self._sidebar = Sidebar()
|
||||
# The offroad launcher owns the full screen; the sidebar is reserved for onroad.
|
||||
self._sidebar.set_visible(False)
|
||||
self._current_mode = MainState.HOME
|
||||
self._prev_onroad = False
|
||||
|
||||
# Initialize layouts
|
||||
self._layouts = {
|
||||
MainState.HOME: HomeLayout(),
|
||||
MainState.SETTINGS: SettingsHubLayout(),
|
||||
MainState.ONROAD: AugmentedRoadView(),
|
||||
MainState.STATS: StatsLayout(),
|
||||
MainState.NAV: NavLayout(),
|
||||
MainState.ROUTES: RoutesLayout(),
|
||||
MainState.VIDEO: VideoPlayerLayout(),
|
||||
}
|
||||
|
||||
self._sidebar_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._content_rect = rl.Rectangle(0, 0, 0, 0)
|
||||
self._transition = MenuTransition()
|
||||
|
||||
# Edge-swipe-back drag state
|
||||
self._swipe_start_pos = None
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
# Release-settle animation (glide to done/cancelled instead of snapping)
|
||||
self._swipe_settling = False
|
||||
self._swipe_settle_from = 0.0
|
||||
self._swipe_settle_to = 0.0
|
||||
self._swipe_settle_t = 0.0
|
||||
self._swipe_render_target: MainState | None = None
|
||||
self._swipe_completing = False
|
||||
# Set callbacks
|
||||
self._setup_callbacks()
|
||||
|
||||
self._onboarding_window = OnboardingWindow()
|
||||
if not self._onboarding_window.completed:
|
||||
gui_app.set_modal_overlay(self._onboarding_window)
|
||||
|
||||
def _render(self, _):
|
||||
self._handle_onroad_transition()
|
||||
self._render_main_content()
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self._sidebar.set_callbacks(on_settings=self._on_settings_clicked,
|
||||
on_flag=self._on_bookmark_clicked,
|
||||
open_settings=lambda: self.open_settings(PanelType.TOGGLES))
|
||||
self._layouts[MainState.HOME].set_settings_callback(self.open_settings)
|
||||
self._layouts[MainState.HOME].set_stats_callback(self.open_stats)
|
||||
self._layouts[MainState.HOME].set_nav_callback(self.open_nav)
|
||||
self._layouts[MainState.HOME].set_routes_callback(self.open_routes)
|
||||
self._layouts[MainState.SETTINGS].set_callbacks(on_close=self._set_mode_for_state)
|
||||
self._layouts[MainState.STATS].set_on_back(self._set_mode_for_state)
|
||||
self._layouts[MainState.NAV].set_on_back(self._set_mode_for_state)
|
||||
self._layouts[MainState.ROUTES].set_on_back(self._set_mode_for_state)
|
||||
self._layouts[MainState.ROUTES].set_on_play(self.open_video)
|
||||
self._layouts[MainState.VIDEO].set_on_back(self.open_routes)
|
||||
self._layouts[MainState.ONROAD].set_click_callback(self._on_onroad_clicked)
|
||||
device.add_interactive_timeout_callback(self._set_mode_for_state)
|
||||
|
||||
def _update_layout_rects(self):
|
||||
self._sidebar_rect = rl.Rectangle(self._rect.x, self._rect.y, SIDEBAR_WIDTH, self._rect.height)
|
||||
|
||||
x_offset = SIDEBAR_WIDTH if self._sidebar.is_visible else 0
|
||||
self._content_rect = rl.Rectangle(self._rect.x + x_offset, self._rect.y, self._rect.width - x_offset, self._rect.height)
|
||||
|
||||
def _handle_onroad_transition(self):
|
||||
if ui_state.started != self._prev_onroad:
|
||||
self._prev_onroad = ui_state.started
|
||||
|
||||
self._set_mode_for_state()
|
||||
|
||||
def _set_mode_for_state(self):
|
||||
if ui_state.started:
|
||||
# Don't hide sidebar from interactive timeout
|
||||
if self._current_mode != MainState.ONROAD:
|
||||
self._set_sidebar_visible(False)
|
||||
self._set_current_layout(MainState.ONROAD)
|
||||
else:
|
||||
# Offroad launcher owns the full screen; the sidebar is reserved for onroad.
|
||||
self._set_current_layout(MainState.HOME)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def _set_current_layout(self, layout: MainState):
|
||||
if self._transition.active:
|
||||
if layout == MainState.ONROAD or ui_state.started:
|
||||
self._cancel_transition()
|
||||
elif layout == self._current_mode:
|
||||
self._cancel_transition()
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
if layout == self._current_mode:
|
||||
return
|
||||
|
||||
old_mode = self._current_mode
|
||||
if self._should_animate_transition(old_mode, layout):
|
||||
self._start_transition(old_mode, layout)
|
||||
return
|
||||
|
||||
self._layouts[old_mode].hide_event()
|
||||
self._current_mode = layout
|
||||
self._layouts[self._current_mode].show_event()
|
||||
|
||||
def _should_animate_transition(self, old_mode: MainState, new_mode: MainState) -> bool:
|
||||
return (
|
||||
not ui_state.started
|
||||
and old_mode != MainState.ONROAD
|
||||
and new_mode != MainState.ONROAD
|
||||
and (old_mode == MainState.HOME or new_mode == MainState.HOME)
|
||||
)
|
||||
|
||||
def _start_transition(self, old_mode: MainState, new_mode: MainState):
|
||||
state = MenuTransitionState.PUSHING if old_mode == MainState.HOME else MenuTransitionState.POPPING
|
||||
self._transition = MenuTransition(
|
||||
state=state,
|
||||
t=0.0,
|
||||
duration=OFFROAD_TRANSITION_SECONDS,
|
||||
from_screen=old_mode,
|
||||
to_screen=new_mode,
|
||||
)
|
||||
self._layouts[new_mode].show_event()
|
||||
|
||||
def _cancel_transition(self):
|
||||
pending_mode = self._transition.to_screen
|
||||
if pending_mode is not None and pending_mode != self._current_mode:
|
||||
self._layouts[pending_mode].hide_event()
|
||||
self._transition = MenuTransition()
|
||||
|
||||
def _finish_transition(self):
|
||||
old_mode = self._transition.from_screen
|
||||
new_mode = self._transition.to_screen
|
||||
if old_mode is not None:
|
||||
self._layouts[old_mode].hide_event()
|
||||
if new_mode is not None:
|
||||
self._current_mode = new_mode
|
||||
self._transition = MenuTransition()
|
||||
|
||||
def _render_layout(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
|
||||
if interactive:
|
||||
layout.render(rect)
|
||||
return
|
||||
|
||||
enabled = layout._enabled
|
||||
layout.set_enabled(False)
|
||||
try:
|
||||
layout.render(rect)
|
||||
finally:
|
||||
layout.set_enabled(enabled)
|
||||
|
||||
def _render_layout_surface(self, layout: Widget, rect: rl.Rectangle, interactive: bool = True):
|
||||
bg_rect = rl.Rectangle(
|
||||
rect.x - TRANSITION_SURFACE_OVERSCAN,
|
||||
rect.y - TRANSITION_SURFACE_OVERSCAN,
|
||||
rect.width + TRANSITION_SURFACE_OVERSCAN * 2,
|
||||
rect.height + TRANSITION_SURFACE_OVERSCAN * 2,
|
||||
)
|
||||
rl.draw_rectangle_rec(bg_rect, TRANSITION_SURFACE_BG)
|
||||
self._render_layout(layout, rect, interactive)
|
||||
|
||||
def _render_layout_surface_translated(self, layout: Widget, rect: rl.Rectangle, x_offset: float):
|
||||
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
|
||||
self._render_layout_surface(layout, translated_rect, interactive=False)
|
||||
|
||||
def _render_transition(self, content_rect: rl.Rectangle) -> bool:
|
||||
if not self._transition.active:
|
||||
return False
|
||||
|
||||
from_mode = self._transition.from_screen
|
||||
to_mode = self._transition.to_screen
|
||||
if from_mode is None or to_mode is None:
|
||||
self._finish_transition()
|
||||
return False
|
||||
|
||||
self._transition.t += rl.get_frame_time()
|
||||
if self._transition.t >= self._transition.duration:
|
||||
self._finish_transition()
|
||||
return False
|
||||
|
||||
p = _clamp(self._transition.t / self._transition.duration)
|
||||
e = _ease_emphasized(p)
|
||||
w = content_rect.width
|
||||
pushing = self._transition.state == MenuTransitionState.PUSHING
|
||||
|
||||
# The incoming card slides its full width on top; the other page parallaxes a fraction and dims.
|
||||
if pushing:
|
||||
top_mode, back_mode = to_mode, from_mode
|
||||
top_x = _lerp(w, 0.0, e)
|
||||
back_x = _lerp(0.0, -w * TRANSITION_PARALLAX, e)
|
||||
back_dim = int(_lerp(0.0, TRANSITION_MAX_DIM, e) * 255)
|
||||
else:
|
||||
top_mode, back_mode = from_mode, to_mode
|
||||
top_x = _lerp(0.0, w, e)
|
||||
back_x = _lerp(-w * TRANSITION_PARALLAX, 0.0, e)
|
||||
back_dim = int(_lerp(TRANSITION_MAX_DIM, 0.0, e) * 255)
|
||||
|
||||
back_rect = rl.Rectangle(round(content_rect.x + back_x), content_rect.y, content_rect.width, content_rect.height)
|
||||
|
||||
rl.draw_rectangle_rec(content_rect, TRANSITION_SURFACE_BG)
|
||||
self._render_layout_surface_translated(self._layouts[back_mode], content_rect, back_x)
|
||||
if back_dim > 0:
|
||||
rl.draw_rectangle_rec(back_rect, rl.Color(0, 0, 0, back_dim))
|
||||
# soft shadow cast by the top card's leading edge onto the page behind
|
||||
edge_x = round(content_rect.x + top_x)
|
||||
if edge_x > content_rect.x:
|
||||
sh = int(min(TRANSITION_SHADOW_W, edge_x - content_rect.x))
|
||||
rl.draw_rectangle_gradient_h(edge_x - sh, int(content_rect.y), sh, int(content_rect.height),
|
||||
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
|
||||
self._render_layout_surface_translated(self._layouts[top_mode], content_rect, top_x)
|
||||
return True
|
||||
|
||||
def open_settings(self, panel_type: PanelType | None = None):
|
||||
self._set_current_layout(MainState.SETTINGS)
|
||||
if panel_type is None:
|
||||
self._layouts[MainState.SETTINGS].show_grid()
|
||||
else:
|
||||
self._layouts[MainState.SETTINGS].set_current_panel(panel_type)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_stats(self):
|
||||
self._set_current_layout(MainState.STATS)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_nav(self):
|
||||
self._set_current_layout(MainState.NAV)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_routes(self):
|
||||
self._set_current_layout(MainState.ROUTES)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def open_video(self, route: str):
|
||||
self._layouts[MainState.VIDEO].set_route(route)
|
||||
self._set_current_layout(MainState.VIDEO)
|
||||
self._set_sidebar_visible(False)
|
||||
|
||||
def _on_settings_clicked(self):
|
||||
self.open_settings()
|
||||
|
||||
def _on_bookmark_clicked(self):
|
||||
user_bookmark = messaging.new_message('bookmarkButton')
|
||||
user_bookmark.valid = True
|
||||
self._pm.send('bookmarkButton', user_bookmark)
|
||||
|
||||
def _set_sidebar_visible(self, visible: bool):
|
||||
self._sidebar.set_visible(visible)
|
||||
self._update_layout_rects()
|
||||
|
||||
def _on_onroad_clicked(self):
|
||||
self._set_sidebar_visible(not self._sidebar.is_visible)
|
||||
|
||||
def _back_target(self) -> MainState | None:
|
||||
"""The page an edge-swipe-back should return to, or None if there's nowhere to go back."""
|
||||
if ui_state.started:
|
||||
return None
|
||||
mode = self._current_mode
|
||||
if mode == MainState.VIDEO:
|
||||
return MainState.ROUTES
|
||||
if mode in (MainState.SETTINGS, MainState.STATS, MainState.NAV, MainState.ROUTES):
|
||||
# The settings hub owns its own panel->grid swipe; only take over once it's back at the grid.
|
||||
if mode == MainState.SETTINGS and getattr(self._layouts[MainState.SETTINGS], "_mode", "grid") == "panel":
|
||||
return None
|
||||
return MainState.HOME
|
||||
return None
|
||||
|
||||
def _reset_swipe(self):
|
||||
self._swipe_start_pos = None
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent) -> None:
|
||||
super()._handle_mouse_event(mouse_event)
|
||||
|
||||
if self._swipe_settling:
|
||||
return # let the release animation finish before accepting a new gesture
|
||||
|
||||
if mouse_event.slot != 0 or self._transition.active or self._back_target() is None:
|
||||
self._reset_swipe()
|
||||
return
|
||||
|
||||
if mouse_event.left_pressed:
|
||||
if mouse_event.pos.x - self._rect.x <= EDGE_SWIPE_ZONE:
|
||||
self._swipe_start_pos = mouse_event.pos
|
||||
self._swipe_active = False
|
||||
self._swipe_blocked = False
|
||||
self._swipe_dx = 0.0
|
||||
else:
|
||||
self._reset_swipe()
|
||||
|
||||
elif self._swipe_start_pos is not None:
|
||||
if mouse_event.left_down:
|
||||
dx = mouse_event.pos.x - self._swipe_start_pos.x
|
||||
dy = abs(mouse_event.pos.y - self._swipe_start_pos.y)
|
||||
if not self._swipe_active and not self._swipe_blocked:
|
||||
if dy > EDGE_SWIPE_BLOCK_VERTICAL and dy > dx:
|
||||
self._swipe_blocked = True # user is scrolling, not swiping back
|
||||
elif dx > EDGE_SWIPE_ARM_DISTANCE:
|
||||
self._swipe_active = True
|
||||
if self._swipe_active:
|
||||
self._swipe_dx = max(0.0, dx)
|
||||
|
||||
elif mouse_event.left_released:
|
||||
if self._swipe_active:
|
||||
target = self._back_target()
|
||||
complete = target is not None and self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
|
||||
self._begin_swipe_settle(target, complete)
|
||||
self._reset_swipe()
|
||||
|
||||
def _complete_back(self, target: MainState):
|
||||
# The finger already dragged the page most of the way across, so switch instantly (matching the
|
||||
# settings hub's swipe) rather than replaying the eased transition.
|
||||
old_mode = self._current_mode
|
||||
if old_mode == target:
|
||||
return
|
||||
self._layouts[old_mode].hide_event()
|
||||
self._current_mode = target
|
||||
self._layouts[target].show_event()
|
||||
|
||||
def _begin_swipe_settle(self, target: MainState | None, complete: bool):
|
||||
# On release, glide from where the finger let go to fully-open (complete) or closed (cancel)
|
||||
# instead of snapping. Needs a back page to slide behind; if there's none, just drop the drag.
|
||||
if target is None:
|
||||
return
|
||||
self._swipe_settle_from = self._swipe_dx
|
||||
self._swipe_settle_to = self._rect.width if complete else 0.0
|
||||
self._swipe_render_target = target
|
||||
self._swipe_completing = complete
|
||||
self._swipe_settle_t = 0.0
|
||||
self._swipe_settling = True
|
||||
|
||||
def _update_swipe_settle(self) -> bool:
|
||||
"""Advance the release animation. Returns True while still animating."""
|
||||
self._swipe_settle_t += rl.get_frame_time()
|
||||
p = _clamp(self._swipe_settle_t / SWIPE_SETTLE_SECONDS)
|
||||
self._swipe_dx = _lerp(self._swipe_settle_from, self._swipe_settle_to, _ease_out_cubic(p))
|
||||
if p < 1.0:
|
||||
return True
|
||||
self._swipe_settling = False
|
||||
target, completing = self._swipe_render_target, self._swipe_completing
|
||||
self._swipe_render_target = None
|
||||
self._swipe_completing = False
|
||||
self._swipe_dx = 0.0
|
||||
if completing and target is not None:
|
||||
self._complete_back(target)
|
||||
return False
|
||||
|
||||
def _render_swipe_drag(self, rect: rl.Rectangle, target: MainState):
|
||||
# Live finger-following pop: the destination sits behind, the current page slides out right.
|
||||
dx = min(self._swipe_dx, rect.width)
|
||||
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
|
||||
self._render_layout_surface_translated(self._layouts[target], rect, dx - rect.width)
|
||||
self._render_layout_surface_translated(self._layouts[self._current_mode], rect, dx)
|
||||
|
||||
def _render_main_content(self):
|
||||
# Render sidebar (onroad only)
|
||||
if self._sidebar.is_visible:
|
||||
self._sidebar.render(self._sidebar_rect)
|
||||
|
||||
content_rect = self._content_rect if self._sidebar.is_visible else self._rect
|
||||
if self._render_transition(content_rect):
|
||||
return
|
||||
if self._swipe_settling:
|
||||
if self._update_swipe_settle():
|
||||
self._render_swipe_drag(content_rect, self._swipe_render_target)
|
||||
return
|
||||
# settled this frame; fall through to render the (possibly switched) current page
|
||||
elif self._swipe_active:
|
||||
target = self._back_target()
|
||||
if target is not None:
|
||||
self._render_swipe_drag(content_rect, target)
|
||||
return
|
||||
self._layouts[self._current_mode].render(content_rect)
|
||||
Reference in New Issue
Block a user