mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
163 lines
6.9 KiB
Python
163 lines
6.9 KiB
Python
from collections import OrderedDict
|
|
from datetime import datetime, timedelta
|
|
import hashlib
|
|
|
|
import pyray as rl
|
|
|
|
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
|
|
from openpilot.starpilot.ui.navigation_icons import ASSETS, MANIFEST, icon_name
|
|
from openpilot.starpilot.ui.navigation_state import distance_text
|
|
from openpilot.starpilot.ui.presentation import FontRole, Profile
|
|
|
|
|
|
def _overlaps_slc_actions(rect, profile, state) -> bool:
|
|
from openpilot.starpilot.ui.onroad_state import slc_controls
|
|
for control in slc_controls(profile, state):
|
|
left, top, right, bottom = control.bounds
|
|
if left < rect.x + rect.width and rect.x < right and top < rect.y + rect.height and rect.y < bottom:
|
|
return True
|
|
return False
|
|
|
|
|
|
def arrival_time(remaining_seconds: float, now: datetime) -> str:
|
|
arrival = now + timedelta(seconds=max(0, remaining_seconds))
|
|
return arrival.strftime('%I:%M %p').lstrip('0')
|
|
|
|
|
|
class NavigationCard:
|
|
def __init__(self, fonts, *, clock=None):
|
|
self.fonts = fonts
|
|
self.clock = clock or (lambda: datetime.now().astimezone())
|
|
self.collapsed = False
|
|
self._key = None
|
|
self._press = None
|
|
self._textures = OrderedDict()
|
|
self._text_layout = None
|
|
|
|
def bounds(self, state):
|
|
if (state.navigation is None or state.alert.size != 'none' or state.reverse_driver_camera or
|
|
state.appearance.camera_view in (CameraViewChoice.DRIVER, CameraViewChoice.NONE)):
|
|
return None
|
|
if state.navigation.key != self._key:
|
|
self._key, self.collapsed = state.navigation.key, False
|
|
rect = self._placed(state)
|
|
# A live SLC decision keeps its touch priority only where its buttons actually sit on the card.
|
|
if rect is not None and _overlaps_slc_actions(rect, self.fonts.profile, state):
|
|
return None
|
|
return rect
|
|
|
|
def _placed(self, state):
|
|
if self.fonts.profile == Profile.COMPACT:
|
|
return rl.Rectangle(244, 18, 72, 72) if self.collapsed else rl.Rectangle(96, 16, 368, 142)
|
|
# The card follows the right edge; legacy callers without a placement keep its fixed spot.
|
|
placed = state.customization["layouts"]["large"].get("nav_card")
|
|
if placed is not None and not placed["enabled"]:
|
|
return None
|
|
x, y = (placed["x"], placed["y"]) if placed is not None else (1230, 415)
|
|
x += state.viewport_width - 1860
|
|
return rl.Rectangle(x + 448, y, 112, 112) if self.collapsed else rl.Rectangle(x, y, 560, 195)
|
|
|
|
def press(self, x, y, state):
|
|
self.cancel()
|
|
rect = self.bounds(state)
|
|
if rect is not None and rect.x <= x <= rect.x + rect.width and rect.y <= y <= rect.y + rect.height:
|
|
self._press = (x, y, state.navigation.key)
|
|
return True
|
|
return False
|
|
|
|
def move(self, x, y, state):
|
|
if self._press is not None:
|
|
px, py, key = self._press
|
|
if abs(x - px) > 5 or abs(y - py) > 5 or self.bounds(state) is None or state.navigation.key != key:
|
|
self.cancel()
|
|
|
|
def release(self, x, y, state):
|
|
self.move(x, y, state)
|
|
if self._press is not None:
|
|
self.collapsed = not self.collapsed
|
|
self.cancel()
|
|
|
|
def cancel(self):
|
|
self._press = None
|
|
|
|
def _icon(self, name):
|
|
if name not in self._textures:
|
|
data = (ASSETS / name).read_bytes()
|
|
expected = MANIFEST[name]
|
|
if len(data) != expected['bytes'] or hashlib.sha256(data).hexdigest() != expected['sha256']:
|
|
raise ValueError('Navigation artwork differs from its manifest')
|
|
texture = rl.load_texture(str(ASSETS / name))
|
|
if not texture.id:
|
|
raise RuntimeError('Unable to load navigation artwork')
|
|
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
|
self._textures[name] = texture
|
|
if len(self._textures) > 8:
|
|
rl.unload_texture(self._textures.popitem(last=False)[1])
|
|
self._textures.move_to_end(name)
|
|
return self._textures[name]
|
|
|
|
def _lines(self, text, width, size):
|
|
key = (text, width, size)
|
|
if self._text_layout is not None and self._text_layout[0] == key:
|
|
return self._text_layout[1]
|
|
words, lines, line = text.split(), [], ''
|
|
for word in words:
|
|
candidate = f'{line} {word}'.strip()
|
|
if line and self.fonts.measure(candidate, FontRole.SEMI_BOLD, size).width > width:
|
|
lines.append(line)
|
|
line = word
|
|
else:
|
|
line = candidate
|
|
lines.append(line)
|
|
if len(lines) > 2:
|
|
lines = lines[:2]
|
|
lines[-1] += '...'
|
|
for i, line in enumerate(lines):
|
|
while len(line) > 3 and self.fonts.measure(line, FontRole.SEMI_BOLD, size).width > width:
|
|
line = line[:-4] + '...' if line.endswith('...') else line[:-1] + '...'
|
|
lines[i] = line
|
|
self._text_layout = key, tuple(lines)
|
|
return self._text_layout[1]
|
|
|
|
def render(self, state):
|
|
rect = self.bounds(state)
|
|
if rect is None:
|
|
self.cancel()
|
|
return
|
|
nav = state.navigation
|
|
compact = self.fonts.profile == Profile.COMPACT
|
|
rl.draw_rectangle_rounded(rect, .2, 8, rl.Color(15, 13, 23, 255 if compact else 245))
|
|
rl.draw_rectangle_rounded_lines_ex(rect, .2, 8, 2, rl.Color(160, 126, 220, 190))
|
|
size = (52 if compact else 80) if self.collapsed else (68 if compact else 100)
|
|
padding = 10 if self.collapsed else (18 if compact else 24)
|
|
icon = self._icon(icon_name('arrive' if nav.arrived else nav.maneuver_type, nav.modifier))
|
|
rl.draw_texture_pro(icon, rl.Rectangle(0, 0, icon.width, icon.height),
|
|
rl.Rectangle(rect.x + padding, rect.y + (rect.height - size) / 2, size, size), rl.Vector2(0, 0), 0, rl.WHITE)
|
|
if self.collapsed:
|
|
return
|
|
x, y = rect.x + padding * 2 + size, rect.y + (14 if compact else 22)
|
|
width = rect.x + rect.width - padding - x
|
|
primary_size, secondary_size = (28, 22) if compact else (40, 30)
|
|
for i, text in enumerate(self._lines(nav.text, width, primary_size)):
|
|
self.fonts.draw(text, FontRole.SEMI_BOLD, primary_size, x, y + i * (primary_size + 3))
|
|
distance = 'Arrived' if nav.arrived else distance_text(nav.distance_m, state.metric)
|
|
self.fonts.draw(distance, FontRole.BOLD, secondary_size, x, rect.y + rect.height - (58 if compact else 85),
|
|
rl.Color(199, 174, 247, 255))
|
|
if not nav.arrived:
|
|
now = self.clock()
|
|
font_size = 22 if compact else 32
|
|
y = rect.y + rect.height - (30 if compact else 44)
|
|
if now is not None:
|
|
remaining = f'ETA {arrival_time(nav.remaining_seconds, now)}'
|
|
measured = self.fonts.measure(remaining, FontRole.SEMI_BOLD, font_size)
|
|
self.fonts.draw(remaining, FontRole.SEMI_BOLD, font_size, rect.x + rect.width - padding - measured.width,
|
|
y, rl.Color(235, 225, 255, 255))
|
|
if not compact:
|
|
trip = f'{distance_text(nav.remaining_distance_m, state.metric)} | {max(1, round(nav.remaining_seconds / 60))} min'
|
|
self.fonts.draw(trip, FontRole.NORMAL, 20, rect.x + padding, y + 8, rl.Color(190, 187, 197, 255))
|
|
|
|
def close(self):
|
|
for texture in self._textures.values():
|
|
rl.unload_texture(texture)
|
|
self._textures.clear()
|