mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-31 21:23:49 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d246f3c0d | |||
| b2885f9f89 | |||
| 01599193df |
@@ -11,6 +11,11 @@ from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.starpilot_border import _csc_state, _intensity, _glow_color
|
||||
from openpilot.selfdrive.ui.lib.starpilot_status import get_border_color
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import (
|
||||
CONTROL_BORDER,
|
||||
CONTROL_BORDER_WIDTH,
|
||||
draw_control_card,
|
||||
)
|
||||
|
||||
|
||||
# --- Scale factor (single knob for all pixel-space dimensions) ---
|
||||
@@ -35,6 +40,31 @@ ROAD_THICKNESS = 4.0 * SCALE
|
||||
ROAD_HALF_SIZE = 40.0 * SCALE
|
||||
ROAD_EDGE_INSET = 2.0 * SCALE
|
||||
FILL_ALPHA = 90
|
||||
ROAD_CONTOUR_WIDTH = 2.0 * SCALE
|
||||
|
||||
# The gauge overlays an unconstrained camera image. It shares the visible
|
||||
# control-card frame used by Set Speed and MAP, then owns two contained
|
||||
# viewports: animated road graphics above, glanceable status metrics below.
|
||||
# Nothing may draw outside the card.
|
||||
AETHER_CONTENT_INSET_X = max(5.0 * SCALE, CONTROL_BORDER_WIDTH / 2.0 + 3.0 * SCALE)
|
||||
AETHER_ROAD_TOP_INSET = max(5.0 * SCALE, CONTROL_BORDER_WIDTH / 2.0 + 3.0 * SCALE)
|
||||
AETHER_ROAD_VIEWPORT_HEIGHT = 75.0 * SCALE
|
||||
AETHER_ROAD_TO_CRADLE_GAP = 2.0 * SCALE
|
||||
AETHER_CRADLE_TOP = 83.0 * SCALE
|
||||
AETHER_CRADLE_BOTTOM_INSET = max(2.0 * SCALE, CONTROL_BORDER_WIDTH / 2.0 + 2.0 * SCALE)
|
||||
|
||||
AETHER_LABEL_SIZE = int(16.0 * SCALE)
|
||||
AETHER_LABEL_SLOT_HEIGHT = AETHER_LABEL_SIZE
|
||||
AETHER_VALUE_GAP = 2.0 * SCALE
|
||||
AETHER_VALUE_MAX_SIZE = int(40.0 * SCALE)
|
||||
AETHER_VALUE_MIN_SIZE = int(24.0 * SCALE)
|
||||
AETHER_LEAD_MAX_SIZE = int(32.0 * SCALE)
|
||||
AETHER_LEAD_MIN_SIZE = int(18.0 * SCALE)
|
||||
AETHER_REDUCTION_SIZE = int(16.0 * SCALE)
|
||||
AETHER_REDUCTION_GAP = 4.0 * SCALE
|
||||
AETHER_ACCENT_GAP = 2.0 * SCALE
|
||||
AETHER_UNIT_SIZE = int(18.0 * SCALE)
|
||||
AETHER_UNIT_GAP = 2.0 * SCALE
|
||||
|
||||
STOP_SNAP_THRESHOLD = 0.5
|
||||
STOP_LERP_RATE = 0.25
|
||||
@@ -51,7 +81,10 @@ LEAD_STOPPED_SPEED_THRESHOLD = 1.0
|
||||
COLOR_FORCE_STOP = rl.Color(255, 30, 60, 255)
|
||||
COLOR_LEAD_STOPPED = rl.Color(255, 60, 60, 255)
|
||||
COLOR_LEAD_SLOWER = rl.Color(255, 191, 0, 255)
|
||||
COLOR_SHADOW = rl.Color(0, 0, 0, 100)
|
||||
COLOR_CONTOUR = rl.Color(2, 6, 9, 235)
|
||||
COLOR_AETHER_CARD = rl.Color(9, 14, 18, 226)
|
||||
COLOR_PRIMARY_TEXT = rl.Color(245, 250, 252, 255)
|
||||
COLOR_SECONDARY_TEXT = rl.Color(225, 235, 240, 235)
|
||||
COLOR_STOP_SIGN_OUTLINE = rl.Color(255, 255, 255, 255)
|
||||
COLOR_STOP_LINE_GLOW = rl.Color(255, 30, 60, 255)
|
||||
COLOR_STOP_LINE_CORE = rl.Color(255, 200, 200, 255)
|
||||
@@ -150,12 +183,119 @@ def _get_perspective_offset(t: float, data: 'AetherGaugeData | None') -> float:
|
||||
max_offset_top = math.tanh(path_y_far * PERSPECTIVE_GAIN) * PERSPECTIVE_MAX_OFFSET
|
||||
return max_offset_top * (t ** PERSPECTIVE_EXPONENT)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AetherGaugeLayout:
|
||||
card: rl.Rectangle
|
||||
road: rl.Rectangle
|
||||
cradle: rl.Rectangle
|
||||
|
||||
|
||||
def _snapped_rect(x: float, y: float, width: float, height: float) -> rl.Rectangle:
|
||||
return rl.Rectangle(
|
||||
int(round(x)), int(round(y)), max(1, int(round(width))), max(1, int(round(height))),
|
||||
)
|
||||
|
||||
|
||||
def _aether_layout(rect: rl.Rectangle) -> AetherGaugeLayout:
|
||||
"""Return the one geometry contract used by every AetherGauge primitive."""
|
||||
# Match the Set Speed and MAP frame exactly; internal viewports provide the
|
||||
# clearance needed by the denser AetherGauge road and metric visuals.
|
||||
card = _snapped_rect(rect.x, rect.y, rect.width, rect.height)
|
||||
|
||||
cradle_top = int(round(card.y + AETHER_CRADLE_TOP))
|
||||
cradle_bottom = int(round(card.y + card.height - AETHER_CRADLE_BOTTOM_INSET))
|
||||
road_y = int(round(card.y + AETHER_ROAD_TOP_INSET))
|
||||
road_height = min(
|
||||
int(round(AETHER_ROAD_VIEWPORT_HEIGHT)),
|
||||
max(1, cradle_top - road_y - int(round(AETHER_ROAD_TO_CRADLE_GAP))),
|
||||
)
|
||||
content_x = int(round(card.x + AETHER_CONTENT_INSET_X))
|
||||
content_width = max(1, int(round(card.width - 2.0 * AETHER_CONTENT_INSET_X)))
|
||||
|
||||
return AetherGaugeLayout(
|
||||
card=card,
|
||||
road=_snapped_rect(content_x, road_y, content_width, road_height),
|
||||
cradle=_snapped_rect(content_x, cradle_top, content_width, max(1, cradle_bottom - cradle_top)),
|
||||
)
|
||||
|
||||
|
||||
def _draw_aether_card(layout: AetherGaugeLayout, alpha: float = 1.0) -> None:
|
||||
"""Draw the shared control frame with AetherGauge's deeper contrast fill."""
|
||||
draw_control_card(
|
||||
layout.card,
|
||||
fill=_fade(COLOR_AETHER_CARD, alpha),
|
||||
border=_fade(CONTROL_BORDER, alpha),
|
||||
)
|
||||
|
||||
|
||||
def _text_outline_px(size: int) -> int:
|
||||
return max(1, int(round(size / (28.0 * SCALE))))
|
||||
|
||||
|
||||
def _draw_text_with_shadow(font: rl.Font, text: str, pos: rl.Vector2, size: int, color: rl.Color, alpha: float = 1.0):
|
||||
for dx, dy in ((-1, -1), (1, -1), (-1, 1), (1, 1)):
|
||||
rl.draw_text_ex(font, text, rl.Vector2(pos.x + dx, pos.y + dy), size, 0, _fade(rl.BLACK, alpha))
|
||||
outline_px = _text_outline_px(size)
|
||||
for dx, dy in (
|
||||
(-outline_px, 0), (outline_px, 0), (0, -outline_px), (0, outline_px),
|
||||
(-outline_px, -outline_px), (outline_px, -outline_px),
|
||||
(-outline_px, outline_px), (outline_px, outline_px),
|
||||
):
|
||||
rl.draw_text_ex(font, text, rl.Vector2(pos.x + dx, pos.y + dy), size, 0, _fade(COLOR_CONTOUR, alpha))
|
||||
rl.draw_text_ex(font, text, pos, size, 0, _fade(color, alpha))
|
||||
|
||||
|
||||
def _fit_text_size(font: rl.Font, text: str, max_size: int, min_size: int,
|
||||
max_width: float, max_height: float) -> tuple[int, rl.Vector2]:
|
||||
"""Measure down to a guaranteed-safe text size for the supplied content box."""
|
||||
for size in range(max_size, min_size - 1, -1):
|
||||
text_size = measure_text_cached(font, text, size)
|
||||
outline = _text_outline_px(size)
|
||||
if text_size.x + 2 * outline <= max_width and text_size.y + 2 * outline <= max_height:
|
||||
return size, text_size
|
||||
|
||||
# Keep pathological future values contained even if they exceed the intended
|
||||
# type scale. Normal gauge data never reaches this fallback.
|
||||
for size in range(min_size - 1, 0, -1):
|
||||
text_size = measure_text_cached(font, text, size)
|
||||
outline = _text_outline_px(size)
|
||||
if text_size.x + 2 * outline <= max_width and text_size.y + 2 * outline <= max_height:
|
||||
return size, text_size
|
||||
|
||||
return 1, measure_text_cached(font, text, 1)
|
||||
|
||||
|
||||
def _fit_numeric_line(font_bold: rl.Font, font_medium: rl.Font, value: str, reduction: str,
|
||||
max_width: float, max_height: float) -> 'tuple[int, rl.Vector2, rl.Vector2 | None, bool]':
|
||||
"""Fit the primary value and optional reduction inline, or reflow the reduction."""
|
||||
reduction_size = measure_text_cached(font_medium, reduction, AETHER_REDUCTION_SIZE) if reduction else None
|
||||
|
||||
for value_font_size in range(AETHER_VALUE_MAX_SIZE, 0, -1):
|
||||
value_size = measure_text_cached(font_bold, value, value_font_size)
|
||||
value_outline = _text_outline_px(value_font_size)
|
||||
if value_size.y + 2 * value_outline > max_height:
|
||||
continue
|
||||
|
||||
if reduction_size is None:
|
||||
if value_size.x + 2 * value_outline <= max_width:
|
||||
return value_font_size, value_size, None, True
|
||||
continue
|
||||
|
||||
reduction_outline = _text_outline_px(AETHER_REDUCTION_SIZE)
|
||||
inline_width = value_size.x + AETHER_REDUCTION_GAP + reduction_size.x + 2 * max(value_outline, reduction_outline)
|
||||
if inline_width <= max_width:
|
||||
return value_font_size, value_size, reduction_size, True
|
||||
|
||||
if value_font_size == AETHER_VALUE_MIN_SIZE:
|
||||
break
|
||||
|
||||
# The normal source values fit inline. This fallback keeps a future long
|
||||
# value contained by using the reserved label row for the reduction.
|
||||
value_font_size, value_size = _fit_text_size(
|
||||
font_bold, value, AETHER_VALUE_MIN_SIZE, 1, max_width, max_height,
|
||||
)
|
||||
return value_font_size, value_size, reduction_size, False
|
||||
|
||||
|
||||
# --- Data model ---
|
||||
|
||||
class IndicatorType(Enum):
|
||||
@@ -178,6 +318,14 @@ class AetherGaugeData:
|
||||
is_numeric: bool = False
|
||||
|
||||
|
||||
def _state_label(data: AetherGaugeData) -> str:
|
||||
return {
|
||||
IndicatorType.FORCE_STOP: "STOP",
|
||||
IndicatorType.STOP_LIGHT: "RED LIGHT",
|
||||
IndicatorType.LEAD: "LEAD",
|
||||
}.get(data.indicator_type, "")
|
||||
|
||||
|
||||
# --- Source functions (replaces class-based sources) ---
|
||||
|
||||
def _build_curve_gauge_data(curvature: float, target_speed: float, v_cruise: float) -> AetherGaugeData:
|
||||
@@ -460,16 +608,13 @@ class AetherGauge:
|
||||
if not data:
|
||||
return
|
||||
|
||||
if cx is None or bottom is None:
|
||||
if cx is None:
|
||||
base_cx = rect.x + rect.width / 2
|
||||
cy_speed = rect.y + 180 * SCALE
|
||||
speed_text = str(round(current_speed))
|
||||
speed_text_size = measure_text_cached(font_bold, speed_text, int(176 * SCALE))
|
||||
icx = base_cx - speed_text_size.x / 2 - 70.0 * SCALE
|
||||
icy = cy_speed - 39.5 * SCALE
|
||||
else:
|
||||
icx = cx
|
||||
icy = bottom - ROAD_HALF_SIZE
|
||||
|
||||
if data.indicator_type != self._last_indicator_type:
|
||||
self._dist_filter.x = data.indicator_value
|
||||
@@ -497,60 +642,71 @@ class AetherGauge:
|
||||
data.unit = unit
|
||||
|
||||
if data.indicator_type in (IndicatorType.ROAD_CURVE, IndicatorType.FORCE_STOP, IndicatorType.LEAD, IndicatorType.STOP_LIGHT):
|
||||
self._render_unified_road(rect, icx, icy, data, font_bold, font_medium, alpha)
|
||||
self._render_unified_road(rect, icx, data, font_bold, font_medium, alpha)
|
||||
|
||||
def _render_unified_road(self, rect, icx, icy, data, font_bold, font_medium, alpha=1.0):
|
||||
bottom = icy + ROAD_HALF_SIZE
|
||||
def _render_unified_road(self, rect, icx, data, font_bold, font_medium, alpha=1.0):
|
||||
layout = _aether_layout(rect)
|
||||
_draw_aether_card(layout, alpha)
|
||||
bottom = layout.road.y + layout.road.height
|
||||
road_icy = bottom - ROAD_HALF_SIZE
|
||||
|
||||
if data.indicator_type in (IndicatorType.FORCE_STOP, IndicatorType.STOP_LIGHT):
|
||||
distance = 15.0
|
||||
else:
|
||||
distance = data.indicator_value
|
||||
|
||||
points_left = []
|
||||
points_right = []
|
||||
self._current_road_h = self._road_h_filter.update(_get_road_height(data))
|
||||
road_h = self._current_road_h
|
||||
# Custom widgets render after AugmentedRoadView releases its content
|
||||
# scissor. Keep every animated primitive inside the road viewport here.
|
||||
rl.begin_scissor_mode(
|
||||
int(layout.road.x), int(layout.road.y), int(layout.road.width), int(layout.road.height),
|
||||
)
|
||||
try:
|
||||
points_left = []
|
||||
points_right = []
|
||||
self._current_road_h = min(self._road_h_filter.update(_get_road_height(data)), layout.road.height)
|
||||
road_h = self._current_road_h
|
||||
|
||||
for i in range(ROAD_SEGMENTS + 1):
|
||||
t = i / ROAD_SEGMENTS
|
||||
offset = _get_perspective_offset(t, data)
|
||||
cx_t = icx + offset
|
||||
y_t = bottom - t * road_h
|
||||
w_t = ROAD_W_BOTTOM - t * (ROAD_W_BOTTOM - ROAD_W_TOP)
|
||||
for i in range(ROAD_SEGMENTS + 1):
|
||||
t = i / ROAD_SEGMENTS
|
||||
offset = _get_perspective_offset(t, data)
|
||||
cx_t = icx + offset
|
||||
y_t = bottom - t * road_h
|
||||
w_t = ROAD_W_BOTTOM - t * (ROAD_W_BOTTOM - ROAD_W_TOP)
|
||||
|
||||
points_left.append(rl.Vector2(cx_t - w_t, y_t))
|
||||
points_right.append(rl.Vector2(cx_t + w_t, y_t))
|
||||
points_left.append(rl.Vector2(cx_t - w_t, y_t))
|
||||
points_right.append(rl.Vector2(cx_t + w_t, y_t))
|
||||
|
||||
fill_color = _fade(_with_alpha(data.color, FILL_ALPHA), alpha)
|
||||
for i in range(ROAD_SEGMENTS):
|
||||
t = i / ROAD_SEGMENTS
|
||||
stroke = ROAD_THICKNESS * (1.0 - 0.6 * t)
|
||||
rl.draw_triangle(points_left[i], points_right[i], points_left[i+1], fill_color)
|
||||
rl.draw_triangle(points_right[i], points_right[i+1], points_left[i+1], fill_color)
|
||||
rl.draw_line_ex(points_left[i], points_left[i+1], stroke, _fade(COLOR_SHADOW, alpha))
|
||||
rl.draw_line_ex(points_right[i], points_right[i+1], stroke, _fade(COLOR_SHADOW, alpha))
|
||||
rl.draw_line_ex(points_left[i], points_left[i+1], stroke, _fade(data.color, alpha))
|
||||
rl.draw_line_ex(points_right[i], points_right[i+1], stroke, _fade(data.color, alpha))
|
||||
fill_color = _fade(_with_alpha(data.color, FILL_ALPHA), alpha)
|
||||
for i in range(ROAD_SEGMENTS):
|
||||
t = i / ROAD_SEGMENTS
|
||||
stroke = ROAD_THICKNESS * (1.0 - 0.6 * t)
|
||||
rl.draw_triangle(points_left[i], points_right[i], points_left[i+1], fill_color)
|
||||
rl.draw_triangle(points_right[i], points_right[i+1], points_left[i+1], fill_color)
|
||||
rl.draw_line_ex(points_left[i], points_left[i+1], stroke + ROAD_CONTOUR_WIDTH, _fade(COLOR_CONTOUR, alpha))
|
||||
rl.draw_line_ex(points_right[i], points_right[i+1], stroke + ROAD_CONTOUR_WIDTH, _fade(COLOR_CONTOUR, alpha))
|
||||
rl.draw_line_ex(points_left[i], points_left[i+1], stroke, _fade(data.color, alpha))
|
||||
rl.draw_line_ex(points_right[i], points_right[i+1], stroke, _fade(data.color, alpha))
|
||||
|
||||
it = data.indicator_type
|
||||
it = data.indicator_type
|
||||
|
||||
if it in (IndicatorType.STOP_LIGHT, IndicatorType.FORCE_STOP):
|
||||
self._draw_stop_line(icx, bottom, distance, data, alpha)
|
||||
if it in (IndicatorType.STOP_LIGHT, IndicatorType.FORCE_STOP):
|
||||
self._draw_stop_line(icx, bottom, distance, data, alpha)
|
||||
|
||||
if it == IndicatorType.LEAD:
|
||||
self._draw_lead_car(icx, bottom, data, alpha)
|
||||
if it == IndicatorType.LEAD:
|
||||
self._draw_lead_car(icx, bottom, data, alpha)
|
||||
|
||||
if it in (IndicatorType.ROAD_CURVE, IndicatorType.FORCE_STOP, IndicatorType.STOP_LIGHT):
|
||||
self._draw_standard_chevrons(icx, bottom, distance, data, alpha)
|
||||
if it in (IndicatorType.ROAD_CURVE, IndicatorType.FORCE_STOP, IndicatorType.STOP_LIGHT):
|
||||
self._draw_standard_chevrons(icx, bottom, distance, data, alpha)
|
||||
|
||||
if it == IndicatorType.STOP_LIGHT:
|
||||
self._draw_traffic_light(icx, icy, distance, data, alpha)
|
||||
if it == IndicatorType.STOP_LIGHT:
|
||||
self._draw_traffic_light(icx, road_icy, distance, data, alpha)
|
||||
|
||||
if it == IndicatorType.FORCE_STOP:
|
||||
self._draw_approaching_stop_sign(icx, icy, bottom, distance, data, font_bold, alpha)
|
||||
if it == IndicatorType.FORCE_STOP:
|
||||
self._draw_approaching_stop_sign(icx, road_icy, bottom, distance, data, font_bold, alpha)
|
||||
finally:
|
||||
rl.end_scissor_mode()
|
||||
|
||||
self._draw_mini_cradle(icx, bottom, data, font_bold, font_medium, alpha)
|
||||
self._draw_mini_cradle(layout.cradle, data, font_bold, font_medium, alpha)
|
||||
|
||||
def _draw_stop_line(self, icx, bottom, distance, data, alpha=1.0):
|
||||
t, cx_line, cy_line = _road_xy(distance, icx, bottom, data, self._current_road_h)
|
||||
@@ -562,6 +718,10 @@ class AetherGauge:
|
||||
fade = 1.0 - t * 0.5
|
||||
glow_a = int(150 * fade)
|
||||
|
||||
rl.draw_line_ex(
|
||||
p_left, p_right, max(3.0 * SCALE, 7.0 * SCALE * fade) + ROAD_CONTOUR_WIDTH,
|
||||
_fade(COLOR_CONTOUR, alpha),
|
||||
)
|
||||
rl.draw_line_ex(p_left, p_right, max(3.0 * SCALE, 7.0 * SCALE * fade), _fade(_with_alpha(COLOR_STOP_LINE_GLOW, glow_a), alpha))
|
||||
rl.draw_line_ex(p_left, p_right, max(1.5 * SCALE, 3.5 * SCALE * fade), _fade(COLOR_STOP_LINE_CORE, alpha))
|
||||
|
||||
@@ -642,7 +802,10 @@ class AetherGauge:
|
||||
|
||||
chev_a = max(0, min(255, int(data.color.a * (1.0 - t / t_lead) * math.sin(t / t_lead * math.pi))))
|
||||
c_color = _fade(_with_alpha(data.color, chev_a), alpha)
|
||||
c_contour = _fade(_with_alpha(COLOR_CONTOUR, int(chev_a * 0.9)), alpha)
|
||||
|
||||
rl.draw_line_ex(rl.Vector2(lx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, c_contour)
|
||||
rl.draw_line_ex(rl.Vector2(rx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, c_contour)
|
||||
rl.draw_line_ex(rl.Vector2(lx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick, c_color)
|
||||
rl.draw_line_ex(rl.Vector2(rx, cy_t - chevron_w * 0.5), rl.Vector2(cx_t, cy_t), chevron_thick, c_color)
|
||||
|
||||
@@ -690,7 +853,10 @@ class AetherGauge:
|
||||
chev_a = max(0, min(255, int(data.color.a * alpha_factor)))
|
||||
chev_color = _fade(_with_alpha(data.color, chev_a), alpha)
|
||||
chev_shadow = _fade(rl.Color(0, 0, 0, int(chev_a * 0.5)), alpha)
|
||||
chev_contour = _fade(_with_alpha(COLOR_CONTOUR, int(chev_a * 0.9)), alpha)
|
||||
|
||||
rl.draw_line_ex(rl.Vector2(lx, ly), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, chev_contour)
|
||||
rl.draw_line_ex(rl.Vector2(rx, ry), rl.Vector2(cx_t, cy_t), chevron_thick + ROAD_CONTOUR_WIDTH, chev_contour)
|
||||
rl.draw_line_ex(rl.Vector2(lx, ly + 1.5), rl.Vector2(cx_t, cy_t + 1.5), chevron_thick, chev_shadow)
|
||||
rl.draw_line_ex(rl.Vector2(rx, ry + 1.5), rl.Vector2(cx_t, cy_t + 1.5), chevron_thick, chev_shadow)
|
||||
rl.draw_line_ex(rl.Vector2(lx, ly), rl.Vector2(cx_t, cy_t), chevron_thick, chev_color)
|
||||
@@ -761,18 +927,53 @@ class AetherGauge:
|
||||
stop_txt_size = measure_text_cached(font_bold, "STOP", stop_font_size)
|
||||
rl.draw_text_ex(font_bold, "STOP", rl.Vector2(cx_stop - stop_txt_size.x / 2, y_sign - stop_txt_size.y / 2), stop_font_size, 0, _fade(rl.WHITE, alpha))
|
||||
|
||||
def _draw_mini_cradle(self, cx, bottom, data, font_bold, font_medium, alpha=1.0):
|
||||
def _draw_mini_cradle(self, cradle, data, font_bold, font_medium, alpha=1.0):
|
||||
if not data.text:
|
||||
return
|
||||
|
||||
if data.is_numeric:
|
||||
val_size = measure_text_cached(font_bold, data.text, int(50 * SCALE))
|
||||
val_pos = rl.Vector2(int(cx - val_size.x / 2), int(bottom + 6 * SCALE))
|
||||
_draw_text_with_shadow(font_bold, data.text, val_pos, int(50 * SCALE), data.color, alpha)
|
||||
label = _state_label(data)
|
||||
metric_top = int(round(cradle.y + AETHER_LABEL_SLOT_HEIGHT + AETHER_VALUE_GAP))
|
||||
cradle_right = cradle.x + cradle.width
|
||||
|
||||
accent_y = int(val_pos.y + val_size.y + 2 * SCALE)
|
||||
accent_w = int(val_size.x + 16 * SCALE)
|
||||
accent_x = int(cx - accent_w / 2)
|
||||
if data.is_numeric:
|
||||
unit_size = measure_text_cached(font_medium, data.unit, AETHER_UNIT_SIZE) if data.unit else None
|
||||
footer_height = AETHER_ACCENT_GAP
|
||||
if unit_size is not None:
|
||||
footer_height += AETHER_UNIT_GAP + unit_size.y
|
||||
max_metric_height = max(1.0, cradle.y + cradle.height - metric_top - footer_height)
|
||||
max_metric_width = max(1.0, cradle.width - 2 * _text_outline_px(AETHER_VALUE_MAX_SIZE))
|
||||
value_font_size, value_size, reduction_size, reduction_inline = _fit_numeric_line(
|
||||
font_bold, font_medium, data.text, data.reduction_text, max_metric_width, max_metric_height,
|
||||
)
|
||||
value_outline = _text_outline_px(value_font_size)
|
||||
reduction_outline = _text_outline_px(AETHER_REDUCTION_SIZE) if reduction_size is not None else 0
|
||||
|
||||
group_width = value_size.x
|
||||
if reduction_size is not None and reduction_inline:
|
||||
group_width += AETHER_REDUCTION_GAP + reduction_size.x
|
||||
value_x = int(round(cradle.x + (cradle.width - group_width) / 2))
|
||||
value_pos = rl.Vector2(value_x, metric_top)
|
||||
|
||||
label_max_width = cradle.width - 2 * _text_outline_px(AETHER_LABEL_SIZE)
|
||||
if label and reduction_size is not None and not reduction_inline:
|
||||
label_max_width -= reduction_size.x + AETHER_REDUCTION_GAP + reduction_outline
|
||||
if label:
|
||||
label_font_size, label_size = _fit_text_size(
|
||||
font_medium, label, AETHER_LABEL_SIZE, 1, max(1.0, label_max_width), AETHER_LABEL_SLOT_HEIGHT,
|
||||
)
|
||||
label_x = cradle.x + value_outline if reduction_size is not None and not reduction_inline else cradle.x + (cradle.width - label_size.x) / 2
|
||||
label_y = cradle.y + (AETHER_LABEL_SLOT_HEIGHT - label_size.y) / 2
|
||||
_draw_text_with_shadow(
|
||||
font_medium, label, rl.Vector2(int(round(label_x)), int(round(label_y))),
|
||||
label_font_size, COLOR_PRIMARY_TEXT, alpha,
|
||||
)
|
||||
|
||||
_draw_text_with_shadow(font_bold, data.text, value_pos, value_font_size, COLOR_PRIMARY_TEXT, alpha)
|
||||
|
||||
accent_y = int(round(value_pos.y + value_size.y + AETHER_ACCENT_GAP))
|
||||
accent_w = min(max_metric_width, value_size.x + 16 * SCALE)
|
||||
accent_x = int(round(value_pos.x + value_size.x / 2 - accent_w / 2))
|
||||
accent_x = max(int(round(cradle.x + value_outline)), min(accent_x, int(round(cradle_right - value_outline - accent_w))))
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(accent_x, accent_y),
|
||||
rl.Vector2(accent_x + accent_w, accent_y),
|
||||
@@ -780,16 +981,42 @@ class AetherGauge:
|
||||
_fade(_with_alpha(data.color, 160), alpha),
|
||||
)
|
||||
|
||||
if data.reduction_text:
|
||||
red_size = measure_text_cached(font_medium, data.reduction_text, int(22 * SCALE))
|
||||
red_pos = rl.Vector2(int(cx + val_size.x / 2 + 6 * SCALE), int(val_pos.y + val_size.y / 2 - red_size.y / 2))
|
||||
_draw_text_with_shadow(font_medium, data.reduction_text, red_pos, int(22 * SCALE), COLOR_REDUCTION, alpha)
|
||||
if reduction_size is not None:
|
||||
if reduction_inline:
|
||||
reduction_x = int(round(value_pos.x + value_size.x + AETHER_REDUCTION_GAP))
|
||||
reduction_y = int(round(value_pos.y + (value_size.y - reduction_size.y) / 2))
|
||||
else:
|
||||
reduction_x = int(round(cradle_right - reduction_size.x - reduction_outline))
|
||||
reduction_y = int(round(cradle.y + (AETHER_LABEL_SLOT_HEIGHT - reduction_size.y) / 2))
|
||||
_draw_text_with_shadow(
|
||||
font_medium, data.reduction_text, rl.Vector2(reduction_x, reduction_y),
|
||||
AETHER_REDUCTION_SIZE, COLOR_REDUCTION, alpha,
|
||||
)
|
||||
|
||||
if data.unit:
|
||||
unit_size = measure_text_cached(font_medium, data.unit, int(20 * SCALE))
|
||||
unit_pos = rl.Vector2(int(cx - unit_size.x / 2), int(accent_y + 3 * SCALE))
|
||||
_draw_text_with_shadow(font_medium, data.unit, unit_pos, int(20 * SCALE), rl.Color(255, 255, 255, 180), alpha)
|
||||
if unit_size is not None:
|
||||
unit_pos = rl.Vector2(
|
||||
int(round(cradle.x + (cradle.width - unit_size.x) / 2)),
|
||||
int(round(accent_y + AETHER_UNIT_GAP)),
|
||||
)
|
||||
_draw_text_with_shadow(font_medium, data.unit, unit_pos, AETHER_UNIT_SIZE, COLOR_SECONDARY_TEXT, alpha)
|
||||
else:
|
||||
val_size = measure_text_cached(font_bold, data.text, int(32 * SCALE))
|
||||
val_pos = rl.Vector2(int(cx - val_size.x / 2), int(bottom + 10 * SCALE))
|
||||
_draw_text_with_shadow(font_bold, data.text, val_pos, int(32 * SCALE), data.color, alpha)
|
||||
if label:
|
||||
label_font_size, label_size = _fit_text_size(
|
||||
font_medium, label, AETHER_LABEL_SIZE, 1,
|
||||
cradle.width - 2 * _text_outline_px(AETHER_LABEL_SIZE), AETHER_LABEL_SLOT_HEIGHT,
|
||||
)
|
||||
label_pos = rl.Vector2(
|
||||
int(round(cradle.x + (cradle.width - label_size.x) / 2)),
|
||||
int(round(cradle.y + (AETHER_LABEL_SLOT_HEIGHT - label_size.y) / 2)),
|
||||
)
|
||||
_draw_text_with_shadow(font_medium, label, label_pos, label_font_size, COLOR_PRIMARY_TEXT, alpha)
|
||||
|
||||
lead_font_size, lead_size = _fit_text_size(
|
||||
font_bold, data.text, AETHER_LEAD_MAX_SIZE, AETHER_LEAD_MIN_SIZE,
|
||||
cradle.width - 2 * _text_outline_px(AETHER_LEAD_MAX_SIZE),
|
||||
cradle.y + cradle.height - metric_top,
|
||||
)
|
||||
lead_pos = rl.Vector2(
|
||||
int(round(cradle.x + (cradle.width - lead_size.x) / 2)), metric_top,
|
||||
)
|
||||
_draw_text_with_shadow(font_bold, data.text, lead_pos, lead_font_size, COLOR_PRIMARY_TEXT, alpha)
|
||||
|
||||
@@ -9,12 +9,12 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import (
|
||||
CONTROL_BG, CONTROL_BORDER, CONTROL_BORDER_WIDTH, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, SLC_HEIGHT,
|
||||
CONTROL_BG, CONTROL_BORDER, CONTROL_BORDER_WIDTH, CONTROL_ROUNDNESS, CONTROL_SEGMENTS,
|
||||
draw_control_card, roundness_for,
|
||||
)
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.source_bubble_layout import (
|
||||
enabled_source_titles, fit_source_label, source_abbreviated_value_text,
|
||||
source_content_metrics, source_value_text, visible_source_rows,
|
||||
SourceBubbleModel, SourceBubbleTransition, enabled_source_titles,
|
||||
source_value_text, visible_source_rows,
|
||||
)
|
||||
from openpilot.selfdrive.ui.lib.starpilot_state import starpilot_state
|
||||
|
||||
@@ -35,7 +35,7 @@ SOURCE_DEFS = [
|
||||
("Dashboard", "Dash", "dashboard_sl", "Dashboard", "dashboard"),
|
||||
("Map Data", "MAP", "map_sl", "Map Data", "map"),
|
||||
("Vision", "VISION", "vision_sl", "Vision", "camera"),
|
||||
("Mapbox", "MBOX", "mapbox_sl", "Mapbox", "map"),
|
||||
("Mapbox", "MBOX", "mapbox_sl", "Mapbox", "navigation"),
|
||||
("Upcoming", "NEXT", "next_sl", "Next", "next"),
|
||||
]
|
||||
|
||||
@@ -179,7 +179,6 @@ def _get_slc_state():
|
||||
'offset_str': offset_str,
|
||||
'speed_conversion': speed_conversion,
|
||||
'speed_unit': " km/h" if ui_state.is_metric else " mph",
|
||||
'slc_abbreviated_sources': params.get_bool("SLCAbbreviatedSources"),
|
||||
'slc_active_sources_only': params.get_bool("SLCActiveSourcesOnly"),
|
||||
'slc_enabled_sources': enabled_source_titles(
|
||||
primary_priority,
|
||||
@@ -417,27 +416,33 @@ def _draw_sign(state: dict, rect: rl.Rectangle, *, pending: bool = False):
|
||||
# Fixed outer footprint; the content scale adapts to the visible row count.
|
||||
_SOURCE_PANEL_WIDTH = 248
|
||||
_SOURCE_PANEL_GAP = 20
|
||||
_SOURCE_PANEL_PAD_X = 9
|
||||
_SOURCE_PANEL_PAD_Y = 2
|
||||
_SOURCE_PANEL_PAD_X = 10
|
||||
_SOURCE_PANEL_PAD_Y = 8
|
||||
_SOURCE_PANEL_BG = rl.Color(0, 0, 0, 175)
|
||||
_SOURCE_PANEL_BORDER = rl.Color(196, 205, 208, 80)
|
||||
_SOURCE_DIVIDER = rl.Color(196, 205, 208, 100)
|
||||
_SOURCE_ACTIVE_BAR = rl.Color(CONTROL_BORDER.r, CONTROL_BORDER.g, CONTROL_BORDER.b, 230)
|
||||
_SOURCE_ICON_MUTED = rl.Color(160, 170, 175, 200)
|
||||
_SOURCE_LABEL_MUTED = rl.Color(166, 166, 166, 255)
|
||||
_SOURCE_ACTIVE_BAR_WIDTH = 6.0
|
||||
_SOURCE_ACTIVE_BAR_HEIGHT = 36.0
|
||||
_SOURCE_ACTIVE_BAR_X = 2.0
|
||||
_SOURCE_ACTIVE_BAR_ROW_INSET = 3.0
|
||||
_SOURCE_ICON_MUTED = rl.Color(184, 194, 198, 230)
|
||||
_SOURCE_LABEL_MUTED = rl.Color(205, 211, 214, 240)
|
||||
_SOURCE_MISSING = rl.Color(155, 166, 171, 220)
|
||||
_SOURCE_ACTIVE_BAR_WIDTH = 5.0
|
||||
_SOURCE_ACTIVE_BAR_X = 6.0
|
||||
_SOURCE_ACTIVE_BAR_CORNER_INSET = 20.0
|
||||
_SOURCE_ACTIVE_BAR_MIN_HEIGHT = 20.0
|
||||
_SOURCE_MIN_LABEL_VALUE_GAP = 6.0
|
||||
|
||||
_SOURCE_COMPACT_LABELS = {
|
||||
"Dashboard": "Dash",
|
||||
"Map Data": "OSM",
|
||||
"Vision": "Vision",
|
||||
"Mapbox": "Mapbox",
|
||||
"Next": "Next",
|
||||
}
|
||||
_SOURCE_READABLE_ICON_SIZE = 32.0
|
||||
_SOURCE_READABLE_ICON_GAP = 8.0
|
||||
_SOURCE_LABEL_FONT = 28
|
||||
_SOURCE_MIN_LABEL_FONT = 18
|
||||
_SOURCE_VALUE_FONT = 38
|
||||
_SOURCE_DENSE_VALUE_FONT = 38
|
||||
_SOURCE_DENSE_FOOTER_VALUE_FONT = 36
|
||||
_SOURCE_DENSE_ICON_SIZE = 32
|
||||
_SOURCE_DENSE_SMALL_ICON_SIZE = 28
|
||||
_SOURCE_DENSE_GAP = 8.0
|
||||
_SOURCE_DENSE_FOOTER_HEIGHT = 60.0
|
||||
_SOURCE_MIN_VALUE_FONT = 30
|
||||
_SOURCE_MIN_MESSAGE_FONT = 20
|
||||
|
||||
|
||||
def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.Color) -> None:
|
||||
@@ -477,7 +482,7 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.
|
||||
rl.draw_rectangle_rounded(body, 0.20, 8, color)
|
||||
lens = rl.Vector2(cx, y + size * 0.54)
|
||||
lens_outer = size * 0.17
|
||||
rl.draw_circle_v(lens, lens_outer, _SOURCE_PANEL_BG)
|
||||
rl.draw_circle_v(lens, lens_outer, _color_with_alpha(_SOURCE_PANEL_BG, color.a))
|
||||
rl.draw_ring(lens, size * 0.105, lens_outer, 0, 360, max(24, int(size * 0.25)), color)
|
||||
rl.draw_rectangle_rounded(
|
||||
rl.Rectangle(x + size * 0.30, y + size * 0.18, size * 0.23, size * 0.15),
|
||||
@@ -503,7 +508,7 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.
|
||||
rl.Vector2(cx, y + size * 0.86),
|
||||
color,
|
||||
)
|
||||
rl.draw_circle_v(pin_center, size * 0.09, _SOURCE_PANEL_BG)
|
||||
rl.draw_circle_v(pin_center, size * 0.09, _color_with_alpha(_SOURCE_PANEL_BG, color.a))
|
||||
else: # Dashboard / fallback
|
||||
dashboard_scale = 1.22
|
||||
pivot = rl.Vector2(cx, cy + size * 0.17)
|
||||
@@ -536,32 +541,355 @@ def _draw_source_icon(icon_key: str, x: float, y: float, size: float, color: rl.
|
||||
rl.draw_circle_v(pivot, max(2.0, size * 0.06 * dashboard_scale), color)
|
||||
|
||||
|
||||
def _draw_sources_bubble_empty_state(panel_rect: rl.Rectangle) -> None:
|
||||
"""Draw the 3-line centered empty state when no sources are available."""
|
||||
font = _get_semi_bold()
|
||||
font_size = 30
|
||||
line_gap = 6.0
|
||||
lines = (tr("NO"), tr("SOURCES"), tr("AVAILABLE"))
|
||||
|
||||
line_sizes = [measure_text_cached(font, line, font_size) for line in lines]
|
||||
total_h = sum(sz.y for sz in line_sizes) + line_gap * (len(lines) - 1)
|
||||
curr_y = round(panel_rect.y + (panel_rect.height - total_h) / 2)
|
||||
|
||||
for line, sz in zip(lines, line_sizes):
|
||||
pos_x = round(panel_rect.x + (panel_rect.width - sz.x) / 2)
|
||||
rl.draw_text_ex(font, line, rl.Vector2(pos_x, curr_y), font_size, 0, _WHITE)
|
||||
curr_y += round(sz.y + line_gap)
|
||||
def _color_with_alpha(color: rl.Color, alpha: int) -> rl.Color:
|
||||
return rl.Color(color.r, color.g, color.b, round(color.a * alpha / 255))
|
||||
|
||||
|
||||
def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
|
||||
"""Draw the expanded source list attached to the SLC card."""
|
||||
def _source_model(state: dict) -> SourceBubbleModel:
|
||||
enabled_sources = state.get('slc_enabled_sources', ())
|
||||
rows = visible_source_rows(
|
||||
SOURCE_DEFS,
|
||||
state,
|
||||
state['speed_limit_source'],
|
||||
enabled_sources,
|
||||
state.get('slc_active_sources_only', False),
|
||||
)
|
||||
if rows:
|
||||
return SourceBubbleModel(rows)
|
||||
reason = "No sources" if not enabled_sources else "No data"
|
||||
return SourceBubbleModel((), reason)
|
||||
|
||||
|
||||
def _fit_font_to_height(font, text: str, requested_size: int, max_height: float, minimum_size: int) -> int:
|
||||
size = requested_size
|
||||
while size > minimum_size and measure_text_cached(font, text, size).y > max_height:
|
||||
size -= 2
|
||||
return size
|
||||
|
||||
|
||||
def _fit_readable_label(font_semi, label_text: str, max_width: float, initial_size: int = _SOURCE_LABEL_FONT) -> int:
|
||||
"""Scale readable label font down if constrained by long translations."""
|
||||
size = initial_size
|
||||
while size > _SOURCE_MIN_LABEL_FONT and measure_text_cached(font_semi, label_text, size).x > max_width:
|
||||
size -= 2
|
||||
return size
|
||||
|
||||
|
||||
def _draw_sources_bubble_empty_state(panel_rect: rl.Rectangle, reason: str, alpha: int, font_semi) -> None:
|
||||
message = tr("No sources") if reason == "No sources" else tr("No data")
|
||||
font_size = _SOURCE_LABEL_FONT
|
||||
max_width = panel_rect.width - 2 * _SOURCE_PANEL_PAD_X
|
||||
max_height = panel_rect.height - 2 * _SOURCE_PANEL_PAD_Y
|
||||
size = measure_text_cached(font_semi, message, font_size)
|
||||
while font_size > _SOURCE_MIN_MESSAGE_FONT and (size.x > max_width or size.y > max_height):
|
||||
font_size -= 2
|
||||
size = measure_text_cached(font_semi, message, font_size)
|
||||
rl.draw_text_ex(
|
||||
font_semi,
|
||||
message,
|
||||
rl.Vector2(round(panel_rect.x + (panel_rect.width - size.x) / 2),
|
||||
round(panel_rect.y + (panel_rect.height - size.y) / 2)),
|
||||
font_size,
|
||||
0,
|
||||
_color_with_alpha(_SOURCE_LABEL_MUTED, alpha),
|
||||
)
|
||||
|
||||
|
||||
def _source_text_color(item, alpha: int) -> rl.Color:
|
||||
if not item.has_reading:
|
||||
return _color_with_alpha(_SOURCE_MISSING, alpha)
|
||||
return _color_with_alpha(_WHITE if item.is_active else _SOURCE_LABEL_MUTED, alpha)
|
||||
|
||||
|
||||
def _source_icon_color(item, alpha: int) -> rl.Color:
|
||||
if not item.has_reading:
|
||||
return _color_with_alpha(_SOURCE_MISSING, alpha)
|
||||
return _color_with_alpha(_WHITE if item.is_active else _SOURCE_ICON_MUTED, alpha)
|
||||
|
||||
|
||||
def _draw_readable_sources(
|
||||
model: SourceBubbleModel,
|
||||
panel_rect: rl.Rectangle,
|
||||
alpha: int,
|
||||
font_semi,
|
||||
font_bold,
|
||||
*,
|
||||
draw_chrome: bool,
|
||||
) -> None:
|
||||
content_left = panel_rect.x + _SOURCE_PANEL_PAD_X
|
||||
content_right = panel_rect.x + panel_rect.width - _SOURCE_PANEL_PAD_X
|
||||
content_top = panel_rect.y + _SOURCE_PANEL_PAD_Y
|
||||
content_height = panel_rect.height - 2 * _SOURCE_PANEL_PAD_Y
|
||||
row_height = content_height / len(model.items)
|
||||
value_font = _fit_font_to_height(
|
||||
font_bold,
|
||||
"000",
|
||||
_SOURCE_VALUE_FONT,
|
||||
max(1.0, row_height - 4),
|
||||
_SOURCE_MIN_VALUE_FONT,
|
||||
)
|
||||
|
||||
icon_size = _SOURCE_READABLE_ICON_SIZE
|
||||
icon_left = content_left + _SOURCE_ACTIVE_BAR_WIDTH + _SOURCE_MIN_LABEL_VALUE_GAP
|
||||
label_left = icon_left + icon_size + _SOURCE_READABLE_ICON_GAP
|
||||
|
||||
for index, item in enumerate(model.items):
|
||||
row_y = content_top + index * row_height
|
||||
if index and draw_chrome:
|
||||
divider_y = round(row_y)
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(content_left, divider_y),
|
||||
rl.Vector2(content_right, divider_y),
|
||||
1,
|
||||
_color_with_alpha(_SOURCE_DIVIDER, alpha),
|
||||
)
|
||||
|
||||
if item.is_active and draw_chrome:
|
||||
row_inset = min(12.0, row_height * 0.16)
|
||||
bar_top = max(panel_rect.y + _SOURCE_ACTIVE_BAR_CORNER_INSET, row_y + row_inset)
|
||||
bar_bottom = min(
|
||||
panel_rect.y + panel_rect.height - _SOURCE_ACTIVE_BAR_CORNER_INSET,
|
||||
row_y + row_height - row_inset,
|
||||
)
|
||||
active_bar_height = max(_SOURCE_ACTIVE_BAR_MIN_HEIGHT, bar_bottom - bar_top)
|
||||
active_bar_rect = rl.Rectangle(
|
||||
panel_rect.x + _SOURCE_ACTIVE_BAR_X,
|
||||
round(bar_top),
|
||||
_SOURCE_ACTIVE_BAR_WIDTH,
|
||||
round(active_bar_height),
|
||||
)
|
||||
rl.draw_rectangle_rounded(active_bar_rect, 0.5, 4, _color_with_alpha(_SOURCE_ACTIVE_BAR, alpha))
|
||||
|
||||
# Icon
|
||||
icon_y = round(row_y + (row_height - icon_size) / 2)
|
||||
_draw_source_icon(item.icon_key, icon_left, icon_y, icon_size, _source_icon_color(item, alpha))
|
||||
|
||||
# Value
|
||||
value_text = source_value_text(item.value)
|
||||
value_size = measure_text_cached(font_bold, value_text, value_font)
|
||||
value_y = round(row_y + (row_height - value_size.y) / 2)
|
||||
value_pos = rl.Vector2(round(content_right - value_size.x), value_y)
|
||||
|
||||
# Label (fitted to available width between icon and speed value)
|
||||
label_text = tr(item.label)
|
||||
max_label_width = max(10.0, (content_right - value_size.x - _SOURCE_MIN_LABEL_VALUE_GAP) - label_left)
|
||||
label_font = _fit_readable_label(font_semi, label_text, max_label_width, _SOURCE_LABEL_FONT)
|
||||
label_size = measure_text_cached(font_semi, label_text, label_font)
|
||||
label_y = round(row_y + (row_height - label_size.y) / 2)
|
||||
rl.draw_text_ex(
|
||||
font_semi,
|
||||
label_text,
|
||||
rl.Vector2(label_left, label_y),
|
||||
label_font,
|
||||
0,
|
||||
_source_text_color(item, alpha),
|
||||
)
|
||||
|
||||
rl.draw_text_ex(font_bold, value_text, value_pos, value_font, 0, _source_text_color(item, alpha))
|
||||
|
||||
|
||||
def _fit_dense_group(font_bold, value_text: str, cell: rl.Rectangle, icon_size: float, value_font: int) -> tuple[float, int, rl.Vector2]:
|
||||
"""Fit an icon/value group inside a dense cell without crossing its divider."""
|
||||
horizontal_edge = 8.0
|
||||
vertical_edge = 2.0
|
||||
fitted_icon = icon_size
|
||||
fitted_font = value_font
|
||||
fit_text = "0" * max(3, len(value_text))
|
||||
max_value_height = max(1.0, cell.height - 2 * vertical_edge)
|
||||
while fitted_font >= 32:
|
||||
value_size = measure_text_cached(font_bold, fit_text, fitted_font)
|
||||
if (value_size.y <= max_value_height and
|
||||
fitted_icon + _SOURCE_DENSE_GAP + value_size.x <= cell.width - horizontal_edge):
|
||||
return fitted_icon, fitted_font, value_size
|
||||
fitted_font -= 2
|
||||
|
||||
while fitted_icon >= 20:
|
||||
value_size = measure_text_cached(font_bold, fit_text, fitted_font)
|
||||
if (value_size.y <= max_value_height and
|
||||
fitted_icon + _SOURCE_DENSE_GAP + value_size.x <= cell.width - horizontal_edge):
|
||||
return fitted_icon, fitted_font, value_size
|
||||
fitted_icon -= 2
|
||||
|
||||
while fitted_font >= 20:
|
||||
value_size = measure_text_cached(font_bold, fit_text, fitted_font)
|
||||
if (value_size.y <= max_value_height and
|
||||
fitted_icon + _SOURCE_DENSE_GAP + value_size.x <= cell.width - horizontal_edge):
|
||||
return fitted_icon, fitted_font, value_size
|
||||
fitted_font -= 2
|
||||
|
||||
fitted_icon = min(fitted_icon, max(20.0, cell.height - 2 * vertical_edge))
|
||||
value_size = measure_text_cached(font_bold, fit_text, fitted_font)
|
||||
return fitted_icon, fitted_font, value_size
|
||||
|
||||
|
||||
def _draw_dense_source_cell(
|
||||
item,
|
||||
cell: rl.Rectangle,
|
||||
alpha: int,
|
||||
font_bold,
|
||||
*,
|
||||
icon_size: float,
|
||||
value_font: int,
|
||||
draw_chrome: bool,
|
||||
footer_label: str | None = None,
|
||||
font_semi = None,
|
||||
) -> None:
|
||||
is_active = item.is_active
|
||||
if draw_chrome:
|
||||
if is_active:
|
||||
rl.draw_rectangle_rounded(cell, 0.30, 8, _color_with_alpha(rl.Color(255, 255, 255, 28), alpha))
|
||||
rl.draw_rectangle_rounded_lines_ex(cell, 0.30, 8, 1.0, _color_with_alpha(rl.Color(255, 255, 255, 90), alpha))
|
||||
bar_h = max(16.0, cell.height - 20.0)
|
||||
bar_r = rl.Rectangle(cell.x + 4.0, round(cell.y + (cell.height - bar_h) / 2), 4.0, round(bar_h))
|
||||
rl.draw_rectangle_rounded(bar_r, 0.5, 4, _color_with_alpha(_SOURCE_ACTIVE_BAR, alpha))
|
||||
else:
|
||||
rl.draw_rectangle_rounded(cell, 0.30, 8, _color_with_alpha(rl.Color(255, 255, 255, 10), alpha))
|
||||
|
||||
value_text = source_value_text(item.value)
|
||||
icon_size, value_font, fit_size = _fit_dense_group(font_bold, value_text, cell, icon_size, value_font)
|
||||
value_size = measure_text_cached(font_bold, value_text, value_font)
|
||||
|
||||
if footer_label and font_semi:
|
||||
label_text = tr(footer_label)
|
||||
label_font = 24
|
||||
label_size = measure_text_cached(font_semi, label_text, label_font)
|
||||
total_group_w = icon_size + _SOURCE_DENSE_GAP + label_size.x + 16.0 + fit_size.x
|
||||
group_left = cell.x + (cell.width - total_group_w) / 2
|
||||
if is_active:
|
||||
group_left += 2.0
|
||||
icon_y = cell.y + (cell.height - icon_size) / 2
|
||||
label_y = cell.y + (cell.height - label_size.y) / 2
|
||||
text_y = cell.y + (cell.height - value_size.y) / 2
|
||||
|
||||
_draw_source_icon(item.icon_key, round(group_left), round(icon_y), icon_size, _source_icon_color(item, alpha))
|
||||
rl.draw_text_ex(
|
||||
font_semi,
|
||||
label_text,
|
||||
rl.Vector2(round(group_left + icon_size + _SOURCE_DENSE_GAP), round(label_y)),
|
||||
label_font,
|
||||
0,
|
||||
_source_text_color(item, alpha),
|
||||
)
|
||||
rl.draw_text_ex(
|
||||
font_bold,
|
||||
value_text,
|
||||
rl.Vector2(
|
||||
round(group_left + icon_size + _SOURCE_DENSE_GAP + label_size.x + 16.0 + (fit_size.x - value_size.x) / 2),
|
||||
round(text_y),
|
||||
),
|
||||
value_font,
|
||||
0,
|
||||
_source_text_color(item, alpha),
|
||||
)
|
||||
else:
|
||||
total_group_w = icon_size + _SOURCE_DENSE_GAP + fit_size.x
|
||||
group_left = cell.x + (cell.width - total_group_w) / 2
|
||||
if is_active:
|
||||
group_left += 2.0
|
||||
icon_y = cell.y + (cell.height - icon_size) / 2
|
||||
text_y = cell.y + (cell.height - value_size.y) / 2
|
||||
|
||||
_draw_source_icon(item.icon_key, round(group_left), round(icon_y), icon_size, _source_icon_color(item, alpha))
|
||||
rl.draw_text_ex(
|
||||
font_bold,
|
||||
value_text,
|
||||
rl.Vector2(
|
||||
round(group_left + icon_size + _SOURCE_DENSE_GAP + (fit_size.x - value_size.x) / 2),
|
||||
round(text_y),
|
||||
),
|
||||
value_font,
|
||||
0,
|
||||
_source_text_color(item, alpha),
|
||||
)
|
||||
|
||||
|
||||
def _draw_dense_sources(model: SourceBubbleModel, panel_rect: rl.Rectangle, alpha: int, font_semi, font_bold, *, draw_chrome: bool) -> None:
|
||||
pad = 8.0
|
||||
gap = 6.0
|
||||
content_w = panel_rect.width - 2 * pad
|
||||
content_h = panel_rect.height - 2 * pad
|
||||
|
||||
if len(model.items) == 5:
|
||||
footer_h = min(44.0, content_h * 0.28)
|
||||
grid_h = content_h - footer_h - gap
|
||||
cell_w = (content_w - gap) / 2
|
||||
cell_h = (grid_h - gap) / 2
|
||||
for index, item in enumerate(model.items[:4]):
|
||||
row, column = divmod(index, 2)
|
||||
_draw_dense_source_cell(
|
||||
item,
|
||||
rl.Rectangle(
|
||||
panel_rect.x + pad + column * (cell_w + gap),
|
||||
panel_rect.y + pad + row * (cell_h + gap),
|
||||
cell_w,
|
||||
cell_h,
|
||||
),
|
||||
alpha,
|
||||
font_bold,
|
||||
icon_size=_SOURCE_DENSE_SMALL_ICON_SIZE,
|
||||
value_font=_SOURCE_DENSE_FOOTER_VALUE_FONT,
|
||||
draw_chrome=draw_chrome,
|
||||
)
|
||||
_draw_dense_source_cell(
|
||||
model.items[4],
|
||||
rl.Rectangle(panel_rect.x + pad, panel_rect.y + pad + grid_h + gap, content_w, footer_h),
|
||||
alpha,
|
||||
font_bold,
|
||||
icon_size=_SOURCE_DENSE_SMALL_ICON_SIZE,
|
||||
value_font=_SOURCE_DENSE_FOOTER_VALUE_FONT,
|
||||
draw_chrome=draw_chrome,
|
||||
footer_label=model.items[4].label,
|
||||
font_semi=font_semi,
|
||||
)
|
||||
return
|
||||
|
||||
cell_w = (content_w - gap) / 2
|
||||
cell_h = (content_h - gap) / 2
|
||||
for index, item in enumerate(model.items):
|
||||
row, column = divmod(index, 2)
|
||||
_draw_dense_source_cell(
|
||||
item,
|
||||
rl.Rectangle(
|
||||
panel_rect.x + pad + column * (cell_w + gap),
|
||||
panel_rect.y + pad + row * (cell_h + gap),
|
||||
cell_w,
|
||||
cell_h,
|
||||
),
|
||||
alpha,
|
||||
font_bold,
|
||||
icon_size=_SOURCE_DENSE_ICON_SIZE,
|
||||
value_font=_SOURCE_DENSE_VALUE_FONT,
|
||||
draw_chrome=draw_chrome,
|
||||
)
|
||||
|
||||
|
||||
def _draw_source_bubble_content(
|
||||
model: SourceBubbleModel,
|
||||
panel_rect: rl.Rectangle,
|
||||
alpha: int,
|
||||
font_semi,
|
||||
font_bold,
|
||||
*,
|
||||
draw_chrome: bool,
|
||||
) -> None:
|
||||
if alpha <= 0:
|
||||
return
|
||||
if model.mode == "empty":
|
||||
_draw_sources_bubble_empty_state(panel_rect, model.empty_reason or "No data", alpha, font_semi)
|
||||
elif model.mode == "dense":
|
||||
_draw_dense_sources(model, panel_rect, alpha, font_semi, font_bold, draw_chrome=draw_chrome)
|
||||
else:
|
||||
_draw_readable_sources(model, panel_rect, alpha, font_semi, font_bold, draw_chrome=draw_chrome)
|
||||
|
||||
|
||||
def _draw_sources_bubble(
|
||||
state: dict,
|
||||
sign_rect: rl.Rectangle,
|
||||
transition: SourceBubbleTransition | None = None,
|
||||
) -> None:
|
||||
"""Draw the expanded source bubble inside its fixed attached footprint."""
|
||||
font_semi = _get_semi_bold()
|
||||
font_bold = _get_bold()
|
||||
active_source = state['speed_limit_source']
|
||||
enabled_sources = state.get('slc_enabled_sources', ())
|
||||
active_only = state.get('slc_active_sources_only', False)
|
||||
abbreviated = state.get('slc_abbreviated_sources', False)
|
||||
|
||||
panel_rect = rl.Rectangle(
|
||||
sign_rect.x + sign_rect.width + _SOURCE_PANEL_GAP,
|
||||
sign_rect.y,
|
||||
@@ -573,113 +901,47 @@ def _draw_sources_bubble(state: dict, sign_rect: rl.Rectangle):
|
||||
panel_rect, CONTROL_ROUNDNESS, CONTROL_SEGMENTS, 1, _SOURCE_PANEL_BORDER,
|
||||
)
|
||||
|
||||
rows = [
|
||||
(
|
||||
panel_label,
|
||||
_SOURCE_COMPACT_LABELS[panel_label],
|
||||
icon_key,
|
||||
value,
|
||||
is_active,
|
||||
)
|
||||
for panel_label, icon_key, value, is_active in visible_source_rows(
|
||||
SOURCE_DEFS, state, active_source, enabled_sources, active_only,
|
||||
)
|
||||
]
|
||||
model = _source_model(state)
|
||||
if transition is None:
|
||||
outgoing, incoming, alpha = model, None, 1.0
|
||||
else:
|
||||
outgoing, incoming, alpha = transition.update(model, rl.get_time())
|
||||
|
||||
if not rows:
|
||||
_draw_sources_bubble_empty_state(panel_rect)
|
||||
return
|
||||
|
||||
row_h = (panel_rect.height - 2 * _SOURCE_PANEL_PAD_Y) / len(rows)
|
||||
content_left = panel_rect.x + _SOURCE_PANEL_PAD_X
|
||||
content_right = panel_rect.x + panel_rect.width - _SOURCE_PANEL_PAD_X
|
||||
font_size, icon_size, icon_gap = source_content_metrics(len(rows))
|
||||
label_left = (
|
||||
content_left + _SOURCE_ACTIVE_BAR_WIDTH + _SOURCE_MIN_LABEL_VALUE_GAP
|
||||
if abbreviated else content_left + icon_size + icon_gap
|
||||
is_transitioning = incoming is not None
|
||||
outgoing_chrome = not is_transitioning or alpha < 0.5
|
||||
incoming_chrome = not is_transitioning or alpha >= 0.5
|
||||
_draw_source_bubble_content(
|
||||
outgoing,
|
||||
panel_rect,
|
||||
round(255 * (1.0 - alpha)),
|
||||
font_semi,
|
||||
font_bold,
|
||||
draw_chrome=outgoing_chrome,
|
||||
)
|
||||
_draw_source_bubble_content(
|
||||
incoming or outgoing,
|
||||
panel_rect,
|
||||
round(255 * alpha),
|
||||
font_semi,
|
||||
font_bold,
|
||||
draw_chrome=incoming_chrome,
|
||||
)
|
||||
|
||||
for index, (panel_label, compact_label, icon_key, value, is_active) in enumerate(rows):
|
||||
row_y = panel_rect.y + _SOURCE_PANEL_PAD_Y + index * row_h
|
||||
if index:
|
||||
divider_y = round(row_y)
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(content_left, divider_y),
|
||||
rl.Vector2(content_right, divider_y),
|
||||
1,
|
||||
_SOURCE_DIVIDER,
|
||||
)
|
||||
|
||||
if is_active:
|
||||
active_bar_height = min(
|
||||
_SOURCE_ACTIVE_BAR_HEIGHT,
|
||||
max(10.0, row_h - 2 * _SOURCE_ACTIVE_BAR_ROW_INSET),
|
||||
)
|
||||
active_bar_rect = rl.Rectangle(
|
||||
panel_rect.x + _SOURCE_ACTIVE_BAR_X,
|
||||
round(row_y + (row_h - active_bar_height) / 2),
|
||||
_SOURCE_ACTIVE_BAR_WIDTH,
|
||||
active_bar_height,
|
||||
)
|
||||
rl.draw_rectangle_rounded(active_bar_rect, 0.5, 4, _SOURCE_ACTIVE_BAR)
|
||||
|
||||
value_text = source_value_text(value)
|
||||
text_color = _WHITE if is_active else _SOURCE_LABEL_MUTED
|
||||
|
||||
if abbreviated:
|
||||
text_font = font_bold if is_active else font_semi
|
||||
label_text = fit_source_label(
|
||||
f"{tr(compact_label)}-{source_abbreviated_value_text(value)}",
|
||||
"",
|
||||
content_right - label_left,
|
||||
lambda text: measure_text_cached(text_font, text, font_size).x,
|
||||
)
|
||||
label_size = measure_text_cached(text_font, label_text, font_size)
|
||||
text_y = round(row_y + (row_h - label_size.y) / 2)
|
||||
rl.draw_text_ex(
|
||||
text_font,
|
||||
label_text,
|
||||
rl.Vector2(label_left, text_y),
|
||||
font_size,
|
||||
0,
|
||||
text_color,
|
||||
)
|
||||
continue
|
||||
|
||||
compact_label = tr(compact_label)
|
||||
full_label = tr(panel_label)
|
||||
value_size = measure_text_cached(font_bold, value_text, font_size)
|
||||
max_label_width = max(
|
||||
0.0,
|
||||
content_right - label_left - _SOURCE_MIN_LABEL_VALUE_GAP - value_size.x,
|
||||
)
|
||||
label_text = fit_source_label(
|
||||
full_label,
|
||||
compact_label,
|
||||
max_label_width,
|
||||
lambda text: measure_text_cached(font_semi, text, font_size).x,
|
||||
)
|
||||
label_size = measure_text_cached(font_semi, label_text, font_size)
|
||||
text_height = max(label_size.y, value_size.y)
|
||||
text_y = round(row_y + (row_h - text_height) / 2)
|
||||
icon_y = round(row_y + (row_h - icon_size) / 2)
|
||||
|
||||
icon_color = _WHITE if is_active else _SOURCE_ICON_MUTED
|
||||
_draw_source_icon(icon_key, content_left, icon_y, icon_size, icon_color)
|
||||
|
||||
label_pos = rl.Vector2(label_left, text_y)
|
||||
value_pos = rl.Vector2(round(content_right - value_size.x), text_y)
|
||||
rl.draw_text_ex(font_semi, label_text, label_pos, font_size, 0, text_color)
|
||||
rl.draw_text_ex(font_bold, value_text, value_pos, font_size, 0, text_color)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
def render_speed_limit_at(state: dict, rect: rl.Rectangle, expanded: bool = False) -> Optional[rl.Rectangle]:
|
||||
def render_speed_limit_at(
|
||||
state: dict,
|
||||
rect: rl.Rectangle,
|
||||
expanded: bool = False,
|
||||
source_bubble_transition: SourceBubbleTransition | None = None,
|
||||
) -> Optional[rl.Rectangle]:
|
||||
"""Render the SLC sign and optional source bubble at a layout rect."""
|
||||
flashing_pending = state['speed_limit_changed'] and state['unconfirmed_valid']
|
||||
|
||||
if flashing_pending:
|
||||
if source_bubble_transition is not None:
|
||||
source_bubble_transition.reset()
|
||||
_draw_sign(state, rect, pending=True)
|
||||
return None
|
||||
|
||||
@@ -689,6 +951,6 @@ def render_speed_limit_at(state: dict, rect: rl.Rectangle, expanded: bool = Fals
|
||||
visual_rect = rl.Rectangle(rect.x, rect.y, EU_SIGN_SIZE, EU_SIGN_SIZE) if use_vienna else rect
|
||||
|
||||
if expanded:
|
||||
_draw_sources_bubble(state, visual_rect)
|
||||
_draw_sources_bubble(state, visual_rect, source_bubble_transition)
|
||||
|
||||
return visual_rect
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
"""Pure layout decisions for the on-road speed-limit source bubble."""
|
||||
"""Pure source selection and transition decisions for the SLC bubble."""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
|
||||
SOURCE_DISPLAY_ORDER = ("Dashboard", "Map Data", "Vision", "Mapbox", "Upcoming")
|
||||
SOURCE_PRIORITY_NAMES = frozenset(("Dashboard", "Map Data", "Vision"))
|
||||
|
||||
SOURCE_BUBBLE_SETTLE_SECONDS = 0.12
|
||||
SOURCE_BUBBLE_TRANSITION_SECONDS = 0.20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceBubbleItem:
|
||||
"""One source row/cell in canonical display order."""
|
||||
|
||||
source_id: str
|
||||
label: str
|
||||
icon_key: str
|
||||
value: float
|
||||
has_reading: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceBubbleModel:
|
||||
"""The source roster to render inside the fixed bubble footprint."""
|
||||
|
||||
items: tuple[SourceBubbleItem, ...]
|
||||
empty_reason: str | None = None
|
||||
|
||||
@property
|
||||
def mode(self) -> str:
|
||||
if not self.items:
|
||||
return "empty"
|
||||
return "dense" if len(self.items) >= 4 else "readable"
|
||||
|
||||
@property
|
||||
def structural_key(self) -> tuple[str, tuple[tuple[str, str, str], ...], str | None]:
|
||||
return (
|
||||
self.mode,
|
||||
tuple((item.source_id, item.label, item.icon_key) for item in self.items),
|
||||
self.empty_reason,
|
||||
)
|
||||
|
||||
|
||||
def enabled_source_titles(
|
||||
primary_priority: str,
|
||||
@@ -16,13 +54,7 @@ def enabled_source_titles(
|
||||
mapbox_enabled: bool,
|
||||
dashboard_available: bool = True,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return source rows that are eligible under the current SLC settings.
|
||||
|
||||
``Highest`` and ``Lowest`` are aggregate priority modes. They consider the
|
||||
two non-vision controller inputs directly; Vision is only a controller input
|
||||
when it is explicitly selected in one of the priority slots. Mapbox is a
|
||||
separate fallback toggle, and Next is derived from the selected map source.
|
||||
"""
|
||||
"""Return source rows eligible under the current SLC settings."""
|
||||
if primary_priority in ("Highest", "Lowest"):
|
||||
enabled = {"Dashboard", "Map Data"}
|
||||
else:
|
||||
@@ -45,13 +77,8 @@ def enabled_source_titles(
|
||||
return tuple(source for source in SOURCE_DISPLAY_ORDER if source in enabled)
|
||||
|
||||
|
||||
def source_content_metrics(row_count: int) -> tuple[int, int, int]:
|
||||
"""Return logical text size, icon size, and icon gap for the visible rows."""
|
||||
if row_count <= 3:
|
||||
return 30, 34, 7
|
||||
if row_count == 4:
|
||||
return 30, 32, 7
|
||||
return 28, 30, 6
|
||||
def _has_reading(value: float) -> bool:
|
||||
return math.isfinite(value) and value > 0 and round(value) > 0
|
||||
|
||||
|
||||
def visible_source_rows(
|
||||
@@ -59,54 +86,138 @@ def visible_source_rows(
|
||||
values: Mapping[str, float],
|
||||
active_source: str,
|
||||
enabled_sources: Iterable[str],
|
||||
active_only: bool = True,
|
||||
) -> list[tuple[str, str, float, bool]]:
|
||||
"""Return enabled source rows that possess a valid positive speed reading."""
|
||||
active_only: bool = False,
|
||||
) -> tuple[SourceBubbleItem, ...]:
|
||||
"""Return enabled sources, optionally filtering those without readings.
|
||||
|
||||
Enabled sources remain visible with an em dash by default. This preserves
|
||||
source discoverability without reserving space for sources disabled in the
|
||||
user's settings.
|
||||
"""
|
||||
enabled = set(enabled_sources)
|
||||
rows = []
|
||||
for title, _abbrev, value_key, panel_label, icon_key in source_defs:
|
||||
for title, _active_label, value_key, panel_label, icon_key in source_defs:
|
||||
if title not in enabled:
|
||||
continue
|
||||
value = values[value_key]
|
||||
has_reading = math.isfinite(value) and value > 0 and round(value) > 0
|
||||
if not has_reading:
|
||||
has_reading = _has_reading(value)
|
||||
if active_only and not has_reading:
|
||||
continue
|
||||
rows.append((
|
||||
panel_label,
|
||||
icon_key,
|
||||
value,
|
||||
active_source == title,
|
||||
rows.append(SourceBubbleItem(
|
||||
source_id=title,
|
||||
label=panel_label,
|
||||
icon_key=icon_key,
|
||||
value=value,
|
||||
has_reading=has_reading,
|
||||
is_active=active_source == title,
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
def fit_source_label(
|
||||
full_label: str,
|
||||
compact_label: str,
|
||||
max_width: float,
|
||||
measure_width: Callable[[str], float],
|
||||
) -> str:
|
||||
"""Choose the longest useful label that leaves room for the value column."""
|
||||
for label in (full_label, compact_label):
|
||||
if measure_width(label) <= max_width:
|
||||
return label
|
||||
|
||||
ellipsis = "…"
|
||||
candidate = compact_label or full_label
|
||||
while candidate and measure_width(candidate + ellipsis) > max_width:
|
||||
candidate = candidate[:-1]
|
||||
return f"{candidate}{ellipsis}" if candidate else ellipsis
|
||||
return tuple(rows)
|
||||
|
||||
|
||||
def source_value_text(value: float) -> str:
|
||||
"""Format a source speed, keeping missing and non-finite values explicit."""
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
if not _has_reading(value):
|
||||
return "–"
|
||||
rounded = int(round(value))
|
||||
return "–" if rounded <= 0 else str(rounded)
|
||||
return str(int(round(value)))
|
||||
|
||||
|
||||
def source_abbreviated_value_text(value: float) -> str:
|
||||
"""Format a compact source value using the established missing-value marker."""
|
||||
value_text = source_value_text(value)
|
||||
return "X" if value_text == "–" else value_text
|
||||
def _ease_out_cubic(progress: float) -> float:
|
||||
progress = min(1.0, max(0.0, progress))
|
||||
return 1.0 - (1.0 - progress) ** 3
|
||||
|
||||
|
||||
def _refresh_model_values(base: SourceBubbleModel, live: SourceBubbleModel) -> SourceBubbleModel:
|
||||
"""Keep structural content stable while refreshing values during a blend."""
|
||||
live_by_id = {item.source_id: item for item in live.items}
|
||||
refreshed = tuple(
|
||||
live_by_id.get(item.source_id, replace(item, value=0.0, has_reading=False))
|
||||
for item in base.items
|
||||
)
|
||||
return SourceBubbleModel(refreshed, base.empty_reason)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceBubbleTransition:
|
||||
"""Coalesce roster changes and crossfade stable bubble presentations."""
|
||||
|
||||
settle_seconds: float = SOURCE_BUBBLE_SETTLE_SECONDS
|
||||
duration_seconds: float = SOURCE_BUBBLE_TRANSITION_SECONDS
|
||||
_current: SourceBubbleModel | None = None
|
||||
_pending: SourceBubbleModel | None = None
|
||||
_pending_since: float | None = None
|
||||
_from: SourceBubbleModel | None = None
|
||||
_to: SourceBubbleModel | None = None
|
||||
_started: float | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
self._current = None
|
||||
self._pending = None
|
||||
self._pending_since = None
|
||||
self._from = None
|
||||
self._to = None
|
||||
self._started = None
|
||||
|
||||
def _begin(self, target: SourceBubbleModel, now: float) -> None:
|
||||
if self._current is None:
|
||||
self._current = target
|
||||
return
|
||||
self._from = self._current
|
||||
self._to = target
|
||||
self._started = now
|
||||
|
||||
def _queue(self, model: SourceBubbleModel, now: float) -> None:
|
||||
if self._pending is None or self._pending.structural_key != model.structural_key:
|
||||
self._pending_since = now
|
||||
self._pending = model
|
||||
|
||||
def update(
|
||||
self,
|
||||
model: SourceBubbleModel,
|
||||
now: float,
|
||||
) -> tuple[SourceBubbleModel, SourceBubbleModel | None, float]:
|
||||
"""Return outgoing model, incoming model, and eased incoming alpha."""
|
||||
if self._current is None:
|
||||
self._current = model
|
||||
return model, None, 1.0
|
||||
|
||||
if self._from is not None and self._to is not None and self._started is not None:
|
||||
if model.structural_key == self._to.structural_key:
|
||||
self._to = model
|
||||
elif model.structural_key == self._from.structural_key:
|
||||
self._current = model
|
||||
self._pending = None
|
||||
self._pending_since = None
|
||||
self._from = None
|
||||
self._to = None
|
||||
self._started = None
|
||||
return model, None, 1.0
|
||||
else:
|
||||
self._from = _refresh_model_values(self._from, model)
|
||||
self._queue(model, now)
|
||||
|
||||
progress = (now - self._started) / max(self.duration_seconds, 1e-6)
|
||||
if progress < 1.0:
|
||||
return self._from, self._to, _ease_out_cubic(progress)
|
||||
|
||||
self._current = self._to
|
||||
self._from = None
|
||||
self._to = None
|
||||
self._started = None
|
||||
|
||||
if model.structural_key == self._current.structural_key:
|
||||
self._current = model
|
||||
self._pending = None
|
||||
self._pending_since = None
|
||||
return self._current, None, 1.0
|
||||
|
||||
self._current = _refresh_model_values(self._current, model)
|
||||
self._queue(model, now)
|
||||
if self._pending_since is not None and now - self._pending_since >= self.settle_seconds:
|
||||
target = self._pending
|
||||
self._pending = None
|
||||
self._pending_since = None
|
||||
self._begin(target, now)
|
||||
if self._from is not None and self._to is not None:
|
||||
return self._from, self._to, 0.0
|
||||
|
||||
return self._current, None, 1.0
|
||||
|
||||
@@ -2,7 +2,7 @@ import pyray as rl
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import AetherGauge, _fade
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import AetherGauge
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import CONTROL_WIDTH
|
||||
|
||||
class AetherGaugeWidget(LayoutWidget):
|
||||
@@ -29,12 +29,10 @@ class AetherGaugeWidget(LayoutWidget):
|
||||
return
|
||||
|
||||
cx = rect.x + rect.width / 2
|
||||
# Set the road bottom to rect.y + 145, leaving 115px for the text cradle underneath
|
||||
bottom = rect.y + 145.0
|
||||
# AetherGauge derives its bounded road and metric viewports from rect.
|
||||
self._aethergauge.render(
|
||||
rect, self._font_bold, self._font_medium,
|
||||
current_speed=self.hud_renderer.speed,
|
||||
cx=cx,
|
||||
bottom=bottom,
|
||||
alpha=alpha,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from openpilot.selfdrive.ui.onroad.starpilot.widgets.base import LayoutWidget
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.slc_speed_limit import (
|
||||
_get_slc_state, render_speed_limit_at, EU_SIGN_SIZE,
|
||||
)
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.source_bubble_layout import SourceBubbleTransition
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.widget_style import CONTROL_WIDTH, SLC_HEIGHT
|
||||
|
||||
|
||||
@@ -16,6 +17,7 @@ class SpeedLimitWidget(LayoutWidget):
|
||||
super().__init__("speed_limit", priority=2)
|
||||
self._slc_state: dict | None = None
|
||||
self._sign_rect: Optional[rl.Rectangle] = None
|
||||
self._source_bubble_transition = SourceBubbleTransition()
|
||||
|
||||
@property
|
||||
def _hit_rect(self) -> rl.Rectangle:
|
||||
@@ -33,6 +35,7 @@ class SpeedLimitWidget(LayoutWidget):
|
||||
self._slc_state = _get_slc_state()
|
||||
if self._slc_state is None:
|
||||
self._sign_rect = None
|
||||
self._source_bubble_transition.reset()
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -51,7 +54,14 @@ class SpeedLimitWidget(LayoutWidget):
|
||||
return
|
||||
params = ui_state.ui_params
|
||||
expanded = params.get_bool("SpeedLimitSources")
|
||||
self._sign_rect = render_speed_limit_at(self._slc_state, rect, expanded)
|
||||
if not expanded:
|
||||
self._source_bubble_transition.reset()
|
||||
self._sign_rect = render_speed_limit_at(
|
||||
self._slc_state,
|
||||
rect,
|
||||
expanded,
|
||||
self._source_bubble_transition,
|
||||
)
|
||||
|
||||
def _handle_mouse_press(self, mouse_pos) -> None:
|
||||
state = self._slc_state
|
||||
|
||||
@@ -31,16 +31,20 @@ mock_ui_state = types.SimpleNamespace(
|
||||
},
|
||||
)
|
||||
from openpilot.selfdrive.ui.onroad.starpilot import aethergauge
|
||||
from openpilot.selfdrive.ui.onroad.starpilot import widget_style
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.aethergauge import (
|
||||
AetherGauge,
|
||||
AetherGaugeData,
|
||||
IndicatorType,
|
||||
_aether_layout,
|
||||
_cem_curvature_data,
|
||||
_draw_aether_card,
|
||||
_is_cem_curvature,
|
||||
_is_curve_speed,
|
||||
_is_lead,
|
||||
_is_stop_light,
|
||||
_lead_data,
|
||||
_state_label,
|
||||
)
|
||||
from openpilot.starpilot.common.experimental_state import CEStatus
|
||||
|
||||
@@ -295,7 +299,7 @@ def test_monotonic_ratchet_clamp_prevents_upward_bounce(monkeypatch):
|
||||
|
||||
rendered_data = []
|
||||
gauge = AetherGauge()
|
||||
monkeypatch.setattr(gauge, "_render_unified_road", lambda rect, cx, cy, data, fb, fm, alpha: rendered_data.append(data))
|
||||
monkeypatch.setattr(gauge, "_render_unified_road", lambda rect, cx, data, fb, fm, alpha: rendered_data.append(data))
|
||||
|
||||
# Initial frame at 30m
|
||||
_set_plan(redLight=True, forcingStopLength=30.0)
|
||||
@@ -307,3 +311,137 @@ def test_monotonic_ratchet_clamp_prevents_upward_bounce(monkeypatch):
|
||||
gauge.render(None, None, None, current_speed=10.0, cx=100.0, bottom=200.0)
|
||||
# Ratchet clamp must prevent the display number from increasing above 30m!
|
||||
assert int(rendered_data[-1].text) <= 30
|
||||
|
||||
|
||||
def test_aether_card_reuses_the_shared_control_frame(monkeypatch):
|
||||
triangles = []
|
||||
fills = []
|
||||
keylines = []
|
||||
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_triangle", lambda *args: triangles.append(args))
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded", lambda *args: fills.append(args))
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded_lines_ex", lambda *args: keylines.append(args))
|
||||
|
||||
rect = aethergauge.rl.Rectangle(10, 20, 176, 260)
|
||||
layout = _aether_layout(rect)
|
||||
_draw_aether_card(layout, 0.5)
|
||||
|
||||
assert not triangles
|
||||
assert len(fills) == 1
|
||||
assert len(keylines) == 1
|
||||
|
||||
card, roundness, segments, fill = fills[0]
|
||||
keyline_card, keyline_roundness, keyline_segments, border_width, border = keylines[0]
|
||||
assert card.x == rect.x
|
||||
assert card.y == rect.y
|
||||
assert card.width == rect.width
|
||||
assert card.height == rect.height
|
||||
assert keyline_card.x == card.x
|
||||
assert keyline_card.y == card.y
|
||||
assert keyline_card.width == card.width
|
||||
assert keyline_card.height == card.height
|
||||
assert roundness == keyline_roundness == widget_style.CONTROL_ROUNDNESS
|
||||
assert segments == keyline_segments == widget_style.CONTROL_SEGMENTS
|
||||
assert border_width == widget_style.CONTROL_BORDER_WIDTH
|
||||
assert fill.a == int(aethergauge.COLOR_AETHER_CARD.a * 0.5)
|
||||
assert (border.r, border.g, border.b, border.a) == (
|
||||
widget_style.CONTROL_BORDER.r,
|
||||
widget_style.CONTROL_BORDER.g,
|
||||
widget_style.CONTROL_BORDER.b,
|
||||
int(widget_style.CONTROL_BORDER.a * 0.5),
|
||||
)
|
||||
|
||||
|
||||
def test_aether_layout_keeps_road_and_cradle_inside_one_card():
|
||||
layout = _aether_layout(aethergauge.rl.Rectangle(10, 20, 176, 260))
|
||||
card_right = layout.card.x + layout.card.width
|
||||
card_bottom = layout.card.y + layout.card.height
|
||||
inner_border = widget_style.CONTROL_BORDER_WIDTH / 2.0
|
||||
|
||||
for viewport in (layout.road, layout.cradle):
|
||||
assert layout.card.x <= viewport.x
|
||||
assert viewport.x + viewport.width <= card_right
|
||||
assert layout.card.y <= viewport.y
|
||||
assert viewport.y + viewport.height <= card_bottom
|
||||
assert layout.road.x >= layout.card.x + inner_border
|
||||
assert layout.road.y >= layout.card.y + inner_border
|
||||
assert layout.cradle.y + layout.cradle.height <= card_bottom - inner_border
|
||||
assert layout.road.y + layout.road.height < layout.cradle.y
|
||||
|
||||
|
||||
def _fake_text_size(_, text, size):
|
||||
return aethergauge.rl.Vector2(max(1, len(text) * size * 0.5), size)
|
||||
|
||||
|
||||
def _assert_text_calls_fit_card(text_calls, card):
|
||||
card_right = card.x + card.width
|
||||
card_bottom = card.y + card.height
|
||||
for text, pos, size in text_calls:
|
||||
measured = _fake_text_size(None, text, size)
|
||||
assert card.x <= pos.x
|
||||
assert pos.x + measured.x <= card_right
|
||||
assert card.y <= pos.y
|
||||
assert pos.y + measured.y <= card_bottom
|
||||
|
||||
|
||||
@pytest.mark.parametrize("data", [
|
||||
AetherGaugeData(
|
||||
text="120", unit="km/h", color=aethergauge.COLOR_CEM_SPEED,
|
||||
indicator_type=IndicatorType.ROAD_CURVE, reduction_text="-20", is_numeric=True,
|
||||
),
|
||||
AetherGaugeData(
|
||||
text="999", unit="ft", color=aethergauge.COLOR_FORCE_STOP,
|
||||
indicator_type=IndicatorType.STOP_LIGHT, is_numeric=True,
|
||||
),
|
||||
AetherGaugeData(
|
||||
text="STOPPED", color=aethergauge.COLOR_LEAD_STOPPED,
|
||||
indicator_type=IndicatorType.LEAD,
|
||||
),
|
||||
])
|
||||
def test_aether_cradle_fits_text_within_card(monkeypatch, data):
|
||||
text_calls = []
|
||||
monkeypatch.setattr(aethergauge, "measure_text_cached", _fake_text_size)
|
||||
monkeypatch.setattr(
|
||||
aethergauge.rl, "draw_text_ex",
|
||||
lambda _, text, pos, size, __, ___: text_calls.append((text, pos, size)),
|
||||
)
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_line_ex", lambda *args: None)
|
||||
|
||||
layout = _aether_layout(aethergauge.rl.Rectangle(10, 20, 176, 260))
|
||||
AetherGauge()._draw_mini_cradle(layout.cradle, data, object(), object())
|
||||
|
||||
_assert_text_calls_fit_card(text_calls, layout.card)
|
||||
|
||||
|
||||
def test_aether_road_render_scissors_the_animated_viewport(monkeypatch):
|
||||
scissor_calls = []
|
||||
events = []
|
||||
monkeypatch.setattr(aethergauge, "measure_text_cached", _fake_text_size)
|
||||
monkeypatch.setattr(aethergauge.rl, "begin_scissor_mode", lambda *args: scissor_calls.append(args))
|
||||
monkeypatch.setattr(aethergauge.rl, "end_scissor_mode", lambda: events.append("end_scissor"))
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded", lambda *args: None)
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_rectangle_rounded_lines_ex", lambda *args: None)
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_triangle", lambda *args: None)
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_line_ex", lambda *args: None)
|
||||
monkeypatch.setattr(aethergauge.rl, "draw_text_ex", lambda *args: events.append("text"))
|
||||
monkeypatch.setattr(aethergauge.rl, "get_frame_time", lambda: 0.0)
|
||||
|
||||
rect = aethergauge.rl.Rectangle(10, 20, 176, 260)
|
||||
layout = _aether_layout(rect)
|
||||
data = AetherGaugeData(
|
||||
text="23", unit="mph", color=aethergauge.COLOR_CEM_SPEED,
|
||||
indicator_type=IndicatorType.ROAD_CURVE, indicator_value=0.005, is_numeric=True,
|
||||
)
|
||||
AetherGauge()._render_unified_road(rect, 98.0, data, object(), object())
|
||||
|
||||
assert scissor_calls == [(
|
||||
int(layout.road.x), int(layout.road.y), int(layout.road.width), int(layout.road.height),
|
||||
)]
|
||||
assert events.index("end_scissor") < events.index("text")
|
||||
|
||||
|
||||
def test_state_labels_make_urgent_and_lead_states_explicit():
|
||||
assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.FORCE_STOP)) == "STOP"
|
||||
assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.STOP_LIGHT)) == "RED LIGHT"
|
||||
assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.LEAD)) == "LEAD"
|
||||
assert _state_label(AetherGaugeData(text="", indicator_type=IndicatorType.ROAD_CURVE)) == ""
|
||||
|
||||
@@ -1,36 +1,117 @@
|
||||
from openpilot.selfdrive.ui.onroad.starpilot.source_bubble_layout import (
|
||||
SourceBubbleItem,
|
||||
SourceBubbleModel,
|
||||
SourceBubbleTransition,
|
||||
enabled_source_titles,
|
||||
fit_source_label,
|
||||
source_abbreviated_value_text,
|
||||
source_content_metrics,
|
||||
source_value_text,
|
||||
visible_source_rows,
|
||||
)
|
||||
|
||||
|
||||
def test_source_content_metrics_scale_with_visible_row_count():
|
||||
assert source_content_metrics(3) == (30, 34, 7)
|
||||
assert source_content_metrics(4) == (30, 32, 7)
|
||||
assert source_content_metrics(5) == (28, 30, 6)
|
||||
|
||||
|
||||
def test_fit_source_label_preserves_a_safe_value_column_gap():
|
||||
def width(text: str) -> int:
|
||||
return len(text) * 10
|
||||
|
||||
assert fit_source_label("Dashboard", "Dash", 80, width) == "Dash"
|
||||
assert fit_source_label("Vision", "Vision", 70, width) == "Vision"
|
||||
assert fit_source_label("Dashboard", "Dash", 20, width) == "D…"
|
||||
|
||||
|
||||
def test_source_value_text_keeps_missing_values_as_a_dash():
|
||||
assert source_value_text(0) == "–"
|
||||
assert source_value_text(0.1) == "–"
|
||||
assert source_value_text(55) == "55"
|
||||
assert source_value_text(float("nan")) == "–"
|
||||
assert source_value_text(float("inf")) == "–"
|
||||
assert source_abbreviated_value_text(0) == "X"
|
||||
assert source_abbreviated_value_text(55) == "55"
|
||||
|
||||
|
||||
def test_source_bubble_ignores_legacy_abbreviation_state(monkeypatch):
|
||||
from openpilot.selfdrive.ui.onroad.starpilot import slc_speed_limit as slc
|
||||
|
||||
text_calls = []
|
||||
icon_calls = []
|
||||
monkeypatch.setattr(slc, "_get_bold", lambda: object())
|
||||
monkeypatch.setattr(slc, "_get_semi_bold", lambda: object())
|
||||
monkeypatch.setattr(slc, "tr", lambda text: text)
|
||||
monkeypatch.setattr(
|
||||
slc,
|
||||
"measure_text_cached",
|
||||
lambda _font, text, size: slc.rl.Vector2(len(text) * size * 0.5, size),
|
||||
)
|
||||
monkeypatch.setattr(slc.rl, "draw_rectangle_rounded", lambda *args: None)
|
||||
monkeypatch.setattr(slc.rl, "draw_rectangle_rounded_lines_ex", lambda *args: None)
|
||||
monkeypatch.setattr(slc.rl, "draw_line_ex", lambda *args: None)
|
||||
monkeypatch.setattr(slc.rl, "draw_text_ex", lambda _font, text, *_args: text_calls.append(text))
|
||||
monkeypatch.setattr(slc, "_draw_source_icon", lambda icon_key, *_args: icon_calls.append(icon_key))
|
||||
|
||||
state = {
|
||||
"speed_limit_source": "Dashboard",
|
||||
"slc_enabled_sources": ("Dashboard", "Map Data"),
|
||||
"slc_active_sources_only": False,
|
||||
"dashboard_sl": 45.0,
|
||||
"map_sl": 30.0,
|
||||
}
|
||||
|
||||
def render(abbreviated: bool):
|
||||
text_calls.clear()
|
||||
icon_calls.clear()
|
||||
slc._draw_sources_bubble(
|
||||
{**state, "slc_abbreviated_sources": abbreviated},
|
||||
slc.rl.Rectangle(0, 0, 176, 196),
|
||||
)
|
||||
return list(icon_calls), list(text_calls)
|
||||
|
||||
abbreviated_rows = render(True)
|
||||
full_rows = render(False)
|
||||
|
||||
assert abbreviated_rows == full_rows
|
||||
assert abbreviated_rows[0] == ["dashboard", "map"]
|
||||
assert abbreviated_rows[1] == ["Dashboard", "45", "Map Data", "30"]
|
||||
|
||||
|
||||
def test_dense_source_bubble_uses_distinct_icons_and_no_source_names(monkeypatch):
|
||||
from openpilot.selfdrive.ui.onroad.starpilot import slc_speed_limit as slc
|
||||
|
||||
text_calls = []
|
||||
icon_calls = []
|
||||
monkeypatch.setattr(slc, "_get_bold", lambda: object())
|
||||
monkeypatch.setattr(slc, "_get_semi_bold", lambda: object())
|
||||
monkeypatch.setattr(slc, "tr", lambda text: text)
|
||||
monkeypatch.setattr(
|
||||
slc,
|
||||
"measure_text_cached",
|
||||
lambda _font, text, size: slc.rl.Vector2(len(text) * size * 0.5, size),
|
||||
)
|
||||
monkeypatch.setattr(slc.rl, "draw_rectangle_rounded", lambda *args: None)
|
||||
monkeypatch.setattr(slc.rl, "draw_rectangle_rounded_lines_ex", lambda *args: None)
|
||||
monkeypatch.setattr(slc.rl, "draw_line_ex", lambda *args: None)
|
||||
monkeypatch.setattr(slc.rl, "draw_text_ex", lambda _font, text, *_args: text_calls.append(text))
|
||||
monkeypatch.setattr(slc, "_draw_source_icon", lambda icon_key, *_args: icon_calls.append(icon_key))
|
||||
|
||||
state = {
|
||||
"speed_limit_source": "Mapbox",
|
||||
"slc_enabled_sources": ("Dashboard", "Map Data", "Vision", "Mapbox", "Upcoming"),
|
||||
"slc_active_sources_only": False,
|
||||
"dashboard_sl": 45.0,
|
||||
"map_sl": 30.0,
|
||||
"vision_sl": 50.0,
|
||||
"mapbox_sl": 35.0,
|
||||
"next_sl": 20.0,
|
||||
}
|
||||
|
||||
slc._draw_sources_bubble(state, slc.rl.Rectangle(0, 0, 176, 196))
|
||||
|
||||
assert text_calls == ["45", "30", "50", "35", "Next", "20"]
|
||||
assert icon_calls == ["dashboard", "map", "camera", "navigation", "next"]
|
||||
|
||||
|
||||
def test_dense_source_group_fits_three_digit_values_inside_cell(monkeypatch):
|
||||
from openpilot.selfdrive.ui.onroad.starpilot import slc_speed_limit as slc
|
||||
|
||||
monkeypatch.setattr(
|
||||
slc,
|
||||
"measure_text_cached",
|
||||
lambda _font, text, size: slc.rl.Vector2(len(text) * size * 0.5, size),
|
||||
)
|
||||
|
||||
icon_size, value_font, value_size = slc._fit_dense_group(
|
||||
object(), "120", slc.rl.Rectangle(0, 0, 114, 49), 36, 48,
|
||||
)
|
||||
|
||||
assert icon_size + slc._SOURCE_DENSE_GAP + value_size.x <= 106
|
||||
assert max(icon_size, value_size.y) <= 47
|
||||
assert value_font < 48
|
||||
|
||||
|
||||
def test_enabled_source_titles_follow_priority_and_fallback_settings():
|
||||
@@ -59,29 +140,106 @@ def test_visible_source_rows_honor_active_only_and_source_order():
|
||||
]
|
||||
values = {"dashboard": 45.0, "map": 0.0, "vision": 50.0, "mapbox": 30.0, "next": 20.0}
|
||||
|
||||
# Map Data has value 0.0, so it is omitted; Dashboard (45.0) is active
|
||||
# Map Data has no reading but remains visible when active-only is disabled.
|
||||
assert visible_source_rows(
|
||||
source_defs, values, "Dashboard", ("Dashboard", "Map Data"),
|
||||
) == [
|
||||
("Dashboard", "dashboard", 45.0, True),
|
||||
]
|
||||
# When Map Data is the active target but has 0.0 reading, Dashboard is inactive (available standby)
|
||||
) == (
|
||||
SourceBubbleItem("Dashboard", "Dashboard", "dashboard", 45.0, True, True),
|
||||
SourceBubbleItem("Map Data", "Map Data", "map", 0.0, False, False),
|
||||
)
|
||||
# Active-only hides the unavailable Map Data row and preserves canonical order.
|
||||
assert visible_source_rows(
|
||||
source_defs, values, "Map Data", ("Dashboard", "Map Data"),
|
||||
) == [
|
||||
("Dashboard", "dashboard", 45.0, False),
|
||||
]
|
||||
source_defs, values, "Map Data", ("Dashboard", "Map Data"), active_only=True,
|
||||
) == (
|
||||
SourceBubbleItem("Dashboard", "Dashboard", "dashboard", 45.0, True, False),
|
||||
)
|
||||
# Multiple available sources with readings appear in canonical order
|
||||
assert visible_source_rows(
|
||||
source_defs, values, "Vision", ("Dashboard", "Map Data", "Vision"),
|
||||
) == [
|
||||
("Dashboard", "dashboard", 45.0, False),
|
||||
("Vision", "camera", 50.0, True),
|
||||
]
|
||||
# When no sources have a valid speed reading (> 0), returns empty list (triggers empty state)
|
||||
) == (
|
||||
SourceBubbleItem("Dashboard", "Dashboard", "dashboard", 45.0, True, False),
|
||||
SourceBubbleItem("Map Data", "Map Data", "map", 0.0, False, False),
|
||||
SourceBubbleItem("Vision", "Vision", "camera", 50.0, True, True),
|
||||
)
|
||||
# Active-only removes all sources without a valid speed reading.
|
||||
assert visible_source_rows(
|
||||
source_defs, {key: 0.0 for key in values}, "Map Data", ("Map Data",),
|
||||
) == []
|
||||
source_defs, dict.fromkeys(values, 0.0), "Map Data", ("Map Data",), active_only=True,
|
||||
) == ()
|
||||
|
||||
|
||||
def test_source_bubble_models_select_readable_and_dense_modes():
|
||||
item = SourceBubbleItem("Vision", "Vision", "camera", 50.0, True, True)
|
||||
assert SourceBubbleModel((item,)).mode == "readable"
|
||||
assert SourceBubbleModel(tuple(item for _ in range(3))).mode == "readable"
|
||||
assert SourceBubbleModel(tuple(item for _ in range(4))).mode == "dense"
|
||||
assert SourceBubbleModel(tuple(item for _ in range(5))).mode == "dense"
|
||||
assert SourceBubbleModel((), "NO SOURCE DATA").mode == "empty"
|
||||
|
||||
|
||||
def test_source_bubble_transition_settles_and_crossfades_structural_changes():
|
||||
first = SourceBubbleModel((SourceBubbleItem("Dashboard", "Dashboard", "dashboard", 45.0, True, True),))
|
||||
second = SourceBubbleModel(tuple(
|
||||
SourceBubbleItem(source, source, icon, value, True, source == "Vision")
|
||||
for source, icon, value in (
|
||||
("Dashboard", "dashboard", 45.0),
|
||||
("Map Data", "map", 30.0),
|
||||
("Vision", "camera", 50.0),
|
||||
("Mapbox", "navigation", 35.0),
|
||||
)
|
||||
))
|
||||
transition = SourceBubbleTransition()
|
||||
|
||||
assert transition.update(first, 0.0) == (first, None, 1.0)
|
||||
outgoing, incoming, alpha = transition.update(second, 0.05)
|
||||
assert outgoing.structural_key == first.structural_key
|
||||
assert outgoing.items[0].is_active is False
|
||||
assert incoming is None
|
||||
assert alpha == 1.0
|
||||
outgoing, incoming, alpha = transition.update(second, 0.18)
|
||||
assert outgoing.structural_key == first.structural_key
|
||||
assert incoming is second
|
||||
assert alpha == 0.0
|
||||
|
||||
_, incoming, alpha = transition.update(second, 0.28)
|
||||
assert incoming is second
|
||||
assert 0.0 < alpha < 1.0
|
||||
|
||||
assert transition.update(second, 0.5) == (second, None, 1.0)
|
||||
|
||||
|
||||
def test_source_bubble_transition_coalesces_latest_pending_layout():
|
||||
first = SourceBubbleModel((SourceBubbleItem("Dashboard", "Dashboard", "dashboard", 45.0, True, True),))
|
||||
second = SourceBubbleModel(tuple(
|
||||
SourceBubbleItem(source, source, icon, 45.0, True, False)
|
||||
for source, icon in (("Dashboard", "dashboard"), ("Map Data", "map"))
|
||||
))
|
||||
third = SourceBubbleModel(tuple(
|
||||
SourceBubbleItem(source, source, icon, 45.0, True, False)
|
||||
for source, icon in (("Dashboard", "dashboard"), ("Map Data", "map"), ("Vision", "camera"))
|
||||
))
|
||||
transition = SourceBubbleTransition()
|
||||
transition.update(first, 0.0)
|
||||
transition.update(second, 0.05)
|
||||
transition.update(third, 0.10)
|
||||
outgoing, incoming, alpha = transition.update(third, 0.23)
|
||||
assert outgoing.structural_key == first.structural_key
|
||||
assert incoming is third
|
||||
assert alpha == 0.0
|
||||
|
||||
|
||||
def test_source_bubble_transition_reverses_to_the_latest_roster():
|
||||
first = SourceBubbleModel((SourceBubbleItem("Dashboard", "Dashboard", "dashboard", 45.0, True, True),))
|
||||
second = SourceBubbleModel(tuple(
|
||||
SourceBubbleItem(source, source, icon, 45.0, True, False)
|
||||
for source, icon in (("Dashboard", "dashboard"), ("Map Data", "map"))
|
||||
))
|
||||
transition = SourceBubbleTransition()
|
||||
transition.update(first, 0.0)
|
||||
transition.update(second, 0.05)
|
||||
transition.update(second, 0.18)
|
||||
|
||||
assert transition.update(first, 0.20) == (first, None, 1.0)
|
||||
assert transition.update(first, 0.40) == (first, None, 1.0)
|
||||
|
||||
|
||||
def test_source_label_color_override_and_engagement_states():
|
||||
|
||||
Reference in New Issue
Block a user