diff --git a/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs b/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs index 3d3fb86f32..fcb5587490 100644 --- a/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs +++ b/openpilot/starpilot/galaxy/tests/test_projection_layout.mjs @@ -11,10 +11,13 @@ metadata.width = 2880 metadata.bounds.width = 2820 metadata.widgets.current_speed.default.x += 510 metadata.widgets.steering_wheel.default.x += 1020 +metadata.widgets.driver_monitor.width = metadata.widgets.driver_monitor.height = 128 +metadata.widgets.driver_monitor.default = {x: 120, y: 840, enabled: true, size: 128} +metadata.widgets.driver_monitor.resizable = {min: 64, default: 128, max: 192} metadata.widgets.car_exit = { label: "Exit to car", kind: "car_exit", width: 96, height: 96, colors: {}, required: true, frontmost: true, - opacity: { min: 25, max: 100, default: 100 }, backgroundToggle: true, + opacity: { min: 25, max: 100, default: 75 }, backgroundToggle: true, note: "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.", - default: { x: 30, y: 954, enabled: true, opacity: 100, background: true } } + default: { x: 30, y: 954, enabled: true, opacity: 75, background: false } } metadata.widgetOrder = [...(metadata.widgetOrder || Object.keys(native.metadata.profiles.large.widgets)), "car_exit"] const widgets = Object.fromEntries(Object.entries(metadata.widgets).map(([id, widget]) => [id, { ...widget.default, ...(widget.resizable ? { size: widget.resizable.default } : {}) }])) @@ -26,6 +29,16 @@ const raw = { version: 1, document: doc, defaults: copy(doc), metadata, const data = editorSnapshot(raw) assert.equal(validSnapshot(data), true) assert.equal(validDocument(data.document, data.metadata), true) +assert.equal(data.document.layouts.large.car_exit.opacity, 75) +assert.equal(data.document.layouts.large.car_exit.background, false) +const resizedMonitor = copy(data.document) +resizedMonitor.layouts.large.driver_monitor.size = 192 +assert.equal(validDocument(resizedMonitor, data.metadata), true) +assert.equal(projectionPayload({document: resizedMonitor}, data.metadata).document.widgets.driver_monitor.size, 192) +resizedMonitor.layouts.large.driver_monitor.size = 193 +assert.equal(validDocument(resizedMonitor, data.metadata), false) +assert.match(OnroadLayoutPage.template, /switchRow\('Show background', selectedPosition.background === true\)/) +assert.doesNotMatch(OnroadLayoutPage.template, /type="checkbox" :checked="selectedPosition.background"/) const exitOpacity = copy(data.document) exitOpacity.layouts.large.car_exit.opacity = 24 assert.equal(validDocument(exitOpacity, data.metadata), false) diff --git a/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs b/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs index 5a567810ac..3ec111a15f 100644 --- a/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs +++ b/openpilot/starpilot/galaxy/tests/test_projection_map_widgets.mjs @@ -95,6 +95,7 @@ const vm = { editable: true, $refs: { preview: { getBoundingClientRect: () => ({ left: 0, top: 0, width: 2880, height: 1080 }), setPointerCapture() {}, hasPointerCapture: () => false, releasePointerCapture() {} } }, finishColorEdit() { this.state.history.group = null }, + sceneHidden() { return false }, } Object.defineProperties(vm, { canUndo: { get: () => vm.state.history.undo.length > 0 }, @@ -182,7 +183,7 @@ for (const [index, key] of ['nav_home', 'nav_work', 'bookmark', 'car_exit'].entr vm.finishColorEdit() const before = copy(vm.state.draft) vm.opacityInput({target: {value: String(25 + index * 10)}}) - vm.setWidgetBackground({target: {checked: false}}) + vm.setWidgetBackground('Off') assert.equal(vm.layout[key].opacity, 25 + index * 10) assert.equal(vm.layout[key].background, false) vm.undo() @@ -206,7 +207,7 @@ for (const [index, key] of ['nav_home', 'nav_work', 'bookmark', 'car_exit'].entr } for (const blocked of [{editable: false}, {state: {...vm.state, drag: {}}}, {state: {...vm.state, layerDrag: {}}}]) { OnroadLayoutPage.methods.opacityInput.call({...vm, ...blocked}, {target: {value: '90'}}) - OnroadLayoutPage.methods.setWidgetBackground.call({...vm, ...blocked}, {target: {checked: true}}) + OnroadLayoutPage.methods.setWidgetBackground.call({...vm, ...blocked}, 'On') assert.equal(vm.layout[key].opacity, 25 + index * 10) assert.equal(vm.layout[key].background, false) } diff --git a/openpilot/starpilot/galaxy/web/js/layout-preview.js b/openpilot/starpilot/galaxy/web/js/layout-preview.js index 4cf82c0d13..4c62ef9d58 100644 --- a/openpilot/starpilot/galaxy/web/js/layout-preview.js +++ b/openpilot/starpilot/galaxy/web/js/layout-preview.js @@ -1,4 +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 = [ @@ -44,7 +45,11 @@ export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}) if (id === "speed_limit_actions") return !longitudinal || scene === "slc_unavailable" || scene === "slc_display" || profile === "compact" && scene !== "slc_pending" const overlaps = (a, b) => a && b && a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y - const box = key => layout[key]?.enabled && widgets[key] ? { ...widgets[key], ...layout[key] } : null + const box = key => { + if (!layout[key]?.enabled || !widgets[key]) return null + const [width, height] = widgetSize(widgets[key], layout[key]) + return { ...widgets[key], ...layout[key], width, height } + } if (profile === "compact" && id === "driver_monitor" && longitudinal) return !!overlaps(box(id), box("max_speed")) if (profile === "compact" && id === "speed_limit") { if (scene === "slc_unavailable") return true diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js index cf262e0ac5..3580131e34 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js @@ -217,7 +217,7 @@ export function validSnapshot(data) { widget.opacity.default > widget.opacity.max)) return false if (widget.backgroundToggle != null && (!projection || !["nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) || widget.backgroundToggle !== true || !widget.opacity)) return false - if (widget.resizable && (widget.kind !== "steering_wheel" || !Number.isInteger(widget.resizable.min) || + if (widget.resizable && (!["steering_wheel", "driver_monitor"].includes(widget.kind) || !Number.isInteger(widget.resizable.min) || !Number.isInteger(widget.resizable.default) || !Number.isInteger(widget.resizable.max) || widget.resizable.default !== widget.width || widget.width !== widget.height || widget.resizable.min < 24 || widget.resizable.max > area.width || widget.resizable.max > area.height || diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout.js b/openpilot/starpilot/galaxy/web/js/onroad-layout.js index f95a94dc64..134e7ba960 100644 --- a/openpilot/starpilot/galaxy/web/js/onroad-layout.js +++ b/openpilot/starpilot/galaxy/web/js/onroad-layout.js @@ -1011,11 +1011,11 @@ export const OnroadLayoutPage = { this.selectedPosition.opacity = value this.recordChange(before, this.state.profile + ":" + this.state.selected + ":opacity") }, - setWidgetBackground(event) { + setWidgetBackground(value) { if (!this.editable || this.state.drag || this.state.layerDrag || !this.selectedWidget?.backgroundToggle) return this.finishColorEdit() const before = clone(this.state.draft) - this.selectedPosition.background = event.target.checked + this.selectedPosition.background = value === 'On' this.recordChange(before) this.state.notice = "" }, @@ -1365,7 +1365,7 @@ export const OnroadLayoutPage = {

Widgets snap to the screen edges, the centre and other widgets. Turn snapping off with the magnet button, or hold Option (Alt) while dragging.

General edit shows samples of all enabled widgets together. Driving scenes hide widgets that would be absent, and the eyes follow the scene. Eye changes stay with that scene; your General edit eyes are remembered.

The stopped timer appears in General edit and Stopped timer. Turn off editing aids to see the screen without outlines and guides.

-

The steering wheel can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.

+

The steering wheel and driver monitoring icon can be resized in each layout{{ projection ? ', and the map overlay takes any size and opacity' : '' }}. Existing display preferences and driving state still control when widgets appear.

Small speed-limit signs can overlap confirmation actions. Live confirmations hide an overlapping sign until the decision ends. Select either widget in the list to edit it.

Favorite tap areas apply to enabled slots. Speed-limit controls and the steering wheel take priority when the drawer is closed.

@@ -1441,8 +1441,8 @@ export const OnroadLayoutPage = { - +

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

{{ selectedWidget.note }}

diff --git a/openpilot/starpilot/system/android_auto/projection_layout.py b/openpilot/starpilot/system/android_auto/projection_layout.py index 850ef221c0..b3997f032b 100644 --- a/openpilot/starpilot/system/android_auto/projection_layout.py +++ b/openpilot/starpilot/system/android_auto/projection_layout.py @@ -6,7 +6,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, +from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, MODE_WIDGET, STOPPED_TIMER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, 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 @@ -111,8 +111,9 @@ def layout_metadata_for_viewport(viewport): 'note': "Returns to the car's own screen without disconnecting Android Auto. Always enabled and above other widgets.", 'default': {'x': 30, 'y': height - 30 - exit_h, 'enabled': True}} for key in ACTION_WIDGETS: - widgets[key].update(opacity={'min': CAR_EXIT_MIN_OPACITY if key == CAR_EXIT else 0, 'max': 100, 'default': 100}, backgroundToggle=True) - widgets[key]['default'].update(opacity=100, background=True) + opacity = 75 if key == CAR_EXIT else 100 + widgets[key].update(opacity={'min': CAR_EXIT_MIN_OPACITY if key == CAR_EXIT else 0, 'max': 100, 'default': opacity}, backgroundToggle=True) + widgets[key]['default'].update(opacity=opacity, background=key != CAR_EXIT) # Background opacity for the cruise and speed-limit card; its text stays solid. widgets['cruise_limits'].update(opacity={'min': 0, 'max': 100, 'default': 100}) widgets['cruise_limits']['default'].update(opacity=100) @@ -174,8 +175,8 @@ def default_layout_for_viewport(viewport, display=None): def placement_size(key, widget, placement): if key in DISPLAY_WIDGETS and placement.get('display') == 'icons': return FAVORITE_ICON_SIZE, FAVORITE_ICON_SIZE - if key == 'steering_wheel': - return placement.get('size', 192), placement.get('size', 192) + if widget.get('resizable'): + return (placement.get('size', widget['resizable']['default']),) * 2 if key == NAV_MAP: return placement['width'], placement['height'] return widget['width'], widget['height'] @@ -241,7 +242,12 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): placement.setdefault('background', True) if key == 'cruise_limits' and type(placement) is dict: placement.setdefault('opacity', 100) - fields = ({'x', 'y', 'enabled'} | ({'size'} if key == 'steering_wheel' else set()) | + if key == 'driver_monitor' and type(placement) is dict and 'size' not in placement: + for axis in ('x', 'y'): + if type(placement.get(axis)) in (int, float): + placement[axis] += 32 + placement['size'] = widget['resizable']['default'] + fields = ({'x', 'y', 'enabled'} | ({'size'} if widget.get('resizable') else set()) | ({'display'} if key in DISPLAY_WIDGETS else set()) | ({'opacity', 'background'} if key in ACTION_WIDGETS else set()) | ({'opacity'} if key == 'cruise_limits' else set()) | @@ -259,9 +265,10 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False): raise ValueError('Invalid cruise card opacity') if key == CAR_EXIT and placement['opacity'] < CAR_EXIT_MIN_OPACITY: raise ValueError('Exit to car opacity must be at least 25%') - size = placement.get('size', 192) - if key == 'steering_wheel' and (type(size) is not int or not WHEEL_SIZES['large'][0] <= size <= WHEEL_SIZES['large'][2]): - raise ValueError('Invalid steering wheel size') + if widget.get('resizable'): + size, limits = placement['size'], widget['resizable'] + if type(size) is not int or not limits['min'] <= size <= limits['max']: + raise ValueError('Invalid widget size') if key == NAV_MAP: box, opacity = widget['box'], widget['opacity'] if (type(placement['width']) is not int or not box['minWidth'] <= placement['width'] <= box['maxWidth'] or 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 fc4f4b5309..e6e0d188c6 100644 --- a/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py +++ b/openpilot/starpilot/system/android_auto/tests/test_projection_layout.py @@ -18,6 +18,33 @@ SCREEN = {'version': 1, 'width': 1280, 'height': 720, 'margin_width': 0, class TestProjectionLayout(unittest.TestCase): + def test_exit_defaults_and_monitor_scaling_preserve_saved_appearance(self): + document = default_layout(SCREEN) + metadata = layout_metadata(SCREEN) + self.assertEqual(document['widgets'][CAR_EXIT]['opacity'], 75) + self.assertIs(document['widgets'][CAR_EXIT]['background'], False) + self.assertIs(document['widgets'][CAR_EXIT]['enabled'], True) + self.assertEqual(metadata['widgets'][CAR_EXIT]['opacity']['default'], 75) + document['widgets'][CAR_EXIT].update(opacity=60, background=True) + monitor = document['widgets']['driver_monitor'] + self.assertEqual(monitor['size'], 128) + monitor.update(x=700, y=600) + del monitor['size'] + restored = validate_layout(document, SCREEN) + self.assertEqual(restored['widgets']['driver_monitor'], {'x': 732, 'y': 632, 'enabled': True, 'size': 128}) + self.assertEqual(restored['widgets'][CAR_EXIT], document['widgets'][CAR_EXIT]) + self.assertEqual(validate_layout(restored, SCREEN), restored) + for size in (64, 192): + restored['widgets']['driver_monitor']['size'] = size + decoded = decode_layout(json.dumps(restored).encode(), SCREEN) + projected = projection_customization(decoded, default_document()) + self.assertEqual(projected['layouts']['large']['driver_monitor']['size'], size) + self.assertEqual(placement_size('driver_monitor', metadata['widgets']['driver_monitor'], decoded['widgets']['driver_monitor']), (size, size)) + for size in (63, 193, True, 128.5): + restored['widgets']['driver_monitor']['size'] = size + with self.assertRaises(ValueError): + validate_layout(restored, SCREEN) + def test_stopped_timer_migrates_and_keeps_independent_placement_and_order(self): document = default_layout(SCREEN) metadata = layout_metadata(SCREEN) diff --git a/openpilot/starpilot/ui/layout_preview_renderer.py b/openpilot/starpilot/ui/layout_preview_renderer.py index 2e8f18d40c..a4fd625c1d 100644 --- a/openpilot/starpilot/ui/layout_preview_renderer.py +++ b/openpilot/starpilot/ui/layout_preview_renderer.py @@ -21,7 +21,7 @@ from openpilot.starpilot.curve_speed.status import CurveObservation from openpilot.starpilot.ui.onroad_state import ConditionalLead from openpilot.starpilot.ui.onroad_state import AlertSize, ObservationKind, OnroadAlert, OnroadState, SourceReading, SpeedLimitObservation from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice, OnroadAppearance -from openpilot.starpilot.ui.onroad_dm import monitor_visible +from openpilot.starpilot.ui.onroad_dm import draw_monitor_art, monitor_bounds, monitor_visible from openpilot.starpilot.ui.onroad_stopped_timer import PREVIEW_DURATION_S from openpilot.starpilot.ui.layout_preview_sidebar import SIDEBAR_WIDTH, render_sidebar from openpilot.starpilot.ui.presentation import BitmapFonts, FontRole, Profile, default_font_directory @@ -181,17 +181,11 @@ class _DriverMonitorArt: raise def render(self, _content: rl.Rectangle, state: OnroadState, *, top_icons: bool = False) -> None: - position = placement(state.customization, self.profile, "driver_monitor") if not monitor_visible(self.profile, state, fresh=True, onroad=True, top_icons=top_icons): return - size = 128 if self.profile == Profile.LARGE else 60 - x = position["x"] + (192 - size) / 2 if self.profile == Profile.LARGE else position["x"] - y = position["y"] + (192 - size) / 2 if self.profile == Profile.LARGE else position["y"] - self.renderer._rect = rl.Rectangle(x, y, size, size) - widget = PROFILES[str(self.profile)]["widgets"]["driver_monitor"] - draw_widget_frame(rl.Rectangle(position["x"], position["y"], widget["width"], widget["height"]), - state.customization, self.profile, "driver_monitor") - self.renderer._render(self.renderer._rect) + 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) def close(self) -> None: if rl.is_window_ready(): diff --git a/openpilot/starpilot/ui/onroad_customization.py b/openpilot/starpilot/ui/onroad_customization.py index 37a2b73e99..dac278de24 100644 --- a/openpilot/starpilot/ui/onroad_customization.py +++ b/openpilot/starpilot/ui/onroad_customization.py @@ -19,6 +19,8 @@ PALETTE = {"cardFill": "#000000A6", "cardBorder": "#C4CDD0B4", "text": "#FFFFFFF ROAD_COLORS = {"path": "#30FF9CFF", "pathEdge": "#00FF40FF", "laneLines": "#FFFFFFFF"} PATH_MODES = ("default", "color", "rainbow", "acceleration") WHEEL_SIZES = {"large": (144, 192, 240), "compact": (40, 50, 70)} +DM_SIZES = {"large": (64, 128, 192), "compact": (40, 60, 90)} +WIDGET_SIZES = {"steering_wheel": WHEEL_SIZES, "driver_monitor": DM_SIZES} class Bounds(TypedDict): @@ -32,6 +34,7 @@ class WidgetPlacement(TypedDict): x: float y: float enabled: bool + size: NotRequired[int] class Widget(TypedDict): @@ -77,7 +80,7 @@ PROFILES: dict[str, LayoutProfile] = { "cruise_limits": _widget("Cruise and speed limit", "cruise_limits", WIDTH, CARD_HEIGHT, 88, 75), "speed_limit_actions": _widget("Speed limit actions", "speed_limit_actions", WIDTH, ACTION_HEIGHT, ACTION_X, ACTION_Y), "steering_wheel": _widget("Steering wheel", "steering_wheel", 192, 192, 1588, 75), - "driver_monitor": _widget("Driver monitoring", "driver_monitor", 192, 192, 88, 808)}}, + "driver_monitor": _widget("Driver monitoring", "driver_monitor", 128, 128, 120, 840)}}, "compact": {"label": "Small UI", "width": 536, "height": 240, "bounds": {"x": 0, "y": 0, "width": 476, "height": 240}, "protectedWidget": "speed_limit_actions", @@ -92,6 +95,9 @@ PROFILES: dict[str, LayoutProfile] = { # Coordinates remain anchored at the buttons, preserving saved v4 placements. PROFILES["compact"]["widgets"]["speed_limit_actions"]["visualInsetTop"] = 32 +for profile, sizes in DM_SIZES.items(): + PROFILES[profile]['widgets']['driver_monitor']['default']['size'] = sizes[1] + LEGACY_WIDGETS = {"large": {"current_speed", "cruise_limits", "steering_wheel"}, "compact": {"max_speed", "speed_limit", "steering_wheel"}} DM_WIDGETS = {profile: keys | {"driver_monitor"} for profile, keys in LEGACY_WIDGETS.items()} @@ -226,8 +232,10 @@ def customization_metadata(): for name, profile in profiles.items(): for key, widget in profile["widgets"].items(): widget["colors"] = dict(WIDGET_COLORS[name].get(key, {})) - profile["widgets"]["steering_wheel"]["resizable"] = { - "min": WHEEL_SIZES[name][0], "default": WHEEL_SIZES[name][1], "max": WHEEL_SIZES[name][2]} + for key, sizes in WIDGET_SIZES.items(): + minimum, default, maximum = sizes[name] + profile["widgets"][key]["resizable"] = {"min": minimum, "default": default, "max": maximum} + profile["widgets"][key]["default"]["size"] = default for profile, data in profiles.items(): data["widgetOrder"] = list(DEFAULT_WIDGET_ORDER[profile]) data["runtimePreviewWidgets"] = list(RUNTIME_PREVIEW_WIDGETS[profile]) @@ -241,7 +249,7 @@ def customization_metadata(): def default_document(): return {"version": 7, "clock24Hour": False, "palette": dict(PALETTE), "roadColors": {profile: {} for profile in PROFILES}, "widgetColors": {profile: {} for profile in PROFILES}, "layouts": { - profile: {key: {**widget["default"], **({"size": WHEEL_SIZES[profile][1]} if key == "steering_wheel" else {})} + profile: {key: {**widget["default"], **({"size": WIDGET_SIZES[key][profile][1]} if key in WIDGET_SIZES else {})} for key, widget in data["widgets"].items()} for profile, data in PROFILES.items()}} @@ -348,6 +356,14 @@ def validate_document(value, *, _legacy_geometry=False): layouts = copy.deepcopy(layouts) for profile, data in PROFILES.items(): if type(layouts[profile]) is dict: + monitor = layouts[profile].get('driver_monitor') + if type(monitor) is dict and 'size' not in monitor: + # Tighten the old 192px box around its 128px icon without moving the icon. + if profile == 'large': + for axis in ('x', 'y'): + if type(monitor.get(axis)) in (int, float): + monitor[axis] += 32 + monitor['size'] = DM_SIZES[profile][1] missing = set(data["widgets"]) - set(layouts[profile]) if missing and missing <= OPTIONAL_WIDGETS: for optional in missing: @@ -392,15 +408,16 @@ def validate_document(value, *, _legacy_geometry=False): for key, widget in data["widgets"].items(): placement = layout[key] bounds = widget_bounds(profile, key) - expected = {"x", "y", "enabled"} | ({"size"} if key == "steering_wheel" and value["version"] >= 4 else set()) + scalable = key == 'driver_monitor' or key == 'steering_wheel' and value['version'] >= 4 + expected = {"x", "y", "enabled"} | ({"size"} if scalable else set()) if type(placement) is not dict or set(placement) != expected or type(placement["enabled"]) is not bool: raise ValueError("Invalid widget placement") size = WHEEL_SIZES[profile][1] - if key == "steering_wheel" and value["version"] >= 4: + if scalable: size = placement["size"] - minimum, _, maximum = WHEEL_SIZES[profile] + minimum, _, maximum = WIDGET_SIZES[key][profile] if type(size) is not int or not minimum <= size <= maximum: - raise ValueError("Invalid steering wheel size") + raise ValueError("Invalid widget size") # Only a gauge the saved layout actually placed moves; one filled in from defaults is already in the new box. saved_layout = value.get("layouts", {}).get(profile) if type(value.get("layouts")) is dict else None old_gauge = key == CEM_GAUGE_WIDGET and value["version"] < 7 and type(saved_layout) is dict and key in saved_layout @@ -410,12 +427,12 @@ def validate_document(value, *, _legacy_geometry=False): {'cruise_limits': (280, 412), 'speed_limit_actions': (280, 96)}) dimensions = legacy.get(key) if _legacy_geometry and profile == 'large' else None dimension = (dimensions[0 if axis == 'x' else 1] if dimensions else - size if key == 'steering_wheel' else widget[extent]) + size if key in WIDGET_SIZES else widget[extent]) if old_gauge: dimension = CEM_GAUGE_FRAME[0 if axis == 'x' else 1] if type(number) not in (int, float) or not math.isfinite(number) or not bounds[axis] <= number <= bounds[axis] + bounds[extent] - dimension: raise ValueError("Widget outside profile bounds") - result["layouts"][profile][key] = {**placement, **({"size": size} if key == "steering_wheel" else {})} + result["layouts"][profile][key] = {**placement, **({"size": size} if key in WIDGET_SIZES else {})} if old_gauge: result["layouts"][profile][key].update(x=placement["x"] + CEM_GAUGE_INSET[0], y=placement["y"] + CEM_GAUGE_INSET[1]) if profile == "compact": @@ -427,7 +444,7 @@ def validate_document(value, *, _legacy_geometry=False): placement = result["layouts"][profile][key] if key in OPTIONAL_WIDGETS and not placement["enabled"]: continue - width, height = ((placement["size"],) * 2 if key == "steering_wheel" else + width, height = ((placement["size"],) * 2 if key in WIDGET_SIZES else (widget["width"], widget["height"])) if (placement["x"] < actions["x"] + area["width"] and placement["x"] + width > actions["x"] and placement["y"] < actions["y"] + area["height"] and placement["y"] + height > actions["y"]): @@ -486,7 +503,7 @@ def widget_size(document, profile, 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"]) + return (position.get("size", WIDGET_SIZES[widget][str(profile)][1]),) * 2 if widget in WIDGET_SIZES else (default["width"], default["height"]) def offset(document, profile, widget): diff --git a/openpilot/starpilot/ui/onroad_dm.py b/openpilot/starpilot/ui/onroad_dm.py index 97eb174aa0..e780aff839 100644 --- a/openpilot/starpilot/ui/onroad_dm.py +++ b/openpilot/starpilot/ui/onroad_dm.py @@ -6,7 +6,7 @@ import pyray as rl from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.starpilot.ui.onroad_customization import PROFILES, placement +from openpilot.starpilot.ui.onroad_customization import PROFILES, placement, widget_size from openpilot.starpilot.ui.onroad_state import AlertSize from openpilot.starpilot.ui.onroad_widget_style import draw_widget_frame from openpilot.starpilot.ui.presentation import Profile @@ -26,11 +26,26 @@ def valid_observation(monitor, driver): def monitor_bounds(document, profile, is_rhd): position = placement(document, profile, 'driver_monitor') widget = PROFILES[str(profile)]['widgets']['driver_monitor'] + width, height = widget_size(document, profile, 'driver_monitor') x, y = position['x'], position['y'] if profile == Profile.LARGE and is_rhd and (x, y) == (widget['default']['x'], widget['default']['y']): bounds = PROFILES['large']['bounds'] - x = 2 * bounds['x'] + bounds['width'] - x - widget['width'] - return rl.Rectangle(x, y, widget['width'], widget['height']) + x = 2 * bounds['x'] + bounds['width'] - x - width + return rl.Rectangle(x, y, width, height) + + +def draw_monitor_art(renderer, bounds, profile): + """Scale the native icon and cone together inside the saved square.""" + base = 128 if profile == Profile.LARGE else 60 + renderer._rect = rl.Rectangle(bounds.x, bounds.y, base, base) + rl.rl_push_matrix() + try: + rl.rl_translatef(bounds.x, bounds.y, 0) + rl.rl_scalef(bounds.width / base, bounds.height / base, 1) + rl.rl_translatef(-bounds.x, -bounds.y, 0) + renderer._render(renderer._rect) + finally: + rl.rl_pop_matrix() def monitor_visible(profile, state, *, fresh, onroad, top_icons): @@ -77,8 +92,7 @@ class DriverMonitorLayer: 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) - size = 128 if self.profile == Profile.LARGE else 60 - self.renderer.set_position(bounds.x + (bounds.width - size) / 2, bounds.y + (bounds.height - size) / 2) + self.renderer.set_position(bounds.x, bounds.y) if ready: self.renderer._update_state() else: @@ -87,4 +101,4 @@ class DriverMonitorLayer: self.renderer._fade_filter.update(0.35 if visible else 0.0) if visible: draw_widget_frame(bounds, state.customization, self.profile, "driver_monitor") - self.renderer._render(self.renderer._rect) + draw_monitor_art(self.renderer, bounds, self.profile) diff --git a/openpilot/starpilot/ui/tests/test_onroad_dm.py b/openpilot/starpilot/ui/tests/test_onroad_dm.py index 44a1e2e223..e363417b24 100644 --- a/openpilot/starpilot/ui/tests/test_onroad_dm.py +++ b/openpilot/starpilot/ui/tests/test_onroad_dm.py @@ -6,7 +6,7 @@ import unittest from dataclasses import replace from types import SimpleNamespace as NS -from openpilot.starpilot.ui.onroad_customization import default_document, validate_document +from openpilot.starpilot.ui.onroad_customization import DM_SIZES, customization_metadata, default_document, validate_document, widget_size from openpilot.starpilot.ui.onroad_dm import DriverMonitorLayer, StableDriverStateRenderer, monitor_bounds, monitor_visible, valid_observation from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadAlert, OnroadState, SpeedLimitObservation from openpilot.starpilot.ui.appearance_preferences import CameraViewChoice @@ -86,14 +86,36 @@ class TestDriverMonitor(unittest.TestCase): document = default_document() left = monitor_bounds(document, Profile.LARGE, False) right = monitor_bounds(document, Profile.LARGE, True) - self.assertEqual((left.x, left.y, left.width, left.height), (88, 808, 192, 192)) - self.assertEqual((right.x, right.y), (1580, 808)) + self.assertEqual((left.x, left.y, left.width, left.height), (120, 840, 128, 128)) + self.assertEqual((right.x, right.y), (1612, 840)) document['layouts']['large']['driver_monitor'].update(x=700.5, y=600.25) custom = monitor_bounds(validate_document(document), Profile.LARGE, True) self.assertEqual((custom.x, custom.y), (700.5, 600.25)) compact = monitor_bounds(default_document(), Profile.COMPACT, True) self.assertEqual((compact.x, compact.y, compact.width, compact.height), (16, 10, 60, 60)) + def test_saved_monitor_box_tightens_without_moving_icon_and_round_trips_size(self): + document = default_document() + document['layouts']['large']['driver_monitor'] = {'x': 700, 'y': 600, 'enabled': True} + migrated = validate_document(document) + self.assertEqual(migrated['layouts']['large']['driver_monitor'], {'x': 732, 'y': 632, 'enabled': True, 'size': 128}) + self.assertEqual(document['layouts']['large']['driver_monitor']['x'], 700) + self.assertEqual(validate_document(migrated), migrated) + for profile, (minimum, default, maximum) in DM_SIZES.items(): + metadata = customization_metadata()['profiles'][profile]['widgets']['driver_monitor'] + self.assertEqual(metadata['resizable'], {'min': minimum, 'default': default, 'max': maximum}) + for size in (minimum, default, maximum): + saved = default_document() + saved['layouts'][profile]['driver_monitor']['size'] = size + restored = validate_document(saved) + self.assertEqual(widget_size(restored, profile, 'driver_monitor'), (size, size)) + bounds = monitor_bounds(restored, Profile(profile), False) + self.assertEqual((bounds.width, bounds.height), (size, size)) + for size in (minimum - 1, maximum + 1, True, 64.5): + saved['layouts'][profile]['driver_monitor']['size'] = size + with self.assertRaises(ValueError): + validate_document(saved) + def test_visibility_reserves_critical_alerts_and_respects_saved_choice(self): state = road() for profile in Profile: @@ -178,7 +200,7 @@ class TestDriverMonitor(unittest.TestCase): patch.object(layer, 'renderer', NS(set_should_draw=Mock(), set_position=Mock(), _update_state=Mock(), _render=Mock(), _rect=object()), create=True) ) layer.render(replace(road(), customization=document), monitor=monitor, driver=driver, fresh=True, onroad=True) - layer.renderer.set_position.assert_called_once_with(732.5, 632.25) + layer.renderer.set_position.assert_called_once_with(700.5, 600.25) layer.renderer._update_state.assert_called_once() layer.renderer._render.assert_called_once_with(layer.renderer._rect)