Files

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()