diff --git a/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs b/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs index 73b54094a1..77db6bbeab 100644 --- a/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs +++ b/openpilot/starpilot/galaxy/tests/test_layout_preview_visibility.mjs @@ -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) diff --git a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs index 0b20fda007..e8f991c135 100644 --- a/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs +++ b/openpilot/starpilot/galaxy/tests/test_native_navigation_widgets.mjs @@ -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}) diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs index 11e73194e6..17db3af6e7 100644 --- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs @@ -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) +} diff --git a/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs b/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs index 94f334a39e..26684fefa2 100644 --- a/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs @@ -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") diff --git a/openpilot/starpilot/galaxy/tests/test_widget_art.mjs b/openpilot/starpilot/galaxy/tests/test_widget_art.mjs index 7cc1efafb3..27aae553f0 100644 --- a/openpilot/starpilot/galaxy/tests/test_widget_art.mjs +++ b/openpilot/starpilot/galaxy/tests/test_widget_art.mjs @@ -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') diff --git a/openpilot/starpilot/galaxy/web/js/layout-preview.js b/openpilot/starpilot/galaxy/web/js/layout-preview.js index deb0271762..25e881ad47 100644 --- a/openpilot/starpilot/galaxy/web/js/layout-preview.js +++ b/openpilot/starpilot/galaxy/web/js/layout-preview.js @@ -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 diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js index ab4ad8318c..812d2b7f4e 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js @@ -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)) || diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js index 77d2e7818e..f4ecc20eb0 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -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 diff --git a/openpilot/starpilot/galaxy/web/js/projection-layout.js b/openpilot/starpilot/galaxy/web/js/projection-layout.js index 103016d42d..0b1a7d7237 100644 --- a/openpilot/starpilot/galaxy/web/js/projection-layout.js +++ b/openpilot/starpilot/galaxy/web/js/projection-layout.js @@ -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, diff --git a/openpilot/starpilot/galaxy/web/js/widget-art.js b/openpilot/starpilot/galaxy/web/js/widget-art.js index 7a9d8fe5de..7abd810e64 100644 --- a/openpilot/starpilot/galaxy/web/js/widget-art.js +++ b/openpilot/starpilot/galaxy/web/js/widget-art.js @@ -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 } diff --git a/openpilot/starpilot/speed_limits/README.md b/openpilot/starpilot/speed_limits/README.md index ff33b71453..4c20676c62 100644 --- a/openpilot/starpilot/speed_limits/README.md +++ b/openpilot/starpilot/speed_limits/README.md @@ -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 diff --git a/openpilot/starpilot/system/starport/projection_layout.py b/openpilot/starpilot/system/starport/projection_layout.py index 57c936995f..dd10445ec2 100644 --- a/openpilot/starpilot/system/starport/projection_layout.py +++ b/openpilot/starpilot/system/starport/projection_layout.py @@ -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: diff --git a/openpilot/starpilot/system/starport/projection_onroad.py b/openpilot/starpilot/system/starport/projection_onroad.py index 52a8a8181d..69793b237b 100644 --- a/openpilot/starpilot/system/starport/projection_onroad.py +++ b/openpilot/starpilot/system/starport/projection_onroad.py @@ -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.""" diff --git a/openpilot/starpilot/system/starport/tests/test_projection_layout.py b/openpilot/starpilot/system/starport/tests/test_projection_layout.py index 50352929a6..0f01bb4513 100644 --- a/openpilot/starpilot/system/starport/tests/test_projection_layout.py +++ b/openpilot/starpilot/system/starport/tests/test_projection_layout.py @@ -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') diff --git a/openpilot/starpilot/ui/assets/README.md b/openpilot/starpilot/ui/assets/README.md index 09bf28f735..41ed294f7d 100644 --- a/openpilot/starpilot/ui/assets/README.md +++ b/openpilot/starpilot/ui/assets/README.md @@ -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. diff --git a/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py b/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py index f9a7fb0bf4..ead8f81541 100644 --- a/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py +++ b/openpilot/starpilot/ui/assets/generate_speed_limit_shapes.py @@ -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 diff --git a/openpilot/starpilot/ui/assets/speed_limit_shapes.png b/openpilot/starpilot/ui/assets/speed_limit_shapes.png index 3d2b865c89..ef51d4ec8f 100644 Binary files a/openpilot/starpilot/ui/assets/speed_limit_shapes.png and b/openpilot/starpilot/ui/assets/speed_limit_shapes.png differ diff --git a/openpilot/starpilot/ui/large_speed_geometry.py b/openpilot/starpilot/ui/large_speed_geometry.py index dc18130f6a..fe53aa512f 100644 --- a/openpilot/starpilot/ui/large_speed_geometry.py +++ b/openpilot/starpilot/ui/large_speed_geometry.py @@ -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): diff --git a/openpilot/starpilot/ui/layout_preview_renderer.py b/openpilot/starpilot/ui/layout_preview_renderer.py index ce58109988..b9e73c8858 100644 --- a/openpilot/starpilot/ui/layout_preview_renderer.py +++ b/openpilot/starpilot/ui/layout_preview_renderer.py @@ -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()) diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py index 6c9ff85e42..ede6b46564 100644 --- a/openpilot/starpilot/ui/onroad.py +++ b/openpilot/starpilot/ui/onroad.py @@ -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 diff --git a/openpilot/starpilot/ui/onroad_compact_widgets.py b/openpilot/starpilot/ui/onroad_compact_widgets.py index 724be52fdf..67f3b869d0 100644 --- a/openpilot/starpilot/ui/onroad_compact_widgets.py +++ b/openpilot/starpilot/ui/onroad_compact_widgets.py @@ -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) diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py index 55dfb98c1a..5226ec73f3 100644 --- a/openpilot/starpilot/ui/onroad_customization.py +++ b/openpilot/starpilot/ui/onroad_customization.py @@ -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) diff --git a/openpilot/starpilot/ui/onroad_dm.py b/openpilot/starpilot/ui/onroad_dm.py index 699a04f4b6..1e659debde 100644 --- a/openpilot/starpilot/ui/onroad_dm.py +++ b/openpilot/starpilot/ui/onroad_dm.py @@ -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() diff --git a/openpilot/starpilot/ui/onroad_large_widgets.py b/openpilot/starpilot/ui/onroad_large_widgets.py index 61d5e3ff42..7e454aba77 100644 --- a/openpilot/starpilot/ui/onroad_large_widgets.py +++ b/openpilot/starpilot/ui/onroad_large_widgets.py @@ -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) + diff --git a/openpilot/starpilot/ui/speed_limit_shapes.py b/openpilot/starpilot/ui/speed_limit_shapes.py index 2e2036079c..217f684af9 100644 --- a/openpilot/starpilot/ui/speed_limit_shapes.py +++ b/openpilot/starpilot/ui/speed_limit_shapes.py @@ -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): diff --git a/openpilot/starpilot/ui/speed_limit_widget.py b/openpilot/starpilot/ui/speed_limit_widget.py index 9d6a5b79bc..95aebcb62e 100644 --- a/openpilot/starpilot/ui/speed_limit_widget.py +++ b/openpilot/starpilot/ui/speed_limit_widget.py @@ -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) diff --git a/openpilot/starpilot/ui/stop_sign_widget.py b/openpilot/starpilot/ui/stop_sign_widget.py index dab3248015..f7189a6773 100644 --- a/openpilot/starpilot/ui/stop_sign_widget.py +++ b/openpilot/starpilot/ui/stop_sign_widget.py @@ -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) diff --git a/openpilot/starpilot/ui/tests/test_appearance_settings.py b/openpilot/starpilot/ui/tests/test_appearance_settings.py index c9db2c574b..6bcff27e00 100644 --- a/openpilot/starpilot/ui/tests/test_appearance_settings.py +++ b/openpilot/starpilot/ui/tests/test_appearance_settings.py @@ -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() diff --git a/openpilot/starpilot/ui/tests/test_compact_sign_height.py b/openpilot/starpilot/ui/tests/test_compact_sign_height.py index 2780182766..79f9382748 100644 --- a/openpilot/starpilot/ui/tests/test_compact_sign_height.py +++ b/openpilot/starpilot/ui/tests/test_compact_sign_height.py @@ -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() diff --git a/openpilot/starpilot/ui/tests/test_developer_preview_render.py b/openpilot/starpilot/ui/tests/test_developer_preview_render.py index afa43b0672..8870d7387f 100644 --- a/openpilot/starpilot/ui/tests/test_developer_preview_render.py +++ b/openpilot/starpilot/ui/tests/test_developer_preview_render.py @@ -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)) diff --git a/openpilot/starpilot/ui/tests/test_dinish_speed.py b/openpilot/starpilot/ui/tests/test_dinish_speed.py index 81a059f876..b179369b75 100644 --- a/openpilot/starpilot/ui/tests/test_dinish_speed.py +++ b/openpilot/starpilot/ui/tests/test_dinish_speed.py @@ -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: diff --git a/openpilot/starpilot/ui/tests/test_layout_scenes.py b/openpilot/starpilot/ui/tests/test_layout_scenes.py index 036e66061e..2050a75e19 100644 --- a/openpilot/starpilot/ui/tests/test_layout_scenes.py +++ b/openpilot/starpilot/ui/tests/test_layout_scenes.py @@ -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) diff --git a/openpilot/starpilot/ui/tests/test_layout_widget_art.py b/openpilot/starpilot/ui/tests/test_layout_widget_art.py index 5d304c2195..7888d7318e 100644 --- a/openpilot/starpilot/ui/tests/test_layout_widget_art.py +++ b/openpilot/starpilot/ui/tests/test_layout_widget_art.py @@ -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)) diff --git a/openpilot/starpilot/ui/tests/test_onroad_axes.py b/openpilot/starpilot/ui/tests/test_onroad_axes.py index a7a825f8b2..b10eb1fe29 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_axes.py +++ b/openpilot/starpilot/ui/tests/test_onroad_axes.py @@ -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)) diff --git a/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py b/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py index 3afe785bb3..b0374c7a49 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py +++ b/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py @@ -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 diff --git a/openpilot/starpilot/ui/tests/test_onroad_customization.py b/openpilot/starpilot/ui/tests/test_onroad_customization.py index 214499f67e..edc14668ce 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_customization.py +++ b/openpilot/starpilot/ui/tests/test_onroad_customization.py @@ -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 diff --git a/openpilot/starpilot/ui/tests/test_onroad_parity.py b/openpilot/starpilot/ui/tests/test_onroad_parity.py index 5d62851419..262c19dfed 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_parity.py +++ b/openpilot/starpilot/ui/tests/test_onroad_parity.py @@ -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(): diff --git a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py index 5a198e6183..20f56ca916 100644 --- a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py +++ b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py @@ -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) diff --git a/openpilot/starpilot/ui/tests/test_slc_confirmation.py b/openpilot/starpilot/ui/tests/test_slc_confirmation.py index 10ec5e6f4d..36c7dfae2d 100644 --- a/openpilot/starpilot/ui/tests/test_slc_confirmation.py +++ b/openpilot/starpilot/ui/tests/test_slc_confirmation.py @@ -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 diff --git a/openpilot/starpilot/ui/tests/test_slc_hud_contract.py b/openpilot/starpilot/ui/tests/test_slc_hud_contract.py new file mode 100644 index 0000000000..8883e29a95 --- /dev/null +++ b/openpilot/starpilot/ui/tests/test_slc_hud_contract.py @@ -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() diff --git a/openpilot/starpilot/ui/tests/test_slc_ui_actions.py b/openpilot/starpilot/ui/tests/test_slc_ui_actions.py index 23eecff95f..22348d2c73 100644 --- a/openpilot/starpilot/ui/tests/test_slc_ui_actions.py +++ b/openpilot/starpilot/ui/tests/test_slc_ui_actions.py @@ -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__": diff --git a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py index 0006b96a77..a310b338a6 100644 --- a/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py +++ b/openpilot/starpilot/ui/tests/test_speed_limit_shapes.py @@ -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() diff --git a/openpilot/starpilot/ui/tests/test_speed_source_drawer_native.py b/openpilot/starpilot/ui/tests/test_speed_source_drawer_native.py index 749be8cf21..920bf810f0 100644 --- a/openpilot/starpilot/ui/tests/test_speed_source_drawer_native.py +++ b/openpilot/starpilot/ui/tests/test_speed_source_drawer_native.py @@ -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()) diff --git a/openpilot/starpilot/ui/tests/test_speed_stack.py b/openpilot/starpilot/ui/tests/test_speed_stack.py index 6aab90044d..30839d73c4 100644 --- a/openpilot/starpilot/ui/tests/test_speed_stack.py +++ b/openpilot/starpilot/ui/tests/test_speed_stack.py @@ -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 diff --git a/openpilot/starpilot/ui/tests/test_stop_sign_widget.py b/openpilot/starpilot/ui/tests/test_stop_sign_widget.py index fa0c758e0c..29efa02c40 100644 --- a/openpilot/starpilot/ui/tests/test_stop_sign_widget.py +++ b/openpilot/starpilot/ui/tests/test_stop_sign_widget.py @@ -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() diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_native.py b/openpilot/starpilot/ui/tests/test_unified_speed_native.py index 7346426a61..034a29562b 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_native.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_native.py @@ -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() diff --git a/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py b/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py index 7350bc7f74..67abf4102c 100644 --- a/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py +++ b/openpilot/starpilot/ui/tests/test_unified_speed_presentation.py @@ -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' diff --git a/openpilot/starpilot/ui/tests/test_vision_speed_units.py b/openpilot/starpilot/ui/tests/test_vision_speed_units.py index f40f0be9b6..5bc2a14115 100644 --- a/openpilot/starpilot/ui/tests/test_vision_speed_units.py +++ b/openpilot/starpilot/ui/tests/test_vision_speed_units.py @@ -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 diff --git a/openpilot/starpilot/ui/unified_speed_presentation.py b/openpilot/starpilot/ui/unified_speed_presentation.py index c86afb5749..9d0f2564fb 100644 --- a/openpilot/starpilot/ui/unified_speed_presentation.py +++ b/openpilot/starpilot/ui/unified_speed_presentation.py @@ -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)