Clarify SLC targets and confirmation prompts

This commit is contained in:
firestarsdog
2026-11-06 06:04:39 -05:00
parent ff255c64b7
commit a6df2eda46
49 changed files with 1232 additions and 785 deletions
@@ -47,6 +47,25 @@ try {
assert.equal(starport.previewHidden("car_exit"), false)
assert.deepEqual([...saved], before)
for (const projection of [false, true]) {
const vm = editor(projection)
vm.state.scene = 'slc_pending'
vm.state.profile = 'large'
vm.layout = {clock:{enabled:true}}
vm.state.art = {status:'ready', scene:'slc_pending', profile:projection ? 'projection' : 'large',
sprites:{sprites:{clock:{visible:false}}}}
assert.equal(vm.sceneHidden('clock'), true, 'Native prompt occlusion hides the preview and its eye control')
vm.layout.clock.enabled = false
assert.equal(vm.sceneHidden('clock'), false, 'A disabled clock can still be added from the tray')
vm.layout.clock.enabled = true
for (const change of [{status:'updating'}, {scene:'engaged'}, {profile:'compact'}]) {
const native = vm.state.art
vm.state.art = {...native, ...change}
assert.equal(vm.sceneHidden('clock'), false, 'Retained artwork cannot apply stale visibility')
vm.state.art = native
}
}
saved.delete('gx-layout-preview-visibility-v2:comma')
saved.set('gx-layout-preview-visibility-v1:comma', JSON.stringify({ large: { current_speed: true }, compact: { stopped_timer: true } }))
const migrated = editor(false)
@@ -10,11 +10,11 @@ print(json.dumps(dict(document=default_document(), defaults=default_document(),
revision="native", editable=True, valid=True, activeProfile="large", supportedProfiles=["large"])))
`], { encoding: "utf8" }))
assert.equal(validSnapshot(data), true)
for (const version of [11, 12]) {
for (const version of [11, 12, 13]) {
assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), true)
assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), true)
}
for (const version of [9, 10, 13]) {
for (const version of [9, 10, 14]) {
assert.equal(validSnapshot({ ...data, document: { ...data.document, version } }), false)
assert.equal(validSnapshot({ ...data, defaults: { ...data.defaults, version } }), false)
}
@@ -92,25 +92,26 @@ assert.equal(vm.layout.nav_map.opacity, 35)
assert.equal(validSnapshot({ ...data, document: vm.state.draft }), true)
// Use actual native geometry for source attachment, bounds, and the transient timer.
assert.deepEqual(data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent, {inline:108, card:116, actionGap:8})
assert.deepEqual(data.metadata.profiles.large.widgets.cruise_limits.sourcesExtent, {inline:104, card:112, actionGap:8})
vm.state.draft = structuredClone(data.document)
vm.state.selected = "cruise_limits"
vm.setSpeedSources("speedSources", true)
assert.equal(vm.layout.speed_limit_actions.y, 359)
assert.equal(vm.layout.speed_limit_actions.y, 381)
vm.setSpeedSources("speedSourcesCard", true)
assert.equal(vm.layout.speed_limit_actions.y, 367)
assert.equal(vm.layout.speed_limit_actions.y, 389)
vm.setSpeedSources("speedSources", false)
assert.equal(vm.layout.speed_limit_actions.y, 267)
const overlapping = structuredClone(data.document)
overlapping.layouts.large.cruise_limits.y = 550
assert.equal(validDocument(overlapping, data.metadata), false)
// Keep the expanded confirmation clear of the torque arc while testing the bottom edge.
vm.layout.cruise_limits.x = 1520
vm.layout.speed_limit_actions.x = 1544
// Disable torque while testing source attachment at the lower screen edge.
vm.layout.torque_bar.enabled = false
vm.layout.cruise_limits.x = 1430
vm.layout.speed_limit_actions.x = 1454
vm.layout.cruise_limits.y = 690
vm.layout.speed_limit_actions.y = 882
vm.setSpeedSources("speedSources", true)
assert.equal(vm.layout.cruise_limits.y, 590)
assert.equal(vm.layout.cruise_limits.y, 568)
assert.equal(vm.layout.speed_limit_actions.y, 882)
assert.equal(validDocument(vm.state.draft, data.metadata), true)
Object.assign(vm.layout.speed_limit_actions, {x:600, y:500})
@@ -531,13 +531,12 @@ assert.match(OnroadLayoutPage.template, /state\.profile === 'compact'" class="gx
// One scene dropdown, grouped, with every scene exactly once.
assert.deepEqual(vm.sceneGroups.map(group => group.label), ["Editing", "Driving", "Conditional experimental", "Speed limit"])
assert.deepEqual(vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene.id)).sort(), vm.scenes.map(scene => scene.id).sort())
// The Small screen only offers scenes that look different there; a hidden pick falls back to General edit.
// Compact exposes its pedal and display-only states just like the large screen.
vm.state.scene = "slc_display"
vm.selectProfile("compact")
assert.equal(vm.state.scene, "general")
assert.equal(vm.state.scene, "slc_display")
const smallScenes = vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene.id))
for (const id of ["slc_pedal", "slc_display"]) assert.ok(!smallScenes.includes(id), id)
assert.ok(smallScenes.includes('slc_retained')) // The override X is visible on the Small screen too.
for (const id of ["slc_pedal", "slc_display", "slc_retained"]) assert.ok(smallScenes.includes(id), id)
assert.ok(smallScenes.includes("slc_unavailable") && smallScenes.includes("slc_disengaged"))
vm.selectProfile("large")
assert.equal(vm.sceneGroups.flatMap(group => group.scenes).length, vm.scenes.length)
@@ -1211,12 +1210,26 @@ console.log('Display options, target switching and reload during polling passed'
// A closed developer sidebar makes the torque arc taller than its narrow-road box.
{
const sample = snapshot(), profile = sample.metadata.profiles.large
profile.widgets.cruise_limits.height = 168
profile.widgets.cruise_limits.confirmation = { x: 172, y: -12, width: 419, height: 204 }
profile.widgets.cruise_limits.height = 194
profile.widgets.cruise_limits.confirmation = { x: 172, y: -12, width: 388 * Math.hypot(2160,1080) / 1800, height: 168 * Math.hypot(2160,1080) / 1800 }
profile.widgets.torque_bar.confirmationObstacle = { x: 0, y: -48, width: 1250, height: 289 }
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 550, sample.document.layouts.large), true)
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 535, sample.document.layouts.large), true)
delete profile.widgets.torque_bar.confirmationObstacle
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 550, sample.document.layouts.large), false)
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 535, sample.document.layouts.large), false)
profile.widgets.cruise_limits.height = 276
assert.equal(documentHelpers.overlapsReserved(profile, 'cruise_limits', 88, 500, sample.document.layouts.large), true)
}
// Portrait sizing and its attached chooser use the same compact bounds as native input.
{
const sample = snapshot(), profile = sample.metadata.profiles.compact
profile.widgets.max_speed.width = 146
profile.widgets.speed_limit = { ...profile.widgets.speed_limit, width: 128, height: 144,
confirmation: { x: -172, y: -8, width: 328, height: 152 } }
sample.document.version = 13
sample.document.layouts.compact.speed_limit = { x: 320, y: 16, enabled: true }
assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 16, sample.document.layouts.compact), false)
assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 320, 24, sample.document.layouts.compact), true)
assert.equal(documentHelpers.overlapsReserved(profile, 'speed_limit', 317, 16, sample.document.layouts.compact), true)
assert.equal(clampPlacement(profile, 'speed_limit', 330, 16, sample.document.layouts.compact).x, 320)
}
@@ -25,15 +25,15 @@ metadata.widgets.jetlink_status = { label: "Jetlink status", kind: "jetlink_stat
metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "jetlink_status", "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: 5, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", canvas: { width: 2880, height: 1080 }, widgets }
const doc = { version: 6, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "center", 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,
colors: { palette: native.document.palette, widgetColors: native.document.widgetColors.large, roadColors: {} } }
const data = editorSnapshot(raw)
assert.deepEqual(editorSnapshot({ ...raw, document: { ...doc, version: 4 }, defaults: { ...doc, version: 4 } }).document,
data.document, "version 4 layouts remain readable alongside the backend's version 5")
for (const bad of [{ version: 6 }, { version: "5" }, { canvas: { width: 1920, height: 1080 } }])
data.document, "version 4 layouts remain readable alongside the backend's version 6")
for (const bad of [{ version: 7 }, { version: "5" }, { canvas: { width: 1920, height: 1080 } }])
assert.throws(() => editorSnapshot({ ...raw, document: { ...doc, ...bad } }), /does not match/)
assert.equal(validSnapshot(data), true)
@@ -260,7 +260,7 @@ const draft = copy(feed.data.document)
draft.layouts.large.current_speed.x += 20
assert.equal(await feed.save(draft), true)
assert.equal(requests[0].url, "./api/starport/layout")
assert.equal(requests[1].body.document.version, 5)
assert.equal(requests[1].body.document.version, 6)
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(requests[1].body.document.speedAlign, "center")
@@ -27,10 +27,22 @@ const withHeader={...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1
assert.deepEqual(await widgetArtMetadata(image(withHeader)),withHeader,'Native action sprites include the heading above the buttons')
for(const bad of [{...meta,width:3},{...meta,sprites:{conditional_mode:{x:1,y:0,width:2,height:1}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:NaN,height:1}}},
{...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-33}}},
{...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-34}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetY:-32}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetX:1}}}])await assert.rejects(widgetArtMetadata(image(bad)))
await assert.rejects(widgetArtMetadata(new Blob(['not PNG'])))
for (const [id, offsetX, offsetY] of [['speed_limit', -182.5, -12.5], ['speed_limit', -2, 0], ['cruise_limits', 0, -12.5]]) {
const native = {...meta, sprites: {[id]: {x:0,y:0,width:2,height:1,offsetX,offsetY,visible:false}}}
assert.deepEqual(await widgetArtMetadata(image(native)), native)
}
for (const rect of [{offsetX:-193}, {offsetY:-14}, {visible:'false'}]) {
await assert.rejects(widgetArtMetadata(image({...meta,sprites:{speed_limit:{x:0,y:0,width:2,height:1,...rect}}})))
}
const compactIds=['weather','max_speed','speed_limit','steering_wheel','conditional_mode','following_distance','model_confidence',
'driver_monitor','speed_limit_actions','driving_mode_descriptions','clock']
const complete={...meta,sprites:Object.fromEntries(compactIds.map(id=>[id,{x:0,y:0,width:2,height:1,visible:true}]))}
assert.deepEqual(await widgetArtMetadata(image(complete)),complete)
await assert.rejects(widgetArtMetadata(image({...complete,sprites:{...complete.sprites,extra:{x:0,y:0,width:2,height:1}}})))
const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve()}
function fixture() {
const updates=[],requests=[],timers=new Map(),revoked=[];let next=0,url=0,unauthorized=0
@@ -59,6 +71,7 @@ tick();await answer(1);assert.equal(requests.length,3);await answer(2);assert.eq
feed.update(document,'compact','engaged')
assert.equal(feed.currentUrl,'blob:1','Returning to a profile restores its artwork immediately')
assert.equal(updates.at(-1).status,'ready');assert.equal(timers.size,0,'Unchanged cached artwork needs no request')
assert.equal(updates.findLast(update=>'profile' in update).profile,'compact','Cached artwork retains its screen identity')
assert.deepEqual(revoked,[],'Switching profiles keeps both mounted sheets alive')
feed.update(document,'large','braking');tick();await answer(3,503)
assert.equal(feed.currentUrl,'blob:2','A transient failure retains visible artwork')
@@ -1,7 +1,5 @@
import { widgetArtMetadata } from "./widget-art.js"
import { widgetSize } from "./onroad-layout-document.js"
// hiddenOn: screens where the scene looks exactly like another one. The Small screen has no text for these
// speed-limit states, so they match Engaged there.
export const PREVIEW_SCENES = [
{ id: "general", label: "General edit" },
{ id: "engaged", label: "Engaged" },
@@ -17,11 +15,11 @@ export const PREVIEW_SCENES = [
{ id: "slc_pending", label: "Confirmation choices" },
{ id: "slc_max", label: "Using set speed" },
{ id: "slc_equal", label: "Equal speed limits" },
{ id: "slc_pedal", label: "Using accelerator", hiddenOn: ["compact"] },
{ id: "slc_pedal", label: "Using accelerator" },
{ id: "slc_retained", label: "Set-speed override" },
{ id: "slc_disengaged", label: "Cruise off" },
{ id: "slc_unavailable", label: "Unavailable speed limit" },
{ id: "slc_display", label: "Display-only speed limit", hiddenOn: ["compact"] },
{ id: "slc_display", label: "Display-only speed limit" },
]
const SCENES = PREVIEW_SCENES.map(({ id }) => id)
@@ -36,8 +34,9 @@ export function previewScene(scene) {
mode: off ? "Disengaged" : scene === "slc_pedal" ? "ACC - Override" : !longitudinal ? "Always On Lateral" : experimental ? "ACC - Experimental" : "ACC - Chill" }
}
export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}) {
export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}, sprites = {}) {
if (scene === "general") return false
if (layout?.[id]?.enabled && typeof sprites[id]?.visible === "boolean") return !sprites[id].visible
const { lateral, longitudinal } = previewScene(scene)
if (id === "stopped_timer") return scene !== "stopped"
if (id === "cem_gauge") return !scene.startsWith("cem_")
@@ -159,7 +158,7 @@ export class LayoutPreviewFeed {
if (cached) this.latestArt.set(profile, cacheKey)
const shown = cached || this.artCache.get(this.latestArt.get(profile))
this.currentUrl = shown?.url || null
this.publish({ url: this.currentUrl, sprites: shown?.sprites || null, scene: shown?.scene || null })
this.publish({ url: this.currentUrl, sprites: shown?.sprites || null, scene: shown?.scene || null, profile })
if (cached?.key === renderKey) {
this.publish({ status: "ready", error: "" })
return
@@ -140,7 +140,7 @@ export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "
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, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "reduceMotion") ? ["reduceMotion"] : []), ...(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"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12].includes(document.version)) ||
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"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "reduceMotion") ? ["reduceMotion"] : []), ...(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"] : [])]) || (metadata?.projection === true ? document.version !== 8 : ![11, 12, 13].includes(document.version)) ||
(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)) ||
@@ -606,7 +606,12 @@ export const OnroadLayoutPage = {
this.state.showEditingAids = !this.state.showEditingAids
try { localStorage.setItem(aidsPreferenceKey, this.state.showEditingAids ? "1" : "0") } catch { }
},
sceneHidden(id) { return sceneHidesWidget(this.state.scene, this.state.profile, id, this.layout, this.profile?.widgets) },
sceneHidden(id) {
const { scene, profile, art } = this.state
const sprites = art?.status === "ready" && art.scene === scene && art.profile === (this.projection ? "projection" : profile) ?
art.sprites?.sprites : undefined
return sceneHidesWidget(scene, profile, id, this.layout, this.profile?.widgets, sprites)
},
previewHidden(id) {
if (this.projection && id === "car_exit") return false
if (this.sceneHidden(id)) return true
@@ -25,7 +25,7 @@ export function editorSnapshot(raw) {
throw new Error(raw?.reason || "Connect Android Auto once to obtain the actual screen.")
const colors = raw.colors
const document = (value) => {
if (![4, 5].includes(value.version) || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
if (![4, 5, 6].includes(value.version) || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
throw new Error("The saved StarPort layout does not match its screen. Reload before editing.")
return { version: 8, 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) } }
@@ -38,7 +38,7 @@ export function editorSnapshot(raw) {
}
export function projectionPayload(body, metadata) {
return { revision: body.revision, document: { version: 5,
return { revision: body.revision, document: { version: 6,
canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height },
clock24Hour: body.document.clock24Hour === true,
largeUiGammaTrial: body.document.largeUiGammaTrial === true,
@@ -32,14 +32,16 @@ export async function widgetArtMetadata(blob, header = null) {
metadata.width < 1 || metadata.width > 1024 || metadata.height < 1 || metadata.height > 1440 ||
metadata.width !== view.getUint32(16) || metadata.height !== view.getUint32(20) ||
!metadata.sprites || typeof metadata.sprites !== "object" || Array.isArray(metadata.sprites) ||
Object.keys(metadata.sprites).length > 10) throw new Error("Invalid widget art metadata")
Object.keys(metadata.sprites).length > 11) throw new Error("Invalid widget art metadata")
for (const [id, rect] of Object.entries(metadata.sprites)) {
const minX = id === "speed_limit" ? -192 : -2
const minY = id === "speed_limit_actions" ? -33 : ["cruise_limits", "speed_limit"].includes(id) ? -13 : -1
if (!/^[a-z_]+$/.test(id) || !rect || ![rect.x,rect.y,rect.width,rect.height].every(Number.isInteger) ||
rect.x < 0 || rect.y < 0 || rect.width < 1 || rect.height < 1 ||
rect.x + rect.width > metadata.width || rect.y + rect.height > metadata.height ||
!Number.isFinite(rect.offsetX ?? 0) || (rect.offsetX ?? 0) <= -1 || (rect.offsetX ?? 0) > 0 ||
!Number.isFinite(rect.offsetY ?? 0) || (rect.offsetY ?? 0) <= (id === 'speed_limit_actions' ? -33 : -1) ||
(rect.offsetY ?? 0) > 0) throw new Error("Invalid widget art rectangle")
!Number.isFinite(rect.offsetX ?? 0) || (rect.offsetX ?? 0) < minX || (rect.offsetX ?? 0) > 0 ||
!Number.isFinite(rect.offsetY ?? 0) || (rect.offsetY ?? 0) < minY || (rect.offsetY ?? 0) > 0 ||
(rect.visible !== undefined && typeof rect.visible !== "boolean")) throw new Error("Invalid widget art rectangle")
}
return metadata
}
+22 -8
View File
@@ -43,14 +43,21 @@ With **Confirm limits** set to **Off**, qualified new limits are adopted immedia
accepted posted limit. An unselected direction is automatic.
Without a previous limit, any manual policy asks for the first one unless it
already matches SET.
The stationary confirmation card gives two gentle purple pulses
The stationary New Limit caption gives two gentle pulses
(700 ms each, separated by 200 ms), then rests until the 3.2-second cycle repeats.
Only the lower part of the card pulses; the speed and source remain steady.
Only the caption changes color; the speed and source remain steady.
The arrow remains relative to vehicle speed.
Outside manual confirmation, large UI uses the same US or Vienna sign at 1.25×
scale beside SET speed.
US signs share a cached antialiased white outline with a transparent interior;
the offset fits inside the fixed sign height on both screens.
Signs and confirmation choices have transparent interiors. Cached sign outlines and
tight dark glyph edges keep the camera visible without sampling its brightness.
SET keeps its label and value in both profiles. When the displayed road limit
matches the fresh accepted cap actually limiting SET, it gets an IN USE label;
the rectangular sign also turns green.
An applied offset target uses a large LIMIT value with the source reading below.
A held override uses the same rectangular readout labeled OVERRIDE, including in
Vienna mode; the road limit remains readable below it. While the accelerator is
pressed, PEDAL identifies the temporary driver input without claiming a held speed.
Both sign styles identify the displayed source: DASH for Dashboard, OSM for offline
Maps, and VISION for the vision model. Explicit Mapbox readings in historical
replay use NAV; unidentified live sources show SPEED. A retained road reading
@@ -58,10 +65,17 @@ shows LAST alongside its source when known. For the same source and speed, the
label clears after 150 ms of live updates. Vision keeps a confirmed sign current
after it leaves view while the camera pipeline stays healthy. DASH follows the
current OEM reading, and Maps keeps the current road limit separate from its
upcoming preview. The binding SLC speed is shown separately beside SET, including
during a new-limit confirmation.
Both styles place only this source above the number, beside any pending arrow.
The source and arrow share a centered row; all sign contents use the same geometry
upcoming preview. Retained and recovering readings never claim IN USE.
During a new-limit confirmation the applied LIMIT or OVERRIDE stays below SET;
if SET is hidden, that status appears in the confirmation header with a `Now:`
label. The expanded Accept button contains the proposed limit including its offset
and unit; Reject says `new limit`. Source readings and offset arithmetic stay in
the collapsed notice, outside the expanded decision. Accept and Reject retain their original touch
areas and action validation. Green Accept and red Reject outlines contain each
button without filling its interior; pressing strengthens the outline and colors
the label. Disabled choices use muted ink.
Ordinary signs keep the source above the number, beside any pending arrow.
The source and arrow share a centered row; sign contents use the same geometry
on both screens. Small arrows are baked at double resolution and text is aligned
to final pixels for clear native rendering.
Steering-wheel + accepts and − declines. Tapping the sign opens the same
@@ -182,7 +182,7 @@ def default_layout(screen, display=None):
def default_layout_for_viewport(viewport, display=None):
metadata = layout_metadata_for_viewport(viewport)
display = {**DISPLAY_DEFAULTS, **{key: value for key, value in (display or {}).items() if key in DISPLAY_DEFAULTS}}
result = {'version': 5, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')},
result = {'version': 6, 'clock24Hour': False, 'largeUiGammaTrial': False, **display, 'canvas': {key: metadata[key] for key in ('width', 'height')},
'widgets': {key: {**widget['default'], **({'size': 192} if key == 'steering_wheel' else {})}
for key, widget in metadata['widgets'].items()}}
place_attached_slc_actions(result['widgets'], metadata['bounds'], sources_extent(result))
@@ -220,7 +220,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if type(value) is dict:
fields |= {field for field in ('widgetOrder', 'clock24Hour', 'largeUiGammaTrial', *DISPLAY_DEFAULTS) if field in value}
if (type(value) is not dict or set(value) != fields or
type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5) or
type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5, 6) or
value['canvas'] != {key: metadata[key] for key in ('width', 'height')} or
type(value['widgets']) is not dict or not set(value['widgets']) <= set(metadata['widgets']) or
not set(metadata['widgets']) - set(value['widgets']) <= {CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, *PROJECTION_WIDGETS}):
@@ -305,6 +305,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
dimensions = SPEED_LAYOUT_V7['large'][key][:2]
elif value['version'] < 5 and key == 'cruise_limits':
dimensions = (widget['width'], 132)
elif value['version'] < 6 and key == 'cruise_limits':
dimensions = (widget['width'], 168)
# Version 2 and older saved the CEM gauge's full drawing frame; check that, then move to the inset box.
old_gauge = key == CEM_GAUGE_WIDGET and value['version'] < 3 and key in value['widgets']
if old_gauge:
@@ -333,7 +335,12 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
actions = result['widgets']['speed_limit_actions']
if (actions['x'], actions['y']) == (100, 296):
actions.update(x=metadata['widgets']['speed_limit_actions']['default']['x'], y=metadata['widgets']['speed_limit_actions']['default']['y'])
result['version'] = 5
result['version'] = 6
if value['version'] == 5 and result.get('speedSources') is True:
card, actions = result['widgets']['cruise_limits'], result['widgets']['speed_limit_actions']
old_gap = 284 + (8 if result.get('speedSourcesCard') is True else 0)
if actions['x'] == card['x'] + 24 and actions['y'] == card['y'] + old_gap:
actions['y'] = card['y'] + 192
migrate_confirmation_layout(result['widgets'], 'large', metadata['widgets'], bounds, extra_height=sources_extent(result))
place_attached_slc_actions(result['widgets'], bounds, sources_extent(result))
if len(json.dumps(result, separators=(',', ':'), allow_nan=False).encode()) > MAX_BYTES:
@@ -288,14 +288,9 @@ class ProjectionOnroad:
else:
self._monitor_pair = None
self._monitor_pair_ns = None
self.native.rl.rl_push_matrix()
self.native.rl.rl_translatef(0, self.height - 1080, 0)
try:
self.monitor.render(state, monitor=monitor, driver=driver,
fresh=monitor is not None and driver is not None,
onroad=ui.is_onroad())
finally:
self.native.rl.rl_pop_matrix()
self.monitor.render(state, monitor=monitor, driver=driver,
fresh=monitor is not None and driver is not None,
onroad=ui.is_onroad(), y_offset=self.height - 1080)
def _ensure_map(self):
"""Build the map overlay the first time a layout turns it on; it stays for the connection."""
@@ -50,7 +50,7 @@ class TestProjectionLayout(unittest.TestCase):
with self.subTest(viewport=viewport, separate=separate):
document = default_layout_for_viewport(viewport, {'speedSources': True, 'speedSourcesCard': separate})
actions = document['widgets']['speed_limit_actions']
self.assertEqual(actions['y'], 367 if separate else 359)
self.assertEqual(actions['y'], 389 if separate else 381)
self.assertEqual(validate_layout_for_viewport(document, viewport), document)
native = projection_customization(document, default_document())
self.assertEqual(placement(native, 'large', 'speed_limit_actions'), actions)
@@ -58,7 +58,7 @@ class TestProjectionLayout(unittest.TestCase):
actions['y'] = 882
saved = validate_layout_for_viewport(document, viewport)
card, actions = (saved['widgets'][key] for key in ('cruise_limits', 'speed_limit_actions'))
self.assertEqual(actions['y'], card['y'] + 284 + (8 if separate else 0))
self.assertEqual(actions['y'], card['y'] + 306 + (8 if separate else 0))
self.assertLessEqual(actions['y'] + 168, viewport[1] - 30)
self.assertEqual(validate_layout_for_viewport(saved, viewport), saved)
@@ -72,8 +72,8 @@ class TestProjectionLayout(unittest.TestCase):
before = copy.deepcopy(document)
migrated = validate_layout(document, SCREEN)
self.assertEqual(document, before)
self.assertEqual(migrated['version'], 5)
self.assertEqual(migrated['widgets']['cruise_limits']['y'], bottom - 192)
self.assertEqual(migrated['version'], 6)
self.assertEqual(migrated['widgets']['cruise_limits']['y'], bottom - 214)
self.assertEqual(migrated['widgets']['speed_limit_actions'], document['widgets']['speed_limit_actions'])
self.assertEqual(validate_layout(migrated, SCREEN), migrated)
@@ -85,6 +85,33 @@ class TestProjectionLayout(unittest.TestCase):
migrated = validate_layout(document, SCREEN)
self.assertEqual(migrated['widgets']['speed_limit_actions']['y'], card['y']+192)
def test_v5_portrait_upgrade_keeps_disabled_actions_and_clamps_only_the_resized_card(self):
document = default_layout(SCREEN)
document['version'] = 5
bottom = sum(layout_metadata(SCREEN)['bounds'][key] for key in ('y', 'height'))
document['widgets']['cruise_limits']['y'] = bottom - 168
document['widgets']['speed_limit_actions'].update(x=250, y=400, enabled=False)
before = copy.deepcopy(document)
migrated = validate_layout(document, SCREEN)
expected = copy.deepcopy(document)
expected['version'] = 6
expected['widgets']['cruise_limits']['y'] = bottom - 194
self.assertEqual(document, before)
self.assertEqual(migrated, expected)
self.assertEqual(validate_layout(migrated, SCREEN), migrated)
def test_v5_source_attachment_follows_the_new_row_without_moving_custom_actions(self):
for separate in (False, True):
document = default_layout(SCREEN, {'speedSources': True, 'speedSourcesCard': separate})
document['version'] = 5
actions = document['widgets']['speed_limit_actions']
actions['y'] = 367 if separate else 359
updated = validate_layout(document, SCREEN)
self.assertEqual(updated['widgets']['speed_limit_actions']['y'], 389 if separate else 381)
self.assertEqual(validate_layout(updated, SCREEN), updated)
actions.update(x=600, y=400)
self.assertEqual(validate_layout(document, SCREEN)['widgets']['speed_limit_actions'], actions)
def test_jetlink_widget_migrates_and_preserves_placement_and_order(self):
document = default_layout(SCREEN)
expected = document['widgets'].pop('jetlink_status')
+24 -10
View File
@@ -1,9 +1,9 @@
# Retained presentation assets
The speed group uses the existing font atlas with a thin contour and short shadow
in `speed_limit_widget.py`; it owns no background texture. The large camera header
gradient and compact `icons_mici/onroad/onroad_fade.png` remain unchanged. The
compact fade stays pinned in `onroad-assets.json` with the retained wheel artwork.
The speed group uses the existing font atlas with a tight dark contour
in `speed_limit_widget.py`; it owns no background texture. The large header gradient
and compact camera fade are no longer drawn. The bundled compact fade remains
pinned in `onroad-assets.json` for the retained upstream artwork.
`fonts` contains the unchanged four Inter bitmap pairs and one Unifont bitmap
pair used by the presentation reference, plus the supplied Sora wordmark font
@@ -46,12 +46,26 @@ Open Font License notice. Home can now use the pinned source-owned Sora, Inter,
and Unifont fonts without an external directory. An explicit font-directory
override remains available and must pass the same byte validation.
The original SLC circle/ring masks, joined arrows and confirmation surfaces are generated by
The SLC ring, rectangular outline, buttons, arrows and steering-wheel cues are generated by
`python -m openpilot.starpilot.ui.assets.generate_speed_limit_shapes`.
The 8× coverage sampling happens offline; runtime shares one small atlas and
draws tinted quads without per-frame shape tessellation or new shaders.
Confirmation surfaces stretch only their flat centers and share that atlas;
their translucent purple pulse stays below the source and speed.
Both onroad profiles use these US and Vienna signs. Large UI scales their geometry
by 1.25 and normalizes font sizes to preserve the compact sign's proportions,
including its fitted offsets, joined arrows and confirmation pulses.
The sign interiors and confirmation choices stay transparent. Tight dark edges
support the light glyphs and outlines; only the New Limit caption pulses.
Expanded Accept / Reject buttons have separate large and compact sprites with
double-density antialiased strokes and a tight dark contour baked in. Each button is one tinted
quad, including its pressed variant; interiors remain transparent.
Button text uses measured ink bounds: action headings share a row, the speed and
unit are centered together, and each complete text group has equal vertical margins.
Both onroad profiles use a 128 × 144 portrait footprint. Large UI scales it by
1.3416 (the 402.5-to-300 PPI ratio) and normalizes font sizes to preserve physical
lettering size. The compact footprint stays above the torque arc. State headings
and targets are centered; long source/offset details use two supporting rows.
Held overrides use the rectangular readout in either sign style so a driver-selected
target cannot look like a posted Vienna limit. Pedal input replaces the number with
`Pedal` until the controller reports a held target.
Open confirmations use 180 × 128 logical touch areas on large UI and 150 × 112
on compact UI. The `Now:` header carries the current controller target once;
the proposed target and unit are inside Accept. The same dimensions drive
rendering, touch handling, layout validation, and native editor artwork.
@@ -3,6 +3,8 @@
import numpy as np
from PIL import Image
from openpilot.starpilot.ui.large_speed_geometry import (SIGN_WIDTH, SIGN_HEIGHT, CONFIRMATION_BUTTON, CONFIRMATION_HEIGHT,
CONFIRMATION_HEADER, CONFIRMATION_PADDING)
from openpilot.starpilot.ui.speed_limit_shapes import ATLAS_PATH, ATLAS_SIZE, SPRITES, SPRITE_DENSITIES
@@ -21,27 +23,31 @@ def generate():
py = ((np.arange(height * SAMPLES) + .5) / SAMPLES - height / 2) / density
xx, yy = np.meshgrid(px, py)
rgba = np.full((height, width, 4), 255, dtype=np.uint8)
if name in ('disc', 'ring'):
if name == 'ring':
radius = np.hypot(xx, yy)
# End the white backing inside the opaque rim, avoiding an outer white fringe.
mask = radius <= 55 if name == 'disc' else (radius <= 59) & (radius >= 51)
mask = (radius <= 59) & (radius >= 51)
rgba[..., 3] = np.rint(coverage(mask, width, height) * 255).astype(np.uint8)
elif name in ('outline', 'override_outline'):
elif name == 'outline':
# Signed distance to a rounded rectangle; supersampling is offline only.
half_width, half_height = (57, 64) if name == 'outline' else (66, 47)
half_width, half_height = SIGN_WIDTH / 2 - 2, SIGN_HEIGHT / 2 - 2
qx, qy = np.abs(xx) - (half_width - 8), np.abs(yy) - (half_height - 8)
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - 8
rgba[..., 3] = np.rint(coverage((distance >= -2) & (distance <= 0), width, height) * 255).astype(np.uint8)
elif name == 'override_circle':
distance = np.hypot(xx, yy) - 60
alpha = coverage(distance <= 0, width, height)
interior = coverage(distance <= -6, width, height)
weight = np.divide(interior, alpha, out=np.zeros_like(alpha), where=alpha > 0)[..., None]
rgba[..., :3] = np.rint(np.array((201, 34, 49)) * (1 - weight) + 255 * weight).astype(np.uint8)
elif name.startswith('button'):
profile = name.split('_')[1]
half_width = (CONFIRMATION_BUTTON[profile] - 8) / 2
half_height = (CONFIRMATION_HEIGHT[profile] - CONFIRMATION_HEADER - 2 * CONFIRMATION_PADDING) / 2
qx, qy = np.abs(xx) - (half_width - 16), np.abs(yy) - (half_height - 16)
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - 16
half_stroke = 1.5 if name.endswith('_pressed') else 1.
fill = coverage(np.abs(distance) <= half_stroke, width, height)
border = coverage((np.abs(distance) > half_stroke) & (np.abs(distance) <= half_stroke + .8), width, height) * .95
alpha = fill + border
# Bake the tight contour into the same quad so curved edges never overlap or facet.
for channel, dark in enumerate((6, 14, 10)):
rgba[..., channel] = np.rint(np.divide(fill * 255 + border * dark, alpha,
out=np.full_like(alpha, dark), where=alpha > 0)).astype(np.uint8)
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
elif name == 'override_slash':
mask = np.hypot(np.maximum(np.abs(xx) - 20.5, 0), yy) <= 1.2
rgba[..., 3] = np.rint(coverage(mask, width, height) * 255).astype(np.uint8)
elif name in ('wheel_plus', 'wheel_minus'):
qx, qy = np.abs(xx) - 4, np.abs(yy) - 4
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - 5
@@ -57,30 +63,6 @@ def generate():
rgba[..., channel] = np.rint(np.divide(border * edge + mark * ink, alpha,
out=np.where(center, ink, edge).astype(float), where=alpha > 0)).astype(np.uint8)
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
elif name.startswith('confirmation'):
pulse = name == 'confirmation_pulse'
radius = 16 if name == 'confirmation_open' else 24
qx = np.abs(xx) - ((width / density - 2) / 2 - radius)
qy = np.abs(yy + (43 if pulse else 0)) - (76 - radius)
distance = np.hypot(np.maximum(qx, 0), np.maximum(qy, 0)) + np.minimum(np.maximum(qx, qy), 0) - radius
fill = coverage(distance <= 0, width, height)
row = ((np.arange(height) + .5) / density - 1 + (86 if pulse else 0)) / 152
if pulse:
rgba[..., :3] = (191, 115, 253)
alpha = fill * np.clip((row * 152 - 86) / 66, 0, 1)[:, None] * .20
else:
stops = (0, .08, .55, .64, 1)
colors = np.array(((73, 69, 94), (57, 55, 77), (55, 52, 78), (65, 53, 90), (86, 55, 118)))
base = np.stack([np.interp(row, stops, colors[:, c]) for c in range(3)], axis=-1)[:, None, :]
colors = np.array(((173, 153, 205), (116, 103, 141), (94, 72, 124)))
edge = np.stack([np.interp(row, (0, .5, 1), colors[:, c]) for c in range(3)], axis=-1)[:, None, :]
rim = coverage((distance >= -1) & (distance <= 0), width, height)
rim = np.divide(rim, fill, out=np.zeros_like(fill), where=fill > 0) * np.interp(row, (0, .5, 1), (.68, .46, .40))[:, None]
alpha = rim + 230 / 255 * (1 - rim)
rgba[..., :3] = np.rint((rim[..., None] * edge + 230 / 255 * (1 - rim[..., None]) * base) / alpha[..., None]).astype(np.uint8)
alpha *= fill
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
rgba[rgba[..., 3] == 0] = 0
else:
half_height, wing, stem, head, outline = (14, 9, 3, 9, 1.4) if name == 'arrow_large' else (7, 5, 2.5, 5, 1)
base = half_height - head
@@ -94,11 +76,11 @@ def generate():
t = np.clip(((xx - ax) * dx + (yy - ay) * dy) / (dx*dx + dy*dy), 0, 1)
distance_sq = np.minimum(distance_sq, (xx - ax - t*dx)**2 + (yy - ay - t*dy)**2)
fill = coverage(inside, width, height)
border = np.zeros_like(fill) if name == 'arrow_confirmation' else coverage((distance_sq <= outline**2) & ~inside, width, height) * .87
border = coverage((distance_sq <= outline**2) & ~inside, width, height) * .87
alpha = fill + border
for channel, dark in enumerate((6, 14, 10)):
rgba[..., channel] = np.rint(np.divide(fill * 255 + border * dark, alpha,
out=np.full_like(alpha, 255 if name == 'arrow_confirmation' else dark), where=alpha > 0)).astype(np.uint8)
out=np.full_like(alpha, dark), where=alpha > 0)).astype(np.uint8)
rgba[..., 3] = np.rint(alpha * 255).astype(np.uint8)
atlas.paste(Image.fromarray(rgba), (x, y))
return atlas
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+18 -14
View File
@@ -4,8 +4,10 @@ import math
WIDTH = 488
MAX_ROW_HEIGHT = 132
LARGE_SIGN_SCALE = 1.25
LIMIT_ROW_HEIGHT = 168
# Preserve physical lettering size across the 402.5 PPI and 300 PPI displays.
LARGE_SIGN_SCALE = math.hypot(2160, 1080) / 6 / 300
SIGN_WIDTH, SIGN_HEIGHT = 128, 144
LIMIT_ROW_HEIGHT = math.ceil(SIGN_HEIGHT * LARGE_SIGN_SCALE)
CARD_HEIGHT = LIMIT_ROW_HEIGHT
ACTION_WIDTH = 308
ACTION_HEIGHT = 168
@@ -15,35 +17,37 @@ GAP = 8
SOURCE_ROW_HEIGHT = 76
PADDING = 24
LIMIT_RECTS = {'large': (318, 75, 148, 165), 'compact': (330, 20, 118, 132)}
LIMIT_RECTS = {'large': (318, 75, math.ceil(SIGN_WIDTH * LARGE_SIGN_SCALE), LIMIT_ROW_HEIGHT),
'compact': (320, 16, SIGN_WIDTH, SIGN_HEIGHT)}
ACTION_BOUNDS = {
'large': ((112, ACTION_Y, 420, ACTION_Y + 80), (112, ACTION_Y + 88, 420, ACTION_Y + ACTION_HEIGHT)),
'compact': ((146, 44, 330, 102), (146, 106, 330, 164)),
}
ACTION_TEXT_SIZE = {'large': 64, 'compact': 48}
# Match physical touch size: the large display is ~402.5 PPI; compact is 300 PPI.
CONFIRMATION_SCALE = {'large': math.hypot(2160, 1080) / 6 / 300, 'compact': 1.0}
CONFIRMATION_HEIGHT = 152
CONFIRMATION_SCALE = {'large': LARGE_SIGN_SCALE, 'compact': 1.0}
CONFIRMATION_HEIGHT = {'large': 168, 'compact': 152}
CONFIRMATION_HEADER = 40
CONFIRMATION_BUTTON = 144
CONFIRMATION_GAP = 8
CONFIRMATION_BUTTON = {'large': 180, 'compact': 150}
CONFIRMATION_GAP = 12
CONFIRMATION_PADDING = 8
def confirmation_bounds(profile, x, y, *, opened=False):
scale = CONFIRMATION_SCALE[profile]
width = 2 * CONFIRMATION_PADDING + 2 * CONFIRMATION_BUTTON + CONFIRMATION_GAP if opened else 240
left = x + 172 if profile == 'large' else x + 130 - width * scale
return left, y - 12, left + width * scale, y - 12 + CONFIRMATION_HEIGHT * scale
width = 2 * CONFIRMATION_PADDING + 2 * CONFIRMATION_BUTTON[profile] + CONFIRMATION_GAP if opened else 240
left = x + 172 if profile == 'large' else x - 172 if opened else x - 100
top = y - (12 if profile == 'large' else 8)
height = CONFIRMATION_HEIGHT[profile] if opened else 152
return left, top, left + width * scale, top + height * scale
def confirmation_buttons(profile, bounds):
left, top, _, bottom = bounds
scale = CONFIRMATION_SCALE[profile]
return tuple((left + (CONFIRMATION_PADDING + x) * scale, top + CONFIRMATION_HEADER * scale,
left + (CONFIRMATION_PADDING + x + CONFIRMATION_BUTTON) * scale, bottom)
for x in (0, CONFIRMATION_BUTTON + CONFIRMATION_GAP))
left + (CONFIRMATION_PADDING + x + CONFIRMATION_BUTTON[profile]) * scale, bottom)
for x in (0, CONFIRMATION_BUTTON[profile] + CONFIRMATION_GAP))
def confirmation_footprint(profile):
@@ -51,7 +55,7 @@ def confirmation_footprint(profile):
return {'x': left, 'y': top, 'width': right - left, 'height': bottom - top}
SOURCE_TOP = math.ceil(confirmation_bounds('large', 0, 0)[3]) + GAP
SOURCE_TOP = math.ceil(confirmation_bounds('large', 0, 0, opened=True)[3]) + GAP
def card_height(mode):
@@ -30,7 +30,6 @@ from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH, render_
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory
from openpilot.starpilot.ui.rainbow_path import RainbowPath
from openpilot.starpilot.ui.road_colors import edge_gradient, lane_color, path_mode, solid_gradient
from openpilot.starpilot.ui.speed_limit_shapes import SPRITES
from openpilot.starpilot.ui.onroad_customization import ROAD_COLORS
from openpilot.system.ui.lib.shader_polygon import Gradient, draw_polygon
@@ -190,10 +189,11 @@ class _DriverMonitorArt:
self.close()
raise
def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False) -> None:
if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons):
def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False, y_offset=0) -> None:
bounds = monitor_bounds(state.customization, self.profile, state.right_hand_drive)
bounds.y += y_offset
if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons, bounds=bounds):
return
bounds = monitor_bounds(state.customization, self.profile, False)
draw_widget_frame(bounds, state.customization, self.profile, "driver_monitor")
draw_monitor_art(self.renderer, bounds, self.profile)
@@ -283,15 +283,7 @@ class _Canvas:
state.appearance.camera_view != CameraViewChoice.DRIVER and not state.reverse_driver_camera and
self.view.compact_hud._set_speed_alpha.x > 0.01 and
placement(state.customization, self.profile, "max_speed")["enabled"])
if self.viewport is None:
self.monitor.render(rect, state, top_icons=top_icons)
else:
rl.rl_push_matrix()
rl.rl_translatef(0, self.viewport[1] - 1080, 0)
try:
self.monitor.render(rect, state, top_icons=top_icons)
finally:
rl.rl_pop_matrix()
self.monitor.render(rect, state, top_icons=top_icons, y_offset=self.viewport[1] - 1080 if self.viewport else 0)
def render(self, state: OnroadState, scene: str) -> bytes:
if self.view is None or self.fonts is None or self.target is None:
@@ -349,13 +341,10 @@ class _Canvas:
crop_y = max(0, math.floor(position['y'] - widget.get('visualInsetTop', 0)))
offset_x, offset_y = crop_x - position['x'], crop_y - position['y']
width, height = math.ceil(logical_width - offset_x), math.ceil(logical_height - offset_y)
if self.profile == Profile.COMPACT and key == 'speed_limit' and state.speed_limit.driver_override_active:
sprite = 'override_circle' if state.appearance.use_vienna_sign else 'override_outline'
# The override sign is wider than the saved sign footprint.
outset = (SPRITES[sprite][2] - logical_width) / 2
crop_x = max(0, math.floor(position['x'] - outset))
if self.profile == Profile.COMPACT and key == 'speed_limit':
crop_x = max(0, math.floor(position['x'] - 2))
offset_x = crop_x - position['x']
width = min(canvas_width - crop_x, math.ceil(logical_width - offset_x + outset))
width = math.ceil(logical_width + 2 - offset_x)
if state.speed_limit.pending_speed_limit_mps is not None and (area := widget.get('confirmation')) is not None:
crop_x = math.floor(min(crop_x, position['x'] + area['x']))
crop_y = math.floor(min(crop_y, position['y'] + area['y']))
@@ -396,7 +385,9 @@ class _Canvas:
rl.image_format(image, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8)
rl.image_flip_vertical(image)
rl.image_crop(image, rl.Rectangle(crop_x, crop_y, width, height))
captures.append((key, _straight_rgba(bytes(image.data[:width * height * 4]))))
data = _straight_rgba(bytes(image.data[:width * height * 4]))
sprites[key]['visible'] = any(data[3::4])
captures.append((key, data))
finally:
rl.unload_image(image)
sheet_width = max(slot['x'] + slot['width'] for slot in sprites.values())
+12 -30
View File
@@ -10,8 +10,6 @@ from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Protocol
import hashlib
import json
from openpilot.starpilot.ui.raylib import rl
@@ -47,19 +45,6 @@ OverlayLayer = Callable[[rl.Rectangle, OnroadState], None]
PipLayer = Callable[[rl.Rectangle, OnroadState], None]
ProjectionControlLayer = Callable[[OnroadState], None]
def _load_fade(path: Path) -> rl.Texture:
entry = next(item for item in json.loads(Path(__file__).with_name('onroad-assets.json').read_text())['files']
if Path(item['file']).name == path.name)
with path.open('rb') as stream:
data = stream.read(entry['bytes'] + 1)
if len(data) != entry['bytes'] or hashlib.sha256(data).hexdigest() != entry['sha256']:
raise ValueError(f'Unreviewed onroad fade art: {path.name}')
texture = rl.load_texture(str(path))
if not texture.id:
raise RuntimeError(f'Unable to load onroad fade art: {path.name}')
return texture
def driving_mode_description(state: OnroadState) -> str:
if state.longitudinal_overridden:
return "ACC - Override"
@@ -159,12 +144,6 @@ class OnroadView:
self.compact_hud = CompactHudRenderer(fonts, asset_directory)
self.compact_sidebar = MiciSidebarWidgets(fonts)
self.personality = self.compact_sidebar.personality
self._fade_path = asset_directory / "icons_mici/onroad/onroad_fade.png"
self._fade: rl.Texture | None = None
def _prepare_compact_fade(self) -> None:
if self._fade is None:
self._fade = _load_fade(self._fade_path)
def render(self, state: OnroadState) -> None:
if state.camera_available and self.camera_layer is None:
@@ -223,6 +202,9 @@ class OnroadView:
state.appearance.camera_view == CameraViewChoice.DRIVER or state.reverse_driver_camera):
return
width, height = widget_size(state.customization, profile, MODE_WIDGET)
x, y = position["x"] + center_shift, position["y"]
if slc_action_occludes(profile, state, (x, y, x + width, y + height)):
return
size = 40 if profile == Profile.LARGE else 18
role = FontRole.SEMI_BOLD
lines = [""]
@@ -260,6 +242,10 @@ class OnroadView:
measured = self.fonts.measure(text, role, size)
x = position["x"] + center_shift + (width - measured.width) / 2
y = position["y"] + (height - measured.height) / 2
left, right = self.fonts.horizontal_ink(text, role, size)
top, bottom = self.fonts.vertical_ink(text, role, size)
if slc_action_occludes(profile, state, (x + left, y + top, x + right, y + bottom)):
return
color = rl.Color(*rgba(state.customization, "text", profile, CLOCK_WIDGET))
offset_px = 2 if profile == Profile.LARGE else 1
self.fonts.draw(text, role, size, x + offset_px, y + offset_px, rl.Color(0, 0, 0, min(color.a, 170)))
@@ -269,6 +255,11 @@ class OnroadView:
if (not placement(state.customization, self.fonts.profile, 'weather')['enabled'] or
state.alert.size != AlertSize.NONE or state.appearance.camera_view == CameraViewChoice.DRIVER or state.reverse_driver_camera):
return
position = placement(state.customization, self.fonts.profile, 'weather')
width, height = widget_size(state.customization, self.fonts.profile, 'weather')
x, y = position['x'] + center_shift, position['y']
if slc_action_occludes(self.fonts.profile, state, (x, y, x + width, y + height)):
return
from openpilot.starpilot.ui.weather import WeatherClient
from openpilot.starpilot.ui.onroad_weather import render_weather
if not hasattr(self, '_weather_client'):
@@ -370,7 +361,6 @@ class OnroadView:
self.background_layer(content, state)
ordered = bool(state.customization.get("widgetOrder", {}).get("large"))
if ordered:
rl.draw_rectangle_gradient_v(30, 30, int(content.width), 300, rl.Color(0, 0, 0, 114), rl.BLANK)
self._ordered_widgets(content, state)
if self.extra_overlays is not None:
self.extra_overlays(content, state)
@@ -381,7 +371,6 @@ class OnroadView:
map_layer = getattr(self, "map_layer", None)
if map_layer is not None and state.alert.size != AlertSize.FULL:
map_layer(content, state)
rl.draw_rectangle_gradient_v(30, 30, int(content.width), 300, rl.Color(0, 0, 0, 114), rl.BLANK)
if state.alert.size != AlertSize.FULL:
if placement(state.customization, "large", "cruise_limits")["enabled"]:
self.unified_speed.render(content, state)
@@ -486,8 +475,6 @@ class OnroadView:
self.background_layer(frame, state)
ordered = bool(state.customization.get("widgetOrder", {}).get("compact"))
if ordered:
self._prepare_compact_fade()
rl.draw_texture_ex(self._fade, rl.Vector2(0, 0), 0, 1, rl.WHITE)
clip.end_scissor_mode()
clip.begin_scissor_mode(0, 0, 536, 240)
self._ordered_widgets(frame, state, driver_camera=driver_camera, stock_layer=stock_layer if use_stock else None)
@@ -500,8 +487,6 @@ class OnroadView:
if pip_layer is not None and state.camera_available and not driver_camera and \
state.appearance.camera_view != CameraViewChoice.NONE:
pip_layer(frame, state)
self._prepare_compact_fade()
rl.draw_texture_ex(self._fade, rl.Vector2(0, 0), 0, 1, rl.WHITE)
if state.alert.size == AlertSize.NONE and not driver_camera:
self.compact_hud.render(state)
if placement(state.customization, "compact", "torque_bar")["enabled"]:
@@ -578,6 +563,3 @@ class OnroadView:
self.steering_wheel.close()
else:
self.compact_hud.close()
if self._fade is not None and rl.is_window_ready():
rl.unload_texture(self._fade)
self._fade = None
@@ -18,7 +18,7 @@ from openpilot.starpilot.ui.onroad_conditional import reason as conditional_reas
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame
from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget, draw_caption, draw_ink, set_speed_status, speed_status
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
from openpilot.starpilot.ui.moon import draw_moon
from openpilot.starpilot.ui.onroad_personality import PersonalityWidget
@@ -135,27 +135,28 @@ class CompactHudRenderer:
self.render_steering_wheel(state)
def render_max_speed(self, state: OnroadState) -> None:
slc_target = resolve_unified_speed(state).slc_target_text
set_speed_alpha = self._set_speed_opacity(state, (state.observed_ns or time.monotonic_ns()), slc_active=slc_target is not None)
cruise = "–" if state.cruise_kph is None else str(round(state.cruise_kph if state.metric else state.cruise_kph * 0.621371))
shown = resolve_unified_speed(state)
status = set_speed_status(state, Profile.COMPACT, shown)
persistent = shown.slc_target_text is not None or shown.override_text is not None or speed_status(state, Profile.COMPACT, shown) is not None
set_speed_alpha = self._set_speed_opacity(state, (state.observed_ns or time.monotonic_ns()), slc_active=persistent)
cruise = shown.max_text
mx, my = widget_offset(state.customization, "compact", "max_speed")
center = widget_size(state.customization, 'compact', 'max_speed')[0] / 2 + mx
if set_speed_alpha > 0.01 and placement(state.customization, "compact", "max_speed")["enabled"]:
shadow = rl.Color(0, 0, 0, int(255 / 2 * set_speed_alpha))
try:
rl.draw_circle_gradient(rl.Vector2(81 + mx, 81 + my), 81, shadow, rl.BLANK)
except (RuntimeError, TypeError):
rl.draw_circle_gradient(int(81 + mx), int(81 + my), 81, shadow, rl.BLANK)
red, green, blue, alpha = rgba(state.customization, "text", "compact", "max_speed")
color = rl.Color(red, green, blue, int(alpha * 0.9 * set_speed_alpha))
speed_size = 112
while speed_size > 72 and self.fonts.measure(cruise, FontRole.SET_SPEED, speed_size).width > 128:
speed_size -= 1
self.fonts.draw(cruise, FontRole.SET_SPEED, speed_size, 17 + mx, -4 + my, color)
self.fonts.draw("SET" if slc_target is not None else "MAX", FontRole.SEMI_BOLD, 36, 25 + mx, 109 + my, color)
if slc_target is not None:
label = f'SLC {slc_target}'
top, _ = self.fonts.vertical_ink(label, FontRole.SEMI_BOLD, 22)
self.fonts.draw(label, FontRole.SEMI_BOLD, 22, 25 + mx, 145 + my - top, color)
color = rl.Color(red, green, blue, int(alpha * set_speed_alpha))
draw_caption(self.fonts, 'SET', center, 20 + my, color, unit=shown.unit, size=24, centered=True)
size = 96
left, right = self.fonts.horizontal_ink(cruise, FontRole.SET_SPEED, size)
size *= min(1, 128 / max(1, right - left))
left, right = self.fonts.horizontal_ink(cruise, FontRole.SET_SPEED, size)
draw_ink(self.fonts, cruise, FontRole.SET_SPEED, size, center - (left + right) / 2, 46 + my, color)
if status and placement(state.customization, 'compact', 'max_speed')['enabled']:
color = rl.Color(241, 246, 243, 255)
left, right = self.fonts.horizontal_ink(status, FontRole.SEMI_BOLD, 24)
size = 24 * min(1, 128 / max(1, right - left))
left, right = self.fonts.horizontal_ink(status, FontRole.SEMI_BOLD, size)
draw_ink(self.fonts, status, FontRole.SEMI_BOLD, size, center - (left + right) / 2, 128 + my, color)
def render_steering_wheel(self, state: OnroadState) -> None:
color = self._feedback.update(state.wheel_acceleration, state.appearance.wheel_pedal_feedback, state.observed_ns, state.drive_frame)
+25 -14
View File
@@ -9,7 +9,7 @@ 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 confirmation_bounds, confirmation_footprint
from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, confirmation_bounds, confirmation_footprint
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
from openpilot.starpilot.ui.onroad_favorites_geometry import shelf_metadata
from openpilot.starpilot.ui.large_speed_geometry import (WIDTH, CARD_HEIGHT, ACTION_WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y,
@@ -104,7 +104,7 @@ PROFILES: dict[str, LayoutProfile] = {
"reservedZones": [],
"widgets": {
"max_speed": _widget("Set speed", "max_speed", 146, 162, 0, 0),
"speed_limit": _widget("Speed limit", "speed_limit", 118, 132, 330, 20),
"speed_limit": _widget("Speed limit", "speed_limit", *LIMIT_RECTS['compact'][2:], *LIMIT_RECTS['compact'][:2]),
"speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", 184, 120, 146, 44),
"steering_wheel": _widget("Steering wheel", "steering_wheel", 50, 50, 21, 176),
"driver_monitor": _widget("Driver monitoring", "driver_monitor", 60, 60, 16, 10)}}}
@@ -247,9 +247,9 @@ _torque['confirmationObstacle'] = {
# These editor sprites use the runtime components and their canonical assets/fonts.
RUNTIME_PREVIEW_WIDGETS = {
"large": (WEATHER_WIDGET, "current_speed", "cruise_limits", CEM_GAUGE_WIDGET, "steering_wheel", "following_distance",
"driver_monitor", "speed_limit_actions"),
"driver_monitor", "speed_limit_actions", MODE_WIDGET, CLOCK_WIDGET),
"compact": (WEATHER_WIDGET, "max_speed", "speed_limit", "steering_wheel", "conditional_mode", "following_distance",
"model_confidence", "driver_monitor", "speed_limit_actions"),
"model_confidence", "driver_monitor", "speed_limit_actions", MODE_WIDGET, CLOCK_WIDGET),
}
_FRAME_COLORS = {"cardFill": "#00000000", "cardBorder": "#00000000"}
@@ -342,7 +342,7 @@ def customization_metadata():
def default_document():
return {"version": 12, "clock24Hour": False, "reduceMotion": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
return {"version": 13, "clock24Hour": False, "reduceMotion": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES},
"widgetColors": {profile: {} for profile in PROFILES}, "layouts": {
profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})}
for key, widget in data["widgets"].items()}
@@ -450,14 +450,15 @@ def migrate_confirmation_layout(layout, profile, widgets, bounds, *, extra_heigh
owner = 'cruise_limits' if profile == 'large' else 'speed_limit'
bounds = widgets[owner].get('bounds', bounds)
area, at = confirmation_footprint(profile), layout[owner]
if not at['enabled'] or not layout['speed_limit_actions']['enabled']:
return
confirming = at['enabled'] and layout['speed_limit_actions']['enabled']
actions = layout['speed_limit_actions']
attached = profile == 'large' and actions['x'] == at['x'] + ACTION_X - 88 and actions['y'] == at['y'] + ACTION_Y - 75
for axis, extent in (('x', 'width'), ('y', 'height')):
low = max(bounds[axis], bounds[axis] - area[axis])
high = min(bounds[axis] + bounds[extent] - widgets[owner][extent],
bounds[axis] + bounds[extent] - area[axis] - area[extent])
low = bounds[axis]
high = bounds[axis] + bounds[extent] - widgets[owner][extent]
if confirming:
low = max(low, bounds[axis] - area[axis])
high = min(high, bounds[axis] + bounds[extent] - area[axis] - area[extent])
if axis == 'y':
high = min(high, bounds['y'] + bounds['height'] - widgets[owner]['height'] - extra_height)
at[axis] = max(math.ceil(low), min(at[axis], math.floor(high)))
@@ -472,7 +473,7 @@ def migrate_confirmation_layout(layout, profile, widgets, bounds, *, extra_heigh
def validate_document(value):
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 13):
if type(value) is not dict or type(value.get("version")) is not int or value["version"] not in range(1, 14):
raise ValueError("Invalid customization version")
fields = {"version", "palette", "layouts"} | ({"widgetColors"} if value["version"] >= 2 else set())
if value["version"] >= 3:
@@ -661,6 +662,11 @@ def validate_document(value):
dimensions = COMPACT_SPEED_LAYOUT_V8.get(key)
if dimensions is None and value['version'] < 10 and profile == 'large' and key == 'cruise_limits':
dimensions = (WIDTH, 132)
if dimensions is None and value['version'] < 13:
if profile == 'large' and key == 'cruise_limits':
dimensions = (WIDTH, 168)
elif profile == 'compact' and key == 'speed_limit':
dimensions = (118, 132)
if dimensions is None and value['version'] < 11 and key == 'speed_limit_actions':
default = widget['default']
current_default = (placement['x'], placement['y']) == (default['x'], default['y'])
@@ -728,11 +734,16 @@ def validate_document(value):
extra_height=sources_extent(result) if profile == 'large' else 0)
return validate_document(result)
for profile, data in PROFILES.items():
if value['version'] < 12:
if value['version'] < 13:
if profile == 'compact':
sign = result['layouts'][profile]['speed_limit']
if (sign['x'], sign['y']) == (330, 20):
sign.update(x=LIMIT_RECTS['compact'][0], y=LIMIT_RECTS['compact'][1])
if profile == 'large' and result.get('speedSources') is True:
card, actions = result['layouts'][profile]['cruise_limits'], result['layouts'][profile]['speed_limit_actions']
old_extra = SOURCE_ROW_HEIGHT + (GAP if result.get('speedSourcesCard') is True else 0)
if actions['x'] == card['x'] + ACTION_X - 88 and actions['y'] == card['y'] + ACTION_Y - 75 + old_extra:
old_gap = 284 if value['version'] == 12 else ACTION_Y - 75 + SOURCE_ROW_HEIGHT
old_gap += GAP if result.get('speedSourcesCard') is True else 0
if actions['x'] == card['x'] + ACTION_X - 88 and actions['y'] == card['y'] + old_gap:
actions['y'] = card['y'] + ACTION_Y - 75
migrate_confirmation_layout(result['layouts'][profile], profile, data['widgets'], data['bounds'],
extra_height=sources_extent(result) if profile == 'large' else 0)
+10 -5
View File
@@ -48,10 +48,14 @@ def draw_monitor_art(renderer, bounds, profile):
rl.rl_pop_matrix()
def monitor_visible(profile, state, *, fresh, onroad, top_icons):
def monitor_visible(profile, state, *, fresh, onroad, top_icons, bounds=None):
from openpilot.starpilot.ui.onroad_state import slc_action_occludes
monitor = bounds if bounds is not None else monitor_bounds(state.customization, profile, state.right_hand_drive)
if slc_action_occludes(profile, state, (monitor.x, monitor.y, monitor.x + monitor.width, monitor.y + monitor.height)):
return False
occluded = False
if profile == Profile.COMPACT and top_icons:
monitor = monitor_bounds(state.customization, profile, False)
speed = placement(state.customization, profile, 'max_speed')
widget = PROFILES['compact']['widgets']['max_speed']
occluded = rl.check_collision_recs(monitor, rl.Rectangle(speed['x'], speed['y'], widget['width'], widget['height']))
@@ -86,12 +90,13 @@ class DriverMonitorLayer:
self.renderer.set_rect(rl.Rectangle(0, 0, 128, 128))
self.renderer.load_icons()
def render(self, state, *, monitor, driver, fresh, onroad, top_icons=False):
def render(self, state, *, monitor, driver, fresh, onroad, top_icons=False, y_offset=0):
ready = fresh and valid_observation(monitor, driver)
visible = monitor_visible(self.profile, state, fresh=ready, onroad=onroad, top_icons=top_icons)
self.renderer.set_should_draw(visible)
is_rhd = monitor.isRHD if ready else getattr(self.renderer, '_is_rhd', False)
bounds = monitor_bounds(state.customization, self.profile, is_rhd)
bounds.y += y_offset
visible = monitor_visible(self.profile, state, fresh=ready, onroad=onroad, top_icons=top_icons, bounds=bounds)
self.renderer.set_should_draw(visible)
self.renderer.set_position(bounds.x, bounds.y)
if ready:
self.renderer._update_state()
+34 -29
View File
@@ -11,12 +11,12 @@ import json
from openpilot.starpilot.ui.raylib import rl
from openpilot.starpilot.ui.onroad_customization import offset, rgba, widget_size
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds, slc_action_occludes
from openpilot.starpilot.ui.onroad_widget_style import draw_control_card
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
from openpilot.starpilot.ui.wheel_feedback import WheelFeedbackFade
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
from openpilot.starpilot.ui.speed_limit_widget import MUTED, SpeedLimitWidget, draw_caption, draw_ink, draw_line, draw_text
from openpilot.starpilot.ui.speed_limit_widget import MUTED, SpeedLimitWidget, draw_caption, draw_ink, draw_line, draw_text, set_speed_status
ENGAGED = rl.Color(128, 216, 166, 255)
@@ -26,7 +26,7 @@ DISENGAGED = rl.Color(145, 155, 149, 255)
def _center(fonts: BitmapFonts, text: str, role: FontRole, size: int, rect: rl.Rectangle, y: float,
color: rl.Color = rl.WHITE) -> None:
measured = fonts.measure(text, role, size)
fonts.draw(text, role, size, rect.x + (rect.width - measured.width) / 2, y, color)
draw_text(fonts, text, role, size, rect.x + (rect.width - measured.width) / 2, y, color)
class UnifiedSpeedWidget:
@@ -80,33 +80,29 @@ class UnifiedSpeedWidget:
text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits'))
if shown.mode != 'limit_only':
confirming = manual_slc_confirmation(Profile.LARGE, state)
center = rect.x + 120
x = rect.x + PADDING if confirming else center
draw_caption(self.fonts, 'SET', x, rect.y + 12, unit=shown.unit, centered=not confirming)
if shown.max_text == '–':
center = rect.x + 120
draw_caption(self.fonts, 'SET', center, rect.y + 14, unit=shown.unit, centered=True)
draw_line(Profile.LARGE, rl.Vector2(center - 28, rect.y + 101), rl.Vector2(center + 28, rect.y + 101), 8, MUTED)
elif confirming:
x = rect.x + PADDING
draw_caption(self.fonts, 'SET', x, rect.y + 19, unit=shown.unit, size=32)
size = 120
while size > 72 and self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width > 148:
size -= 1
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, x, rect.y + 51, text)
draw_line(Profile.LARGE, rl.Vector2(center - 28, rect.y + 90), rl.Vector2(center + 28, rect.y + 90), 8, MUTED)
else:
center = rect.x + 120
draw_caption(self.fonts, 'SET', center, rect.y + 14, unit=shown.unit, centered=True)
size = 108
size *= min(1, 200 / max(1, self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width))
size = 100
size *= min(1, (148 if confirming else 200) / max(1, self.fonts.measure(shown.max_text, FontRole.SET_SPEED, size).width))
left, right = self.fonts.horizontal_ink(shown.max_text, FontRole.SET_SPEED, size)
top, bottom = self.fonts.vertical_ink(shown.max_text, FontRole.SET_SPEED, size)
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, center - (left + right) / 2,
rect.y + 101 - (bottom - top) / 2, text)
if shown.slc_target_text is not None:
label = f'SLC {shown.slc_target_text}'
width = self.fonts.measure(label, FontRole.SEMI_BOLD, 22).width
x = rect.x + (PADDING if confirming else 120 - width / 2)
draw_ink(self.fonts, label, FontRole.SEMI_BOLD, 22, x, rect.y + 148, text)
number_x = x + self.fonts.horizontal_ink('SET', FontRole.MEDIUM, 34)[0] - left if confirming else center - (left + right) / 2
draw_ink(self.fonts, shown.max_text, FontRole.SET_SPEED, size, number_x, rect.y + 90 - (bottom - top) / 2, text)
if shown.mode != 'limit_only' and (label := set_speed_status(state, Profile.LARGE, shown)):
confirming = manual_slc_confirmation(Profile.LARGE, state)
size = 24
left, right = self.fonts.horizontal_ink(label, FontRole.SEMI_BOLD, size)
size *= min(1, (148 if confirming else 200) / max(1, right - left))
left, right = self.fonts.horizontal_ink(label, FontRole.SEMI_BOLD, size)
x = rect.x + (PADDING + self.fonts.horizontal_ink('SET', FontRole.MEDIUM, 34)[0] - left if confirming else 120 - (left + right) / 2)
draw_ink(self.fonts, label, FontRole.SEMI_BOLD, size, x, rect.y + 136, text)
self.sign.render(state, defer_confirmation=True)
if shown.mode == 'limit_only' and (bounds := speed_limit_bounds(Profile.LARGE, state)) is not None:
if (shown.mode == 'limit_only' and not manual_slc_confirmation(Profile.LARGE, state) and
(bounds := speed_limit_bounds(Profile.LARGE, state)) is not None):
draw_ink(self.fonts, shown.unit, FontRole.NORMAL, 24, bounds[2], bounds[3] - 32)
if sources:
row = rl.Rectangle(rect.x, rect.y + max(height, SOURCE_TOP) + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT)
@@ -129,13 +125,22 @@ class CurrentSpeedHud:
rect = rl.Rectangle(content.x + 610 + dx, content.y + dy, 580, 300)
number_top, _ = self.fonts.vertical_ink(speed, FontRole.SPEED, 176)
old_top, old_bottom = self.fonts.vertical_ink(speed, FontRole.BOLD, 176)
_center(self.fonts, speed, FontRole.SPEED, 176, rect, content.y + 42 + dy + old_top - number_top,
rl.Color(*rgba(state.customization, 'text', 'large', 'current_speed')))
unit = 'km/h' if state.metric else 'mph'
unit_top, _ = self.fonts.vertical_ink(unit, FontRole.NORMAL, 28)
red, green, blue, alpha = rgba(state.customization, 'text', 'large', 'current_speed')
_center(self.fonts, unit, FontRole.NORMAL, 28, rect, content.y + 42 + dy + old_bottom + 20 - unit_top,
rl.Color(red, green, blue, int(alpha * 200 / 255)))
rows = ((speed, FontRole.SPEED, 176, content.y + 42 + dy + old_top - number_top, rl.Color(red, green, blue, alpha)),
(unit, FontRole.NORMAL, 28, content.y + 42 + dy + old_bottom + 20 - unit_top,
rl.Color(red, green, blue, int(alpha * 200 / 255))))
if manual_slc_confirmation(Profile.LARGE, state):
for text, role, size, y, _ in rows:
x = rect.x + (rect.width - self.fonts.measure(text, role, size).width) / 2
left, right = self.fonts.horizontal_ink(text, role, size)
top, bottom = self.fonts.vertical_ink(text, role, size)
if slc_action_occludes(Profile.LARGE, state, (x + left, y + top, x + right, y + bottom)):
return
for text, role, size, y, color in rows:
_center(self.fonts, text, role, size, rect, y, color)
+19 -59
View File
@@ -1,4 +1,4 @@
"""One cached atlas for speed-limit signs and confirmation surfaces."""
"""One cached atlas for transparent speed signs, buttons, arrows and wheel cues."""
from pathlib import Path
@@ -6,21 +6,16 @@ from openpilot.starpilot.ui.raylib import rl
ATLAS_PATH = Path(__file__).with_name('assets') / 'speed_limit_shapes.png'
ATLAS_SIZE = (384, 612)
ATLAS_SIZE = (736, 688)
SPRITES = {
'disc': (0, 0, 128, 128), 'ring': (128, 0, 128, 128),
'ring': (128, 0, 128, 128),
'arrow_large': (0, 128, 32, 40), 'arrow_compact': (32, 128, 32, 48),
'outline': (256, 0, 128, 144),
'confirmation': (0, 176, 104, 308), 'confirmation_open': (104, 176, 72, 308),
'confirmation_pulse': (176, 176, 104, 136),
'outline': (256, 0, 136, 152),
'wheel_plus': (280, 176, 48, 48), 'wheel_minus': (328, 176, 48, 48),
'arrow_confirmation': (280, 224, 32, 48),
'override_outline': (0, 484, 136, 98),
'override_circle': (136, 484, 124, 124),
'override_slash': (260, 484, 48, 8),
'button_large': (0, 224, 368, 248), 'button_large_pressed': (368, 224, 368, 248),
'button_compact': (0, 472, 308, 216), 'button_compact_pressed': (308, 472, 308, 216),
}
SPRITE_DENSITIES = {'arrow_compact': 2, 'confirmation': 2, 'confirmation_open': 2, 'confirmation_pulse': 2,
'wheel_plus': 2, 'wheel_minus': 2, 'arrow_confirmation': 2}
SPRITE_DENSITIES = {name: 2 for name in SPRITES if name.startswith('button') or name in ('arrow_compact', 'wheel_plus', 'wheel_minus')}
class SpeedLimitShapes:
@@ -28,7 +23,6 @@ class SpeedLimitShapes:
self.scale = scale
self._headless_context = headless_context
self.texture = None
self.short_outline = None
self.sources = {name: rl.Rectangle(*rect) for name, rect in SPRITES.items()}
self.origins = {name: rl.Vector2(rect[2] * scale / SPRITE_DENSITIES.get(name, 1) / 2,
rect[3] * scale / SPRITE_DENSITIES.get(name, 1) / 2) for name, rect in SPRITES.items()}
@@ -65,60 +59,26 @@ class SpeedLimitShapes:
origin, rotation, color)
def circle(self, x, y, color):
self._draw('disc', x, y, rl.WHITE)
self._draw('ring', x, y, color)
self._outlined('ring', x, y, color)
def override_sign(self, x, y, *, vienna):
self._draw('override_circle' if vienna else 'override_outline', x, y, rl.WHITE)
def _outlined(self, name, x, y, color, *, scale=None):
radius = self.scale if scale is None else scale
for dx, dy in ((-radius, 0), (radius, 0), (0, -radius), (0, radius)):
self._draw(name, x + dx, y + dy, rl.Color(6, 14, 10, 230), scale=scale)
self._draw(name, x, y, color, scale=scale)
def override_slash(self, x, y, width, color):
self._load()
source = self.sources['override_slash']
height = source.height * self.scale
rl.draw_texture_pro(self.texture, source, rl.Rectangle(x, y, width, height),
rl.Vector2(width / 2, height / 2), -11, color)
def outline(self, x, y, color):
self._outlined('outline', x, y, color)
def outline(self, x, y, color, *, short=False):
if not short:
self._draw('outline', x, y, color)
return
self._load()
if self.short_outline is None:
source = self.sources['outline']
# Remove only the straight middle: corners and border thickness stay intact.
self.short_outline = ((rl.Rectangle(source.x, source.y, source.width, 64), -64),
(rl.Rectangle(source.x, source.y + 80, source.width, 64), 0))
origin = rl.Vector2(64 * self.scale, 0)
for source, dy in self.short_outline:
rl.draw_texture_pro(self.texture, source,
rl.Rectangle(x, y + dy * self.scale, source.width * self.scale, source.height * self.scale),
origin, 0, color)
def button(self, x, y, color, *, profile, scale, pressed=False):
self._draw(f'button_{profile}' + ('_pressed' if pressed else ''), x, y, color, scale=scale)
def arrow(self, size, x, y, direction, color, *, scale=None):
name = 'arrow_large' if size == 'large' else 'arrow_confirmation' if size == 'confirmation' else 'arrow_compact'
name = 'arrow_large' if size == 'large' else 'arrow_compact'
self._draw(name, x, y, color, 180 if direction < 0 else 0, scale=scale)
def wheel_key(self, plus, x, y, *, scale):
self._draw('wheel_plus' if plus else 'wheel_minus', x, y, rl.WHITE, scale=scale)
def confirmation(self, bounds, *, opened=False, strength=0):
self._load()
x, y, right, bottom = bounds
scale = (bottom - y) / 152
layers = [('confirmation_open' if opened else 'confirmation', y, 255)]
if not opened and strength > 0:
layers.append(('confirmation_pulse', y + 86 * scale, round(255 * strength)))
for name, top, alpha in layers:
source = self.sources[name]
texel_scale = scale / SPRITE_DENSITIES[name]
cap = (source.width - 8) / 2
# Stretch only the flat middle; keep the two-pixel atlas padding out of the quad.
for sx, sw, dx, dw in ((source.x + 2, cap, x, cap * texel_scale),
(source.x + 2 + cap, 4, x + cap * texel_scale, right - x - 2 * cap * texel_scale),
(source.x + source.width - 2 - cap, cap, right - cap * texel_scale, cap * texel_scale)):
rl.draw_texture_pro(self.texture, rl.Rectangle(sx, source.y + 2, sw, source.height - 4),
rl.Rectangle(dx, top, dw, (source.height - 4) * texel_scale),
rl.Vector2(0, 0), 0, rl.Color(255, 255, 255, alpha))
self._outlined('wheel_plus' if plus else 'wheel_minus', x, y, rl.WHITE, scale=scale)
def close(self):
if self.texture is not None and (rl.is_window_ready() or self._headless_context):
+128 -112
View File
@@ -6,18 +6,20 @@ from typing import cast
from openpilot.starpilot.ui.raylib import rl
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, CONFIRMATION_SCALE, CONFIRMATION_PADDING
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_WIDTH, SIGN_HEIGHT, CONFIRMATION_SCALE
from openpilot.starpilot.ui.onroad_customization import placement
from openpilot.starpilot.ui.onroad_state import OnroadState, manual_slc_confirmation, speed_limit_bounds, slc_controls, slc_accept_available
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile
from openpilot.starpilot.ui.speed_limit_pulse import SpeedLimitPulse
from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes
from openpilot.starpilot.ui.stop_sign_widget import render_stop_sign
from openpilot.starpilot.ui.unified_speed_presentation import displayed_adjusted_limit_mps, positive, resolve_unified_speed
INK = rl.Color(241, 246, 243, 255)
MUTED = rl.Color(190, 208, 202, 255)
IN_USE = rl.Color(128, 216, 166, 255)
OVERRIDE = rl.Color(214, 188, 240, 255)
REJECT = rl.Color(242, 139, 139, 255)
SOURCE_LABELS = {'dashboard': 'DASH', 'map': 'OSM', 'vision': 'VISION', 'mapbox': 'NAV'}
@@ -25,11 +27,9 @@ def draw_text(fonts, text, role, size, x, y, color=INK):
if isinstance(color, (tuple, list)):
color = rl.Color(*color)
radius = 1.15 if fonts.profile == Profile.LARGE else 1.
contour = rl.Color(6, 14, 10, round(color.a * .90))
contour = rl.Color(6, 14, 10, round(color.a * .95))
for dx, dy in ((-radius, 0), (radius, 0), (0, -radius), (0, radius)):
fonts.draw(text, role, size, x + dx, y + dy, contour)
fonts.draw(text, role, size, x, y + (1.5 if fonts.profile == Profile.LARGE else 1.2),
rl.Color(7, 16, 12, round(color.a * .32)))
fonts.draw(text, role, size, x, y, color)
@@ -65,6 +65,34 @@ def draw_caption(fonts, label, x, top, color=MUTED, *, unit=None, size=None, cen
return width, height
def speed_status(state, profile, shown):
"""The current controller state, independent of where its label is drawn."""
obs = state.speed_limit
if obs.action_enabled and (state.longitudinal_overridden or
state.longitudinal_active and obs.driver_override_active and obs.override_basis == 'pedal'):
return 'PEDAL'
if shown.pending or speed_limit_bounds(profile, state) is None:
if shown.override_text is not None:
return f'OVERRIDE {shown.override_text}'
if shown.slc_target_text is not None:
return f'LIMIT {shown.slc_target_text}'
return None
def set_speed_status(state, profile, shown):
status = speed_status(state, profile, shown)
if speed_limit_bounds(profile, state) is not None:
confirming = manual_slc_confirmation(profile, state)
if confirming and state.slc_prompt_open or not confirming and status == 'PEDAL':
return None
return status
def set_speed_visible(state, profile):
return (state.customization.get('showMaxSpeed', True) if profile == Profile.LARGE else
placement(state.customization, profile, 'max_speed')['enabled'])
class SpeedLimitWidget:
def __init__(self, fonts: BitmapFonts, profile: Profile):
self.fonts = fonts
@@ -78,6 +106,8 @@ class SpeedLimitWidget:
self.shapes.close()
def render(self, state: OnroadState, *, defer_confirmation=False) -> None:
from openpilot.starpilot.ui.stop_sign_widget import render_stop_sign
render_stop_sign(self.fonts, state)
bounds = speed_limit_bounds(self.profile, state)
now = state.observed_ns / 1e9 if state.observed_ns else time.monotonic()
@@ -101,6 +131,8 @@ class SpeedLimitWidget:
width = (right - x) / scale
shown = resolve_unified_speed(state, confirmation=True)
source = SOURCE_LABELS.get(shown.source, 'SPEED')
current_status = (speed_status(state, self.profile, resolve_unified_speed(state))
if state.slc_prompt_open or not set_speed_visible(state, self.profile) else None)
white = rl.Color(242, 246, 244, 255)
secondary = rl.Color(209, 222, 218, 255)
@@ -111,57 +143,66 @@ class SpeedLimitWidget:
left, right = self.fonts.horizontal_ink(text, role, text_size(size))
return (right - left) / scale
def ink_height(label, size, role=FontRole.MEDIUM):
top, bottom = self.fonts.vertical_ink(label, role, text_size(size))
return (bottom - top) / scale
def text(label, size, cx, cy, color=white, role=FontRole.MEDIUM):
size = text_size(size)
left, right = self.fonts.horizontal_ink(label, role, size)
top, bottom = self.fonts.vertical_ink(label, role, size)
self.fonts.draw(label, role, size, x + cx * scale - (left + right) / 2,
y + cy * scale - (top + bottom) / 2, color)
def rounded(left, top, w, h, color, radius=8):
rect = rl.Rectangle(x + left * scale, y + top * scale, w * scale, h * scale)
rl.draw_rectangle_rounded(rect, 2 * radius / min(w, h), 12, color)
draw_text(self.fonts, label, role, size, x + cx * scale - (left + right) / 2,
y + cy * scale - (top + bottom) / 2, color)
if state.slc_prompt_open:
self.shapes.confirmation(bounds, opened=True)
target_size = 40
nw, uw = ink_width(shown.proposal_text, target_size, FontRole.BOLD), ink_width(shown.unit, 20)
context = source + (f' {shown.posted_text} {shown.offset_text}' if shown.offset_text else '')
context_size = 20 * min(1, (width - 24 - nw - 6 - uw - 16) / max(1, ink_width(context, 20)))
context_width = ink_width(context, context_size)
start = (width - nw - 6 - uw - 16 - context_width) / 2
text(shown.proposal_text, target_size, start + nw / 2, 24, white, FontRole.BOLD)
nt, nb = self.fonts.vertical_ink(shown.proposal_text, FontRole.BOLD, text_size(target_size))
ut, ub = self.fonts.vertical_ink(shown.unit, FontRole.MEDIUM, text_size(20))
text(shown.unit, 20, start + nw + 6 + uw / 2, 24 + (nb - nt - ub + ut) / scale / 2, secondary)
text(context, context_size, start + nw + 6 + uw + 16 + context_width / 2, 24, rl.Color(203, 197, 215, 255))
for control, (fill, border, ink) in zip(slc_controls(self.profile, state), (
((35, 74, 57), (106, 153, 127), (224, 243, 232)),
((77, 46, 57), (173, 122, 134), (248, 229, 234))), strict=True):
if not control.enabled:
fill, border, ink = (43, 49, 48), (94, 105, 101), (161, 174, 168)
context = (f'Now: {current_status}' + (f' {shown.unit}' if current_status != 'PEDAL' else '')
if current_status else 'New limit')
context_size = 26 if self.profile == Profile.LARGE else 24
context_size *= min(1, (width - 24) / max(1, ink_width(context, context_size)))
text(context, context_size, width / 2, 24, secondary)
for control in slc_controls(self.profile, state):
accent = (IN_USE if control.label == 'Accept' else REJECT) if control.enabled else MUTED
selected = control.enabled and pressed == control.request
ink = accent if selected or not control.enabled else white
left = (control.bounds[0] - x) / scale
top = (control.bounds[1] - y) / scale
w, h = (control.bounds[2] - control.bounds[0]) / scale, (control.bounds[3] - control.bounds[1]) / scale
# Keep the full touch area around the shorter, visually balanced face.
face_top, face_height = top + CONFIRMATION_PADDING, h - 2 * CONFIRMATION_PADDING
rounded(left, face_top, w, face_height, rl.Color(*border, 255), 16)
color = tuple(min(255, channel + 14) for channel in fill) if pressed == control.request else fill
rounded(left + 1.5, face_top + 1.5, w - 3, face_height - 3, rl.Color(*color, 255), 14.5)
text(control.label, 37, left + w / 2, top + h / 2, rl.Color(*ink, 255))
self.shapes.button(x + (left + w / 2) * scale, y + (top + h / 2) * scale, accent,
profile=self.profile, scale=scale, pressed=selected)
uw = ink_width(shown.unit, 20)
size = 60 if self.profile == Profile.LARGE else 52
padding, gap = (28, 6) if self.profile == Profile.LARGE else (24, 4)
size *= min(1, (w - padding - gap - uw) / max(1, ink_width(shown.proposal_text, size, FontRole.BOLD)))
nh, uh = ink_height(shown.proposal_text, size, FontRole.BOLD), ink_height(shown.unit, 20)
caption_height = ink_height('new limit', 22)
row_height = max(nh, uh, caption_height)
# Center each visible text group while keeping both action headings on one row.
label_y = top + (h - 8 - row_height) / 2
detail_bottom = label_y + ink_height(control.label, 37) / 2 + 8 + row_height
text(control.label, 37, left + w / 2, label_y, ink)
if control.label == 'Accept':
# Bind the adjusted proposal to its action, not to the current override or source reading.
nw = ink_width(shown.proposal_text, size, FontRole.BOLD)
start = left + (w - nw - gap - uw) / 2
text(shown.proposal_text, size, start + nw / 2, detail_bottom - nh / 2, ink, FontRole.BOLD)
text(shown.unit, 20, start + nw + gap + uw / 2, detail_bottom - uh / 2, ink)
else:
text('new limit', 22, left + w / 2, detail_bottom - caption_height / 2, ink)
return
self.shapes.confirmation(bounds, strength=self.pulse.confirmation_strength(now))
source_color = rl.Color(197, 212, 208, 255)
label_width = ink_width(source, 22, FontRole.SEMI_BOLD)
if current_status:
source += f' / {current_status}'
label_size = 22 * min(1, (width - 40) / max(1, ink_width(source, 22, FontRole.SEMI_BOLD)))
label_width = ink_width(source, label_size, FontRole.SEMI_BOLD)
target = state.speed_limit.pending_adjusted_limit_mps
if not positive(target):
target = displayed_adjusted_limit_mps(state.speed_limit)
direction = (-1 if target > state.speed_mps else 1) if target is not None and state.speed_mps is not None and target != state.speed_mps else 0
start = (width - label_width - (19.2 if direction else 0)) / 2
text(source, 22, start + label_width / 2, 22, source_color, FontRole.SEMI_BOLD)
text(source, label_size, start + label_width / 2, 22, source_color, FontRole.SEMI_BOLD)
if direction:
self.shapes.arrow('confirmation', x + (start + label_width + 12.6) * scale, y + 22.5 * scale,
self.shapes.arrow('compact', x + (start + label_width + 12.6) * scale, y + 22.5 * scale,
direction, source_color, scale=1.1 * scale)
nw = ink_width(shown.posted_text, 64, FontRole.BOLD)
ow = ink_width(shown.offset_text, 32) if shown.offset_text else 0
@@ -176,11 +217,12 @@ class SpeedLimitWidget:
label = 'New Limit' if slc_accept_available(state) else 'Unavailable'
caption = ink_width(label, 30, FontRole.SEMI_BOLD)
cx = (width - caption - 10) / 2
accent = rl.Color(214, 188, 240, 255)
strength = self.pulse.confirmation_strength(now)
accent = rl.Color(214, round(188 + 35 * strength), 240, 255)
text(label, 30, cx + caption / 2, 104, accent, FontRole.SEMI_BOLD)
ax, ay = x + (cx + caption + 8) * scale, y + 104 * scale
rl.draw_line_ex(rl.Vector2(ax - 2 * scale, ay - 4 * scale), rl.Vector2(ax + 2 * scale, ay), 1.5 * scale, accent)
rl.draw_line_ex(rl.Vector2(ax + 2 * scale, ay), rl.Vector2(ax - 2 * scale, ay + 4 * scale), 1.5 * scale, accent)
draw_line(self.profile, rl.Vector2(ax - 2 * scale, ay - 4 * scale), rl.Vector2(ax + 2 * scale, ay), 1.5 * scale, accent)
draw_line(self.profile, rl.Vector2(ax + 2 * scale, ay), rl.Vector2(ax - 2 * scale, ay + 4 * scale), 1.5 * scale, accent)
if state.slc_wheel_confirmation_available:
accept, reject = ink_width('Accept', 26), ink_width('Reject', 26)
start = (width - 18 - 4 - accept - 10 - 18 - 4 - reject) / 2
@@ -189,6 +231,8 @@ class SpeedLimitWidget:
hint_ink = rl.Color(210, 216, 216, 255)
text('Accept', 26, start + 22 + accept / 2, 135.5, hint_ink if slc_accept_available(state) else MUTED)
text('Reject', 26, start + 18 + 4 + accept + 10 + 22 + reject / 2, 135.5, hint_ink)
else:
text('Tap to review', 22, width / 2, 135.5, secondary)
def _sign(self, state: OnroadState, bounds, now: float) -> None:
shown, obs = resolve_unified_speed(state), state.speed_limit
@@ -198,6 +242,7 @@ class SpeedLimitWidget:
heading = f'LAST {heading}' if heading != 'SPEED' else 'LAST'
x, top = bounds[0] + 12, bounds[1] + 12
scale = self.scale
center_x = SIGN_WIDTH / 2
pending = shown.pending and not obs.automatic_adoption_pending
target = displayed_adjusted_limit_mps(obs) if pending and not shown.posted_retained else None
arrow_direction = 0
@@ -210,16 +255,16 @@ class SpeedLimitWidget:
return tuple(value / scale for value in (*self.fonts.horizontal_ink(text, role, size * self.text_scale),
*self.fonts.vertical_ink(text, role, size * self.text_scale)))
def centered(text, role, size, ink_top, color, center_x=59):
def centered(text, role, size, ink_top, color, cx=None):
left, right, a, b = ink(text, role, size)
# Snap only the final draw, after fitting in the shared 118 x 132 geometry.
origin = round(x + (center_x - (left + right) / 2) * scale)
# Snap only the final draw, after fitting the visible glyphs inside the sign.
origin = round(x + ((center_x if cx is None else cx) - (left + right) / 2) * scale)
y = round(top + (ink_top - a) * scale)
self.fonts.draw(text, role, size * self.text_scale, origin, y, color)
draw_text(self.fonts, text, role, size * self.text_scale, origin, y, color)
return origin + left * scale, y + a * scale, origin + right * scale, y + b * scale
vienna_center_y = 67
mutcd_center_y = 66
mutcd_center_y = SIGN_HEIGHT / 2
def circle_fits(left, right, a, b):
return all((px - 59) ** 2 + (py - vienna_center_y) ** 2 <= 46 ** 2
@@ -227,80 +272,49 @@ class SpeedLimitWidget:
vienna = state.appearance.use_vienna_sign
sign_center_y = vienna_center_y if vienna else mutcd_center_y
set_visible = (shown.mode != 'limit_only' if self.profile == Profile.LARGE else
shown.max_text != '–' and placement(state.customization, self.profile, 'max_speed')['enabled'])
road_target = shown.limit_text if shown.offset_text else shown.posted_text
active_text = shown.override_text
if not set_visible and shown.slc_target_text != road_target:
if shown.slc_target_text is not None and (not shown.limit_in_use or shown.slc_target_text != shown.posted_text):
active_text = active_text or shown.slc_target_text
if active_text is not None and shown.posted_text != '–' and not pending:
override = shown.override_text is not None
road_role = FontRole.SEMI_BOLD if vienna else FontRole.BOLD
override_role = FontRole.BOLD
def fit(text, role, size, width):
left, right, _, _ = ink(text, role, size)
size *= min(1, width / (right - left))
left, right, a, b = ink(text, role, size)
return size, right - left, b - a
road_text = shown.posted_text + (shown.offset_text or '') if not override else shown.posted_text
road_size, road_width, road_height = fit(road_text, road_role, 33, 32 if vienna else 38)
override_size, override_width, override_height = fit(active_text, override_role, 56, 46 if vienna else 55)
label = 'OVERRIDE' if override else f'{heading} / SLC'
if override and last:
label = 'LAST / OVERRIDE'
heading_role = FontRole.SEMI_BOLD
heading_size = 17 if vienna else 20
if self.profile == Profile.COMPACT:
heading_size = 15 if vienna else 14
heading_size, _, heading_height = fit(label, heading_role, heading_size, 72 if vienna else 94)
row_left = 59 - (road_width + override_width + 11) / 2
road_x = row_left + 1.5 + road_width / 2
override_x = row_left + road_width + 11 + override_width / 2
heading_gap = 10 if vienna else 13
row_height = max(road_height, override_height)
half_height = (heading_height + heading_gap + row_height) / 2
content_center = sign_center_y - (3 if vienna else 0)
baseline = content_center + half_height
# Vienna centers the two numbers vertically. MUTCD aligns their baselines.
road_top = baseline - ((row_height + road_height) / 2 if vienna else road_height)
override_top = baseline - ((row_height + override_height) / 2 if vienna else override_height)
muted = rl.Color(86, 92, 99, 255) if vienna else rl.Color(164, 174, 182, 255)
active = rl.BLACK if vienna else rl.Color(243, 233, 255, 255)
slash = rl.Color(201, 34, 49, 255) if vienna else rl.Color(245, 100, 102, 255)
cx, cy = x + 59 * scale, top + sign_center_y * scale
if override:
self.shapes.override_sign(cx, cy, vienna=vienna)
elif vienna:
self.shapes.circle(cx, cy, rl.Color(201, 34, 49, 255))
else:
self.shapes.outline(cx, cy, white)
centered(label, heading_role, heading_size, content_center - half_height, muted)
centered(road_text, road_role, road_size, road_top, muted, road_x)
centered(active_text, override_role, override_size, override_top, active if override or vienna else white, override_x)
if override:
self.shapes.override_slash(x + road_x * scale, top + (road_top + road_height * .51) * scale,
(road_width + 5) * scale, slash)
pedal = speed_status(state, self.profile, shown) == 'PEDAL'
if (active_text is not None or pedal or shown.limit_in_use and not vienna) and not shown.pending:
# Driver targets are rectangular readouts, never regulatory red-ring signs.
self.shapes.outline(x + center_x * scale, top + mutcd_center_y * scale, IN_USE if shown.limit_in_use else white)
label = 'Override' if shown.override_text is not None else 'Limit'
active_text = active_text or shown.slc_target_text or shown.posted_text
target_rows = (('Pedal', FontRole.SEMI_BOLD, 44, 68, OVERRIDE),) if pedal else (
(label, FontRole.SEMI_BOLD, 34, 25, OVERRIDE if label == 'Override' else white),
(active_text, FontRole.SET_SPEED if self.profile == Profile.LARGE else FontRole.BOLD, 64, 68, white))
posted = shown.posted_text + (f' {shown.offset_text}' if shown.offset_text else '')
detail = f'{heading} {posted}' if pedal or shown.override_text is not None or active_text != shown.posted_text else heading
left, right, _, _ = ink(detail, FontRole.MEDIUM, 22)
source_rows = ((detail, FontRole.MEDIUM, 22, 120, secondary),) if detail == heading or right - left <= 108 else (
(heading, FontRole.SEMI_BOLD, 18, 104, secondary), (posted, FontRole.MEDIUM, 20, 125, secondary))
for value, role, size, cy, color in (*target_rows, *source_rows):
left, right, _, _ = ink(value, role, size)
size *= min(1, 108 / max(1, right - left))
_, _, a, b = ink(value, role, size)
centered(value, role, size, cy - (b - a) / 2, color)
return
large_steady = self.profile == Profile.LARGE and not vienna and not shown.pending
color = rl.Color(0, 0, 0, 255) if vienna else white
color = white
if vienna:
x, top = x + 5 * scale, top + 5 * scale
center_x = 59
self.shapes.circle(x + 59 * scale, top + sign_center_y * scale, self.pulse.color(rl.Color(201, 34, 49, 255), now))
else:
self.shapes.outline(x + 59 * scale, top + sign_center_y * scale, white)
self.shapes.outline(x + center_x * scale, top + sign_center_y * scale, IN_USE if shown.limit_in_use else white)
heading_size = 28 / self.text_scale if large_steady else 18
heading_size = 28 if not vienna else 18
if vienna and self.profile == Profile.LARGE and not shown.pending:
heading_size = 21
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size)
if not vienna and (large_steady or last) and right - left > 96:
heading_size *= 96 / (right - left)
if not vienna and right - left > 108:
heading_size *= 108 / (right - left)
left, right, a, b = ink(heading, FontRole.SEMI_BOLD, heading_size)
# Lift the Vienna stack to keep the offset clear of the bottom rim.
lift = (7 if shown.offset_text else 2) if vienna else 0
header_y = 47 - lift if vienna else 21 if large_steady else 26
lift = (12 if shown.limit_in_use else 7 if shown.offset_text else 2) if vienna else 0
header_y = 47 - lift if vienna else 26
arrow_scale = 1.
if vienna:
# Fit the heading and arrow inside the circle at the top of their ink.
@@ -315,16 +329,16 @@ class SpeedLimitWidget:
heading_width = right - left
# The small arrow's visible ink is 12 x 16, with a six-pixel label gap.
arrow_width = 12 * arrow_scale
header_left = 59 - (heading_width + (6 + arrow_width if arrow_direction else 0)) / 2
header_left = center_x - (heading_width + (6 + arrow_width if arrow_direction else 0)) / 2
centered(heading, FontRole.SEMI_BOLD, heading_size, header_y - (b - a) / 2, color, header_left + heading_width / 2)
if arrow_direction:
self.shapes.arrow('compact', round(x + (header_left + heading_width + 6 + arrow_width / 2) * scale),
round(top + header_y * scale), arrow_direction, color, scale=scale * arrow_scale)
role = FontRole.SET_SPEED if large_steady else FontRole.BOLD
size = 77.92 / self.text_scale if large_steady else 58 if vienna else 64
size = 58 if vienna else 64
offset_size = 30 if vienna else 27
number_center = (73 if shown.offset_text else 80) - lift if vienna else (67 if shown.offset_text else 81)
number_center = (73 if shown.offset_text else 80) - lift if vienna else (68 if shown.offset_text else 80)
while True:
left, right, a, b = ink(shown.posted_text, role, size)
width, height = right - left, b - a
@@ -336,7 +350,7 @@ class SpeedLimitWidget:
offset_top = number_top + height + 4
fits = fits and circle_fits(59 - (right - left) / 2, 59 + (right - left) / 2, offset_top, offset_top + b - a)
else:
fits = width <= 96 and height <= 54
fits = width <= 108 and height <= 54
if fits:
break
size *= .95
@@ -346,6 +360,8 @@ class SpeedLimitWidget:
if shown.offset_text:
if not vienna:
left, right, a, b = ink(shown.offset_text, FontRole.MEDIUM, offset_size)
offset_size *= min(1, 96 / (right - left), 24 / (b - a))
offset_top = 97
offset_size *= min(1, 108 / (right - left), 24 / (b - a))
offset_top = 105
centered(shown.offset_text, FontRole.MEDIUM, offset_size, offset_top, color if vienna else secondary)
if shown.limit_in_use:
centered('IN USE', FontRole.SEMI_BOLD, 15 if vienna else 22, 98 if vienna else 117, IN_USE)
+5 -6
View File
@@ -3,12 +3,13 @@ import math
import time
from openpilot.starpilot.speed_limits.vision.stop_observation import STOP_MAX_AGE_NS
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, LIMIT_RECTS
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_HEIGHT, LIMIT_RECTS
from openpilot.starpilot.ui.onroad_customization import offset, placement
from openpilot.starpilot.ui.onroad_state import AlertSize, slc_action_occludes
from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice
from openpilot.starpilot.ui.presentation import FontRole, Profile
from openpilot.starpilot.ui.raylib import rl
from openpilot.starpilot.ui.speed_limit_widget import draw_line, draw_text
def stop_sign_bounds(profile: Profile, state):
@@ -19,7 +20,7 @@ def stop_sign_bounds(profile: Profile, state):
dx, dy = offset(state.customization, profile, widget)
scale = LARGE_SIGN_SCALE if profile == Profile.LARGE else .7
left = x + dx + width + 24 if profile == Profile.LARGE else x + dx - 94
top = y + dy + (66 * LARGE_SIGN_SCALE - 50 * scale if profile == Profile.LARGE else 8)
top = y + dy + (SIGN_HEIGHT / 2 * LARGE_SIGN_SCALE - 50 * scale if profile == Profile.LARGE else 8)
if slc_action_occludes(profile, state, (left, top, left + 100 * scale, top + 100 * scale)):
return None
return left, top, 100 * scale, scale
@@ -42,13 +43,11 @@ def draw_stop_sign(fonts, x: float, y: float, *, scale: float = 1) -> None:
white = rl.Color(244, 247, 245, 255)
center = rl.Vector2(x + 50 * scale, y + 50 * scale)
radius = 50 * scale / math.cos(math.pi / 8)
if fonts.profile == Profile.LARGE:
rl.draw_poly(center, 8, radius, 22.5, rl.Color(10, 17, 19, 230))
vertices = [rl.Vector2(center.x + radius * math.cos(math.radians(22.5 + i * 45)),
center.y + radius * math.sin(math.radians(22.5 + i * 45))) for i in range(8)]
for i, point in enumerate(vertices):
rl.draw_line_ex(point, vertices[(i + 1) % 8], 2 * scale, white)
draw_line(fonts.profile, point, vertices[(i + 1) % 8], 2 * scale, white)
size = 28 * scale * Profile.COMPACT.font_scale / fonts.profile.font_scale
left, right = fonts.horizontal_ink('STOP', FontRole.BOLD, size)
top, bottom = fonts.vertical_ink('STOP', FontRole.BOLD, size)
fonts.draw('STOP', FontRole.BOLD, size, center.x - (left + right) / 2, center.y - (top + bottom) / 2, white)
draw_text(fonts, 'STOP', FontRole.BOLD, size, center.x - (left + right) / 2, center.y - (top + bottom) / 2, white)
@@ -345,7 +345,6 @@ class AppearanceSettingsTests(unittest.TestCase):
view.torque_bar = Mock()
view.stock_confidence_layer = Mock()
view.stock_confidence_reset = Mock()
view._fade = None
base = OnroadState(engaged=True, camera_available=False, speed_mps=12, cruise_kph=60,
speed_limit=SpeedLimitObservation(),
appearance=OnroadAppearance(show_stock_confidence_ball=True),
@@ -356,7 +355,7 @@ class AppearanceSettingsTests(unittest.TestCase):
patch.object(onroad.rl, "draw_rectangle_rec"), patch.object(onroad.rl, "draw_rectangle_gradient_v"), \
patch.object(onroad.rl, "draw_rectangle"), patch.object(onroad.rl, "draw_texture_ex"), \
patch.object(onroad.rl, "draw_rectangle_rounded_lines_ex"), patch.object(onroad, "render_compact_half_borders"), \
patch.object(view, "_prepare_compact_fade"), patch.object(view, "_slc_actions"):
patch.object(view, "_slc_actions"):
view.render(base)
view.stock_confidence_layer.assert_called_once()
view.stock_confidence_reset.assert_called_once()
@@ -624,7 +623,7 @@ class AppearanceSettingsTests(unittest.TestCase):
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), base)
card.assert_not_called()
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls
self.assertEqual(foreground_calls(fonts.draw)[0].args[3:5], (192, 89))
self.assertEqual(foreground_calls(fonts.draw)[0].args[3:5], (192, 87))
for profile in (Profile.LARGE, Profile.COMPACT):
view = onroad.OnroadView.__new__(onroad.OnroadView)
view.personality = Mock()
@@ -645,12 +644,11 @@ class AppearanceSettingsTests(unittest.TestCase):
view.steering_wheel = Mock()
view.compact_hud = Mock()
view.compact_sidebar = Mock()
view._fade = None
with patch.object(onroad.clip, "begin_scissor_mode"), patch.object(onroad.clip, "end_scissor_mode"), \
patch.object(onroad.rl, "draw_rectangle_rec"), patch.object(onroad.rl, "draw_rectangle_gradient_v"), \
patch.object(onroad.rl, "draw_rectangle_lines_ex"), patch.object(onroad.rl, "draw_rectangle"), \
patch.object(onroad.rl, "draw_texture_ex"), patch.object(onroad.rl, "draw_rectangle_rounded_lines_ex"), \
patch.object(onroad, "render_corner_hint"), patch.object(view, "_prepare_compact_fade"), \
patch.object(onroad, "render_corner_hint"), \
patch.object(view, "_slc_actions") as slc:
view.render(base)
view.torque_bar.render.assert_called_once()
@@ -4,11 +4,11 @@ from unittest.mock import Mock, patch
from openpilot.starpilot.ui.raylib import rl
import pytest
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_WIDTH, SIGN_HEIGHT
from openpilot.starpilot.ui.onroad_state import speed_limit_bounds, slc_controls, SpeedLimitObservation, ObservationKind
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
from openpilot.starpilot.ui.tests.test_unified_speed_native import fonts, state as pending_state
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls, fonts, state as pending_state
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
@@ -46,9 +46,9 @@ def test_both_sign_styles_keep_their_height_with_or_without_offset(profile, offs
else:
historical_outline.assert_not_called()
widget.shapes.outline.assert_called_once()
assert widget.shapes.outline.call_args.args[:2] == (bounds[0]+12+59*scale, bounds[1]+12+66*scale)
assert widget.shapes.outline.call_args.args[:2] == (bounds[0]+12+SIGN_WIDTH/2*scale, bounds[1]+12+SIGN_HEIGHT/2*scale)
assert widget.shapes.outline.call_args.kwargs == {}
assert ('+5' in [call.args[0] for call in widget.fonts.draw.call_args_list]) == offset
assert ('+5' in [call.args[0] for call in foreground_calls(widget.fonts.draw)]) == offset
@pytest.mark.parametrize('value', (65, 120))
@@ -69,8 +69,8 @@ def test_small_sign_keeps_its_footprint_for_every_offset(value, metric, show, of
assert bounds is not None
historical_outline.assert_not_called()
widget.shapes.outline.assert_called_once()
assert bounds[3] - bounds[1] == 152
texts = [call.args[0] for call in widget.fonts.draw.call_args_list]
assert bounds[3] - bounds[1] == SIGN_HEIGHT + 20
texts = [call.args[0] for call in foreground_calls(widget.fonts.draw)]
assert texts == ['VISION', str(value)] + ([shown.offset_text] if shown.offset_text else [])
@@ -83,7 +83,7 @@ def test_small_offset_layout_transitions_reuse_the_same_widget(historical_outlin
historical_outline.assert_not_called()
bounds = speed_limit_bounds(Profile.COMPACT, current)
assert bounds is not None
assert bounds[3] - bounds[1] == 152
assert bounds[3] - bounds[1] == SIGN_HEIGHT + 20
@pytest.mark.parametrize('y', [20, 50, 108])
@@ -100,6 +100,6 @@ def test_small_rectangular_no_offset_regression(historical_outline):
widget.shapes = Mock()
widget.render(current)
bounds = speed_limit_bounds(Profile.COMPACT, current)
assert bounds is not None and bounds[3] - bounds[1] == 152
assert bounds is not None and bounds[3] - bounds[1] == SIGN_HEIGHT + 20
historical_outline.assert_not_called()
widget.shapes.outline.assert_called_once()
@@ -92,7 +92,6 @@ class DeveloperPreviewRenderTests(unittest.TestCase):
view.compact_hud = Mock()
view.compact_sidebar = Mock()
view.torque_bar = Mock()
view._fade = Mock()
view._fade_path = Path('/unused')
with patch('openpilot.starpilot.ui.onroad.clip.begin_scissor_mode'), \
patch('openpilot.starpilot.ui.onroad.clip.end_scissor_mode'), \
@@ -104,7 +103,7 @@ class DeveloperPreviewRenderTests(unittest.TestCase):
patch('openpilot.starpilot.ui.onroad.rl.draw_rectangle_rounded'), \
patch('openpilot.starpilot.ui.onroad.rl.draw_rectangle_rounded_lines_ex'), \
patch('openpilot.starpilot.ui.onroad.render_corner_hint'), \
patch.object(view, '_prepare_compact_fade'), patch.object(view, '_slc_actions'):
patch.object(view, '_slc_actions'):
view.render(state(full_alert=True))
drawn = [call.args[0] for call in fonts.draw.call_args_list]
self.assertTrue(any('SYNTHETIC REPLAY PREVIEW' in text for text in drawn))
+198 -51
View File
@@ -2,6 +2,7 @@
from dataclasses import replace
from itertools import product
import io
from pathlib import Path
import re
from types import SimpleNamespace
from unittest.mock import Mock
@@ -13,6 +14,7 @@ import pytest
from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory, font_filename, font_path, validate_bitmap_font
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_WIDTH, SIGN_HEIGHT, LIMIT_RECTS, confirmation_bounds
from openpilot.starpilot.ui.onroad_state import ObservationKind, slc_controls, speed_limit_bounds
from openpilot.starpilot.ui.tests.test_speed_stack import road
from openpilot.starpilot.system.starport.projection_layout import default_layout_for_viewport, validate_layout_for_viewport
@@ -92,13 +94,14 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position():
with patch.object(fonts, 'draw', lambda *args, calls=calls: calls.append(args)), patch.object(rl, 'draw_line_ex') as lines:
UnifiedSpeedWidget(fonts).render(rl.Rectangle(30, 30, 1800, 1020), current)
calls = [call for call in calls if call[-1].a == 255]
posted = next(call for call in calls if call[0] == '120' and call[1] == FontRole.SET_SPEED)
posted = next(call for call in calls if call[0] == '130' and call[1] == FontRole.SET_SPEED and call[3] > 388)
left, right = fonts.horizontal_ink(posted[0], posted[1], posted[2])
assert right-left <= 96*1.25
assert abs(posted[3] + (left + right) / 2 - 491.75) <= .5
a, b = fonts.vertical_ink('120', FontRole.SET_SPEED, posted[2])
assert b - a == pytest.approx(60, abs=.1)
assert abs(posted[4]+(a+b)/2-(95+67*1.25)) <= .5
assert right-left <= 108*LARGE_SIGN_SCALE
assert abs(posted[3] + (left + right) / 2 - (418 + SIGN_WIDTH/2*LARGE_SIGN_SCALE)) <= .5
a, b = fonts.vertical_ink('130', FontRole.SET_SPEED, posted[2])
assert 30 <= b - a <= 64
assert abs(posted[4]+(a+b)/2-(95+68*LARGE_SIGN_SCALE)) <= .5
assert {'Limit', '120 +10'} <= {call[0] for call in calls}
set_calls = [call for call in calls if call[1] == FontRole.SET_SPEED and call[3] < 388]
assert bool(set_calls) == (show_set and value is not None)
if set_calls:
@@ -108,15 +111,15 @@ def test_large_sign_is_secondary_to_set_speed_at_the_saved_position():
assert x + (left + right) / 2 == pytest.approx(308)
assert x + right + 20 <= 418
top, bottom = fonts.vertical_ink(text, role, size)
assert bottom - top == pytest.approx(83, abs=1)
assert y + (top + bottom) / 2 == pytest.approx(196)
assert bottom - top == pytest.approx(77, abs=1)
assert y + (top + bottom) / 2 == pytest.approx(185)
assert next(call for call in calls if call[0] == 'SET')[2] == 34
unit = next(call for call in calls if call[0] == ('km/h' if metric else 'mph'))
assert unit[2] == 32
assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] + 12 <= y + top
assert unit[4] + fonts.vertical_ink(unit[0], unit[1], unit[2])[1] + 6 <= y + top
if show_set and value is None:
start, end, stroke, color = lines.call_args.args
assert (start.x, start.y, end.x, end.y, stroke) == (280, 196, 336, 196, 8)
assert (start.x, start.y, end.x, end.y, stroke) == (280, 185, 336, 185, 8)
assert (color.r, color.g, color.b, color.a) == (190, 208, 202, 255)
assert all(call[0] not in ('–', '?') for call in calls)
else:
@@ -150,6 +153,115 @@ def test_pending_confirmation_keeps_three_digit_set_speed_clear():
rl.close_window()
def test_status_below_set_has_real_ink_clearance_and_fits_its_widget():
from unittest.mock import patch
from openpilot.starpilot.ui.onroad_customization import placement, widget_size
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
from openpilot.starpilot.ui.tests.test_unified_speed_native import state
owns_context = not rl.is_window_ready()
if owns_context:
rl.init_window(128, 128, 'SET status clearance')
try:
for profile, status, set_speed in product(Profile, ('PEDAL', 'LIMIT 45', 'OVERRIDE 100'), (70, 100, 199)):
with BitmapFonts(profile, default_font_directory()) as fonts:
current = state(cruise_kph=set_speed, longitudinal_overridden=status == 'PEDAL', slc_prompt_open=True)
current = replace(current, speed_limit=replace(current.speed_limit,
pending_speed_limit_mps=None if status == 'PEDAL' else 50/3.6,
limiting_max_set=status == 'LIMIT 45', driver_override_active=status == 'OVERRIDE 100',
override_basis='persistent', effective_cluster_target_mps=(100 if status == 'OVERRIDE 100' else 45)/3.6))
widget = UnifiedSpeedWidget(fonts) if profile == Profile.LARGE else CompactHudRenderer(fonts, Path('/unused'))
if isinstance(widget, CompactHudRenderer):
widget._set_speed_alpha.x = 1
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), \
patch('openpilot.starpilot.ui.speed_limit_widget.speed_limit_bounds', return_value=None):
if isinstance(widget, UnifiedSpeedWidget):
widget.render(rl.Rectangle(30,30,1800,1020), current)
else:
widget.render_max_speed(current)
rows = [row for row in calls if row[-1].a == 255]
number = next(row for row in rows if row[0] == str(set_speed) and row[1] == FontRole.SET_SPEED)
caption = next(row for row in rows if row[0] == status)
bottom = number[4] + fonts.vertical_ink(*number[:3])[1]
top = caption[4] + fonts.vertical_ink(*caption[:3])[0]
assert top - bottom >= 6, (profile, status, set_speed)
key = 'cruise_limits' if profile == Profile.LARGE else 'max_speed'
saved = placement(current.customization, profile, key)
width, height = widget_size(current.customization, profile, key)
assert caption[4] + fonts.vertical_ink(*caption[:3])[1] <= saved['y'] + height - 12
assert 18 <= caption[2] <= 24
left, right = fonts.horizontal_ink(*caption[:3])
number_left, number_right = fonts.horizontal_ink(*number[:3])
if profile == Profile.COMPACT:
assert saved['x'] + 8 <= caption[3] + left < caption[3] + right <= saved['x'] + width - 8
assert caption[3] + (left + right) / 2 == pytest.approx(saved['x'] + width / 2)
assert number[3] + (number_left + number_right) / 2 == pytest.approx(saved['x'] + width / 2)
elif status != 'PEDAL':
label = next(row for row in rows if row[0] == 'SET')
label_left = label[3] + fonts.horizontal_ink(*label[:3])[0]
assert caption[3] + left == pytest.approx(label_left)
assert number[3] + number_left == pytest.approx(label_left)
widget.close()
finally:
if owns_context:
rl.close_window()
def test_sign_status_rows_keep_balanced_insets_with_real_fonts():
from unittest.mock import patch
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
from openpilot.starpilot.ui.tests.test_unified_speed_native import state
owns_context = not rl.is_window_ready()
if owns_context:
rl.init_window(128, 128, 'Sign status margins')
try:
for profile in Profile:
with BitmapFonts(profile, default_font_directory()) as fonts:
widget = SpeedLimitWidget(fonts, profile)
for variant in ('in_use', 'offset', 'override', 'pedal', 'retained'):
current = state(cruise_kph=140, longitudinal_overridden=variant == 'pedal')
target = 128 if variant == 'override' else 110 if variant == 'offset' else 100
current = replace(current, speed_limit=replace(current.speed_limit,
source='vision', speed_limit_mps=100/3.6, pending_speed_limit_mps=None,
accepted_source='vision', accepted_speed_limit_mps=100/3.6, accepted_adjusted_limit_mps=target/3.6,
presentation_adjusted_limit_mps=target/3.6, effective_cluster_target_mps=target/3.6,
limiting_max_set=True, driver_override_active=variant == 'override', override_basis='persistent'))
if variant == 'pedal':
current.customization['showMaxSpeed'] = False
current.customization['layouts']['compact']['max_speed']['enabled'] = False
if variant == 'retained':
current = replace(current, speed_limit=replace(current.speed_limit, kind=ObservationKind.STALE,
speed_limit_mps=None, last_received_speed_limit_mps=100/3.6, last_received_source='vision'))
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
widget.render(current)
if variant == 'retained':
assert [call[0] for call in calls if call[-1].a == 255] == ['Limit', '100', 'LAST VISION']
bounds = speed_limit_bounds(profile, current)
assert bounds is not None
x, y = bounds[0] + 12, bounds[1] + 12
scale = widget.scale
boxes = []
for text, role, size, left, top, color in calls:
if color.a != 255:
continue
a, b = fonts.horizontal_ink(text, role, size)
c, d = fonts.vertical_ink(text, role, size)
box = left+a, top+c, left+b, top+d
# Keep real glyph ink clear of the portrait outline, including both footer rows.
assert x+9.5*scale <= box[0] < box[2] <= x+118.5*scale, (profile, variant, text)
assert y+10.5*scale <= box[1] < box[3] <= y+133.5*scale, (profile, variant, text)
assert (box[0]+box[2])/2 == pytest.approx(x+SIGN_WIDTH/2*scale, abs=.5)
boxes.append(box)
assert len(boxes) >= 2
assert all(second[1] - first[3] >= 4*scale for first, second in zip(boxes, boxes[1:], strict=False))
widget.close()
finally:
if owns_context:
rl.close_window()
def test_galaxy_card_pixels_align_without_reading_font_textures():
from unittest.mock import patch
from openpilot.starpilot.ui.layout_preview_renderer import _Canvas, sample_state
@@ -276,7 +388,7 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign():
rl.init_window(128, 128, 'SLC preview crop fixture')
canvas = _Canvas(Profile.COMPACT)
try:
for y, scene, vienna in product((20, 24), ('slc_pending', 'slc_retained'), (False, True)):
for y, scene, vienna in product((16, 20), ('slc_pending', 'slc_retained', 'slc_display'), (False, True)):
document = default_document()
document['layouts']['compact']['speed_limit']['y'] = y
assert validate_document(document)['layouts']['compact']['speed_limit']['y'] == y
@@ -288,7 +400,7 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign():
slot = json.loads(sheet.info['starpilot-widget-art'])['sprites']['speed_limit']
sprite = sheet.crop((slot['x'], slot['y'], slot['x']+slot['width'], slot['y']+slot['height']))
rebuilt = Image.new('RGBA', Profile.COMPACT.size)
rebuilt.paste(sprite, (round(330+slot['offsetX']), round(y+slot['offsetY'])))
rebuilt.paste(sprite, (round(LIMIT_RECTS['compact'][0]+slot['offsetX']), round(y+slot['offsetY'])))
image = rl.load_image_from_texture(canvas.target.texture)
try:
rl.image_flip_vertical(image)
@@ -297,10 +409,9 @@ def test_compact_preview_sprite_keeps_the_full_confirmation_and_override_sign():
rl.unload_image(image)
assert np.array_equal(direct[..., 3], np.asarray(rebuilt)[..., 3])
if scene == 'slc_pending':
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-182, -12, 312, 152)
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-172, -8, 328, 152)
else:
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (
(-3, 0, 124, 132) if vienna else (-9, 0, 136, 132))
assert (slot['offsetX'], slot['offsetY'], slot['width'], slot['height']) == (-2, 0, SIGN_WIDTH+4, SIGN_HEIGHT)
assert np.any(direct[..., 3]), (y, scene)
finally:
canvas.close()
@@ -378,9 +489,14 @@ def test_source_card_action_sprite_contains_both_actual_button_rows():
slot = json.loads(image.info['starpilot-widget-art'])['sprites']['cruise_limits']
assert slot['width'] >= 591 and slot['offsetY'] == -12
alpha = np.asarray(image.convert('RGBA'))[..., 3]
assert alpha[100, 220] > 200, 'Accept must remain in its sprite'
assert alpha[100, 450] > 200, 'Reject must remain in its sprite'
assert alpha[100, 382] > 200, 'The shared surface must remain in its sprite'
origin = document['layouts']['large']['cruise_limits']
for control in slc_controls(Profile.LARGE, state):
left, top, right, bottom = control.bounds
x1, x2 = [round(value - origin['x'] - slot['offsetX'] + slot['x']) for value in (left, right)]
y1, y2 = [round(value - origin['y'] - slot['offsetY'] + slot['y']) for value in (top, bottom)]
assert np.count_nonzero(alpha[y1:y2, x1:x2] > 200) > 50, control.label
assert np.mean(alpha[y1:y2, x1:x2] == 0) > .65, 'Button border and label leave the camera visible'
assert alpha[100, 382] == 0, 'The space between choices keeps the camera visible'
finally:
rl.unload_render_texture(target)
if owns_context:
@@ -411,7 +527,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
with BitmapFonts(profile, default_font_directory()) as fonts:
widget = SpeedLimitWidget(fonts, profile)
scale = widget.scale
positions = ((88, 75), (188, 95), (1642, 882)) if profile == Profile.LARGE else ((330, 20), (100, 0), (358, 108))
positions = ((88, 75), (188, 95), (1642, 856)) if profile == Profile.LARGE else ((320, 16), (100, 0), (348, 96))
for vienna, (source, heading), (posted, offset), elapsed, (saved_x, y), direction, retained in product(
(False, True), (('map', 'OSM'), ('vision', 'VISION')),
((65, 5), (120, 10), (130, -10), (145, -137), (199, -191), (200, -192), (252, -244), (406, -261), (55, 0)),
@@ -433,6 +549,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), \
patch.object(widget.shapes, 'arrow') as arrow_draw:
widget.render(current)
calls = [call for call in calls if call[-1].a == 255]
assert sum(call[0] == (f'LAST {heading}' if retained else heading) for call in calls) == 1, case
assert all(call[0] != 'LIMIT' for call in calls), case
ink = []
@@ -441,14 +558,15 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
c, d = fonts.vertical_ink(text, role, size)
box = left+a, top+c, left+b, top+d
if vienna:
cx, cy = x+59*scale, y+67*scale
cx, cy = x+64*scale, y+72*scale
assert all((px-cx)**2+(py-cy)**2 <= (47*scale)**2
for px, py in product((box[0], box[2]), (box[1], box[3]))), case
assert (color.r, color.g, color.b, color.a) == (0, 0, 0, 255), case
expected = widget.pulse.color(rl.Color(244, 247, 245, 255), current.observed_ns / 1e9)
assert (color.r, color.g, color.b, color.a) == (expected.r, expected.g, expected.b, expected.a), case
else:
# The inner stroke ends at x+4/x+114; keep one clear pixel beside the glyphs.
assert x+5*scale <= box[0] < box[2] <= x+113*scale, case
assert y+7*scale <= box[1] < box[3] <= y+125*scale, case
# The inner stroke ends at x+4/x+124; keep one clear pixel beside the glyphs.
assert x+5*scale <= box[0] < box[2] <= x+123*scale, case
assert y+7*scale <= box[1] < box[3] <= y+137*scale, case
ink.append((text, box))
nominal_size = size/widget.text_scale
if role == FontRole.BOLD:
@@ -468,7 +586,7 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
arrow = ax+left*arrow_scale, ay+top*arrow_scale, ax+right*arrow_scale, ay+bottom*arrow_scale
label = next(box for text, box in ink if text == heading)
assert abs(arrow[0] - label[2] - 6*scale) <= 1, case
assert abs((label[0] + arrow[2]) / 2 - (x+59*scale)) <= .5, case
assert abs((label[0] + arrow[2]) / 2 - (x+64*scale)) <= .5, case
assert abs((label[1] + label[3]) / 2 - (arrow[1] + arrow[3]) / 2) <= 1, case
if vienna:
# The arrow's transparent bounding-box corners are not painted ink.
@@ -476,8 +594,8 @@ def test_both_sign_styles_contain_text_and_arrows_at_each_profile_scale():
assert np.all(np.linalg.norm(pixels-(cx, cy), axis=1) <= 47*scale), case
assert .95*(label[3]-label[1]) <= arrow[3]-arrow[1] <= 1.25*(label[3]-label[1]), case
else:
assert x+7*scale <= arrow[0] < arrow[2] <= x+111*scale, case
assert y+7*scale <= arrow[1] < arrow[3] <= y+125*scale, case
assert x+7*scale <= arrow[0] < arrow[2] <= x+121*scale, case
assert y+7*scale <= arrow[1] < arrow[3] <= y+137*scale, case
assert all(box[2]+4*scale <= arrow[0] for text, box in ink if text == heading), (case, ink, arrow)
assert arrow[3]+6*scale <= next(box[1] for text, box in ink if text==str(posted))
ink.append(('arrow', arrow))
@@ -512,9 +630,10 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels():
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)):
widget.render(current)
calls = [call for call in calls if call[-1].a == 255]
assert all(float(call[3]).is_integer() and float(call[4]).is_integer() for call in calls)
if fonts.profile == Profile.LARGE and not vienna and not pending:
assert calls[0][2] <= 28
if not vienna:
assert calls[0][2]/widget.text_scale <= 28
elif fonts.profile == Profile.LARGE and not pending:
assert 18 <= calls[0][2] / widget.text_scale <= 21
elif vienna and pending and source == 'vision':
@@ -532,8 +651,8 @@ def test_sign_profiles_share_layout_and_snap_only_final_pixels():
start = int(vienna and not pending)
for a, b in zip(small_calls[start:], large_calls[start:], strict=True):
assert abs(a[2]/small_widget.text_scale-b[2]/large_widget.text_scale) < 1e-9
assert abs((a[3]-330)-(b[3]-318)/1.25) <= 1
assert abs((a[4]-20)-(b[4]-75)/1.25) <= 1
assert abs((a[3]-320)-(b[3]-318)/LARGE_SIGN_SCALE) <= 1
assert abs((a[4]-16)-(b[4]-75)/LARGE_SIGN_SCALE) <= 1
assert (a[-1].r, a[-1].g, a[-1].b, a[-1].a) == (b[-1].r, b[-1].g, b[-1].b, b[-1].a)
if pending:
for widget in (small_widget, large_widget):
@@ -554,7 +673,7 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent():
doc['layouts']['large']['cruise_limits'].update(x=1550, y=638)
doc['layouts']['large']['speed_limit_actions'].update(x=1550, y=954)
migrated = validate_document(doc)
assert migrated['version'] == 12
assert migrated['version'] == 13
assert migrated['layouts']['large']['cruise_limits'] == default_document()['layouts']['large']['cruise_limits']
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1550, 'y': 882, 'enabled': True}
assert migrated['speedSources'] is True
@@ -562,7 +681,7 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent():
assert validate_document(migrated) == migrated
doc['layouts']['large']['cruise_limits'].update(x=88, y=75)
doc['layouts']['large']['speed_limit_actions'].update(x=88, y=447)
assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 359
assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 381
def test_pending_choices_use_the_sign_with_separated_horizontal_targets():
@@ -581,7 +700,7 @@ def test_projection_v1_layout_migrates_without_losing_other_widgets():
doc['widgets']['speed_limit_actions'].update(x=88, y=447)
doc['widgets']['current_speed'].update(x=1400, y=100)
migrated = validate_layout_for_viewport(doc, viewport)
assert migrated['version'] == 5
assert migrated['version'] == 6
assert migrated['widgets']['speed_limit_actions']['y'] == 267
assert migrated['widgets']['current_speed'] == doc['widgets']['current_speed']
assert validate_layout_for_viewport(migrated, viewport) == migrated
@@ -594,12 +713,14 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped():
doc['widgets']['cruise_limits'].update(x=1654, y=75)
doc['widgets']['speed_limit_actions'].update(x=1654, y=500)
migrated = validate_layout_for_viewport(doc, viewport)
assert migrated['widgets']['cruise_limits']['x'] == 1239
card = migrated['widgets']['cruise_limits']
assert confirmation_bounds('large', card['x'], card['y'], opened=True)[2] <= 1830
assert confirmation_bounds('large', card['x']+1, card['y'], opened=True)[2] > 1830
assert migrated['widgets']['speed_limit_actions'] == {'x': 1510, 'y': 500, 'enabled': True}
def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
def test_chooser_binds_the_target_to_accept_and_keeps_all_native_ink_inside_its_control():
from unittest.mock import patch
from openpilot.starpilot.ui.large_speed_geometry import CONFIRMATION_SCALE
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
@@ -615,7 +736,9 @@ def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
try:
for metric, posted, offset, show_offset in ((False, 65, 5, True), (True, 120, 10, True),
(True, 50, -5, True), (False, 65, 5, False),
(True, 120, 10, False), (True, 50, -5, False)):
(True, 120, 10, False), (True, 50, -5, False),
(True, 110, 10, True), (True, 110, 10, False),
(False, 5, 0, False), (True, 5, 0, False)):
current = state()
factor = 3.6 if metric else 2.2369362921
obs = replace(current.speed_limit, source='vision', speed_limit_mps=posted / factor,
@@ -627,6 +750,7 @@ def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
notice_calls = []
with patch.object(fonts, 'draw', lambda *args, sink=notice_calls: sink.append(args)):
widget.render_confirmation(current)
notice_calls = [call for call in notice_calls if call[-1].a == 255]
notice_bounds = speed_limit_bounds(profile, current)
assert notice_bounds is not None
boxes = {}
@@ -647,29 +771,52 @@ def test_chooser_target_header_and_padded_labels_fit_the_native_ink():
else:
assert f'{offset:+d}' not in boxes
current = replace(current, slc_prompt_open=True)
if posted == 110:
document = default_document()
document['showMaxSpeed'] = False
document['layouts']['compact']['max_speed']['enabled'] = False
current = replace(current, customization=document, speed_limit=replace(current.speed_limit,
driver_override_active=True, override_basis='persistent', effective_cluster_target_mps=128 / 3.6))
calls = []
with patch.object(fonts, 'draw', lambda *args, sink=calls: sink.append(args)), patch.object(rl, 'draw_rectangle_rounded'):
widget.render_confirmation(current)
assert calls[0][0] == str(posted + offset)
assert calls[1][0] == ('km/h' if metric else 'mph')
assert calls[2][0] == (f'VISION {posted} {offset:+d}' if show_offset else 'VISION')
calls = [call for call in calls if call[-1].a == 255]
context = 'Now: OVERRIDE 128 km/h' if posted == 110 else 'New limit'
assert [call[0] for call in calls] == [context, 'Accept', str(posted + offset),
'km/h' if metric else 'mph', 'Reject', 'new limit']
bounds = speed_limit_bounds(profile, current)
assert bounds is not None
previous_right = bounds[0]
for text, role, size, x, y, _ in calls[:3]:
for text, role, size, x, y, _ in calls[:1]:
left, right = fonts.horizontal_ink(text, role, size)
top, bottom = fonts.vertical_ink(text, role, size)
assert x + left >= previous_right
assert x + left >= bounds[0] + 10 * scale
assert x + right <= bounds[2] - 10 * scale
assert bounds[1] + 8 * scale <= y + top < y + bottom <= bounds[1] + 40 * scale
previous_right = x + right
for control, (text, role, size, x, y, _) in zip(slc_controls(profile, current), calls[-2:], strict=True):
left, right = fonts.horizontal_ink(text, role, size)
top, bottom = fonts.vertical_ink(text, role, size)
assert x + left - control.bounds[0] >= 12 * scale
assert control.bounds[2] - x - right >= 12 * scale
assert control.bounds[1] + 10 * scale <= y + top < y + bottom <= control.bounds[3] - 10 * scale
assert (bottom - top) / scale * 25.4 / 300 >= 2.8
heading_centers = []
for control, content in zip(slc_controls(profile, current), (calls[1:4], calls[4:]), strict=True):
label_bottom = 0
boxes = []
for text, role, size, x, y, _ in content:
left, right = fonts.horizontal_ink(text, role, size)
top, bottom = fonts.vertical_ink(text, role, size)
boxes.append((x + left, y + top, x + right, y + bottom))
assert x + left - control.bounds[0] >= 12 * scale - 1e-6
assert control.bounds[2] - x - right >= 12 * scale - 1e-6
assert control.bounds[1] + 10 * scale <= y + top < y + bottom <= control.bounds[3] - 10 * scale
if text == control.label:
label_bottom = y + bottom
assert (bottom - top) / scale * 25.4 / 300 >= 2.8
else:
assert y + top >= label_bottom + 6 * scale
cx, cy = (control.bounds[0] + control.bounds[2]) / 2, (control.bounds[1] + control.bounds[3]) / 2
assert (boxes[0][0] + boxes[0][2]) / 2 == pytest.approx(cx)
assert (min(box[0] for box in boxes[1:]) + max(box[2] for box in boxes[1:])) / 2 == pytest.approx(cx)
assert (min(box[1] for box in boxes) + max(box[3] for box in boxes)) / 2 == pytest.approx(cy)
heading_centers.append((boxes[0][1] + boxes[0][3]) / 2)
if control.label == 'Accept':
assert boxes[1][3] == pytest.approx(boxes[2][3]), 'Speed and unit share a lower edge'
assert (boxes[1][3] - boxes[1][1]) / scale * 25.4 / 300 >= (3.3 if profile == Profile.LARGE else 2.7)
assert heading_centers[0] == pytest.approx(heading_centers[1])
finally:
widget.close()
finally:
@@ -2,6 +2,7 @@
import json
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
import shutil
import subprocess
@@ -70,7 +71,9 @@ def test_every_scene_matches_native_border_mode_and_widget_visibility(target):
expected['steering_wheel'] = not state.lateral_active
expected['max_speed'] = not state.cruise_active
expected['speed_limit'] = str(state.speed_limit.kind) != 'valid'
expected['driver_monitor'] = not monitor_visible(profile, state, fresh=True, onroad=True,
# Before native art arrives, scene rules cover eligibility; native pixels supply prompt occlusion.
eligibility = replace(state, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=None))
expected['driver_monitor'] = not monitor_visible(profile, eligibility, fresh=True, onroad=True,
top_icons=state.cruise_active and layout['max_speed']['enabled'])
assert scene['hidden'] == expected, (target, scene['id'], move)
@@ -47,6 +47,55 @@ def test_widget_art_registry_is_shared_with_native_and_projection_metadata():
assert layout_metadata_for_viewport((2880, 1080))['runtimePreviewWidgets'] == list(RUNTIME_PREVIEW_WIDGETS['large'])
@pytest.mark.parametrize('profile', ['large', 'compact'])
def test_native_prompt_art_parses_in_galaxy_and_reports_obscured_widgets(profile):
import io
from pathlib import Path
import subprocess
from PIL import Image
from openpilot.starpilot.ui.layout_preview_renderer import _Canvas, sample_state
from openpilot.starpilot.ui.onroad_customization import validate_document
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.raylib import rl
document = default_document()
layout = document['layouts'][profile]
if profile == 'compact':
layout['max_speed']['enabled'] = False
layout['speed_limit'].update(x=200, y=20)
x, y, obscured = 200, 0, 'driver_monitor'
else:
layout['cruise_limits']['x'] = 600
x, y, obscured = 900, 80, 'current_speed'
for key in ('clock', 'driving_mode_descriptions'):
layout[key].update(enabled=True, x=x, y=y)
document = validate_document(document)
own_window = not rl.is_window_ready()
if own_window:
rl.init_window(128, 128, 'Native preview contract')
canvas = _Canvas(Profile(profile))
try:
for scene, visible in [('slc_pending', False), ('engaged', True)]:
png = canvas.render_widget_art(sample_state(scene, document), document['layouts'][profile])
with Image.open(io.BytesIO(png)) as image:
native = json.loads(image.info['starpilot-widget-art'])
assert len(native['sprites']) == len(RUNTIME_PREVIEW_WIDGETS[profile])
for key in (obscured, 'clock', 'driving_mode_descriptions'):
assert native['sprites'][key]['visible'] is visible, (scene, key)
parser = Path(__file__).parents[2] / 'galaxy/web/js/widget-art.js'
script = f"""
import {{widgetArtMetadata}} from {json.dumps(parser.as_uri())};
const data = []; for await (const part of process.stdin) data.push(part);
console.log(JSON.stringify(await widgetArtMetadata(new Blob(data))));
"""
result = subprocess.run(['node', '--input-type=module', '-e', script], input=png, capture_output=True, check=True)
assert json.loads(result.stdout) == native
finally:
canvas.close()
if own_window:
rl.close_window()
def test_transparent_render_coverage_exports_straight_alpha_without_darkening():
straight = _straight_rgba(bytes((128, 64, 0, 128, 9, 8, 7, 0, 255, 255, 255, 255)))
assert straight == bytes((255, 128, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255))
@@ -32,7 +32,7 @@ class TestOnroadAxes(unittest.TestCase):
def setUp(self):
for name in ('draw_rectangle_rounded', 'draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'):
self.enterContext(patch('openpilot.starpilot.ui.speed_limit_widget.rl.' + name, new_callable=Mock))
for name in ('circle', 'arrow', 'outline', 'confirmation'):
for name in ('circle', 'arrow', 'outline', 'button'):
self.enterContext(patch('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.' + name, new_callable=Mock))
self.ui = ui_fake()
self.ui.CP = NS(carFingerprint='', openpilotLongitudinalControl=True, pcmCruise=False)
@@ -316,12 +316,11 @@ class TestOnroadAxes(unittest.TestCase):
view.steering_wheel = Mock()
view.compact_hud = Mock()
view.compact_sidebar = Mock()
view._fade = None
with patch.object(onroad.clip, "begin_scissor_mode"), patch.object(onroad.clip, "end_scissor_mode"), \
patch.object(onroad.rl, "draw_rectangle_rec"), patch.object(onroad.rl, "draw_rectangle_gradient_v"), \
patch.object(onroad.rl, "draw_rectangle_lines_ex"), patch.object(onroad.rl, "draw_rectangle"), \
patch.object(onroad.rl, "draw_texture_ex"), patch.object(onroad.rl, "draw_rectangle_rounded_lines_ex") as border, \
patch.object(onroad, "render_corner_hint"), patch.object(view, "_prepare_compact_fade"), \
patch.object(onroad, "render_corner_hint"), \
patch.object(view, "_slc_actions"):
view.render(state)
expected = compact_color if profile == Profile.COMPACT else rgba(axis_status_color(state))
@@ -301,11 +301,11 @@ def test_gauge_box_shrinks_to_its_content_and_saved_positions_keep_the_drawing_i
# Android Auto version 2 layouts move the same way.
viewport = (2880, 1080)
aa = default_layout_for_viewport(viewport)
assert aa['version'] == 5
assert aa['version'] == 6
aa['version'] = 2
aa['widgets'][CEM_GAUGE_WIDGET].update(x=900, y=200)
moved = validate_layout_for_viewport(aa, viewport)
assert moved['version'] == 5 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224)
assert moved['version'] == 6 and (moved['widgets'][CEM_GAUGE_WIDGET]['x'], moved['widgets'][CEM_GAUGE_WIDGET]['y']) == (944, 224)
assert validate_layout_for_viewport(moved, viewport) == moved
@@ -28,7 +28,7 @@ def drawing_guard(monkeypatch):
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.arrow', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.outline', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.circle', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.confirmation', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.button', Mock())
def road(document=None, **changes):
@@ -43,6 +43,57 @@ def test_torque_bar_keeps_its_familiar_customization_label():
assert profile['widgets']['torque_bar']['label'] == 'Torque bar'
def test_v12_portrait_upgrade_preserves_preferences_and_is_idempotent():
import copy
old = default_document()
old['version'] = 12
old['layouts']['compact']['speed_limit'].update(x=330, y=20)
old['layouts']['large']['current_speed'].update(x=700, enabled=False)
old['widgetColors']['large']['current_speed'] = {'text': '#ABCDEF80'}
old['reduceMotion'] = True
before = copy.deepcopy(old)
updated = validate_document(old)
expected = copy.deepcopy(old)
expected['version'] = 13
expected['layouts']['compact']['speed_limit'].update(x=320, y=16)
assert old == before and updated == expected
assert validate_document(updated) == updated
def test_v12_edge_placements_remain_bounded_even_with_confirmation_disabled():
old = default_document()
old['version'] = 12
for layout in old['layouts'].values():
layout['speed_limit_actions']['enabled'] = False
old['layouts']['large']['cruise_limits']['y'] = 882
old['layouts']['compact']['speed_limit'].update(x=358, y=108)
updated = validate_document(old)
assert updated['layouts']['large']['cruise_limits']['y'] == 856
assert updated['layouts']['compact']['speed_limit'] == {'x': 348, 'y': 96, 'enabled': True}
assert validate_document(updated) == updated
assert all(not layout['speed_limit_actions']['enabled'] for layout in updated['layouts'].values())
@pytest.mark.parametrize('separate', [False, True])
def test_v12_source_attachment_follows_the_new_row_without_moving_custom_actions(separate):
old = default_document()
old.update(version=12, speedSources=True, speedSourcesCard=separate)
actions = old['layouts']['large']['speed_limit_actions']
actions['y'] = 367 if separate else 359
updated = validate_document(old)
assert updated['layouts']['large']['speed_limit_actions']['y'] == (389 if separate else 381)
assert validate_document(updated) == updated
actions.update(x=600, y=400)
assert validate_document(old)['layouts']['large']['speed_limit_actions'] == actions
def test_current_portrait_layout_rejects_a_chooser_outside_the_compact_screen():
document = default_document()
document['layouts']['compact']['speed_limit']['x'] = 330
with pytest.raises(ValueError, match='confirmation'):
validate_document(document)
def test_defaults_and_metadata_are_independent_and_valid():
document = default_document()
assert validate_document(document) == document
@@ -92,7 +143,7 @@ def test_source_attachment_saves_real_bounded_positions_without_changing_input(v
assert placement(saved, 'large', 'speed_limit_actions') == actions
assert actions['y'] == card['y'] + widget_size(saved, 'large', 'cruise_limits')[1] + 8
assert actions['y'] + widget_size(saved, 'large', 'speed_limit_actions')[1] <= 1050
assert actions['y'] == (367 if separate else 359)
assert actions['y'] == (389 if separate else 381)
def test_v7_speed_layout_migration_is_bounded_non_mutating_and_idempotent():
@@ -114,12 +165,12 @@ def test_v7_speed_layout_migration_is_bounded_non_mutating_and_idempotent():
original = copy.deepcopy(document)
migrated = validate_document(document)
assert document == original
assert migrated['version'] == 12 and validate_document(migrated) == migrated
assert migrated['layouts']['large']['cruise_limits']['x'] == 1510
assert migrated['version'] == 13 and validate_document(migrated) == migrated
assert migrated['layouts']['large']['cruise_limits']['x'] == 1437
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1510, 'y': 882, 'enabled': True}
assert migrated['layouts']['large']['current_speed'] == original['layouts']['large']['current_speed']
assert migrated['layouts']['large']['cem_gauge'] == {'x': 56, 'y': 536, 'enabled': True}
assert migrated['layouts']['compact']['speed_limit']['x'] == 330
assert migrated['layouts']['compact']['speed_limit']['x'] == 320
assert migrated['layouts']['compact']['max_speed']['y'] == 60
assert migrated['widgetColors'] == original['widgetColors'] and migrated['speedSources']
document['layouts']['large']['cruise_limits']['x'] = 2000
@@ -144,11 +195,11 @@ def test_v8_restores_only_compact_speed_defaults_and_preserves_customizations(cu
original = copy.deepcopy(document)
migrated = validate_document(document)
assert document == original
assert migrated['version'] == 12 and validate_document(migrated) == migrated
assert migrated['version'] == 13 and validate_document(migrated) == migrated
assert migrated['layouts']['large'] == document['layouts']['large']
assert migrated['widgetColors'] == document['widgetColors']
assert migrated['widgetOrder'] == document['widgetOrder']
expected = document['layouts']['compact']['speed_limit'] if custom else {'x': 330, 'y': 20, 'enabled': True}
expected = document['layouts']['compact']['speed_limit'] if custom else {'x': 320, 'y': 16, 'enabled': True}
assert migrated['layouts']['compact']['speed_limit'] == expected
assert migrated['layouts']['compact']['max_speed']['x'] == 0
assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 146, 'y': 44, 'enabled': True}
@@ -164,7 +215,7 @@ def test_v8_compact_speed_widgets_at_old_edges_stay_in_bounds_after_upgrade():
document['layouts']['compact']['speed_limit'].update(x=212, y=158)
migrated = validate_document(document)
assert migrated['layouts']['compact']['max_speed'] == {'x': 0, 'y': 0, 'enabled': True}
assert migrated['layouts']['compact']['speed_limit'] == {'x': 330, 'y': 20, 'enabled': True}
assert migrated['layouts']['compact']['speed_limit'] == {'x': 320, 'y': 16, 'enabled': True}
assert validate_document(migrated) == migrated
@@ -185,7 +236,7 @@ def test_v8_actionless_layout_preserves_customizations_when_adding_compact_actio
original = copy.deepcopy(document)
migrated = validate_document(document)
assert document == original
assert migrated['version'] == 12 and validate_document(migrated) == migrated
assert migrated['version'] == 13 and validate_document(migrated) == migrated
assert migrated['layouts']['large'] == {**original['layouts']['large'], 'speed_limit_actions': large_actions}
assert migrated['layouts']['compact']['speed_limit'] == default_document()['layouts']['compact']['speed_limit']
assert migrated['layouts']['compact']['speed_limit_actions'] == {'x': 146, 'y': 44, 'enabled': True}
@@ -230,7 +281,7 @@ def test_protected_compact_actions_reject_actual_overlap(widget, x, y):
validate_document(document)
@pytest.mark.parametrize("widget,x,y", [("speed_limit", 330, 24), ("max_speed", 0, 0), ("steering_wheel", 78, 174)])
@pytest.mark.parametrize("widget,x,y", [("speed_limit", 320, 23), ("max_speed", 0, 0), ("steering_wheel", 78, 174)])
def test_protected_zone_edge_touch_is_valid(widget, x, y):
document = default_document()
document["layouts"]["compact"][widget].update(x=x, y=y)
@@ -304,7 +355,7 @@ def test_v3_layout_migrates_wheel_size_without_changing_saved_placement_or_color
for profile in ("large", "compact"):
del document["layouts"][profile]["steering_wheel"]["size"]
migrated = validate_document(document)
assert migrated["version"] == 12
assert migrated["version"] == 13
assert migrated["palette"] == document["palette"]
assert migrated["layouts"]["large"]["steering_wheel"] == {"x": 1300, "y": 100, "enabled": False, "size": 192}
assert migrated["layouts"]["compact"]["steering_wheel"] == {"x": 90, "y": 130, "enabled": True, "size": 50}
@@ -356,7 +407,7 @@ def test_malformed_pre_action_cruise_placement_is_rejected():
def test_duplicate_key_cannot_apply_otherwise_valid_placement():
document = default_document()
document["layouts"]["large"]["current_speed"]["x"] = 100
raw = json.dumps(document).replace('"version": 12', '"version": 11, "version": 11').encode()
raw = json.dumps(document).replace('"version": 13', '"version": 11, "version": 11').encode()
with patch("openpilot.starpilot.ui.onroad_customization.read_saved", return_value=(raw, True)):
assert read_customization(Mock()) == default_document()
@@ -372,7 +423,7 @@ def test_v9_large_sign_migration_preserves_customizations_and_clamps_bottom_edge
before = deepcopy(old)
updated = validate_document(old)
assert old == before
assert updated['version'] == 12
assert updated['version'] == 13
assert updated['layouts']['large']['cruise_limits']['y'] == default_document()['layouts']['large']['cruise_limits']['y']
assert updated['layouts']['large']['speed_limit_actions'] == before['layouts']['large']['speed_limit_actions']
assert updated['layouts']['compact'] == before['layouts']['compact']
@@ -482,9 +533,9 @@ def test_large_draw_origins_and_wheel_input_move_together():
with patch("openpilot.starpilot.ui.onroad_large_widgets.draw_control_card") as card, patch.object(rl, "draw_line_ex"):
UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
card.assert_not_called()
assert foreground_calls(fonts.draw)[0].args[3:5] == (292, 109)
assert foreground_calls(fonts.draw)[0].args[3:5] == (292, 107)
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
assert fonts.draw.call_args_list[-2].args[3:5] == (965, 112)
assert foreground_calls(fonts.draw)[-2].args[3:5] == (965, 112)
wheel = SteeringWheelWidget(Path('/unused'))
wheel._texture = Mock()
with patch.object(rl, "draw_circle") as circle, patch.object(rl, "draw_texture_pro"):
@@ -515,10 +566,10 @@ def test_frozen_default_draw_geometry_and_neutral_colors():
UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
card.assert_not_called()
assert fonts.draw.call_args_list[0].args[0] == 'SET'
assert foreground_calls(fonts.draw)[0].args[3:5] == (192, 89)
assert foreground_calls(fonts.draw)[0].args[3:5] == (192, 87)
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(content, state)
assert fonts.draw.call_args_list[-2].args[3:5] == (925, 72)
assert fonts.draw.call_args_list[-1].args[3:5] == pytest.approx((925, 215.2))
assert foreground_calls(fonts.draw)[-2].args[3:5] == (925, 72)
assert foreground_calls(fonts.draw)[-1].args[3:5] == pytest.approx((925, 215.2))
wheel = SteeringWheelWidget(Path('/unused'))
wheel._texture = Mock()
with patch.object(rl, "draw_circle") as circle, patch.object(rl, "draw_texture_pro"):
@@ -541,16 +592,17 @@ def test_compact_us_sign_keeps_camera_visible_at_saved_position(speed):
hud._speed_limit_sign(state)
fill.assert_not_called()
outline.assert_called_once()
assert outline.call_args.args[:2] == (259, 96)
assert outline.call_args.args[:2] == (264, 102)
assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['SPEED', str(speed)]
assert foreground_calls(fonts.draw)[0].args[3:5] == (254, 50)
assert foreground_calls(fonts.draw)[0].args[3:5] == (259, 46)
def test_compact_drag_keeps_animation_state_and_separate_origins():
document = default_document()
document["layouts"]["compact"]["max_speed"].update(x=100, y=10)
document["layouts"]["compact"]["steering_wheel"].update(x=90, y=150)
fonts = NS(profile=Profile.COMPACT, draw=Mock(), vertical_ink=lambda text, role, size: (0, size * .7), measure=lambda text, _role, size: NS(
fonts = NS(profile=Profile.COMPACT, draw=Mock(), vertical_ink=lambda text, role, size: (0, size * .7),
horizontal_ink=lambda text, role, size: (0, len(text) * size * .5), measure=lambda text, _role, size: NS(
width=len(text) * size * .5, height=size * Profile.COMPACT.font_scale))
hud = CompactHudRenderer(Mock(spec=BitmapFonts, **vars(fonts)), Mock())
hud._wheel = Mock()
@@ -558,7 +610,7 @@ def test_compact_drag_keeps_animation_state_and_separate_origins():
with patch.object(hud, "_speed_limit_sign"), patch.object(rl, "draw_circle_gradient"), patch.object(rl, "draw_texture_pro") as wheel:
hud.render(state)
hud.render(state)
assert foreground_calls(fonts.draw)[-1].args[3:5] == (125, 119)
assert foreground_calls(fonts.draw)[-1].args[3:5] == (125, 56)
rect = wheel.call_args.args[2]
assert (rect.x, rect.y) == (115, 175)
alpha = hud._set_speed_alpha.x
@@ -627,7 +679,7 @@ def test_v1_palette_positions_and_visibility_migrate_without_mutating_source():
original = copy.deepcopy(old)
result = decode_document(json.dumps(old).encode())
assert old == original
assert result['version'] == 12
assert result['version'] == 13
assert result['palette'] == old['palette']
for profile, layout in old['layouts'].items():
for key, position in layout.items():
@@ -664,10 +716,10 @@ def test_widget_color_isolation_and_native_text_resolution():
assert rgba(validated, 'text', 'compact', 'max_speed') == (16, 32, 48, 64)
assert rgba(validated, 'text', 'large', 'cruise_limits') == (16, 32, 48, 64)
assert widget_palette(validated, 'compact', 'conditional_mode')['cardFill'] == '#00000000'
fonts = NS(measure=lambda *a, **k: NS(width=10, height=20), vertical_ink=lambda text, role, size: (0., size * .7),
fonts = NS(profile=Profile.LARGE, measure=lambda *a, **k: NS(width=10, height=20), vertical_ink=lambda text, role, size: (0., size * .7),
horizontal_ink=lambda text, role, size: (0., 10.), draw=Mock())
CurrentSpeedHud(Mock(spec=BitmapFonts, **vars(fonts))).render(rl.Rectangle(30, 30, 1800, 1020), road(validated))
actual = fonts.draw.call_args_list[0].args[-1]
actual = foreground_calls(fonts.draw)[0].args[-1]
assert (actual.r, actual.g, actual.b, actual.a) == (170, 187, 204, 221)
@@ -746,7 +798,7 @@ def test_driver_frame_matches_native_preview_and_keeps_face_renderer_separate(mo
def test_small_sign_action_overlap_roundtrips_without_hiding_sign(enabled):
document = default_document()
large = json.loads(json.dumps(document["layouts"]["large"]))
document["layouts"]["compact"]["speed_limit"].update(x=330, y=20, enabled=enabled)
document["layouts"]["compact"]["speed_limit"].update(x=320, y=16, enabled=enabled)
document["layouts"]["compact"]["speed_limit_actions"].update(x=150, y=44)
assert decode_document(json.dumps(validate_document(document))) == document
assert document["layouts"]["large"] == large
@@ -189,7 +189,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt():
renderer._speed_limit_sign(shown)
fill.assert_not_called()
outline.assert_called_once()
assert outline.call_args.args[:2] == (389, 86)
assert outline.call_args.args[:2] == (384, 88)
assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['VISION', '55']
outline.reset_mock()
renderer._speed_limit_sign(replace(shown, speed_limit=SpeedLimitObservation(kind=ObservationKind.STALE)))
@@ -200,7 +200,7 @@ def test_valid_vision_limit_has_persistent_sign_without_action_receipt():
with patch.object(renderer._speed_limit_widget.shapes, 'circle') as circle:
renderer._speed_limit_sign(replace(shown, appearance=replace(shown.appearance, use_vienna_sign=True)))
circle.assert_called_once()
assert [call.args[0] for call in fonts.draw.call_args_list] == ['VISION', '55']
assert [call.args[0] for call in foreground_calls(fonts.draw)] == ['VISION', '55']
def test_pending_confirmation_omits_redundant_speed_heading():
@@ -216,9 +216,10 @@ def test_pending_confirmation_omits_redundant_speed_heading():
view.fonts.horizontal_ink.return_value = (0, 50)
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
with patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rounded'), patch.object(SpeedLimitShapes, 'confirmation'):
with patch.object(rl, 'draw_line_ex'), patch.object(rl, 'draw_rectangle_rounded'), \
patch.object(SpeedLimitShapes, 'button'):
view._slc_actions(state)
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['35', 'mph', 'VISION', 'Accept', 'Reject']
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['New limit', 'Accept', '35', 'mph', 'Reject', 'new limit']
def test_compact_pending_label_and_button_follow_saved_action_placement():
@@ -238,11 +239,12 @@ def test_compact_pending_label_and_button_follow_saved_action_placement():
view.fonts.horizontal_ink.return_value = (0, 50)
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
with patch.object(rl, 'draw_rectangle_rounded') as button, patch.object(rl, 'draw_line_ex'), patch.object(SpeedLimitShapes, 'confirmation'):
with patch.object(rl, 'draw_rectangle_rounded') as button, patch.object(rl, 'draw_line_ex'), \
patch.object(SpeedLimitShapes, 'button'):
view._slc_actions(state)
assert [call.args[0] for call in foreground_calls(view.fonts.draw)][-2:] == ['Accept', 'Reject']
assert button.call_count == 4
assert slc_controls(Profile.COMPACT, state)[0].bounds == (156., 48., 300., 160.)
assert [call.args[0] for call in foreground_calls(view.fonts.draw) if call.args[0] in ('Accept', 'Reject')] == ['Accept', 'Reject']
button.assert_not_called()
assert slc_controls(Profile.COMPACT, state)[0].bounds == (156., 48., 306., 160.)
def test_fresh_physical_blinker_uses_upstream_art_without_optional_border():
@@ -824,6 +824,8 @@ class TestRuntimeSnapshot(unittest.TestCase):
def test_zero_speed_remains_visible_across_signed_filtered_estimates(self):
from openpilot.starpilot.ui.raylib import rl
from openpilot.starpilot.ui.onroad_large_widgets import CurrentSpeedHud
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.tests.test_unified_speed_native import foreground_calls
for metric in (False, True):
ui = ui_fake()
@@ -836,12 +838,13 @@ class TestRuntimeSnapshot(unittest.TestCase):
state = adapter.build(ShellMode.ONROAD, now_ns=NOW).onroad
self.assertEqual(state.speed_mps, max(0.0, speed))
fonts = Mock(spec=BitmapFonts)
fonts.profile = Profile.LARGE
fonts.measure.return_value = NS(width=10, height=20)
fonts.vertical_ink.return_value = (0., 20.)
CurrentSpeedHud(fonts).render(rl.Rectangle(0, 0, 1800, 1020), state)
self.assertEqual(fonts.draw.call_args_list[0].args[0], "1" if speed == 0.44704 and not metric else
self.assertEqual(foreground_calls(fonts.draw)[0].args[0], "1" if speed == 0.44704 and not metric else
str(round(max(0., speed) * (3.6 if metric else 2.2369362921))))
self.assertEqual(fonts.draw.call_args_list[1].args[0], "km/h" if metric else "mph")
self.assertEqual(foreground_calls(fonts.draw)[1].args[0], "km/h" if metric else "mph")
ui.sm.logMonoTime["carState"] = NOW - 2_000_000_000
self.assertIsNone(adapter.build(ShellMode.ONROAD, now_ns=NOW).onroad.speed_mps)
@@ -50,19 +50,19 @@ def test_closed_and_open_card_share_anchor_and_physically_scaled_targets(profile
opened = touch.present(current)
after = speed_limit_bounds(profile, opened)
assert before is not None and after is not None
assert before[1::2] == after[1::2]
assert before[0 if profile == Profile.LARGE else 2] == after[0 if profile == Profile.LARGE else 2]
assert before[1] == after[1] and before[3] <= after[3]
assert after[0] <= before[0] < before[2] <= after[2]
first, last = slc_controls(profile, opened)
scale = CONFIRMATION_SCALE[profile]
assert (first.bounds[2] - first.bounds[0]) / scale == pytest.approx(144)
assert (first.bounds[3] - first.bounds[1]) / scale == pytest.approx(112)
assert (last.bounds[0] - first.bounds[2]) / scale == pytest.approx(8)
assert (first.bounds[2] - first.bounds[0]) / scale == pytest.approx(180 if profile == Profile.LARGE else 150)
assert (first.bounds[3] - first.bounds[1]) / scale == pytest.approx(128 if profile == Profile.LARGE else 112)
assert (last.bounds[0] - first.bounds[2]) / scale == pytest.approx(12)
assert (first.bounds[0] - after[0]) / scale == pytest.approx(8)
assert (after[2] - last.bounds[2]) / scale == pytest.approx(8)
torque = current.customization['layouts'][str(profile)]['torque_bar']
assert after[3] < torque['y']
if profile == Profile.COMPACT:
assert after == (148., 8, 460., 160.)
assert after == (148., 8, 476., 160.)
assert after[0] > 146
@@ -136,19 +136,19 @@ def test_new_layouts_reject_torque_overlap_and_legacy_migration_is_targeted(prof
original = deepcopy(doc)
migrated = validate_document(doc)
assert doc == original
assert migrated['version'] == 12
assert migrated['version'] == 13
assert migrated['palette'] == doc['palette']
assert migrated['layouts']['large']['current_speed'] == doc['layouts']['large']['current_speed']
assert migrated['layouts'][str(profile)][owner]['y'] == default_document()['layouts'][str(profile)][owner]['y']
assert validate_document(migrated) == migrated
def test_legacy_sources_keep_guidance_below_the_row_and_above_the_gauge():
def test_legacy_sources_keep_the_saved_action_area_below_the_row():
doc = default_document()
doc.update(version=11, speedSources=True, speedSourcesCard=True)
doc['layouts']['large']['speed_limit_actions']['y'] = 351
migrated = validate_document(doc)
assert migrated['layouts']['large']['speed_limit_actions']['y'] == 367
assert migrated['layouts']['large']['speed_limit_actions']['y'] == 389
assert validate_document(migrated) == migrated
@@ -0,0 +1,174 @@
"""Controller output and native layouts agree across driver actions and saved layouts."""
from dataclasses import replace
from datetime import datetime
from unittest.mock import Mock
import pytest
from openpilot.starpilot.speed_limits import runtime_settings as settings
from openpilot.starpilot.speed_limits.acceptance import Policy
from openpilot.starpilot.speed_limits.runtime import Action
from openpilot.starpilot.speed_limits.tests import test_runtime_replay as replay
from openpilot.starpilot.speed_limits.tests.test_core_flow import controller, tick
from openpilot.starpilot.ui.onroad_customization import default_document, validate_document
from openpilot.starpilot.ui.onroad import OnroadView
from openpilot.starpilot.ui.onroad_dm import monitor_visible
from openpilot.starpilot.ui.onroad_large_widgets import CurrentSpeedHud, UnifiedSpeedWidget
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
from openpilot.starpilot.ui.onroad_state import OnroadState, speed_limit_from_message
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.raylib import rl
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget, set_speed_status, speed_status
from openpilot.starpilot.ui.tests.test_unified_speed_native import drawing as drawing, fonts, foreground_calls, state
from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed
@pytest.mark.parametrize('mode', [settings.PedalOverride.SPEED_REACHED, settings.PedalOverride.SET_SPEED])
def test_controller_messages_keep_set_stable_through_pedal_release_stop_and_resume(mode):
fixture = replay.RuntimeReplayTests()
fixture.setUp()
sm, cp = fixture.sm, fixture.cp
runtime = controller()
runtime.settings = replace(runtime.settings, pedal_override=mode)
def display(step):
output = tick(runtime, sm, cp, step)
view = OnroadState(True, False, max(0., sm['carState'].vEgoCluster), 100., speed_limit_from_message(output.message.slcState),
metric=True, longitudinal_active=True)
return view, resolve_unified_speed(view)
_, first = display(0)
assert first.limit_in_use and first.max_text == '100'
sm['carState'].vEgo = sm['carState'].vEgoCluster = 95. / 3.6
sm['carState'].gasPressed = True
pressed, shown = display(1)
assert shown.override_text is None and not shown.limit_in_use
assert speed_status(pressed, Profile.COMPACT, shown) == 'PEDAL'
assert set_speed_status(pressed, Profile.COMPACT, shown) is None
sm['carState'].gasPressed = False
_, released = display(2)
assert released.override_text == ('95' if mode is settings.PedalOverride.SPEED_REACHED else '100')
sm['carState'].standstill = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = -.08
_, stopped = display(3)
assert stopped.override_text == released.override_text and stopped.max_text == '100'
sm['carState'].standstill = False
sm['carState'].vEgo = sm['carState'].vEgoCluster = 1.
assert display(4)[1].override_text == released.override_text
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('mode', ('limit', 'override', 'pedal'))
def test_hidden_set_keeps_control_context_inside_moved_confirmation(profile, mode, drawing):
document = default_document()
document['showMaxSpeed'] = False
document['layouts']['compact']['max_speed']['enabled'] = False
document['layouts']['compact']['speed_limit'].update(x=200, y=20)
document = validate_document(document)
current = state(customization=document, slc_prompt_open=True)
current = replace(current, longitudinal_overridden=mode == 'pedal', speed_limit=replace(current.speed_limit,
source='vision', pending_source='vision', speed_limit_mps=110 / 3.6, pending_speed_limit_mps=110 / 3.6,
pending_adjusted_limit_mps=120 / 3.6, presentation_adjusted_limit_mps=120 / 3.6,
limiting_max_set=mode == 'limit', driver_override_active=mode != 'limit',
override_basis='pedal' if mode == 'pedal' else 'persistent', effective_cluster_target_mps=128 / 3.6))
font = fonts(profile)
if profile == Profile.COMPACT:
CompactHudRenderer(font, Mock()).render_max_speed(current)
else:
UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current)
assert not foreground_calls(font.draw)
SpeedLimitWidget(font, profile).render_confirmation(current)
rows = [call.args for call in foreground_calls(font.draw)]
expected = {'limit': 'Now: LIMIT 128 km/h', 'override': 'Now: OVERRIDE 128 km/h', 'pedal': 'Now: PEDAL'}[mode]
assert [row[0] for row in rows] == [expected, 'Accept', '120', 'km/h', 'Reject', 'new limit']
status, accept = rows[:2]
assert status[4] + font.vertical_ink(*status[:3])[1] < accept[4]
assert not monitor_visible(Profile.COMPACT, current, fresh=True, onroad=True, top_icons=False)
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('choice', ('accept', 'reject'))
def test_displayed_proposal_matches_the_decision_during_a_held_override(profile, choice, drawing):
fixture = replay.RuntimeReplayTests()
fixture.setUp()
sm, cp = fixture.sm, fixture.cp
sm['carState'].vCruise = sm['carState'].vCruiseCluster = 130.
sm['carState'].vEgo = sm['carState'].vEgoCluster = 100 / 3.6
sm['slcDashboardObservation'].speedMps = 100 / 3.6
runtime = controller(offset=10 / 3.6)
runtime.settings = replace(runtime.settings, pedal_override=settings.PedalOverride.SPEED_REACHED)
tick(runtime, sm, cp, 0)
sm['carState'].gasPressed = True
sm['carState'].vEgo = sm['carState'].vEgoCluster = 128 / 3.6
tick(runtime, sm, cp, 1)
sm['carState'].gasPressed = False
tick(runtime, sm, cp, 2)
runtime.settings = replace(runtime.settings, acceptance=Policy())
sm['slcDashboardObservation'].speedMps = 110 / 3.6
pending = tick(runtime, sm, cp, 3).message.slcState
current = OnroadState(True, False, 128 / 3.6, 130., speed_limit_from_message(pending),
metric=True, longitudinal_active=True, slc_prompt_open=True)
font = fonts(profile)
SpeedLimitWidget(font, profile).render_confirmation(current)
assert [call.args[0] for call in foreground_calls(font.draw)] == ['Now: OVERRIDE 128 km/h', 'Accept', '120', 'km/h', 'Reject', 'new limit']
assert resolve_unified_speed(current).override_text == '128'
action = Action(runtime.session_id, 1, pending.decisionId, pending.presentationId, choice)
result = tick(runtime, sm, cp, 4, request=action).message.slcState
assert not result.hasPending
assert result.acceptedSpeedLimit == pytest.approx((110 if choice == 'accept' else 100) / 3.6)
assert result.effectiveClusterTarget == pytest.approx((120 if choice == 'accept' else 128) / 3.6)
assert result.driverOverrideActive == (choice == 'reject')
assert sm['carState'].vCruise == 130.
def test_moved_transparent_prompt_suppresses_intersecting_current_speed_only(drawing):
document = default_document()
document['layouts']['large']['cruise_limits']['x'] = 600
current = state(customization=validate_document(document), slc_prompt_open=True, viewport_width=2160)
font = fonts(Profile.LARGE)
hud = CurrentSpeedHud(font)
hud.render(rl.Rectangle(180, 30, 2100, 1020), current)
font.draw.assert_not_called()
clear = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
hud.render(rl.Rectangle(180, 30, 2100, 1020), clear)
assert foreground_calls(font.draw)
font.draw.reset_mock()
hud.render(rl.Rectangle(30, 30, 1800, 1020), state(slc_prompt_open=True))
assert foreground_calls(font.draw), 'The default prompt only intersects layout padding, not current-speed ink.'
@pytest.mark.parametrize('widget,method', [('clock', '_clock'), ('driving_mode_descriptions', '_driving_mode'), ('weather', '_weather')])
def test_hidden_set_prompt_suppresses_only_overlapping_optional_text(widget, method, drawing, monkeypatch):
monkeypatch.setattr('openpilot.starpilot.ui.onroad_weather.draw_weather_icon', Mock())
document = default_document()
document['layouts']['compact']['max_speed']['enabled'] = False
document['layouts']['compact']['speed_limit'].update(x=200, y=20)
document['layouts']['compact'][widget].update(enabled=True, x=330 if widget == 'weather' else 200, y=0)
current = state(customization=validate_document(document), slc_prompt_open=True)
view = OnroadView.__new__(OnroadView)
view.fonts = fonts(Profile.COMPACT)
view.wall_clock = lambda: datetime(2026, 11, 5, 12)
getattr(view, method)(current)
view.fonts.draw.assert_not_called()
clear = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
getattr(view, method)(clear)
assert view.fonts.draw.call_count > 0
def test_projection_monitor_checks_the_same_shifted_bounds_it_draws(drawing):
from openpilot.starpilot.system.starport.projection_layout import default_layout_for_viewport, projection_customization
from openpilot.starpilot.ui.onroad_dm import DriverMonitorLayer
viewport = (2880, 1440)
layout = default_layout_for_viewport(viewport)
layout['widgets']['cruise_limits'].update(x=88, y=1200)
layout['widgets']['driver_monitor'].update(x=270, y=1200)
layout['widgets']['torque_bar']['enabled'] = False
document = projection_customization(layout, default_document())
current = state(customization=document, slc_prompt_open=True)
layer = DriverMonitorLayer.__new__(DriverMonitorLayer)
layer.profile = Profile.LARGE
layer.renderer = Mock(_is_rhd=False)
layer.render(current, monitor=None, driver=None, fresh=False, onroad=True, y_offset=360)
layer.renderer.set_position.assert_called_once_with(270, 1200)
layer.renderer.set_should_draw.assert_called_once_with(False)
layer.renderer._render.assert_not_called()
@@ -13,7 +13,7 @@ from openpilot.starpilot.ui.preview_settings import reference_settings_state
from openpilot.starpilot.ui.shell import ShellInput, ShellMode, ShellSnapshot
from openpilot.starpilot.ui.slc_action_dispatch import SlcActionDispatcher
from openpilot.starpilot.ui.large_speed_geometry import sources_extent
from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, GAP, ACTION_Y, sources_extent
from openpilot.starpilot.ui.onroad_customization import default_document, placement, validate_document
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
from openpilot.starpilot.ui.tests.test_onroad_customization import road
@@ -275,7 +275,7 @@ def test_large_attached_actions_follow_source_row_and_custom_actions_stay_put():
state.customization['speedSources'] = True
state = replace(state, customization=validate_document(state.customization))
after = slc_action_bounds(Profile.LARGE, state)
assert after[0][1] - before[0][1] == sources_extent(state.customization) - 16
assert after[0][1] - before[0][1] == CARD_HEIGHT + sources_extent(state.customization) + GAP - (ACTION_Y - 75)
assert after[0][:2] == tuple(placement(state.customization, Profile.LARGE, 'speed_limit_actions')[key] for key in ('x', 'y'))
state.customization['layouts']['large']['speed_limit_actions'].update(x=600, y=500)
assert slc_action_bounds(Profile.LARGE, state)[0][:2] == (600, 500)
@@ -300,11 +300,12 @@ def test_v10_default_migration_preserves_unrelated_customization_and_optional_wi
def test_large_controls_with_source_card_clear_the_default_cem_gauge():
state = road()
state.customization.update(speedSources=True, speedSourcesCard=True)
state = replace(state, customization=validate_document(state.customization))
bounds = slc_action_bounds(Profile.LARGE, state)
assert bounds[-1][3] < state.customization['layouts']['large']['cem_gauge']['y']
current = replace(state(), slc_prompt_open=True)
current.customization.update(speedSources=True, speedSourcesCard=True)
current = replace(current, customization=validate_document(current.customization))
controls = slc_controls(Profile.LARGE, current)
assert len(controls) == 2
assert controls[-1].bounds[3] < current.customization['layouts']['large']['cem_gauge']['y']
if __name__ == "__main__":
@@ -28,7 +28,7 @@ def test_atlas_matches_generated_geometry_and_has_symmetric_antialiased_edges():
center = alpha[:, width//2]
painted = np.flatnonzero(center > 40)
assert painted[-1] - painted[0] + 1 == len(painted)
if name in ('outline', 'override_outline'):
if name == 'outline':
assert not alpha[18:-18, 18:-18].any(), 'the camera must remain visible inside the sign'
assert np.array_equal(alpha, alpha[::-1]), 'rounded corners must be symmetric'
@@ -48,13 +48,14 @@ def gpu(monkeypatch):
def test_shapes_share_one_upload_and_arrow_is_one_draw_in_either_direction(gpu):
owner = shapes.SpeedLimitShapes()
owner.circle(389, 87, shapes.rl.RED)
assert gpu['draw_texture_pro'].call_count == 2
assert gpu['draw_texture_pro'].call_count == 5
assert all(call.args[1].x == shapes.SPRITES['ring'][0] for call in gpu['draw_texture_pro'].call_args_list)
gpu['draw_texture_pro'].reset_mock()
owner.outline(59, 66, shapes.rl.WHITE)
owner.outline(59, 66, shapes.rl.WHITE)
assert gpu['draw_texture_pro'].call_count == 2
assert gpu['draw_texture_pro'].call_count == 10
_, source, destination, _, _, _ = gpu['draw_texture_pro'].call_args.args
assert (source.width, source.height) == (128, 144)
assert (source.width, source.height) == (136, 152)
assert (destination.x, destination.y) == (59, 66)
for direction, rotation in ((1, 0), (-1, 180)):
gpu['draw_texture_pro'].reset_mock()
@@ -97,30 +98,8 @@ def test_small_arrow_has_larger_ink_at_double_density():
assert ((bounds[2]-bounds[0])/density, (bounds[3]-bounds[1])/density) == (12, 16)
def test_confirmation_arrow_has_the_same_fill_without_the_dark_outline():
with Image.open(shapes.ATLAS_PATH) as atlas:
sprites = {}
for name in ('arrow_compact', 'arrow_confirmation'):
x, y, width, height = shapes.SPRITES[name]
sprites[name] = np.asarray(atlas.crop((x, y, x + width, y + height)))
outlined = sprites['arrow_compact']
plain = sprites['arrow_confirmation']
assert shapes.SPRITE_DENSITIES['arrow_confirmation'] == 2
assert np.all(plain[..., :3] == 255)
assert np.all(plain[..., 3] <= outlined[..., 3])
assert np.any(plain[..., 3] < outlined[..., 3])
assert np.array_equal(plain[..., 3] == 255, outlined[..., 3] == 255)
@pytest.mark.parametrize('direction,rotation', [(1, 0), (-1, 180)])
def test_confirmation_arrow_uses_the_shared_atlas_in_both_directions(gpu, direction, rotation):
owner = shapes.SpeedLimitShapes()
owner.arrow('confirmation', 100, 50, direction, shapes.rl.WHITE, scale=1.1)
_, source, destination, _, drawn_rotation, _ = gpu['draw_texture_pro'].call_args.args
assert (source.x, source.y, source.width, source.height) == (280, 224, 32, 48)
assert (destination.width, destination.height) == pytest.approx((17.6, 26.4))
assert drawn_rotation == rotation
owner.close()
def test_wheel_cues_have_smooth_rounded_edges_and_distinct_symbols():
@@ -141,12 +120,11 @@ def test_wheel_cues_have_smooth_rounded_edges_and_distinct_symbols():
def test_wheel_cues_share_the_confirmation_atlas(gpu):
owner = shapes.SpeedLimitShapes()
owner.confirmation((0, 0, 240, 152))
owner.outline(120, 76, shapes.rl.WHITE)
gpu['draw_texture_pro'].reset_mock()
owner.wheel_key(True, 50, 135.5, scale=1)
owner.wheel_key(False, 160, 135.5, scale=1)
assert gpu['draw_texture_pro'].call_count == 2
assert [call.args[1].x for call in gpu['draw_texture_pro'].call_args_list] == [280, 328]
assert {call.args[1].x for call in gpu['draw_texture_pro'].call_args_list} == {280, 328}
assert all(call.args[2].width == 24 for call in gpu['draw_texture_pro'].call_args_list)
gpu['load_image'].assert_called_once()
gpu['load_texture_from_image'].assert_called_once()
@@ -171,108 +149,36 @@ def test_headless_context_loads_atlas_without_a_window(gpu, monkeypatch):
gpu['load_texture_from_image'].assert_called_once()
def test_short_outline_reuses_the_same_atlas_and_preserves_corner_scale(gpu):
owner = shapes.SpeedLimitShapes()
owner.outline(59, 58, shapes.rl.WHITE, short=True)
assert gpu['draw_texture_pro'].call_count == 2
rows = [call.args for call in gpu['draw_texture_pro'].call_args_list]
assert [(r[1].x, r[1].y, r[1].width, r[1].height) for r in rows] == [(256, 0, 128, 64), (256, 80, 128, 64)]
assert [(r[2].x, r[2].y, r[2].width, r[2].height) for r in rows] == [(59, -6, 128, 64), (59, 58, 128, 64)]
assert all((r[3].x, r[3].y) == (64, 0) for r in rows)
owner.outline(59, 66, shapes.rl.WHITE)
owner.outline(59, 58, shapes.rl.WHITE, short=True)
gpu['load_image'].assert_called_once()
gpu['load_texture_from_image'].assert_called_once()
owner.close()
gpu['unload_texture'].assert_called_once()
@pytest.mark.parametrize('scale', [1, 1.3416407865])
@pytest.mark.parametrize('opened', [False, True])
def test_confirmation_reuses_atlas_and_preserves_corners_without_overlapping_slices(gpu, scale, opened):
owner = shapes.SpeedLimitShapes()
width = (312 if opened else 240) * scale
bounds = (12., 8., 12 + width, 8 + 152 * scale)
owner.confirmation(bounds, opened=opened, strength=1)
rows = [call.args for call in gpu['draw_texture_pro'].call_args_list]
assert len(rows) == (3 if opened else 6)
cap = (16 if opened else 24) * scale
for group in (rows[:3], rows[3:]):
if not group:
continue
left, middle, right = [row[2] for row in group]
assert left.x == pytest.approx(bounds[0])
assert left.width == pytest.approx(cap)
assert middle.x == pytest.approx(left.x + left.width)
assert right.x == pytest.approx(middle.x + middle.width)
assert right.x + right.width == pytest.approx(bounds[2])
assert right.width == pytest.approx(cap)
assert all(row[2].y + row[2].height == pytest.approx(bounds[3]) for row in group)
assert rows[0][2].height == pytest.approx(152 * scale)
if not opened:
assert rows[3][2].y == pytest.approx(bounds[1] + 86 * scale)
owner.confirmation(bounds, opened=opened)
gpu['load_image'].assert_called_once()
gpu['load_texture_from_image'].assert_called_once()
owner.close()
gpu['unload_texture'].assert_called_once()
def test_confirmation_has_translucent_fill_a_defined_rim_and_a_local_pulse():
@pytest.mark.parametrize('profile', ('large', 'compact'))
def test_buttons_have_smooth_symmetric_contours_and_transparent_interiors(profile):
colored_areas = []
with Image.open(shapes.ATLAS_PATH) as atlas:
x, y, w, h = shapes.SPRITES['confirmation']
face = np.asarray(atlas.crop((x, y, x + w, y + h)))
assert face[h // 2, w // 2, 3] == 230
assert face[2, w // 2, 3] > 230
x, y, w, h = shapes.SPRITES['confirmation_pulse']
pulse = np.asarray(atlas.crop((x, y, x + w, y + h)))
assert pulse[2, w // 2, 3] == 0
assert 0 < pulse[-3, w // 2, 3] <= 51
for name in (f'button_{profile}', f'button_{profile}_pressed'):
x, y, width, height = shapes.SPRITES[name]
pixels = np.asarray(atlas.crop((x, y, x + width, y + height)))
alpha = pixels[..., 3]
assert shapes.SPRITE_DENSITIES[name] == 2
assert np.array_equal(pixels, pixels[::-1]) and np.array_equal(pixels, pixels[:, ::-1])
assert not alpha[40:-40, 40:-40].any()
assert np.mean(alpha == 0) > .85
assert ((alpha > 0) & (alpha < 255)).sum() > 500
colored_areas.append(np.count_nonzero((pixels[..., 0] > 128) & (alpha > 0)))
assert colored_areas[1] > colored_areas[0], 'Press feedback strengthens the same contour'
@pytest.mark.parametrize('scale', [1, 1.3416407865])
def test_native_confirmation_blends_once_and_pulses_only_below_the_speed(scale):
rl = shapes.rl
owns_context = not rl.is_window_ready()
if owns_context:
rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN)
rl.init_window(128, 128, 'SLC surface fixture')
target = rl.load_render_texture(460, 240)
def test_buttons_share_one_upload_and_each_uses_one_quad(gpu):
owner = shapes.SpeedLimitShapes()
def capture(bounds, opened, strength, background=(90, 120, 160)):
rl.begin_texture_mode(target)
rl.clear_background(rl.Color(*background, 255))
owner.confirmation(bounds, opened=opened, strength=strength)
rl.end_texture_mode()
image = rl.load_image_from_texture(target.texture)
try:
rl.image_flip_vertical(image)
return np.frombuffer(image.data[:image.width * image.height * 4], dtype=np.uint8).reshape(image.height, image.width, 4).copy()
finally:
rl.unload_image(image)
try:
for opened in (False, True):
width = (312 if opened else 240) * scale
bounds = (16, 16, 16 + width, 16 + 152 * scale)
rest, peak = (capture(bounds, opened, strength) for strength in (0, 1))
assert np.array_equal(rest[:int(16 + 86 * scale)], peak[:int(16 + 86 * scale)])
if opened:
assert np.array_equal(rest, peak)
else:
assert not np.array_equal(rest, peak)
changed = np.any(rest != peak, axis=-1)
assert not changed[:, :16].any() and not changed[:, int(np.ceil(bounds[2])):].any()
assert not changed[int(np.ceil(bounds[3])):].any()
row = int(16 + 76 * scale)
columns = slice(18, int(bounds[2]) - 2)
assert np.ptp(rest[row, columns, :3], axis=0).max() <= 1, 'slices must join without a seam'
lighter = capture(bounds, opened, 0, (190, 220, 255))
difference = lighter[row, int(16 + width / 2), :3].astype(int) - rest[row, int(16 + width / 2), :3]
assert np.all((difference >= 8) & (difference <= 12)), 'the core should transmit about 10% of the camera'
finally:
owner.close()
rl.unload_render_texture(target)
if owns_context:
rl.close_window()
for profile, scale in (('compact', 1.), ('large', 1.342)):
for pressed in (False, True):
gpu['draw_texture_pro'].reset_mock()
owner.button(200, 96, shapes.rl.GREEN, profile=profile, scale=scale, pressed=pressed)
gpu['draw_texture_pro'].assert_called_once()
_, source, destination, origin, _, color = gpu['draw_texture_pro'].call_args.args
assert (source.x, source.y, source.width, source.height) == shapes.SPRITES[f'button_{profile}' + ('_pressed' if pressed else '')]
assert (destination.x, destination.y) == (200, 96)
assert (destination.width, destination.height) == pytest.approx((source.width / 2 * scale, source.height / 2 * scale))
assert (origin.x, origin.y) == pytest.approx((source.width / 4 * scale, source.height / 4 * scale))
assert color == shapes.rl.GREEN
gpu['load_texture_from_image'].assert_called_once()
owner.close()
gpu['unload_texture'].assert_called_once()
@@ -168,6 +168,5 @@ class DrawerNativeTest(unittest.TestCase):
@pytest.fixture(autouse=True)
def mock_sign_graphics(monkeypatch):
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.outline', Mock())
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_shapes.SpeedLimitShapes.confirmation', Mock())
for name in ('draw_rectangle_rounded_lines_ex', 'draw_circle', 'draw_ring'):
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_widget.rl.' + name, Mock())
@@ -52,8 +52,8 @@ def test_v4_migration_preserves_settings_and_clamps_only_enlarged_widgets():
doc['layouts']['large']['speed_limit_actions'].update(x=1654, y=500)
old_speed = dict(doc['layouts']['large']['current_speed'])
migrated = validate_document(doc)
assert migrated['version'] == 12
assert migrated['layouts']['large']['cruise_limits']['x'] == 1539
assert migrated['version'] == 13
assert migrated['layouts']['large']['cruise_limits']['x'] == 1437
assert migrated['layouts']['large']['speed_limit_actions'] == {'x': 1678, 'y': 267, 'enabled': True}
assert migrated['layouts']['large']['current_speed'] == old_speed
assert migrated['speedSources'] is True
@@ -5,6 +5,7 @@ import unittest
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation, ObservationKind
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.large_speed_geometry import LARGE_SIGN_SCALE, SIGN_HEIGHT, LIMIT_RECTS
from openpilot.starpilot.ui.speed_limit_widget import SpeedLimitWidget
from openpilot.starpilot.ui.stop_sign_widget import render_stop_sign, stop_sign_bounds
@@ -16,8 +17,12 @@ class TestStopSignWidget(unittest.TestCase):
def test_approved_full_screen_positions(self):
state = self.state()
self.assertEqual(stop_sign_bounds(Profile.COMPACT, state)[:3], (236, 28, 70.))
self.assertEqual(stop_sign_bounds(Profile.LARGE, state)[:3], (490, 95., 125.))
self.assertEqual(stop_sign_bounds(Profile.COMPACT, state)[:3], (226, 24, 70.))
left, top, size, _ = stop_sign_bounds(Profile.LARGE, state)
x, y, width, _ = LIMIT_RECTS['large']
self.assertEqual(left, x + width + 24)
self.assertAlmostEqual(top + size/2, y + SIGN_HEIGHT/2*LARGE_SIGN_SCALE)
self.assertAlmostEqual(size, 100*LARGE_SIGN_SCALE)
def test_camera_age_expiry_and_missing_observation_hide(self):
state = self.state()
@@ -1,6 +1,5 @@
"""One set speed and a shared, latest-limit sign in the large and compact UIs."""
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
@@ -14,7 +13,7 @@ from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
from openpilot.starpilot.ui.onroad import OnroadView
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation, slc_controls, speed_limit_bounds
from openpilot.starpilot.ui.presentation import FontRole, Profile
from openpilot.starpilot.ui.speed_limit_widget import INK, MUTED, SpeedLimitWidget, draw_text
from openpilot.starpilot.ui.speed_limit_widget import INK, IN_USE, MUTED, REJECT, SpeedLimitWidget, draw_text
from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes
from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps, resolve_unified_speed
@@ -26,7 +25,7 @@ def drawing(monkeypatch):
'draw_ring', 'draw_circle_gradient', 'draw_rectangle_rec', 'draw_circle_v', 'draw_rectangle_gradient_ex'):
calls[name] = Mock()
monkeypatch.setattr(rl, name, calls[name])
for name in ('circle', 'arrow', 'outline', 'confirmation', 'wheel_key', 'override_sign', 'override_slash'):
for name in ('circle', 'arrow', 'outline', 'wheel_key', 'button'):
calls[name] = Mock()
monkeypatch.setattr(SpeedLimitShapes, name, calls[name])
monkeypatch.setattr('openpilot.starpilot.ui.onroad_large_widgets.draw_control_card', Mock())
@@ -90,9 +89,9 @@ def test_sign_keeps_distinct_slc_target_when_set_widget_is_hidden(profile, vienn
effective_cluster_target_mps=45 / 3.6))
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
assert [call.args[0] for call in font.draw.call_args_list] == ['OSM / SLC', '50+5', '45']
drawing['circle' if vienna else 'outline'].assert_called_once()
drawing['override_slash'].assert_not_called()
assert [call.args[0] for call in foreground_calls(font.draw)] == ['Limit', '45', 'OSM 50 +5']
drawing['outline'].assert_called_once()
drawing['circle'].assert_not_called()
@pytest.mark.parametrize('profile', list(Profile))
@@ -107,7 +106,8 @@ def test_set_and_binding_slc_stay_visible_beside_sign_or_prompt(profile, stage):
presentation_adjusted_limit_mps=speed, last_received_speed_limit_mps=speed, last_received_source='dashboard',
pending_speed_limit_mps=25 * CV.MPH_TO_MS if pending else None,
pending_adjusted_limit_mps=25 * CV.MPH_TO_MS if pending else None, pending_source='map',
effective_cluster_target_mps=speed, effective_cap_mps=speed, limiting_max_set=True)
effective_cluster_target_mps=speed, effective_cap_mps=speed, limiting_max_set=True,
accepted_speed_limit_mps=speed, accepted_adjusted_limit_mps=speed, accepted_source='dashboard')
current = replace(current, speed_limit=observation)
font = fonts(profile)
view = slc_view(font)
@@ -121,9 +121,13 @@ def test_set_and_binding_slc_stay_visible_beside_sign_or_prompt(profile, stage):
view.compact_hud._speed_limit_sign(current)
view._slc_actions(current)
texts = [call.args[0] for call in foreground_calls(font.draw)]
assert 'SET' in texts and '35' in texts and texts.count('SLC 30') == 1
assert 'SET' in texts and '35' in texts and not any(text.startswith('SLC ') for text in texts)
assert ('LIMIT 30' in texts) == (stage == 'prompt')
assert ('Now: LIMIT 30 mph' in texts) == (stage == 'expanded')
assert ('Limit' in texts) == (stage in ('live', 'retained'))
if pending:
assert '25' in texts and 'OSM' in texts and not any(text.startswith('LAST') for text in texts)
assert '25' in texts and not any(text.startswith('LAST') for text in texts)
assert ('OSM' in texts) == (stage != 'expanded')
else:
assert ('LAST DASH' if gap else 'DASH') in texts and '30' in texts
@@ -136,7 +140,7 @@ def test_confirmation_arrow_uses_upcoming_target_while_current_reading_stays_cur
font = fonts(profile)
SpeedLimitWidget(font, profile).render_confirmation(current)
assert drawing['arrow'].call_args.args[3] == 1
assert '30' in [call.args[0] for call in font.draw.call_args_list]
assert '30' in [call.args[0] for call in foreground_calls(font.draw)]
assert current.speed_limit.speed_limit_mps == 50 / 3.6
@@ -177,12 +181,12 @@ def test_sign_heading_identifies_the_displayed_source(profile, pending, vienna,
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
expected = [heading, '50']
assert [call.args[0] for call in font.draw.call_args_list] == expected + (['+5'] if offset else []) + (['New Limit'] if pending else [])
assert [call.args[0] for call in foreground_calls(font.draw)] == expected + (['+5'] if offset else []) + (['New Limit', 'Tap to review'] if pending else [])
if not pending:
label, speed = font.draw.call_args_list[:2]
label, speed = foreground_calls(font.draw)[:2]
assert rgba_tuple(label.args[-1]) == rgba_tuple(speed.args[-1])
if vienna and not pending:
assert rgba_tuple(font.draw.call_args_list[0].args[-1]) == (0, 0, 0, 255)
assert rgba_tuple(foreground_calls(font.draw)[0].args[-1]) == (244, 247, 245, 255)
@pytest.mark.parametrize('metric', [False, True])
@@ -197,7 +201,7 @@ def test_compact_sign_omits_missing_zero_and_rounded_zero_offsets(metric, vienna
assert resolve_unified_speed(current).offset_text is None
font = fonts(Profile.COMPACT)
CompactHudRenderer(font, Mock())._speed_limit_sign(current)
assert [call.args[0] for call in font.draw.call_args_list] == ['OSM', '65']
assert [call.args[0] for call in foreground_calls(font.draw)] == ['OSM', '65']
@pytest.mark.parametrize('profile', list(Profile))
@@ -209,14 +213,14 @@ def test_pending_notice_pulses_without_changing_ink_and_arrow_follows_speed(prof
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna))
render_sign(sign, current)
before = {call.args[0]: call.args for call in foreground_calls(font.draw)}
strength = drawing['confirmation'].call_args.kwargs['strength']
assert drawing['arrow'].call_args.args[0] == 'confirmation'
assert drawing['arrow'].call_args.args[0] == 'compact'
assert drawing['arrow'].call_args.args[3] == -1
render_sign(sign, replace(current, observed_ns=10_350_000_000, speed_mps=60 / 3.6))
after = {call.args[0]: call.args for call in foreground_calls(font.draw)}
assert set(before) == {'OSM', '50', '+5', 'New Limit'}
assert all(after[text][3:5] == args[3:5] and rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items())
assert drawing['confirmation'].call_args.kwargs['strength'] != strength
assert set(before) == {'OSM', '50', '+5', 'New Limit', 'Tap to review'}
assert all(after[text][3:5] == args[3:5] for text, args in before.items())
assert all(rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items() if text != 'New Limit')
assert rgba_tuple(after['New Limit'][-1]) != rgba_tuple(before['New Limit'][-1])
drawing['circle'].assert_not_called()
drawing['outline'].assert_not_called()
assert drawing['arrow'].call_args.args[3] == 1
@@ -231,7 +235,7 @@ def test_regular_sign_keeps_the_outlined_arrow(profile, vienna, recovering, draw
speed_limit=replace(current.speed_limit, decision_id=0, sign_recovering=recovering))
font = fonts(profile)
SpeedLimitWidget(font, profile).render(current)
assert font.draw.call_args_list[0].args[0] == ('LAST OSM' if recovering else 'OSM')
assert foreground_calls(font.draw)[0].args[0] == ('LAST OSM' if recovering else 'OSM')
assert drawing['arrow'].call_args.args[0] == 'compact'
@@ -242,6 +246,8 @@ def test_wheel_cues_follow_confirmation_availability(profile, available, drawing
current = state(slc_wheel_confirmation_available=available)
sign = SpeedLimitWidget(fonts(profile), profile)
sign.render_confirmation(current)
drawing['draw_rectangle_rounded_lines_ex'].assert_not_called()
drawing['button'].assert_not_called()
calls = drawing['wheel_key'].call_args_list
assert [call.args[0] for call in calls] == ([True, False] if available else [])
if available:
@@ -266,13 +272,13 @@ def test_sign_text_stays_centered_without_an_arrow(profile, vienna, reason, draw
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
drawing['arrow'].assert_not_called()
for call in font.draw.call_args_list:
for call in foreground_calls(font.draw):
text, role, size, x, _, _ = call.args
if reason in ('equal_speed', 'unknown_speed'):
continue
left, right = font.horizontal_ink(text, role, size)
from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, LARGE_SIGN_SCALE
center = LIMIT_RECTS[profile][0] + 59 * (1 if profile == Profile.COMPACT else LARGE_SIGN_SCALE)
center = LIMIT_RECTS[profile][0] + 64 * (1 if profile == Profile.COMPACT else LARGE_SIGN_SCALE)
assert abs(x + (left + right) / 2 - center) <= .5
@@ -282,40 +288,30 @@ def test_sign_text_stays_centered_without_an_arrow(profile, vienna, reason, draw
def test_override_sign_uses_cluster_target_and_restores_normal_sign(profile, vienna, retained, drawing):
font = fonts(profile)
sign = SpeedLimitWidget(font, profile)
current = state()
current = state(cruise_kph=140)
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna),
speed_limit=replace(current.speed_limit, speed_limit_mps=100 / 3.6,
pending_speed_limit_mps=None, effective_cluster_target_mps=128 / 3.6))
presentation_adjusted_limit_mps=100 / 3.6, pending_speed_limit_mps=None,
effective_cluster_target_mps=128 / 3.6))
if retained:
current = replace(current, speed_limit=replace(current.speed_limit, kind=ObservationKind.ABSENT, source='none',
speed_limit_mps=None, last_received_speed_limit_mps=100 / 3.6, last_received_source='map'))
sign.render(replace(current, longitudinal_overridden=True))
drawing['override_sign'].assert_not_called()
drawing['override_slash'].assert_not_called()
overridden = replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True))
sign.render(replace(overridden, longitudinal_overridden=True))
assert 'Override' not in [call.args[0] for call in foreground_calls(font.draw)]
assert 'Pedal' in [call.args[0] for call in foreground_calls(font.draw)]
font.draw.reset_mock()
sign.render(replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True)))
assert drawing['override_sign'].call_args.kwargs == {'vienna': vienna}
drawing['override_slash'].assert_called_once()
heading, posted, override = [call.args for call in font.draw.call_args_list]
assert (heading[0], posted[0], override[0]) == ('LAST / OVERRIDE' if retained else 'OVERRIDE', '100', '128')
assert heading[1] == FontRole.SEMI_BOLD
assert posted[1] == (FontRole.SEMI_BOLD if vienna else FontRole.BOLD)
assert override[1] == FontRole.BOLD
assert override[2] > posted[2]
assert override[3] > posted[3]
heading_bottom = heading[4] + font.vertical_ink(*heading[:3])[1]
number_top = min(row[4] + font.vertical_ink(*row[:3])[0] for row in (posted, override))
assert number_top - heading_bottom >= (10 if vienna else 13) * sign.scale - 1
if vienna:
centers = [row[4] + sum(font.vertical_ink(*row[:3])) / 2 for row in (posted, override)]
assert abs(centers[0] - centers[1]) <= 1
sign.render(overridden)
rows = [call.args for call in foreground_calls(font.draw)]
assert [row[0] for row in rows] == (['Override', '128', 'LAST OSM', '100'] if retained else ['Override', '128', 'OSM 100'])
assert rows[1][2] > rows[-1][2]
for first, second in zip(rows, rows[1:], strict=False):
assert first[4] + font.vertical_ink(*first[:3])[1] <= second[4] + font.vertical_ink(*second[:3])[0] + 1
drawing['outline'].assert_called()
font.draw.reset_mock()
sign.render(current)
assert font.draw.call_args_list[0].args[0] == ('LAST OSM' if retained else 'OSM')
drawing['override_sign'].assert_called_once()
drawing['override_slash'].assert_called_once()
normal_shape = drawing['circle' if vienna else 'outline']
assert normal_shape.call_args.args[:2] == drawing['override_sign'].call_args.args[:2]
assert foreground_calls(font.draw)[0].args[0] == ('LAST OSM' if retained else 'OSM')
drawing['circle' if vienna else 'outline'].assert_called()
@pytest.mark.parametrize('profile', list(Profile))
@@ -333,7 +329,7 @@ def test_retained_limits_keep_original_source_and_have_no_actions(profile, vienn
current = replace(current, speed_limit=observation, appearance=replace(current.appearance, use_vienna_sign=vienna))
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
texts = [call.args[0] for call in font.draw.call_args_list]
texts = [call.args[0] for call in foreground_calls(font.draw)]
assert texts.count(heading) == 1
assert ('50' if latest_received else '40') in texts
drawing['arrow'].assert_not_called()
@@ -348,7 +344,7 @@ def test_retained_sign_has_no_proposal_arrow_when_actions_are_hidden(profile, dr
last_received_speed_limit_mps=50 / 3.6, last_received_source='map')
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), replace(current, speed_limit=observation))
assert font.draw.call_args_list[0].args[0] == 'LAST OSM'
assert foreground_calls(font.draw)[0].args[0] == 'LAST OSM'
drawing['arrow'].assert_not_called()
@@ -377,20 +373,35 @@ def test_compact_set_speed_stays_visible_during_confirmation_then_fades():
assert alpha < .01
def test_compact_max_restores_dinish_number_label_and_shadow(drawing):
from openpilot.starpilot.ui.presentation import FontRole, font_filename
def test_compact_set_stays_visible_when_pedal_status_moves_into_the_sign():
font = fonts(Profile.COMPACT)
renderer = CompactHudRenderer(font, Mock())
current = state(drive_frame=1, longitudinal_overridden=True)
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None,
driver_override_active=True, override_basis='pedal', limiting_max_set=False))
for now in range(10_000_000_000, 20_000_000_000, 100_000_000):
renderer.render_max_speed(replace(current, observed_ns=now))
assert renderer._set_speed_alpha.x > .99
assert 'SET' in [call.args[0] for call in foreground_calls(font.draw)]
assert 'PEDAL' not in [call.args[0] for call in foreground_calls(font.draw)]
font.draw.reset_mock()
renderer._speed_limit_sign(current)
assert 'Pedal' in [call.args[0] for call in foreground_calls(font.draw)]
def test_compact_set_keeps_dinish_number_and_has_no_backplate(drawing):
from openpilot.starpilot.ui.presentation import font_filename
font = fonts(Profile.COMPACT)
hud = CompactHudRenderer(font, Mock())
current = state(drive_frame=1, cruise_kph=80)
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
hud.render_max_speed(current)
hud.render_max_speed(replace(current, observed_ns=current.observed_ns + 20_000_000))
assert [(call.args[0], call.args[1]) for call in font.draw.call_args_list] == [
('80', FontRole.SET_SPEED), ('MAX', FontRole.SEMI_BOLD)]
assert [(call.args[0], call.args[1]) for call in foreground_calls(font.draw)] == [
('SET', FontRole.MEDIUM), ('km/h', FontRole.NORMAL), ('80', FontRole.SET_SPEED)]
assert font_filename(Profile.COMPACT, FontRole.SET_SPEED) == 'DINish-Speed.fnt'
assert font.draw.call_args_list[0].args[3:5] == (17, -4)
assert font.draw.call_args_list[1].args[2:5] == (36, 25, 109)
drawing['draw_circle_gradient'].assert_called_once()
assert foreground_calls(font.draw)[2].args[4] == 46
drawing['draw_circle_gradient'].assert_not_called()
@pytest.mark.parametrize('metric', [False, True])
@@ -418,38 +429,51 @@ def test_large_limit_keeps_a_unit_when_set_speed_is_hidden():
@pytest.mark.parametrize('profile', list(Profile))
def test_chooser_has_separate_tonal_actions_and_immediate_pressed_feedback(profile, drawing):
view = OnroadView.__new__(OnroadView)
view.personality = Mock()
view.fonts = fonts(profile)
if profile == Profile.LARGE:
view.unified_speed = UnifiedSpeedWidget(view.fonts)
else:
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
@pytest.mark.parametrize('pressed_index', (0, 1))
def test_transparent_chooser_keeps_touch_targets_and_pressed_feedback(profile, pressed_index, drawing, monkeypatch):
view = slc_view(fonts(profile))
current = state(slc_prompt_open=True)
controls = slc_controls(profile, current)
view._slc_actions(current)
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['55', 'km/h', 'OSM 50 +5', 'Accept', 'Reject']
backgrounds = [rgba_tuple(call.args[-1]) for call in drawing['draw_rectangle_rounded'].call_args_list]
assert backgrounds[1] == (35, 74, 57, 255)
assert backgrounds[3] == (77, 46, 57, 255)
bounds = speed_limit_bounds(profile, current)
drawing['confirmation'].assert_called_once_with(bounds, opened=True)
assert bounds is not None
x, y, right, bottom = bounds
panel = rl.Rectangle(x, y, right - x, bottom - y)
accept, _, reject, _ = [call.args[0] for call in drawing['draw_rectangle_rounded'].call_args_list]
margin = accept.x - panel.x
assert margin > 0
assert reject.x - accept.x - accept.width == pytest.approx(margin, abs=.001)
assert panel.x + panel.width - reject.x - reject.width == pytest.approx(margin, abs=.001)
assert panel.y + panel.height - accept.y - accept.height == pytest.approx(margin, abs=.001)
assert accept.y == reject.y and accept.height == reject.height
rows = [call.args for call in foreground_calls(view.fonts.draw)]
assert [row[0] for row in rows] == ['New limit', 'Accept', '55', 'km/h', 'Reject', 'new limit']
drawing['draw_rectangle_rounded'].assert_not_called()
drawing['draw_line_ex'].assert_not_called()
view.pressed_action = lambda: slc_controls(profile, current)[0].request
drawing['draw_rectangle_rounded'].reset_mock()
drawing['draw_rectangle_rounded_lines_ex'].assert_not_called()
outlines = drawing['button'].call_args_list
assert [rgba_tuple(call.args[-1]) for call in outlines] == [rgba_tuple(IN_USE), rgba_tuple(REJECT)]
for control, content in zip(controls, (rows[1:4], rows[4:]), strict=True):
for row in content:
left, top, right, bottom = control.bounds
a, b = view.fonts.horizontal_ink(*row[:3])
c, d = view.fonts.vertical_ink(*row[:3])
assert left < row[3] + a < row[3] + b < right
assert top < row[4] + c < row[4] + d < bottom
original = [rgba_tuple(row[-1]) for row in rows if row[0] in ('Accept', 'Reject')]
view.pressed_action = lambda: controls[pressed_index].request
view.fonts.draw.reset_mock()
drawing['button'].reset_mock()
view._slc_actions(current)
pressed = [rgba_tuple(call.args[-1]) for call in drawing['draw_rectangle_rounded'].call_args_list]
assert pressed[1] != backgrounds[1] and pressed[3] == backgrounds[3]
assert [call.args[:5] for call in foreground_calls(view.fonts.draw)] == [row[:5] for row in rows]
pressed = [rgba_tuple(call.args[-1]) for call in foreground_calls(view.fonts.draw) if call.args[0] in ('Accept', 'Reject')]
assert pressed[pressed_index] != original[pressed_index] and pressed[1-pressed_index] == original[1-pressed_index]
pressed_outlines = drawing['button'].call_args_list
assert [call.kwargs['pressed'] for call in pressed_outlines] == [pressed_index == 0, pressed_index == 1]
assert not any(call.kwargs['pressed'] for call in outlines)
for before, after in zip(outlines, pressed_outlines, strict=True):
assert before.args[:2] == after.args[:2]
assert before.kwargs['scale'] == after.kwargs['scale']
assert rgba_tuple(before.args[-1]) == rgba_tuple(after.args[-1])
assert slc_controls(profile, current) == controls
monkeypatch.setattr('openpilot.starpilot.ui.speed_limit_widget.slc_controls',
lambda *_: tuple(replace(control, enabled=False) for control in controls))
view.fonts.draw.reset_mock()
drawing['button'].reset_mock()
view._slc_actions(current)
assert all(rgba_tuple(call.args[-1]) == rgba_tuple(MUTED) for call in foreground_calls(view.fonts.draw)[1:])
assert all(rgba_tuple(call.args[-1]) == rgba_tuple(MUTED)
for call in drawing['button'].call_args_list)
assert not any(call.kwargs['pressed'] for call in drawing['button'].call_args_list)
@pytest.mark.parametrize('profile', list(Profile))
@@ -460,15 +484,13 @@ def test_text_contour_is_bounded_and_preserves_foreground_opacity(profile, alpha
color = rl.Color(188, 132, 255, alpha)
draw_text(font, '65 +5', FontRole.MEDIUM, 36, 100, 50, color)
calls = [call.args for call in font.draw.call_args_list]
assert len(calls) == 6
assert len(calls) == 5
assert all(call[:3] == ('65 +5', FontRole.MEDIUM, 36) for call in calls)
assert calls[-1][3:5] == (100, 50) and calls[-1][-1] is color
radius = 1.15 if profile == Profile.LARGE else 1.
assert {(round(call[3]-100, 2), round(call[4]-50, 2)) for call in calls[:4]} == {
(-radius, 0), (radius, 0), (0, -radius), (0, radius)}
assert all(call[-1].a == round(alpha*.90) for call in calls[:4])
assert calls[-2][3:5] == (100, 50+(1.5 if profile == Profile.LARGE else 1.2))
assert calls[-2][-1].a == round(alpha*.32)
assert all(call[-1].a == round(alpha*.95) for call in calls[:4])
def test_full_coverage_contour_protects_base_muted_and_peak_purple_on_white():
@@ -476,7 +498,7 @@ def test_full_coverage_contour_protects_base_muted_and_peak_purple_on_white():
def luminance(channels):
linear = [((x/255+.055)/1.055)**2.4 if x/255 > .04045 else x/255/12.92 for x in channels]
return sum(x*weight for x, weight in zip(linear, (.2126, .7152, .0722), strict=True))
for color in (INK, MUTED, rl.Color(188, 132, 255, 255), rl.Color(170, 115, 235, 255)):
for color in (INK, MUTED, IN_USE, REJECT, rl.Color(188, 132, 255, 255), rl.Color(170, 115, 235, 255)):
font = fonts()
draw_text(font, '65', FontRole.BOLD, 70, 100, 50, color)
contour = font.draw.call_args_list[0].args[-1]
@@ -490,18 +512,3 @@ def test_contour_accepts_raylib_standard_color_tuples():
draw_text(font, 'Dash', FontRole.NORMAL, 22, 100, 50, rl.WHITE)
color = font.draw.call_args.args[-1]
assert (color.r, color.g, color.b, color.a) == (255, 255, 255, 255)
def test_retained_compact_fade_still_validates_before_decoding(tmp_path, monkeypatch):
from openpilot.starpilot.ui.onroad import _load_fade
directory = Path(__file__).parents[3] / 'selfdrive/assets'
path = directory / 'icons_mici/onroad/onroad_fade.png'
decoder = Mock(return_value=SimpleNamespace(id=1))
monkeypatch.setattr(rl, 'load_texture', decoder)
assert _load_fade(path) is decoder.return_value
decoder.reset_mock()
invalid = tmp_path / path.name
invalid.write_bytes(b'invalid PNG')
with pytest.raises(ValueError, match='Unreviewed'):
_load_fade(invalid)
decoder.assert_not_called()
@@ -116,3 +116,32 @@ def test_confirmation_proposal_uses_the_pending_target_and_its_offset(pending, a
observation = replace(current.speed_limit, pending_speed_limit_mps=pending, pending_adjusted_limit_mps=adjusted,
presentation_adjusted_limit_mps=presentation, offset_mps=offset)
assert resolve_unified_speed(replace(current, speed_limit=observation)).proposal_text == expected
@pytest.mark.parametrize('change', ['none', 'retained', 'hold', 'recovering', 'pending', 'source', 'rounded-posted',
'rounded-target', 'pedal', 'override', 'display-only', 'not-limiting', 'disengaged'])
def test_in_use_requires_fresh_accepted_source_and_actual_control(change):
current = state()
obs = replace(current.speed_limit, accepted_speed_limit_mps=30., accepted_adjusted_limit_mps=32.,
accepted_source='vision', effective_cluster_target_mps=32., limiting_max_set=True)
changes = {
'retained': {'kind': ObservationKind.STALE, 'speed_limit_mps': None, 'last_received_speed_limit_mps': 30.},
'hold': {'status': 'display_hold'}, 'recovering': {'sign_recovering': True},
'pending': {'pending_speed_limit_mps': 30.}, 'source': {'accepted_source': 'dashboard'},
'rounded-posted': {'accepted_speed_limit_mps': 30.01},
'rounded-target': {'effective_cluster_target_mps': 32.01},
'override': {'driver_override_active': True}, 'display-only': {'action_enabled': False},
'not-limiting': {'limiting_max_set': False},
}
current = replace(current, speed_limit=replace(obs, **changes.get(change, {})),
longitudinal_overridden=change == 'pedal', longitudinal_active=change != 'disengaged')
assert resolve_unified_speed(current).limit_in_use == (change == 'none')
def test_pedal_basis_cannot_be_presented_as_a_held_override_between_message_updates():
current = state()
current = replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True,
effective_cluster_target_mps=26., override_basis='pedal'))
assert resolve_unified_speed(current).override_text is None
released = replace(current, speed_limit=replace(current.speed_limit, override_basis='persistent'))
assert resolve_unified_speed(released).override_text == '94'
@@ -83,4 +83,4 @@ def test_vision_sign_and_slc_cap_use_configured_units_without_rewriting_set(prof
texts = render(output.message.slcState, car.vCruise)
assert str(number) in texts
assert str(round(90. if metric else 90. * CV.KPH_TO_MPH)) in texts
assert ('SET' if profile == Profile.LARGE else 'MAX') in texts
assert 'SET' in texts
@@ -20,6 +20,7 @@ class UnifiedSpeedPresentation:
override_text: str | None
slc_target_text: str | None = None
posted_retained: bool = False
limit_in_use: bool = False
@property
def max_status(self) -> str:
@@ -105,12 +106,12 @@ def resolve_unified_speed(state, *, confirmation=False) -> UnifiedSpeedPresentat
status = 'Unavailable'
elif not obs.action_enabled:
status = 'Display only'
elif state.longitudinal_overridden:
status = 'Using accelerator'
elif obs.driver_override_active:
status = 'Set-speed override'
elif pending:
status = 'Adopting limit' if getattr(obs, 'automatic_adoption_pending', False) else 'Confirm speed limit'
elif state.longitudinal_overridden:
status = 'Using accelerator'
elif state.cruise_active and slc_limiting:
status = 'Using speed limit'
elif state.cruise_active:
@@ -124,12 +125,20 @@ def resolve_unified_speed(state, *, confirmation=False) -> UnifiedSpeedPresentat
proposal = obs.pending_adjusted_limit_mps or (
target if obs.pending_speed_limit_mps == posted else obs.pending_speed_limit_mps + (obs.offset_mps or 0))
active_target = obs.effective_cluster_target_mps
# A matching displayed number alone does not establish that this source is controlling.
limit_in_use = bool(slc_limiting and not pending and not posted_retained and not obs.sign_recovering and
positive(posted) and positive(target) and positive(active_target) and
positive(obs.accepted_speed_limit_mps) and source == obs.accepted_source and
math.isclose(posted, obs.accepted_speed_limit_mps, abs_tol=.001) and
math.isclose(target, active_target, abs_tol=.001))
return UnifiedSpeedPresentation(mode, max_text, speed(posted), offset, 'km/h' if state.metric else 'mph',
source, pending,
('limit' if slc_limiting else 'max') if state.cruise_active else 'none',
speed(target), status,
speed(proposal),
speed(obs.effective_cluster_target_mps) if obs.driver_override_active and
obs.action_enabled and state.longitudinal_active and not state.longitudinal_overridden and
obs.override_basis != 'pedal' and
positive(obs.effective_cluster_target_mps) else None,
speed(active_target) if slc_limiting and positive(active_target) else None,
posted_retained)
posted_retained, limit_in_use)