Combine Speed Sources into card. allow drawer, allow L, C, R alignment

This commit is contained in:
dirwin31
2026-10-14 13:21:47 -07:00
parent 44fa7866ac
commit 96f9af0166
22 changed files with 196 additions and 358 deletions
@@ -487,14 +487,36 @@ assert.deepEqual(vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene
assert.match(OnroadLayoutPage.template, /<GalaxySelect class="gx-field gx-field--full" aria-label="Preview scene"/)
// The widget open in Edit glows; the sources panel is a layout setting, not an on-screen toggle.
assert.match(OnroadLayoutPage.template, /'is-editing': state\.showEditingAids && state\.inspectorPanel === 'edit' && state\.selected === widget\.id/)
// Sources sit inside the cruise card (or a card right under it), so its box grows and actions right below follow.
vm.state.data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent = { inline: 76, card: 84 }
vm.state.selected = "cruise_limits"
vm.setSpeedSources({ target: { checked: true } })
const sourcesCard = vm.layout.cruise_limits, sourcesActions = vm.layout.speed_limit_actions
const actionsBefore = { ...sourcesActions }
Object.assign(sourcesActions, { x: sourcesCard.x, y: sourcesCard.y + 522 + 22 })
vm.setSpeedSources("speedSources", true)
assert.equal(vm.state.draft.speedSources, true)
vm.undo()
assert.equal(vm.profile.widgets.cruise_limits.height, 522 + 76)
assert.equal(sourcesActions.y, sourcesCard.y + 522 + 76 + 22)
vm.setSpeedSources("speedSourcesCard", true)
assert.equal(vm.profile.widgets.cruise_limits.height, 522 + 84)
assert.equal(sourcesActions.y, sourcesCard.y + 522 + 84 + 22)
vm.undo(); vm.undo()
assert.notEqual(vm.state.draft.speedSources, true)
assert.equal(vm.profile.widgets.cruise_limits.height, 522)
assert.equal(vm.layout.speed_limit_actions.y, vm.layout.cruise_limits.y + 522 + 22)
Object.assign(vm.layout.speed_limit_actions, actionsBefore)
vm.state.selected = "current_speed"
vm.setSpeedSources({ target: { checked: true } })
vm.setSpeedSources("speedSources", true)
assert.notEqual(vm.state.draft.speedSources, true) // Only from the cruise widget's own panel.
vm.state.selected = "cruise_limits"
vm.setSpeedAlign("center")
assert.equal(vm.state.draft.speedAlign, "center")
vm.setSpeedAlign("justify")
assert.equal(vm.state.draft.speedAlign, "center") // Only left, center or right.
vm.undo()
assert.equal(vm.state.draft.speedAlign ?? "left", "left")
vm.setSpeedSources("palette", true)
assert.equal(vm.state.draft.palette === true, false) // Only the two sources settings.
// Moving or removing widgets keeps the same backdrop; scene and size changes replace it.
const backdropCount = backdropCalls.length
vm.changePosition("current_speed", 720, 220)
@@ -18,7 +18,7 @@ metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96,
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "car_exit"]
const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id,
{ ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }]))
const doc = { version: 2, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, canvas: { width: 2880, height: 1080 }, widgets }
const doc = { version: 2, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "left", canvas: { width: 2880, height: 1080 }, widgets }
const raw = { version: 1, document: doc, defaults: copy(doc), metadata,
screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 },
revision: "aa-screen-and-layout", editable: true, valid: true,
@@ -221,7 +221,7 @@ draft.layouts.large.current_speed.x += 20
assert.equal(await feed.save(draft), true)
assert.equal(requests[0].url, "./api/android-auto/layout")
assert.equal(requests[1].body.document.version, 2)
assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedSources", "version", "wheelPedalFeedback", "widgets"])
assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedAlign", "speedSources", "speedSourcesCard", "version", "wheelPedalFeedback", "widgets"])
assert.equal(requests[1].body.document.widgets.current_speed.x, 1170)
assert.equal(updates.at(-1).notice, "Android Auto layout saved.")
feed.stop()
@@ -105,13 +105,17 @@ export function previewPoint(event, rect, profile) {
}
// Android Auto display switches, independent of the comma screen's preferences.
// Text alignment inside the cruise and speed-limit card, matching onroad_customization.SPEED_ALIGNMENTS.
export const SPEED_ALIGNMENTS = ["left", "center", "right"]
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"]
export function validDocument(document, metadata) {
if (!object(document)) return false
const profiles = metadata?.projection === true ? ["large"] : PROFILES
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 6 ||
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 6 ||
(Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
(Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") ||
(Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) ||
(Object.hasOwn(document, "clock24Hour") && typeof document.clock24Hour !== "boolean") ||
(Object.hasOwn(document, "showMaxSpeed") && typeof document.showMaxSpeed !== "boolean") ||
(Object.hasOwn(document, "display") && (!object(document.display) || !Object.values(document.display).every((value) => typeof value === "string"))) ||
@@ -368,7 +368,13 @@ export const OnroadLayoutPage = {
modalLeavePrompt() { return this.pendingLeave && this.state.leavePresentation === "modal" },
saveAvailable() { return !!this.state.data?.editable && !this.state.error && !this.state.needsReload && this.dirty && !this.state.drag && !this.state.layerDrag },
canSavePending() { return this.saveAvailable && !this.busy },
profile() { return this.state.data?.metadata.profiles[this.state.profile] },
profile() {
const profile = this.state.data?.metadata.profiles[this.state.profile], cruise = profile?.widgets?.cruise_limits
// Speed-limit sources draw inside the cruise card or as a small card under it, so its box grows to match.
if (!cruise?.sourcesExtent || this.state.draft?.speedSources !== true) return profile
const grow = this.state.draft.speedSourcesCard === true ? cruise.sourcesExtent.card : cruise.sourcesExtent.inline
return { ...profile, widgets: { ...profile.widgets, cruise_limits: { ...cruise, height: cruise.height + grow } } }
},
sceneGroups() {
return [["Driving", scene => !/^(cem|slc)_/.test(scene.id)], ["Conditional experimental", scene => scene.id.startsWith("cem_")],
["Speed limit", scene => scene.id.startsWith("slc_")]].map(([label, test]) => ({ label, scenes: this.scenes.filter(test) }))
@@ -788,10 +794,26 @@ export const OnroadLayoutPage = {
this.recordChange(before)
this.state.notice = ""
},
setSpeedSources(event) {
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits") return
setSpeedSources(key, value) {
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" ||
!["speedSources", "speedSourcesCard"].includes(key)) return
const before = clone(this.state.draft), oldHeight = this.profile.widgets.cruise_limits.height
this.state.draft[key] = value === true
const card = this.layout.cruise_limits, actions = this.layout.speed_limit_actions, height = this.profile.widgets.cruise_limits.height
// Actions that sat right under the card follow its new bottom; both stay on screen.
if (actions && actions.x === card.x && actions.y === card.y + oldHeight + 22) actions.y = card.y + height + 22
for (const id of ["cruise_limits", "speed_limit_actions"].filter(id => this.layout[id])) {
const limits = placementLimits(this.profile, id, this.layout)
this.layout[id].y = Math.max(limits.minY, Math.min(this.layout[id].y, limits.maxY))
}
this.recordChange(before)
this.state.notice = ""
},
setSpeedAlign(align) {
if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" ||
!["left", "center", "right"].includes(align)) return
const before = clone(this.state.draft)
this.state.draft.speedSources = event.target.checked
this.state.draft.speedAlign = align
this.recordChange(before)
this.state.notice = ""
},
@@ -1384,7 +1406,19 @@ export const OnroadLayoutPage = {
<p v-if="selectedWidget.defaultAnchor === 'driver_side'" class="gx-note">The default position follows the driver’s side. A moved position stays where you place it.</p>
<p v-if="selectedWidget.note" class="gx-note">{{ selectedWidget.note }}</p>
<label v-if="selectedWidget.kind === 'clock'" class="gx-layout__clock-format"><input type="checkbox" :checked="!!state.draft.clock24Hour" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setClock24Hour"> Use 24-hour time</label>
<label v-if="selectedWidget.kind === 'cruise_limits'" class="gx-layout__clock-format"><input type="checkbox" :checked="state.draft.speedSources === true" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setSpeedSources"> Show speed-limit sources</label>
<div v-if="selectedWidget.kind === 'cruise_limits'" class="gx-layout__favorite-display">
<span>Text alignment</span>
<div class="gx-segment" role="group" aria-label="Cruise and speed limit text alignment">
<button v-for="align in ['left', 'center', 'right']" :key="align" class="gx-btn" type="button"
:class="(state.draft.speedAlign || 'left') === align ? 'gx-btn--primary' : 'gx-btn--tonal'"
:aria-pressed="(state.draft.speedAlign || 'left') === align" :disabled="!editAvailable || !!state.drag || !!state.layerDrag"
@click="setSpeedAlign(align)">{{ align === 'left' ? 'Left' : align === 'center' ? 'Center' : 'Right' }}</button>
</div>
</div>
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits'" :row="switchRow('Show Speed-Limit Sources', state.draft.speedSources === true)" :index="0"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSources', value === 'On')" />
<GalaxySettingRow v-if="selectedWidget.kind === 'cruise_limits' && state.draft.speedSources === true" :row="switchRow('Show as Separate Card', state.draft.speedSourcesCard === true)" :index="1"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setSpeedSources('speedSourcesCard', value === 'On')" />
<label v-if="!projection && selectedWidget.kind === 'cruise_limits'" class="gx-layout__clock-format"><input type="checkbox" :checked="state.draft.showMaxSpeed !== false" :disabled="!editAvailable || !!state.drag || !!state.layerDrag" @change="setShowMaxSpeed"> Show MAX speed</label>
<GalaxySettingRow v-if="projection && selectedWidget.kind === 'steering_wheel'" :row="switchRow('Brake and Acceleration Colors', state.draft.wheelPedalFeedback === true)" :index="0"
:disabled="!editAvailable || !!state.drag || !!state.layerDrag" :save-value="(index, value) => setProjectionDisplay('wheelPedalFeedback', value)" />
@@ -27,7 +27,7 @@ export function editorSnapshot(raw) {
const document = (value) => {
if (value.version !== 2 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
throw new Error("The saved Android Auto layout does not match its screen. Reload before editing.")
return { version: 6, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
return { version: 6, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, speedSourcesCard: value.speedSourcesCard === true, speedAlign: ["center", "right"].includes(value.speedAlign) ? value.speedAlign : "left", ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } }
}
return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults),
@@ -50,6 +50,8 @@ export function projectionPayload(body, metadata) {
leadIndicator: body.document.leadIndicator === true,
leadInfo: LEAD_INFO.includes(body.document.leadInfo) ? body.document.leadInfo : "Off",
speedSources: body.document.speedSources === true,
speedSourcesCard: body.document.speedSourcesCard === true,
speedAlign: ["center", "right"].includes(body.document.speedAlign) ? body.document.speedAlign : "left",
...(body.document.widgetOrder?.large ? { widgetOrder: clone(body.document.widgetOrder.large) } : {}),
widgets: clone(body.document.layouts.large) } }
}
@@ -7,7 +7,7 @@ from pathlib import Path
from openpilot.starpilot.system.android_auto.identity import DATA_DIR
from openpilot.starpilot.system.android_auto.display_profile import screen_geometry
from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, MODE_WIDGET, STOPPED_TIMER_WIDGET, WHEEL_SIZES,
customization_metadata, validate_widget_order)
SPEED_ALIGNMENTS, customization_metadata, validate_widget_order)
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
@@ -123,7 +123,8 @@ def layout_metadata_for_viewport(viewport):
# Android Auto display switches, independent of the comma screen's preferences.
DISPLAY_DEFAULTS = {'blindSpotBorder': False, 'signalBorder': False, 'blindSpotCamera': False,
'hidePersonalityAlerts': False, 'wheelPedalFeedback': False,
'leadIndicator': False, 'leadInfo': 'Off', 'speedSources': False}
'leadIndicator': False, 'leadInfo': 'Off', 'speedSources': False,
'speedSourcesCard': False, 'speedAlign': 'left'}
# Lead info shown above the lead marker, in LeadInfoMode order.
LEAD_INFO_CHOICES = ('Off', 'Distance', 'Speed')
@@ -216,7 +217,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
result.setdefault('largeUiGammaTrial', False)
for field, default in DISPLAY_DEFAULTS.items():
if field in value and (type(value[field]) is not type(default) or
(field == 'leadInfo' and value[field] not in LEAD_INFO_CHOICES)):
(field == 'leadInfo' and value[field] not in LEAD_INFO_CHOICES) or
(field == 'speedAlign' and value[field] not in SPEED_ALIGNMENTS)):
raise ValueError('Invalid projection display setting')
result.setdefault(field, default)
for key in set(metadata['widgets']) - set(value['widgets']):
@@ -341,6 +343,7 @@ def projection_customization(document, base_customization):
red, green, blue, alpha = rgba(result, 'cardFill', 'large', 'cruise_limits')
fill = f'#{red:02X}{green:02X}{blue:02X}{round(alpha * opacity / 100):02X}'
result['widgetColors']['large'].setdefault('cruise_limits', {})['cardFill'] = fill
if 'speedSources' in document:
result['speedSources'] = document['speedSources']
for key in ('speedSources', 'speedSourcesCard', 'speedAlign'):
if key in document:
result[key] = document[key]
return result
@@ -180,6 +180,7 @@ class TestProjectionLayout(unittest.TestCase):
layout = default_layout_for_viewport((1920, 1080))
self.assertEqual(layout['widgets']['cruise_limits']['opacity'], 100)
self.assertIs(layout['speedSources'], False)
self.assertEqual(layout['speedAlign'], 'left')
base = default_document()
solid = rgba(base, 'cardFill', 'large', 'cruise_limits')
native = projection_customization(layout, base)
@@ -196,6 +197,9 @@ class TestProjectionLayout(unittest.TestCase):
del saved['speedSources']
restored = validate_layout_for_viewport(saved, (1920, 1080))
self.assertEqual((restored['widgets']['cruise_limits']['opacity'], restored['speedSources']), (100, False))
self.assertEqual(projection_customization(validate_layout_for_viewport({**layout, 'speedAlign': 'right'}, (1920, 1080)), base)['speedAlign'], 'right')
with self.assertRaises(ValueError):
validate_layout_for_viewport({**layout, 'speedAlign': 'justify'}, (1920, 1080))
for bad in (-1, 101, 50.5, True):
layout['widgets']['cruise_limits']['opacity'] = bad
with self.assertRaises(ValueError):
+6 -72
View File
@@ -1,5 +1,4 @@
"""Large speed stack dimensions shared by rendering, input and layout metadata."""
from functools import lru_cache
WIDTH = 320
MAX_ROW_HEIGHT = 210
@@ -10,7 +9,7 @@ ACTION_X = 88
ACTION_Y = 75 + CARD_HEIGHT + 22
ACTION_HEADER_HEIGHT = 44
GAP = 8
SOURCE_ROW_HEIGHT = 56
SOURCE_ROW_HEIGHT = 76
PADDING = 24
@@ -18,73 +17,8 @@ def card_height(mode):
return CARD_HEIGHT if mode == 'split' else LIMIT_ROW_HEIGHT if mode == 'limit_only' else MAX_ROW_HEIGHT
@lru_cache(maxsize=8)
def _projection_widgets(width, height):
from openpilot.starpilot.system.android_auto.projection_layout import layout_metadata_for_viewport
return layout_metadata_for_viewport((width, height))['widgets']
def _action_regions(state):
from openpilot.starpilot.ui.onroad_customization import placement
from openpilot.starpilot.ui.onroad_state import slc_controls
from openpilot.starpilot.ui.presentation import Profile
header = ACTION_HEADER_HEIGHT if state.speed_limit.pending_speed_limit_mps is not None or state.speed_limit.action_feedback else 0
regions = [(l, t - header, r, b) for l, t, r, b in
(control.bounds for control in slc_controls(Profile.LARGE, state))]
saved = placement(state.customization, 'large', 'speed_limit_actions')
if not regions and state.speed_limit.action_feedback and saved['enabled']:
regions.append((saved['x'], saved['y'], saved['x'] + WIDTH, saved['y'] + ACTION_HEADER_HEIGHT))
return regions
def source_panel_bounds(state, height, viewport):
"""Prefer the aligned column; move the popup only for constrained saved layouts."""
from openpilot.starpilot.ui.onroad_customization import placement, PROFILES, widget_size, CAMERA_WIDGETS
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
card = placement(state.customization, 'large', 'cruise_limits')
height_of_card = card_height(resolve_unified_speed(state).mode)
# The panel opens from the card itself; there is no on-screen toggle row any more.
left, top, right, bottom = card['x'], card['y'], card['x'] + WIDTH, card['y'] + height_of_card
occupied = [(left, top, right, bottom)]
occupied.extend(_action_regions(state))
layouts = state.customization['layouts']['large']
projected = 'nav_card' in layouts
if projected:
from openpilot.starpilot.system.android_auto.projection_layout import placement_size
widgets = _projection_widgets(round(viewport.width + 60), round(viewport.height + 60))
else:
widgets = PROFILES['large']['widgets']
for key, widget in widgets.items():
if key in ('cruise_limits', 'speed_limit_actions', *CAMERA_WIDGETS):
continue
saved = layouts.get(key)
if saved is None:
continue
if saved['enabled']:
if key == 'cem_gauge':
from openpilot.starpilot.ui.onroad_cem_gauge import gauge_data
if gauge_data(state) is None:
continue
width, widget_height = (placement_size(key, widget, saved) if projected else widget_size(state.customization, 'large', key))
native = PROFILES['large']['widgets'].get(key)
dx = widget['default']['x'] - native['default']['x'] if projected and native else 0
dy = widget['default']['y'] - native['default']['y'] if projected and native else 0
x, y = saved['x'] + dx, saved['y'] + dy
occupied.append((x, y, x + width, y + widget_height))
candidates = [(left, bottom + GAP), (left, top - GAP - height),
(left, card['y'] - GAP - height), (right + GAP, top), (left - GAP - WIDTH, top),
(right + GAP, top - GAP - height), (left - GAP - WIDTH, top - GAP - height)]
# Keep the aligned choices first; another saved widget can fill that column.
# Its edges supply further columns using the same popup gap and clip checks.
for occupied_left, _, occupied_right, _ in occupied:
candidates.extend(((occupied_right + GAP, top), (occupied_left - GAP - WIDTH, top),
(occupied_right + GAP, top - GAP - height), (occupied_left - GAP - WIDTH, top - GAP - height)))
for x, y in candidates:
y = min(max(y, viewport.y), viewport.y + viewport.height - height)
if not viewport.x <= x <= viewport.x + viewport.width - WIDTH:
continue
if all(x + WIDTH <= l or x >= r or y + height <= t or y >= b for l, t, r, b in occupied):
return x, y, WIDTH, height
return None
def sources_extent(document):
"""Extra height the cruise widget takes for speed-limit sources: a row in the card, or a small card of its own below."""
if document.get('speedSources') is not True:
return 0
return GAP + SOURCE_ROW_HEIGHT if document.get('speedSourcesCard') is True else SOURCE_ROW_HEIGHT
@@ -281,7 +281,7 @@ class _Canvas:
finally:
rl.rl_pop_matrix()
def render(self, state: OnroadState, scene: str, *, label: bool = True, source_panel: bool = False) -> bytes:
def render(self, state: OnroadState, scene: str, *, label: bool = True) -> bytes:
if self.view is None or self.fonts is None or self.target is None:
raise RuntimeError("Layout preview resources are closed")
self._settle(state)
@@ -299,10 +299,6 @@ class _Canvas:
self.fonts.draw('StarPilot', FontRole.BRAND, 30, width - 210, height - 90)
if self.profile == Profile.LARGE and self.viewport is None:
render_sidebar(self.fonts, rl.Rectangle(width - SIDEBAR_WIDTH, 0, SIDEBAR_WIDTH, height))
# The backdrop omits the cruise card, so draw its open sources panel on its own.
if (source_panel and self.profile == Profile.LARGE and state.alert.size != AlertSize.FULL and
placement(state.customization, 'large', 'cruise_limits')['enabled']):
self.view.unified_speed.render_source_panel(rl.Rectangle(30, 30, width - 60, height - 60), state)
if label:
self._label(scene)
finally:
@@ -394,12 +390,6 @@ class _Canvas:
hud._set_speed_alpha.x = 1.0 if state.cruise_active else 0.0
hud._wheel_alpha.x = 255 * 0.9 if state.lateral_active else 0.0
hud._wheel_y.x = 0.0 if state.lateral_active else 25.0
if (speed := getattr(self.view, 'unified_speed', None)) is not None:
# A still frame shows the sources panel fully open or closed, never mid-slide.
opened = float(state.customization.get('speedSources', False) is True)
drawer = speed._source_drawer
drawer._open, drawer.progress, drawer._start_progress = bool(opened), opened, opened
speed._source_session = state.speed_limit.session_id
self.view.torque_bar._torque_filter.x = state.torque_utilization if state.lateral_active else 0.0
self.view.torque_bar._alpha_filter.x = 1.0 if state.lateral_active else 0.0
@@ -480,8 +470,7 @@ class LayoutPreviewRenderer:
# Everything the car draws except the movable widgets, which the editor places over it.
# A non-empty order that names no widget keeps the ordered path and draws none of them.
order = {**state.customization.get('widgetOrder', {}), str(profile): ['backdrop']}
return self._canvas.render(replace(state, customization={**state.customization, 'widgetOrder': order}), scene,
label=False, source_panel=True)
return self._canvas.render(replace(state, customization={**state.customization, 'widgetOrder': order}), scene, label=False)
return self._canvas.render(state, scene)
def expire(self) -> None:
-2
View File
@@ -147,8 +147,6 @@ class OnroadView:
if state.camera_available and self.camera_layer is None:
raise ValueError("Camera observation requires a camera renderer")
if self.fonts.profile == Profile.LARGE:
if state.alert.size == AlertSize.FULL or not placement(state.customization, "large", "cruise_limits")["enabled"]:
self.unified_speed.collapse_sources()
self._large(state)
else:
self._compact(state)
+19 -1
View File
@@ -9,10 +9,12 @@ from typing import Any, NotRequired, TypedDict
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y, GAP, SOURCE_ROW_HEIGHT, sources_extent
PARAM_KEY = "OnroadCustomizations"
MAX_BYTES = 16384
# Text alignment inside the cruise and speed-limit card; left is the original look.
SPEED_ALIGNMENTS = ("left", "center", "right")
PALETTE = {"cardFill": "#000000A6", "cardBorder": "#C4CDD0B4", "text": "#FFFFFFFF"}
ROAD_COLORS = {"path": "#30FF9CFF", "pathEdge": "#00FF40FF", "laneLines": "#FFFFFFFF"}
PATH_MODES = ("default", "color", "rainbow", "acceleration")
@@ -206,6 +208,8 @@ def widget_bounds(profile, widget):
def customization_metadata():
profiles = copy.deepcopy(PROFILES)
profiles["large"]["widgets"]["driver_monitor"]["defaultAnchor"] = "driver_side"
# The editor grows the cruise box by these while speed-limit sources are shown.
profiles["large"]["widgets"]["cruise_limits"]["sourcesExtent"] = {"inline": SOURCE_ROW_HEIGHT, "card": GAP + SOURCE_ROW_HEIGHT}
profiles["large"]["inputZones"] = [{"id": "favorites_drawer", "label": "Quick Select drawer",
"x": 30, "y": 900, "width": 150, "height": 150}]
profiles["compact"]["inputZones"] = [
@@ -263,6 +267,14 @@ def validate_document(value, *, _legacy_geometry=False):
fields.add('speedSources')
if type(value['speedSources']) is not bool:
raise ValueError('Invalid speed source drawer preference')
if value['version'] >= 4 and 'speedSourcesCard' in value:
fields.add('speedSourcesCard')
if type(value['speedSourcesCard']) is not bool:
raise ValueError('Invalid speed source card preference')
if value['version'] >= 4 and 'speedAlign' in value:
fields.add('speedAlign')
if value['speedAlign'] not in SPEED_ALIGNMENTS:
raise ValueError('Invalid speed card alignment')
if value['version'] >= 4 and 'widgetOrder' in value:
fields.add('widgetOrder')
if 'clock24Hour' in value:
@@ -418,6 +430,10 @@ def validate_document(value, *, _legacy_geometry=False):
result['widgetOrder'][profile] = validate_widget_order(order, PROFILES[profile]['widgets'])
if 'speedSources' in value:
result['speedSources'] = value['speedSources']
if 'speedSourcesCard' in value:
result['speedSourcesCard'] = value['speedSourcesCard']
if 'speedAlign' in value:
result['speedAlign'] = value['speedAlign']
if 'clock24Hour' in value:
result['clock24Hour'] = value['clock24Hour']
if 'showMaxSpeed' in value:
@@ -455,6 +471,8 @@ def placement(document, profile, widget):
def widget_size(document, profile, widget):
default = PROFILES[str(profile)]["widgets"][widget]
position = placement(document, profile, widget)
if widget == "cruise_limits":
return default["width"], default["height"] + sources_extent(document)
return (position.get("size", WHEEL_SIZES[str(profile)][1]),) * 2 if widget == "steering_wheel" else (default["width"], default["height"])
+40 -65
View File
@@ -7,7 +7,6 @@ HudRenderer and ExpButton. See the adjacent UI LICENSE for retained source.
from pathlib import Path
import hashlib
import json
import time
import pyray as rl
@@ -16,7 +15,6 @@ from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState
from openpilot.starpilot.ui.onroad_widget_style import draw_control_card
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole
from openpilot.starpilot.ui.wheel_feedback import wheel_feedback_rgb
from openpilot.starpilot.ui.speed_source_drawer import SpeedSourceDrawer
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
@@ -35,59 +33,63 @@ class UnifiedSpeedWidget:
def __init__(self, fonts: BitmapFonts):
self.fonts = fonts
self._source_drawer = SpeedSourceDrawer()
self._source_session = None
def collapse_sources(self):
self._source_drawer.reset()
def _draw_source_contents(self, panel, state):
def _draw_sources(self, rect, state):
"""Each enabled source as a small label over its reading; the accepted source is highlighted."""
from openpilot.starpilot.ui.large_speed_geometry import PADDING
rows = [row for row in state.speed_limit.source_readings if row.enabled]
if not rows:
_center(self.fonts, 'No sources enabled', FontRole.NORMAL, 28, panel, panel.y + 30)
_center(self.fonts, 'No sources enabled', FontRole.NORMAL, 26, rect, rect.y + 22)
return
row_height = (panel.height - 32) / len(rows)
active = state.speed_limit.accepted_source
labels = {'dashboard': 'Dashboard', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'}
labels = {'dashboard': 'Dash', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'}
cell = (rect.width - 2 * PADDING) / len(rows)
for index, row in enumerate(rows):
role = FontRole.MEDIUM if row.source == active else FontRole.NORMAL
color = rl.WHITE if row.source == active else rl.Color(170, 179, 174, 255)
value = str(round(row.speed_mps * (3.6 if state.metric else 2.2369362921))) if row.kind == 'valid' and row.speed_mps is not None else '–'
label = labels[row.source]
size = 28
value_width = self.fonts.measure(value, role, 30).width
while size > 12 and self.fonts.measure(label, role, size).width > panel.width - 64 - value_width:
box = rl.Rectangle(rect.x + PADDING + index * cell, rect.y, cell, rect.height)
size = 22
while size > 12 and self.fonts.measure(labels[row.source], FontRole.NORMAL, size).width > cell - 4:
size -= 1
y = panel.y + 16 + index * row_height
label_height = self.fonts.measure(label, role, size).height
value_height = self.fonts.measure(value, role, 30).height
self.fonts.draw(label, role, size, panel.x + 32, y + (row_height - label_height) / 2, color)
self.fonts.draw(value, role, 30, panel.x + panel.width - 32 - value_width, y + (row_height - value_height) / 2, color)
_center(self.fonts, labels[row.source], FontRole.NORMAL, size, box, rect.y + 10, color)
_center(self.fonts, value, FontRole.MEDIUM if row.source == active else FontRole.NORMAL, 30, box, rect.y + 36, color)
def render(self, content: rl.Rectangle, state: OnroadState) -> None:
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, MAX_ROW_HEIGHT, PADDING, card_height
from openpilot.starpilot.ui.large_speed_geometry import GAP, WIDTH, MAX_ROW_HEIGHT, PADDING, SOURCE_ROW_HEIGHT, card_height
from openpilot.starpilot.ui.onroad_customization import placement
shown = resolve_unified_speed(state)
if shown.mode == 'hidden':
self.collapse_sources()
return
saved = placement(state.customization, 'large', 'cruise_limits')
rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, card_height(shown.mode))
height = card_height(shown.mode)
rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, height)
# Sources follow the Galaxy layout setting: a row in this card, or a small card of its own right below.
sources = (state.customization.get('speedSources') is True and shown.mode != 'max_only' and
state.speed_limit.kind == ObservationKind.VALID and bool(state.speed_limit.session_id))
separate = state.customization.get('speedSourcesCard') is True
if sources and not separate:
rect.height += SOURCE_ROW_HEIGHT
fill = rl.Color(*rgba(state.customization, 'cardFill', 'large', 'cruise_limits'))
border = rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits'))
text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits'))
muted, accent = rl.Color(172, 190, 203, 255), rl.Color(197, 163, 255, 255)
draw_control_card(rect, fill=fill, border=border, border_width=1, roundness=.09)
def label(value, x, y, size=32, color=muted, role=FontRole.MEDIUM):
align = state.customization.get('speedAlign', 'left')
def start(width):
# Text sits left (the default), centred, or right within the card's padding.
return (rect.x + (WIDTH - width) / 2 if align == 'center' else
rect.x + WIDTH - PADDING - width if align == 'right' else rect.x + PADDING)
def label(value, y, size=32, color=muted, role=FontRole.MEDIUM):
while size > 20 and self.fonts.measure(value, role, size).width > WIDTH - 2 * PADDING:
size -= 1
ink_top, _ = self.fonts.vertical_ink(value, role, size)
self.fonts.draw(value, role, size, x, y - ink_top, color)
self.fonts.draw(value, role, size, start(self.fonts.measure(value, role, size).width), y - ink_top, color)
def row(title, value, top, footer, status='', active=False, footer_color=muted, footer_y=174):
left = rect.x + PADDING
label(title, left, top + 24, color=accent if active else muted)
label(title, top + 24, color=accent if active else muted)
size, unit_size = 112, 28
has_value = value != '–'
unit_width = self.fonts.measure(shown.unit, FontRole.NORMAL, unit_size).width if has_value else 0
@@ -99,14 +101,15 @@ class UnifiedSpeedWidget:
if not has_value:
digit_top, digit_bottom = self.fonts.vertical_ink('0', FontRole.SPEED, size)
number_y += ((digit_bottom - digit_top) - (number_bottom - number_top)) / 2
left = start(number_width + (14 + unit_width if has_value else 0))
self.fonts.draw(value, FontRole.SPEED, size, left, number_y, text)
if has_value:
_, unit_bottom = self.fonts.vertical_ink(shown.unit, FontRole.NORMAL, unit_size)
self.fonts.draw(shown.unit, FontRole.NORMAL, unit_size, left + number_width + 14,
top + 72 + number_bottom - number_top - unit_bottom, muted)
if status:
label(status, left, top + 174, size=30, role=FontRole.NORMAL, color=accent if active else muted)
label(footer, left, top + footer_y, size=30, role=FontRole.NORMAL, color=footer_color)
label(status, top + 174, size=30, role=FontRole.NORMAL, color=accent if active else muted)
label(footer, top + footer_y, size=30, role=FontRole.NORMAL, color=footer_color)
if shown.mode != 'limit_only':
max_active = shown.active_side in ('max', 'shared') and shown.max_text != '–'
@@ -121,41 +124,13 @@ class UnifiedSpeedWidget:
road_limit += f' {shown.offset_text}'
row('SLC LIMIT', shown.limit_text, top, road_limit, shown.limit_status,
active=shown.active_side in ('slc', 'shared'), footer_y=224)
session = state.speed_limit.session_id
if session != self._source_session:
self.collapse_sources()
self._source_session = session
available = state.speed_limit.kind == ObservationKind.VALID and bool(session) and shown.mode != 'max_only'
if not available:
self.collapse_sources()
return
# Sources open from the Galaxy layout setting; there is no on-screen toggle.
now = state.observed_ns / 1e9 if state.observed_ns else time.monotonic()
self._source_drawer.update(state.customization.get('speedSources', False), now)
self._draw_sources(content, state, fill, border)
def render_source_panel(self, content: rl.Rectangle, state: OnroadState) -> None:
"""Only the open sources panel, for the layout preview, which places the card itself as a movable widget."""
shown = resolve_unified_speed(state)
if (shown.mode in ('hidden', 'max_only') or state.speed_limit.kind != ObservationKind.VALID or
not state.speed_limit.session_id or not state.customization.get('speedSources', False)):
return
self._draw_sources(content, state, rl.Color(*rgba(state.customization, 'cardFill', 'large', 'cruise_limits')),
rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits')))
def _draw_sources(self, content: rl.Rectangle, state: OnroadState, fill: rl.Color, border: rl.Color) -> None:
from openpilot.starpilot.ui.large_speed_geometry import PADDING, SOURCE_ROW_HEIGHT, source_panel_bounds
if self._source_drawer.progress <= 0:
return
rows = sum(row.enabled for row in state.speed_limit.source_readings)
height = max(1, rows) * SOURCE_ROW_HEIGHT + PADDING
panel_bounds = source_panel_bounds(state, height, content)
if panel_bounds is None:
self.collapse_sources()
return
panel = rl.Rectangle(*panel_bounds)
self._source_drawer.draw_panel(panel, content, fill, border,
lambda panel: self._draw_source_contents(panel, state))
if sources:
row = rl.Rectangle(rect.x, rect.y + height + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT)
if separate:
draw_control_card(row, fill=fill, border=border, border_width=1, roundness=.2)
else:
rl.draw_line_ex(rl.Vector2(rect.x + 1, row.y), rl.Vector2(rect.x + WIDTH - 1, row.y), 1, border)
self._draw_sources(row, state)
class CurrentSpeedHud:
+4 -1
View File
@@ -258,9 +258,12 @@ def _default_slc_controls(profile: Profile, state: OnroadState) -> tuple[SlcCont
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y, ACTION_HEADER_HEIGHT
target = observation.pending_adjusted_limit_mps
label = f'Apply {round(target * (3.6 if state.metric else 2.2369362921))}' if target is not None and target > 0 else 'Apply'
# Name the speed each choice leaves you at: the new target, or the limit already in use.
current = observation.accepted_adjusted_limit_mps
keep = f'Keep {round(current * (3.6 if state.metric else 2.2369362921))}' if current is not None and current > 0 else 'Keep'
return (SlcControl(request(SlcActionKind.ACCEPT), label,
(ACTION_X, ACTION_Y + ACTION_HEADER_HEIGHT, ACTION_X + WIDTH // 2 - 6, ACTION_Y + ACTION_HEIGHT)),
SlcControl(request(SlcActionKind.REJECT), 'Keep',
SlcControl(request(SlcActionKind.REJECT), keep,
(ACTION_X + WIDTH // 2 + 6, ACTION_Y + ACTION_HEADER_HEIGHT, ACTION_X + WIDTH, ACTION_Y + ACTION_HEIGHT)))
return (SlcControl(request(SlcActionKind.ACCEPT), "ACCEPT", (174, 180, 310, 234)),
SlcControl(request(SlcActionKind.REJECT), "REJECT", (320, 180, 456, 234)))
-2
View File
@@ -85,8 +85,6 @@ class ShellView:
acquired.pop_all()
def render(self, snapshot: ShellSnapshot, drag_x: float = 0.0) -> None:
if snapshot.mode != ShellMode.ONROAD and self.profile == Profile.LARGE:
self.onroad.unified_speed.collapse_sources()
if snapshot.mode == ShellMode.HOME:
self.home.render(snapshot.home)
elif snapshot.mode == ShellMode.ONROAD:
@@ -1,17 +0,0 @@
"""A vertically revealed, display-only source panel."""
import pyray as rl
from openpilot.starpilot.ui import clip
from openpilot.starpilot.ui.speed_source_drawer_model import DrawerMotion
class SpeedSourceDrawer(DrawerMotion):
def draw_panel(self, panel, parent, fill, border, draw_contents):
if self.progress <= 0:
return None
visible = rl.Rectangle(panel.x, panel.y, panel.width, panel.height * self.progress)
with clip.clipped(visible, parent):
rl.draw_rectangle_rounded(panel, .14, 12, fill)
rl.draw_rectangle_rounded_lines_ex(panel, .14, 12, 1, border)
draw_contents(panel)
@@ -1,28 +0,0 @@
"""Reversible reveal progress for the display-only source panel."""
OPEN_SECONDS = 0.18
CLOSE_SECONDS = 0.14
class DrawerMotion:
def __init__(self):
self.reset()
def reset(self) -> None:
self.progress = 0.0
self._open = False
self._start_progress = 0.0
self._start_time = 0.0
def update(self, opened: bool, now: float) -> None:
# Sample the old transition first so a second tap reverses without jumping.
target = float(self._open)
if self.progress != target:
duration = OPEN_SECONDS if self._open else CLOSE_SECONDS
phase = min(1.0, max(0.0, (now - self._start_time) / duration))
self.progress = target + (self._start_progress - target) * (1 - phase) ** 4
if opened != self._open:
self._open = opened
self._start_progress = self.progress
self._start_time = now
@@ -7,7 +7,7 @@ from PIL import Image
import pyray as rl
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory, font_filename, font_path, validate_bitmap_font
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, PADDING, source_panel_bounds
from openpilot.starpilot.ui.large_speed_geometry import WIDTH, PADDING
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document
from openpilot.starpilot.ui.onroad_state import slc_controls
from openpilot.starpilot.ui.tests.test_speed_stack import road
@@ -81,17 +81,17 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent():
assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 567
def test_pending_buttons_and_source_popup_do_not_cover_monitor():
def test_pending_buttons_name_both_speeds():
state = road()
state = replace(state, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=25))
state = replace(state, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=25, pending_adjusted_limit_mps=24,
accepted_adjusted_limit_mps=22))
apply, keep = slc_controls(Profile.LARGE, state)
assert apply.bounds == (88, 611, 242, 675)
assert keep.bounds == (254, 611, 408, 675)
panel = source_panel_bounds(state, 200, SimpleNamespace(x=30, y=30, width=1800, height=1020))
assert panel is not None
x, y, width, height = panel
assert x >= 280 or x + width <= 88 or y + height <= 808 or y >= 1000
assert x >= 1437 or x + width <= 439 or y + height <= 759 or y >= 999
# Each choice says the speed it leaves you at: the new target or the limit already in use.
assert (apply.label, keep.label) == ('Apply 54', 'Keep 49')
no_current = replace(state, speed_limit=replace(state.speed_limit, accepted_adjusted_limit_mps=None))
assert slc_controls(Profile.LARGE, no_current)[1].label == 'Keep'
def test_projection_v1_layout_migrates_without_losing_other_widgets():
@@ -118,18 +118,3 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped():
assert migrated['widgets']['speed_limit_actions'] == {'x': 1510, 'y': 567, 'enabled': True}
def test_projected_source_popup_avoids_moved_navigation_card():
from openpilot.starpilot.system.android_auto.projection_layout import projection_customization
viewport = (2880, 1080)
doc = default_layout_for_viewport(viewport)
rect = SimpleNamespace(x=30, y=30, width=2820, height=1020)
state = road(projection_customization(doc, default_document()))
first = source_panel_bounds(state, 200, rect)
assert first is not None
doc['widgets']['nav_card'].update(x=first[0], y=first[1], enabled=True)
state = road(projection_customization(doc, default_document()))
moved = source_panel_bounds(state, 200, rect)
if moved is not None:
x, y, width, height = moved
nx, ny = doc['widgets']['nav_card']['x'], doc['widgets']['nav_card']['y']
assert x + width <= nx or x >= nx + 560 or y + height <= ny or y >= ny + 195
@@ -68,7 +68,7 @@ class LayoutPreviewRendererTests(unittest.TestCase):
pending = sample_state("slc_pending", document)
for profile in (Profile.LARGE, Profile.COMPACT):
self.assertEqual([item.label for item in slc_controls(profile, pending)],
["Apply 54", "Keep"] if profile == Profile.LARGE else ["ACCEPT", "REJECT"])
["Apply 54", "Keep 49"] if profile == Profile.LARGE else ["ACCEPT", "REJECT"])
self.assertEqual(len(slc_controls(profile, sample_state("engaged", document))), 1 if profile == Profile.LARGE else 0)
manual = sample_state("experimental", document)
self.assertTrue(manual.experimental_enabled)
@@ -120,7 +120,7 @@ class LayoutPreviewRendererTests(unittest.TestCase):
self.assertEqual(renderer({"document": document, "profile": "compact", "scene": "braking", "art": "backdrop"}), b"png")
state, scene = canvas.render.call_args.args
self.assertEqual(scene, "braking")
self.assertEqual(canvas.render.call_args.kwargs, {"label": False, "source_panel": True})
self.assertEqual(canvas.render.call_args.kwargs, {"label": False})
# An order naming no widget keeps the ordered path, so no movable widget is drawn.
self.assertEqual(state.customization["widgetOrder"]["compact"], ["backdrop"])
self.assertEqual(state.customization["layouts"], document["layouts"])
@@ -253,18 +253,13 @@ def test_numeric_caption_declared_footprint_fits_actual_bottom_content_edge(proj
@pytest.mark.parametrize('kind', [None, 'scene_unavailable', 'unavailable', 'no_trigger', 'manual_chill',
'manual_experimental', 'unknown_wire_reason', 'cem_signal', 'cem_speed',
'cem_stop', 'cem_lead', 'cem_curve', 'curve_control'])
def test_source_drawer_reserves_gauge_only_when_actual_renderer_draws(projection, kind):
def test_gauge_draws_only_for_visible_reasons(projection, kind):
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, projection_customization
from openpilot.starpilot.ui.large_speed_geometry import source_panel_bounds
from openpilot.starpilot.ui.tests.test_speed_stack import road
viewport = (2160, 1080)
document = default_document()
document['layouts']['large']['speed_limit_actions']['y'] = 750
if projection:
projected = default_layout_for_viewport(viewport)
projected['widgets']['speed_limit_actions']['y'] = 750
document = projection_customization(projected, document)
document = projection_customization(default_layout_for_viewport((2160, 1080)), document)
sample = replace(state(kind if kind and kind != 'curve_control' else 'no_trigger'), customization=document,
longitudinal_active=True, slc_system_long_available=True, speed_limit=road().speed_limit)
if kind is None:
@@ -272,22 +267,9 @@ def test_source_drawer_reserves_gauge_only_when_actual_renderer_draws(projection
elif kind == 'curve_control':
curve = CurveObservation(True, 15., 15., True, True, False, 100., 2., 30., 'curve', 'selected', 'saved', True, False, .005)
sample = replace(sample, curve=curve)
content = NS(x=30, y=30, width=1800, height=1020)
bounds = source_panel_bounds(sample, 200, content)
disabled = copy.deepcopy(document)
disabled['layouts']['large'][CEM_GAUGE_WIDGET]['enabled'] = False
disabled_bounds = source_panel_bounds(replace(sample, customization=disabled), 200, content)
gauge = AetherGauge(Mock())
with patch.object(gauge, '_render_unified_road') as draw:
gauge.render(sample)
visible = kind in ('cem_stop', 'cem_lead', 'cem_curve', 'curve_control')
assert (gauge_data(sample) is not None) is visible
assert draw.called is visible
assert bounds is not None and disabled_bounds is not None
if not visible:
assert bounds == disabled_bounds # A blank gauge consumes exactly the disabled footprint.
else:
placed = document['layouts']['large'][CEM_GAUGE_WIDGET]
x, y, width, height = bounds
assert (x + width <= placed['x'] or x >= placed['x'] + 344 or
y + height <= placed['y'] or y >= placed['y'] + 300)
@@ -1,23 +0,0 @@
"""Pure reveal and shared-frame geometry; no UI/native resource acquisition."""
import unittest
from openpilot.starpilot.ui.speed_source_drawer_model import DrawerMotion
class DrawerTest(unittest.TestCase):
def test_reveal_finishes_and_reverses_without_jumping(self):
drawer = DrawerMotion()
drawer.update(True, 1)
self.assertEqual(drawer.progress, 0)
drawer.update(True, 1.09)
before = drawer.progress
self.assertTrue(0 < before < 1)
drawer.update(False, 1.09)
self.assertEqual(drawer.progress, before)
drawer.update(False, 1.24)
self.assertEqual(drawer.progress, 0)
drawer.update(True, 2)
drawer.update(True, 2.19)
self.assertEqual(drawer.progress, 1)
drawer.reset()
self.assertEqual(drawer.progress, 0)
@@ -76,9 +76,29 @@ class DrawerNativeTest(unittest.TestCase):
self.assertNotIn('speedSources', validate_document(doc))
doc['speedSources'] = True
self.assertIs(validate_document(doc)['speedSources'], True)
doc['speedSources'] = 1
with self.assertRaises(ValueError):
validate_document(doc)
doc['speedSourcesCard'] = True
self.assertIs(validate_document(doc)['speedSourcesCard'], True)
for key in ('speedSources', 'speedSourcesCard'):
with self.assertRaises(ValueError):
validate_document({**doc, key: 1})
self.assertNotIn('speedAlign', validate_document(default_document())) # Left, the original look, by default.
for align in ('left', 'center', 'right'):
self.assertEqual(validate_document({**doc, 'speedAlign': align})['speedAlign'], align)
for bad in ('justify', 1, None):
with self.assertRaises(ValueError):
validate_document({**doc, 'speedAlign': bad})
def test_sources_grow_the_cruise_widget_by_one_row_or_an_attached_card(self):
from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, GAP, SOURCE_ROW_HEIGHT, WIDTH
from openpilot.starpilot.ui.onroad_customization import customization_metadata, widget_size
doc = default_document()
self.assertEqual(widget_size(doc, 'large', 'cruise_limits'), (WIDTH, CARD_HEIGHT))
doc['speedSources'] = True
self.assertEqual(widget_size(doc, 'large', 'cruise_limits'), (WIDTH, CARD_HEIGHT + SOURCE_ROW_HEIGHT))
doc['speedSourcesCard'] = True
self.assertEqual(widget_size(doc, 'large', 'cruise_limits'), (WIDTH, CARD_HEIGHT + GAP + SOURCE_ROW_HEIGHT))
self.assertEqual(customization_metadata()['profiles']['large']['widgets']['cruise_limits']['sourcesExtent'],
{'inline': SOURCE_ROW_HEIGHT, 'card': GAP + SOURCE_ROW_HEIGHT})
def test_expired_producer_and_old_drive_transport_cannot_supply_readings(self):
sm, cp = replay_inputs()
@@ -133,19 +153,8 @@ class DrawerNativeTest(unittest.TestCase):
drawn = []
fonts = SimpleNamespace(measure=lambda *args: SimpleNamespace(width=20, height=20), draw=lambda text, role, *args: drawn.append((text, role)))
widget = UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts)))
widget._draw_source_contents(rl.Rectangle(264, 271, 248, 215), state)
self.assertIn(('Dashboard', FontRole.MEDIUM), drawn)
self.assertIn(('Vision', FontRole.NORMAL), drawn)
widget._draw_sources(rl.Rectangle(88, 545, 320, 76), state)
self.assertEqual([text for text, _ in drawn], ['Dash', '56', 'Vision', '56'])
self.assertIn(('56', FontRole.MEDIUM), drawn) # The accepted source's reading is emphasised.
self.assertIn(('56', FontRole.NORMAL), drawn)
def test_custom_right_edge_keeps_panel_inside_viewport(self):
from openpilot.starpilot.ui.large_speed_geometry import source_panel_bounds
obs = SpeedLimitObservation(ObservationKind.VALID, 'dashboard', 25, session_id='edge')
doc = default_document()
doc['speedSources'] = True
doc['layouts']['large']['cruise_limits']['x'] = 1350
state = OnroadState(False, False, 20, 100, obs, customization=validate_document(doc))
bounds = source_panel_bounds(state, 200, rl.Rectangle(30, 30, 1800, 1020))
self.assertIsNotNone(bounds)
x, y, width, height = bounds
self.assertLessEqual(x + width, 1830)
self.assertLessEqual(y + height, 1050)
@@ -5,7 +5,6 @@ from unittest.mock import Mock
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadInput, OnroadState, SlcActionKind, SlcUiRequest, SpeedLimitObservation
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document, rgba
from openpilot.starpilot.ui.large_speed_geometry import source_panel_bounds
from openpilot.starpilot.ui.slc_action_dispatch import SlcActionDispatcher
from openpilot.starpilot.ui.tests.test_slc_ui_actions import native_state, NOW
from openpilot.starpilot.speed_limits.speed_domain import adjusted_limit, OffsetBand, OffsetSchedule
@@ -45,21 +44,6 @@ def test_numeric_card_and_sources_row_are_not_tap_targets_while_pending():
assert not emitted
def test_saved_edge_geometry_keeps_panel_accessible():
viewport = N(x=30, y=30, width=1800, height=1020)
for card_x, card_y in ((88, 75), (1350, 528), (88, 528)):
for action_y in (567, 750, 942):
doc = default_document()
doc['layouts']['large']['cruise_limits'].update(x=card_x, y=card_y)
doc['layouts']['large']['speed_limit_actions'].update(x=card_x, y=action_y)
state = road(validate_document(doc))
bounds = source_panel_bounds(state, 200, viewport)
assert bounds is not None
x, y, width, height = bounds
assert 30 <= x and x + width <= 1830
assert 30 <= y and y + height <= 1050
def test_v4_migration_preserves_settings_and_clamps_only_enlarged_widgets():
doc = default_document()
doc['version'] = 4
@@ -111,41 +95,3 @@ def test_publisher_receipt_survives_next_cycle_and_resets_with_session():
assert runtime._command_ui_sequence == 0
def test_disengaging_during_receipt_keeps_sources_separate_from_feedback():
state = road()
state = replace(state, longitudinal_active=False, speed_limit=replace(state.speed_limit, action_feedback='Applied'))
from openpilot.starpilot.ui.large_speed_geometry import ACTION_HEADER_HEIGHT, WIDTH
bounds = source_panel_bounds(state, 200, N(x=30, y=30, width=1800, height=1020))
assert bounds is not None
x, y, width, height = bounds
actions = state.customization['layouts']['large']['speed_limit_actions']
assert (x + width <= actions['x'] or x >= actions['x'] + WIDTH or
y + height <= actions['y'] or y >= actions['y'] + ACTION_HEADER_HEIGHT)
def test_sources_popup_moves_past_gauge_and_keeps_enabled_widget_footprints_clear():
from openpilot.starpilot.ui.large_speed_geometry import GAP
from openpilot.starpilot.ui.onroad_customization import CAMERA_WIDGETS, CEM_GAUGE_WIDGET, PROFILES, widget_size
from openpilot.starpilot.conditional_mode.policy import ModeChoice
from openpilot.starpilot.ui.conditional_status import ConditionalDisplay
viewport = N(x=30, y=30, width=1800, height=1020)
doc = default_document()
doc['layouts']['large']['speed_limit_actions']['y'] = 750
state = replace(road(validate_document(doc)), conditional_configured=ModeChoice.CEM,
conditional_perception=ConditionalDisplay(ModeChoice.CEM, False, 'cem_stop', 8, 'planner', 1))
bounds = source_panel_bounds(state, 200, viewport)
assert bounds is not None
x, y, width, height = bounds
gauge = state.customization['layouts']['large'][CEM_GAUGE_WIDGET]
gauge_width, _ = widget_size(state.customization, 'large', CEM_GAUGE_WIDGET)
assert gauge['enabled'] and x >= gauge['x'] + gauge_width + GAP
assert viewport.x <= x and x + width <= viewport.x + viewport.width
assert viewport.y <= y and y + height <= viewport.y + viewport.height
for key in PROFILES['large']['widgets']:
saved = state.customization['layouts']['large'][key]
if saved['enabled'] and key not in CAMERA_WIDGETS:
widget_width, widget_height = widget_size(state.customization, 'large', key)
assert (x + width <= saved['x'] or x >= saved['x'] + widget_width or
y + height <= saved['y'] or y >= saved['y'] + widget_height), key