StarPort: Keep saved layouts when an update adds or changes widgets

This commit is contained in:
Danny
2026-11-06 18:36:00 -08:00
parent fca2d5ce78
commit 15c21ec776
2 changed files with 69 additions and 4 deletions
@@ -7,7 +7,7 @@ from pathlib import Path
from openpilot.starpilot.system.starport.identity import DATA_DIR
from openpilot.starpilot.system.starport.display_profile import screen_geometry
from openpilot.starpilot.ui.onroad_customization import (CEM_GAUGE_WIDGET, CLOCK_WIDGET, JETLINK_WIDGET, MODE_WIDGET,
STOPPED_TIMER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, NAV_CARD_SIZE,
STOPPED_TIMER_WIDGET, WEATHER_WIDGET, CEM_GAUGE_FRAME, CEM_GAUGE_INSET, MAP_MIN_SIZE, NAV_CARD_SIZE,
SPEED_ALIGNMENTS, customization_metadata, validate_map_box, validate_widget_order)
from openpilot.starpilot.ui.onroad_stopped_timer import timer_size
from openpilot.starpilot.ui.onroad_torque_geometry import maximum_footprint
@@ -223,7 +223,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
type(value['version']) is not int or value['version'] not in (1, 2, 3, 4, 5) 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}):
not set(metadata['widgets']) - set(value['widgets']) <= {CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, WEATHER_WIDGET, *PROJECTION_WIDGETS}):
raise ValueError('Projection layout does not match saved screen')
result = copy.deepcopy(value)
if 'clock24Hour' in value and type(value['clock24Hour']) is not bool:
@@ -243,7 +243,7 @@ def validate_layout_for_viewport(value, viewport, *, _legacy_geometry=False):
if 'widgetOrder' in value:
order = value['widgetOrder']
if type(order) is list:
order = [*order, *(key for key in (CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, *PROJECTION_WIDGETS) if key not in order)]
order = [*order, *(key for key in (CEM_GAUGE_WIDGET, MODE_WIDGET, CLOCK_WIDGET, WEATHER_WIDGET, *PROJECTION_WIDGETS) if key not in order)]
order = validate_widget_order(order, metadata['widgets'])
# Older or hand-edited documents may put the escape control underneath
# another widget. Preserve their order while restoring this safety layer.
@@ -362,7 +362,52 @@ def decode_layout_for_viewport(raw, viewport):
exit_placement = widgets.get(CAR_EXIT) if type(widgets) is dict else None
if type(exit_placement) is dict and type(exit_placement.get('opacity')) is int and 0 <= exit_placement['opacity'] < CAR_EXIT_MIN_OPACITY:
exit_placement['opacity'] = CAR_EXIT_MIN_OPACITY
return validate_layout_for_viewport(value, viewport)
try:
return validate_layout_for_viewport(value, viewport)
except ValueError:
return _salvage_layout(value, viewport)
def _salvage_layout(value, viewport):
"""Keep every saved placement an update still accepts, so one stale widget does not reset the whole layout."""
metadata = layout_metadata_for_viewport(viewport)
if type(value) is not dict or value.get('canvas') != {key: metadata[key] for key in ('width', 'height')} or type(value.get('widgets')) is not dict:
raise ValueError('Projection layout does not match saved screen')
defaults = {key: dict(widget['default']) for key, widget in metadata['widgets'].items()}
saved = {key: placement for key, placement in value['widgets'].items() if key in defaults}
result = {'version': value.get('version'), 'canvas': value['canvas'], 'widgets': dict(defaults)}
def accepts(candidate):
try:
validate_layout_for_viewport(candidate, viewport)
return True
except (ValueError, TypeError, KeyError):
return False
if not accepts(result):
result['version'] = 5
for field in ('clock24Hour', 'largeUiGammaTrial', *DISPLAY_DEFAULTS):
if field in value and accepts({**result, field: value[field]}):
result[field] = value[field]
for key, placement in saved.items():
if type(placement) is not dict:
continue
merged = {**defaults[key], **{field: placement[field] for field in placement if field in defaults[key]}}
clamped = dict(merged)
try:
bounds, size = metadata['bounds'], placement_size(key, metadata['widgets'][key], merged)
for (axis, extent), dimension in zip((('x', 'width'), ('y', 'height')), size, strict=True):
clamped[axis] = max(bounds[axis], min(clamped[axis], bounds[axis] + bounds[extent] - dimension))
except (KeyError, TypeError, ValueError):
pass
for candidate in (placement, merged, clamped):
trial = {**result, 'widgets': {**result['widgets'], key: candidate}}
if accepts(trial):
result = trial
break
if 'widgetOrder' in value and accepts({**result, 'widgetOrder': value['widgetOrder']}):
result['widgetOrder'] = value['widgetOrder']
return validate_layout_for_viewport(result, viewport)
def projection_customization(document, base_customization):
@@ -34,6 +34,26 @@ class TestProjectionLayout(unittest.TestCase):
self.assertGreaterEqual(placed[axis], metadata['bounds'][axis])
self.assertLessEqual(placed[axis] + widget[extent], metadata['bounds'][axis] + metadata['bounds'][extent])
def test_saved_layout_survives_a_new_widget_and_a_stale_one(self):
document = default_layout(SCREEN)
document['widgets']['steering_wheel'].update(x=500, y=400, enabled=False)
del document['widgets']['weather']
document['widgets']['retired_widget'] = {'x': 0, 'y': 0, 'enabled': True}
document['widgets']['nav_card']['x'] = 99999
decoded = decode_layout(json.dumps(document).encode(), SCREEN)
self.assertEqual({key: decoded['widgets']['steering_wheel'][key] for key in ('x', 'y', 'enabled')}, {'x': 500, 'y': 400, 'enabled': False})
self.assertEqual(decoded['widgets']['weather'], default_layout(SCREEN)['widgets']['weather'])
self.assertNotIn('retired_widget', decoded['widgets'])
metadata = layout_metadata(SCREEN)
bounds, card = metadata['bounds'], metadata['widgets']['nav_card']
self.assertEqual(decoded['widgets']['nav_card']['x'], bounds['x'] + bounds['width'] - card['width'])
def test_salvage_still_rejects_a_different_screen(self):
document = default_layout(SCREEN)
document['canvas'] = {'width': 1, 'height': 1}
with self.assertRaises(ValueError):
decode_layout(json.dumps(document).encode(), SCREEN)
def test_unsaved_layer_order_draws_the_editor_default(self):
document = default_layout(SCREEN)
self.assertNotIn('widgetOrder', document)