From 96f9af0166fc71be692db33f8294e88296997702 Mon Sep 17 00:00:00 2001 From: dirwin31 <83434411+dirwin31@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:21:47 -0700 Subject: [PATCH] Combine Speed Sources into card. allow drawer, allow L, C, R alignment --- .../galaxy/tests/test_onroad_layout.mjs | 28 ++++- .../galaxy/tests/test_projection_layout.mjs | 4 +- .../galaxy/web/js/onroad-layout-document.js | 6 +- .../starpilot/galaxy/web/js/onroad-layout.js | 44 +++++++- .../galaxy/web/js/projection-layout.js | 4 +- .../system/android_auto/projection_layout.py | 13 ++- .../tests/test_projection_layout.py | 4 + .../starpilot/ui/large_speed_geometry.py | 78 +------------ .../starpilot/ui/layout_preview_renderer.py | 15 +-- openpilot/starpilot/ui/onroad.py | 2 - .../starpilot/ui/onroad_customization.py | 20 +++- .../starpilot/ui/onroad_large_widgets.py | 105 +++++++----------- openpilot/starpilot/ui/onroad_state.py | 5 +- openpilot/starpilot/ui/shell.py | 2 - openpilot/starpilot/ui/speed_source_drawer.py | 17 --- .../starpilot/ui/speed_source_drawer_model.py | 28 ----- .../starpilot/ui/tests/test_dinish_speed.py | 31 ++---- .../ui/tests/test_layout_preview_renderer.py | 4 +- .../ui/tests/test_onroad_cem_gauge.py | 22 +--- .../ui/tests/test_speed_source_drawer.py | 23 ---- .../tests/test_speed_source_drawer_native.py | 45 +++++--- .../starpilot/ui/tests/test_speed_stack.py | 54 --------- 22 files changed, 196 insertions(+), 358 deletions(-) delete mode 100644 openpilot/starpilot/ui/speed_source_drawer.py delete mode 100644 openpilot/starpilot/ui/speed_source_drawer_model.py delete mode 100644 openpilot/starpilot/ui/tests/test_speed_source_drawer.py diff --git a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs index 417ec511ee..d990a61115 100644 --- a/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_onroad_layout.mjs @@ -487,14 +487,36 @@ assert.deepEqual(vm.sceneGroups.flatMap(group => group.scenes.map(scene => scene assert.match(OnroadLayoutPage.template, / [id, { ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }])) -const doc = { version: 2, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, canvas: { width: 2880, height: 1080 }, widgets } +const doc = { version: 2, clock24Hour: false, largeUiGammaTrial: false, blindSpotBorder: false, signalBorder: false, blindSpotCamera: false, hidePersonalityAlerts: false, wheelPedalFeedback: false, leadIndicator: false, leadInfo: "Off", speedSources: false, speedSourcesCard: false, speedAlign: "left", canvas: { width: 2880, height: 1080 }, widgets } const raw = { version: 1, document: doc, defaults: copy(doc), metadata, screen: { width: 1280, height: 720, margin_width: 0, margin_height: 240 }, revision: "aa-screen-and-layout", editable: true, valid: true, @@ -221,7 +221,7 @@ draft.layouts.large.current_speed.x += 20 assert.equal(await feed.save(draft), true) assert.equal(requests[0].url, "./api/android-auto/layout") assert.equal(requests[1].body.document.version, 2) -assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedSources", "version", "wheelPedalFeedback", "widgets"]) +assert.deepEqual(Object.keys(requests[1].body.document).sort(), ["blindSpotBorder", "blindSpotCamera", "canvas", "clock24Hour", "hidePersonalityAlerts", "largeUiGammaTrial", "leadIndicator", "leadInfo", "signalBorder", "speedAlign", "speedSources", "speedSourcesCard", "version", "wheelPedalFeedback", "widgets"]) assert.equal(requests[1].body.document.widgets.current_speed.x, 1170) assert.equal(updates.at(-1).notice, "Android Auto layout saved.") feed.stop() diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js index e94fca492e..f9d50a8a29 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js @@ -105,13 +105,17 @@ export function previewPoint(event, rect, profile) { } // Android Auto display switches, independent of the comma screen's preferences. +// Text alignment inside the cruise and speed-limit card, matching onroad_customization.SPEED_ALIGNMENTS. +export const SPEED_ALIGNMENTS = ["left", "center", "right"] export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"] export function validDocument(document, metadata) { if (!object(document)) return false const profiles = metadata?.projection === true ? ["large"] : PROFILES - if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 6 || + if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 6 || (Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") || + (Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") || + (Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) || (Object.hasOwn(document, "clock24Hour") && typeof document.clock24Hour !== "boolean") || (Object.hasOwn(document, "showMaxSpeed") && typeof document.showMaxSpeed !== "boolean") || (Object.hasOwn(document, "display") && (!object(document.display) || !Object.values(document.display).every((value) => typeof value === "string"))) || diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js index 6152213367..8b0fa95d6b 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -368,7 +368,13 @@ export const OnroadLayoutPage = { modalLeavePrompt() { return this.pendingLeave && this.state.leavePresentation === "modal" }, saveAvailable() { return !!this.state.data?.editable && !this.state.error && !this.state.needsReload && this.dirty && !this.state.drag && !this.state.layerDrag }, canSavePending() { return this.saveAvailable && !this.busy }, - profile() { return this.state.data?.metadata.profiles[this.state.profile] }, + profile() { + const profile = this.state.data?.metadata.profiles[this.state.profile], cruise = profile?.widgets?.cruise_limits + // Speed-limit sources draw inside the cruise card or as a small card under it, so its box grows to match. + if (!cruise?.sourcesExtent || this.state.draft?.speedSources !== true) return profile + const grow = this.state.draft.speedSourcesCard === true ? cruise.sourcesExtent.card : cruise.sourcesExtent.inline + return { ...profile, widgets: { ...profile.widgets, cruise_limits: { ...cruise, height: cruise.height + grow } } } + }, sceneGroups() { return [["Driving", scene => !/^(cem|slc)_/.test(scene.id)], ["Conditional experimental", scene => scene.id.startsWith("cem_")], ["Speed limit", scene => scene.id.startsWith("slc_")]].map(([label, test]) => ({ label, scenes: this.scenes.filter(test) })) @@ -788,10 +794,26 @@ export const OnroadLayoutPage = { this.recordChange(before) this.state.notice = "" }, - setSpeedSources(event) { - if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits") return + setSpeedSources(key, value) { + if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" || + !["speedSources", "speedSourcesCard"].includes(key)) return + const before = clone(this.state.draft), oldHeight = this.profile.widgets.cruise_limits.height + this.state.draft[key] = value === true + const card = this.layout.cruise_limits, actions = this.layout.speed_limit_actions, height = this.profile.widgets.cruise_limits.height + // Actions that sat right under the card follow its new bottom; both stay on screen. + if (actions && actions.x === card.x && actions.y === card.y + oldHeight + 22) actions.y = card.y + height + 22 + for (const id of ["cruise_limits", "speed_limit_actions"].filter(id => this.layout[id])) { + const limits = placementLimits(this.profile, id, this.layout) + this.layout[id].y = Math.max(limits.minY, Math.min(this.layout[id].y, limits.maxY)) + } + this.recordChange(before) + this.state.notice = "" + }, + setSpeedAlign(align) { + if (!this.editable || this.state.drag || this.state.layerDrag || this.selectedWidget?.kind !== "cruise_limits" || + !["left", "center", "right"].includes(align)) return const before = clone(this.state.draft) - this.state.draft.speedSources = event.target.checked + this.state.draft.speedAlign = align this.recordChange(before) this.state.notice = "" }, @@ -1384,7 +1406,19 @@ export const OnroadLayoutPage = {

The default position follows the driver’s side. A moved position stays where you place it.

{{ selectedWidget.note }}

- +
+ Text alignment +
+ +
+
+ + diff --git a/openpilot/starpilot/galaxy/web/js/projection-layout.js b/openpilot/starpilot/galaxy/web/js/projection-layout.js index 9af457cdb0..8d6c003118 100644 --- a/openpilot/starpilot/galaxy/web/js/projection-layout.js +++ b/openpilot/starpilot/galaxy/web/js/projection-layout.js @@ -27,7 +27,7 @@ export function editorSnapshot(raw) { const document = (value) => { if (value.version !== 2 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height) throw new Error("The saved Android Auto layout does not match its screen. Reload before editing.") - return { version: 6, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) }, + return { version: 6, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, speedSourcesCard: value.speedSourcesCard === true, speedAlign: ["center", "right"].includes(value.speedAlign) ? value.speedAlign : "left", ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) }, widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } } } return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults), @@ -50,6 +50,8 @@ export function projectionPayload(body, metadata) { leadIndicator: body.document.leadIndicator === true, leadInfo: LEAD_INFO.includes(body.document.leadInfo) ? body.document.leadInfo : "Off", speedSources: body.document.speedSources === true, + speedSourcesCard: body.document.speedSourcesCard === true, + speedAlign: ["center", "right"].includes(body.document.speedAlign) ? body.document.speedAlign : "left", ...(body.document.widgetOrder?.large ? { widgetOrder: clone(body.document.widgetOrder.large) } : {}), widgets: clone(body.document.layouts.large) } } } diff --git a/openpilot/starpilot/system/android_auto/projection_layout.py b/openpilot/starpilot/system/android_auto/projection_layout.py index a8b46ab58a..648394c69c 100644 --- a/openpilot/starpilot/system/android_auto/projection_layout.py +++ b/openpilot/starpilot/system/android_auto/projection_layout.py @@ -7,7 +7,7 @@ from pathlib import Path from openpilot.starpilot.system.android_auto.identity import DATA_DIR from openpilot.starpilot.system.android_auto.display_profile import screen_geometry from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, MODE_WIDGET, STOPPED_TIMER_WIDGET, WHEEL_SIZES, - customization_metadata, validate_widget_order) + SPEED_ALIGNMENTS, customization_metadata, validate_widget_order) from openpilot.starpilot.ui.onroad_stopped_timer import timer_size from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint @@ -123,7 +123,8 @@ def layout_metadata_for_viewport(viewport): # Android Auto display switches, independent of the comma screen's preferences. DISPLAY_DEFAULTS = {'blindSpotBorder': False, 'signalBorder': False, 'blindSpotCamera': False, 'hidePersonalityAlerts': False, 'wheelPedalFeedback': False, - 'leadIndicator': False, 'leadInfo': 'Off', 'speedSources': False} + 'leadIndicator': False, 'leadInfo': 'Off', 'speedSources': False, + 'speedSourcesCard': False, 'speedAlign': 'left'} # Lead info shown above the lead marker, in LeadInfoMode order. LEAD_INFO_CHOICES = ('Off', 'Distance', 'Speed') @@ -216,7 +217,8 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): result.setdefault('largeUiGammaTrial', False) for field, default in DISPLAY_DEFAULTS.items(): if field in value and (type(value[field]) is not type(default) or - (field == 'leadInfo' and value[field] not in LEAD_INFO_CHOICES)): + (field == 'leadInfo' and value[field] not in LEAD_INFO_CHOICES) or + (field == 'speedAlign' and value[field] not in SPEED_ALIGNMENTS)): raise ValueError('Invalid projection display setting') result.setdefault(field, default) for key in set(metadata['widgets']) - set(value['widgets']): @@ -341,6 +343,7 @@ def projection_customization(document, base_customization): red, green, blue, alpha = rgba(result, 'cardFill', 'large', 'cruise_limits') fill = f'#{red:02X}{green:02X}{blue:02X}{round(alpha * opacity / 100):02X}' result['widgetColors']['large'].setdefault('cruise_limits', {})['cardFill'] = fill - if 'speedSources' in document: - result['speedSources'] = document['speedSources'] + for key in ('speedSources', 'speedSourcesCard', 'speedAlign'): + if key in document: + result[key] = document[key] return result diff --git a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py index 98390a7ec0..fc4f4b5309 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py +++ b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py @@ -180,6 +180,7 @@ class TestProjectionLayout(unittest.TestCase): layout = default_layout_for_viewport((1920, 1080)) self.assertEqual(layout['widgets']['cruise_limits']['opacity'], 100) self.assertIs(layout['speedSources'], False) + self.assertEqual(layout['speedAlign'], 'left') base = default_document() solid = rgba(base, 'cardFill', 'large', 'cruise_limits') native = projection_customization(layout, base) @@ -196,6 +197,9 @@ class TestProjectionLayout(unittest.TestCase): del saved['speedSources'] restored = validate_layout_for_viewport(saved, (1920, 1080)) self.assertEqual((restored['widgets']['cruise_limits']['opacity'], restored['speedSources']), (100, False)) + self.assertEqual(projection_customization(validate_layout_for_viewport({**layout, 'speedAlign': 'right'}, (1920, 1080)), base)['speedAlign'], 'right') + with self.assertRaises(ValueError): + validate_layout_for_viewport({**layout, 'speedAlign': 'justify'}, (1920, 1080)) for bad in (-1, 101, 50.5, True): layout['widgets']['cruise_limits']['opacity'] = bad with self.assertRaises(ValueError): diff --git a/openpilot/starpilot/ui/large_speed_geometry.py b/openpilot/starpilot/ui/large_speed_geometry.py index 75b54810c1..974c503753 100644 --- a/openpilot/starpilot/ui/large_speed_geometry.py +++ b/openpilot/starpilot/ui/large_speed_geometry.py @@ -1,5 +1,4 @@ """Large speed stack dimensions shared by rendering, input and layout metadata.""" -from functools import lru_cache WIDTH = 320 MAX_ROW_HEIGHT = 210 @@ -10,7 +9,7 @@ ACTION_X = 88 ACTION_Y = 75 + CARD_HEIGHT + 22 ACTION_HEADER_HEIGHT = 44 GAP = 8 -SOURCE_ROW_HEIGHT = 56 +SOURCE_ROW_HEIGHT = 76 PADDING = 24 @@ -18,73 +17,8 @@ def card_height(mode): return CARD_HEIGHT if mode == 'split' else LIMIT_ROW_HEIGHT if mode == 'limit_only' else MAX_ROW_HEIGHT -@lru_cache(maxsize=8) -def _projection_widgets(width, height): - from openpilot.starpilot.system.android_auto.projection_layout import layout_metadata_for_viewport - return layout_metadata_for_viewport((width, height))['widgets'] - - -def _action_regions(state): - from openpilot.starpilot.ui.onroad_customization import placement - from openpilot.starpilot.ui.onroad_state import slc_controls - from openpilot.starpilot.ui.presentation import Profile - - header = ACTION_HEADER_HEIGHT if state.speed_limit.pending_speed_limit_mps is not None or state.speed_limit.action_feedback else 0 - regions = [(l, t - header, r, b) for l, t, r, b in - (control.bounds for control in slc_controls(Profile.LARGE, state))] - saved = placement(state.customization, 'large', 'speed_limit_actions') - if not regions and state.speed_limit.action_feedback and saved['enabled']: - regions.append((saved['x'], saved['y'], saved['x'] + WIDTH, saved['y'] + ACTION_HEADER_HEIGHT)) - return regions - - -def source_panel_bounds(state, height, viewport): - """Prefer the aligned column; move the popup only for constrained saved layouts.""" - from openpilot.starpilot.ui.onroad_customization import placement, PROFILES, widget_size, CAMERA_WIDGETS - from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed - - card = placement(state.customization, 'large', 'cruise_limits') - height_of_card = card_height(resolve_unified_speed(state).mode) - # The panel opens from the card itself; there is no on-screen toggle row any more. - left, top, right, bottom = card['x'], card['y'], card['x'] + WIDTH, card['y'] + height_of_card - occupied = [(left, top, right, bottom)] - occupied.extend(_action_regions(state)) - layouts = state.customization['layouts']['large'] - projected = 'nav_card' in layouts - if projected: - from openpilot.starpilot.system.android_auto.projection_layout import placement_size - widgets = _projection_widgets(round(viewport.width + 60), round(viewport.height + 60)) - else: - widgets = PROFILES['large']['widgets'] - for key, widget in widgets.items(): - if key in ('cruise_limits', 'speed_limit_actions', *CAMERA_WIDGETS): - continue - saved = layouts.get(key) - if saved is None: - continue - if saved['enabled']: - if key == 'cem_gauge': - from openpilot.starpilot.ui.onroad_cem_gauge import gauge_data - if gauge_data(state) is None: - continue - width, widget_height = (placement_size(key, widget, saved) if projected else widget_size(state.customization, 'large', key)) - native = PROFILES['large']['widgets'].get(key) - dx = widget['default']['x'] - native['default']['x'] if projected and native else 0 - dy = widget['default']['y'] - native['default']['y'] if projected and native else 0 - x, y = saved['x'] + dx, saved['y'] + dy - occupied.append((x, y, x + width, y + widget_height)) - candidates = [(left, bottom + GAP), (left, top - GAP - height), - (left, card['y'] - GAP - height), (right + GAP, top), (left - GAP - WIDTH, top), - (right + GAP, top - GAP - height), (left - GAP - WIDTH, top - GAP - height)] - # Keep the aligned choices first; another saved widget can fill that column. - # Its edges supply further columns using the same popup gap and clip checks. - for occupied_left, _, occupied_right, _ in occupied: - candidates.extend(((occupied_right + GAP, top), (occupied_left - GAP - WIDTH, top), - (occupied_right + GAP, top - GAP - height), (occupied_left - GAP - WIDTH, top - GAP - height))) - for x, y in candidates: - y = min(max(y, viewport.y), viewport.y + viewport.height - height) - if not viewport.x <= x <= viewport.x + viewport.width - WIDTH: - continue - if all(x + WIDTH <= l or x >= r or y + height <= t or y >= b for l, t, r, b in occupied): - return x, y, WIDTH, height - return None +def sources_extent(document): + """Extra height the cruise widget takes for speed-limit sources: a row in the card, or a small card of its own below.""" + if document.get('speedSources') is not True: + return 0 + return GAP + SOURCE_ROW_HEIGHT if document.get('speedSourcesCard') is True else SOURCE_ROW_HEIGHT diff --git a/openpilot/starpilot/ui/layout_preview_renderer.py b/openpilot/starpilot/ui/layout_preview_renderer.py index e7c805fa13..68b1a26f8e 100644 --- a/openpilot/starpilot/ui/layout_preview_renderer.py +++ b/openpilot/starpilot/ui/layout_preview_renderer.py @@ -281,7 +281,7 @@ class _Canvas: finally: rl.rl_pop_matrix() - def render(self, state: OnroadState, scene: str, *, label: bool = True, source_panel: bool = False) -> bytes: + def render(self, state: OnroadState, scene: str, *, label: bool = True) -> bytes: if self.view is None or self.fonts is None or self.target is None: raise RuntimeError("Layout preview resources are closed") self._settle(state) @@ -299,10 +299,6 @@ class _Canvas: self.fonts.draw('StarPilot', FontRole.BRAND, 30, width - 210, height - 90) if self.profile == Profile.LARGE and self.viewport is None: render_sidebar(self.fonts, rl.Rectangle(width - SIDEBAR_WIDTH, 0, SIDEBAR_WIDTH, height)) - # The backdrop omits the cruise card, so draw its open sources panel on its own. - if (source_panel and self.profile == Profile.LARGE and state.alert.size != AlertSize.FULL and - placement(state.customization, 'large', 'cruise_limits')['enabled']): - self.view.unified_speed.render_source_panel(rl.Rectangle(30, 30, width - 60, height - 60), state) if label: self._label(scene) finally: @@ -394,12 +390,6 @@ class _Canvas: hud._set_speed_alpha.x = 1.0 if state.cruise_active else 0.0 hud._wheel_alpha.x = 255 * 0.9 if state.lateral_active else 0.0 hud._wheel_y.x = 0.0 if state.lateral_active else 25.0 - if (speed := getattr(self.view, 'unified_speed', None)) is not None: - # A still frame shows the sources panel fully open or closed, never mid-slide. - opened = float(state.customization.get('speedSources', False) is True) - drawer = speed._source_drawer - drawer._open, drawer.progress, drawer._start_progress = bool(opened), opened, opened - speed._source_session = state.speed_limit.session_id self.view.torque_bar._torque_filter.x = state.torque_utilization if state.lateral_active else 0.0 self.view.torque_bar._alpha_filter.x = 1.0 if state.lateral_active else 0.0 @@ -480,8 +470,7 @@ class LayoutPreviewRenderer: # Everything the car draws except the movable widgets, which the editor places over it. # A non-empty order that names no widget keeps the ordered path and draws none of them. order = {**state.customization.get('widgetOrder', {}), str(profile): ['backdrop']} - return self._canvas.render(replace(state, customization={**state.customization, 'widgetOrder': order}), scene, - label=False, source_panel=True) + return self._canvas.render(replace(state, customization={**state.customization, 'widgetOrder': order}), scene, label=False) return self._canvas.render(state, scene) def expire(self) -> None: diff --git a/openpilot/starpilot/ui/onroad.py b/openpilot/starpilot/ui/onroad.py index 467e8bcc4f..0728242735 100644 --- a/openpilot/starpilot/ui/onroad.py +++ b/openpilot/starpilot/ui/onroad.py @@ -147,8 +147,6 @@ class OnroadView: if state.camera_available and self.camera_layer is None: raise ValueError("Camera observation requires a camera renderer") if self.fonts.profile == Profile.LARGE: - if state.alert.size == AlertSize.FULL or not placement(state.customization, "large", "cruise_limits")["enabled"]: - self.unified_speed.collapse_sources() self._large(state) else: self._compact(state) diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py index c35f7a43cf..3be21765fa 100644 --- a/openpilot/starpilot/ui/onroad_customization.py +++ b/openpilot/starpilot/ui/onroad_customization.py @@ -9,10 +9,12 @@ from typing import Any, NotRequired, TypedDict from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint -from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y +from openpilot.starpilot.ui.large_speed_geometry import WIDTH, CARD_HEIGHT, ACTION_HEIGHT, ACTION_X, ACTION_Y, GAP, SOURCE_ROW_HEIGHT, sources_extent PARAM_KEY = "OnroadCustomizations" MAX_BYTES = 16384 +# Text alignment inside the cruise and speed-limit card; left is the original look. +SPEED_ALIGNMENTS = ("left", "center", "right") PALETTE = {"cardFill": "#000000A6", "cardBorder": "#C4CDD0B4", "text": "#FFFFFFFF"} ROAD_COLORS = {"path": "#30FF9CFF", "pathEdge": "#00FF40FF", "laneLines": "#FFFFFFFF"} PATH_MODES = ("default", "color", "rainbow", "acceleration") @@ -206,6 +208,8 @@ def widget_bounds(profile, widget): def customization_metadata(): profiles = copy.deepcopy(PROFILES) profiles["large"]["widgets"]["driver_monitor"]["defaultAnchor"] = "driver_side" + # The editor grows the cruise box by these while speed-limit sources are shown. + profiles["large"]["widgets"]["cruise_limits"]["sourcesExtent"] = {"inline": SOURCE_ROW_HEIGHT, "card": GAP + SOURCE_ROW_HEIGHT} profiles["large"]["inputZones"] = [{"id": "favorites_drawer", "label": "Quick Select drawer", "x": 30, "y": 900, "width": 150, "height": 150}] profiles["compact"]["inputZones"] = [ @@ -263,6 +267,14 @@ def validate_document(value, *, _legacy_geometry=False): fields.add('speedSources') if type(value['speedSources']) is not bool: raise ValueError('Invalid speed source drawer preference') + if value['version'] >= 4 and 'speedSourcesCard' in value: + fields.add('speedSourcesCard') + if type(value['speedSourcesCard']) is not bool: + raise ValueError('Invalid speed source card preference') + if value['version'] >= 4 and 'speedAlign' in value: + fields.add('speedAlign') + if value['speedAlign'] not in SPEED_ALIGNMENTS: + raise ValueError('Invalid speed card alignment') if value['version'] >= 4 and 'widgetOrder' in value: fields.add('widgetOrder') if 'clock24Hour' in value: @@ -418,6 +430,10 @@ def validate_document(value, *, _legacy_geometry=False): result['widgetOrder'][profile] = validate_widget_order(order, PROFILES[profile]['widgets']) if 'speedSources' in value: result['speedSources'] = value['speedSources'] + if 'speedSourcesCard' in value: + result['speedSourcesCard'] = value['speedSourcesCard'] + if 'speedAlign' in value: + result['speedAlign'] = value['speedAlign'] if 'clock24Hour' in value: result['clock24Hour'] = value['clock24Hour'] if 'showMaxSpeed' in value: @@ -455,6 +471,8 @@ def placement(document, profile, widget): def widget_size(document, profile, widget): default = PROFILES[str(profile)]["widgets"][widget] position = placement(document, profile, widget) + if widget == "cruise_limits": + return default["width"], default["height"] + sources_extent(document) return (position.get("size", WHEEL_SIZES[str(profile)][1]),) * 2 if widget == "steering_wheel" else (default["width"], default["height"]) diff --git a/openpilot/starpilot/ui/onroad_large_widgets.py b/openpilot/starpilot/ui/onroad_large_widgets.py index ccf6ef50f4..541251b697 100644 --- a/openpilot/starpilot/ui/onroad_large_widgets.py +++ b/openpilot/starpilot/ui/onroad_large_widgets.py @@ -7,7 +7,6 @@ HudRenderer and ExpButton. See the adjacent UI LICENSE for retained source. from pathlib import Path import hashlib import json -import time import pyray as rl @@ -16,7 +15,6 @@ from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState from openpilot.starpilot.ui.onroad_widget_style import draw_control_card from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole from openpilot.starpilot.ui.wheel_feedback import wheel_feedback_rgb -from openpilot.starpilot.ui.speed_source_drawer import SpeedSourceDrawer from openpilot.starpilot.ui.unified_speed_presentation import resolve_unified_speed @@ -35,59 +33,63 @@ class UnifiedSpeedWidget: def __init__(self, fonts: BitmapFonts): self.fonts = fonts - self._source_drawer = SpeedSourceDrawer() - self._source_session = None - def collapse_sources(self): - self._source_drawer.reset() - - def _draw_source_contents(self, panel, state): + def _draw_sources(self, rect, state): + """Each enabled source as a small label over its reading; the accepted source is highlighted.""" + from openpilot.starpilot.ui.large_speed_geometry import PADDING rows = [row for row in state.speed_limit.source_readings if row.enabled] if not rows: - _center(self.fonts, 'No sources enabled', FontRole.NORMAL, 28, panel, panel.y + 30) + _center(self.fonts, 'No sources enabled', FontRole.NORMAL, 26, rect, rect.y + 22) return - row_height = (panel.height - 32) / len(rows) active = state.speed_limit.accepted_source - labels = {'dashboard': 'Dashboard', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'} + labels = {'dashboard': 'Dash', 'map': 'Map', 'vision': 'Vision', 'online': 'Online'} + cell = (rect.width - 2 * PADDING) / len(rows) for index, row in enumerate(rows): - role = FontRole.MEDIUM if row.source == active else FontRole.NORMAL color = rl.WHITE if row.source == active else rl.Color(170, 179, 174, 255) value = str(round(row.speed_mps * (3.6 if state.metric else 2.2369362921))) if row.kind == 'valid' and row.speed_mps is not None else '–' - label = labels[row.source] - size = 28 - value_width = self.fonts.measure(value, role, 30).width - while size > 12 and self.fonts.measure(label, role, size).width > panel.width - 64 - value_width: + box = rl.Rectangle(rect.x + PADDING + index * cell, rect.y, cell, rect.height) + size = 22 + while size > 12 and self.fonts.measure(labels[row.source], FontRole.NORMAL, size).width > cell - 4: size -= 1 - y = panel.y + 16 + index * row_height - label_height = self.fonts.measure(label, role, size).height - value_height = self.fonts.measure(value, role, 30).height - self.fonts.draw(label, role, size, panel.x + 32, y + (row_height - label_height) / 2, color) - self.fonts.draw(value, role, 30, panel.x + panel.width - 32 - value_width, y + (row_height - value_height) / 2, color) + _center(self.fonts, labels[row.source], FontRole.NORMAL, size, box, rect.y + 10, color) + _center(self.fonts, value, FontRole.MEDIUM if row.source == active else FontRole.NORMAL, 30, box, rect.y + 36, color) def render(self, content: rl.Rectangle, state: OnroadState) -> None: - from openpilot.starpilot.ui.large_speed_geometry import WIDTH, MAX_ROW_HEIGHT, PADDING, card_height + from openpilot.starpilot.ui.large_speed_geometry import GAP, WIDTH, MAX_ROW_HEIGHT, PADDING, SOURCE_ROW_HEIGHT, card_height from openpilot.starpilot.ui.onroad_customization import placement shown = resolve_unified_speed(state) if shown.mode == 'hidden': - self.collapse_sources() return saved = placement(state.customization, 'large', 'cruise_limits') - rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, card_height(shown.mode)) + height = card_height(shown.mode) + rect = rl.Rectangle(saved['x'], saved['y'], WIDTH, height) + # Sources follow the Galaxy layout setting: a row in this card, or a small card of its own right below. + sources = (state.customization.get('speedSources') is True and shown.mode != 'max_only' and + state.speed_limit.kind == ObservationKind.VALID and bool(state.speed_limit.session_id)) + separate = state.customization.get('speedSourcesCard') is True + if sources and not separate: + rect.height += SOURCE_ROW_HEIGHT fill = rl.Color(*rgba(state.customization, 'cardFill', 'large', 'cruise_limits')) border = rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits')) text = rl.Color(*rgba(state.customization, 'text', 'large', 'cruise_limits')) muted, accent = rl.Color(172, 190, 203, 255), rl.Color(197, 163, 255, 255) draw_control_card(rect, fill=fill, border=border, border_width=1, roundness=.09) - def label(value, x, y, size=32, color=muted, role=FontRole.MEDIUM): + align = state.customization.get('speedAlign', 'left') + + def start(width): + # Text sits left (the default), centred, or right within the card's padding. + return (rect.x + (WIDTH - width) / 2 if align == 'center' else + rect.x + WIDTH - PADDING - width if align == 'right' else rect.x + PADDING) + + def label(value, y, size=32, color=muted, role=FontRole.MEDIUM): while size > 20 and self.fonts.measure(value, role, size).width > WIDTH - 2 * PADDING: size -= 1 ink_top, _ = self.fonts.vertical_ink(value, role, size) - self.fonts.draw(value, role, size, x, y - ink_top, color) + self.fonts.draw(value, role, size, start(self.fonts.measure(value, role, size).width), y - ink_top, color) def row(title, value, top, footer, status='', active=False, footer_color=muted, footer_y=174): - left = rect.x + PADDING - label(title, left, top + 24, color=accent if active else muted) + label(title, top + 24, color=accent if active else muted) size, unit_size = 112, 28 has_value = value != '–' unit_width = self.fonts.measure(shown.unit, FontRole.NORMAL, unit_size).width if has_value else 0 @@ -99,14 +101,15 @@ class UnifiedSpeedWidget: if not has_value: digit_top, digit_bottom = self.fonts.vertical_ink('0', FontRole.SPEED, size) number_y += ((digit_bottom - digit_top) - (number_bottom - number_top)) / 2 + left = start(number_width + (14 + unit_width if has_value else 0)) self.fonts.draw(value, FontRole.SPEED, size, left, number_y, text) if has_value: _, unit_bottom = self.fonts.vertical_ink(shown.unit, FontRole.NORMAL, unit_size) self.fonts.draw(shown.unit, FontRole.NORMAL, unit_size, left + number_width + 14, top + 72 + number_bottom - number_top - unit_bottom, muted) if status: - label(status, left, top + 174, size=30, role=FontRole.NORMAL, color=accent if active else muted) - label(footer, left, top + footer_y, size=30, role=FontRole.NORMAL, color=footer_color) + label(status, top + 174, size=30, role=FontRole.NORMAL, color=accent if active else muted) + label(footer, top + footer_y, size=30, role=FontRole.NORMAL, color=footer_color) if shown.mode != 'limit_only': max_active = shown.active_side in ('max', 'shared') and shown.max_text != '–' @@ -121,41 +124,13 @@ class UnifiedSpeedWidget: road_limit += f' {shown.offset_text}' row('SLC LIMIT', shown.limit_text, top, road_limit, shown.limit_status, active=shown.active_side in ('slc', 'shared'), footer_y=224) - session = state.speed_limit.session_id - if session != self._source_session: - self.collapse_sources() - self._source_session = session - available = state.speed_limit.kind == ObservationKind.VALID and bool(session) and shown.mode != 'max_only' - if not available: - self.collapse_sources() - return - # Sources open from the Galaxy layout setting; there is no on-screen toggle. - now = state.observed_ns / 1e9 if state.observed_ns else time.monotonic() - self._source_drawer.update(state.customization.get('speedSources', False), now) - self._draw_sources(content, state, fill, border) - - def render_source_panel(self, content: rl.Rectangle, state: OnroadState) -> None: - """Only the open sources panel, for the layout preview, which places the card itself as a movable widget.""" - shown = resolve_unified_speed(state) - if (shown.mode in ('hidden', 'max_only') or state.speed_limit.kind != ObservationKind.VALID or - not state.speed_limit.session_id or not state.customization.get('speedSources', False)): - return - self._draw_sources(content, state, rl.Color(*rgba(state.customization, 'cardFill', 'large', 'cruise_limits')), - rl.Color(*rgba(state.customization, 'cardBorder', 'large', 'cruise_limits'))) - - def _draw_sources(self, content: rl.Rectangle, state: OnroadState, fill: rl.Color, border: rl.Color) -> None: - from openpilot.starpilot.ui.large_speed_geometry import PADDING, SOURCE_ROW_HEIGHT, source_panel_bounds - if self._source_drawer.progress <= 0: - return - rows = sum(row.enabled for row in state.speed_limit.source_readings) - height = max(1, rows) * SOURCE_ROW_HEIGHT + PADDING - panel_bounds = source_panel_bounds(state, height, content) - if panel_bounds is None: - self.collapse_sources() - return - panel = rl.Rectangle(*panel_bounds) - self._source_drawer.draw_panel(panel, content, fill, border, - lambda panel: self._draw_source_contents(panel, state)) + if sources: + row = rl.Rectangle(rect.x, rect.y + height + (GAP if separate else 0), WIDTH, SOURCE_ROW_HEIGHT) + if separate: + draw_control_card(row, fill=fill, border=border, border_width=1, roundness=.2) + else: + rl.draw_line_ex(rl.Vector2(rect.x + 1, row.y), rl.Vector2(rect.x + WIDTH - 1, row.y), 1, border) + self._draw_sources(row, state) class CurrentSpeedHud: diff --git a/openpilot/starpilot/ui/onroad_state.py b/openpilot/starpilot/ui/onroad_state.py index 8c9247a9da..f175e013cf 100644 --- a/openpilot/starpilot/ui/onroad_state.py +++ b/openpilot/starpilot/ui/onroad_state.py @@ -258,9 +258,12 @@ def _default_slc_controls(profile: Profile, state: OnroadState) -> tuple[SlcCont from openpilot.starpilot.ui.large_speed_geometry import WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y, ACTION_HEADER_HEIGHT target = observation.pending_adjusted_limit_mps label = f'Apply {round(target * (3.6 if state.metric else 2.2369362921))}' if target is not None and target > 0 else 'Apply' + # Name the speed each choice leaves you at: the new target, or the limit already in use. + current = observation.accepted_adjusted_limit_mps + keep = f'Keep {round(current * (3.6 if state.metric else 2.2369362921))}' if current is not None and current > 0 else 'Keep' return (SlcControl(request(SlcActionKind.ACCEPT), label, (ACTION_X, ACTION_Y + ACTION_HEADER_HEIGHT, ACTION_X + WIDTH // 2 - 6, ACTION_Y + ACTION_HEIGHT)), - SlcControl(request(SlcActionKind.REJECT), 'Keep', + SlcControl(request(SlcActionKind.REJECT), keep, (ACTION_X + WIDTH // 2 + 6, ACTION_Y + ACTION_HEADER_HEIGHT, ACTION_X + WIDTH, ACTION_Y + ACTION_HEIGHT))) return (SlcControl(request(SlcActionKind.ACCEPT), "ACCEPT", (174, 180, 310, 234)), SlcControl(request(SlcActionKind.REJECT), "REJECT", (320, 180, 456, 234))) diff --git a/openpilot/starpilot/ui/shell.py b/openpilot/starpilot/ui/shell.py index 971682e34d..22c917defa 100644 --- a/openpilot/starpilot/ui/shell.py +++ b/openpilot/starpilot/ui/shell.py @@ -85,8 +85,6 @@ class ShellView: acquired.pop_all() def render(self, snapshot: ShellSnapshot, drag_x: float = 0.0) -> None: - if snapshot.mode != ShellMode.ONROAD and self.profile == Profile.LARGE: - self.onroad.unified_speed.collapse_sources() if snapshot.mode == ShellMode.HOME: self.home.render(snapshot.home) elif snapshot.mode == ShellMode.ONROAD: diff --git a/openpilot/starpilot/ui/speed_source_drawer.py b/openpilot/starpilot/ui/speed_source_drawer.py deleted file mode 100644 index c6fc37fdbb..0000000000 --- a/openpilot/starpilot/ui/speed_source_drawer.py +++ /dev/null @@ -1,17 +0,0 @@ -"""A vertically revealed, display-only source panel.""" - -import pyray as rl - -from openpilot.starpilot.ui import clip -from openpilot.starpilot.ui.speed_source_drawer_model import DrawerMotion - - -class SpeedSourceDrawer(DrawerMotion): - def draw_panel(self, panel, parent, fill, border, draw_contents): - if self.progress <= 0: - return None - visible = rl.Rectangle(panel.x, panel.y, panel.width, panel.height * self.progress) - with clip.clipped(visible, parent): - rl.draw_rectangle_rounded(panel, .14, 12, fill) - rl.draw_rectangle_rounded_lines_ex(panel, .14, 12, 1, border) - draw_contents(panel) diff --git a/openpilot/starpilot/ui/speed_source_drawer_model.py b/openpilot/starpilot/ui/speed_source_drawer_model.py deleted file mode 100644 index 76f822f6ed..0000000000 --- a/openpilot/starpilot/ui/speed_source_drawer_model.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Reversible reveal progress for the display-only source panel.""" - -OPEN_SECONDS = 0.18 -CLOSE_SECONDS = 0.14 - - -class DrawerMotion: - def __init__(self): - self.reset() - - def reset(self) -> None: - self.progress = 0.0 - self._open = False - self._start_progress = 0.0 - self._start_time = 0.0 - - def update(self, opened: bool, now: float) -> None: - # Sample the old transition first so a second tap reverses without jumping. - target = float(self._open) - if self.progress != target: - duration = OPEN_SECONDS if self._open else CLOSE_SECONDS - phase = min(1.0, max(0.0, (now - self._start_time) / duration)) - self.progress = target + (self._start_progress - target) * (1 - phase) ** 4 - if opened != self._open: - self._open = opened - self._start_progress = self.progress - self._start_time = now - diff --git a/openpilot/starpilot/ui/tests/test_dinish_speed.py b/openpilot/starpilot/ui/tests/test_dinish_speed.py index b7b8153441..fe3b3da8b1 100644 --- a/openpilot/starpilot/ui/tests/test_dinish_speed.py +++ b/openpilot/starpilot/ui/tests/test_dinish_speed.py @@ -7,7 +7,7 @@ from PIL import Image import pyray as rl from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory, font_filename, font_path, validate_bitmap_font -from openpilot.starpilot.ui.large_speed_geometry import WIDTH, PADDING, source_panel_bounds +from openpilot.starpilot.ui.large_speed_geometry import WIDTH, PADDING from openpilot.starpilot.ui.onroad_customization import default_document, validate_document from openpilot.starpilot.ui.onroad_state import slc_controls from openpilot.starpilot.ui.tests.test_speed_stack import road @@ -81,17 +81,17 @@ def test_v5_geometry_migrates_and_keeps_custom_actions_independent(): assert validate_document(doc)['layouts']['large']['speed_limit_actions']['y'] == 567 -def test_pending_buttons_and_source_popup_do_not_cover_monitor(): +def test_pending_buttons_name_both_speeds(): state = road() - state = replace(state, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=25)) + state = replace(state, speed_limit=replace(state.speed_limit, pending_speed_limit_mps=25, pending_adjusted_limit_mps=24, + accepted_adjusted_limit_mps=22)) apply, keep = slc_controls(Profile.LARGE, state) assert apply.bounds == (88, 611, 242, 675) assert keep.bounds == (254, 611, 408, 675) - panel = source_panel_bounds(state, 200, SimpleNamespace(x=30, y=30, width=1800, height=1020)) - assert panel is not None - x, y, width, height = panel - assert x >= 280 or x + width <= 88 or y + height <= 808 or y >= 1000 - assert x >= 1437 or x + width <= 439 or y + height <= 759 or y >= 999 + # Each choice says the speed it leaves you at: the new target or the limit already in use. + assert (apply.label, keep.label) == ('Apply 54', 'Keep 49') + no_current = replace(state, speed_limit=replace(state.speed_limit, accepted_adjusted_limit_mps=None)) + assert slc_controls(Profile.LARGE, no_current)[1].label == 'Keep' def test_projection_v1_layout_migrates_without_losing_other_widgets(): @@ -118,18 +118,3 @@ def test_committed_projection_v1_right_edge_is_preserved_and_clamped(): assert migrated['widgets']['speed_limit_actions'] == {'x': 1510, 'y': 567, 'enabled': True} -def test_projected_source_popup_avoids_moved_navigation_card(): - from openpilot.starpilot.system.android_auto.projection_layout import projection_customization - viewport = (2880, 1080) - doc = default_layout_for_viewport(viewport) - rect = SimpleNamespace(x=30, y=30, width=2820, height=1020) - state = road(projection_customization(doc, default_document())) - first = source_panel_bounds(state, 200, rect) - assert first is not None - doc['widgets']['nav_card'].update(x=first[0], y=first[1], enabled=True) - state = road(projection_customization(doc, default_document())) - moved = source_panel_bounds(state, 200, rect) - if moved is not None: - x, y, width, height = moved - nx, ny = doc['widgets']['nav_card']['x'], doc['widgets']['nav_card']['y'] - assert x + width <= nx or x >= nx + 560 or y + height <= ny or y >= ny + 195 diff --git a/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py b/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py index 6d8e1ea214..82c2856f7e 100644 --- a/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py +++ b/openpilot/starpilot/ui/tests/test_layout_preview_renderer.py @@ -68,7 +68,7 @@ class LayoutPreviewRendererTests(unittest.TestCase): pending = sample_state("slc_pending", document) for profile in (Profile.LARGE, Profile.COMPACT): self.assertEqual([item.label for item in slc_controls(profile, pending)], - ["Apply 54", "Keep"] if profile == Profile.LARGE else ["ACCEPT", "REJECT"]) + ["Apply 54", "Keep 49"] if profile == Profile.LARGE else ["ACCEPT", "REJECT"]) self.assertEqual(len(slc_controls(profile, sample_state("engaged", document))), 1 if profile == Profile.LARGE else 0) manual = sample_state("experimental", document) self.assertTrue(manual.experimental_enabled) @@ -120,7 +120,7 @@ class LayoutPreviewRendererTests(unittest.TestCase): self.assertEqual(renderer({"document": document, "profile": "compact", "scene": "braking", "art": "backdrop"}), b"png") state, scene = canvas.render.call_args.args self.assertEqual(scene, "braking") - self.assertEqual(canvas.render.call_args.kwargs, {"label": False, "source_panel": True}) + self.assertEqual(canvas.render.call_args.kwargs, {"label": False}) # An order naming no widget keeps the ordered path, so no movable widget is drawn. self.assertEqual(state.customization["widgetOrder"]["compact"], ["backdrop"]) self.assertEqual(state.customization["layouts"], document["layouts"]) diff --git a/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py b/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py index dc6b72a5af..cd1af9dd5a 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py +++ b/openpilot/starpilot/ui/tests/test_onroad_cem_gauge.py @@ -253,18 +253,13 @@ def test_numeric_caption_declared_footprint_fits_actual_bottom_content_edge(proj @pytest.mark.parametrize('kind', [None, 'scene_unavailable', 'unavailable', 'no_trigger', 'manual_chill', 'manual_experimental', 'unknown_wire_reason', 'cem_signal', 'cem_speed', 'cem_stop', 'cem_lead', 'cem_curve', 'curve_control']) -def test_source_drawer_reserves_gauge_only_when_actual_renderer_draws(projection, kind): +def test_gauge_draws_only_for_visible_reasons(projection, kind): from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, projection_customization - from openpilot.starpilot.ui.large_speed_geometry import source_panel_bounds from openpilot.starpilot.ui.tests.test_speed_stack import road - viewport = (2160, 1080) document = default_document() - document['layouts']['large']['speed_limit_actions']['y'] = 750 if projection: - projected = default_layout_for_viewport(viewport) - projected['widgets']['speed_limit_actions']['y'] = 750 - document = projection_customization(projected, document) + document = projection_customization(default_layout_for_viewport((2160, 1080)), document) sample = replace(state(kind if kind and kind != 'curve_control' else 'no_trigger'), customization=document, longitudinal_active=True, slc_system_long_available=True, speed_limit=road().speed_limit) if kind is None: @@ -272,22 +267,9 @@ def test_source_drawer_reserves_gauge_only_when_actual_renderer_draws(projection elif kind == 'curve_control': curve = CurveObservation(True, 15., 15., True, True, False, 100., 2., 30., 'curve', 'selected', 'saved', True, False, .005) sample = replace(sample, curve=curve) - content = NS(x=30, y=30, width=1800, height=1020) - bounds = source_panel_bounds(sample, 200, content) - disabled = copy.deepcopy(document) - disabled['layouts']['large'][CEM_GAUGE_WIDGET]['enabled'] = False - disabled_bounds = source_panel_bounds(replace(sample, customization=disabled), 200, content) gauge = AetherGauge(Mock()) with patch.object(gauge, '_render_unified_road') as draw: gauge.render(sample) visible = kind in ('cem_stop', 'cem_lead', 'cem_curve', 'curve_control') assert (gauge_data(sample) is not None) is visible assert draw.called is visible - assert bounds is not None and disabled_bounds is not None - if not visible: - assert bounds == disabled_bounds # A blank gauge consumes exactly the disabled footprint. - else: - placed = document['layouts']['large'][CEM_GAUGE_WIDGET] - x, y, width, height = bounds - assert (x + width <= placed['x'] or x >= placed['x'] + 344 or - y + height <= placed['y'] or y >= placed['y'] + 300) diff --git a/openpilot/starpilot/ui/tests/test_speed_source_drawer.py b/openpilot/starpilot/ui/tests/test_speed_source_drawer.py deleted file mode 100644 index 58d6faf9c4..0000000000 --- a/openpilot/starpilot/ui/tests/test_speed_source_drawer.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Pure reveal and shared-frame geometry; no UI/native resource acquisition.""" - -import unittest -from openpilot.starpilot.ui.speed_source_drawer_model import DrawerMotion - - -class DrawerTest(unittest.TestCase): - def test_reveal_finishes_and_reverses_without_jumping(self): - drawer = DrawerMotion() - drawer.update(True, 1) - self.assertEqual(drawer.progress, 0) - drawer.update(True, 1.09) - before = drawer.progress - self.assertTrue(0 < before < 1) - drawer.update(False, 1.09) - self.assertEqual(drawer.progress, before) - drawer.update(False, 1.24) - self.assertEqual(drawer.progress, 0) - drawer.update(True, 2) - drawer.update(True, 2.19) - self.assertEqual(drawer.progress, 1) - drawer.reset() - self.assertEqual(drawer.progress, 0) 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 6ad72c8752..45f26a350b 100644 --- a/openpilot/starpilot/ui/tests/test_speed_source_drawer_native.py +++ b/openpilot/starpilot/ui/tests/test_speed_source_drawer_native.py @@ -76,9 +76,29 @@ class DrawerNativeTest(unittest.TestCase): self.assertNotIn('speedSources', validate_document(doc)) doc['speedSources'] = True self.assertIs(validate_document(doc)['speedSources'], True) - doc['speedSources'] = 1 - with self.assertRaises(ValueError): - validate_document(doc) + doc['speedSourcesCard'] = True + self.assertIs(validate_document(doc)['speedSourcesCard'], True) + for key in ('speedSources', 'speedSourcesCard'): + with self.assertRaises(ValueError): + validate_document({**doc, key: 1}) + self.assertNotIn('speedAlign', validate_document(default_document())) # Left, the original look, by default. + for align in ('left', 'center', 'right'): + self.assertEqual(validate_document({**doc, 'speedAlign': align})['speedAlign'], align) + for bad in ('justify', 1, None): + with self.assertRaises(ValueError): + validate_document({**doc, 'speedAlign': bad}) + + def test_sources_grow_the_cruise_widget_by_one_row_or_an_attached_card(self): + from openpilot.starpilot.ui.large_speed_geometry import CARD_HEIGHT, GAP, SOURCE_ROW_HEIGHT, WIDTH + from openpilot.starpilot.ui.onroad_customization import customization_metadata, widget_size + doc = default_document() + self.assertEqual(widget_size(doc, 'large', 'cruise_limits'), (WIDTH, CARD_HEIGHT)) + doc['speedSources'] = True + self.assertEqual(widget_size(doc, 'large', 'cruise_limits'), (WIDTH, CARD_HEIGHT + SOURCE_ROW_HEIGHT)) + doc['speedSourcesCard'] = True + self.assertEqual(widget_size(doc, 'large', 'cruise_limits'), (WIDTH, CARD_HEIGHT + GAP + SOURCE_ROW_HEIGHT)) + self.assertEqual(customization_metadata()['profiles']['large']['widgets']['cruise_limits']['sourcesExtent'], + {'inline': SOURCE_ROW_HEIGHT, 'card': GAP + SOURCE_ROW_HEIGHT}) def test_expired_producer_and_old_drive_transport_cannot_supply_readings(self): sm, cp = replay_inputs() @@ -133,19 +153,8 @@ class DrawerNativeTest(unittest.TestCase): drawn = [] fonts = SimpleNamespace(measure=lambda *args: SimpleNamespace(width=20, height=20), draw=lambda text, role, *args: drawn.append((text, role))) widget = UnifiedSpeedWidget(Mock(spec=BitmapFonts, **vars(fonts))) - widget._draw_source_contents(rl.Rectangle(264, 271, 248, 215), state) - self.assertIn(('Dashboard', FontRole.MEDIUM), drawn) - self.assertIn(('Vision', FontRole.NORMAL), drawn) + widget._draw_sources(rl.Rectangle(88, 545, 320, 76), state) + self.assertEqual([text for text, _ in drawn], ['Dash', '56', 'Vision', '56']) + self.assertIn(('56', FontRole.MEDIUM), drawn) # The accepted source's reading is emphasised. + self.assertIn(('56', FontRole.NORMAL), drawn) - def test_custom_right_edge_keeps_panel_inside_viewport(self): - from openpilot.starpilot.ui.large_speed_geometry import source_panel_bounds - obs = SpeedLimitObservation(ObservationKind.VALID, 'dashboard', 25, session_id='edge') - doc = default_document() - doc['speedSources'] = True - doc['layouts']['large']['cruise_limits']['x'] = 1350 - state = OnroadState(False, False, 20, 100, obs, customization=validate_document(doc)) - bounds = source_panel_bounds(state, 200, rl.Rectangle(30, 30, 1800, 1020)) - self.assertIsNotNone(bounds) - x, y, width, height = bounds - self.assertLessEqual(x + width, 1830) - self.assertLessEqual(y + height, 1050) diff --git a/openpilot/starpilot/ui/tests/test_speed_stack.py b/openpilot/starpilot/ui/tests/test_speed_stack.py index efb56439d5..9a359c8b7a 100644 --- a/openpilot/starpilot/ui/tests/test_speed_stack.py +++ b/openpilot/starpilot/ui/tests/test_speed_stack.py @@ -5,7 +5,6 @@ from unittest.mock import Mock from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadInput, OnroadState, SlcActionKind, SlcUiRequest, SpeedLimitObservation from openpilot.starpilot.ui.onroad_customization import default_document, validate_document, rgba -from openpilot.starpilot.ui.large_speed_geometry import source_panel_bounds from openpilot.starpilot.ui.slc_action_dispatch import SlcActionDispatcher from openpilot.starpilot.ui.tests.test_slc_ui_actions import native_state, NOW from openpilot.starpilot.speed_limits.speed_domain import adjusted_limit, OffsetBand, OffsetSchedule @@ -45,21 +44,6 @@ def test_numeric_card_and_sources_row_are_not_tap_targets_while_pending(): assert not emitted -def test_saved_edge_geometry_keeps_panel_accessible(): - viewport = N(x=30, y=30, width=1800, height=1020) - for card_x, card_y in ((88, 75), (1350, 528), (88, 528)): - for action_y in (567, 750, 942): - doc = default_document() - doc['layouts']['large']['cruise_limits'].update(x=card_x, y=card_y) - doc['layouts']['large']['speed_limit_actions'].update(x=card_x, y=action_y) - state = road(validate_document(doc)) - bounds = source_panel_bounds(state, 200, viewport) - assert bounds is not None - x, y, width, height = bounds - assert 30 <= x and x + width <= 1830 - assert 30 <= y and y + height <= 1050 - - def test_v4_migration_preserves_settings_and_clamps_only_enlarged_widgets(): doc = default_document() doc['version'] = 4 @@ -111,41 +95,3 @@ def test_publisher_receipt_survives_next_cycle_and_resets_with_session(): assert runtime._command_ui_sequence == 0 -def test_disengaging_during_receipt_keeps_sources_separate_from_feedback(): - state = road() - state = replace(state, longitudinal_active=False, speed_limit=replace(state.speed_limit, action_feedback='Applied')) - from openpilot.starpilot.ui.large_speed_geometry import ACTION_HEADER_HEIGHT, WIDTH - bounds = source_panel_bounds(state, 200, N(x=30, y=30, width=1800, height=1020)) - assert bounds is not None - x, y, width, height = bounds - actions = state.customization['layouts']['large']['speed_limit_actions'] - assert (x + width <= actions['x'] or x >= actions['x'] + WIDTH or - y + height <= actions['y'] or y >= actions['y'] + ACTION_HEADER_HEIGHT) - - -def test_sources_popup_moves_past_gauge_and_keeps_enabled_widget_footprints_clear(): - from openpilot.starpilot.ui.large_speed_geometry import GAP - from openpilot.starpilot.ui.onroad_customization import CAMERA_WIDGETS, CEM_GAUGE_WIDGET, PROFILES, widget_size - - from openpilot.starpilot.conditional_mode.policy import ModeChoice - from openpilot.starpilot.ui.conditional_status import ConditionalDisplay - - viewport = N(x=30, y=30, width=1800, height=1020) - doc = default_document() - doc['layouts']['large']['speed_limit_actions']['y'] = 750 - state = replace(road(validate_document(doc)), conditional_configured=ModeChoice.CEM, - conditional_perception=ConditionalDisplay(ModeChoice.CEM, False, 'cem_stop', 8, 'planner', 1)) - bounds = source_panel_bounds(state, 200, viewport) - assert bounds is not None - x, y, width, height = bounds - gauge = state.customization['layouts']['large'][CEM_GAUGE_WIDGET] - gauge_width, _ = widget_size(state.customization, 'large', CEM_GAUGE_WIDGET) - assert gauge['enabled'] and x >= gauge['x'] + gauge_width + GAP - assert viewport.x <= x and x + width <= viewport.x + viewport.width - assert viewport.y <= y and y + height <= viewport.y + viewport.height - for key in PROFILES['large']['widgets']: - saved = state.customization['layouts']['large'][key] - if saved['enabled'] and key not in CAMERA_WIDGETS: - widget_width, widget_height = widget_size(state.customization, 'large', key) - assert (x + width <= saved['x'] or x >= saved['x'] + widget_width or - y + height <= saved['y'] or y >= saved['y'] + widget_height), key