mirror of
https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
synced 2026-07-24 21:12:05 +08:00
557 lines
24 KiB
Python
557 lines
24 KiB
Python
import pyray as rl
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from enum import Enum, auto
|
|
|
|
from openpilot.common.params import Params
|
|
from openpilot.iqpilot.ui.layouts.settings.iq_panels import IQSettingsLayout
|
|
from openpilot.selfdrive.ui.layouts.home import _format_updater_description
|
|
from openpilot.selfdrive.ui.ui_state import ui_state
|
|
from openpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT, BACK_BTN_SIZE
|
|
from openpilot.system.ui.lib.application import gui_app, FontWeight, MouseEvent, MousePos
|
|
from openpilot.system.ui.lib.multilang import tr
|
|
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
|
from openpilot.system.ui.widgets import Widget, DialogResult
|
|
from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog, alert_dialog
|
|
from openpilot.system.ui.widgets.label import UnifiedLabel
|
|
|
|
# iOS-style swipe-from-the-left-edge-to-go-back, for panel -> grid only (the one place
|
|
# this layout already has a from/to slide transition to reuse for the live drag).
|
|
EDGE_SWIPE_ZONE = 80 # px from the left edge a swipe-back touch must start within
|
|
EDGE_SWIPE_ARM_DISTANCE = 8 # px of rightward movement before we commit to "this is a swipe"
|
|
EDGE_SWIPE_BLOCK_VERTICAL = 60 # px of vertical movement (while still under arm distance) that cancels it
|
|
EDGE_SWIPE_COMPLETE_FRACTION = 0.3 # fraction of screen width dragged to complete the pop on release
|
|
SWIPE_SETTLE_SECONDS = 0.16 # glide from the release point to done/cancelled (no snap)
|
|
|
|
MARGIN = 40
|
|
SPACING = 25
|
|
COLUMNS = 3
|
|
PILL_GAP = 24
|
|
PILL_MIN_HEIGHT = 150
|
|
BUBBLE_SIZE = 86
|
|
BUBBLE_RED = rl.Color(226, 60, 52, 255)
|
|
BUBBLE_RED_PRESSED = rl.Color(245, 92, 82, 255)
|
|
BUBBLE_GREY = rl.Color(70, 72, 78, 255)
|
|
BUBBLE_GREY_PRESSED = rl.Color(95, 98, 106, 255)
|
|
BUBBLE_TEAL = rl.Color(16, 185, 169, 255)
|
|
BUBBLE_TEAL_PRESSED = rl.Color(22, 210, 192, 255)
|
|
SETTINGS_TRANSITION_SECONDS = 0.28
|
|
TRANSITION_SURFACE_OVERSCAN = 4
|
|
TRANSITION_SURFACE_BG = rl.Color(10, 10, 10, 255)
|
|
|
|
|
|
class MenuTransitionState(Enum):
|
|
IDLE = auto()
|
|
PUSHING = auto()
|
|
POPPING = auto()
|
|
|
|
|
|
@dataclass
|
|
class MenuTransition:
|
|
state: MenuTransitionState = MenuTransitionState.IDLE
|
|
t: float = 0.0
|
|
duration: float = SETTINGS_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 grid<->panel push/pop (see main.py for the same treatment).
|
|
TRANSITION_PARALLAX = 0.28
|
|
TRANSITION_MAX_DIM = 0.5
|
|
TRANSITION_SHADOW_W = 32
|
|
|
|
|
|
class SettingsPill(Widget):
|
|
"""A settings-grid button: icon + label on a rounded card, opens a sub-panel."""
|
|
|
|
BG = rl.Color(38, 40, 46, 255)
|
|
BG_PRESSED = rl.Color(54, 57, 65, 255)
|
|
BORDER = rl.Color(255, 255, 255, 38)
|
|
|
|
def __init__(self, icon_path: str, label: str, on_click: Callable[[], None]):
|
|
super().__init__()
|
|
self._label = label
|
|
self._icon = gui_app.texture(icon_path, 80, 80, keep_aspect_ratio=True) if icon_path else None
|
|
self.set_click_callback(on_click)
|
|
|
|
def _render(self, rect: rl.Rectangle):
|
|
rl.draw_rectangle_rounded(rect, 0.25, 20, self.BG_PRESSED if self.is_pressed else self.BG)
|
|
rl.draw_rectangle_rounded_lines_ex(rect, 0.25, 20, 2, self.BORDER)
|
|
|
|
font = gui_app.font(FontWeight.MEDIUM)
|
|
label_size = 50
|
|
ts = measure_text_cached(font, self._label, label_size)
|
|
icon_w = self._icon.width if self._icon else 0
|
|
gap = 24 if self._icon else 0
|
|
group_w = icon_w + gap + ts.x
|
|
x = rect.x + (rect.width - group_w) / 2
|
|
cy = rect.y + rect.height / 2
|
|
|
|
if self._icon:
|
|
rl.draw_texture(self._icon, int(x), int(cy - self._icon.height / 2), rl.WHITE)
|
|
x += icon_w + gap
|
|
rl.draw_text_ex(font, self._label, rl.Vector2(int(x), int(cy - ts.y / 2)), label_size, 0, rl.WHITE)
|
|
|
|
|
|
class SettingsHubLayout(Widget):
|
|
"""Offroad Settings: a grid of pill buttons (the mockup look) that open the existing
|
|
IQ settings sub-panels. Back from a panel returns to the grid; back from the grid goes home.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._params = Params()
|
|
self._settings = IQSettingsLayout() # one instance: reuse its configured panels + wiring
|
|
self._header = self._child(ScreenHeader(tr("Settings")))
|
|
self._on_close: Callable[[], None] | None = None
|
|
|
|
self._mode = "grid" # "grid" | "panel"
|
|
self._cur_panel = None
|
|
|
|
# Edge-swipe-back drag state (panel -> grid only)
|
|
self._swipe_start_pos: MousePos | None = None
|
|
self._swipe_active = False # past EDGE_SWIPE_ARM_DISTANCE, now tracking finger 1:1
|
|
self._swipe_blocked = False # vertical movement won out before we armed; ignore rest of this touch
|
|
self._swipe_dx = 0.0
|
|
# Release-settle animation (glide to grid/panel 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_completing = False
|
|
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
|
|
self._version_label = UnifiedLabel("", font_size=44, font_weight=FontWeight.MEDIUM,
|
|
text_color=rl.Color(185, 185, 190, 255),
|
|
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
|
wrap_text=False, scroll=True)
|
|
self._transition = MenuTransition()
|
|
|
|
# Restart / power-off / always-offroad / night-mode bubbles in the grid header
|
|
self._restart_icon = gui_app.texture("icons/iq/restart.png", 48, 48, keep_aspect_ratio=True)
|
|
self._power_icon = gui_app.texture("icons/iq/power.png", 48, 48, keep_aspect_ratio=True)
|
|
self._offroad_icon = gui_app.texture("icons/iq/square-parking.png", 48, 48, keep_aspect_ratio=True)
|
|
self._night_icon = gui_app.texture("icons/iq/moon.png", 48, 48, keep_aspect_ratio=True)
|
|
self._bell_icon = gui_app.texture("icons/iq/bell.png", 48, 48, keep_aspect_ratio=True)
|
|
self._bell_slash_icon = gui_app.texture("icons/iq/bell-slash.png", 48, 48, keep_aspect_ratio=True)
|
|
self._restart_rect = rl.Rectangle(0, 0, 0, 0)
|
|
self._power_rect = rl.Rectangle(0, 0, 0, 0)
|
|
self._offroad_rect = rl.Rectangle(0, 0, 0, 0)
|
|
self._night_rect = rl.Rectangle(0, 0, 0, 0)
|
|
self._silent_rect = rl.Rectangle(0, 0, 0, 0)
|
|
|
|
# Build the grid from the settings panels, skipping ones hidden from navigation (e.g. Cruise).
|
|
panels = self._settings._panels
|
|
hidden = self._settings._hidden_from_sidebar
|
|
self._grid_panels = [pt for pt in panels if pt not in hidden]
|
|
self._pills = [
|
|
SettingsPill(panels[pt].icon, tr(panels[pt].name), (lambda p=pt: self._open_panel(p)))
|
|
for pt in self._grid_panels
|
|
]
|
|
|
|
# Route the Toggles -> Cruise shortcut into this hub's panel view.
|
|
cruise_pt = next((pt for pt in panels if pt.name == "CRUISE"), None)
|
|
toggles_pt = next((pt for pt in panels if pt.name == "TOGGLES"), None)
|
|
if cruise_pt is not None and toggles_pt is not None:
|
|
toggles = panels[toggles_pt].instance
|
|
if hasattr(toggles, "set_cruise_panel_callback"):
|
|
toggles.set_cruise_panel_callback(lambda: self._open_panel(cruise_pt))
|
|
|
|
def set_callbacks(self, on_close: Callable[[], None] | None = None):
|
|
self._on_close = on_close
|
|
|
|
def show_grid(self):
|
|
self._mode = "grid"
|
|
self._transition = MenuTransition()
|
|
|
|
def set_current_panel(self, panel_type):
|
|
self._open_panel(panel_type)
|
|
|
|
def show_event(self):
|
|
super().show_event()
|
|
self._mode = "grid"
|
|
self._transition = MenuTransition()
|
|
self._version_text = _format_updater_description(self._params.get("UpdaterCurrentDescription"))
|
|
|
|
def _open_panel(self, panel_type):
|
|
if self._transition.active:
|
|
return
|
|
|
|
from_grid = self._mode == "grid"
|
|
self._settings.set_current_panel(panel_type)
|
|
self._cur_panel = panel_type
|
|
if from_grid and not ui_state.started:
|
|
self._start_transition("grid", "panel", MenuTransitionState.PUSHING)
|
|
else:
|
|
self._mode = "panel"
|
|
|
|
def _back_to_grid(self):
|
|
if self._mode == "panel" and not self._transition.active:
|
|
self._start_transition("panel", "grid", MenuTransitionState.POPPING)
|
|
|
|
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._mode != "panel" or self._transition.active:
|
|
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
|
|
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:
|
|
complete = self._swipe_dx > self._rect.width * EDGE_SWIPE_COMPLETE_FRACTION
|
|
self._begin_swipe_settle(complete)
|
|
self._reset_swipe()
|
|
|
|
def _start_transition(self, from_mode: str, to_mode: str, state: MenuTransitionState):
|
|
self._transition = MenuTransition(
|
|
state=state,
|
|
t=0.0,
|
|
duration=SETTINGS_TRANSITION_SECONDS,
|
|
from_screen=from_mode,
|
|
to_screen=to_mode,
|
|
)
|
|
|
|
def _render(self, rect: rl.Rectangle):
|
|
if self._render_transition(rect):
|
|
return
|
|
|
|
if self._swipe_settling:
|
|
if self._update_swipe_settle():
|
|
self._render_swipe_drag(rect)
|
|
return
|
|
# settled this frame; fall through to render the (possibly switched) mode
|
|
elif self._swipe_active and self._mode == "panel":
|
|
self._render_swipe_drag(rect)
|
|
return
|
|
|
|
self._render_mode(self._mode, rect)
|
|
|
|
def _begin_swipe_settle(self, complete: bool):
|
|
# On release, glide from where the finger let go to fully-open (complete -> grid) or closed
|
|
# (cancel -> stay on panel) instead of snapping.
|
|
self._swipe_settle_from = self._swipe_dx
|
|
self._swipe_settle_to = self._rect.width if complete else 0.0
|
|
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
|
|
completing = self._swipe_completing
|
|
self._swipe_completing = False
|
|
self._swipe_dx = 0.0
|
|
if completing:
|
|
self._mode = "grid"
|
|
self._transition = MenuTransition()
|
|
return False
|
|
|
|
def _render_swipe_drag(self, rect: rl.Rectangle):
|
|
# Live, finger-following preview of the panel -> grid pop, mirroring _render_transition's
|
|
# POPPING geometry but driven 1:1 by touch position instead of eased elapsed time.
|
|
dx = min(self._swipe_dx, rect.width)
|
|
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
|
|
self._render_mode_surface_translated("grid", rect, dx - rect.width)
|
|
self._render_mode_surface_translated("panel", rect, dx)
|
|
|
|
def _layout_rects(self, rect: rl.Rectangle) -> tuple[rl.Rectangle, rl.Rectangle]:
|
|
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
|
|
content_y = header_rect.y + HEADER_HEIGHT + SPACING
|
|
content_rect = rl.Rectangle(rect.x + MARGIN, content_y, rect.width - 2 * MARGIN,
|
|
rect.y + rect.height - content_y - MARGIN)
|
|
return header_rect, content_rect
|
|
|
|
def _render_mode(self, mode: str, rect: rl.Rectangle, interactive: bool = True):
|
|
if mode == "panel" and self._cur_panel is not None:
|
|
self._render_panel(rect, interactive)
|
|
else:
|
|
self._render_grid(rect, interactive)
|
|
|
|
def _render_mode_surface(self, mode: str, 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_mode(mode, rect, interactive)
|
|
|
|
def _render_mode_surface_translated(self, mode: str, rect: rl.Rectangle, x_offset: float):
|
|
translated_rect = rl.Rectangle(round(rect.x + x_offset), rect.y, rect.width, rect.height)
|
|
self._render_mode_surface(mode, translated_rect, interactive=False)
|
|
|
|
def _render_transition(self, 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
|
|
from_mode = str(from_mode)
|
|
to_mode = str(to_mode)
|
|
|
|
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 = 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(rect.x + back_x), rect.y, rect.width, rect.height)
|
|
|
|
rl.draw_rectangle_rec(rect, TRANSITION_SURFACE_BG)
|
|
self._render_mode_surface_translated(back_mode, 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(rect.x + top_x)
|
|
if edge_x > rect.x:
|
|
sh = int(min(TRANSITION_SHADOW_W, edge_x - rect.x))
|
|
rl.draw_rectangle_gradient_h(edge_x - sh, int(rect.y), sh, int(rect.height),
|
|
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 120))
|
|
self._render_mode_surface_translated(top_mode, rect, top_x)
|
|
return True
|
|
|
|
def _finish_transition(self):
|
|
if self._transition.to_screen is not None:
|
|
self._mode = str(self._transition.to_screen)
|
|
self._transition = MenuTransition()
|
|
|
|
def _render_widget(self, widget: Widget, rect: rl.Rectangle, interactive: bool):
|
|
if interactive:
|
|
widget.render(rect)
|
|
return
|
|
|
|
enabled = widget._enabled
|
|
widget.set_enabled(False)
|
|
try:
|
|
widget.render(rect)
|
|
finally:
|
|
widget.set_enabled(enabled)
|
|
|
|
def _render_panel(self, rect: rl.Rectangle, interactive: bool = True):
|
|
header_rect, content_rect = self._layout_rects(rect)
|
|
|
|
self._header.set_title(tr(self._settings._panels[self._cur_panel].name))
|
|
self._header.set_on_back(self._back_to_grid)
|
|
self._header.set_title_offset(0)
|
|
self._render_widget(self._header, header_rect, interactive)
|
|
|
|
panel = self._settings._panels[self._settings._current_panel].instance
|
|
enabled = panel._enabled
|
|
if not interactive:
|
|
panel.set_enabled(False)
|
|
try:
|
|
self._settings._draw_current_panel(content_rect)
|
|
finally:
|
|
if not interactive:
|
|
panel.set_enabled(enabled)
|
|
|
|
def _render_grid(self, rect: rl.Rectangle, interactive: bool = True):
|
|
header_rect, content_rect = self._layout_rects(rect)
|
|
|
|
# Grid landing
|
|
title = tr("Settings")
|
|
title_offset = 5 * BUBBLE_SIZE + 4 * 18 + 36
|
|
self._header.set_title(title)
|
|
self._header.set_on_back(self._on_close)
|
|
self._header.set_title_offset(title_offset) # make room for the bubbles
|
|
self._render_widget(self._header, header_rect, interactive)
|
|
|
|
# Restart / power-off / always-offroad bubbles, just right of the back button
|
|
cy = header_rect.y + HEADER_HEIGHT / 2
|
|
bx = header_rect.x + BACK_BTN_SIZE + 24
|
|
self._restart_rect = rl.Rectangle(bx, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
|
self._power_rect = rl.Rectangle(bx + BUBBLE_SIZE + 18, cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
|
self._offroad_rect = rl.Rectangle(bx + 2 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
|
self._night_rect = rl.Rectangle(bx + 3 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
|
self._silent_rect = rl.Rectangle(bx + 4 * (BUBBLE_SIZE + 18), cy - BUBBLE_SIZE / 2, BUBBLE_SIZE, BUBBLE_SIZE)
|
|
mouse = rl.get_mouse_position()
|
|
for r, icon in ((self._restart_rect, self._restart_icon), (self._power_rect, self._power_icon)):
|
|
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, r)
|
|
rl.draw_circle(int(r.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, BUBBLE_RED_PRESSED if pressed else BUBBLE_RED)
|
|
rl.draw_texture(icon, int(r.x + (BUBBLE_SIZE - icon.width) / 2), int(cy - icon.height / 2), rl.WHITE)
|
|
# Always Offroad bubble (grey when off, teal when on)
|
|
offroad_active = ui_state.params.get_bool("IQAlwaysOffroad")
|
|
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._offroad_rect)
|
|
offroad_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if offroad_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
|
|
rl.draw_circle(int(self._offroad_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, offroad_color)
|
|
rl.draw_texture(self._offroad_icon,
|
|
int(self._offroad_rect.x + (BUBBLE_SIZE - self._offroad_icon.width) / 2),
|
|
int(cy - self._offroad_icon.height / 2), rl.WHITE)
|
|
# Night Mode bubble (grey when off, teal when on)
|
|
night_active = ui_state.params.get_bool("NightMode")
|
|
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._night_rect)
|
|
night_color = (BUBBLE_TEAL_PRESSED if pressed else BUBBLE_TEAL) if night_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
|
|
rl.draw_circle(int(self._night_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, night_color)
|
|
rl.draw_texture(self._night_icon,
|
|
int(self._night_rect.x + (BUBBLE_SIZE - self._night_icon.width) / 2),
|
|
int(cy - self._night_icon.height / 2), rl.WHITE)
|
|
|
|
# Silent Mode bubble (grey bell when off, red bell-with-slash when on)
|
|
silent_active = ui_state.params.get_bool("IQAlertSilence")
|
|
pressed = self.is_pressed and rl.check_collision_point_rec(mouse, self._silent_rect)
|
|
silent_color = (BUBBLE_RED_PRESSED if pressed else BUBBLE_RED) if silent_active else (BUBBLE_GREY_PRESSED if pressed else BUBBLE_GREY)
|
|
silent_icon = self._bell_slash_icon if silent_active else self._bell_icon
|
|
rl.draw_circle(int(self._silent_rect.x + BUBBLE_SIZE / 2), int(cy), BUBBLE_SIZE / 2, silent_color)
|
|
rl.draw_texture(silent_icon,
|
|
int(self._silent_rect.x + (BUBBLE_SIZE - silent_icon.width) / 2),
|
|
int(cy - silent_icon.height / 2), rl.WHITE)
|
|
|
|
# Small version label, top-right of the header row. Its scroll viewport
|
|
# starts after the title, so long branch text does not clip at the bubbles.
|
|
font = gui_app.font(FontWeight.MEDIUM)
|
|
title_font = gui_app.font(FontWeight.BOLD)
|
|
ver_fs = 44
|
|
title_size = measure_text_cached(title_font, title, 64)
|
|
ver_size = measure_text_cached(font, self._version_text, ver_fs)
|
|
title_x = header_rect.x + BACK_BTN_SIZE + 36 + title_offset
|
|
version_left = title_x + title_size.x + 36
|
|
version_right = header_rect.x + header_rect.width
|
|
version_rect = rl.Rectangle(version_left, header_rect.y, max(0, version_right - version_left), HEADER_HEIGHT)
|
|
if version_rect.width > 0:
|
|
if ver_size.x <= version_rect.width:
|
|
rl.draw_text_ex(font, self._version_text,
|
|
rl.Vector2(int(header_rect.x + header_rect.width - ver_size.x),
|
|
int(header_rect.y + (HEADER_HEIGHT - ver_size.y) / 2)),
|
|
ver_fs, 0, rl.Color(185, 185, 190, 255))
|
|
else:
|
|
self._version_label.set_text(self._version_text)
|
|
self._version_label.render(version_rect)
|
|
|
|
rows = (len(self._pills) + COLUMNS - 1) // COLUMNS
|
|
pill_w = (content_rect.width - PILL_GAP * (COLUMNS - 1)) / COLUMNS
|
|
pill_h = max(PILL_MIN_HEIGHT, (content_rect.height - PILL_GAP * (rows - 1)) / rows)
|
|
for i, pill in enumerate(self._pills):
|
|
col = i % COLUMNS
|
|
row = i // COLUMNS
|
|
px = content_rect.x + col * (pill_w + PILL_GAP)
|
|
py = content_rect.y + row * (pill_h + PILL_GAP)
|
|
self._render_widget(pill, rl.Rectangle(px, py, pill_w, pill_h), interactive)
|
|
|
|
def _handle_mouse_release(self, mouse_pos: MousePos):
|
|
if self._mode != "grid" or self._transition.active:
|
|
return
|
|
if rl.check_collision_point_rec(mouse_pos, self._restart_rect):
|
|
self._reboot_prompt()
|
|
elif rl.check_collision_point_rec(mouse_pos, self._power_rect):
|
|
self._power_off_prompt()
|
|
elif rl.check_collision_point_rec(mouse_pos, self._offroad_rect):
|
|
self._toggle_offroad_prompt()
|
|
elif rl.check_collision_point_rec(mouse_pos, self._night_rect):
|
|
ui_state.params.put_bool("NightMode", not ui_state.params.get_bool("NightMode"))
|
|
elif rl.check_collision_point_rec(mouse_pos, self._silent_rect):
|
|
ui_state.params.put_bool("IQAlertSilence", not ui_state.params.get_bool("IQAlertSilence"))
|
|
|
|
def _reboot_prompt(self):
|
|
if ui_state.engaged:
|
|
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Reboot")))
|
|
return
|
|
dialog = ConfirmDialog(tr("Are you sure you want to reboot?"), tr("Reboot"))
|
|
gui_app.set_modal_overlay(dialog, callback=self._perform_reboot)
|
|
|
|
def _perform_reboot(self, result: int):
|
|
if not ui_state.engaged and result == DialogResult.CONFIRM:
|
|
self._params.put_bool_nonblocking("DoReboot", True)
|
|
|
|
def _power_off_prompt(self):
|
|
if ui_state.engaged:
|
|
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Power Off")))
|
|
return
|
|
dialog = ConfirmDialog(tr("Are you sure you want to power off?"), tr("Power Off"))
|
|
gui_app.set_modal_overlay(dialog, callback=self._perform_power_off)
|
|
|
|
def _perform_power_off(self, result: int):
|
|
if not ui_state.engaged and result == DialogResult.CONFIRM:
|
|
self._params.put_bool_nonblocking("DoShutdown", True)
|
|
|
|
def _toggle_offroad_prompt(self):
|
|
if ui_state.engaged:
|
|
gui_app.set_modal_overlay(alert_dialog(tr("Disengage to Enter Always Offroad Mode")))
|
|
return
|
|
active = ui_state.params.get_bool("IQAlwaysOffroad")
|
|
msg = tr("Are you sure you want to exit Always Offroad mode?") if active else tr("Are you sure you want to enter Always Offroad mode?")
|
|
|
|
def _confirm(result: int):
|
|
if result == DialogResult.CONFIRM and not ui_state.engaged:
|
|
ui_state.params.put_bool("IQAlwaysOffroad", not active)
|
|
|
|
gui_app.set_modal_overlay(ConfirmDialog(msg, tr("Confirm")), callback=_confirm)
|