diff --git a/selfdrive/assets/icons_mici/adb_short.png b/selfdrive/assets/icons_mici/adb_short.png new file mode 100644 index 000000000..162b4e71d Binary files /dev/null and b/selfdrive/assets/icons_mici/adb_short.png differ diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_hover.png deleted file mode 100644 index b098870b7..000000000 Binary files a/selfdrive/assets/icons_mici/buttons/button_circle_hover.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png new file mode 100644 index 000000000..90a5af125 Binary files /dev/null and b/selfdrive/assets/icons_mici/buttons/button_circle_pressed.png differ diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png deleted file mode 100644 index e42e22982..000000000 Binary files a/selfdrive/assets/icons_mici/buttons/button_circle_red_hover.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png new file mode 100644 index 000000000..8349feb40 Binary files /dev/null and b/selfdrive/assets/icons_mici/buttons/button_circle_red_pressed.png differ diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle.png b/selfdrive/assets/icons_mici/buttons/button_rectangle.png index 0a68722c4..c9731f44a 100644 Binary files a/selfdrive/assets/icons_mici/buttons/button_rectangle.png and b/selfdrive/assets/icons_mici/buttons/button_rectangle.png differ diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png index e4ef99b82..2a9ea9dfc 100644 Binary files a/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png and b/selfdrive/assets/icons_mici/buttons/button_rectangle_disabled.png differ diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png deleted file mode 100644 index 6c98434f5..000000000 Binary files a/selfdrive/assets/icons_mici/buttons/button_rectangle_hover.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png index 43ed496ad..3adf0d1b3 100644 Binary files a/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png and b/selfdrive/assets/icons_mici/buttons/button_rectangle_pressed.png differ diff --git a/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png index ae6111d12..03aa4cd1b 100644 Binary files a/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png and b/selfdrive/assets/icons_mici/buttons/toggle_dot_disabled.png differ diff --git a/selfdrive/assets/icons_mici/exclamation_point.png b/selfdrive/assets/icons_mici/exclamation_point.png index 8e07b6ced..7a5773d7a 100644 Binary files a/selfdrive/assets/icons_mici/exclamation_point.png and b/selfdrive/assets/icons_mici/exclamation_point.png differ diff --git a/selfdrive/assets/icons_mici/experimental_mode.png b/selfdrive/assets/icons_mici/experimental_mode.png index 73713dd31..db699bc8d 100644 Binary files a/selfdrive/assets/icons_mici/experimental_mode.png and b/selfdrive/assets/icons_mici/experimental_mode.png differ diff --git a/selfdrive/assets/icons_mici/microphone.png b/selfdrive/assets/icons_mici/microphone.png index b07d098dd..c64f871d8 100644 Binary files a/selfdrive/assets/icons_mici/microphone.png and b/selfdrive/assets/icons_mici/microphone.png differ diff --git a/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png index 604ef5ba2..31bedba51 100644 Binary files a/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png and b/selfdrive/assets/icons_mici/offroad_alerts/green_wheel.png differ diff --git a/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png index d7188bda4..ff1f7cdf6 100644 Binary files a/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png and b/selfdrive/assets/icons_mici/offroad_alerts/orange_warning.png differ diff --git a/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png index 6913689a6..a726f528a 100644 Binary files a/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png and b/selfdrive/assets/icons_mici/offroad_alerts/red_warning.png differ diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_left.png b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png index 2f8cab958..e50c22c89 100644 Binary files a/selfdrive/assets/icons_mici/onroad/blind_spot_left.png and b/selfdrive/assets/icons_mici/onroad/blind_spot_left.png differ diff --git a/selfdrive/assets/icons_mici/onroad/blind_spot_right.png b/selfdrive/assets/icons_mici/onroad/blind_spot_right.png deleted file mode 100644 index 40307b3c0..000000000 Binary files a/selfdrive/assets/icons_mici/onroad/blind_spot_right.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/onroad/bookmark.png b/selfdrive/assets/icons_mici/onroad/bookmark.png index dfeb0d7f5..929654f01 100644 Binary files a/selfdrive/assets/icons_mici/onroad/bookmark.png and b/selfdrive/assets/icons_mici/onroad/bookmark.png differ diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png index 4fb4541fb..b464f3c10 100644 Binary files a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png and b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_background.png differ diff --git a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png index 7341b4be0..223ff6979 100644 Binary files a/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png and b/selfdrive/assets/icons_mici/onroad/driver_monitoring/dm_person.png differ diff --git a/selfdrive/assets/icons_mici/onroad/eye_fill.png b/selfdrive/assets/icons_mici/onroad/eye_fill.png index cc6d7666a..438b3bc8c 100644 Binary files a/selfdrive/assets/icons_mici/onroad/eye_fill.png and b/selfdrive/assets/icons_mici/onroad/eye_fill.png differ diff --git a/selfdrive/assets/icons_mici/onroad/eye_orange.png b/selfdrive/assets/icons_mici/onroad/eye_orange.png index 3d304e462..8cd929707 100644 Binary files a/selfdrive/assets/icons_mici/onroad/eye_orange.png and b/selfdrive/assets/icons_mici/onroad/eye_orange.png differ diff --git a/selfdrive/assets/icons_mici/onroad/onroad_fade.png b/selfdrive/assets/icons_mici/onroad/onroad_fade.png index e2ad7668d..de0388580 100644 Binary files a/selfdrive/assets/icons_mici/onroad/onroad_fade.png and b/selfdrive/assets/icons_mici/onroad/onroad_fade.png differ diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_left.png b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png index 32dce91da..bb7e5970d 100644 Binary files a/selfdrive/assets/icons_mici/onroad/turn_signal_left.png and b/selfdrive/assets/icons_mici/onroad/turn_signal_left.png differ diff --git a/selfdrive/assets/icons_mici/onroad/turn_signal_right.png b/selfdrive/assets/icons_mici/onroad/turn_signal_right.png deleted file mode 100644 index 3cccd96e6..000000000 Binary files a/selfdrive/assets/icons_mici/onroad/turn_signal_right.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings.png b/selfdrive/assets/icons_mici/settings.png index d6610ce69..91a4a1ca6 100644 Binary files a/selfdrive/assets/icons_mici/settings.png and b/selfdrive/assets/icons_mici/settings.png differ diff --git a/selfdrive/assets/icons_mici/settings/comma_icon.png b/selfdrive/assets/icons_mici/settings/comma_icon.png index e0b7d460e..71007e260 100644 Binary files a/selfdrive/assets/icons_mici/settings/comma_icon.png and b/selfdrive/assets/icons_mici/settings/comma_icon.png differ diff --git a/selfdrive/assets/icons_mici/settings/developer/ssh.png b/selfdrive/assets/icons_mici/settings/developer/ssh.png index e85cf623b..a99becf24 100644 Binary files a/selfdrive/assets/icons_mici/settings/developer/ssh.png and b/selfdrive/assets/icons_mici/settings/developer/ssh.png differ diff --git a/selfdrive/assets/icons_mici/settings/developer_icon.png b/selfdrive/assets/icons_mici/settings/developer_icon.png index 42077fb61..b6e74098d 100644 Binary files a/selfdrive/assets/icons_mici/settings/developer_icon.png and b/selfdrive/assets/icons_mici/settings/developer_icon.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/cameras.png b/selfdrive/assets/icons_mici/settings/device/cameras.png index 2029b0da8..d51a1f1fa 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/cameras.png and b/selfdrive/assets/icons_mici/settings/device/cameras.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/info.png b/selfdrive/assets/icons_mici/settings/device/info.png index fa538f8ac..05cfb723e 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/info.png and b/selfdrive/assets/icons_mici/settings/device/info.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/language.png b/selfdrive/assets/icons_mici/settings/device/language.png deleted file mode 100644 index 02b504fd8..000000000 Binary files a/selfdrive/assets/icons_mici/settings/device/language.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/device/lkas.png b/selfdrive/assets/icons_mici/settings/device/lkas.png index a7b4c23bb..b6b10c8d2 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/lkas.png and b/selfdrive/assets/icons_mici/settings/device/lkas.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/pair.png b/selfdrive/assets/icons_mici/settings/device/pair.png index 5e50b2fdb..0c9a19c44 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/pair.png and b/selfdrive/assets/icons_mici/settings/device/pair.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/power.png b/selfdrive/assets/icons_mici/settings/device/power.png index 9afe0bbb6..2b06febc1 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/power.png and b/selfdrive/assets/icons_mici/settings/device/power.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/reboot.png b/selfdrive/assets/icons_mici/settings/device/reboot.png index 84ce963cc..a107f9ebe 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/reboot.png and b/selfdrive/assets/icons_mici/settings/device/reboot.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/uninstall.png b/selfdrive/assets/icons_mici/settings/device/uninstall.png index 08bb9f7b3..6db14985d 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/uninstall.png and b/selfdrive/assets/icons_mici/settings/device/uninstall.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/up_to_date.png b/selfdrive/assets/icons_mici/settings/device/up_to_date.png index 8144386c6..ed87ba667 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/up_to_date.png and b/selfdrive/assets/icons_mici/settings/device/up_to_date.png differ diff --git a/selfdrive/assets/icons_mici/settings/device/update.png b/selfdrive/assets/icons_mici/settings/device/update.png index 6dcc59e60..277d27ea9 100644 Binary files a/selfdrive/assets/icons_mici/settings/device/update.png and b/selfdrive/assets/icons_mici/settings/device/update.png differ diff --git a/selfdrive/assets/icons_mici/settings/device_icon.png b/selfdrive/assets/icons_mici/settings/device_icon.png index 3a15e6bbf..d56e6679d 100644 Binary files a/selfdrive/assets/icons_mici/settings/device_icon.png and b/selfdrive/assets/icons_mici/settings/device_icon.png differ diff --git a/selfdrive/assets/icons_mici/settings/firehose.png b/selfdrive/assets/icons_mici/settings/firehose.png new file mode 100644 index 000000000..c1e53f9b5 Binary files /dev/null and b/selfdrive/assets/icons_mici/settings/firehose.png differ diff --git a/selfdrive/assets/icons_mici/settings/galaxy.png b/selfdrive/assets/icons_mici/settings/galaxy.png new file mode 100644 index 000000000..1cc55ea81 Binary files /dev/null and b/selfdrive/assets/icons_mici/settings/galaxy.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/backspace.png b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png index ad5b5402f..ebea79638 100644 Binary files a/selfdrive/assets/icons_mici/settings/keyboard/backspace.png and b/selfdrive/assets/icons_mici/settings/keyboard/backspace.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png index ce27e4ab2..97c29255f 100644 Binary files a/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png and b/selfdrive/assets/icons_mici/settings/keyboard/caps_lock.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png index a762bc6dd..e2e4526f4 100644 Binary files a/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png and b/selfdrive/assets/icons_mici/settings/keyboard/caps_lower.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png index ff44ff1c7..348a4c14f 100644 Binary files a/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png and b/selfdrive/assets/icons_mici/settings/keyboard/caps_upper.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/confirm.png b/selfdrive/assets/icons_mici/settings/keyboard/confirm.png deleted file mode 100644 index 375ceb5fd..000000000 Binary files a/selfdrive/assets/icons_mici/settings/keyboard/confirm.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/enter.png b/selfdrive/assets/icons_mici/settings/keyboard/enter.png new file mode 100644 index 000000000..b853d98d7 Binary files /dev/null and b/selfdrive/assets/icons_mici/settings/keyboard/enter.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/enter_disabled.png b/selfdrive/assets/icons_mici/settings/keyboard/enter_disabled.png new file mode 100644 index 000000000..df54c043c Binary files /dev/null and b/selfdrive/assets/icons_mici/settings/keyboard/enter_disabled.png differ diff --git a/selfdrive/assets/icons_mici/settings/keyboard/space.png b/selfdrive/assets/icons_mici/settings/keyboard/space.png index 86327de2c..0d5bc90ef 100644 Binary files a/selfdrive/assets/icons_mici/settings/keyboard/space.png and b/selfdrive/assets/icons_mici/settings/keyboard/space.png differ diff --git a/selfdrive/assets/icons_mici/settings/manual_icon.png b/selfdrive/assets/icons_mici/settings/manual_icon.png deleted file mode 100644 index a6a1bb6ac..000000000 Binary files a/selfdrive/assets/icons_mici/settings/manual_icon.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png index ca7fda444..6899a72ce 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png and b/selfdrive/assets/icons_mici/settings/network/cell_strength_full.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png index 71da5bcf2..751ce84ac 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png and b/selfdrive/assets/icons_mici/settings/network/cell_strength_high.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png index e806f1ac2..ff7d21692 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png and b/selfdrive/assets/icons_mici/settings/network/cell_strength_low.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png index c8ad72678..c69d66750 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png and b/selfdrive/assets/icons_mici/settings/network/cell_strength_medium.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png index 41eb180bc..63846f360 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png and b/selfdrive/assets/icons_mici/settings/network/cell_strength_none.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button.png deleted file mode 100644 index 88967f7f4..000000000 Binary files a/selfdrive/assets/icons_mici/settings/network/new/connect_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png deleted file mode 100644 index fd9c2e22f..000000000 Binary files a/selfdrive/assets/icons_mici/settings/network/new/connect_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png deleted file mode 100644 index 8dd295bf9..000000000 Binary files a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png b/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png deleted file mode 100644 index cf39bc9fe..000000000 Binary files a/selfdrive/assets/icons_mici/settings/network/new/full_connect_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/lock.png b/selfdrive/assets/icons_mici/settings/network/new/lock.png index b2096751b..53f60e187 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/new/lock.png and b/selfdrive/assets/icons_mici/settings/network/new/lock.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/trash.png b/selfdrive/assets/icons_mici/settings/network/new/trash.png index fe11f6486..8805ac133 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/new/trash.png and b/selfdrive/assets/icons_mici/settings/network/new/trash.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png b/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png deleted file mode 100644 index 18b593b7a..000000000 Binary files a/selfdrive/assets/icons_mici/settings/network/new/wifi_selected.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/network/tethering.png b/selfdrive/assets/icons_mici/settings/network/tethering.png index c5031642a..239a2ce3b 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/tethering.png and b/selfdrive/assets/icons_mici/settings/network/tethering.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png index e38135968..8297266f8 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png and b/selfdrive/assets/icons_mici/settings/network/wifi_strength_full.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png index 75f92e91b..93e66dc9e 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png and b/selfdrive/assets/icons_mici/settings/network/wifi_strength_low.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png index 1b9bebcb5..e5145cf32 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png and b/selfdrive/assets/icons_mici/settings/network/wifi_strength_medium.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png index 684657d6b..641c2cd5e 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png and b/selfdrive/assets/icons_mici/settings/network/wifi_strength_none.png differ diff --git a/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png index 4fca37254..dfb36eba8 100644 Binary files a/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png and b/selfdrive/assets/icons_mici/settings/network/wifi_strength_slash.png differ diff --git a/selfdrive/assets/icons_mici/settings/toggles_icon.png b/selfdrive/assets/icons_mici/settings/toggles_icon.png deleted file mode 100644 index 191d1bf57..000000000 Binary files a/selfdrive/assets/icons_mici/settings/toggles_icon.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png b/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png deleted file mode 100644 index f0e966354..000000000 Binary files a/selfdrive/assets/icons_mici/settings/vertical_scroll_indicator.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/back_new.png b/selfdrive/assets/icons_mici/setup/back_new.png deleted file mode 100644 index bd57a8d06..000000000 Binary files a/selfdrive/assets/icons_mici/setup/back_new.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/cancel.png b/selfdrive/assets/icons_mici/setup/cancel.png new file mode 100644 index 000000000..b785c16b9 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/cancel.png differ diff --git a/selfdrive/assets/icons_mici/setup/continue.png b/selfdrive/assets/icons_mici/setup/continue.png new file mode 100644 index 000000000..48641b106 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/continue.png differ diff --git a/selfdrive/assets/icons_mici/setup/continue_disabled.png b/selfdrive/assets/icons_mici/setup/continue_disabled.png new file mode 100644 index 000000000..f6cb6723f Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/continue_disabled.png differ diff --git a/selfdrive/assets/icons_mici/setup/continue_pressed.png b/selfdrive/assets/icons_mici/setup/continue_pressed.png new file mode 100644 index 000000000..e476cd846 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/continue_pressed.png differ diff --git a/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_check.png b/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_check.png index 72bc11529..f6ef2063e 100644 Binary files a/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_check.png and b/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_check.png differ diff --git a/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_question.png b/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_question.png index 1b8b8721a..c0abb7b14 100644 Binary files a/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_question.png and b/selfdrive/assets/icons_mici/setup/driver_monitoring/dm_question.png differ diff --git a/selfdrive/assets/icons_mici/setup/factory_reset.png b/selfdrive/assets/icons_mici/setup/factory_reset.png new file mode 100644 index 000000000..f5d75c286 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/factory_reset.png differ diff --git a/selfdrive/assets/icons_mici/setup/green_button.png b/selfdrive/assets/icons_mici/setup/green_button.png deleted file mode 100644 index 7d01f054b..000000000 Binary files a/selfdrive/assets/icons_mici/setup/green_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/green_button_pressed.png b/selfdrive/assets/icons_mici/setup/green_button_pressed.png deleted file mode 100644 index f4fa13033..000000000 Binary files a/selfdrive/assets/icons_mici/setup/green_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/green_car.png b/selfdrive/assets/icons_mici/setup/green_car.png deleted file mode 100644 index 3820b8a52..000000000 Binary files a/selfdrive/assets/icons_mici/setup/green_car.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/green_dm.png b/selfdrive/assets/icons_mici/setup/green_dm.png index 2c9ce2ed3..96907d67c 100644 Binary files a/selfdrive/assets/icons_mici/setup/green_dm.png and b/selfdrive/assets/icons_mici/setup/green_dm.png differ diff --git a/selfdrive/assets/icons_mici/setup/green_info.png b/selfdrive/assets/icons_mici/setup/green_info.png index 3693d1608..0b860c86f 100644 Binary files a/selfdrive/assets/icons_mici/setup/green_info.png and b/selfdrive/assets/icons_mici/setup/green_info.png differ diff --git a/selfdrive/assets/icons_mici/setup/green_pedal.png b/selfdrive/assets/icons_mici/setup/green_pedal.png deleted file mode 100644 index 075093828..000000000 Binary files a/selfdrive/assets/icons_mici/setup/green_pedal.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/medium_button_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_bg.png deleted file mode 100644 index 8cbcf2329..000000000 Binary files a/selfdrive/assets/icons_mici/setup/medium_button_bg.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png b/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png deleted file mode 100644 index 905c6c92e..000000000 Binary files a/selfdrive/assets/icons_mici/setup/medium_button_pressed_bg.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/orange_dm.png b/selfdrive/assets/icons_mici/setup/orange_dm.png index 0678e9f88..87ffdaf1f 100644 Binary files a/selfdrive/assets/icons_mici/setup/orange_dm.png and b/selfdrive/assets/icons_mici/setup/orange_dm.png differ diff --git a/selfdrive/assets/icons_mici/setup/red_warning.png b/selfdrive/assets/icons_mici/setup/red_warning.png index 925871da3..ea712d50f 100644 Binary files a/selfdrive/assets/icons_mici/setup/red_warning.png and b/selfdrive/assets/icons_mici/setup/red_warning.png differ diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button.png b/selfdrive/assets/icons_mici/setup/reset/small_button.png deleted file mode 100644 index 15d1215ef..000000000 Binary files a/selfdrive/assets/icons_mici/setup/reset/small_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png deleted file mode 100644 index 44cbbd195..000000000 Binary files a/selfdrive/assets/icons_mici/setup/reset/small_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button.png b/selfdrive/assets/icons_mici/setup/reset/wide_button.png deleted file mode 100644 index 6b0980104..000000000 Binary files a/selfdrive/assets/icons_mici/setup/reset/wide_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png b/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png deleted file mode 100644 index e5d9f4259..000000000 Binary files a/selfdrive/assets/icons_mici/setup/reset/wide_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/reset_failed.png b/selfdrive/assets/icons_mici/setup/reset_failed.png new file mode 100644 index 000000000..72491fbea Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/reset_failed.png differ diff --git a/selfdrive/assets/icons_mici/setup/restore.png b/selfdrive/assets/icons_mici/setup/restore.png index b7c4c0da1..db98a948c 100644 Binary files a/selfdrive/assets/icons_mici/setup/restore.png and b/selfdrive/assets/icons_mici/setup/restore.png differ diff --git a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png b/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png deleted file mode 100644 index 5a140d2e2..000000000 Binary files a/selfdrive/assets/icons_mici/setup/scroll_down_indicator.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill.png b/selfdrive/assets/icons_mici/setup/small_red_pill.png deleted file mode 100644 index e8b38046d..000000000 Binary files a/selfdrive/assets/icons_mici/setup/small_red_pill.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png b/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png deleted file mode 100644 index 2e6aca32f..000000000 Binary files a/selfdrive/assets/icons_mici/setup/small_red_pill_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png index 8ec333d5b..7c9e6f74c 100644 Binary files a/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png and b/selfdrive/assets/icons_mici/setup/small_slider/slider_arrow.png differ diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png deleted file mode 100644 index 291dbd607..000000000 Binary files a/selfdrive/assets/icons_mici/setup/small_slider/slider_bg.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle_pressed.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle_pressed.png new file mode 100644 index 000000000..27fbe38d1 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/small_slider/slider_black_rounded_rectangle_pressed.png differ diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png index 5fc1a143e..f54c96dc9 100644 Binary files a/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png and b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle.png differ diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle_pressed.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle_pressed.png new file mode 100644 index 000000000..e75a4a09b Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/small_slider/slider_green_rounded_rectangle_pressed.png differ diff --git a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png b/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png deleted file mode 100644 index cec31dcc8..000000000 Binary files a/selfdrive/assets/icons_mici/setup/small_slider/slider_red_circle.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/smaller_button.png b/selfdrive/assets/icons_mici/setup/smaller_button.png deleted file mode 100644 index 35293936a..000000000 Binary files a/selfdrive/assets/icons_mici/setup/smaller_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png b/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png deleted file mode 100644 index d10e0fe33..000000000 Binary files a/selfdrive/assets/icons_mici/setup/smaller_button_disabled.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png b/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png deleted file mode 100644 index 58d24382a..000000000 Binary files a/selfdrive/assets/icons_mici/setup/smaller_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/start_button.png b/selfdrive/assets/icons_mici/setup/start_button.png new file mode 100644 index 000000000..745371381 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/start_button.png differ diff --git a/selfdrive/assets/icons_mici/setup/start_button_pressed.png b/selfdrive/assets/icons_mici/setup/start_button_pressed.png new file mode 100644 index 000000000..3a303c029 Binary files /dev/null and b/selfdrive/assets/icons_mici/setup/start_button_pressed.png differ diff --git a/selfdrive/assets/icons_mici/setup/warning.png b/selfdrive/assets/icons_mici/setup/warning.png index a789ce4cb..0113ceb76 100644 Binary files a/selfdrive/assets/icons_mici/setup/warning.png and b/selfdrive/assets/icons_mici/setup/warning.png differ diff --git a/selfdrive/assets/icons_mici/setup/widish_button.png b/selfdrive/assets/icons_mici/setup/widish_button.png deleted file mode 100644 index ffaecc673..000000000 Binary files a/selfdrive/assets/icons_mici/setup/widish_button.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png b/selfdrive/assets/icons_mici/setup/widish_button_disabled.png deleted file mode 100644 index d192accff..000000000 Binary files a/selfdrive/assets/icons_mici/setup/widish_button_disabled.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png b/selfdrive/assets/icons_mici/setup/widish_button_pressed.png deleted file mode 100644 index 72a4c3181..000000000 Binary files a/selfdrive/assets/icons_mici/setup/widish_button_pressed.png and /dev/null differ diff --git a/selfdrive/assets/icons_mici/ssh_short.png b/selfdrive/assets/icons_mici/ssh_short.png new file mode 100644 index 000000000..128d8d5a4 Binary files /dev/null and b/selfdrive/assets/icons_mici/ssh_short.png differ diff --git a/selfdrive/assets/icons_mici/turn_intent_left.png b/selfdrive/assets/icons_mici/turn_intent_left.png index 325adc857..3815158c9 100644 Binary files a/selfdrive/assets/icons_mici/turn_intent_left.png and b/selfdrive/assets/icons_mici/turn_intent_left.png differ diff --git a/selfdrive/assets/icons_mici/wheel.png b/selfdrive/assets/icons_mici/wheel.png index 721d57e5f..c5a0acf2c 100644 Binary files a/selfdrive/assets/icons_mici/wheel.png and b/selfdrive/assets/icons_mici/wheel.png differ diff --git a/selfdrive/assets/icons_mici/wheel_critical.png b/selfdrive/assets/icons_mici/wheel_critical.png index 502c7ce5f..3c399916d 100644 Binary files a/selfdrive/assets/icons_mici/wheel_critical.png and b/selfdrive/assets/icons_mici/wheel_critical.png differ diff --git a/selfdrive/ui/mici/layouts/home.py b/selfdrive/ui/mici/layouts/home.py index c58a86ce7..a213643c9 100644 --- a/selfdrive/ui/mici/layouts/home.py +++ b/selfdrive/ui/mici/layouts/home.py @@ -1,23 +1,17 @@ -import time +import datetime import re +import time from cereal import log import pyray as rl from collections.abc import Callable -from openpilot.system.ui.widgets.label import gui_label, MiciLabel, UnifiedLabel from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.lib.application import gui_app, FontWeight, DEFAULT_TEXT_COLOR, MousePos -from openpilot.starpilot.common.starpilot_variables import MODELS_PATH -from openpilot.starpilot.common.experimental_state import ( - CEStatus, - next_manual_ce_status, - requested_experimental_mode, - sync_manual_ce_state, -) +from openpilot.system.ui.widgets.layouts import HBoxLayout +from openpilot.system.ui.widgets.icon_widget import IconWidget +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.text import wrap_text -from openpilot.system.version import training_version -from openpilot.system.hardware import PC +from openpilot.system.version import RELEASE_BRANCHES HEAD_BUTTON_FONT_SIZE = 40 HOME_PADDING = 8 @@ -35,57 +29,56 @@ NETWORK_TYPES = { } -class DeviceStatus(Widget): +class NetworkIcon(Widget): def __init__(self): super().__init__() - self.set_rect(rl.Rectangle(0, 0, 300, 175)) - self._update_state() - self._version_text = self._get_version_text() + self.set_rect(rl.Rectangle(0, 0, 54, 44)) # max size of all icons + self._net_type = NetworkType.none + self._net_strength = 0 - self._do_welcome() + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) + self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 37) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 37) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 37) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 37) - def _do_welcome(self): - # Keep onboarding bypass for desktop UI runs only. - if PC: - ui_state.params.put("CompletedTrainingVersion", training_version) - - def refresh(self): - self._update_state() - self._version_text = self._get_version_text() - - def _get_version_text(self) -> str: - brand = "starpilot" - description = ui_state.params.get("UpdaterCurrentDescription") - return f"{brand} {description}" if description else brand + self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 54, 36) + self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 54, 36) + self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 54, 36) + self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 54, 36) + self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 54, 36) def _update_state(self): - # TODO: refresh function that can be called periodically, not at 60 fps, so we can update version - # update system status - self._system_status = "SYSTEM READY βœ“" if ui_state.panda_type != log.PandaState.PandaType.unknown else "BOOTING UP..." - - # update network status - strength = ui_state.sm['deviceState'].networkStrength.raw - strength_text = "● " * strength + "β—‹ " * (4 - strength) # β—Œ also works - network_type = NETWORK_TYPES[ui_state.sm['deviceState'].networkType.raw] - self._network_status = f"{network_type} {strength_text}" + device_state = ui_state.sm['deviceState'] + self._net_type = device_state.networkType + strength = device_state.networkStrength + self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 def _render(self, _): - # draw status - status_rect = rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, 40) - gui_label(status_rect, self._system_status, font_size=HEAD_BUTTON_FONT_SIZE, color=DEFAULT_TEXT_COLOR, - font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + if self._net_type == NetworkType.wifi: + # There is no 1 + draw_net_txt = {0: self._wifi_none_txt, + 2: self._wifi_low_txt, + 3: self._wifi_medium_txt, + 4: self._wifi_full_txt, + 5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt) + elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): + draw_net_txt = {0: self._cell_none_txt, + 2: self._cell_low_txt, + 3: self._cell_medium_txt, + 4: self._cell_high_txt, + 5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt) + else: + draw_net_txt = self._wifi_slash_txt - # draw network status - network_rect = rl.Rectangle(self._rect.x, self._rect.y + 60, self._rect.width, 40) - gui_label(network_rect, self._network_status, font_size=40, color=DEFAULT_TEXT_COLOR, - font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + draw_x = self._rect.x + (self._rect.width - draw_net_txt.width) / 2 + draw_y = self._rect.y + (self._rect.height - draw_net_txt.height) / 2 - # draw version - version_font_size = 30 - version_rect = rl.Rectangle(self._rect.x, self._rect.y + 140, self._rect.width + 20, 40) - wrapped_text = '\n'.join(wrap_text(self._version_text, version_font_size, version_rect.width)) - gui_label(version_rect, wrapped_text, font_size=version_font_size, color=DEFAULT_TEXT_COLOR, - font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT) + if draw_net_txt == self._wifi_slash_txt: + # Offset by difference in height between slashless and slash icons to make center align match + draw_y -= (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 + + rl.draw_texture_ex(draw_net_txt, rl.Vector2(draw_x, draw_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9))) class MiciHomeLayout(Widget): @@ -100,117 +93,46 @@ class MiciHomeLayout(Widget): self._version_text = None self._experimental_mode = False - self._safe_mode = False self._current_model_name = "default" - self._settings_txt = gui_app.texture("icons_mici/settings.png", 48, 48) - self._experimental_txt = gui_app.texture("icons_mici/experimental_mode.png", 48, 48) - self._mic_txt = gui_app.texture("icons_mici/microphone.png", 48, 48) + self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48)) + self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46)) - self._net_type = NETWORK_TYPES.get(NetworkType.none) - self._net_strength = 0 + self._status_bar_layout = HBoxLayout([ + IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9), + NetworkIcon(), + self._experimental_icon, + self._mic_icon, + ], spacing=18) - self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 50, 44) - self._wifi_none_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_none.png", 50, 44) - self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 50, 44) - self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 50, 44) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 50, 44) - - self._cell_none_txt = gui_app.texture("icons_mici/settings/network/cell_strength_none.png", 55, 35) - self._cell_low_txt = gui_app.texture("icons_mici/settings/network/cell_strength_low.png", 55, 35) - self._cell_medium_txt = gui_app.texture("icons_mici/settings/network/cell_strength_medium.png", 55, 35) - self._cell_high_txt = gui_app.texture("icons_mici/settings/network/cell_strength_high.png", 55, 35) - self._cell_full_txt = gui_app.texture("icons_mici/settings/network/cell_strength_full.png", 55, 35) - - self._openpilot_label = MiciLabel("starpilot", font_size=96, color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY) - self._version_label = MiciLabel("", font_size=36, font_weight=FontWeight.ROMAN) - self._large_version_label = MiciLabel("", font_size=64, color=rl.GRAY, font_weight=FontWeight.ROMAN) - self._date_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN) + self._openpilot_label = UnifiedLabel("starpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False) + self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) + self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) + self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) self._branch_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, scroll=True) - self._version_commit_label = MiciLabel("", font_size=36, color=rl.GRAY, font_weight=FontWeight.ROMAN) + self._version_commit_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False) def show_event(self): + super().show_event() self._version_text = self._get_version_text() - self._update_network_status(ui_state.sm['deviceState']) self._update_params() def _update_params(self): - self._safe_mode = ui_state.params.get_bool("SafeMode") - self._experimental_mode = requested_experimental_mode(ui_state.params, ui_state.params_memory) + self._experimental_mode = ui_state.params.get_bool("ExperimentalMode") - def _clean_name(value: str) -> str: + def _clean_model_name(value: str) -> str: return re.sub(r"[πŸ—ΊοΈπŸ‘€πŸ“‘]", "", value).replace("(Default)", "").strip() - def _decode_default(value) -> str: - if isinstance(value, bytes): - return value.decode("utf-8", errors="ignore").strip() - return str(value or "").strip() + current_name = _clean_model_name(ui_state.params.get("DrivingModelName", encoding="utf-8") or "") + if not current_name: + default_name = ui_state.params.get_default_value("DrivingModelName") + if isinstance(default_name, bytes): + default_name = default_name.decode("utf-8", errors="ignore") + current_name = _clean_model_name(str(default_name or "")) - model_key = (ui_state.params.get("Model", encoding="utf-8") or - ui_state.params.get("DrivingModel", encoding="utf-8") or "").strip() - current_param_name = _clean_name(ui_state.params.get("DrivingModelName", encoding="utf-8") or "") - - available_models = [entry.strip() for entry in (ui_state.params.get("AvailableModels", encoding="utf-8") or "").split(",")] - available_names = [entry.strip() for entry in (ui_state.params.get("AvailableModelNames", encoding="utf-8") or "").split(",")] - model_versions = [entry.strip() for entry in (ui_state.params.get("ModelVersions", encoding="utf-8") or "").split(",")] - model_name_map = { - key: _clean_name(name) - for key, name in zip(available_models, available_names) - if key and _clean_name(name) - } - model_version_map = { - key: version - for key, version in zip(available_models, model_versions) - if key and version - } - - default_key = _decode_default(ui_state.params.get_default_value("DrivingModel") or - ui_state.params.get_default_value("Model")) or "sc" - default_name = _clean_name(_decode_default(ui_state.params.get_default_value("DrivingModelName"))) or "South Carolina" - - def _is_model_installed(key: str) -> bool: - if not key: - return False - - # Built-in default model is always available. - if key == default_key: - return True - - if (MODELS_PATH / f"{key}.thneed").is_file(): - return True - - version = model_version_map.get(key, "") - required = [ - f"{key}_driving_policy_tinygrad.pkl", - f"{key}_driving_vision_tinygrad.pkl", - f"{key}_driving_policy_metadata.pkl", - f"{key}_driving_vision_metadata.pkl", - ] - if version == "v12": - required.extend([ - f"{key}_driving_off_policy_tinygrad.pkl", - f"{key}_driving_off_policy_metadata.pkl", - ]) - return all((MODELS_PATH / filename).is_file() for filename in required) - - # If a stale custom model is selected but not actually installed, show default. - if model_key and not _is_model_installed(model_key): - model_key = default_key - - resolved_name = "" - if model_key in model_name_map: - resolved_name = model_name_map[model_key] - elif model_key.endswith("2") and model_key[:-1] in model_name_map: - resolved_name = model_name_map[model_key[:-1]] - elif model_key == default_key or (model_key.endswith("2") and model_key[:-1] == default_key): - resolved_name = default_name - - if not resolved_name and current_param_name: - resolved_name = current_param_name - if not resolved_name: - resolved_name = default_name if (not model_key or model_key == default_key) else model_key - - self._current_model_name = resolved_name + current_key = (ui_state.params.get("Model", encoding="utf-8") or + ui_state.params.get("DrivingModel", encoding="utf-8") or "").strip() + self._current_model_name = current_name or current_key or "default" def _update_state(self): if self.is_pressed and not self._is_pressed_prev: @@ -223,33 +145,18 @@ class MiciHomeLayout(Widget): if self._mouse_down_t is not None: if time.monotonic() - self._mouse_down_t > 0.5: # long gating for experimental mode - only allow toggle if longitudinal control is available - if ui_state.has_longitudinal_control and not self._safe_mode: - if ui_state.params.get_bool("ConditionalExperimental"): - current_status = ui_state.params_memory.get_int("CEStatus", default=CEStatus["OFF"]) - override_value = next_manual_ce_status(current_status, self._experimental_mode) - ui_state.params_memory.put_int("CEStatus", override_value) - sync_manual_ce_state(ui_state.params, override_value) - self._experimental_mode = override_value == CEStatus["USER_OVERRIDDEN"] - else: - self._experimental_mode = not self._experimental_mode - ui_state.params.put_bool("ExperimentalMode", self._experimental_mode) + if ui_state.has_longitudinal_control: + self._experimental_mode = not self._experimental_mode + ui_state.params.put("ExperimentalMode", self._experimental_mode) self._mouse_down_t = None self._did_long_press = True if rl.get_time() - self._last_refresh > 5.0: - device_state = ui_state.sm['deviceState'] - self._update_network_status(device_state) - # Update version text self._version_text = self._get_version_text() self._last_refresh = rl.get_time() self._update_params() - def _update_network_status(self, device_state): - self._net_type = device_state.networkType - strength = device_state.networkStrength - self._net_strength = max(0, min(5, strength.raw + 1)) if strength.raw > 0 else 0 - def set_callbacks(self, on_settings: Callable | None = None): self._on_settings_click = on_settings @@ -260,17 +167,22 @@ class MiciHomeLayout(Widget): self._did_long_press = False def _get_version_text(self) -> tuple[str, str, str, str] | None: - description = ui_state.params.get("UpdaterCurrentDescription") + version = ui_state.params.get("Version") + branch = ui_state.params.get("GitBranch") + commit = ui_state.params.get("GitCommit") - if description is not None and len(description) > 0: - # Expect "version / branch / commit / date"; be tolerant of other formats - try: - version, branch, commit, date = description.split(" / ") - return version, branch, commit, date - except Exception: - return None + if not all((version, branch, commit)): + return None - return None + commit_date_raw = ui_state.params.get("GitCommitDate") + try: + # GitCommitDate format from get_commit_date(): '%ct %ci' e.g. "'1708012345 2024-02-15 ...'" + unix_ts = int(commit_date_raw.strip("'").split()[0]) + date_str = datetime.datetime.fromtimestamp(unix_ts).strftime("%b %d") + except (ValueError, IndexError, TypeError, AttributeError): + date_str = "" + + return version, branch, commit[:7], date_str def _render(self, _): # TODO: why is there extra space here to get it to be flush? @@ -279,83 +191,31 @@ class MiciHomeLayout(Widget): self._openpilot_label.render() if self._version_text is not None: + # release branch + release_branch = self._version_text[1] in RELEASE_BRANCHES version_pos = rl.Rectangle(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16, 100, 44) self._version_label.set_text(self._version_text[0]) self._version_label.set_position(version_pos.x, version_pos.y) self._version_label.render() self._date_label.set_text(" " + self._version_text[3]) - self._date_label.set_position(version_pos.x + self._version_label.rect.width + 10, version_pos.y) + self._date_label.set_position(version_pos.x + self._version_label.text_width + 10, version_pos.y) self._date_label.render() - self._branch_label.set_max_width(gui_app.width - self._version_label.rect.width - self._date_label.rect.width - 32) + self._branch_label.set_max_width(gui_app.width - self._version_label.text_width - self._date_label.text_width - 32) self._branch_label.set_text(" " + self._current_model_name) - self._branch_label.set_position(version_pos.x + self._version_label.rect.width + self._date_label.rect.width + 20, version_pos.y) + self._branch_label.set_position(version_pos.x + self._version_label.text_width + self._date_label.text_width + 20, version_pos.y) self._branch_label.render() - self._version_commit_label.set_text(self._version_text[2]) - self._version_commit_label.set_position(version_pos.x, version_pos.y + self._date_label.font_size + 7) - self._version_commit_label.render() - else: - self._branch_label.set_max_width(gui_app.width - 32) - self._branch_label.set_text(self._current_model_name) - self._branch_label.set_position(text_pos.x, text_pos.y + self._openpilot_label.font_size + 16) - self._branch_label.render() + if not release_branch: + # 2nd line + self._version_commit_label.set_text(self._version_text[2]) + self._version_commit_label.set_position(version_pos.x, version_pos.y + self._date_label.font_size + 7) + self._version_commit_label.render() - self._render_bottom_status_bar() - - def _render_bottom_status_bar(self): # ***** Center-aligned bottom section icons ***** + self._experimental_icon.set_visible(self._experimental_mode) + self._mic_icon.set_visible(ui_state.recording_audio) - # TODO: refactor repeated icon drawing into a small loop - ITEM_SPACING = 18 - Y_CENTER = 24 - - last_x = self.rect.x + HOME_PADDING - - # Draw settings icon in bottom left corner - rl.draw_texture(self._settings_txt, int(last_x), int(self._rect.y + self.rect.height - self._settings_txt.height / 2 - Y_CENTER), - rl.Color(255, 255, 255, int(255 * 0.9))) - last_x = last_x + self._settings_txt.width + ITEM_SPACING - - # draw network - if self._net_type == NetworkType.wifi: - # There is no 1 - draw_net_txt = {0: self._wifi_none_txt, - 2: self._wifi_low_txt, - 3: self._wifi_medium_txt, - 4: self._wifi_full_txt, - 5: self._wifi_full_txt}.get(self._net_strength, self._wifi_low_txt) - rl.draw_texture(draw_net_txt, int(last_x), - int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9))) - last_x += draw_net_txt.width + ITEM_SPACING - - elif self._net_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G): - draw_net_txt = {0: self._cell_none_txt, - 2: self._cell_low_txt, - 3: self._cell_medium_txt, - 4: self._cell_high_txt, - 5: self._cell_full_txt}.get(self._net_strength, self._cell_none_txt) - rl.draw_texture(draw_net_txt, int(last_x), - int(self._rect.y + self.rect.height - draw_net_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, int(255 * 0.9))) - last_x += draw_net_txt.width + ITEM_SPACING - - else: - # No network - # Offset by difference in height between slashless and slash icons to make center align match - rl.draw_texture(self._wifi_slash_txt, int(last_x), int(self._rect.y + self.rect.height - self._wifi_slash_txt.height / 2 - - (self._wifi_slash_txt.height - self._wifi_none_txt.height) / 2 - Y_CENTER), - rl.Color(255, 255, 255, 255)) - last_x += self._wifi_slash_txt.width + ITEM_SPACING - - # draw experimental icon - if self._experimental_mode: - rl.draw_texture(self._experimental_txt, int(last_x), - int(self._rect.y + self.rect.height - self._experimental_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255)) - last_x += self._experimental_txt.width + ITEM_SPACING - - # draw microphone icon when recording audio is enabled - if ui_state.recording_audio: - rl.draw_texture(self._mic_txt, int(last_x), - int(self._rect.y + self.rect.height - self._mic_txt.height / 2 - Y_CENTER), rl.Color(255, 255, 255, 255)) - last_x += self._mic_txt.width + ITEM_SPACING + footer_rect = rl.Rectangle(self.rect.x + HOME_PADDING, self.rect.y + self.rect.height - 48, self.rect.width - HOME_PADDING, 48) + self._status_bar_layout.render(footer_rect) diff --git a/selfdrive/ui/mici/layouts/main.py b/selfdrive/ui/mici/layouts/main.py index 5d0d27b0c..95258e279 100644 --- a/selfdrive/ui/mici/layouts/main.py +++ b/selfdrive/ui/mici/layouts/main.py @@ -1,7 +1,5 @@ import pyray as rl -from enum import IntEnum import cereal.messaging as messaging -from openpilot.system.hardware import PC from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts @@ -16,18 +14,12 @@ from openpilot.system.ui.lib.application import gui_app ONROAD_DELAY = 2.5 # seconds -class MainState(IntEnum): - MAIN = 0 - SETTINGS = 1 - - -class MiciMainLayout(Widget): +class MiciMainLayout(Scroller): def __init__(self): - super().__init__() + super().__init__(snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False) self._pm = messaging.PubMaster(['bookmarkButton']) - self._current_mode: MainState | None = None self._prev_onroad = False self._prev_standstill = False self._onroad_time_delay: float | None = None @@ -44,46 +36,37 @@ class MiciMainLayout(Widget): # TODO: set parent rect and use it if never passed rect from render (like in Scroller) widget.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - self._scroller = Scroller([ + self._scroller.add_widgets([ self._alerts_layout, self._home_layout, self._onroad_layout, - ], spacing=0, pad_start=0, pad_end=0) + ]) self._scroller.set_reset_scroll_at_show(False) # Disable scrolling when onroad is interacting with bookmark self._scroller.set_scrolling_enabled(lambda: not self._onroad_layout.is_swiping_left()) - self._layouts = { - MainState.MAIN: self._scroller, - MainState.SETTINGS: self._settings_layout, - } - # Set callbacks self._setup_callbacks() - # Skip onboarding on desktop; keep normal flow on device. - self._onboarding_window = None - if not PC: - self._onboarding_window = OnboardingWindow() - if not self._onboarding_window.completed: - gui_app.set_modal_overlay(self._onboarding_window) + gui_app.add_nav_stack_tick(self._handle_transitions) + gui_app.push_widget(self) + + # Start onboarding if terms or training not completed, make sure to push after self + self._onboarding_window = OnboardingWindow(lambda: gui_app.pop_widgets_to(self)) + if not self._onboarding_window.completed: + gui_app.push_widget(self._onboarding_window) def _setup_callbacks(self): - self._home_layout.set_callbacks(on_settings=self._on_settings_clicked) - self._settings_layout.set_callbacks(on_close=self._on_settings_closed) + self._home_layout.set_callbacks(on_settings=lambda: gui_app.push_widget(self._settings_layout)) self._onroad_layout.set_click_callback(lambda: self._scroll_to(self._home_layout)) - device.add_interactive_timeout_callback(self._set_mode_for_started) + device.add_interactive_timeout_callback(self._on_interactive_timeout) def _scroll_to(self, layout: Widget): layout_x = int(layout.rect.x) self._scroller.scroll_to(layout_x, smooth=True) def _render(self, _): - # Initial show event - if self._current_mode is None: - self._set_mode(MainState.MAIN) - if not self._setup: if self._alerts_layout.active_alerts() > 0: self._scroller.scroll_to(self._alerts_layout.rect.x) @@ -92,59 +75,47 @@ class MiciMainLayout(Widget): self._setup = True # Render - if self._current_mode == MainState.MAIN: - self._scroller.render(self._rect) - - elif self._current_mode == MainState.SETTINGS: - self._settings_layout.render(self._rect) - - self._handle_transitions() - - def _set_mode(self, mode: MainState): - if mode != self._current_mode: - if self._current_mode is not None: - self._layouts[self._current_mode].hide_event() - self._layouts[mode].show_event() - self._current_mode = mode + super()._render(self._rect) def _handle_transitions(self): + # Don't pop if onboarding + if gui_app.widget_in_stack(self._onboarding_window): + return + if ui_state.started != self._prev_onroad: self._prev_onroad = ui_state.started + # onroad: after delay, pop nav stack and scroll to onroad + # offroad: immediately scroll to home, but don't pop nav stack (can stay in settings) if ui_state.started: self._onroad_time_delay = rl.get_time() else: - self._set_mode_for_started(True) - - # delay so we show home for a bit after starting - if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: - self._set_mode_for_started(True) - self._onroad_time_delay = None - - CS = ui_state.sm["carState"] - if not CS.standstill and self._prev_standstill: - self._set_mode(MainState.MAIN) - self._scroll_to(self._onroad_layout) - self._prev_standstill = CS.standstill - - def _set_mode_for_started(self, onroad_transition: bool = False): - if ui_state.started: - CS = ui_state.sm["carState"] - # Only go onroad if car starts or is not at a standstill - if not CS.standstill or onroad_transition: - self._set_mode(MainState.MAIN) - self._scroll_to(self._onroad_layout) - else: - # Stay in settings if car turns off while in settings - if not onroad_transition or self._current_mode != MainState.SETTINGS: - self._set_mode(MainState.MAIN) self._scroll_to(self._home_layout) - def _on_settings_clicked(self): - self._set_mode(MainState.SETTINGS) + # FIXME: these two pops can interrupt user interacting in the settings + if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY: + gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) + self._onroad_time_delay = None - def _on_settings_closed(self): - self._set_mode(MainState.MAIN) + # When car leaves standstill, pop nav stack and scroll to onroad + CS = ui_state.sm["carState"] + if not CS.standstill and self._prev_standstill: + gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) + self._prev_standstill = CS.standstill + + def _on_interactive_timeout(self): + # Don't pop if onboarding + if gui_app.widget_in_stack(self._onboarding_window): + return + + if ui_state.started: + # Don't pop if at standstill + if not ui_state.sm["carState"].standstill: + gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout)) + else: + # Screen turns off on timeout offroad, so pop immediately without animation + gui_app.pop_widgets_to(self, instant=True) + self._scroll_to(self._home_layout) def _on_bookmark_clicked(self): user_bookmark = messaging.new_message('bookmarkButton') diff --git a/selfdrive/ui/mici/layouts/offroad_alerts.py b/selfdrive/ui/mici/layouts/offroad_alerts.py index 60f64b31b..0dae5d207 100644 --- a/selfdrive/ui/mici/layouts/offroad_alerts.py +++ b/selfdrive/ui/mici/layouts/offroad_alerts.py @@ -144,7 +144,7 @@ class AlertItem(Widget): bg_texture = self._bg_small_pressed if self.is_pressed else self._bg_small # Draw background - rl.draw_texture(bg_texture, int(self._rect.x), int(self._rect.y), rl.WHITE) + rl.draw_texture_ex(bg_texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, rl.WHITE) # Calculate text area (left side, avoiding icon on right) title_width = self.ALERT_WIDTH - (self.ALERT_PADDING * 2) - self.ICON_SIZE - self.ICON_MARGIN @@ -183,22 +183,20 @@ class AlertItem(Widget): icon_texture = self._icon_orange icon_x = self._rect.x + self.ALERT_WIDTH - self.ALERT_PADDING - self.ICON_SIZE icon_y = self._rect.y + self.ALERT_PADDING - rl.draw_texture(icon_texture, int(icon_x), int(icon_y), rl.WHITE) + rl.draw_texture_ex(icon_texture, rl.Vector2(icon_x, icon_y), 0.0, 1.0, rl.WHITE) -class MiciOffroadAlerts(Widget): +class MiciOffroadAlerts(Scroller): """Offroad alerts layout with vertical scrolling.""" def __init__(self): - super().__init__() + # Create vertical scroller + super().__init__(horizontal=False, spacing=12, pad=0) self.params = Params() self.sorted_alerts: list[AlertData] = [] self.alert_items: list[AlertItem] = [] self._last_refresh = 0.0 - # Create vertical scroller - self._scroller = Scroller([], horizontal=False, spacing=12, pad_start=0, pad_end=0, snap_items=False) - # Create empty state label self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, @@ -289,7 +287,7 @@ class MiciOffroadAlerts(Widget): def show_event(self): """Reset scroll position when shown and refresh alerts.""" - self._scroller.show_event() + super().show_event() self._last_refresh = time.monotonic() self.refresh() diff --git a/selfdrive/ui/mici/layouts/onboarding.py b/selfdrive/ui/mici/layouts/onboarding.py index 0d5080d27..b918bf6ef 100644 --- a/selfdrive/ui/mici/layouts/onboarding.py +++ b/selfdrive/ui/mici/layouts/onboarding.py @@ -1,35 +1,27 @@ -from enum import IntEnum - -import weakref import math -import time import numpy as np +import qrcode import pyray as rl +from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import FontWeight, gui_app from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.button import SmallButton, SmallCircleIconButton -from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.slider import SmallSlider -from openpilot.system.ui.mici_setup import TermsHeader, TermsPage as SetupTermsPage -from openpilot.selfdrive.ui.ui_state import ui_state, device -from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer -from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog +from openpilot.system.ui.widgets.button import SmallCircleIconButton +from openpilot.system.ui.widgets.scroller import NavScroller, Scroller +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.mici_setup import GreyBigButton, BigPillButton from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.lib.multilang import tr from openpilot.system.version import terms_version, training_version +from openpilot.selfdrive.ui.ui_state import ui_state, device +from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationCircleButton +from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer +from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import BaseDriverCameraDialog -class OnboardingState(IntEnum): - TERMS = 0 - ONBOARDING = 1 - DECLINE = 2 - - -class DriverCameraSetupDialog(DriverCameraDialog): +class DriverCameraSetupDialog(BaseDriverCameraDialog): def __init__(self): - super().__init__(no_escape=True) + super().__init__() self.driver_state_renderer = DriverStateRenderer(inset=True) self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 120, 120)) self.driver_state_renderer.load_icons() @@ -43,7 +35,7 @@ class DriverCameraSetupDialog(DriverCameraDialog): gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) rl.end_scissor_mode() - return -1 + return # Position dmoji on opposite side from driver is_rhd = self.driver_state_renderer.is_rhd @@ -56,92 +48,64 @@ class DriverCameraSetupDialog(DriverCameraDialog): self._draw_face_detection(rect) rl.end_scissor_mode() - return -1 -class TrainingGuidePreDMTutorial(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("driver monitoring setup", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) +class TrainingGuidePreDMTutorial(NavScroller): + def __init__(self, continue_callback: Callable[[], None]): + super().__init__() - self._dm_label = UnifiedLabel("Next, we'll ensure comma four is mounted properly.\n\nIf it does not have a clear view of the driver, " + - "unplug and remount before continuing.", 42, - FontWeight.ROMAN) + continue_button = BigPillButton("next") + continue_button.set_click_callback(continue_callback) + + self._scroller.add_widgets([ + GreyBigButton("driver monitoring\ncheck", "scroll to continue", + gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)), + GreyBigButton("", "Next, we'll check if comma four can detect the driver properly."), + GreyBigButton("", "openpilot uses the cabin camera to check if the driver is distracted."), + GreyBigButton("", "If it does not have a clear view of the driver, unplug and remount before continuing."), + continue_button, + ]) def show_event(self): super().show_event() # Get driver monitoring model ready for next step - ui_state.params.put_bool("IsDriverViewEnabled", True) - - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) + ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", True) -class DMBadFaceDetected(SetupTermsPage): - def __init__(self, continue_callback, back_callback): - super().__init__(continue_callback, back_callback, continue_text="power off") - self._title_header = TermsHeader("make sure comma four can see your face", gui_app.texture("icons_mici/setup/orange_dm.png", 60, 60)) - self._dm_label = UnifiedLabel("Re-mount if your face is occluded or driver monitoring has difficulty tracking your face.", 42, FontWeight.ROMAN) +class DMBadFaceDetected(NavScroller): + def __init__(self): + super().__init__() - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() + back_button = BigPillButton("back") + back_button.set_click_callback(self.dismiss) - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) + self._scroller.add_widgets([ + GreyBigButton("looking for driver", "make sure comma\nfour can see your face", + gui_app.texture("icons_mici/setup/orange_dm.png", 64, 64)), + GreyBigButton("", "Remount if your face is blocked, or driver monitoring has difficulty tracking your face."), + back_button, + ]) -class TrainingGuideDMTutorial(Widget): +class TrainingGuideDMTutorial(NavWidget): PROGRESS_DURATION = 4 LOOKING_THRESHOLD_DEG = 30.0 - def __init__(self, continue_callback): + def __init__(self, continue_callback: Callable[[], None]): super().__init__() - self._back_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_question.png", 48, 48)) - self._back_button.set_click_callback(self._show_bad_face_page) - self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 48, 35)) - # Wrap the continue callback to restore settings - def wrapped_continue_callback(): - device.set_offroad_brightness(None) - continue_callback() + self._back_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_question.png", 28, 48)) + self._back_button.set_click_callback(lambda: gui_app.push_widget(self._bad_face_page)) + self._back_button.set_touch_valid_callback(lambda: self.enabled and not self.is_dismissing) # for nav stack + self._good_button = SmallCircleIconButton(gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 42, 42)) + self._good_button.set_touch_valid_callback(lambda: self.enabled and not self.is_dismissing) # for nav stack - self._good_button.set_click_callback(wrapped_continue_callback) + self._good_button.set_click_callback(continue_callback) self._good_button.set_enabled(False) self._progress = FirstOrderFilter(0.0, 0.5, 1 / gui_app.target_fps) - self._step_start_time = time.monotonic() self._dialog = DriverCameraSetupDialog() - self._bad_face_page = DMBadFaceDetected(HARDWARE.shutdown, self._hide_bad_face_page) - self._should_show_bad_face_page = False + self._bad_face_page = DMBadFaceDetected() # Disable driver monitoring model when device times out for inactivity def inactivity_callback(): @@ -149,23 +113,10 @@ class TrainingGuideDMTutorial(Widget): device.add_interactive_timeout_callback(inactivity_callback) - def _show_bad_face_page(self): - self._bad_face_page.show_event() - self.hide_event() - self._should_show_bad_face_page = True - - def _hide_bad_face_page(self): - self._bad_face_page.hide_event() - self.show_event() - self._should_show_bad_face_page = False - def show_event(self): super().show_event() self._dialog.show_event() self._progress.x = 0.0 - self._step_start_time = time.monotonic() - - device.set_offroad_brightness(100) def _update_state(self): super()._update_state() @@ -174,10 +125,6 @@ class TrainingGuideDMTutorial(Widget): sm = ui_state.sm if sm.recv_frame.get("driverMonitoringState", 0) == 0: - # Fallback for devices where DM model isn't publishing during onboarding: - # allow manual continue once camera is active so setup isn't hard-blocked. - if self._dialog._camera_view.frame and (time.monotonic() - self._step_start_time) > 2.5: - self._good_button.set_enabled(True) return dm_state = sm["driverMonitoringState"] @@ -190,7 +137,8 @@ class TrainingGuideDMTutorial(Widget): looking_center = False # stay at 100% once reached - if (dm_state.faceDetected and looking_center) or self._progress.x > 0.99: + in_bad_face = gui_app.get_active_widget() == self._bad_face_page + if ((dm_state.faceDetected and looking_center) or self._progress.x > 0.99) and not in_bad_face: slow = self._progress.x < 0.25 duration = self.PROGRESS_DURATION * 2 if slow else self.PROGRESS_DURATION self._progress.x += 1.0 / (duration * gui_app.target_fps) @@ -201,13 +149,12 @@ class TrainingGuideDMTutorial(Widget): self._good_button.set_enabled(self._progress.x >= 0.999) def _render(self, _): - if self._should_show_bad_face_page: - return self._bad_face_page.render(self._rect) - self._dialog.render(self._rect) - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 80), - int(self._rect.width), 80, rl.BLANK, rl.BLACK) + gradient_y = int(self._rect.y + self._rect.height - 80) + gradient_h = int(self._rect.y) + int(self._rect.height) - gradient_y + rl.draw_rectangle_gradient_v(int(self._rect.x), gradient_y, + int(self._rect.width), gradient_h, rl.BLANK, rl.BLACK) # draw white ring around dm icon to indicate progress ring_thickness = 8 @@ -260,238 +207,181 @@ class TrainingGuideDMTutorial(Widget): )) # rounded border + rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), int(self._rect.width), int(self._rect.height)) rl.draw_rectangle_rounded_lines_ex(self._rect, 0.2 * 1.02, 10, 50, rl.BLACK) + rl.end_scissor_mode() -class TrainingGuideRecordFront(SetupTermsPage): - def __init__(self, continue_callback): - def on_back(): - ui_state.params.put_bool("RecordFront", False) - continue_callback() - - def on_continue(): - ui_state.params.put_bool("RecordFront", True) - continue_callback() - - super().__init__(on_continue, back_callback=on_back, back_text="no", continue_text="yes") - self._title_header = TermsHeader("improve driver monitoring", gui_app.texture("icons_mici/setup/green_dm.png", 60, 60)) - - self._dm_label = UnifiedLabel("Do you want to upload driver camera data?", 42, - FontWeight.ROMAN) - - def show_event(self): - super().show_event() - # Disable driver monitoring model after last step - ui_state.params.put_bool("IsDriverViewEnabled", False) - - @property - def _content_height(self): - return self._dm_label.rect.y + self._dm_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._dm_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._dm_label.get_content_height(int(self._rect.width - 32)), - )) - - -class TrainingGuideAttentionNotice(SetupTermsPage): - def __init__(self, continue_callback): - super().__init__(continue_callback, continue_text="continue") - self._title_header = TermsHeader("driver assistance", gui_app.texture("icons_mici/setup/warning.png", 60, 60)) - self._warning_label = UnifiedLabel("1. openpilot is a driver assistance system.\n\n" + - "2. You must pay attention at all times.\n\n" + - "3. You must be ready to take over at any time.\n\n" + - "4. You are fully responsible for driving the car.", 42, - FontWeight.ROMAN) - - @property - def _content_height(self): - return self._warning_label.rect.y + self._warning_label.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16 + scroll_offset, - self._title_header.rect.width, - self._title_header.rect.height, - )) - - self._warning_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + 16, - self._rect.width - 32, - self._warning_label.get_content_height(int(self._rect.width - 32)), - )) - - -class TrainingGuide(Widget): - def __init__(self, completed_callback=None): +class TrainingGuideRecordFront(NavScroller): + def __init__(self, continue_callback: Callable[[], None]): super().__init__() - self._completed_callback = completed_callback - self._step = 0 - self_ref = weakref.ref(self) + def on_accept(): + ui_state.params.put_bool_nonblocking("RecordFront", True) + continue_callback() - def on_continue(): - if obj := self_ref(): - obj._advance_step() + def on_decline(): + ui_state.params.put_bool_nonblocking("RecordFront", False) + continue_callback() + + self._accept_button = BigConfirmationCircleButton("allow data uploading", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), + on_accept, exit_on_confirm=False) + + self._decline_button = BigConfirmationCircleButton("no, don't upload", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline, + exit_on_confirm=False) + + self._scroller.add_widgets([ + GreyBigButton("driver camera data", "do you want to share video data for training?", + gui_app.texture("icons_mici/setup/green_dm.png", 64, 64)), + GreyBigButton("", "Sharing your data with comma helps improve openpilot for everyone."), + self._accept_button, + self._decline_button, + ]) + + +class TrainingGuideAttentionNotice(Scroller): + def __init__(self, continue_callback: Callable[[], None]): + super().__init__() + + continue_button = BigPillButton("next") + continue_button.set_click_callback(continue_callback) + + self._scroller.add_widgets([ + GreyBigButton("what is openpilot?", "scroll to continue", + gui_app.texture("icons_mici/setup/green_info.png", 64, 64)), + GreyBigButton("", "1. openpilot is a driver assistance system."), + GreyBigButton("", "2. You must pay attention at all times."), + GreyBigButton("", "3. You must be ready to take over at any time."), + GreyBigButton("", "4. You are fully responsible for driving the car."), + continue_button, + ]) + + +class TrainingGuide(NavWidget): + def __init__(self, completed_callback: Callable[[], None]): + super().__init__() self._steps = [ - TrainingGuideAttentionNotice(continue_callback=on_continue), - TrainingGuidePreDMTutorial(continue_callback=on_continue), - TrainingGuideDMTutorial(continue_callback=on_continue), - TrainingGuideRecordFront(continue_callback=on_continue), + TrainingGuideAttentionNotice(continue_callback=lambda: gui_app.push_widget(self._steps[1])), + TrainingGuidePreDMTutorial(continue_callback=lambda: gui_app.push_widget(self._steps[2])), + TrainingGuideDMTutorial(continue_callback=lambda: gui_app.push_widget(self._steps[3])), + TrainingGuideRecordFront(continue_callback=completed_callback), ] - def show_event(self): - super().show_event() - device.set_override_interactive_timeout(300) - - def hide_event(self): - super().hide_event() - device.set_override_interactive_timeout(None) - - def _advance_step(self): - if self._step < len(self._steps) - 1: - self._step += 1 - self._steps[self._step].show_event() - else: - self._step = 0 - if self._completed_callback: - self._completed_callback() + self._child(self._steps[0]) + self._steps[0].set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack def _render(self, _): - if self._step < len(self._steps): - self._steps[self._step].render(self._rect) - return -1 + self._steps[0].render(self._rect) -class DeclinePage(Widget): - def __init__(self, back_callback=None): +class QRCodeWidget(Widget): + def __init__(self, url: str, size: int = 170): super().__init__() - self._uninstall_slider = SmallSlider("uninstall openpilot", self._on_uninstall) + self.set_rect(rl.Rectangle(0, 0, size, size)) + self._size = size + self._qr_texture: rl.Texture | None = None + self._generate_qr(url) - self._back_button = SmallButton("back") - self._back_button.set_click_callback(back_callback) + def _generate_qr(self, url: str): + qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) + qr.add_data(url) + qr.make(fit=True) - self._warning_header = TermsHeader("you must accept the\nterms to use openpilot", - gui_app.texture("icons_mici/setup/red_warning.png", 66, 60)) + pil_img = qr.make_image(fill_color="white", back_color="black").convert('RGBA') + img_array = np.array(pil_img, dtype=np.uint8) - def _on_uninstall(self): - ui_state.params.put_bool("DoUninstall", True) - gui_app.request_close() + rl_image = rl.Image() + rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) + rl_image.width = pil_img.width + rl_image.height = pil_img.height + rl_image.mipmaps = 1 + rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 + + self._qr_texture = rl.load_texture_from_image(rl_image) def _render(self, _): - self._warning_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16, - self._warning_header.rect.width, - self._warning_header.rect.height, - )) + if self._qr_texture: + scale = self._size / self._qr_texture.height + rl.draw_texture_ex(self._qr_texture, rl.Vector2(round(self._rect.x), round(self._rect.y)), 0.0, scale, rl.WHITE) - self._back_button.set_opacity(1 - self._uninstall_slider.slider_percentage) - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) - - self._uninstall_slider.render(rl.Rectangle( - self._rect.x + self._rect.width - self._uninstall_slider.rect.width, - self._rect.y + self._rect.height - self._uninstall_slider.rect.height, - self._uninstall_slider.rect.width, - self._uninstall_slider.rect.height, - )) + def __del__(self): + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) -class TermsPage(SetupTermsPage): - def __init__(self, on_accept=None, on_decline=None): - super().__init__(on_accept, on_decline, "decline") +class TermsPage(Scroller): + def __init__(self, on_accept, on_decline): + super().__init__() - info_txt = gui_app.texture("icons_mici/setup/green_info.png", 60, 60) - self._title_header = TermsHeader("terms & conditions", info_txt) + self._accept_button = BigConfirmationCircleButton("accept\nterms", gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 64, 64), on_accept) + self._decline_button = BigConfirmationCircleButton("decline &\nuninstall", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), on_decline, + red=True, exit_on_confirm=False) - self._terms_label = UnifiedLabel("You must accept the Terms and Conditions to use openpilot. " + - "Read the latest terms at https://comma.ai/terms before continuing.", 36, - FontWeight.ROMAN) + self._terms_header = GreyBigButton("terms and\nconditions", "scroll to continue", + gui_app.texture("icons_mici/setup/green_info.png", 64, 64)) + self._must_accept_card = GreyBigButton("", "You must accept the Terms & Conditions to use openpilot.") - @property - def _content_height(self): - return self._terms_label.rect.y + self._terms_label.rect.height - self._scroll_panel.get_offset() + self._scroller.add_widgets([ + self._terms_header, + GreyBigButton("swipe for QR code", "or go to https://comma.ai/terms", + gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)), + QRCodeWidget("https://comma.ai/terms"), + self._must_accept_card, + self._accept_button, + self._decline_button, + ]) - def _render_content(self, scroll_offset): - self._title_header.set_position(self._rect.x + 16, self._rect.y + 12 + scroll_offset) - self._title_header.render() - - self._terms_label.render(rl.Rectangle( - self._rect.x + 16, - self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, - self._rect.width - 100, - self._terms_label.get_content_height(int(self._rect.width - 100)), - )) + def _render(self, _): + rl.draw_rectangle_rec(self._rect, rl.BLACK) + super()._render(_) class OnboardingWindow(Widget): - def __init__(self): + def __init__(self, completed_callback: Callable[[], None]): super().__init__() + self._completed_callback = completed_callback self._accepted_terms: bool = ui_state.params.get("HasAcceptedTerms") == terms_version self._training_done: bool = ui_state.params.get("CompletedTrainingVersion") == training_version - self._state = OnboardingState.TERMS if not self._accepted_terms else OnboardingState.ONBOARDING - - self.set_rect(rl.Rectangle(0, 0, 458, gui_app.height)) + self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) # Windows - self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_terms_declined) + self._terms = TermsPage(on_accept=self._on_terms_accepted, on_decline=self._on_uninstall) + self._terms.set_enabled(lambda: self.enabled) # for nav stack self._training_guide = TrainingGuide(completed_callback=self._on_completed_training) - self._decline_page = DeclinePage(back_callback=self._on_decline_back) + self._training_guide.set_enabled(lambda: self.enabled) # for nav stack + + def _on_uninstall(self): + ui_state.params.put_bool("DoUninstall", True) def show_event(self): super().show_event() device.set_override_interactive_timeout(300) + device.set_offroad_brightness(100) def hide_event(self): super().hide_event() + # FIXME: when nav stack sends hide event to widget 2 below on push, this needs to be moved device.set_override_interactive_timeout(None) + device.set_offroad_brightness(None) @property def completed(self) -> bool: return self._accepted_terms and self._training_done - def _on_terms_declined(self): - self._state = OnboardingState.DECLINE - - def _on_decline_back(self): - self._state = OnboardingState.TERMS - def close(self): - ui_state.params.put_bool("IsDriverViewEnabled", False) - gui_app.set_modal_overlay(None) + ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", False) + self._completed_callback() def _on_terms_accepted(self): ui_state.params.put("HasAcceptedTerms", terms_version) - self._state = OnboardingState.ONBOARDING + gui_app.push_widget(self._training_guide) def _on_completed_training(self): ui_state.params.put("CompletedTrainingVersion", training_version) self.close() def _render(self, _): - if self._state == OnboardingState.TERMS: - self._terms.render(self._rect) - elif self._state == OnboardingState.ONBOARDING: - self._training_guide.render(self._rect) - elif self._state == OnboardingState.DECLINE: - self._decline_page.render(self._rect) - return -1 + rl.draw_rectangle_rec(self._rect, rl.BLACK) + self._terms.render(self._rect) diff --git a/selfdrive/ui/mici/layouts/settings/developer.py b/selfdrive/ui/mici/layouts/settings/developer.py index 9b3ad1bfe..2ffc6c2bb 100644 --- a/selfdrive/ui/mici/layouts/settings/developer.py +++ b/selfdrive/ui/mici/layouts/settings/developer.py @@ -1,9 +1,6 @@ -import pyray as rl -from collections.abc import Callable - from openpilot.common.time_helpers import system_time_valid -from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl +from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigToggle, BigParamControl, BigCircleParamControl from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigInputDialog, BigMultiOptionDialog from openpilot.selfdrive.ui.mici.layouts.settings.fingerprint_catalog import ( FingerprintModelOption, @@ -12,106 +9,119 @@ from openpilot.selfdrive.ui.mici.layouts.settings.fingerprint_catalog import ( shorten_model_label, ) from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets import NavWidget from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyAction +from openpilot.selfdrive.ui.widgets.ssh_key import SshKeyFetcher -class DeveloperLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): +class DeveloperLayoutMici(NavScroller): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) + self._ssh_fetcher = SshKeyFetcher(ui_state.params) + self._make_options, self._models_by_make, self._models_by_value, self._make_by_model = get_fingerprint_catalog() def github_username_callback(username: str): if username: - ssh_keys = SshKeyAction() - ssh_keys._fetch_ssh_key(username) - if not ssh_keys._error_message: - self._ssh_keys_btn.set_value(username) - else: - dlg = BigDialog("", ssh_keys._error_message) - gui_app.set_modal_overlay(dlg) + self._ssh_keys_btn.set_value("Loading...") + self._ssh_keys_btn.set_enabled(False) + + def on_response(error): + self._ssh_keys_btn.set_enabled(True) + if error is None: + self._ssh_keys_btn.set_value(username) + else: + self._ssh_keys_btn.set_value("Not set") + gui_app.push_widget(BigDialog("", error)) + + self._ssh_fetcher.fetch(username, on_response) + else: + self._ssh_fetcher.clear() + self._ssh_keys_btn.set_value("Not set") def ssh_keys_callback(): github_username = ui_state.params.get("GithubUsername") or "" - dlg = BigInputDialog("enter GitHub username", github_username, confirm_callback=github_username_callback) + dlg = BigInputDialog("enter GitHub username...", github_username, minimum_length=0, confirm_callback=github_username_callback) if not system_time_valid(): - dlg = BigDialog("Please connect to Wi-Fi to fetch your key", "") - gui_app.set_modal_overlay(dlg) + dlg = BigDialog("", "Please connect to Wi-Fi to fetch your key.") + gui_app.push_widget(dlg) return - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(dlg) - txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 77, 44) + txt_ssh = gui_app.texture("icons_mici/settings/developer/ssh.png", 56, 64) github_username = ui_state.params.get("GithubUsername") or "" self._ssh_keys_btn = BigButton("SSH keys", "Not set" if not github_username else github_username, icon=txt_ssh) self._ssh_keys_btn.set_click_callback(ssh_keys_callback) - # Fingerprint controls - ( - self._make_options, - self._models_by_make, - self._models_by_value, - self._make_by_model, - ) = get_fingerprint_catalog() self._car_make_btn = BigButton("car make", self._get_display_make()) self._car_make_btn.set_click_callback(self._open_make_selector) self._car_model_btn = BigButton("car model", self._get_display_model()) self._car_model_btn.set_click_callback(self._open_model_selector) self._force_fingerprint_toggle = BigParamControl( - "disable auto fingerprint", "ForceFingerprint", toggle_callback=lambda checked: restart_needed_callback(checked) + "disable auto fingerprint", "ForceFingerprint", toggle_callback=restart_needed_callback, ) # adb, ssh, ssh keys, debug mode, joystick debug mode, longitudinal maneuver mode, ip address # ******** Main Scroller ******** - self._adb_toggle = BigParamControl("enable ADB", "AdbEnabled") + self._adb_toggle = BigCircleParamControl(gui_app.texture("icons_mici/adb_short.png", 82, 82), "AdbEnabled", icon_offset=(0, 12)) + self._ssh_toggle = BigCircleParamControl(gui_app.texture("icons_mici/ssh_short.png", 82, 82), "SshEnabled", icon_offset=(0, 12)) self._use_prebuilt_toggle = BigParamControl("use prebuilt binaries", "UsePrebuilt") - self._ssh_toggle = BigParamControl("enable SSH", "SshEnabled") - self._joystick_toggle = BigToggle( - "joystick debug mode", initial_state=ui_state.params.get_bool("JoystickDebugMode"), toggle_callback=self._on_joystick_debug_mode - ) - self._long_maneuver_toggle = BigToggle( - "longitudinal maneuver mode", initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), toggle_callback=self._on_long_maneuver_mode - ) - self._alpha_long_toggle = BigToggle( - "alpha longitudinal", initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), toggle_callback=self._on_alpha_long_enabled - ) - self._debug_mode_toggle = BigParamControl( - "ui debug mode", "ShowDebugInfo", toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)) - ) + self._joystick_toggle = BigToggle("joystick debug mode", + initial_state=ui_state.params.get_bool("JoystickDebugMode"), + toggle_callback=self._on_joystick_debug_mode) + self._long_maneuver_toggle = BigToggle("longitudinal maneuver mode", + initial_state=ui_state.params.get_bool("LongitudinalManeuverMode"), + toggle_callback=self._on_long_maneuver_mode) + self._lat_maneuver_toggle = BigToggle("lateral maneuver mode", + initial_state=ui_state.params.get_bool("LateralManeuverMode"), + toggle_callback=self._on_lat_maneuver_mode) + self._alpha_long_toggle = BigToggle("alpha longitudinal", + initial_state=ui_state.params.get_bool("AlphaLongitudinalEnabled"), + toggle_callback=self._on_alpha_long_enabled) + self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo", + toggle_callback=lambda checked: (gui_app.set_show_touches(checked), + gui_app.set_show_fps(checked))) - self._scroller = Scroller( - [ - self._adb_toggle, - self._use_prebuilt_toggle, - self._ssh_toggle, - self._ssh_keys_btn, - self._car_make_btn, - self._car_model_btn, - self._force_fingerprint_toggle, - self._joystick_toggle, - self._long_maneuver_toggle, - self._alpha_long_toggle, - self._debug_mode_toggle, - ], - snap_items=False, - scroll_indicator=True, - edge_shadows=True, - ) + self._scroller.add_widgets([ + self._adb_toggle, + self._ssh_toggle, + self._ssh_keys_btn, + self._car_make_btn, + self._car_model_btn, + self._force_fingerprint_toggle, + self._use_prebuilt_toggle, + self._joystick_toggle, + self._long_maneuver_toggle, + self._lat_maneuver_toggle, + self._alpha_long_toggle, + self._debug_mode_toggle, + ]) # Toggle lists self._refresh_toggles = ( ("AdbEnabled", self._adb_toggle), - ("UsePrebuilt", self._use_prebuilt_toggle), ("SshEnabled", self._ssh_toggle), ("ForceFingerprint", self._force_fingerprint_toggle), + ("UsePrebuilt", self._use_prebuilt_toggle), ("JoystickDebugMode", self._joystick_toggle), ("LongitudinalManeuverMode", self._long_maneuver_toggle), + ("LateralManeuverMode", self._lat_maneuver_toggle), ("AlphaLongitudinalEnabled", self._alpha_long_toggle), ("ShowDebugInfo", self._debug_mode_toggle), ) - onroad_blocked_toggles = (self._adb_toggle, self._car_make_btn, self._car_model_btn, self._force_fingerprint_toggle, self._joystick_toggle) - engaged_blocked_toggles = (self._long_maneuver_toggle, self._alpha_long_toggle) + onroad_blocked_toggles = ( + self._adb_toggle, + self._car_make_btn, + self._car_model_btn, + self._force_fingerprint_toggle, + self._use_prebuilt_toggle, + self._joystick_toggle, + ) + release_blocked_toggles = (self._joystick_toggle, self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) + engaged_blocked_toggles = (self._long_maneuver_toggle, self._lat_maneuver_toggle, self._alpha_long_toggle) + + # Hide non-release toggles on release builds + for item in release_blocked_toggles: + item.set_visible(not ui_state.is_release) # Disable toggles that require offroad for item in onroad_blocked_toggles: @@ -128,23 +138,43 @@ class DeveloperLayoutMici(NavWidget): ui_state.add_offroad_transition_callback(self._update_toggles) + def _update_state(self): + super()._update_state() + self._ssh_fetcher.update() + + def show_event(self): + super().show_event() + self._car_make_btn.set_value(self._get_display_make()) + self._car_model_btn.set_value(self._get_display_model()) + self._update_toggles() + + def _show_option_dialog(self, title: str, options: list[str], current: str, on_selected): + dialog_holder: dict[str, BigMultiOptionDialog] = {} + + def on_confirm(): + on_selected(dialog_holder["dialog"].get_selected_option()) + + dialog = BigMultiOptionDialog(options=options, default=current, right_btn_callback=on_confirm) + dialog_holder["dialog"] = dialog + gui_app.push_widget(dialog) + def _get_display_make(self) -> str: - make = ui_state.params.get("CarMake") or "" + make = ui_state.params.get("CarMake", encoding="utf-8") or "" if make: return make - model = ui_state.params.get("CarModel") or "" + model = ui_state.params.get("CarModel", encoding="utf-8") or "" if model: return self._make_by_model.get(model, format_fingerprint_value(model.split("_", 1)[0])) return "Select" def _get_selected_model_option(self) -> FingerprintModelOption | None: - model = ui_state.params.get("CarModel") or "" + model = ui_state.params.get("CarModel", encoding="utf-8") or "" if not model: return None - model_name = ui_state.params.get("CarModelName") or "" - make = ui_state.params.get("CarMake") or self._make_by_model.get(model, "") + model_name = ui_state.params.get("CarModelName", encoding="utf-8") or "" + make = ui_state.params.get("CarMake", encoding="utf-8") or self._make_by_model.get(model, "") if make and model_name: for option in self._models_by_make.get(make, ()): if option.value == model and option.label == model_name: @@ -157,8 +187,8 @@ class DeveloperLayoutMici(NavWidget): if selected_option is not None: return selected_option.button_label - model = ui_state.params.get("CarModel") or "" - model_name = ui_state.params.get("CarModelName") or "" + model = ui_state.params.get("CarModel", encoding="utf-8") or "" + model_name = ui_state.params.get("CarModelName", encoding="utf-8") or "" if model: model_option = self._models_by_value.get(model) if model_option is not None: @@ -197,54 +227,40 @@ class DeveloperLayoutMici(NavWidget): def _open_make_selector(self): options = list(self._make_options) if not options: - gui_app.set_modal_overlay(BigDialog("No fingerprint list available", "")) + gui_app.push_widget(BigDialog("", "No fingerprint list available")) return current_make = self._get_display_make() default_make = current_make if current_make in options else options[0] - def on_selected(): - selected_make = option_dialog.get_selected_option() + def on_selected(selected_make: str): self._set_car_make(selected_make) - - current_model = ui_state.params.get("CarModel") or "" + current_model = ui_state.params.get("CarModel", encoding="utf-8") or "" available_models = {option.value for option in self._models_by_make.get(selected_make, ())} - if current_model not in available_models: - default_model = self._models_by_make[selected_make][0] - self._set_car_model(default_model) + if current_model not in available_models and self._models_by_make.get(selected_make): + self._set_car_model(self._models_by_make[selected_make][0]) - option_dialog = BigMultiOptionDialog(options=options, default=default_make, right_btn_callback=on_selected) - gui_app.set_modal_overlay(option_dialog) + self._show_option_dialog("select make", options, default_make, on_selected) def _open_model_selector(self): make = self._get_display_make() model_options = self._models_by_make.get(make, ()) if not model_options: - gui_app.set_modal_overlay(BigDialog("Select a car make first", "")) + gui_app.push_widget(BigDialog("", "Select a car make first")) return - current_model = ui_state.params.get("CarModel") or "" - current_model_name = ui_state.params.get("CarModelName") or "" + current_model = ui_state.params.get("CarModel", encoding="utf-8") or "" + current_model_name = ui_state.params.get("CarModelName", encoding="utf-8") or "" option_labels = [option.option_label for option in model_options] selected_by_label = {option.option_label: option for option in model_options} default_model = next((option.option_label for option in model_options if option.value == current_model and option.label == current_model_name), None) if default_model is None: default_model = next((option.option_label for option in model_options if option.value == current_model), option_labels[0]) - def on_selected(): - selected_model = selected_by_label[option_dialog.get_selected_option()] - self._set_car_model(selected_model) + def on_selected(selected_label: str): + self._set_car_model(selected_by_label[selected_label]) - option_dialog = BigMultiOptionDialog(options=option_labels, default=default_model, right_btn_callback=on_selected) - gui_app.set_modal_overlay(option_dialog) - - def show_event(self): - super().show_event() - self._scroller.show_event() - self._update_toggles() - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) + self._show_option_dialog("select model", option_labels, default_model, on_selected) def _update_toggles(self): ui_state.update_params() @@ -252,7 +268,7 @@ class DeveloperLayoutMici(NavWidget): # CP gating if ui_state.CP is not None: alpha_avail = ui_state.CP.alphaLongitudinalAvailable - if not alpha_avail: + if not alpha_avail or ui_state.is_release: self._alpha_long_toggle.set_visible(False) ui_state.params.remove("AlphaLongitudinalEnabled") else: @@ -263,8 +279,12 @@ class DeveloperLayoutMici(NavWidget): if not long_man_enabled: self._long_maneuver_toggle.set_checked(False) ui_state.params.put_bool("LongitudinalManeuverMode", False) + + lat_man_enabled = ui_state.is_offroad() + self._lat_maneuver_toggle.set_enabled(lat_man_enabled) else: self._long_maneuver_toggle.set_enabled(False) + self._lat_maneuver_toggle.set_enabled(False) self._alpha_long_toggle.set_visible(False) # Refresh toggles from params to mirror external changes @@ -278,11 +298,24 @@ class DeveloperLayoutMici(NavWidget): ui_state.params.put_bool("JoystickDebugMode", state) ui_state.params.put_bool("LongitudinalManeuverMode", False) self._long_maneuver_toggle.set_checked(False) + ui_state.params.put_bool("LateralManeuverMode", False) + self._lat_maneuver_toggle.set_checked(False) def _on_long_maneuver_mode(self, state: bool): ui_state.params.put_bool("LongitudinalManeuverMode", state) ui_state.params.put_bool("JoystickDebugMode", False) self._joystick_toggle.set_checked(False) + ui_state.params.put_bool("LateralManeuverMode", False) + self._lat_maneuver_toggle.set_checked(False) + restart_needed_callback(state) + + def _on_lat_maneuver_mode(self, state: bool): + ui_state.params.put_bool("LateralManeuverMode", state) + ui_state.params.put_bool("ExperimentalMode", False) + ui_state.params.put_bool("JoystickDebugMode", False) + self._joystick_toggle.set_checked(False) + ui_state.params.put_bool("LongitudinalManeuverMode", False) + self._long_maneuver_toggle.set_checked(False) restart_needed_callback(state) def _on_alpha_long_enabled(self, state: bool): diff --git a/selfdrive/ui/mici/layouts/settings/device.py b/selfdrive/ui/mici/layouts/settings/device.py index a774560f3..3c165b5bb 100644 --- a/selfdrive/ui/mici/layouts/settings/device.py +++ b/selfdrive/ui/mici/layouts/settings/device.py @@ -1,47 +1,52 @@ import os import threading -import json -import hashlib -import secrets -import string import pyray as rl from enum import IntEnum from collections.abc import Callable -from pathlib import Path from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params -from openpilot.common.swaglog import cloudlog from openpilot.common.time_helpers import system_time_valid -from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton, BigParamControl -from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigDialog, BigConfirmationDialogV2, BigInputDialog +from openpilot.system.ui.widgets.scroller import NavRawScrollPanel, NavScroller +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigCircleButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog -from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide +from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide, TermsPage from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.system.ui.widgets import Widget from openpilot.selfdrive.ui.ui_state import device, ui_state -from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.html_render import HtmlModal, HtmlRenderer from openpilot.system.athena.registration import UNREGISTERED_DONGLE_ID -from openpilot.system.hardware import PC -from openpilot.system.hardware.hw import Paths - -import qrcode -import numpy as np -class MiciFccModal(NavWidget): - BACK_TOUCH_AREA_PERCENTAGE = 0.1 +class ReviewTermsPage(TermsPage, NavScroller): + """TermsPage with NavWidget swipe-to-dismiss for reviewing in device settings.""" + def __init__(self): + super().__init__(on_accept=self.dismiss, on_decline=self.dismiss) + self._terms_header.set_visible(False) + self._must_accept_card.set_visible(False) + self._accept_button.set_visible(False) + self._decline_button.set_visible(False) + +class ReviewTrainingGuide(TrainingGuide): + def show_event(self): + super().show_event() + device.set_override_interactive_timeout(300) + + def hide_event(self): + super().hide_event() + device.set_override_interactive_timeout(None) + ui_state.params.put_bool_nonblocking("IsDriverViewEnabled", False) + + +class MiciFccModal(NavRawScrollPanel): def __init__(self, file_path: str | None = None, text: str | None = None): super().__init__() - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) self._content = HtmlRenderer(file_path=file_path, text=text) - self._scroll_panel = GuiScrollPanel2(horizontal=False) self._fcc_logo = gui_app.texture("icons_mici/settings/device/fcc_logo.png", 76, 64) def _render(self, rect: rl.Rectangle): @@ -58,39 +63,32 @@ class MiciFccModal(NavWidget): rl.draw_texture_ex(self._fcc_logo, fcc_pos, 0.0, 1.0, rl.WHITE) - return -1 - -def _engaged_confirmation_callback(callback: Callable, action_text: str): +def _engaged_confirmation_click(callback: Callable, action_text: str, icon: rl.Texture, exit_on_confirm: bool = True, red: bool = False): if not ui_state.engaged: def confirm_callback(): # Check engaged again in case it changed while the dialog was open + # TODO: if true, we stay on the dialog if not exit_on_confirm until normal onroad timeout if not ui_state.engaged: callback() - red = False - if action_text == "power off": - icon = "icons_mici/settings/device/power.png" - red = True - elif action_text == "reboot": - icon = "icons_mici/settings/device/reboot.png" - elif action_text == "reset": - icon = "icons_mici/settings/device/lkas.png" - elif action_text == "reset driver monitoring": - icon = "icons_mici/settings/device/cameras.png" - elif action_text == "uninstall": - icon = "icons_mici/settings/device/uninstall.png" - else: - # TODO: check - icon = "icons_mici/settings/comma_icon.png" - - dlg: BigConfirmationDialogV2 | BigDialog = BigConfirmationDialogV2(f"slide to\n{action_text.lower()}", icon, red=red, - exit_on_confirm=action_text in {"reset", "reset driver monitoring"}, - confirm_callback=confirm_callback) - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(BigConfirmationDialog(f"slide to\n{action_text.lower()}", icon, confirm_callback, exit_on_confirm=exit_on_confirm, red=red)) else: - dlg = BigDialog(f"Disengage to {action_text}", "") - gui_app.set_modal_overlay(dlg) + gui_app.push_widget(BigDialog("", f"Disengage to {action_text}")) + + +class EngagedConfirmationCircleButton(BigCircleButton): + def __init__(self, title: str, icon: rl.Texture, callback: Callable[[], None], exit_on_confirm: bool = True, + red: bool = False, icon_offset: tuple[int, int] = (0, 0)): + super().__init__(icon, red, icon_offset) + self.set_click_callback(lambda: _engaged_confirmation_click(callback, title, icon, exit_on_confirm=exit_on_confirm, red=red)) + + +class EngagedConfirmationButton(BigButton): + def __init__(self, text: str, action_text: str, icon: rl.Texture, callback: Callable[[], None], + exit_on_confirm: bool = True, red: bool = False): + super().__init__(text, "", icon) + self.set_click_callback(lambda: _engaged_confirmation_click(callback, action_text, icon, exit_on_confirm=exit_on_confirm, red=red)) class DeviceInfoLayoutMici(Widget): @@ -100,14 +98,15 @@ class DeviceInfoLayoutMici(Widget): self.set_rect(rl.Rectangle(0, 0, 360, 180)) params = Params() - header_color = rl.Color(255, 255, 255, int(255 * 0.9)) subheader_color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)) max_width = int(self._rect.width - 20) - self._dongle_id_label = MiciLabel("device ID", 48, width=max_width, color=header_color, font_weight=FontWeight.DISPLAY) - self._dongle_id_text_label = MiciLabel(params.get("DongleId") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN) + self._dongle_id_label = UnifiedLabel("device ID", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self._dongle_id_text_label = UnifiedLabel(params.get("DongleId") or 'N/A', 32, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False) - self._serial_number_label = MiciLabel("serial", 48, color=header_color, font_weight=FontWeight.DISPLAY) - self._serial_number_text_label = MiciLabel(params.get("HardwareSerial") or 'N/A', 32, width=max_width, color=subheader_color, font_weight=FontWeight.ROMAN) + self._serial_number_label = UnifiedLabel("serial", 48, max_width=max_width, font_weight=FontWeight.DISPLAY, wrap_text=False) + self._serial_number_text_label = UnifiedLabel(params.get("HardwareSerial") or 'N/A', 32, max_width=max_width, text_color=subheader_color, + font_weight=FontWeight.ROMAN, wrap_text=False) def _render(self, _): self._dongle_id_label.set_position(self._rect.x + 20, self._rect.y - 10) @@ -131,9 +130,14 @@ class UpdaterState(IntEnum): class PairBigButton(BigButton): def __init__(self): - super().__init__("pair", "connect.comma.ai", "icons_mici/settings/comma_icon.png") + super().__init__("pair", "connect.comma.ai", gui_app.texture("icons_mici/settings/comma_icon.png", 33, 60)) + + def _get_label_font_size(self): + return 64 def _update_state(self): + super()._update_state() + if ui_state.prime_state.is_paired(): self.set_text("paired") if ui_state.prime_state.is_prime(): @@ -152,191 +156,27 @@ class PairBigButton(BigButton): return dlg: BigDialog | PairingDialog if not system_time_valid(): - dlg = BigDialog(tr("Please connect to Wi-Fi to complete initial pairing"), "") + dlg = BigDialog("", tr("Please connect to Wi-Fi to complete initial pairing.")) elif UNREGISTERED_DONGLE_ID == (ui_state.params.get("DongleId") or UNREGISTERED_DONGLE_ID): - dlg = BigDialog(tr("Device must be registered with the comma.ai backend to pair"), "") + dlg = BigDialog("", tr("Device must be registered with the comma.ai backend to pair.")) else: dlg = PairingDialog() - gui_app.set_modal_overlay(dlg) - - -class GalaxyQRDialog(NavWidget): - def __init__(self, url: str): - super().__init__() - self._url = url - self._qr_texture: rl.Texture | None = None - - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) - - self._title = MiciLabel("pair with galaxy", 48, font_weight=FontWeight.BOLD, - color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True) - self._generate_qr_code() - - def _generate_qr_code(self) -> None: - try: - qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) - qr.add_data(self._url) - qr.make(fit=True) - - pil_img = qr.make_image(fill_color="white", back_color="black").convert("RGBA") - img_array = np.array(pil_img, dtype=np.uint8) - - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - rl_image = rl.Image() - rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) - rl_image.width = pil_img.width - rl_image.height = pil_img.height - rl_image.mipmaps = 1 - rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 - self._qr_texture = rl.load_texture_from_image(rl_image) - except Exception as e: - cloudlog.warning(f"Galaxy QR generation failed: {e}") - self._qr_texture = None - - def _render(self, rect: rl.Rectangle): - if self._qr_texture is not None: - scale = rect.height / self._qr_texture.height - pos = rl.Vector2(rect.x + 8, rect.y) - rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) - else: - rl.draw_text_ex( - gui_app.font(FontWeight.BOLD), - "QR Code Error", - rl.Vector2(rect.x + 20, rect.y + rect.height // 2 - 15), - 30, - 0.0, - rl.RED, - ) - - label_x = rect.x + 8 + rect.height + 24 - self._title.set_width(int(rect.width - label_x)) - self._title.set_position(label_x, rect.y + 28) - self._title.render() - - return -1 - - def __del__(self): - if self._qr_texture and self._qr_texture.id != 0: - rl.unload_texture(self._qr_texture) - - -class GalaxyBigButton(BigButton): - _SLUG_CHARS = string.ascii_letters + string.digits - - def __init__(self): - super().__init__("galaxy", "", gui_app.starpilot_texture("../system/the_pond/assets/images/main_logo.png", 64, 64)) - self._galaxy_dir = Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy") - self._auth_path = self._galaxy_dir / "glxyauth" - self._session_path = self._galaxy_dir / "glxysession" - self._slug_path = self._galaxy_dir / "glxyslug" - - def _is_paired(self) -> bool: - try: - return len(self._auth_path.read_text(encoding="utf-8").strip()) == 64 - except Exception: - return False - - def _get_slug(self) -> str: - try: - return self._slug_path.read_text(encoding="utf-8").strip() - except Exception: - return "" - - def _show_qr(self): - slug = self._get_slug() - if not slug: - gui_app.set_modal_overlay(BigDialog("Galaxy is not paired yet.", "")) - return - gui_app.set_modal_overlay(GalaxyQRDialog(f"https://galaxy.firestar.link/{slug}")) - - def _pair_with_pin(self, pin: str): - clean_pin = str(pin or "").strip() - if len(clean_pin) < 6: - gui_app.set_modal_overlay(BigDialog("PIN must be at least 6 characters.", "")) - return - - try: - self._galaxy_dir.mkdir(parents=True, exist_ok=True) - self._auth_path.write_text(hashlib.sha256(clean_pin.encode("utf-8")).hexdigest(), encoding="utf-8") - self._session_path.write_text(secrets.token_hex(32), encoding="utf-8") - slug = "".join(secrets.choice(self._SLUG_CHARS) for _ in range(16)) - self._slug_path.write_text(slug, encoding="utf-8") - except Exception as e: - cloudlog.warning(f"Galaxy pairing write failed: {e}") - gui_app.set_modal_overlay(BigDialog("Failed to pair with Galaxy.", "")) - return - - self._show_qr() - - def _unpair(self): - for path in (self._auth_path, self._session_path, self._slug_path): - try: - path.unlink(missing_ok=True) - except TypeError: - # Python < 3.8 fallback - if path.exists(): - path.unlink() - except Exception as e: - cloudlog.warning(f"Galaxy unpair cleanup failed for {path}: {e}") - - def _handle_mouse_release(self, mouse_pos: MousePos): - super()._handle_mouse_release(mouse_pos) - - if self._is_paired(): - show_qr_option = "show qr" - unpair_option = "unpair" - - def on_option_selected(): - selected = option_dialog.get_selected_option() - if selected == show_qr_option: - self._show_qr() - elif selected == unpair_option: - confirm = BigConfirmationDialogV2( - "slide to\nunpair galaxy", - "icons_mici/settings/device/uninstall.png", - red=True, - confirm_callback=self._unpair, - ) - gui_app.set_modal_overlay(confirm) - - option_dialog = BigMultiOptionDialog( - options=[show_qr_option, unpair_option], - default=show_qr_option, - right_btn_callback=on_option_selected, - ) - gui_app.set_modal_overlay(option_dialog) - return - - pin_dialog = BigInputDialog( - hint="enter galaxy pin...", - default_text="", - minimum_length=6, - confirm_callback=self._pair_with_pin, - ) - gui_app.set_modal_overlay(pin_dialog) - - def _update_state(self): - self.set_value("paired" if self._is_paired() else "pair") + gui_app.push_widget(dlg) UPDATER_TIMEOUT = 10.0 # seconds to wait for updater to respond -UPDATE_SCREEN_TIMEOUT = 180 # Keep display awake for 3 minutes during long-running update phases. -EXTENDED_TIMEOUT_UPDATER_STATES = {"downloading...", "finalizing update..."} class UpdateOpenpilotBigButton(BigButton): def __init__(self): - self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 64) - self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 64) + self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75) + self._txt_reboot_icon = gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70) self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64) super().__init__("update openpilot", "", self._txt_update_icon) self._waiting_for_updater_t: float | None = None self._hide_value_t: float | None = None self._state: UpdaterState = UpdaterState.IDLE - self._extended_timeout_enabled = False ui_state.add_offroad_transition_callback(self.offroad_transition) @@ -345,9 +185,11 @@ class UpdateOpenpilotBigButton(BigButton): self.set_enabled(True) def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + if not system_time_valid(): - dlg = BigDialog(tr("Please connect to Wi-Fi to update"), "") - gui_app.set_modal_overlay(dlg) + dlg = BigDialog("", tr("Please connect to Wi-Fi to update.")) + gui_app.push_widget(dlg) return self.set_enabled(False) @@ -364,17 +206,6 @@ class UpdateOpenpilotBigButton(BigButton): threading.Thread(target=run, daemon=True).start() - def hide_event(self): - super().hide_event() - self._set_extended_timeout(False) - - def _set_extended_timeout(self, enabled: bool): - if self._extended_timeout_enabled == enabled: - return - - self._extended_timeout_enabled = enabled - device.set_override_interactive_timeout(UPDATE_SCREEN_TIMEOUT if enabled else None) - def set_value(self, value: str): super().set_value(value) if value: @@ -383,13 +214,13 @@ class UpdateOpenpilotBigButton(BigButton): self.set_text("update openpilot") def _update_state(self): + super()._update_state() + if ui_state.started: - self._set_extended_timeout(False) self.set_enabled(False) return updater_state = ui_state.params.get("UpdaterState") or "" - should_extend_timeout = updater_state in EXTENDED_TIMEOUT_UPDATER_STATES failed_count = ui_state.params.get("UpdateFailedCount") failed = False if failed_count is None else int(failed_count) > 0 @@ -411,7 +242,7 @@ class UpdateOpenpilotBigButton(BigButton): if self._waiting_for_updater_t is not None and rl.get_time() - self._waiting_for_updater_t > UPDATER_TIMEOUT: self.set_rotate_icon(False) - self.set_value("updater failed to respond") + self.set_value("updater failed\nto respond") self._state = UpdaterState.IDLE self._hide_value_t = rl.get_time() @@ -453,16 +284,12 @@ class UpdateOpenpilotBigButton(BigButton): if self._state != UpdaterState.WAITING_FOR_UPDATER: self._waiting_for_updater_t = None - self._set_extended_timeout(should_extend_timeout) - -class DeviceLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): +class DeviceLayoutMici(NavScroller): + def __init__(self): super().__init__() self._fcc_dialog: HtmlModal | None = None - self._driver_camera: DriverCameraDialog | None = None - self._training_guide: TrainingGuide | None = None def power_off_callback(): ui_state.params.put_bool("DoShutdown", True) @@ -479,106 +306,52 @@ class DeviceLayoutMici(NavWidget): params.remove("LiveDelay") params.put_bool("OnroadCycleRequested", True) - def reset_driver_monitoring_callback(): - params = ui_state.params - params.remove("IsRhdDetected") - params.put_bool("OnroadCycleRequested", True) - def uninstall_openpilot_callback(): ui_state.params.put_bool("DoUninstall", True) - reset_driver_monitoring_btn = BigButton("reset driver monitoring calibration", "", "icons_mici/settings/device/cameras.png") - reset_driver_monitoring_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_driver_monitoring_callback, "reset driver monitoring")) + reset_calibration_btn = EngagedConfirmationButton("reset calibration", "reset", gui_app.texture("icons_mici/settings/device/lkas.png", 122, 64), + reset_calibration_callback) - reset_calibration_btn = BigButton("reset calibration", "", "icons_mici/settings/device/lkas.png") - reset_calibration_btn.set_click_callback(lambda: _engaged_confirmation_callback(reset_calibration_callback, "reset")) + uninstall_openpilot_btn = EngagedConfirmationButton("uninstall openpilot", "uninstall", + gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), + uninstall_openpilot_callback, exit_on_confirm=False) - uninstall_openpilot_btn = BigButton("uninstall openpilot", "", "icons_mici/settings/device/uninstall.png") - uninstall_openpilot_btn.set_click_callback(lambda: _engaged_confirmation_callback(uninstall_openpilot_callback, "uninstall")) + reboot_btn = EngagedConfirmationCircleButton("reboot", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), + reboot_callback, exit_on_confirm=False) - reboot_btn = BigCircleButton("icons_mici/settings/device/reboot.png", red=False) - reboot_btn.set_click_callback(lambda: _engaged_confirmation_callback(reboot_callback, "reboot")) + self._power_off_btn = EngagedConfirmationCircleButton("power off", gui_app.texture("icons_mici/settings/device/power.png", 64, 66), + power_off_callback, exit_on_confirm=False, red=True) + self._power_off_btn.set_visible(lambda: not ui_state.ignition) - self._power_off_btn = BigCircleButton("icons_mici/settings/device/power.png", red=True) - self._power_off_btn.set_click_callback(lambda: _engaged_confirmation_callback(power_off_callback, "power off")) - - self._load_languages() - - def language_callback(): - def selected_language_callback(): - selected_language = dlg.get_selected_option() - ui_state.params.put("LanguageSetting", self._languages[selected_language]) - - current_language_name = ui_state.params.get("LanguageSetting") - current_language = next(name for name, lang in self._languages.items() if lang == current_language_name) - - dlg = BigMultiOptionDialog(list(self._languages), default=current_language, right_btn_callback=selected_language_callback) - gui_app.set_modal_overlay(dlg) - - # lang_button = BigButton("change language", "", "icons_mici/settings/device/language.png") - # lang_button.set_click_callback(language_callback) - - regulatory_btn = BigButton("regulatory info", "", "icons_mici/settings/device/info.png") + regulatory_btn = BigButton("regulatory info", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) regulatory_btn.set_click_callback(self._on_regulatory) - driver_cam_btn = BigButton("driver camera preview", "", "icons_mici/settings/device/cameras.png") - driver_cam_btn.set_click_callback(self._show_driver_camera) + driver_cam_btn = BigButton("driver\ncamera preview", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + driver_cam_btn.set_click_callback(lambda: gui_app.push_widget(DriverCameraDialog())) driver_cam_btn.set_enabled(lambda: ui_state.is_offroad()) - review_training_guide_btn = BigButton("review training guide", "", "icons_mici/settings/device/info.png") - review_training_guide_btn.set_click_callback(self._on_review_training_guide) + review_training_guide_btn = BigButton("review\ntraining guide", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) + review_training_guide_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTrainingGuide(completed_callback=lambda: gui_app.pop_widgets_to(self)))) review_training_guide_btn.set_enabled(lambda: ui_state.is_offroad()) - self._scroller = Scroller([ + terms_btn = BigButton("terms &\nconditions", "", gui_app.texture("icons_mici/settings/device/info.png", 64, 64)) + terms_btn.set_click_callback(lambda: gui_app.push_widget(ReviewTermsPage())) + + self._scroller.add_widgets([ DeviceInfoLayoutMici(), UpdateOpenpilotBigButton(), - BigParamControl("automatically update starpilot", "AutomaticUpdates"), PairBigButton(), review_training_guide_btn, driver_cam_btn, - reset_driver_monitoring_btn, - # lang_button, + terms_btn, + regulatory_btn, reset_calibration_btn, uninstall_openpilot_btn, - regulatory_btn, reboot_btn, self._power_off_btn, - ], snap_items=False, scroll_indicator=True, edge_shadows=True) - - # Set up back navigation - self.set_back_callback(back_callback) - - # Hide power off button when onroad - ui_state.add_offroad_transition_callback(self._offroad_transition) + ]) def _on_regulatory(self): if not self._fcc_dialog: self._fcc_dialog = MiciFccModal(os.path.join(BASEDIR, "selfdrive/assets/offroad/mici_fcc.html")) - gui_app.set_modal_overlay(self._fcc_dialog, callback=setattr(self, '_fcc_dialog', None)) - - def _offroad_transition(self): - self._power_off_btn.set_visible(ui_state.is_offroad()) - - def _show_driver_camera(self): - if not self._driver_camera: - self._driver_camera = DriverCameraDialog() - gui_app.set_modal_overlay(self._driver_camera, callback=lambda result: setattr(self, '_driver_camera', None)) - - def _on_review_training_guide(self): - if not self._training_guide: - def completed_callback(): - gui_app.set_modal_overlay(None) - - self._training_guide = TrainingGuide(completed_callback=completed_callback) - gui_app.set_modal_overlay(self._training_guide, callback=lambda result: setattr(self, '_training_guide', None)) - - def _load_languages(self): - with open(os.path.join(BASEDIR, "selfdrive/ui/translations/languages.json")) as f: - self._languages = json.load(f) - - def show_event(self): - super().show_event() - self._scroller.show_event() - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) + gui_app.push_widget(self._fcc_dialog) diff --git a/selfdrive/ui/mici/layouts/settings/driving_model.py b/selfdrive/ui/mici/layouts/settings/driving_model.py index eab57a220..3226ba6f8 100644 --- a/selfdrive/ui/mici/layouts/settings/driving_model.py +++ b/selfdrive/ui/mici/layouts/settings/driving_model.py @@ -18,7 +18,7 @@ from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigDialogBase, BigMultiOptionDialog from openpilot.selfdrive.ui.ui_state import ui_state from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.widgets import DialogResult, Widget +from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import gui_label import pyray as rl @@ -75,17 +75,19 @@ def _split_param(param_value: str | None) -> list[str]: class DownloadProgressDialog(BigDialogBase): def __init__(self, params_memory: Params, is_downloading: Callable[[], bool], cancel_callback: Callable[[], None], - is_terminal_progress: Callable[[str], bool]): + is_terminal_progress: Callable[[str], bool], on_close: Callable[[], None] | None = None): super().__init__() self._params_memory = params_memory self._is_downloading = is_downloading self._cancel_callback = cancel_callback self._is_terminal_progress = is_terminal_progress + self._on_close = on_close self._progress = 0.0 self._status = "Downloading..." self._terminal_progress_since = 0.0 self._downloading = False + self._dismissed = False self._cancel_btn = DownloadActionButton("cancel download") self._cancel_btn.set_click_callback(self._cancel_callback) @@ -140,7 +142,9 @@ class DownloadProgressDialog(BigDialogBase): if self._terminal_progress_since == 0.0: self._terminal_progress_since = time.monotonic() elif time.monotonic() - self._terminal_progress_since >= _DOWNLOAD_DIALOG_CLOSE_SECONDS: - self._ret = DialogResult.CONFIRM + if not self._dismissed: + self._dismissed = True + self.dismiss(self._on_close) else: self._terminal_progress_since = 0.0 @@ -235,8 +239,6 @@ class DownloadProgressDialog(BigDialogBase): alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, ) - return self._ret - class DownloadActionButton(Widget): def __init__(self, label: str): @@ -276,7 +278,7 @@ class DownloadActionButton(Widget): class DrivingModelBigButton(BigButton): def __init__(self): - super().__init__("driving model", "", "icons_mici/settings/device/lkas.png") + super().__init__("driving model", "", gui_app.texture("icons_mici/settings/device/lkas.png", 72, 56)) self._params = Params() self._params_memory = Params(memory=True) self._model_manager = ModelManager(self._params, self._params_memory) @@ -285,12 +287,17 @@ class DrivingModelBigButton(BigButton): self._active_job = "" self._manifest_last_refresh_mono = 0.0 self._terminal_progress_since = 0.0 + self._sub_label.set_font_size(32) + self._sub_label._scroll = True + self._sub_label._elide = False + self._sub_label._wrap_text = False self.set_click_callback(self._open_manager_menu) self.refresh() def show_event(self): super().show_event() + self._sub_label.reset_scroll() self.refresh() # Always fetch manifest once when this settings pane opens. self._maybe_refresh_manifest(force=(self._manifest_last_refresh_mono == 0.0)) @@ -298,6 +305,31 @@ class DrivingModelBigButton(BigButton): def refresh(self): self._update_button_value() + def set_value(self, value: str): + super().set_value(value) + self._sub_label.reset_scroll() + + def _get_label_font_size(self): + return 42 + + def _width_hint(self) -> int: + icon_width = self._txt_icon.width + 16 if self._txt_icon else 0 + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_width) + + def _show_option_dialog(self, title: str, options: list[str], current: str | None, on_selected: Callable[[str], None], + back_callback: Callable[[], None] | None = None): + dialog_holder: dict[str, BigMultiOptionDialog] = {} + + def on_confirm(): + on_selected(dialog_holder["dialog"].get_selected_option()) + + default_option = current if current in options else None + dialog = BigMultiOptionDialog(options=options, default=default_option, right_btn_callback=on_confirm) + if back_callback is not None: + dialog.set_back_callback(back_callback) + dialog_holder["dialog"] = dialog + gui_app.push_widget(dialog) + def _update_state(self): super()._update_state() self._process_terminal_progress() @@ -315,8 +347,7 @@ class DrivingModelBigButton(BigButton): if not options: return - def on_confirm(): - value = option_dialog.get_selected_option() + def on_selected(value: str): if value == "set sort mode": self._open_sort_mode_dialog() elif value == "switch model": @@ -328,9 +359,7 @@ class DrivingModelBigButton(BigButton): elif value == "refresh manifest": self._maybe_refresh_manifest(force=True) - default_option = "switch model" if "switch model" in options else options[0] - option_dialog = BigMultiOptionDialog(options=options, default=default_option, right_btn_callback=on_confirm) - gui_app.set_modal_overlay(option_dialog) + self._show_option_dialog("driving model", options, None, on_selected) def _open_switch_dialog(self): self._maybe_refresh_manifest(force=False) @@ -346,7 +375,8 @@ class DrivingModelBigButton(BigButton): return current_key = self._get_current_model_key() - self._show_model_dialog("Select Driving Model", installed, current_key, self._switch_model) + self._show_model_dialog("Select Driving Model", installed, current_key, self._switch_model, + back_callback=self._open_manager_menu) def _open_download_dialog(self): if ui_state.started: @@ -365,7 +395,8 @@ class DrivingModelBigButton(BigButton): self._show_message("All models downloaded", "No additional models are available.", return_to_manager=True) return - self._show_model_dialog("Download Driving Model", missing, "", self._start_model_download) + self._show_model_dialog("Download Driving Model", missing, "", self._start_model_download, + back_callback=self._open_manager_menu) def _download_all_missing(self): if ui_state.started: @@ -398,22 +429,20 @@ class DrivingModelBigButton(BigButton): options = [_SORT_MODE_LABELS[mode] for mode in _SORT_MODES] current_mode = self._get_sort_mode() - def on_confirm(): - selected_label = sort_dialog.get_selected_option() + def on_selected(selected_label: str): selected_mode = _LABEL_TO_SORT_MODE.get(selected_label, _SORT_MODE_ALPHABETICAL) self._params.put(_SORT_MODE_PARAM, selected_mode) - self._open_manager_menu_if_no_overlay() + self._open_manager_menu() - sort_dialog = BigMultiOptionDialog(options=options, default=_SORT_MODE_LABELS[current_mode], right_btn_callback=on_confirm) - sort_dialog.set_back_callback(self._open_manager_menu) - gui_app.set_modal_overlay(sort_dialog) + self._show_option_dialog("sort mode", options, _SORT_MODE_LABELS[current_mode], on_selected, + back_callback=self._open_manager_menu) def _get_sort_mode(self) -> str: mode = (self._params.get(_SORT_MODE_PARAM, encoding="utf-8") or "").strip() return mode if mode in _SORT_MODES else _SORT_MODE_ALPHABETICAL def _show_model_dialog(self, title: str, entries: list[ModelEntry], current_key: str, - on_selected: Callable[[str], None]): + on_selected: Callable[[str], None], back_callback: Callable[[], None] | None = None): options, option_to_key, key_to_option = self._build_model_options(entries) if not options: self._show_message("No models available", "Refresh manifest and try again.", return_to_manager=True) @@ -421,15 +450,12 @@ class DrivingModelBigButton(BigButton): default_option = key_to_option.get(current_key, options[0]) - def on_confirm(): - model_key = option_to_key.get(model_dialog.get_selected_option()) + def on_dialog_selected(selected_option: str): + model_key = option_to_key.get(selected_option) if model_key: on_selected(model_key) - self._open_manager_menu_if_no_overlay() - model_dialog = BigMultiOptionDialog(options=options, default=default_option, right_btn_callback=on_confirm) - model_dialog.set_back_callback(self._open_manager_menu) - gui_app.set_modal_overlay(model_dialog) + self._show_option_dialog(title, options, default_option, on_dialog_selected, back_callback=back_callback) def _build_model_options(self, entries: list[ModelEntry]) -> tuple[list[str], dict[str, str], dict[str, str]]: # Ensure display names are unique before applying status text (date/favorite). @@ -712,21 +738,18 @@ class DrivingModelBigButton(BigButton): lower = progress.lower() return any(pattern in lower for pattern in _TERMINAL_PROGRESS_PATTERNS) - def _open_manager_menu_if_no_overlay(self): - if gui_app._modal_overlay.overlay is None: - self._open_manager_menu() - def _show_download_progress_dialog(self): dialog = DownloadProgressDialog( params_memory=self._params_memory, is_downloading=self._is_download_job_running, cancel_callback=self._cancel_download, is_terminal_progress=self._is_terminal_progress, + on_close=self._open_manager_menu, ) - gui_app.set_modal_overlay(dialog, callback=lambda _result: self._open_manager_menu()) + gui_app.push_widget(dialog) def _show_message(self, title: str, description: str, return_to_manager: bool = False): dialog = BigDialog(title, description) if return_to_manager: dialog.set_back_callback(self._open_manager_menu) - gui_app.set_modal_overlay(dialog) + gui_app.push_widget(dialog) diff --git a/selfdrive/ui/mici/layouts/settings/firehose.py b/selfdrive/ui/mici/layouts/settings/firehose.py index d305906e1..4c27a909f 100644 --- a/selfdrive/ui/mici/layouts/settings/firehose.py +++ b/selfdrive/ui/mici/layouts/settings/firehose.py @@ -13,7 +13,8 @@ from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 from openpilot.system.ui.lib.multilang import tr, trn, tr_noop -from openpilot.system.ui.widgets import Widget, NavWidget +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller import NavRawScrollPanel TITLE = tr_noop("Firehose Mode") DESCRIPTION = tr_noop( @@ -80,12 +81,12 @@ class FirehoseLayoutBase(Widget): def _render(self, rect: rl.Rectangle): # compute total content height for scrolling content_height = self._measure_content_height(rect) - scroll_offset = round(self._scroll_panel.update(rect, content_height)) + scroll_offset = self._scroll_panel.update(rect, content_height) # start drawing with offset - x = int(rect.x + 40) - y = int(rect.y + 40 + scroll_offset) - w = int(rect.width - 80) + x = rect.x + 40 + y = rect.y + 40 + scroll_offset + w = rect.width - 80 # Title title_text = tr(TITLE) @@ -99,7 +100,7 @@ class FirehoseLayoutBase(Widget): y += 20 # Separator - rl.draw_rectangle(x, y, w, 2, self.GRAY) + rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY) y += 20 # Status @@ -115,7 +116,7 @@ class FirehoseLayoutBase(Widget): y += 20 # Separator - rl.draw_rectangle(x, y, w, 2, self.GRAY) + rl.draw_rectangle_rec(rl.Rectangle(x, y, w, 2), self.GRAY) y += 20 # Instructions intro @@ -132,9 +133,6 @@ class FirehoseLayoutBase(Widget): y = self._draw_wrapped_text(x, y, w, tr(answer), gui_app.font(FontWeight.ROMAN), 32, self.LIGHT_GRAY) y += 20 - # return value not used by NavWidget - return -1 - def _draw_wrapped_text(self, x, y, width, text, font, font_size, color): wrapped = wrap_text(font, text, font_size, width) for line in wrapped: @@ -220,9 +218,5 @@ class FirehoseLayoutBase(Widget): time.sleep(self.UPDATE_INTERVAL) -class FirehoseLayout(FirehoseLayoutBase, NavWidget): - BACK_TOUCH_AREA_PERCENTAGE = 0.1 - - def __init__(self, back_callback): - super().__init__() - self.set_back_callback(back_callback) +class FirehoseLayout(NavRawScrollPanel, FirehoseLayoutBase): + pass diff --git a/selfdrive/ui/mici/layouts/settings/galaxy.py b/selfdrive/ui/mici/layouts/settings/galaxy.py new file mode 100644 index 000000000..2eafabe1a --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/galaxy.py @@ -0,0 +1,159 @@ +import hashlib +import secrets +import string +from pathlib import Path + +import numpy as np +import pyray as rl +import qrcode + +from openpilot.common.swaglog import cloudlog +from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog, BigMultiOptionDialog +from openpilot.system.hardware import PC +from openpilot.system.hardware.hw import Paths +from openpilot.system.ui.lib.application import FontWeight, gui_app +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.widgets.nav_widget import NavWidget + + +class GalaxyQRDialog(NavWidget): + def __init__(self, url: str): + super().__init__() + self._url = url + self._qr_texture: rl.Texture | None = None + self._title = UnifiedLabel("pair with galaxy", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8) + self._generate_qr_code() + + def _generate_qr_code(self) -> None: + try: + qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=0) + qr.add_data(self._url) + qr.make(fit=True) + + pil_img = qr.make_image(fill_color="white", back_color="black").convert("RGBA") + img_array = np.array(pil_img, dtype=np.uint8) + + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) + + rl_image = rl.Image() + rl_image.data = rl.ffi.cast("void *", img_array.ctypes.data) + rl_image.width = pil_img.width + rl_image.height = pil_img.height + rl_image.mipmaps = 1 + rl_image.format = rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8A8 + self._qr_texture = rl.load_texture_from_image(rl_image) + except Exception as e: + cloudlog.warning(f"Galaxy QR generation failed: {e}") + self._qr_texture = None + + def _render(self, rect: rl.Rectangle): + if self._qr_texture is None: + rl.draw_text_ex(gui_app.font(FontWeight.BOLD), "QR Code Error", rl.Vector2(rect.x + 20, rect.y + rect.height / 2 - 15), 30, 0.0, rl.RED) + return + + scale = rect.height / self._qr_texture.height + pos = rl.Vector2(round(rect.x + 8), round(rect.y)) + rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) + + label_x = rect.x + 8 + rect.height + 24 + self._title.set_max_width(int(rect.width - label_x)) + self._title.set_position(label_x, rect.y + 16) + self._title.render() + + def __del__(self): + if self._qr_texture and self._qr_texture.id != 0: + rl.unload_texture(self._qr_texture) + + +class GalaxyBigButton(BigButton): + _SLUG_CHARS = string.ascii_letters + string.digits + + def __init__(self): + super().__init__("galaxy", "", gui_app.texture("icons_mici/settings/galaxy.png", 64, 64)) + self._galaxy_dir = Path(Paths.comma_home()) / "starpilot" / "data" / "galaxy" if PC else Path("/data/galaxy") + self._auth_path = self._galaxy_dir / "glxyauth" + self._session_path = self._galaxy_dir / "glxysession" + self._slug_path = self._galaxy_dir / "glxyslug" + + def _get_label_font_size(self): + return 64 + + def _is_paired(self) -> bool: + try: + return len(self._auth_path.read_text(encoding="utf-8").strip()) == 64 + except Exception: + return False + + def _get_slug(self) -> str: + try: + return self._slug_path.read_text(encoding="utf-8").strip() + except Exception: + return "" + + def _show_qr(self): + slug = self._get_slug() + if not slug: + gui_app.push_widget(BigDialog("", "Galaxy is not paired yet.")) + return + gui_app.push_widget(GalaxyQRDialog(f"https://galaxy.firestar.link/{slug}")) + + def _pair_with_pin(self, pin: str): + clean_pin = str(pin or "").strip() + if len(clean_pin) < 6: + gui_app.push_widget(BigDialog("", "PIN must be at least 6 characters.")) + return + + try: + self._galaxy_dir.mkdir(parents=True, exist_ok=True) + self._auth_path.write_text(hashlib.sha256(clean_pin.encode("utf-8")).hexdigest(), encoding="utf-8") + self._session_path.write_text(secrets.token_hex(32), encoding="utf-8") + slug = "".join(secrets.choice(self._SLUG_CHARS) for _ in range(16)) + self._slug_path.write_text(slug, encoding="utf-8") + except Exception as e: + cloudlog.warning(f"Galaxy pairing write failed: {e}") + gui_app.push_widget(BigDialog("", "Failed to pair with Galaxy.")) + return + + self._show_qr() + + def _unpair(self): + for path in (self._auth_path, self._session_path, self._slug_path): + try: + path.unlink(missing_ok=True) + except TypeError: + if path.exists(): + path.unlink() + except Exception as e: + cloudlog.warning(f"Galaxy unpair cleanup failed for {path}: {e}") + + def _handle_mouse_release(self, mouse_pos): + super()._handle_mouse_release(mouse_pos) + + if self._is_paired(): + dialog_holder: dict[str, BigMultiOptionDialog] = {} + + def on_confirm(): + selection = dialog_holder["dialog"].get_selected_option() + if selection == "show qr": + self._show_qr() + elif selection == "unpair": + gui_app.push_widget( + BigConfirmationDialog( + "slide to\nunpair galaxy", + gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64), + self._unpair, + red=True, + ) + ) + + dialog = BigMultiOptionDialog(options=["show qr", "unpair"], default="show qr", right_btn_callback=on_confirm) + dialog_holder["dialog"] = dialog + gui_app.push_widget(dialog) + return + + gui_app.push_widget(BigInputDialog("enter galaxy pin...", default_text="", minimum_length=6, confirm_callback=self._pair_with_pin)) + + def _update_state(self): + self.set_value("paired" if self._is_paired() else "pair") diff --git a/selfdrive/ui/mici/layouts/settings/network/__init__.py b/selfdrive/ui/mici/layouts/settings/network/__init__.py index 333a24531..ddbab4b47 100644 --- a/selfdrive/ui/mici/layouts/settings/network/__init__.py +++ b/selfdrive/ui/mici/layouts/settings/network/__init__.py @@ -1,204 +1,60 @@ import pyray as rl -from enum import IntEnum -from collections.abc import Callable -from openpilot.system.ui.widgets.scroller import Scroller -from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici, WifiIcon, normalize_ssid -from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigToggle, BigParamControl -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog -from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.selfdrive.ui.lib.prime_state import PrimeType +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiIcon +from openpilot.selfdrive.ui.mici.widgets.button import BigButton from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets import NavWidget -from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType +from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus, SecurityType, normalize_ssid -class NetworkPanelType(IntEnum): - NONE = 0 - WIFI = 1 - - -class NetworkLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): - super().__init__() - - self._current_panel = NetworkPanelType.WIFI - self.set_back_enabled(lambda: self._current_panel == NetworkPanelType.NONE) - - self._wifi_manager = WifiManager() - self._wifi_manager.set_active(False) - self._wifi_ui = WifiUIMici(self._wifi_manager, back_callback=lambda: self._switch_to_panel(NetworkPanelType.NONE)) - - self._wifi_manager.add_callbacks( - networks_updated=self._on_network_updated, - ) - - _tethering_icon = "icons_mici/settings/network/tethering.png" - - # ******** Tethering ******** - def tethering_toggle_callback(checked: bool): - self._tethering_toggle_btn.set_enabled(False) - self._network_metered_btn.set_enabled(False) - self._wifi_manager.set_tethering_active(checked) - - self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) - - def tethering_password_callback(password: str): - if password: - self._wifi_manager.set_tethering_password(password) - - def tethering_password_clicked(): - tethering_password = self._wifi_manager.tethering_password - dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8, - confirm_callback=tethering_password_callback) - gui_app.set_modal_overlay(dlg) - - txt_tethering = gui_app.texture(_tethering_icon, 64, 53) - self._tethering_password_btn = BigButton("tethering password", "", txt_tethering) - self._tethering_password_btn.set_click_callback(tethering_password_clicked) - - # ******** IP Address ******** - self._ip_address_btn = BigButton("IP Address", "Not connected") - - # ******** Network Metered ******** - def network_metered_callback(value: str): - self._network_metered_btn.set_enabled(False) - metered = { - 'default': MeteredType.UNKNOWN, - 'metered': MeteredType.YES, - 'unmetered': MeteredType.NO - }.get(value, MeteredType.UNKNOWN) - self._wifi_manager.set_current_network_metered(metered) - - # TODO: signal for current network metered type when changing networks, this is wrong until you press it once - # TODO: disable when not connected - self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) - self._network_metered_btn.set_enabled(False) +class WifiNetworkButton(BigButton): + def __init__(self, wifi_manager: WifiManager): + self._wifi_manager = wifi_manager + self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 28, 36) + self._draw_lock = False self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 64, 56) self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 64, 47) self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 64, 47) self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 64, 47) - wifi_button = BigButton("wi-fi", "not connected", self._wifi_slash_txt) - wifi_button.set_click_callback(lambda: self._switch_to_panel(NetworkPanelType.WIFI)) - self._wifi_button = wifi_button - - # ******** Advanced settings ******** - # ******** Roaming toggle ******** - self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", toggle_callback=self._toggle_roaming) - - # ******** APN settings ******** - self._apn_btn = BigButton("apn settings", "edit") - self._apn_btn.set_click_callback(self._edit_apn) - - # ******** Cellular metered toggle ******** - self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", toggle_callback=self._toggle_cellular_metered) - - # Main scroller ---------------------------------- - self._scroller = Scroller([ - wifi_button, - self._network_metered_btn, - self._tethering_toggle_btn, - self._tethering_password_btn, - # /* Advanced settings - self._roaming_btn, - self._apn_btn, - self._cellular_metered_btn, - # */ - self._ip_address_btn, - ], snap_items=False, scroll_indicator=True, edge_shadows=True) - - # Set initial config - roaming_enabled = ui_state.params.get_bool("GsmRoaming") - metered = ui_state.params.get_bool("GsmMetered") - self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered) - - # Set up back navigation - self.set_back_callback(back_callback) + super().__init__("wi-fi", "not connected", self._wifi_slash_txt, scroll=True) def _update_state(self): super()._update_state() - # If not using prime SIM, show GSM settings and enable IPv4 forwarding - show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) - self._wifi_manager.set_ipv4_forward(show_cell_settings) - self._roaming_btn.set_visible(show_cell_settings) - self._apn_btn.set_visible(show_cell_settings) - self._cellular_metered_btn.set_visible(show_cell_settings) - - def show_event(self): - super().show_event() - self._current_panel = NetworkPanelType.NONE - self._wifi_ui.show_event() - self._scroller.show_event() - - def hide_event(self): - super().hide_event() - self._wifi_ui.hide_event() - - def _toggle_roaming(self, checked: bool): - self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered")) - - def _edit_apn(self): - def update_apn(apn: str): - apn = apn.strip() - if apn == "": - ui_state.params.remove("GsmApn") - else: - ui_state.params.put("GsmApn", apn) - - self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered")) - - current_apn = ui_state.params.get("GsmApn") or "" - dlg = BigInputDialog("enter APN", current_apn, minimum_length=0, confirm_callback=update_apn) - gui_app.set_modal_overlay(dlg) - - def _toggle_cellular_metered(self, checked: bool): - self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked) - - def _on_network_updated(self, networks: list[Network]): - # Update tethering state - tethering_active = self._wifi_manager.is_tethering_active() - # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons - self._tethering_toggle_btn.set_enabled(True) - self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) - self._tethering_toggle_btn.set_checked(tethering_active) - - connected_network = next((network for network in networks if network.is_connected), None) - if connected_network is not None: - self._wifi_button.set_value(normalize_ssid(connected_network.ssid)) - strength = round(connected_network.strength / 100 * 2) - if strength >= 2: - self._wifi_button.set_icon(self._wifi_full_txt) - elif strength == 1: - self._wifi_button.set_icon(self._wifi_medium_txt) - else: - self._wifi_button.set_icon(self._wifi_low_txt) + # Update wi-fi button with ssid and ip address + # TODO: make sure we handle hidden ssids + wifi_state = self._wifi_manager.wifi_state + display_network = next((n for n in self._wifi_manager.networks if n.ssid == wifi_state.ssid), None) + if wifi_state.status == ConnectStatus.CONNECTING: + self.set_text(normalize_ssid(wifi_state.ssid or "wi-fi")) + self.set_value("starting" if self._wifi_manager.is_tethering_active() else "connecting...") + elif wifi_state.status == ConnectStatus.CONNECTED: + self.set_text(normalize_ssid(wifi_state.ssid or "wi-fi")) + self.set_value(self._wifi_manager.ipv4_address or "obtaining IP...") else: - self._wifi_button.set_value("not connected") - self._wifi_button.set_icon(self._wifi_slash_txt) + display_network = None + self.set_text("wi-fi") + self.set_value("not connected") - # Update IP address - self._ip_address_btn.set_value(self._wifi_manager.ipv4_address or "Not connected") - - # Update network metered - self._network_metered_btn.set_value( - { - MeteredType.UNKNOWN: 'default', - MeteredType.YES: 'metered', - MeteredType.NO: 'unmetered' - }.get(self._wifi_manager.current_network_metered, 'default')) - - def _switch_to_panel(self, panel_type: NetworkPanelType): - if panel_type == NetworkPanelType.WIFI: - self._wifi_ui.show_event() - self._current_panel = panel_type - - def _render(self, rect: rl.Rectangle): - self._wifi_manager.process_callbacks() - - if self._current_panel == NetworkPanelType.WIFI: - self._wifi_ui.render(rect) + if display_network is not None: + strength = WifiIcon.get_strength_icon_idx(display_network.strength) + self.set_icon(self._wifi_full_txt if strength == 2 else self._wifi_medium_txt if strength == 1 else self._wifi_low_txt) + self._draw_lock = display_network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED) + elif self._wifi_manager.is_tethering_active(): + # takes a while to get Network + self.set_icon(self._wifi_full_txt) + self._draw_lock = True else: - self._scroller.render(rect) + self.set_icon(self._wifi_slash_txt) + self._draw_lock = False + + def _draw_content(self, btn_y: float): + super()._draw_content(btn_y) + # Render lock icon at lower right of wifi icon if secured + if self._draw_lock: + icon_x = self._rect.x + self._rect.width - 30 - self._txt_icon.width + icon_y = btn_y + 30 + lock_x = icon_x + self._txt_icon.width - self._lock_txt.width + 7 + lock_y = icon_y + self._txt_icon.height - self._lock_txt.height + 8 + rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE) diff --git a/selfdrive/ui/mici/layouts/settings/network/action_state.py b/selfdrive/ui/mici/layouts/settings/network/action_state.py deleted file mode 100644 index 9f8b67617..000000000 --- a/selfdrive/ui/mici/layouts/settings/network/action_state.py +++ /dev/null @@ -1,5 +0,0 @@ -def should_show_forget_button(network=None, *, is_saved: bool = False, is_connected: bool = False) -> bool: - if network is not None: - return bool(network.is_saved or network.is_connected) - - return bool(is_saved or is_connected) diff --git a/selfdrive/ui/mici/layouts/settings/network/network_layout.py b/selfdrive/ui/mici/layouts/settings/network/network_layout.py new file mode 100644 index 000000000..9f6fae4b5 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/network/network_layout.py @@ -0,0 +1,154 @@ +from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle, BigParamControl, BigToggle +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.lib.prime_state import PrimeType +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, MeteredType + + +class NetworkLayoutMici(NavScroller): + def __init__(self): + super().__init__() + + self._wifi_manager = WifiManager() + self._wifi_manager.set_active(False) + self._wifi_ui = WifiUIMici(self._wifi_manager) + + self._wifi_manager.add_callbacks( + networks_updated=self._on_network_updated, + ) + + # ******** Tethering ******** + def tethering_toggle_callback(checked: bool): + self._tethering_toggle_btn.set_enabled(False) + self._tethering_password_btn.set_enabled(False) + self._network_metered_btn.set_enabled(False) + self._wifi_manager.set_tethering_active(checked) + + self._tethering_toggle_btn = BigToggle("enable tethering", "", toggle_callback=tethering_toggle_callback) + + def tethering_password_callback(password: str): + if password: + self._tethering_toggle_btn.set_enabled(False) + self._tethering_password_btn.set_enabled(False) + self._wifi_manager.set_tethering_password(password) + + def tethering_password_clicked(): + tethering_password = self._wifi_manager.tethering_password + dlg = BigInputDialog("enter password...", tethering_password, minimum_length=8, + confirm_callback=tethering_password_callback) + gui_app.push_widget(dlg) + + txt_tethering = gui_app.texture("icons_mici/settings/network/tethering.png", 64, 54) + self._tethering_password_btn = BigButton("tethering password", "", txt_tethering) + self._tethering_password_btn.set_click_callback(tethering_password_clicked) + + # ******** Network Metered ******** + def network_metered_callback(value: str): + self._network_metered_btn.set_enabled(False) + metered = { + 'default': MeteredType.UNKNOWN, + 'metered': MeteredType.YES, + 'unmetered': MeteredType.NO + }.get(value, MeteredType.UNKNOWN) + self._wifi_manager.set_current_network_metered(metered) + + # TODO: signal for current network metered type when changing networks, this is wrong until you press it once + # TODO: disable when not connected + self._network_metered_btn = BigMultiToggle("network usage", ["default", "metered", "unmetered"], select_callback=network_metered_callback) + self._network_metered_btn.set_enabled(False) + + self._wifi_button = WifiNetworkButton(self._wifi_manager) + self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + + # ******** Advanced settings ******** + # ******** Roaming toggle ******** + self._roaming_btn = BigParamControl("enable roaming", "GsmRoaming", toggle_callback=self._toggle_roaming) + + # ******** APN settings ******** + self._apn_btn = BigButton("apn settings", "edit") + self._apn_btn.set_click_callback(self._edit_apn) + + # ******** Cellular metered toggle ******** + self._cellular_metered_btn = BigParamControl("cellular metered", "GsmMetered", toggle_callback=self._toggle_cellular_metered) + + # Main scroller ---------------------------------- + self._scroller.add_widgets([ + self._wifi_button, + self._network_metered_btn, + self._tethering_toggle_btn, + self._tethering_password_btn, + # /* Advanced settings + self._roaming_btn, + self._apn_btn, + self._cellular_metered_btn, + # */ + ]) + + # Set initial config + roaming_enabled = ui_state.params.get_bool("GsmRoaming") + metered = ui_state.params.get_bool("GsmMetered") + self._wifi_manager.update_gsm_settings(roaming_enabled, ui_state.params.get("GsmApn") or "", metered) + + def _update_state(self): + super()._update_state() + + # If not using prime SIM, show GSM settings and enable IPv4 forwarding + show_cell_settings = ui_state.prime_state.get_type() in (PrimeType.NONE, PrimeType.LITE) + self._wifi_manager.set_ipv4_forward(show_cell_settings) + self._roaming_btn.set_visible(show_cell_settings) + self._apn_btn.set_visible(show_cell_settings) + self._cellular_metered_btn.set_visible(show_cell_settings) + + def show_event(self): + super().show_event() + self._wifi_manager.set_active(True) + + # Process wifi callbacks while at any point in the nav stack + gui_app.add_nav_stack_tick(self._wifi_manager.process_callbacks) + + def hide_event(self): + super().hide_event() + self._wifi_manager.set_active(False) + + gui_app.remove_nav_stack_tick(self._wifi_manager.process_callbacks) + + def _toggle_roaming(self, checked: bool): + self._wifi_manager.update_gsm_settings(checked, ui_state.params.get("GsmApn") or "", ui_state.params.get_bool("GsmMetered")) + + def _edit_apn(self): + def update_apn(apn: str): + apn = apn.strip() + if apn == "": + ui_state.params.remove("GsmApn") + else: + ui_state.params.put("GsmApn", apn) + + self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), apn, ui_state.params.get_bool("GsmMetered")) + + current_apn = ui_state.params.get("GsmApn") or "" + dlg = BigInputDialog("enter APN...", current_apn, minimum_length=0, confirm_callback=update_apn) + gui_app.push_widget(dlg) + + def _toggle_cellular_metered(self, checked: bool): + self._wifi_manager.update_gsm_settings(ui_state.params.get_bool("GsmRoaming"), ui_state.params.get("GsmApn") or "", checked) + + def _on_network_updated(self, networks: list[Network]): + # Update tethering state + tethering_active = self._wifi_manager.is_tethering_active() + # TODO: use real signals (like activated/settings changed, etc.) to speed up re-enabling buttons + self._tethering_toggle_btn.set_enabled(True) + self._tethering_password_btn.set_enabled(True) + self._network_metered_btn.set_enabled(lambda: not tethering_active and bool(self._wifi_manager.ipv4_address)) + self._tethering_toggle_btn.set_checked(tethering_active) + + # Update network metered + self._network_metered_btn.set_value( + { + MeteredType.UNKNOWN: 'default', + MeteredType.YES: 'metered', + MeteredType.NO: 'unmetered' + }.get(self._wifi_manager.current_network_metered, 'default')) diff --git a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py index 20c92649a..006027e25 100644 --- a/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py +++ b/selfdrive/ui/mici/layouts/settings/network/wifi_ui.py @@ -4,407 +4,339 @@ import pyray as rl from collections.abc import Callable from openpilot.common.swaglog import cloudlog -from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.selfdrive.ui.mici.widgets.dialog import BigMultiOptionDialog, BigInputDialog, BigDialogOptionButton, BigConfirmationDialogV2 +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationDialog +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, LABEL_COLOR from openpilot.system.ui.lib.application import gui_app, MousePos, FontWeight -from openpilot.system.ui.widgets import Widget, NavWidget -from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType -from openpilot.selfdrive.ui.mici.layouts.settings.network.action_state import should_show_forget_button - - -def normalize_ssid(ssid: str) -> str: - return ssid.replace("’", "'") # for iPhone hotspots +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.scroller import NavScroller +from openpilot.system.ui.lib.wifi_manager import WifiManager, Network, SecurityType, normalize_ssid class LoadingAnimation(Widget): - def _render(self, _): - cx = int(self._rect.x + 70) - cy = int(self._rect.y + self._rect.height / 2 - 50) + RADIUS = 8 + SPACING = 24 # center-to-center: diameter (16) + gap (8) + Y_MAG = 11.2 - y_mag = 20 - anim_scale = 5 - spacing = 28 + def __init__(self): + super().__init__() + w = self.SPACING * 2 + self.RADIUS * 2 + h = self.RADIUS * 2 + int(self.Y_MAG) + self.set_rect(rl.Rectangle(0, 0, w, h)) + + def _render(self, _): + # Balls rest at bottom center; bounce upward + base_x = int(self._rect.x + self._rect.width / 2) + base_y = int(self._rect.y + self._rect.height - self.RADIUS) for i in range(3): - x = cx - spacing + i * spacing - y = int(cy + min(math.sin((rl.get_time() - i * 0.2) * anim_scale) * y_mag, 0)) - alpha = int(np.interp(cy - y, [0, y_mag], [255 * 0.45, 255 * 0.9])) - rl.draw_circle(x, y, 10, rl.Color(255, 255, 255, alpha)) + x = base_x + (i - 1) * self.SPACING + y = int(base_y + min(math.sin((rl.get_time() - i * 0.2) * 4) * self.Y_MAG, 0)) + alpha = int(np.interp(base_y - y, [0, self.Y_MAG], [255 * 0.45, 255 * 0.9])) + rl.draw_circle(x, y, self.RADIUS, rl.Color(255, 255, 255, alpha)) class WifiIcon(Widget): - def __init__(self): + def __init__(self, network: Network): super().__init__() - self.set_rect(rl.Rectangle(0, 0, 89, 64)) + self.set_rect(rl.Rectangle(0, 0, 48 + 5, 36 + 5)) - self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 89, 64) - self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 89, 64) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 89, 64) - self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 23, 32) + self._wifi_slash_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 48, 42) + self._wifi_low_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_low.png", 48, 36) + self._wifi_medium_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_medium.png", 48, 36) + self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 48, 36) + self._lock_txt = gui_app.texture("icons_mici/settings/network/new/lock.png", 21, 27) - self._network: Network | None = None - self._scale = 1.0 + self._network: Network = network + self._network_missing = False # if network disappeared from scan results - def set_current_network(self, network: Network): + def update_network(self, network: Network): self._network = network - def set_scale(self, scale: float): - self._scale = scale + def set_network_missing(self, missing: bool): + self._network_missing = missing + + @staticmethod + def get_strength_icon_idx(strength: int) -> int: + return round(strength / 100 * 2) def _render(self, _): - if self._network is None: - return - # Determine which wifi strength icon to use - strength = round(self._network.strength / 100 * 2) - if strength == 2: + strength = self.get_strength_icon_idx(self._network.strength) + if self._network_missing: + strength_icon = self._wifi_slash_txt + elif strength == 2: strength_icon = self._wifi_full_txt elif strength == 1: strength_icon = self._wifi_medium_txt else: strength_icon = self._wifi_low_txt - icon_x = int(self._rect.x + (self._rect.width - strength_icon.width * self._scale) // 2) - icon_y = int(self._rect.y + (self._rect.height - strength_icon.height * self._scale) // 2) - rl.draw_texture_ex(strength_icon, (icon_x, icon_y), 0.0, self._scale, rl.WHITE) + rl.draw_texture_ex(strength_icon, (self._rect.x, self._rect.y + self._rect.height - strength_icon.height), 0.0, 1.0, rl.WHITE) # Render lock icon at lower right of wifi icon if secured if self._network.security_type not in (SecurityType.OPEN, SecurityType.UNSUPPORTED): - lock_scale = self._scale * 1.1 - lock_x = int(icon_x + 1 + strength_icon.width * self._scale - self._lock_txt.width * lock_scale / 2) - lock_y = int(icon_y + 1 + strength_icon.height * self._scale - self._lock_txt.height * lock_scale / 2) - rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, lock_scale, rl.WHITE) + lock_x = self._rect.x + self._rect.width - self._lock_txt.width + lock_y = self._rect.y + self._rect.height - self._lock_txt.height + 6 + rl.draw_texture_ex(self._lock_txt, (lock_x, lock_y), 0.0, 1.0, rl.WHITE) -class WifiItem(BigDialogOptionButton): - LEFT_MARGIN = 20 +class WifiButton(BigButton): + LABEL_PADDING = 98 + LABEL_WIDTH = 402 - 98 - 28 # button width - left padding - right padding + SUB_LABEL_WIDTH = 402 - BigButton.LABEL_HORIZONTAL_PADDING * 2 - def __init__(self, network: Network): - super().__init__(network.ssid) - - self.set_rect(rl.Rectangle(0, 0, gui_app.width, self.HEIGHT)) - - self._selected_txt = gui_app.texture("icons_mici/settings/network/new/wifi_selected.png", 48, 96) + def __init__(self, network: Network, wifi_manager: WifiManager): + super().__init__(normalize_ssid(network.ssid), scroll=True) self._network = network - self._wifi_icon = WifiIcon() - self._wifi_icon.set_current_network(network) - - def set_current_network(self, network: Network): - self._network = network - self._wifi_icon.set_current_network(network) - - def _render(self, _): - if self._network.is_connected: - selected_x = int(self._rect.x - self._selected_txt.width / 2) - selected_y = int(self._rect.y + (self._rect.height - self._selected_txt.height) / 2) - rl.draw_texture(self._selected_txt, selected_x, selected_y, rl.WHITE) - - self._wifi_icon.set_scale((1.0 if self._selected else 0.65) * 0.7) - self._wifi_icon.render(rl.Rectangle( - self._rect.x + self.LEFT_MARGIN, - self._rect.y, - self.SELECTED_HEIGHT, - self._rect.height - )) - - if self._selected: - self._label.set_font_size(self.SELECTED_HEIGHT) - self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9))) - self._label.set_font_weight(FontWeight.DISPLAY) - else: - self._label.set_font_size(self.HEIGHT) - self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.58))) - self._label.set_font_weight(FontWeight.DISPLAY_REGULAR) - - label_offset = self.LEFT_MARGIN + self._wifi_icon.rect.width + 20 - label_rect = rl.Rectangle(self._rect.x + label_offset, self._rect.y, self._rect.width - label_offset, self._rect.height) - self._label.set_text(normalize_ssid(self._network.ssid)) - self._label.render(label_rect) - - -class ConnectButton(Widget): - def __init__(self): - super().__init__() - self._bg_txt = gui_app.texture("icons_mici/settings/network/new/connect_button.png", 410, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/connect_button_pressed.png", 410, 100) - self._bg_full_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button.png", 520, 100) - self._bg_full_pressed_txt = gui_app.texture("icons_mici/settings/network/new/full_connect_button_pressed.png", 520, 100) - - self._full: bool = False - - self._label = UnifiedLabel("", 36, FontWeight.MEDIUM, rl.Color(255, 255, 255, int(255 * 0.9)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - - @property - def full(self) -> bool: - return self._full - - def set_full(self, full: bool): - self._full = full - self.set_rect(rl.Rectangle(0, 0, 520 if self._full else 410, 100)) - - def set_label(self, text: str): - self._label.set_text(text) - - def _render(self, _): - if self._full: - bg_txt = self._bg_full_pressed_txt if self.is_pressed and self.enabled else self._bg_full_txt - else: - bg_txt = self._bg_pressed_txt if self.is_pressed and self.enabled else self._bg_txt - - rl.draw_texture(bg_txt, int(self._rect.x), int(self._rect.y), rl.WHITE) - - self._label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9) if self.enabled else int(255 * 0.9 * 0.65))) - self._label.render(self._rect) - - -class ForgetButton(Widget): - HORIZONTAL_MARGIN = 8 - - def __init__(self, forget_network: Callable, open_network_manage_page): - super().__init__() - self._forget_network = forget_network - self._open_network_manage_page = open_network_manage_page - - self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 100, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 100, 100) - self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 32, 36) - self.set_rect(rl.Rectangle(0, 0, 100 + self.HORIZONTAL_MARGIN * 2, 100)) - - def _handle_mouse_release(self, mouse_pos: MousePos): - super()._handle_mouse_release(mouse_pos) - dlg = BigConfirmationDialogV2("slide to forget", "icons_mici/settings/network/new/trash.png", red=True, - confirm_callback=self._forget_network) - gui_app.set_modal_overlay(dlg, callback=self._open_network_manage_page) - - def _render(self, _): - bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt - rl.draw_texture(bg_txt, int(self._rect.x + self.HORIZONTAL_MARGIN), int(self._rect.y), rl.WHITE) - - trash_x = int(self._rect.x + (self._rect.width - self._trash_txt.width) // 2) - trash_y = int(self._rect.y + (self._rect.height - self._trash_txt.height) // 2) - rl.draw_texture(self._trash_txt, trash_x, trash_y, rl.WHITE) - - -class NetworkInfoPage(NavWidget): - def __init__(self, wifi_manager, connect_callback: Callable, forget_callback: Callable, open_network_manage_page: Callable): - super().__init__() self._wifi_manager = wifi_manager - self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + self._wifi_icon = WifiIcon(network) + self._forget_btn = ForgetButton(self._forget_network) + self._check_txt = gui_app.texture("icons_mici/setup/driver_monitoring/dm_check.png", 32, 32) - self._wifi_icon = WifiIcon() - self._forget_btn = ForgetButton(lambda: forget_callback(self._network.ssid) if self._network is not None else None, - open_network_manage_page) - self._connect_btn = ConnectButton() - self._connect_btn.set_click_callback(lambda: connect_callback(self._network.ssid) if self._network is not None else None) + # Eager state (not sourced from Network) + self._network_missing = False + self._network_forgetting = False + self._wrong_password = False - self._title = UnifiedLabel("", 64, FontWeight.DISPLAY, rl.Color(255, 255, 255, int(255 * 0.9)), - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, scroll=True) - self._subtitle = UnifiedLabel("", 36, FontWeight.ROMAN, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) - - # State - self._network: Network | None = None - self._connecting: Callable[[], str | None] | None = None - self._show_forget_btn = False - - def show_event(self): - super().show_event() - self._title.reset_scroll() - - def update_networks(self, networks: dict[str, Network]): - # update current network from latest scan results - for ssid, network in networks.items(): - if self._network is not None and ssid == self._network.ssid: - self.set_current_network(network) - break - else: - # network disappeared, close page - gui_app.set_modal_overlay(None) - - def _update_state(self): - super()._update_state() - # Modal overlays stop main UI rendering, so we need to call here - self._wifi_manager.process_callbacks() - - if self._network is None: - return - - self._show_forget_btn = should_show_forget_button(self._network) - self._connect_btn.set_full(not self._show_forget_btn) - if self._is_connecting: - self._connect_btn.set_label("connecting...") - self._connect_btn.set_enabled(False) - elif self._network.is_connected: - self._connect_btn.set_label("disconnect") - self._connect_btn.set_enabled(True) - elif self._network.security_type == SecurityType.UNSUPPORTED: - self._connect_btn.set_label("connect") - self._connect_btn.set_enabled(False) - else: # saved or unknown - self._connect_btn.set_label("connect") - self._connect_btn.set_enabled(True) - - self._title.set_text(normalize_ssid(self._network.ssid)) - if self._network.security_type == SecurityType.OPEN: - self._subtitle.set_text("open") - elif self._network.security_type == SecurityType.UNSUPPORTED: - self._subtitle.set_text("unsupported") - else: - self._subtitle.set_text("secured") - - def set_current_network(self, network: Network): + def update_network(self, network: Network): self._network = network - self._wifi_icon.set_current_network(network) + self._wifi_icon.update_network(network) - def set_connecting(self, is_connecting: Callable[[], str | None]): - self._connecting = is_connecting + # We can assume network is not missing if got new Network + self._network_missing = False + self._wifi_icon.set_network_missing(False) + if self._is_connected or self._is_connecting: + self._wrong_password = False @property - def _is_connecting(self): - if self._connecting is None or self._network is None: - return False - is_connecting = self._connecting() == self._network.ssid - return is_connecting + def network_forgetting(self) -> bool: + return self._network_forgetting - def _render(self, _): + def _forget_network(self): + if self._network_forgetting: + return + + self._network_forgetting = True + self._wifi_manager.forget_connection(self._network.ssid) + + def on_forgotten(self): + self._network_forgetting = False + + def set_network_missing(self, missing: bool): + self._network_missing = missing + self._wifi_icon.set_network_missing(missing) + + def set_wrong_password(self): + self._wrong_password = True + self.trigger_shake() + + @property + def network(self) -> Network: + return self._network + + @property + def _show_forget_btn(self): + if self._network.is_tethering or self._network_forgetting: + return False + + return (self._is_saved and not self._wrong_password) or self._is_connecting + + def _handle_mouse_release(self, mouse_pos: MousePos): + if self._show_forget_btn and rl.check_collision_point_rec(mouse_pos, self._forget_btn.rect): + return + super()._handle_mouse_release(mouse_pos) + + def _get_label_font_size(self): + return 48 + + def _draw_content(self, btn_y: float): + self._label.set_color(LABEL_COLOR) + label_rect = rl.Rectangle(self._rect.x + self.LABEL_PADDING, btn_y + self.LABEL_VERTICAL_PADDING, + self.LABEL_WIDTH, self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + if self.value: + sub_label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING + label_y = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING + sub_label_w = self.SUB_LABEL_WIDTH - (self._forget_btn.rect.width if self._show_forget_btn else 0) + sub_label_height = self._sub_label.get_content_height(sub_label_w) + + if self._is_connected and not self._network_forgetting: + check_y = int(label_y - sub_label_height + (sub_label_height - self._check_txt.height) / 2) + rl.draw_texture_ex(self._check_txt, rl.Vector2(sub_label_x, check_y), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.9 * 0.65))) + sub_label_x += self._check_txt.width + 14 + + sub_label_rect = rl.Rectangle(sub_label_x, label_y - sub_label_height, sub_label_w, sub_label_height) + self._sub_label.render(sub_label_rect) + + # Wifi icon self._wifi_icon.render(rl.Rectangle( - self._rect.x + 32, - self._rect.y + (self._rect.height - self._connect_btn.rect.height - self._wifi_icon.rect.height) / 2, + self._rect.x + 30, + btn_y + 30, self._wifi_icon.rect.width, self._wifi_icon.rect.height, )) - self._title.render(rl.Rectangle( - self._rect.x + self._wifi_icon.rect.width + 32 + 32, - self._rect.y + 32 - 16, - self._rect.width - (self._wifi_icon.rect.width + 32 + 32), - 64, - )) - - self._subtitle.render(rl.Rectangle( - self._rect.x + self._wifi_icon.rect.width + 32 + 32, - self._rect.y + 32 + 64 - 16, - self._rect.width - (self._wifi_icon.rect.width + 32 + 32), - 48, - )) - - self._connect_btn.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._connect_btn.rect.height, - self._connect_btn.rect.width, - self._connect_btn.rect.height, - )) - + # Forget button if self._show_forget_btn: self._forget_btn.render(rl.Rectangle( self._rect.x + self._rect.width - self._forget_btn.rect.width, - self._rect.y + self._rect.height - self._forget_btn.rect.height, + btn_y + self._rect.height - self._forget_btn.rect.height, self._forget_btn.rect.width, self._forget_btn.rect.height, )) - return -1 + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(lambda: touch_callback() and not self._forget_btn.is_pressed) + self._forget_btn.set_touch_valid_callback(touch_callback) + + @property + def _is_saved(self): + return self._wifi_manager.is_connection_saved(self._network.ssid) + + @property + def _is_connecting(self): + return self._wifi_manager.connecting_to_ssid == self._network.ssid + + @property + def _is_connected(self): + return self._wifi_manager.connected_ssid == self._network.ssid + + def _update_state(self): + super()._update_state() + + if any((self._network_missing, self._is_connecting, self._is_connected, self._network_forgetting, + self._network.security_type == SecurityType.UNSUPPORTED)): + self.set_enabled(False) + self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.585))) + self._sub_label.set_font_weight(FontWeight.ROMAN) + + if self._network_forgetting: + self.set_value("forgetting...") + elif self._is_connecting: + self.set_value("starting..." if self._network.is_tethering else "connecting...") + elif self._is_connected: + self.set_value("tethering" if self._network.is_tethering else "connected") + elif self._network_missing: + # after connecting/connected since NM will still attempt to connect/stay connected for a while + self.set_value("not in range") + else: + self.set_value("unsupported") + + else: # saved, wrong password, or unknown + self.set_value("wrong password" if self._wrong_password else "connect") + self.set_enabled(True) + self._sub_label.set_color(rl.Color(255, 255, 255, int(255 * 0.9))) + self._sub_label.set_font_weight(FontWeight.SEMI_BOLD) -class WifiUIMici(BigMultiOptionDialog): - # Wait this long after user interacts with widget to update network list - INACTIVITY_TIMEOUT = 1 +class ForgetButton(Widget): + MARGIN = 12 # bottom and right - def __init__(self, wifi_manager: WifiManager, back_callback: Callable): - super().__init__([], None, None, right_btn_callback=None) + def __init__(self, forget_network: Callable): + super().__init__() + self._forget_network = forget_network - # Set up back navigation - self.set_back_callback(back_callback) + self._bg_txt = gui_app.texture("icons_mici/settings/network/new/forget_button.png", 84, 84) + self._bg_pressed_txt = gui_app.texture("icons_mici/settings/network/new/forget_button_pressed.png", 84, 84) + self._trash_txt = gui_app.texture("icons_mici/settings/network/new/trash.png", 29, 35) + self.set_rect(rl.Rectangle(0, 0, 84 + self.MARGIN * 2, 84 + self.MARGIN * 2)) - self._network_info_page = NetworkInfoPage(wifi_manager, self._connect_to_network, self._forget_network, self._open_network_manage_page) - self._network_info_page.set_connecting(lambda: self._connecting) + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + dlg = BigConfirmationDialog("slide to forget", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), self._forget_network, red=True) + gui_app.push_widget(dlg) + def _render(self, _): + bg_txt = self._bg_pressed_txt if self.is_pressed else self._bg_txt + rl.draw_texture_ex(bg_txt, (self._rect.x + (self._rect.width - self._bg_txt.width) / 2, + self._rect.y + (self._rect.height - self._bg_txt.height) / 2), 0, 1.0, rl.WHITE) + + trash_x = self._rect.x + (self._rect.width - self._trash_txt.width) / 2 + trash_y = self._rect.y + (self._rect.height - self._trash_txt.height) / 2 + rl.draw_texture_ex(self._trash_txt, (trash_x, trash_y), 0, 1.0, rl.WHITE) + + +class ScanningButton(BigButton): + def __init__(self): + super().__init__("", "searching for networks") + self.set_enabled(False) self._loading_animation = LoadingAnimation() - self._wifi_manager = wifi_manager - self._connecting: str | None = None - self._networks: dict[str, Network] = {} + def _draw_content(self, btn_y: float): + super()._draw_content(btn_y) + anim = self._loading_animation + x = self._rect.x + self._rect.width - anim.rect.width - 40 + y = btn_y + self._rect.height - anim.rect.height - 30 + anim.set_position(x, y) + anim.render() - # widget state - self._last_interaction_time = -float('inf') - self._restore_selection = False + +class WifiUIMici(NavScroller): + def __init__(self, wifi_manager: WifiManager): + super().__init__() + + self._scanning_btn = ScanningButton() + + self._wifi_manager = wifi_manager + self._networks: dict[str, Network] = {} self._wifi_manager.add_callbacks( need_auth=self._on_need_auth, - activated=self._on_activated, forgotten=self._on_forgotten, networks_updated=self._on_network_updated, - disconnected=self._on_disconnected, ) + @property + def any_network_forgetting(self) -> bool: + # TODO: deactivate before forget and add DISCONNECTING state + return any(btn.network_forgetting for btn in self._scroller.items if isinstance(btn, WifiButton)) + def show_event(self): - # Call super to prepare scroller; selection scroll is handled dynamically + # Re-sort scroller items and update from latest scan results super().show_event() self._wifi_manager.set_active(True) - self._last_interaction_time = -float('inf') - - def hide_event(self): - super().hide_event() - self._wifi_manager.set_active(False) - - def _open_network_manage_page(self, result=None): - self._network_info_page.update_networks(self._networks) - gui_app.set_modal_overlay(self._network_info_page) - - def _forget_network(self, ssid: str): - network = self._networks.get(ssid) - if network is None: - cloudlog.warning(f"Trying to forget unknown network: {ssid}") - return - - self._wifi_manager.forget_connection(network.ssid) + self._networks = {n.ssid: n for n in self._wifi_manager.networks} + self._update_buttons(re_sort=True) def _on_network_updated(self, networks: list[Network]): self._networks = {network.ssid: network for network in networks} self._update_buttons() - self._network_info_page.update_networks(self._networks) - def _update_buttons(self): - # Don't update buttons while user is actively interacting - if rl.get_time() - self._last_interaction_time < self.INACTIVITY_TIMEOUT: - return + def _update_buttons(self, re_sort: bool = False): + # Update existing buttons, add new ones to the end + existing = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)} for network in self._networks.values(): - # pop and re-insert to eliminate stuttering on update (prevents position lost for a frame) - network_button_idx = next((i for i, btn in enumerate(self._scroller._items) if btn.option == network.ssid), None) - if network_button_idx is not None: - network_button = self._scroller._items.pop(network_button_idx) - # Update network on existing button - network_button.set_current_network(network) + if network.ssid in existing: + existing[network.ssid].update_network(network) else: - network_button = WifiItem(network) + btn = WifiButton(network, self._wifi_manager) + btn.set_click_callback(lambda ssid=network.ssid: self._connect_to_network(ssid)) + self._scroller.add_widget(btn) - self._scroller.add_widget(network_button) + if re_sort: + # Remove stale buttons and sort to match scan order, preserving eager state + btn_map = {btn.network.ssid: btn for btn in self._scroller.items if isinstance(btn, WifiButton)} + self._scroller.items[:] = [btn_map[ssid] for ssid in self._networks if ssid in btn_map] + else: + # Mark networks no longer in scan results (display handled by _update_state) + for btn in self._scroller.items: + if isinstance(btn, WifiButton) and btn.network.ssid not in self._networks: + btn.set_network_missing(True) - # remove networks no longer present - self._scroller._items[:] = [btn for btn in self._scroller._items if btn.option in self._networks] - - # try to restore previous selection to prevent jumping from adding/removing/reordering buttons - self._restore_selection = True + # Keep scanning button at the end + items = self._scroller.items + if self._scanning_btn in items: + items.append(items.pop(items.index(self._scanning_btn))) + else: + self._scroller.add_widget(self._scanning_btn) def _connect_with_password(self, ssid: str, password: str): - if password: - self._connecting = ssid - self._wifi_manager.connect_to_network(ssid, password) - self._update_buttons() - - def _on_option_selected(self, option: str): - super()._on_option_selected(option) - - if option in self._networks: - self._network_info_page.set_current_network(self._networks[option]) - self._open_network_manage_page() + self._wifi_manager.connect_to_network(ssid, password) + self._move_network_to_front(ssid, scroll=True) def _connect_to_network(self, ssid: str): network = self._networks.get(ssid) @@ -412,51 +344,48 @@ class WifiUIMici(BigMultiOptionDialog): cloudlog.warning(f"Trying to connect to unknown network: {ssid}") return - if network.is_connected: - self._wifi_manager.disconnect_network(network.ssid) - return - - if network.is_saved: - self._connecting = network.ssid + if self._wifi_manager.is_connection_saved(network.ssid): self._wifi_manager.activate_connection(network.ssid) - self._update_buttons() elif network.security_type == SecurityType.OPEN: - self._connecting = network.ssid self._wifi_manager.connect_to_network(network.ssid, "") - self._update_buttons() else: self._on_need_auth(network.ssid, False) + return + + self._move_network_to_front(ssid, scroll=True) def _on_need_auth(self, ssid, incorrect_password=True): - hint = "incorrect password..." if incorrect_password else "enter password..." - dlg = BigInputDialog(hint, "", minimum_length=8, + if incorrect_password: + for btn in self._scroller.items: + if isinstance(btn, WifiButton) and btn.network.ssid == ssid: + btn.set_wrong_password() + break + return + + dlg = BigInputDialog("enter password...", "", minimum_length=8, confirm_callback=lambda _password: self._connect_with_password(ssid, _password)) - # go back to the manage network page - gui_app.set_modal_overlay(dlg, self._open_network_manage_page) + gui_app.push_widget(dlg) - def _on_activated(self): - self._connecting = None + def _on_forgotten(self, ssid): + # For eager UI forget + for btn in self._scroller.items: + if isinstance(btn, WifiButton) and btn.network.ssid == ssid: + btn.on_forgotten() - def _on_forgotten(self): - self._connecting = None + def _move_network_to_front(self, ssid: str | None, scroll: bool = False): + # Move connecting/connected network to the front with animation + front_btn_idx = next((i for i, btn in enumerate(self._scroller.items) + if isinstance(btn, WifiButton) and + btn.network.ssid == ssid), None) if ssid else None - def _on_disconnected(self): - self._connecting = None + if front_btn_idx is not None and front_btn_idx > 0: + self._scroller.move_item(front_btn_idx, 0) + + if scroll: + # Scroll to the new position of the network + self._scroller.scroll_to(self._scroller.scroll_panel.get_offset(), smooth=True) def _update_state(self): super()._update_state() - if self.is_pressed: - self._last_interaction_time = rl.get_time() - def _render(self, _): - # Update Scroller layout and restore current selection whenever buttons are updated, before first render - current_selection = self.get_selected_option() - if self._restore_selection and current_selection in self._networks: - self._scroller._layout() - BigMultiOptionDialog._on_option_selected(self, current_selection) - self._restore_selection = None - - super()._render(_) - - if not self._networks: - self._loading_animation.render(self._rect) + self._move_network_to_front(self._wifi_manager.wifi_state.ssid) diff --git a/selfdrive/ui/mici/layouts/settings/settings.py b/selfdrive/ui/mici/layouts/settings/settings.py index 28da9ad26..29709344a 100644 --- a/selfdrive/ui/mici/layouts/settings/settings.py +++ b/selfdrive/ui/mici/layouts/settings/settings.py @@ -1,34 +1,19 @@ -import pyray as rl -from dataclasses import dataclass -from enum import IntEnum -from collections.abc import Callable - from openpilot.common.params import Params -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigMultiToggle from openpilot.selfdrive.ui.mici.layouts.settings.toggles import TogglesLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.network import NetworkLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton, GalaxyBigButton -from openpilot.selfdrive.ui.mici.layouts.settings.driving_model import DrivingModelBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.network_layout import NetworkLayoutMici +from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici, PairBigButton from openpilot.selfdrive.ui.mici.layouts.settings.developer import DeveloperLayoutMici -from openpilot.selfdrive.ui.mici.layouts.settings.firehose import FirehoseLayout +from openpilot.selfdrive.ui.mici.layouts.settings.driving_model import DrivingModelBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.galaxy import GalaxyBigButton +from openpilot.selfdrive.ui.mici.layouts.settings.visuals import VisualsLayoutMici from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.widgets import Widget, NavWidget -class PanelType(IntEnum): - TOGGLES = 0 - NETWORK = 1 - DEVICE = 2 - DEVELOPER = 3 - USER_MANUAL = 4 - FIREHOSE = 5 - - -@dataclass -class PanelInfo: - name: str - instance: Widget +class SettingsBigButton(BigButton): + def _get_label_font_size(self): + return 64 class ForceDriveStateBigButton(BigMultiToggle): @@ -37,6 +22,12 @@ class ForceDriveStateBigButton(BigMultiToggle): self._params = Params() self.refresh() + def _get_label_font_size(self): + return 40 + + def _width_hint(self) -> int: + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width - 20) + def _handle_mouse_release(self, mouse_pos): super()._handle_mouse_release(mouse_pos) self._apply_mode(self.value) @@ -61,92 +52,51 @@ class ForceDriveStateBigButton(BigMultiToggle): self.set_value("off") -class SettingsLayout(NavWidget): +class SettingsLayout(NavScroller): def __init__(self): super().__init__() self._params = Params() - self._current_panel = None # PanelType.DEVICE - toggles_btn = BigButton("toggles", "", "icons_mici/settings/toggles_icon.png") - toggles_btn.set_click_callback(lambda: self._set_current_panel(PanelType.TOGGLES)) - network_btn = BigButton("network", "", "icons_mici/settings/network/wifi_strength_full.png") - network_btn.set_click_callback(lambda: self._set_current_panel(PanelType.NETWORK)) - device_btn = BigButton("device", "", "icons_mici/settings/device_icon.png") - device_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVICE)) - developer_btn = BigButton("developer", "", "icons_mici/settings/developer_icon.png") - developer_btn.set_click_callback(lambda: self._set_current_panel(PanelType.DEVELOPER)) + toggles_panel = TogglesLayoutMici() + toggles_btn = SettingsBigButton("toggles", "", gui_app.texture("icons_mici/settings.png", 64, 64)) + toggles_btn.set_click_callback(lambda: gui_app.push_widget(toggles_panel)) - firehose_btn = BigButton("firehose", "", "icons_mici/settings/comma_icon.png") - firehose_btn.set_click_callback(lambda: self._set_current_panel(PanelType.FIREHOSE)) + network_panel = NetworkLayoutMici() + network_btn = SettingsBigButton("network", "", gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 76, 56)) + network_btn.set_click_callback(lambda: gui_app.push_widget(network_panel)) + + visuals_panel = VisualsLayoutMici() + visuals_btn = SettingsBigButton("visuals", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + visuals_btn.set_click_callback(lambda: gui_app.push_widget(visuals_panel)) + + device_panel = DeviceLayoutMici() + device_btn = SettingsBigButton("device", "", gui_app.texture("icons_mici/settings/device_icon.png", 72, 58)) + device_btn.set_click_callback(lambda: gui_app.push_widget(device_panel)) + + developer_panel = DeveloperLayoutMici() + developer_btn = SettingsBigButton("developer", "", gui_app.texture("icons_mici/settings/developer_icon.png", 64, 60)) + developer_btn.set_click_callback(lambda: gui_app.push_widget(developer_panel)) self._force_drive_state_btn = ForceDriveStateBigButton() self._driving_model_btn = DrivingModelBigButton() + galaxy_btn = GalaxyBigButton() - self._scroller = Scroller([ + self._scroller.add_widgets([ toggles_btn, network_btn, self._force_drive_state_btn, - self._driving_model_btn, device_btn, + self._driving_model_btn, + visuals_btn, + galaxy_btn, PairBigButton(), - GalaxyBigButton(), #BigDialogButton("manual", "", "icons_mici/settings/manual_icon.png", "Check out the mici user\nmanual at comma.ai/setup"), - firehose_btn, developer_btn, - ], snap_items=False, scroll_indicator=True, edge_shadows=True) - - # Set up back navigation - self.set_back_callback(self.close_settings) - self.set_back_enabled(lambda: self._current_panel is None) - - self._panels = { - PanelType.TOGGLES: PanelInfo("Toggles", TogglesLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.NETWORK: PanelInfo("Network", NetworkLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.DEVICE: PanelInfo("Device", DeviceLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.DEVELOPER: PanelInfo("Developer", DeveloperLayoutMici(back_callback=lambda: self._set_current_panel(None))), - PanelType.FIREHOSE: PanelInfo("Firehose", FirehoseLayout(back_callback=lambda: self._set_current_panel(None))), - } + ]) self._font_medium = gui_app.font(FontWeight.MEDIUM) - # Callbacks - self._close_callback: Callable | None = None - def show_event(self): super().show_event() self._force_drive_state_btn.refresh() self._driving_model_btn.refresh() - self._set_current_panel(None) - self._scroller.show_event() - if self._current_panel is not None: - self._panels[self._current_panel].instance.show_event() - - def hide_event(self): - super().hide_event() - if self._current_panel is not None: - self._panels[self._current_panel].instance.hide_event() - - def set_callbacks(self, on_close: Callable): - self._close_callback = on_close - - def _render(self, rect: rl.Rectangle): - if self._current_panel is not None: - self._draw_current_panel() - else: - self._scroller.render(rect) - - def _draw_current_panel(self): - panel = self._panels[self._current_panel] - panel.instance.render(self._rect) - - def _set_current_panel(self, panel_type: PanelType | None): - if panel_type != self._current_panel: - if self._current_panel is not None: - self._panels[self._current_panel].instance.hide_event() - self._current_panel = panel_type - if self._current_panel is not None: - self._panels[self._current_panel].instance.show_event() - - def close_settings(self): - if self._close_callback: - self._close_callback() diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index e638e365c..21b538597 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -1,21 +1,17 @@ -import pyray as rl -from collections.abc import Callable from cereal import log -from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.scroller import NavScroller from openpilot.selfdrive.ui.mici.widgets.button import BigParamControl, BigMultiParamToggle from openpilot.system.ui.lib.application import gui_app -from openpilot.system.ui.widgets import NavWidget from openpilot.selfdrive.ui.layouts.settings.common import restart_needed_callback from openpilot.selfdrive.ui.ui_state import ui_state PERSONALITY_TO_INT = log.LongitudinalPersonality.schema.enumerants -class TogglesLayoutMici(NavWidget): - def __init__(self, back_callback: Callable): +class TogglesLayoutMici(NavScroller): + def __init__(self): super().__init__() - self.set_back_callback(back_callback) self._personality_toggle = BigMultiParamToggle("driving personality", "LongitudinalPersonality", ["aggressive", "standard", "relaxed"]) self._safe_mode_btn = BigParamControl("safe mode", "SafeMode", toggle_callback=restart_needed_callback) @@ -27,7 +23,7 @@ class TogglesLayoutMici(NavWidget): record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback) enable_openpilot = BigParamControl("enable openpilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback) - self._scroller = Scroller([ + self._scroller.add_widgets([ self._personality_toggle, self._safe_mode_btn, self._experimental_btn, @@ -37,7 +33,7 @@ class TogglesLayoutMici(NavWidget): record_front, record_mic, enable_openpilot, - ], snap_items=False, scroll_indicator=True, edge_shadows=True) + ]) # Toggle lists self._refresh_toggles = ( @@ -72,7 +68,6 @@ class TogglesLayoutMici(NavWidget): def show_event(self): super().show_event() - self._scroller.show_event() self._update_toggles() def _update_toggles(self): @@ -105,6 +100,3 @@ class TogglesLayoutMici(NavWidget): # Refresh toggles from params to mirror external changes for key, item in self._refresh_toggles: item.set_checked(ui_state.params.get_bool(key)) - - def _render(self, rect: rl.Rectangle): - self._scroller.render(rect) diff --git a/selfdrive/ui/mici/layouts/settings/visuals.py b/selfdrive/ui/mici/layouts/settings/visuals.py new file mode 100644 index 000000000..e44c57c69 --- /dev/null +++ b/selfdrive/ui/mici/layouts/settings/visuals.py @@ -0,0 +1,74 @@ +from openpilot.common.params import Params +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigMultiOptionDialog +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.scroller import NavScroller + +CAMERA_VIEW_LABELS = ["Auto", "Driver", "Standard", "Wide"] + + +class CameraViewBigButton(BigButton): + def __init__(self): + super().__init__("camera view", "", gui_app.texture("icons_mici/settings/device/cameras.png", 64, 64)) + self._params = Params() + self.set_click_callback(self._show_selector) + self.refresh() + + def refresh(self): + current_idx = self._params.get_int("CameraView", return_default=True, default=3) + current_idx = max(0, min(current_idx, len(CAMERA_VIEW_LABELS) - 1)) + self.set_value(CAMERA_VIEW_LABELS[current_idx].lower()) + + def _show_selector(self): + current_idx = self._params.get_int("CameraView", return_default=True, default=3) + current_idx = max(0, min(current_idx, len(CAMERA_VIEW_LABELS) - 1)) + dialog_holder: dict[str, BigMultiOptionDialog] = {} + + def on_confirm(): + try: + idx = CAMERA_VIEW_LABELS.index(dialog_holder["dialog"].get_selected_option()) + except ValueError: + gui_app.push_widget(BigDialog("", "Invalid camera view")) + return + self._params.put_int("CameraView", idx) + self.refresh() + + dialog = BigMultiOptionDialog(options=CAMERA_VIEW_LABELS, default=CAMERA_VIEW_LABELS[current_idx], right_btn_callback=on_confirm) + dialog_holder["dialog"] = dialog + gui_app.push_widget(dialog) + + +class VisualsLayoutMici(NavScroller): + def __init__(self): + super().__init__() + self._camera_view_btn = CameraViewBigButton() + self._driver_camera_btn = BigParamControl("driver camera on reverse", "DriverCamera") + self._stopped_timer_btn = BigParamControl("stopped timer", "StoppedTimer") + self._speed_limit_signs_btn = BigParamControl("speed limit signs", "ShowSpeedLimits") + self._slc_confirmation_btn = BigParamControl("confirm new speed limits", "SLCConfirmation") + self._slc_confirmation_lower_btn = BigParamControl("confirm lower limits", "SLCConfirmationLower") + self._slc_confirmation_higher_btn = BigParamControl("confirm higher limits", "SLCConfirmationHigher") + + self._scroller.add_widgets([ + self._camera_view_btn, + self._driver_camera_btn, + self._stopped_timer_btn, + self._speed_limit_signs_btn, + self._slc_confirmation_btn, + self._slc_confirmation_lower_btn, + self._slc_confirmation_higher_btn, + ]) + + def show_event(self): + super().show_event() + self._refresh() + + def _update_state(self): + super()._update_state() + self._refresh() + + def _refresh(self): + self._camera_view_btn.refresh() + confirmation_enabled = self._slc_confirmation_btn.params.get_bool("SLCConfirmation") + self._slc_confirmation_lower_btn.set_visible(confirmation_enabled) + self._slc_confirmation_higher_btn.set_visible(confirmation_enabled) diff --git a/selfdrive/ui/mici/onroad/alert_renderer.py b/selfdrive/ui/mici/onroad/alert_renderer.py index a8eb0998a..7b006aaae 100644 --- a/selfdrive/ui/mici/onroad/alert_renderer.py +++ b/selfdrive/ui/mici/onroad/alert_renderer.py @@ -98,8 +98,6 @@ class AlertRenderer(Widget): self._prev_alert: Alert | None = None self._text_gen_time = 0 self._alert_text2_gen = '' - self._last_started_frame = -1 - self._below_steer_speed_shown_this_drive = False # animation filters # TODO: use 0.1 but with proper alert height calculation @@ -113,23 +111,15 @@ class AlertRenderer(Widget): self._load_icons() def _load_icons(self): - self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 100, 91) - self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_right.png', 100, 91) - self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 108, 128) - self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_right.png', 108, 128) + self._txt_turn_signal_left = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 104, 96) + self._txt_turn_signal_right = gui_app.texture('icons_mici/onroad/turn_signal_left.png', 104, 96, flip_x=True) + self._txt_blind_spot_left = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 134, 150) + self._txt_blind_spot_right = gui_app.texture('icons_mici/onroad/blind_spot_left.png', 134, 150, flip_x=True) def get_alert(self, sm: messaging.SubMaster) -> Alert | None: """Generate the current alert based on selfdrive state.""" ss = sm['selfdriveState'] - # Reset per-drive one-shot alert state on each new onroad session. - if ui_state.started and ui_state.started_frame != self._last_started_frame: - self._last_started_frame = ui_state.started_frame - self._below_steer_speed_shown_this_drive = False - elif not ui_state.started: - self._last_started_frame = -1 - self._below_steer_speed_shown_this_drive = False - # Check if selfdriveState messages have stopped arriving if not sm.updated['selfdriveState']: recv_frame = sm.recv_frame['selfdriveState'] @@ -155,13 +145,6 @@ class AlertRenderer(Widget): # Return current alert ret = Alert(text1=ss.alertText1, text2=ss.alertText2, size=ss.alertSize.raw, status=ss.alertStatus.raw, visual_alert=ss.alertHudVisual, alert_type=ss.alertType) - - # Stock-like once-per-drive minimum lateral warning behavior. - if ret.alert_type.startswith("belowSteerSpeed/"): - if self._below_steer_speed_shown_this_drive: - return None - self._below_steer_speed_shown_this_drive = True - self._prev_alert = ret return ret @@ -275,8 +258,8 @@ class AlertRenderer(Widget): else: icon_alpha = int(min(self._turn_signal_alpha_filter.x, 255)) - rl.draw_texture(alert_layout.icon.texture, pos_x, int(self._rect.y + alert_layout.icon.margin_y), - rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x))) + rl.draw_texture_ex(alert_layout.icon.texture, rl.Vector2(pos_x, self._rect.y + alert_layout.icon.margin_y), 0.0, 1.0, + rl.Color(255, 255, 255, int(icon_alpha * self._alpha_filter.x))) def _draw_background(self, alert: Alert) -> None: # draw top gradient for alert text at top diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index 776bc8b65..aac38ba71 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -51,13 +51,9 @@ if TICI: void main() { vec4 color = texture(texture0, fragTexCoord); + // Keep the onroad camera feed full-color in every driving state. if (engaged == 1) { - float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114)); // Luma - color.rgb = mix(vec3(gray), color.rgb, 0.2); // 20% saturation - color.rgb = clamp((color.rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast - color.rgb = pow(color.rgb, vec3(1.0/1.28)); - } else { - color.rgb *= 0.85; // 85% opacity + color.rgb = color.rgb; } if (enhance_driver == 1) { float brightness = 1.1; @@ -82,12 +78,9 @@ else: float y = texture(texture0, fragTexCoord).r; vec2 uv = texture(texture1, fragTexCoord).ra - 0.5; vec3 rgb = vec3(y + 1.402*uv.y, y - 0.344*uv.x - 0.714*uv.y, y + 1.772*uv.x); + // Keep the onroad camera feed full-color in every driving state. if (engaged == 1) { - float gray = dot(rgb, vec3(0.299, 0.587, 0.114)); - rgb = mix(vec3(gray), rgb, 0.2); // 20% saturation - rgb = clamp((rgb - 0.5) * 1.2 + 0.5, 0.0, 1.0); // +20% contrast - } else { - rgb *= 0.85; // 85% opacity + rgb = rgb; } // TODO: the images out of camerad need some more correction and // the ui should apply a gamma curve for the device display @@ -324,8 +317,10 @@ class CameraView(Widget): def _update_texture_color_filtering(self): self._engaged_val[0] = 1 if ui_state.status != UIStatus.DISENGAGED else 0 - rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) - rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) + if self._engaged_loc >= 0: + rl.set_shader_value(self.shader, self._engaged_loc, self._engaged_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) + if self._enhance_driver_loc >= 0: + rl.set_shader_value(self.shader, self._enhance_driver_loc, self._enhance_driver_val, rl.ShaderUniformDataType.SHADER_UNIFORM_INT) def _ensure_connection(self) -> bool: if not self.client.is_connected(): diff --git a/selfdrive/ui/mici/onroad/confidence_ball.py b/selfdrive/ui/mici/onroad/confidence_ball.py index 4ecfc4b68..62f14c561 100644 --- a/selfdrive/ui/mici/onroad/confidence_ball.py +++ b/selfdrive/ui/mici/onroad/confidence_ball.py @@ -34,8 +34,14 @@ class ConfidenceBall(Widget): if self._demo: return - self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) * - (1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1]))) + lateral_ui_active = ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_lateral_active + + # animate status dot in from bottom + if not lateral_ui_active: + self._confidence_filter.update(-0.5) + else: + self._confidence_filter.update((1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.brakeDisengageProbs or [1])) * + (1 - max(ui_state.sm['modelV2'].meta.disengagePredictions.steerOverrideProbs or [1]))) def _render(self, _): content_rect = rl.Rectangle( @@ -50,7 +56,7 @@ class ConfidenceBall(Widget): dot_height = self._rect.y + dot_height # confidence zones - if ui_state.status != UIStatus.OVERRIDE or self._demo: + if ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active or self._demo: if self._confidence_filter.x > 0.5: top_dot_color = rl.Color(0, 255, 204, 255) bottom_dot_color = rl.Color(0, 255, 38, 255) @@ -65,6 +71,10 @@ class ConfidenceBall(Widget): top_dot_color = rl.Color(255, 255, 255, 255) bottom_dot_color = rl.Color(82, 82, 82, 255) + else: + top_dot_color = rl.Color(50, 50, 50, 255) + bottom_dot_color = rl.Color(13, 13, 13, 255) + draw_circle_gradient(content_rect.x + content_rect.width - status_dot_radius, dot_height, status_dot_radius, top_dot_color, bottom_dot_color) diff --git a/selfdrive/ui/mici/onroad/driver_camera_dialog.py b/selfdrive/ui/mici/onroad/driver_camera_dialog.py index afacb5323..e8b8abb7f 100644 --- a/selfdrive/ui/mici/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/mici/onroad/driver_camera_dialog.py @@ -7,7 +7,8 @@ from openpilot.selfdrive.ui.ui_state import ui_state, device from openpilot.selfdrive.selfdrived.events import EVENTS, ET from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import NavWidget +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.label import gui_label EventName = log.OnroadEvent.EventName @@ -24,19 +25,15 @@ class DriverCameraView(CameraView): return base -class DriverCameraDialog(NavWidget): - def __init__(self, no_escape=False): +class BaseDriverCameraDialog(Widget): + # Not a NavWidget so training guide can use this without back navigation + def __init__(self): super().__init__() self._camera_view = DriverCameraView("camerad", VisionStreamType.VISION_STREAM_DRIVER) self.driver_state_renderer = DriverStateRenderer(lines=True) self.driver_state_renderer.set_rect(rl.Rectangle(0, 0, 200, 200)) self.driver_state_renderer.load_icons() self._pm: messaging.PubMaster | None = None - if not no_escape: - # TODO: this can grow unbounded, should be given some thought - device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None)) - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) - self.set_back_enabled(not no_escape) # Load eye icons self._eye_fill_texture = None @@ -85,7 +82,7 @@ class DriverCameraDialog(NavWidget): alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) rl.end_scissor_mode() self._publish_alert_sound(None) - return -1 + return driver_data = self._draw_face_detection(rect) if driver_data is not None: @@ -103,7 +100,7 @@ class DriverCameraDialog(NavWidget): self._render_dm_alerts(rect) rl.end_scissor_mode() - return -1 + return def _publish_alert_sound(self, dm_state): """Publish selfdriveState with only alertSound field set""" @@ -217,13 +214,20 @@ class DriverCameraDialog(NavWidget): rl.draw_texture_v(self._eye_fill_texture, (eye_x, eye_y), rl.Color(255, 255, 255, int(255 * fill_opacity))) +class DriverCameraDialog(NavWidget, BaseDriverCameraDialog): + def __init__(self): + super().__init__() + # TODO: this can grow unbounded, should be given some thought + device.add_interactive_timeout_callback(gui_app.pop_widget) + + if __name__ == "__main__": gui_app.init_window("Driver Camera View (mici)") driver_camera_view = DriverCameraDialog() + gui_app.push_widget(driver_camera_view) try: for _ in gui_app.render(): ui_state.update() - driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: driver_camera_view.close() diff --git a/selfdrive/ui/mici/onroad/driver_state.py b/selfdrive/ui/mici/onroad/driver_state.py index 356d7ac83..92ff07c1e 100644 --- a/selfdrive/ui/mici/onroad/driver_state.py +++ b/selfdrive/ui/mici/onroad/driver_state.py @@ -61,7 +61,7 @@ class DriverStateRenderer(Widget): self._dm_cone = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_cone.png", cone_and_person_size, cone_and_person_size) center_size = round(36 / self.BASE_SIZE * self._rect.width) self._dm_center = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_center.png", center_size, center_size) - self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", self._rect.width, self._rect.height) + self._dm_background = gui_app.texture("icons_mici/onroad/driver_monitoring/dm_background.png", int(self._rect.width), int(self._rect.height)) def set_should_draw(self, should_draw: bool): self._should_draw = should_draw @@ -88,15 +88,14 @@ class DriverStateRenderer(Widget): if DEBUG: rl.draw_rectangle_lines_ex(self._rect, 1, rl.RED) - rl.draw_texture(self._dm_background, - int(self._rect.x), - int(self._rect.y), - rl.Color(255, 255, 255, int(255 * self._fade_filter.x))) + rl.draw_texture_ex(self._dm_background, + rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, + rl.Color(255, 255, 255, int(255 * self._fade_filter.x))) - rl.draw_texture(self._dm_person, - int(self._rect.x + (self._rect.width - self._dm_person.width) / 2), - int(self._rect.y + (self._rect.height - self._dm_person.height) / 2), - rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x))) + rl.draw_texture_ex(self._dm_person, + rl.Vector2(self._rect.x + (self._rect.width - self._dm_person.width) / 2, + self._rect.y + (self._rect.height - self._dm_person.height) / 2), 0.0, 1.0, + rl.Color(255, 255, 255, int(255 * 0.9 * self._fade_filter.x))) if self.effective_active: source_rect = rl.Rectangle(0, 0, self._dm_cone.width, self._dm_cone.height) diff --git a/selfdrive/ui/mici/onroad/hud_renderer.py b/selfdrive/ui/mici/onroad/hud_renderer.py index efa4718b1..2c22ad638 100644 --- a/selfdrive/ui/mici/onroad/hud_renderer.py +++ b/selfdrive/ui/mici/onroad/hud_renderer.py @@ -264,12 +264,13 @@ class HudRenderer(Widget): def _draw_steering_wheel(self, rect: rl.Rectangle) -> None: wheel_txt = self._txt_wheel_critical if self._show_wheel_critical else self._txt_wheel + lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active if self._show_wheel_critical: self._wheel_alpha_filter.update(255) self._wheel_y_filter.update(0) else: - if ui_state.status == UIStatus.DISENGAGED: + if not lateral_ui_active and ui_state.status == UIStatus.DISENGAGED: self._wheel_alpha_filter.update(0) self._wheel_y_filter.update(wheel_txt.height / 2) else: diff --git a/selfdrive/ui/mici/onroad/model_renderer.py b/selfdrive/ui/mici/onroad/model_renderer.py index e3a1e23ed..b4112fff7 100644 --- a/selfdrive/ui/mici/onroad/model_renderer.py +++ b/selfdrive/ui/mici/onroad/model_renderer.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from openpilot.common.params import Params from openpilot.common.filter_simple import FirstOrderFilter from openpilot.selfdrive.locationd.calibrationd import HEIGHT_INIT -from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus from openpilot.selfdrive.ui.mici.onroad import blend_colors from openpilot.selfdrive.ui.mici.onroad.starpilot_status import get_border_color, get_path_edge_color from openpilot.system.ui.lib.application import gui_app @@ -331,7 +331,8 @@ class ModelRenderer(Widget): if not self._path.projected_points.size: return - allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control + lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active + allow_throttle = sm['longitudinalPlan'].allowThrottle or not self._longitudinal_control or ui_state.always_on_lateral_active self._blend_filter.update(int(allow_throttle)) if self._experimental_mode: @@ -345,6 +346,8 @@ class ModelRenderer(Widget): # Blend throttle/no throttle colors based on transition blend_factor = round(self._blend_filter.x * 100) / 100 blended_colors = self._blend_colors(NO_THROTTLE_COLORS, THROTTLE_COLORS, blend_factor) + if lateral_ui_active and blend_factor < 1.0: + blended_colors = self._blend_colors(blended_colors, THROTTLE_COLORS, 0.65) gradient = Gradient( start=(0.0, 1.0), # Bottom of path end=(0.0, 0.0), # Top of path diff --git a/selfdrive/ui/mici/onroad/torque_bar.py b/selfdrive/ui/mici/onroad/torque_bar.py index 412d21a7f..079a9fcac 100644 --- a/selfdrive/ui/mici/onroad/torque_bar.py +++ b/selfdrive/ui/mici/onroad/torque_bar.py @@ -145,6 +145,9 @@ def arc_bar_pts(cx: float, cy: float, return pts +DEFAULT_MAX_LAT_ACCEL = 3.0 # m/s^2 + + class TorqueBar(Widget): def __init__(self, demo: bool = False): super().__init__() @@ -165,16 +168,23 @@ class TorqueBar(Widget): controls_state = ui_state.sm['controlsState'] car_state = ui_state.sm['carState'] live_parameters = ui_state.sm['liveParameters'] - lateral_acceleration = controls_state.curvature * car_state.vEgo ** 2 - live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY - # TODO: pull from carparams - max_lateral_acceleration = 3 + car_control = ui_state.sm['carControl'] - # from selfdrived + # Include lateral accel error in estimated torque utilization actual_lateral_accel = controls_state.curvature * car_state.vEgo ** 2 desired_lateral_accel = controls_state.desiredCurvature * car_state.vEgo ** 2 accel_diff = (desired_lateral_accel - actual_lateral_accel) - self._torque_filter.update(min(max(lateral_acceleration / max_lateral_acceleration + accel_diff, -1), 1)) + # Include road roll in estimated torque utilization + # Roll is less accurate near standstill, so reduce its effect at low speed + roll_compensation = live_parameters.roll * ACCELERATION_DUE_TO_GRAVITY * np.interp(car_state.vEgo, [5, 15], [0.0, 1.0]) + lateral_acceleration = actual_lateral_accel - roll_compensation + max_lateral_acceleration = ui_state.CP.maxLateralAccel if ui_state.CP else DEFAULT_MAX_LAT_ACCEL + + if not car_control.latActive: + self._torque_filter.update(0.0) + else: + self._torque_filter.update(np.clip((lateral_acceleration + accel_diff) / max_lateral_acceleration, -1, 1)) else: self._torque_filter.update(-ui_state.sm['carOutput'].actuatorsOutput.torque) @@ -182,16 +192,17 @@ class TorqueBar(Widget): # adjust y pos with torque torque_line_offset = np.interp(abs(self._torque_filter.x), [0.5, 1], [22, 26]) torque_line_height = np.interp(abs(self._torque_filter.x), [0.5, 1], [14, 56]) + lateral_ui_active = ui_state.status == UIStatus.ENGAGED or ui_state.always_on_lateral_active # animate alpha and angle span if not self._demo: - self._torque_line_alpha_filter.update(1.0) + self._torque_line_alpha_filter.update(lateral_ui_active or ui_state.status == UIStatus.OVERRIDE) else: self._torque_line_alpha_filter.update(1.0) torque_line_bg_alpha = np.interp(abs(self._torque_filter.x), [0.5, 1.0], [0.25, 0.5]) torque_line_bg_color = rl.Color(255, 255, 255, int(255 * torque_line_bg_alpha * self._torque_line_alpha_filter.x)) - if ui_state.status != UIStatus.ENGAGED and not self._demo: + if not lateral_ui_active and ui_state.status != UIStatus.OVERRIDE and not self._demo: torque_line_bg_color = rl.Color(255, 255, 255, int(255 * 0.15 * self._torque_line_alpha_filter.x)) # draw curved line polygon torque bar @@ -234,7 +245,7 @@ class TorqueBar(Widget): max(0, abs(self._torque_filter.x) - 0.75) * 4, ) - if ui_state.status != UIStatus.ENGAGED and not self._demo: + if not lateral_ui_active and ui_state.status != UIStatus.OVERRIDE and not self._demo: start_color = end_color = rl.Color(255, 255, 255, int(255 * 0.35 * self._torque_line_alpha_filter.x)) gradient = Gradient( diff --git a/selfdrive/ui/mici/tests/test_widget_leaks.py b/selfdrive/ui/mici/tests/test_widget_leaks.py new file mode 100755 index 000000000..e35cb4477 --- /dev/null +++ b/selfdrive/ui/mici/tests/test_widget_leaks.py @@ -0,0 +1,119 @@ +import pyray as rl +rl.set_config_flags(rl.ConfigFlags.FLAG_WINDOW_HIDDEN) +import gc +import weakref +import pytest +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget + +# mici dialogs +from openpilot.selfdrive.ui.mici.layouts.onboarding import TrainingGuide as MiciTrainingGuide, OnboardingWindow as MiciOnboardingWindow +from openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog import DriverCameraDialog as MiciDriverCameraDialog +from openpilot.selfdrive.ui.mici.widgets.pairing_dialog import PairingDialog as MiciPairingDialog +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationDialog, BigInputDialog +from openpilot.selfdrive.ui.mici.layouts.settings.device import MiciFccModal + +# tici dialogs +from openpilot.selfdrive.ui.onroad.driver_camera_dialog import DriverCameraDialog as TiciDriverCameraDialog +from openpilot.selfdrive.ui.layouts.onboarding import OnboardingWindow as TiciOnboardingWindow +from openpilot.selfdrive.ui.widgets.pairing_dialog import PairingDialog as TiciPairingDialog +from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog +from openpilot.system.ui.widgets.option_dialog import MultiOptionDialog +from openpilot.system.ui.widgets.html_render import HtmlModal +from openpilot.system.ui.widgets.keyboard import Keyboard + +# FIXME: known small leaks not worth worrying about at the moment +KNOWN_LEAKS = { + "openpilot.selfdrive.ui.mici.onroad.driver_camera_dialog.DriverCameraView", + "openpilot.selfdrive.ui.mici.layouts.onboarding.TermsPage", + "openpilot.selfdrive.ui.mici.layouts.onboarding.TrainingGuide", + "openpilot.selfdrive.ui.mici.layouts.onboarding.DeclinePage", + "openpilot.selfdrive.ui.mici.layouts.onboarding.OnboardingWindow", + "openpilot.selfdrive.ui.onroad.driver_state.DriverStateRenderer", + "openpilot.selfdrive.ui.onroad.driver_camera_dialog.DriverCameraDialog", + "openpilot.selfdrive.ui.layouts.onboarding.TermsPage", + "openpilot.selfdrive.ui.layouts.onboarding.DeclinePage", + "openpilot.selfdrive.ui.layouts.onboarding.OnboardingWindow", + "openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", + "openpilot.system.ui.widgets.label.Label", + "openpilot.system.ui.widgets.button.Button", + "openpilot.system.ui.widgets.html_render.HtmlRenderer", + "openpilot.system.ui.widgets.nav_widget.NavBar", + "openpilot.selfdrive.ui.mici.layouts.settings.device.MiciFccModal", + "openpilot.system.ui.widgets.inputbox.InputBox", + "openpilot.system.ui.widgets.scroller_tici.Scroller", + "openpilot.system.ui.widgets.label.UnifiedLabel", + "openpilot.system.ui.widgets.mici_keyboard.MiciKeyboard", + "openpilot.selfdrive.ui.mici.widgets.dialog.BigConfirmationDialog", + "openpilot.system.ui.widgets.keyboard.Keyboard", + "openpilot.system.ui.widgets.slider.BigSlider", + "openpilot.selfdrive.ui.mici.widgets.dialog.BigInputDialog", + "openpilot.system.ui.widgets.option_dialog.MultiOptionDialog", +} + + +def get_child_widgets(widget: Widget) -> list[Widget]: + children = [] + for val in widget.__dict__.values(): + items = val if isinstance(val, (list, tuple)) else (val,) + children.extend(w for w in items if isinstance(w, Widget)) + return children + + +@pytest.mark.skip(reason="segfaults") +def test_dialogs_do_not_leak(): + gui_app.init_window("ref-test") + + leaked_widgets = set() + + for ctor in ( + # mici + MiciDriverCameraDialog, MiciPairingDialog, + lambda: MiciTrainingGuide(lambda: None), + lambda: MiciOnboardingWindow(lambda: None), + lambda: BigDialog("test", "test"), + lambda: BigConfirmationDialog("test", gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64), lambda: None), + lambda: BigInputDialog("test"), + lambda: MiciFccModal(text="test"), + # tici + TiciDriverCameraDialog, TiciOnboardingWindow, TiciPairingDialog, Keyboard, + lambda: ConfirmDialog("test", "ok"), + lambda: MultiOptionDialog("test", ["a", "b"]), + lambda: HtmlModal(text="test"), + ): + widget = ctor() + all_refs = [weakref.ref(w) for w in get_child_widgets(widget) + [widget]] + + del widget + + for ref in all_refs: + if ref() is not None: + obj = ref() + name = f"{type(obj).__module__}.{type(obj).__qualname__}" + leaked_widgets.add(name) + + print(f"\n=== Widget {name} alive after del") + print(" Referrers:") + for r in gc.get_referrers(obj): + if r is obj: + continue + + if hasattr(r, '__self__') and r.__self__ is not obj: + print(f" bound method: {type(r.__self__).__qualname__}.{r.__name__}") + elif hasattr(r, '__func__'): + print(f" method: {r.__name__}") + else: + print(f" {type(r).__module__}.{type(r).__qualname__}") + del obj + + gui_app.close() + + unexpected = leaked_widgets - KNOWN_LEAKS + assert not unexpected, f"New leaked widgets: {unexpected}" + + fixed = KNOWN_LEAKS - leaked_widgets + assert not fixed, f"These leaks are fixed, remove from KNOWN_LEAKS: {fixed}" + + +if __name__ == "__main__": + test_dialogs_do_not_leak() diff --git a/selfdrive/ui/mici/widgets/button.py b/selfdrive/ui/mici/widgets/button.py index b500f1430..058c351fb 100644 --- a/selfdrive/ui/mici/widgets/button.py +++ b/selfdrive/ui/mici/widgets/button.py @@ -1,11 +1,11 @@ +import math import pyray as rl from typing import Union from enum import Enum from collections.abc import Callable from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.scroller import DO_ZOOM -from openpilot.system.ui.lib.text_measure import measure_text_cached from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.common.filter_simple import BounceFilter @@ -16,8 +16,7 @@ except ImportError: SCROLLING_SPEED_PX_S = 50 COMPLICATION_SIZE = 36 -LABEL_COLOR = rl.WHITE -LABEL_HORIZONTAL_PADDING = 40 +LABEL_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) COMPLICATION_GREY = rl.Color(0xAA, 0xAA, 0xAA, 255) PRESSED_SCALE = 1.15 if DO_ZOOM else 1.07 @@ -29,50 +28,51 @@ class ScrollState(Enum): class BigCircleButton(Widget): - def __init__(self, icon: str, red: bool = False): + def __init__(self, icon: rl.Texture, red: bool = False, icon_offset: tuple[int, int] = (0, 0)): super().__init__() self._red = red + self._icon_offset = icon_offset # State self.set_rect(rl.Rectangle(0, 0, 180, 180)) - self._press_state_enabled = True self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._click_delay = 0.075 # Icons - self._txt_icon = gui_app.texture(icon, 64, 53) + self._txt_icon = icon self._txt_btn_disabled_bg = gui_app.texture("icons_mici/buttons/button_circle_disabled.png", 180, 180) self._txt_btn_bg = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) - self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180) + self._txt_btn_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", 180, 180) self._txt_btn_red_bg = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180) - self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180) + self._txt_btn_red_pressed_bg = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180) - def set_enable_pressed_state(self, pressed: bool): - self._press_state_enabled = pressed + def _draw_content(self, btn_y: float): + # draw icon + icon_color = rl.Color(255, 255, 255, int(255 * 0.9)) if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) + rl.draw_texture_ex(self._txt_icon, (self._rect.x + (self._rect.width - self._txt_icon.width) / 2 + self._icon_offset[0], + btn_y + (self._rect.height - self._txt_icon.height) / 2 + self._icon_offset[1]), 0, 1.0, icon_color) def _render(self, _): # draw background txt_bg = self._txt_btn_bg if not self._red else self._txt_btn_red_bg if not self.enabled: txt_bg = self._txt_btn_disabled_bg - elif self.is_pressed and self._press_state_enabled: + elif self.is_pressed: txt_bg = self._txt_btn_pressed_bg if not self._red else self._txt_btn_red_pressed_bg - scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed and self._press_state_enabled else 1.0) + scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0) btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) - # draw icon - icon_color = rl.WHITE if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) - rl.draw_texture(self._txt_icon, int(self._rect.x + (self._rect.width - self._txt_icon.width) / 2), - int(self._rect.y + (self._rect.height - self._txt_icon.height) / 2), icon_color) + self._draw_content(btn_y) class BigCircleToggle(BigCircleButton): - def __init__(self, icon: str, toggle_callback: Callable = None): - super().__init__(icon, False) + def __init__(self, icon: rl.Texture, toggle_callback: Callable | None = None, icon_offset: tuple[int, int] = (0, 0)): + super().__init__(icon, False, icon_offset=icon_offset) self._toggle_callback = toggle_callback # State @@ -80,7 +80,7 @@ class BigCircleToggle(BigCircleButton): # Icons self._txt_toggle_enabled = gui_app.texture("icons_mici/buttons/toggle_dot_enabled.png", 66, 66) - self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 70, 70) # TODO: why discrepancy? + self._txt_toggle_disabled = gui_app.texture("icons_mici/buttons/toggle_dot_disabled.png", 66, 66) def set_checked(self, checked: bool): self._checked = checked @@ -92,62 +92,47 @@ class BigCircleToggle(BigCircleButton): if self._toggle_callback: self._toggle_callback(self._checked) - def _render(self, _): - super()._render(_) + def _draw_content(self, btn_y: float): + super()._draw_content(btn_y) # draw status icon - rl.draw_texture(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled, - int(self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2), - int(self._rect.y + 5), rl.WHITE) + rl.draw_texture_ex(self._txt_toggle_enabled if self._checked else self._txt_toggle_disabled, + (self._rect.x + (self._rect.width - self._txt_toggle_enabled.width) / 2, btn_y + 5), + 0, 1.0, rl.WHITE) class BigButton(Widget): + LABEL_HORIZONTAL_PADDING = 40 + LABEL_VERTICAL_PADDING = 23 # visually matches 30 in figma + """A lightweight stand-in for the Qt BigButton, drawn & updated each frame.""" - def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = ""): + def __init__(self, text: str, value: str = "", icon: Union[rl.Texture, None] = None, scroll: bool = False): super().__init__() self.set_rect(rl.Rectangle(0, 0, 402, 180)) self.text = text self.value = value - self.set_icon(icon) - self._label_font_size_override: int | None = None + self._txt_icon = icon + self._scroll = scroll self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._click_delay = 0.075 + self._shake_start: float | None = None + self._grow_animation_until: float | None = None self._rotate_icon_t: float | None = None - self._label_font = gui_app.font(FontWeight.DISPLAY) - self._value_font = gui_app.font(FontWeight.ROMAN) - - self._label = MiciLabel(text, font_size=self._get_label_font_size(), width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2), - font_weight=FontWeight.DISPLAY, color=LABEL_COLOR, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True) - self._sub_label = MiciLabel(value, font_size=COMPLICATION_SIZE, width=int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2), - font_weight=FontWeight.ROMAN, color=COMPLICATION_GREY, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, wrap_text=True) + self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD, + text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll, + line_height=0.9) + self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN, + text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._update_label_layout() self._load_images() - # internal state - self._scroll_offset = 0 # in pixels - self._needs_scroll = measure_text_cached(self._label_font, text, self._get_label_font_size()).x + 25 > self._rect.width - self._scroll_timer = 0 - self._scroll_state = ScrollState.PRE_SCROLL - - def set_icon(self, icon: Union[str, rl.Texture]): - self._txt_icon = gui_app.texture(icon, 64, 64) if isinstance(icon, str) and len(icon) else icon - - def _refresh_label_metrics(self): - font_size = self._label_font_size_override if self._label_font_size_override is not None else self._get_label_font_size() - self._label.set_font_size(font_size) - self._needs_scroll = measure_text_cached(self._label_font, self.text, font_size).x + 25 > self._rect.width - self._scroll_offset = 0 - self._scroll_timer = 0 - self._scroll_state = ScrollState.PRE_SCROLL - - def _set_label_font_size_override(self, font_size: int | None): - self._label_font_size_override = font_size - self._refresh_label_metrics() + def set_icon(self, icon: Union[rl.Texture, None]): + self._txt_icon = icon def set_rotate_icon(self, rotate: bool): if rotate and self._rotate_icon_t is not None: @@ -158,32 +143,37 @@ class BigButton(Widget): self._txt_default_bg = gui_app.texture("icons_mici/buttons/button_rectangle.png", 402, 180) self._txt_pressed_bg = gui_app.texture("icons_mici/buttons/button_rectangle_pressed.png", 402, 180) self._txt_disabled_bg = gui_app.texture("icons_mici/buttons/button_rectangle_disabled.png", 402, 180) - self._txt_hover_bg = gui_app.texture("icons_mici/buttons/button_rectangle_hover.png", 402, 180) + + def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None: + super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None) + + def _width_hint(self) -> int: + # Single line if scrolling, so hide behind icon if exists + icon_size = self._txt_icon.width if self._txt_icon and self._scroll and self.value else 0 + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size) def _get_label_font_size(self): - if len(self.text) < 12: - font_size = 64 - elif len(self.text) < 17: - font_size = 48 - elif len(self.text) < 20: - font_size = 42 + if len(self.text) <= 18: + return 48 else: - font_size = 36 + return 42 + def _update_label_layout(self): + self._label.set_font_size(self._get_label_font_size()) if self.value: - font_size -= 20 - - return font_size + self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) + else: + self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) def set_text(self, text: str): self.text = text self._label.set_text(text) - self._refresh_label_metrics() + self._update_label_layout() def set_value(self, value: str): self.value = value self._sub_label.set_text(value) - self._refresh_label_metrics() + self._update_label_layout() def get_value(self) -> str: return self.value @@ -191,64 +181,60 @@ class BigButton(Widget): def get_text(self): return self.text - def _update_state(self): - # hold on text for a bit, scroll, hold again, reset - if self._needs_scroll: - """`dt` should be seconds since last frame (rl.get_frame_time()).""" - # TODO: this comment is generated by GPT, prob wrong and misused - dt = rl.get_frame_time() + def trigger_shake(self): + self._shake_start = rl.get_time() - self._scroll_timer += dt - if self._scroll_state == ScrollState.PRE_SCROLL: - if self._scroll_timer < 0.5: - return - self._scroll_state = ScrollState.SCROLLING - self._scroll_timer = 0 + def trigger_grow_animation(self, duration: float = 0.65): + self._grow_animation_until = rl.get_time() + duration - elif self._scroll_state == ScrollState.SCROLLING: - self._scroll_offset -= SCROLLING_SPEED_PX_S * dt - # reset when text has completely left the button + 50 px gap - # TODO: use global constant for 30+30 px gap - # TODO: add std Widget padding option integrated into the self._rect - full_len = measure_text_cached(self._label_font, self.text, self._get_label_font_size()).x + 30 + 30 - if self._scroll_offset < (self._rect.width - full_len): - self._scroll_state = ScrollState.POST_SCROLL - self._scroll_timer = 0 + @property + def _shake_offset(self) -> float: + SHAKE_DURATION = 0.5 + SHAKE_AMPLITUDE = 24.0 + SHAKE_FREQUENCY = 32.0 + if self._shake_start is None: + return 0.0 + t = rl.get_time() - self._shake_start + if t > SHAKE_DURATION: + return 0.0 + decay = 1.0 - t / SHAKE_DURATION + return decay * SHAKE_AMPLITUDE * math.sin(t * SHAKE_FREQUENCY) - elif self._scroll_state == ScrollState.POST_SCROLL: - # wait for a bit before starting to scroll again - if self._scroll_timer < 0.75: - return - self._scroll_state = ScrollState.PRE_SCROLL - self._scroll_timer = 0 - self._scroll_offset = 0 + def set_position(self, x: float, y: float) -> None: + super().set_position(x + self._shake_offset, y) + + def _handle_background(self) -> tuple[rl.Texture, float, float, float]: + if self._grow_animation_until is not None: + if rl.get_time() >= self._grow_animation_until: + self._grow_animation_until = None - def _render(self, _): # draw _txt_default_bg txt_bg = self._txt_default_bg if not self.enabled: txt_bg = self._txt_disabled_bg elif self.is_pressed: - txt_bg = self._txt_hover_bg + txt_bg = self._txt_pressed_bg - scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed else 1.0) + scale = self._scale_filter.update(PRESSED_SCALE if self.is_pressed or self._grow_animation_until is not None else 1.0) btn_x = self._rect.x + (self._rect.width * (1 - scale)) / 2 btn_y = self._rect.y + (self._rect.height * (1 - scale)) / 2 - rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) + return txt_bg, btn_x, btn_y, scale + def _draw_content(self, btn_y: float): # LABEL ------------------------------------------------------------------ - lx = self._rect.x + LABEL_HORIZONTAL_PADDING - ly = btn_y + self._rect.height - 33 # - 40# - self._get_label_font_size() / 2 - - if self.value: - self._sub_label.set_position(lx, ly) - ly -= self._sub_label.font_size + 9 - self._sub_label.render() + label_x = self._rect.x + self.LABEL_HORIZONTAL_PADDING label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35)) self._label.set_color(label_color) - self._label.set_position(lx, ly) - self._label.render() + label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(), + self._rect.height - self.LABEL_VERTICAL_PADDING * 2) + self._label.render(label_rect) + + if self.value: + label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint()) + sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y + sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height) + self._sub_label.render(sub_label_rect) # ICON ------------------------------------------------------------------- if self._txt_icon: @@ -256,23 +242,35 @@ class BigButton(Widget): if self._rotate_icon_t is not None: rotation = (rl.get_time() - self._rotate_icon_t) * 180 - # drop top right with 30px padding + # draw top right with 30px padding x = self._rect.x + self._rect.width - 30 - self._txt_icon.width / 2 - y = self._rect.y + 30 + self._txt_icon.height / 2 + y = btn_y + 30 + self._txt_icon.height / 2 source_rec = rl.Rectangle(0, 0, self._txt_icon.width, self._txt_icon.height) - dest_rec = rl.Rectangle(int(x), int(y), self._txt_icon.width, self._txt_icon.height) + dest_rec = rl.Rectangle(x, y, self._txt_icon.width, self._txt_icon.height) origin = rl.Vector2(self._txt_icon.width / 2, self._txt_icon.height / 2) - rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.WHITE) + rl.draw_texture_pro(self._txt_icon, source_rec, dest_rec, origin, rotation, rl.Color(255, 255, 255, int(255 * 0.9))) + + def _render(self, _): + txt_bg, btn_x, btn_y, scale = self._handle_background() + + if self._scroll: + # draw black background since images are transparent + scaled_rect = rl.Rectangle(btn_x, btn_y, self._rect.width * scale, self._rect.height * scale) + rl.draw_rectangle_rounded(scaled_rect, 0.4, 7, rl.Color(0, 0, 0, int(255 * 0.5))) + + self._draw_content(btn_y) + rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) + else: + rl.draw_texture_ex(txt_bg, (btn_x, btn_y), 0, scale, rl.WHITE) + self._draw_content(btn_y) class BigToggle(BigButton): - def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable = None): + def __init__(self, text: str, value: str = "", initial_state: bool = False, toggle_callback: Callable | None = None): super().__init__(text, value, "") self._checked = initial_state self._toggle_callback = toggle_callback - self._set_label_font_size_override(48) - def _load_images(self): super()._load_images() self._txt_enabled_toggle = gui_app.texture("icons_mici/buttons/toggle_pill_enabled.png", 84, 66) @@ -290,35 +288,30 @@ class BigToggle(BigButton): def _draw_pill(self, x: float, y: float, checked: bool): # draw toggle icon top right if checked: - rl.draw_texture(self._txt_enabled_toggle, int(x), int(y), rl.WHITE) + rl.draw_texture_ex(self._txt_enabled_toggle, (x, y), 0, 1.0, rl.WHITE) else: - rl.draw_texture(self._txt_disabled_toggle, int(x), int(y), rl.WHITE) + rl.draw_texture_ex(self._txt_disabled_toggle, (x, y), 0, 1.0, rl.WHITE) - def _render(self, _): - super()._render(_) + def _draw_content(self, btn_y: float): + super()._draw_content(btn_y) x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width - y = self._rect.y + y = btn_y self._draw_pill(x, y, self._checked) class BigMultiToggle(BigToggle): - def __init__(self, text: str, options: list[str], toggle_callback: Callable = None, - select_callback: Callable = None): + def __init__(self, text: str, options: list[str], toggle_callback: Callable | None = None, + select_callback: Callable | None = None): super().__init__(text, "", toggle_callback=toggle_callback) assert len(options) > 0 self._options = options self._select_callback = select_callback - self._label.set_width(int(self._rect.width - LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)) - # Keep the title size stable when the selected option changes. - self._set_label_font_size_override(self._get_label_font_size()) - self.set_value(self._options[0]) - def _get_label_font_size(self): - font_size = super()._get_label_font_size() - return font_size - 6 + def _width_hint(self) -> int: + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width) def _handle_mouse_release(self, mouse_pos: MousePos): super()._handle_mouse_release(mouse_pos) @@ -328,22 +321,60 @@ class BigMultiToggle(BigToggle): if self._select_callback: self._select_callback(self.value) - def _render(self, _): - BigButton._render(self, _) + def _draw_content(self, btn_y: float): + # don't draw pill from BigToggle + BigButton._draw_content(self, btn_y) checked_idx = self._options.index(self.value) x = self._rect.x + self._rect.width - self._txt_enabled_toggle.width - y = self._rect.y + y = btn_y for i in range(len(self._options)): self._draw_pill(x, y, checked_idx == i) y += 35 +class GreyBigButton(BigButton): + """Users should manage newlines with this class themselves""" + + LABEL_HORIZONTAL_PADDING = 30 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_touch_valid_callback(lambda: False) + + self._rect.width = 476 + + self._label.set_font_size(36) + self._label.set_font_weight(FontWeight.BOLD) + self._label.set_line_height(1.0) + + self._sub_label.set_font_size(36) + self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9))) + self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR) + self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else + rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + self._sub_label.set_line_height(0.95) + + @property + def LABEL_VERTICAL_PADDING(self): + return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18 + + def _width_hint(self) -> int: + return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2) + + def _get_label_font_size(self): + return 36 + + def _render(self, _): + rl.draw_rectangle_rounded(self._rect, 0.4, 10, rl.Color(255, 255, 255, int(255 * 0.15))) + self._draw_content(self._rect.y) + + class BigMultiParamToggle(BigMultiToggle): - def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable = None, - select_callback: Callable = None): + def __init__(self, text: str, param: str, options: list[str], toggle_callback: Callable | None = None, + select_callback: Callable | None = None): super().__init__(text, options, toggle_callback, select_callback) self._param = param @@ -360,7 +391,7 @@ class BigMultiParamToggle(BigMultiToggle): class BigParamControl(BigToggle): - def __init__(self, text: str, param: str, toggle_callback: Callable = None): + def __init__(self, text: str, param: str, toggle_callback: Callable | None = None): super().__init__(text, "", toggle_callback=toggle_callback) self.param = param self.params = Params() @@ -376,8 +407,9 @@ class BigParamControl(BigToggle): # TODO: param control base class class BigCircleParamControl(BigCircleToggle): - def __init__(self, icon: str, param: str, toggle_callback: Callable = None): - super().__init__(icon, toggle_callback) + def __init__(self, icon: rl.Texture, param: str, toggle_callback: Callable | None = None, + icon_offset: tuple[int, int] = (0, 0)): + super().__init__(icon, toggle_callback, icon_offset=icon_offset) self._param = param self.params = Params() self.set_checked(self.params.get_bool(self._param, False)) diff --git a/selfdrive/ui/mici/widgets/dialog.py b/selfdrive/ui/mici/widgets/dialog.py index d64571e3c..b23a83ecd 100644 --- a/selfdrive/ui/mici/widgets/dialog.py +++ b/selfdrive/ui/mici/widgets/dialog.py @@ -1,19 +1,18 @@ import abc import math import pyray as rl -from typing import Union +from typing import Union, cast from collections.abc import Callable -from typing import cast -from openpilot.system.ui.widgets import Widget, NavWidget, DialogResult -from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.label import UnifiedLabel from openpilot.system.ui.widgets.mici_keyboard import MiciKeyboard from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.wrap_text import wrap_text from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, MouseEvent from openpilot.system.ui.widgets.scroller import Scroller from openpilot.system.ui.widgets.slider import RedBigSlider, BigSlider from openpilot.common.filter_simple import FirstOrderFilter -from openpilot.selfdrive.ui.mici.widgets.button import BigButton +from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton, BigButton, GreyBigButton from openpilot.selfdrive.ui.mici.widgets.side_button import SideButton DEBUG = False @@ -22,162 +21,80 @@ PADDING = 20 class BigDialogBase(NavWidget, abc.ABC): - def __init__(self, right_btn: str | None = None, right_btn_callback: Callable | None = None): + def __init__(self): super().__init__() - self._ret = DialogResult.NO_ACTION self.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - self.set_back_callback(lambda: setattr(self, '_ret', DialogResult.CANCEL)) - - self._right_btn = None - if right_btn: - def right_btn_callback_wrapper(): - gui_app.set_modal_overlay(None) - if right_btn_callback: - right_btn_callback() - - self._right_btn = SideButton(right_btn) - self._right_btn.set_click_callback(right_btn_callback_wrapper) - # move to right side - self._right_btn._rect.x = self._rect.x + self._rect.width - self._right_btn._rect.width - - def _render(self, _) -> DialogResult: - """ - Allows `gui_app.set_modal_overlay(BigDialog(...))`. - The overlay runner keeps calling until result != NO_ACTION. - """ - if self._right_btn: - self._right_btn.set_position(self._right_btn._rect.x, self._rect.y) - self._right_btn.render() - - return self._ret class BigDialog(BigDialogBase): - def __init__(self, - title: str, - description: str, - right_btn: str | None = None, - right_btn_callback: Callable | None = None): - super().__init__(right_btn, right_btn_callback) - self._title = title - self._description = description + def __init__(self, title: str, description: str, icon: Union[rl.Texture, None] = None): + super().__init__() + self._card = GreyBigButton(title, description, icon) - def _render(self, _) -> DialogResult: - super()._render(_) - - # draw title - # TODO: we desperately need layouts - # TODO: coming up with these numbers manually is a pain and not scalable - # TODO: no clue what any of these numbers mean. VBox and HBox would remove all of this shite - max_width = self._rect.width - PADDING * 2 - if self._right_btn: - max_width -= self._right_btn._rect.width - - title_font_size = 50 - desc_font_size = 30 - title_lines = wrap_text(gui_app.font(FontWeight.BOLD), self._title, title_font_size, int(max_width)) - if not title_lines: - title_lines = [""] - title_line_height = max(int(title_font_size * 1.2), int(measure_text_cached(gui_app.font(FontWeight.BOLD), "Ag", title_font_size).y)) - text_x_offset = 0 - title_x = int(self._rect.x + text_x_offset + PADDING) - title_y = int(self._rect.y + PADDING) - for i, line in enumerate(title_lines): - line_rect = rl.Rectangle( - title_x, - title_y + i * title_line_height, - int(max_width), - int(title_line_height), - ) - gui_label(line_rect, line, title_font_size, font_weight=FontWeight.BOLD, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) - - # draw description - desc_lines = wrap_text(gui_app.font(FontWeight.MEDIUM), self._description, desc_font_size, int(max_width)) - if not desc_lines: - desc_lines = [""] - desc_line_height = max(int(desc_font_size * 1.25), int(measure_text_cached(gui_app.font(FontWeight.MEDIUM), "Ag", desc_font_size).y)) - desc_y = max( - int(self._rect.y + self._rect.height / 3), - title_y + title_line_height * len(title_lines) + 22, - ) - for i, line in enumerate(desc_lines): - line_rect = rl.Rectangle( - title_x, - desc_y + i * desc_line_height, - int(max_width), - int(desc_line_height), - ) - gui_label(line_rect, line, desc_font_size, font_weight=FontWeight.MEDIUM, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP) - - return self._ret + def _render(self, _): + self._card.render(rl.Rectangle( + self._rect.x + self._rect.width / 2 - self._card.rect.width / 2, + self._rect.y + self._rect.height / 2 - self._card.rect.height / 2, + self._card.rect.width, + self._card.rect.height, + )) -class BigConfirmationDialogV2(BigDialogBase): - def __init__(self, title: str, icon: str, red: bool = False, - exit_on_confirm: bool = True, - confirm_callback: Callable | None = None): +class BigConfirmationDialog(BigDialogBase): + def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], + exit_on_confirm: bool = True, red: bool = False): super().__init__() self._confirm_callback = confirm_callback self._exit_on_confirm = exit_on_confirm - icon_txt = gui_app.texture(icon, 64, 53) self._slider: BigSlider | RedBigSlider if red: - self._slider = RedBigSlider(title, icon_txt, confirm_callback=self._on_confirm) + self._slider = self._child(RedBigSlider(title, icon, confirm_callback=self._on_confirm)) else: - self._slider = BigSlider(title, icon_txt, confirm_callback=self._on_confirm) - self._slider.set_enabled(lambda: not self._swiping_away) - - def show_event(self): - super().show_event() - self._slider.show_event() - - def hide_event(self): - super().hide_event() - self._slider.hide_event() + self._slider = self._child(BigSlider(title, icon, confirm_callback=self._on_confirm)) + self._slider.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget def _on_confirm(self): - if self._confirm_callback: - self._confirm_callback() if self._exit_on_confirm: - self._ret = DialogResult.CONFIRM + self.dismiss(self._confirm_callback) + elif self._confirm_callback: + self._confirm_callback() def _update_state(self): super()._update_state() - if self._swiping_away and not self._slider.confirmed: - self._slider.reset(reset_shimmer=False) + if self.is_dismissing and not self._slider.confirmed: + self._slider.reset() - def _render(self, _) -> DialogResult: + def _render(self, _): self._slider.render(self._rect) - return self._ret class BigInputDialog(BigDialogBase): BACK_TOUCH_AREA_PERCENTAGE = 0.2 BACKSPACE_RATE = 25 # hz + TEXT_INPUT_SIZE = 35 def __init__(self, hint: str, default_text: str = "", minimum_length: int = 1, - confirm_callback: Callable[[str], None] = None): - super().__init__(None, None) + confirm_callback: Callable[[str], None] | None = None, + auto_return_to_letters: str = ""): + super().__init__() self._hint_label = UnifiedLabel(hint, font_size=35, text_color=rl.Color(255, 255, 255, int(255 * 0.35)), font_weight=FontWeight.MEDIUM) - self._keyboard = MiciKeyboard() + self._keyboard = MiciKeyboard(auto_return_to_letters=auto_return_to_letters) self._keyboard.set_text(default_text) + self._keyboard.set_enabled(lambda: self.enabled and not self.is_dismissing) # for nav stack + NavWidget self._minimum_length = minimum_length self._backspace_held_time: float | None = None - self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 44, 44) + self._backspace_img = gui_app.texture("icons_mici/settings/keyboard/backspace.png", 42, 36) self._backspace_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) - self._enter_img = gui_app.texture("icons_mici/settings/keyboard/confirm.png", 44, 44) + self._enter_img = gui_app.texture("icons_mici/settings/keyboard/enter.png", 76, 62) + self._enter_disabled_img = gui_app.texture("icons_mici/settings/keyboard/enter_disabled.png", 76, 62) self._enter_img_alpha = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps) # rects for top buttons @@ -185,14 +102,17 @@ class BigInputDialog(BigDialogBase): self._top_right_button_rect = rl.Rectangle(0, 0, 0, 0) def confirm_callback_wrapper(): - self._ret = DialogResult.CONFIRM - if confirm_callback: - confirm_callback(self._keyboard.text()) + text = self._keyboard.text() + self.dismiss((lambda: confirm_callback(text)) if confirm_callback else None) self._confirm_callback = confirm_callback_wrapper def _update_state(self): super()._update_state() + if self.is_dismissing: + self._backspace_held_time = None + return + last_mouse_event = gui_app.last_mouse_event if last_mouse_event.left_down and rl.check_collision_point_rec(last_mouse_event.pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 1: if self._backspace_held_time is None: @@ -206,64 +126,60 @@ class BigInputDialog(BigDialogBase): self._backspace_held_time = None def _render(self, _): - text_input_size = 35 - # draw current text so far below everything. text floats left but always stays in view text = self._keyboard.text() candidate_char = self._keyboard.get_candidate_character() - text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, text_input_size) - text_x = PADDING * 2 + self._enter_img.width + text_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), text + candidate_char or self._hint_label.text, self.TEXT_INPUT_SIZE) - # text needs to move left if we're at the end where right button is - text_rect = rl.Rectangle(text_x, - int(self._rect.y + PADDING), - # clip width to right button when in view - int(self._rect.width - text_x - PADDING * 2 - self._enter_img.width + 5), # TODO: why 5? - int(text_size.y)) - - # draw rounded background for text input bg_block_margin = 5 - text_field_rect = rl.Rectangle(text_rect.x - bg_block_margin, text_rect.y - bg_block_margin, - text_rect.width + bg_block_margin * 2, text_input_size + bg_block_margin * 2) + text_x = PADDING / 2 + self._enter_img.width + PADDING + text_field_rect = rl.Rectangle(text_x, self._rect.y + PADDING - bg_block_margin, + self._rect.width - text_x * 2, + text_size.y) # draw text input # push text left with a gradient on left side if too long - if text_size.x > text_rect.width: - text_x -= text_size.x - text_rect.width + if text_size.x > text_field_rect.width: + text_x -= text_size.x - text_field_rect.width - rl.begin_scissor_mode(int(text_rect.x), int(text_rect.y), int(text_rect.width), int(text_rect.height)) - rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_rect.y), text_input_size, 0, rl.WHITE) + rl.begin_scissor_mode(int(text_field_rect.x), int(text_field_rect.y), int(text_field_rect.width), int(text_field_rect.height)) + rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), text, rl.Vector2(text_x, text_field_rect.y), self.TEXT_INPUT_SIZE, 0, rl.WHITE) # draw grayed out character user is hovering over if candidate_char: - candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, text_input_size) + candidate_char_size = measure_text_cached(gui_app.font(FontWeight.ROMAN), candidate_char, self.TEXT_INPUT_SIZE) rl.draw_text_ex(gui_app.font(FontWeight.ROMAN), candidate_char, - rl.Vector2(min(text_x + text_size.x, text_rect.x + text_rect.width) - candidate_char_size.x, text_rect.y), - text_input_size, 0, rl.Color(255, 255, 255, 128)) + rl.Vector2(min(text_x + text_size.x, text_field_rect.x + text_field_rect.width) - candidate_char_size.x, text_field_rect.y), + self.TEXT_INPUT_SIZE, 0, rl.Color(255, 255, 255, 128)) rl.end_scissor_mode() # draw gradient on left side to indicate more text - if text_size.x > text_rect.width: - rl.draw_rectangle_gradient_h(int(text_rect.x), int(text_rect.y), 80, int(text_rect.height), - rl.BLACK, rl.BLANK) + if text_size.x > text_field_rect.width: + rl.draw_rectangle_gradient_ex(rl.Rectangle(text_field_rect.x, text_field_rect.y, 80, text_field_rect.height), + rl.BLACK, rl.BLANK, rl.BLANK, rl.BLACK) # draw cursor + blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 if text: - blink_alpha = (math.sin(rl.get_time() * 6) + 1) / 2 - cursor_x = min(text_x + text_size.x + 3, text_rect.x + text_rect.width) - rl.draw_rectangle_rounded(rl.Rectangle(int(cursor_x), int(text_rect.y), 4, int(text_size.y)), - 1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha))) + cursor_x = min(text_x + text_size.x + 3, text_field_rect.x + text_field_rect.width) + else: + cursor_x = text_field_rect.x - 6 + rl.draw_rectangle_rounded(rl.Rectangle(cursor_x, text_field_rect.y, 4, text_size.y), + 1, 4, rl.Color(255, 255, 255, int(255 * blink_alpha))) # draw backspace icon with nice fade self._backspace_img_alpha.update(255 * bool(text)) if self._backspace_img_alpha.x > 1: color = rl.Color(255, 255, 255, int(self._backspace_img_alpha.x)) - rl.draw_texture(self._backspace_img, int(self._rect.width - self._enter_img.width - 15), int(text_field_rect.y), color) + rl.draw_texture_ex(self._backspace_img, rl.Vector2(self._rect.width - self._backspace_img.width - 27, self._rect.y + 14), 0.0, 1.0, color) if not text and self._hint_label.text and not candidate_char: # draw description if no text entered yet and not drawing candidate char - self._hint_label.render(text_field_rect) + hint_rect = rl.Rectangle(text_field_rect.x, text_field_rect.y, + self._rect.width - text_field_rect.x - PADDING, + text_field_rect.height) + self._hint_label.render(hint_rect) # TODO: move to update state # make rect take up entire area so it's easier to click @@ -271,10 +187,12 @@ class BigInputDialog(BigDialogBase): self._top_right_button_rect = rl.Rectangle(text_field_rect.x + text_field_rect.width, self._rect.y, self._rect.width - (text_field_rect.x + text_field_rect.width), self._top_left_button_rect.height) - self._enter_img_alpha.update(255 if (len(text) >= self._minimum_length) else 255 * 0.35) - if self._enter_img_alpha.x > 1: - color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) - rl.draw_texture(self._enter_img, int(self._rect.x + 15), int(text_field_rect.y), color) + # draw enter button + self._enter_img_alpha.update(255 if len(text) >= self._minimum_length else 0) + color = rl.Color(255, 255, 255, int(self._enter_img_alpha.x)) + rl.draw_texture_ex(self._enter_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) + color = rl.Color(255, 255, 255, 255 - int(self._enter_img_alpha.x)) + rl.draw_texture_ex(self._enter_disabled_img, rl.Vector2(self._rect.x + PADDING / 2, self._rect.y), 0.0, 1.0, color) # keyboard goes over everything self._keyboard.render(self._rect) @@ -282,16 +200,17 @@ class BigInputDialog(BigDialogBase): # draw debugging rect bounds if DEBUG: rl.draw_rectangle_lines_ex(text_field_rect, 1, rl.Color(100, 100, 100, 255)) - rl.draw_rectangle_lines_ex(text_rect, 1, rl.Color(0, 255, 0, 255)) rl.draw_rectangle_lines_ex(self._top_right_button_rect, 1, rl.Color(0, 255, 0, 255)) rl.draw_rectangle_lines_ex(self._top_left_button_rect, 1, rl.Color(0, 255, 0, 255)) - return self._ret - def _handle_mouse_press(self, mouse_pos: MousePos): super()._handle_mouse_press(mouse_pos) # TODO: need to track where press was so enter and back can activate on release rather than press # or turn into icon widgets :eyes_open: + + if self.is_dismissing: + return + # handle backspace icon click if rl.check_collision_point_rec(mouse_pos, self._top_right_button_rect) and self._backspace_img_alpha.x > 254: self._keyboard.backspace() @@ -300,6 +219,30 @@ class BigInputDialog(BigDialogBase): self._confirm_callback() +class BigDialogButton(BigButton): + def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""): + super().__init__(text, value, icon) + self._description = description + + def _handle_mouse_release(self, mouse_pos: MousePos): + super()._handle_mouse_release(mouse_pos) + + dlg = BigDialog(self.text, self._description) + gui_app.push_widget(dlg) + + +class BigConfirmationCircleButton(BigCircleButton): + def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable[[], None], exit_on_confirm: bool = True, + red: bool = False, icon_offset: tuple[int, int] = (0, 0)): + super().__init__(icon, red, icon_offset) + + def show_confirm_dialog(): + gui_app.push_widget(BigConfirmationDialog(title, icon, confirm_callback, + exit_on_confirm=exit_on_confirm, red=red)) + + self.set_click_callback(show_confirm_dialog) + + class BigDialogOptionButton(Widget): HEIGHT = 64 SELECTED_HEIGHT = 74 @@ -308,12 +251,15 @@ class BigDialogOptionButton(Widget): super().__init__() self.option = option self.set_rect(rl.Rectangle(0, 0, int(gui_app.width / 2 + 220), self.HEIGHT)) - self._selected = False - - self._label = UnifiedLabel(option, font_size=70, text_color=rl.Color(255, 255, 255, int(255 * 0.58)), - font_weight=FontWeight.DISPLAY_REGULAR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - scroll=True) + self._label = UnifiedLabel( + option, + font_size=70, + text_color=rl.Color(255, 255, 255, int(255 * 0.58)), + font_weight=FontWeight.DISPLAY_REGULAR, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, + scroll=True, + ) def show_event(self): super().show_event() @@ -324,10 +270,6 @@ class BigDialogOptionButton(Widget): self._rect.height = self.SELECTED_HEIGHT if selected else self.HEIGHT def _render(self, _): - if DEBUG: - rl.draw_rectangle_lines_ex(self._rect, 1, rl.Color(0, 255, 0, 255)) - - # FIXME: offset x by -45 because scroller centers horizontally if self._selected: self._label.set_font_size(self.SELECTED_HEIGHT) self._label.set_color(rl.Color(255, 255, 255, int(255 * 0.9))) @@ -340,56 +282,63 @@ class BigDialogOptionButton(Widget): self._label.render(self._rect) -class BigMultiOptionDialog(BigDialogBase): - BACK_TOUCH_AREA_PERCENTAGE = 0.1 +class BigMultiOptionDialog(NavWidget): + BACK_TOUCH_AREA_PERCENTAGE = 0.25 def __init__(self, options: list[str], default: str | None, - right_btn: str | None = 'check', right_btn_callback: Callable[[], None] = None): - super().__init__(right_btn, right_btn_callback=right_btn_callback) + right_btn: str | None = "check", right_btn_callback: Callable[[], None] | None = None): + super().__init__() self._options = options - if default is not None: - assert default in options + if default not in options: + default = options[0] if options else None + self._right_btn_callback = right_btn_callback self._default_option: str | None = default - self._selected_option: str = self._default_option or (options[0] if len(options) > 0 else "") + self._selected_option: str = self._default_option or (options[0] if options else "") self._last_selected_option: str = self._selected_option - - # Widget doesn't differentiate between click and drag self._can_click = True - self._scroller = Scroller([], horizontal=False, pad_start=100, pad_end=100, spacing=0, snap_items=True) + self._scroller = self._child(Scroller(horizontal=False, pad=100, spacing=0, snap_items=True, + scroll_indicator=False, edge_shadows=False)) + self._scroll_inner = self._scroller._scroller + + self._right_btn = self._child(SideButton(right_btn or "check")) if right_btn is not None else None if self._right_btn is not None: - self._scroller.set_enabled(lambda: not cast(Widget, self._right_btn).is_pressed) + self._right_btn.set_click_callback(self._confirm_selection) + self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing and not self._right_btn.is_pressed) + else: + self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing) for option in options: - self._scroller.add_widget(BigDialogOptionButton(option)) + self._scroll_inner.add_widget(BigDialogOptionButton(option)) def show_event(self): super().show_event() - self._scroller.show_event() if self._default_option is not None: self._on_option_selected(self._default_option) def get_selected_option(self) -> str: return self._selected_option + def _confirm_selection(self): + self.dismiss(self._right_btn_callback) + def _on_option_selected(self, option: str): y_pos = 0.0 - for btn in self._scroller._items: + for btn in self._scroll_inner.items: btn = cast(BigDialogOptionButton, btn) if btn.option == option: rect_center_y = self._rect.y + self._rect.height / 2 if btn._selected: height = btn.rect.height else: - # when selecting an option under current, account for changing heights - btn_center_y = btn.rect.y + btn.rect.height / 2 # not accurate, just to determine direction + btn_center_y = btn.rect.y + btn.rect.height / 2 height_offset = BigDialogOptionButton.SELECTED_HEIGHT - BigDialogOptionButton.HEIGHT height = (BigDialogOptionButton.HEIGHT - height_offset) if rect_center_y < btn_center_y else BigDialogOptionButton.SELECTED_HEIGHT y_pos = rect_center_y - (btn.rect.y + height / 2) break - self._scroller.scroll_to(-y_pos) + self._scroll_inner.scroll_to(-y_pos) def _selected_option_changed(self): pass @@ -400,9 +349,7 @@ class BigMultiOptionDialog(BigDialogBase): def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: super()._handle_mouse_event(mouse_event) - - # # TODO: add generic _handle_mouse_click handler to Widget - if not self._scroller.scroll_panel.is_touch_valid(): + if not self._scroll_inner.scroll_panel.is_touch_valid(): self._can_click = False def _handle_mouse_release(self, mouse_pos: MousePos): @@ -411,8 +358,7 @@ class BigMultiOptionDialog(BigDialogBase): if not self._can_click: return - # select current option - for btn in self._scroller._items: + for btn in self._scroll_inner.items: btn = cast(BigDialogOptionButton, btn) if btn.option == self._selected_option: self._on_option_selected(btn.option) @@ -420,39 +366,27 @@ class BigMultiOptionDialog(BigDialogBase): def _update_state(self): super()._update_state() + if not self.is_dismissing: + self._nav_bar.set_alpha(1.0) - # get selection by whichever button is closest to center center_y = self._rect.y + self._rect.height / 2 - closest_btn = (None, float('inf')) - for btn in self._scroller._items: + closest_btn = (None, float("inf")) + for btn in self._scroll_inner.items: dist_y = abs((btn.rect.y + btn.rect.height / 2) - center_y) if dist_y < closest_btn[1]: closest_btn = (btn, dist_y) - if closest_btn[0]: - for btn in self._scroller._items: + if closest_btn[0] is not None: + for btn in self._scroll_inner.items: btn.set_selected(btn.option == closest_btn[0].option) self._selected_option = closest_btn[0].option - # Signal to subclasses if selection changed if self._selected_option != self._last_selected_option: self._selected_option_changed() self._last_selected_option = self._selected_option def _render(self, _): - super()._render(_) + if self._right_btn is not None: + self._right_btn.set_position(self._rect.x + self._rect.width - self._right_btn.rect.width, self._rect.y) + self._right_btn.render() self._scroller.render(self._rect) - - return self._ret - - -class BigDialogButton(BigButton): - def __init__(self, text: str, value: str = "", icon: Union[str, rl.Texture] = "", description: str = ""): - super().__init__(text, value, icon) - self._description = description - - def _handle_mouse_release(self, mouse_pos: MousePos): - super()._handle_mouse_release(mouse_pos) - - dlg = BigDialog(self.text, self._description) - gui_app.set_modal_overlay(dlg) diff --git a/selfdrive/ui/mici/widgets/pairing_dialog.py b/selfdrive/ui/mici/widgets/pairing_dialog.py index e064205d5..a18b26ec0 100644 --- a/selfdrive/ui/mici/widgets/pairing_dialog.py +++ b/selfdrive/ui/mici/widgets/pairing_dialog.py @@ -7,9 +7,9 @@ from openpilot.common.api import Api from openpilot.common.swaglog import cloudlog from openpilot.common.params import Params from openpilot.selfdrive.ui.ui_state import ui_state -from openpilot.system.ui.widgets import NavWidget +from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.lib.application import FontWeight, gui_app -from openpilot.system.ui.widgets.label import MiciLabel +from openpilot.system.ui.widgets.label import UnifiedLabel class PairingDialog(NavWidget): @@ -19,14 +19,12 @@ class PairingDialog(NavWidget): def __init__(self): super().__init__() - self.set_back_callback(lambda: gui_app.set_modal_overlay(None)) self._params = Params() self._qr_texture: rl.Texture | None = None self._last_qr_generation = float("-inf") - self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 84, 64) - self._pair_label = MiciLabel("pair with comma connect", 48, font_weight=FontWeight.BOLD, - color=rl.Color(255, 255, 255, int(255 * 0.9)), line_height=40, wrap_text=True) + self._txt_pair = gui_app.texture("icons_mici/settings/device/pair.png", 33, 60) + self._pair_label = UnifiedLabel("pair with comma connect", font_size=48, font_weight=FontWeight.BOLD, line_height=0.8) def _get_pairing_url(self) -> str: try: @@ -69,24 +67,22 @@ class PairingDialog(NavWidget): def _update_state(self): super()._update_state() - if ui_state.prime_state.is_paired(): - self._playing_dismiss_animation = True + if ui_state.prime_state.is_paired() and not self.is_dismissing: + self.dismiss() - def _render(self, rect: rl.Rectangle) -> int: + def _render(self, rect: rl.Rectangle): self._check_qr_refresh() self._render_qr_code() label_x = self._rect.x + 8 + self._rect.height + 24 - self._pair_label.set_width(int(self._rect.width - label_x)) + self._pair_label.set_max_width(int(self._rect.width - label_x)) self._pair_label.set_position(label_x, self._rect.y + 16) self._pair_label.render() rl.draw_texture_ex(self._txt_pair, rl.Vector2(label_x, self._rect.y + self._rect.height - self._txt_pair.height - 16), 0.0, 1.0, rl.Color(255, 255, 255, int(255 * 0.35))) - return -1 - def _render_qr_code(self) -> None: if not self._qr_texture: error_font = gui_app.font(FontWeight.BOLD) @@ -96,7 +92,7 @@ class PairingDialog(NavWidget): return scale = self._rect.height / self._qr_texture.height - pos = rl.Vector2(self._rect.x + 8, self._rect.y) + pos = rl.Vector2(round(self._rect.x + 8), round(self._rect.y)) rl.draw_texture_ex(self._qr_texture, pos, 0.0, scale, rl.WHITE) def __del__(self): @@ -107,10 +103,9 @@ class PairingDialog(NavWidget): if __name__ == "__main__": gui_app.init_window("pairing device") pairing = PairingDialog() + gui_app.push_widget(pairing) try: for _ in gui_app.render(): - result = pairing.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - if result != -1: - break + pass finally: del pairing diff --git a/selfdrive/ui/mici/widgets/side_button.py b/selfdrive/ui/mici/widgets/side_button.py index 4803b6d20..f03ac00cc 100644 --- a/selfdrive/ui/mici/widgets/side_button.py +++ b/selfdrive/ui/mici/widgets/side_button.py @@ -1,11 +1,8 @@ import pyray as rl + from openpilot.system.ui.widgets import Widget from openpilot.system.ui.lib.application import gui_app -# --------------------------------------------------------------------------- -# Constants extracted from the original Qt style -# --------------------------------------------------------------------------- -# TODO: this should be corrected, but Scroller relies on this being incorrect :/ WIDTH, HEIGHT = 112, 240 @@ -15,17 +12,15 @@ class SideButton(Widget): self.type = btn_type self.set_rect(rl.Rectangle(0, 0, WIDTH, HEIGHT)) - # load pre-rendered button images if btn_type not in ("check", "back"): btn_type = "back" btn_img_path = f"icons_mici/buttons/button_side_{btn_type}.png" btn_img_pressed_path = f"icons_mici/buttons/button_side_{btn_type}_pressed.png" - self._txt_btn, self._txt_btn_back = gui_app.texture(btn_img_path, 100, 224), gui_app.texture(btn_img_pressed_path, 100, 224) + self._txt_btn = gui_app.texture(btn_img_path, 100, 224) + self._txt_btn_back = gui_app.texture(btn_img_pressed_path, 100, 224) - def _render(self, _) -> bool: + def _render(self, _): x = int(self._rect.x + 12) y = int(self._rect.y + (self._rect.height - self._txt_btn.height) / 2) - rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back, - x, y, rl.WHITE) - + rl.draw_texture(self._txt_btn if not self.is_pressed else self._txt_btn_back, x, y, rl.WHITE) return False diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 519593198..913ca3c98 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -325,6 +325,7 @@ class AugmentedRoadView(CameraView): if __name__ == "__main__": gui_app.init_window("OnRoad Camera View") road_camera_view = AugmentedRoadView(ROAD_CAM) + gui_app.push_widget(road_camera_view) print("***press space to switch camera view***") try: for _ in gui_app.render(): @@ -333,6 +334,5 @@ if __name__ == "__main__": if WIDE_CAM in road_camera_view.available_streams: stream = ROAD_CAM if road_camera_view.stream_type == WIDE_CAM else WIDE_CAM road_camera_view.switch_stream(stream) - road_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: road_camera_view.close() diff --git a/selfdrive/ui/onroad/driver_camera_dialog.py b/selfdrive/ui/onroad/driver_camera_dialog.py index f69ad8c49..e66e04b82 100644 --- a/selfdrive/ui/onroad/driver_camera_dialog.py +++ b/selfdrive/ui/onroad/driver_camera_dialog.py @@ -14,7 +14,7 @@ class DriverCameraDialog(CameraView): super().__init__("camerad", VisionStreamType.VISION_STREAM_DRIVER) self.driver_state_renderer = DriverStateRenderer() # TODO: this can grow unbounded, should be given some thought - device.add_interactive_timeout_callback(lambda: gui_app.set_modal_overlay(None)) + device.add_interactive_timeout_callback(gui_app.pop_widget) ui_state.params.put_bool("IsDriverViewEnabled", True) def hide_event(self): @@ -24,7 +24,7 @@ class DriverCameraDialog(CameraView): def _handle_mouse_release(self, _): super()._handle_mouse_release(_) - gui_app.set_modal_overlay(None) + gui_app.pop_widget() def __del__(self): self.close() @@ -103,9 +103,9 @@ if __name__ == "__main__": gui_app.init_window("Driver Camera View") driver_camera_view = DriverCameraDialog() + gui_app.push_widget(driver_camera_view) try: for _ in gui_app.render(): ui_state.update() - driver_camera_view.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) finally: driver_camera_view.close() diff --git a/selfdrive/ui/onroad/exp_button.py b/selfdrive/ui/onroad/exp_button.py index 2dd3caf7b..a3047e280 100644 --- a/selfdrive/ui/onroad/exp_button.py +++ b/selfdrive/ui/onroad/exp_button.py @@ -63,7 +63,7 @@ class ExpButton(Widget): texture = self._txt_exp if self._held_or_actual_mode() else self._txt_wheel rl.draw_circle(center_x, center_y, self._rect.width / 2, self._black_bg) - rl.draw_texture(texture, center_x - texture.width // 2, center_y - texture.height // 2, self._white_color) + rl.draw_texture_ex(texture, rl.Vector2(center_x - texture.width / 2, center_y - texture.height / 2), 0.0, 1.0, self._white_color) def _held_or_actual_mode(self): now = time.monotonic() diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index 79f150dee..73df8b396 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -86,7 +86,7 @@ class HudRenderer(Widget): v_cruise_cluster = car_state.vCruiseCluster self.set_speed = ( - controls_state.vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster + controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster ) self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA self.is_cruise_available = self.set_speed != -1 diff --git a/selfdrive/ui/ui.py b/selfdrive/ui/ui.py old mode 100755 new mode 100644 index 185f82345..99e482105 --- a/selfdrive/ui/ui.py +++ b/selfdrive/ui/ui.py @@ -1,55 +1,35 @@ #!/usr/bin/env python3 import os -import pyray as rl from openpilot.system.hardware import TICI from openpilot.common.realtime import config_realtime_process, set_core_affinity -from openpilot.common.watchdog import kick_watchdog from openpilot.system.ui.lib.application import gui_app -from openpilot.selfdrive.ui.stall_monitor import UIStallMonitor -from openpilot.selfdrive.ui.layouts.main import MainLayout -from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout from openpilot.selfdrive.ui.ui_state import ui_state +BIG_UI = gui_app.big_ui() + def main(): cores = {5, } config_realtime_process(0, 51) gui_app.init_window("UI") - if gui_app.big_ui(): - main_layout = MainLayout() + if BIG_UI: + from openpilot.selfdrive.ui.layouts.main import MainLayout + MainLayout() else: - main_layout = MiciMainLayout() - main_layout.set_rect(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - stall_monitor = UIStallMonitor("raylib_ui") - gui_app.set_progress_hook(stall_monitor.progress) - stall_monitor.progress("ui.loop_ready") - stall_monitor.start() + from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout + MiciMainLayout() - def render_layout() -> None: - stall_monitor.progress("ui.before_layout_render") - main_layout.render() - stall_monitor.progress("ui.after_layout_render") - - try: - for should_render in gui_app.render(render_callback=render_layout): - stall_monitor.progress("ui.loop_iteration") - kick_watchdog() - stall_monitor.progress("ui.after_watchdog") - ui_state.update() - stall_monitor.progress("ui.after_state_update") - if should_render: - # reaffine after power save offlines our core - if TICI and os.sched_getaffinity(0) != cores: - try: - set_core_affinity(list(cores)) - except OSError: - pass - stall_monitor.progress("ui.loop_idle") - finally: - gui_app.set_progress_hook(None) - stall_monitor.stop() + for should_render in gui_app.render(): + ui_state.update() + if should_render: + # reaffine after power save offlines our core + if TICI and os.sched_getaffinity(0) != cores: + try: + set_core_affinity(list(cores)) + except OSError: + pass if __name__ == "__main__": diff --git a/selfdrive/ui/widgets/ssh_key.py b/selfdrive/ui/widgets/ssh_key.py index 88389cb05..29af2ee02 100644 --- a/selfdrive/ui/widgets/ssh_key.py +++ b/selfdrive/ui/widgets/ssh_key.py @@ -25,6 +25,52 @@ from openpilot.system.ui.widgets.list_view import ( VALUE_FONT_SIZE = 48 +class SshKeyFetcher: + HTTP_TIMEOUT = 15 # seconds + + def __init__(self, params: Params): + self._params = params + self._on_response: Callable[[str | None], None] | None = None + self._done = False + self._error: str | None = None + + def fetch(self, username: str, on_response: Callable[[str | None], None]): + self._error = None + self._done = False + self._on_response = on_response + threading.Thread(target=self._fetch_thread, args=(username,), daemon=True).start() + + def update(self): + if not self._done: + return + self._done = False + if self._error is not None: + self.clear() + if self._on_response: + self._on_response(self._error) + + def clear(self): + self._params.remove("GithubUsername") + self._params.remove("GithubSshKeys") + + def _fetch_thread(self, username: str): + try: + response = requests.get(f"https://github.com/{username}.keys", timeout=self.HTTP_TIMEOUT) + response.raise_for_status() + keys = response.text.strip() + if not keys: + raise requests.exceptions.HTTPError("No SSH keys found") + + self._params.put("GithubUsername", username) + self._params.put("GithubSshKeys", keys) + except requests.exceptions.Timeout: + self._error = tr("Request timed out") + except Exception: + self._error = tr("No SSH keys found for user '{}'").format(username) + finally: + self._done = True + + class SshKeyActionState(Enum): LOADING = tr_noop("LOADING") ADD = tr_noop("ADD") diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 72ffaa2cf..980410b02 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -1,6 +1,8 @@ import atexit import cffi +import math import os +import queue import time import signal import sys @@ -11,7 +13,6 @@ import subprocess from contextlib import contextmanager from collections.abc import Callable from collections import deque -from dataclasses import dataclass from enum import StrEnum from pathlib import Path from typing import NamedTuple @@ -40,6 +41,10 @@ PROFILE_RENDER = int(os.getenv("PROFILE_RENDER", "0")) PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output RECORD = os.getenv("RECORD") == "1" RECORD_OUTPUT = str(Path(os.getenv("RECORD_OUTPUT", "output")).with_suffix(".mp4")) +RECORD_QUALITY = int(os.getenv("RECORD_QUALITY", "23")) # Dynamic bitrate quality level (CRF); 0 is lossless (bigger size), max is 51, default is 23 for x264 +RECORD_BITRATE = os.getenv("RECORD_BITRATE", "") # Target bitrate e.g. "2000k" (overrides RECORD_QUALITY when set) +RECORD_SPEED = int(os.getenv("RECORD_SPEED", "1")) # Speed multiplier +OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offline rendering GL_VERSION = """ #version 300 es @@ -51,9 +56,7 @@ if platform.system() == "Darwin": """ BURN_IN_MODE = "BURN_IN" in os.environ -BURN_IN_VERTEX_SHADER = ( - GL_VERSION - + """ +BURN_IN_VERTEX_SHADER = GL_VERSION + """ in vec3 vertexPosition; in vec2 vertexTexCoord; uniform mat4 mvp; @@ -63,10 +66,7 @@ void main() { gl_Position = mvp * vec4(vertexPosition, 1.0); } """ -) -BURN_IN_FRAGMENT_SHADER = ( - GL_VERSION - + """ +BURN_IN_FRAGMENT_SHADER = GL_VERSION + """ in vec2 fragTexCoord; uniform sampler2D texture0; out vec4 fragColor; @@ -82,7 +82,6 @@ void main() { fragColor = vec4(gradient, sampled.a); } """ -) DEFAULT_TEXT_SIZE = 60 DEFAULT_TEXT_COLOR = rl.Color(255, 255, 255, int(255 * 0.9)) @@ -96,13 +95,10 @@ FONT_DIR = ASSETS_DIR.joinpath("fonts") class FontWeight(StrEnum): - LIGHT = "Inter-Light.fnt" NORMAL = "Inter-Regular.fnt" if BIG_UI else "Inter-Medium.fnt" MEDIUM = "Inter-Medium.fnt" BOLD = "Inter-Bold.fnt" SEMI_BOLD = "Inter-SemiBold.fnt" - EXTRA_BOLD = "Inter-ExtraBold.fnt" - BLACK = "Inter-Black.fnt" UNIFONT = "unifont.fnt" # Small UI fonts @@ -118,12 +114,6 @@ def font_fallback(font: rl.Font) -> rl.Font: return font -@dataclass -class ModalOverlay: - overlay: object = None - callback: Callable | None = None - - class MousePos(NamedTuple): x: float y: float @@ -179,6 +169,10 @@ class MouseState: self._rk.keep_time() def _handle_mouse_event(self): + # TODO: read touch events from evdev directly to get real kernel timestamps. + # Polling at 140Hz with time.monotonic() causes timing jitter that makes scroll + # velocity oscillate (alternating high/low). Real timestamps would also let us + # detect swipe-stop-lift via event gaps instead of the fragile decel heuristic. for slot in range(MAX_TOUCH_SLOTS): mouse_pos = rl.get_touch_position(slot) x = mouse_pos.x / self._scale if self._scale != 1.0 else mouse_pos.x @@ -192,7 +186,8 @@ class MouseState: time.monotonic(), ) # Only add changes - if self._prev_mouse_event[slot] is None or ev[:-1] != self._prev_mouse_event[slot][:-1]: + prev = self._prev_mouse_event[slot] + if prev is None or ev[:-1] != prev[:-1]: with self._lock: self._events.append(ev) self._prev_mouse_event[slot] = ev @@ -200,6 +195,8 @@ class MouseState: class GuiApplication: def __init__(self, width: int | None = None, height: int | None = None): + self._set_log_callback() + self._fonts: dict[FontWeight, rl.Font] = {} self._width = width if width is not None else GuiApplication._default_width() self._height = height if height is not None else GuiApplication._default_height() @@ -218,17 +215,17 @@ class GuiApplication: self._render_texture: rl.RenderTexture | None = None self._burn_in_shader: rl.Shader | None = None self._ffmpeg_proc: subprocess.Popen | None = None + self._ffmpeg_queue: queue.Queue | None = None + self._ffmpeg_thread: threading.Thread | None = None + self._ffmpeg_stop_event: threading.Event | None = None self._textures: dict[str, rl.Texture] = {} self._target_fps: int = _DEFAULT_FPS self._last_fps_log_time: float = time.monotonic() self._frame = 0 self._window_close_requested = False - self._trace_log_callback = None - self._progress_hook: Callable[[str], None] | None = None - self._modal_overlay = ModalOverlay() - self._modal_overlay_shown = False - self._modal_overlay_tick: Callable[[], None] | None = None - self._nav_stack: list = [] + self._nav_stack: list[object] = [] + self._nav_stack_ticks: list[Callable[[], None]] = [] + self._nav_stack_widgets_to_render = 1 if self.big_ui() else 2 self._mouse = MouseState(self._scale) self._mouse_events: list[MouseEvent] = [] @@ -255,6 +252,10 @@ class GuiApplication: def set_show_fps(self, show: bool): self._show_fps = show + @property + def show_touches(self) -> bool: + return self._show_touches + @property def target_fps(self): return self._target_fps @@ -262,31 +263,14 @@ class GuiApplication: def request_close(self): self._window_close_requested = True - def set_progress_hook(self, hook: Callable[[str], None] | None): - self._progress_hook = hook - - def _mark_progress(self, phase: str): - if self._progress_hook is None: - return - - try: - self._progress_hook(phase) - except Exception: - pass - def init_window(self, title: str, fps: int = _DEFAULT_FPS): with self._startup_profile_context(): - def _close(sig, frame): self.close() sys.exit(0) - signal.signal(signal.SIGINT, _close) atexit.register(self.close) - self._set_log_callback() - rl.set_trace_log_level(rl.TraceLogLevel.LOG_WARNING) - flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT if ENABLE_VSYNC: flags |= rl.ConfigFlags.FLAG_VSYNC_HINT @@ -298,44 +282,48 @@ class GuiApplication: if self._scale != 1.0: rl.set_mouse_scale(1 / self._scale, 1 / self._scale) if needs_render_texture: - self._render_texture = rl.load_render_texture(self._width, self._height) + self._render_texture = rl.load_render_texture(self._scaled_width, self._scaled_height) rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) if RECORD: + output_fps = fps * RECORD_SPEED ffmpeg_args = [ 'ffmpeg', - '-v', - 'warning', # Reduce ffmpeg log spam - '-stats', # Show encoding progress - '-f', - 'rawvideo', # Input format - '-pix_fmt', - 'rgba', # Input pixel format - '-s', - f'{self._width}x{self._height}', # Input resolution - '-r', - str(fps), # Input frame rate - '-i', - 'pipe:0', # Input from stdin - '-vf', - 'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p - '-c:v', - 'libx264', # Video codec - '-preset', - 'ultrafast', # Encoding speed - '-y', # Overwrite existing file - '-f', - 'mp4', # Output format - RECORD_OUTPUT, # Output file path + '-v', 'warning', # Reduce ffmpeg log spam + '-nostats', # Suppress encoding progress + '-f', 'rawvideo', # Input format + '-pix_fmt', 'rgba', # Input pixel format + '-s', f'{self._scaled_width}x{self._scaled_height}', # Input resolution + '-r', str(fps), # Input frame rate + '-i', 'pipe:0', # Input from stdin + '-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p + '-r', str(output_fps), # Output frame rate (for speed multiplier) + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', str(RECORD_QUALITY) + ] + if RECORD_BITRATE: + # NOTE: custom bitrate overrides crf setting + ffmpeg_args += ['-b:v', RECORD_BITRATE, '-maxrate', RECORD_BITRATE, '-bufsize', RECORD_BITRATE] + ffmpeg_args += [ + '-y', # Overwrite existing file + '-f', 'mp4', # Output format + RECORD_OUTPUT, # Output file path ] self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE) + self._ffmpeg_queue = queue.Queue(maxsize=60) # Buffer up to 60 frames + self._ffmpeg_stop_event = threading.Event() + self._ffmpeg_thread = threading.Thread(target=self._ffmpeg_writer_thread, daemon=True) + self._ffmpeg_thread.start() - rl.set_target_fps(fps) + # OFFSCREEN disables FPS limiting for fast offline rendering (e.g. clips) + rl.set_target_fps(0 if OFFSCREEN else fps) self._target_fps = fps self._set_styles() self._load_fonts() self._patch_text_functions() + self._patch_scissor_mode() if BURN_IN_MODE and self._burn_in_shader is None: self._burn_in_shader = rl.load_shader_from_memory(BURN_IN_VERTEX_SHADER, BURN_IN_FRAGMENT_SHADER) @@ -372,93 +360,132 @@ class GuiApplication: print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}") sys.exit(0) - def set_modal_overlay(self, overlay, callback: Callable | None = None): - if self._modal_overlay.overlay is not None: - if hasattr(self._modal_overlay.overlay, 'hide_event'): - self._modal_overlay.overlay.hide_event() + def _ffmpeg_writer_thread(self): + """Background thread that writes frames to ffmpeg.""" + while True: + try: + data = self._ffmpeg_queue.get(timeout=1.0) + if data is None: # Sentinel to stop + break + self._ffmpeg_proc.stdin.write(data) + except queue.Empty: + if self._ffmpeg_stop_event.is_set(): + break + continue + except Exception: + break - if self._modal_overlay.callback is not None: - self._modal_overlay.callback(-1) - - self._modal_overlay = ModalOverlay(overlay=overlay, callback=callback) - - def set_modal_overlay_tick(self, tick_function: Callable | None): - self._modal_overlay_tick = tick_function - - def push_widget(self, widget): + def push_widget(self, widget: object): if widget in self._nav_stack: + cloudlog.warning("Widget already in stack, cannot push again!") return - if self._nav_stack: - prev = self._nav_stack[-1] - if hasattr(prev, 'set_enabled'): - prev.set_enabled(False) + + # disable previous widget to prevent input processing + if len(self._nav_stack) > 0: + prev_widget = self._nav_stack[-1] + # TODO: change these to touch_valid + prev_widget.set_enabled(False) + self._nav_stack.append(widget) - if hasattr(widget, 'show_event'): - widget.show_event() - if hasattr(widget, 'set_enabled'): - widget.set_enabled(True) + widget.show_event() + widget.set_enabled(True) def pop_widget(self, idx: int | None = None): + # Pops widget instantly without animation if len(self._nav_stack) < 2: + cloudlog.warning("At least one widget should remain on the stack, ignoring pop!") return + idx_to_pop = len(self._nav_stack) - 1 if idx is None else idx if idx_to_pop <= 0 or idx_to_pop >= len(self._nav_stack): + cloudlog.warning(f"Invalid index {idx_to_pop} to pop, ignoring!") return - if idx_to_pop == len(self._nav_stack) - 1: - prev = self._nav_stack[idx_to_pop - 1] - if hasattr(prev, 'set_enabled'): - prev.set_enabled(True) - widget = self._nav_stack.pop(idx_to_pop) - if hasattr(widget, 'hide_event'): - widget.hide_event() - def _render_nav_stack(self) -> bool: - if not self._nav_stack: - return False - widget = self._nav_stack[-1] - if hasattr(widget, 'render'): - widget.render(rl.Rectangle(0, 0, self.width, self.height)) - return True + # only re-enable previous widget if popping top widget + if idx_to_pop == len(self._nav_stack) - 1: + prev_widget = self._nav_stack[idx_to_pop - 1] + prev_widget.set_enabled(True) + + widget = self._nav_stack.pop(idx_to_pop) + widget.hide_event() + + def pop_widgets_to(self, widget: object, callback: Callable[[], None] | None = None, instant: bool = False): + # Pops middle widgets instantly without animation then dismisses top, animated out if NavWidget + if widget not in self._nav_stack: + cloudlog.warning("Widget not in stack, cannot pop to it!") + return + + # Nothing to pop, ensure we still run callback + top_widget = self._nav_stack[-1] + if top_widget == widget: + if callback: + callback() + return + + # instantly pop widgets in between, then dismiss top widget for animation + while len(self._nav_stack) > 1 and self._nav_stack[-2] != widget: + self.pop_widget(len(self._nav_stack) - 2) + + if not instant: + top_widget.dismiss(callback) + else: + self.pop_widget() + + def get_active_widget(self): + if len(self._nav_stack) > 0: + return self._nav_stack[-1] + return None + + def widget_in_stack(self, widget: object) -> bool: + return widget in self._nav_stack + + def add_nav_stack_tick(self, tick_function: Callable[[], None]): + if tick_function not in self._nav_stack_ticks: + self._nav_stack_ticks.append(tick_function) + + def remove_nav_stack_tick(self, tick_function: Callable[[], None]): + if tick_function in self._nav_stack_ticks: + self._nav_stack_ticks.remove(tick_function) def set_should_render(self, should_render: bool): self._should_render = should_render - def texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True): - cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" + def texture(self, asset_path: str, width: int | None = None, height: int | None = None, + alpha_premultiply=False, keep_aspect_ratio=True, flip_x: bool = False) -> rl.Texture: + if width is not None: + width = round(width) + if height is not None: + height = round(height) + + cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}_{keep_aspect_ratio}_{flip_x}" if cache_key in self._textures: return self._textures[cache_key] with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath: - image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio) + image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio, flip_x) texture_obj = self._load_texture_from_image(image_obj) + + # Set logical size so widget layout math stays at 1x coordinates + if self._scale != 1.0 and width is not None and height is not None: + texture_obj.width = width + texture_obj.height = height + self._textures[cache_key] = texture_obj return texture_obj - def starpilot_texture(self, asset_path: str, width: int | None = None, height: int | None = None, alpha_premultiply=False, keep_aspect_ratio=True): - """Load a texture from the StarPilot assets folder.""" - cache_key = f"starpilot_{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}" - if cache_key in self._textures: - return self._textures[cache_key] - - starpilot_assets = files("openpilot.starpilot").joinpath("assets") - with as_file(starpilot_assets.joinpath(asset_path)) as fspath: - image_obj = self._load_image_from_path(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio) - texture_obj = self._load_texture_from_image(image_obj) - self._textures[cache_key] = texture_obj - return texture_obj - - def _load_image_from_path( - self, image_path: str, width: int | None = None, height: int | None = None, alpha_premultiply: bool = False, keep_aspect_ratio: bool = True - ) -> rl.Image: + def _load_image_from_path(self, image_path: str, width: int | None = None, height: int | None = None, + alpha_premultiply: bool = False, keep_aspect_ratio: bool = True, flip_x: bool = False) -> rl.Image: """Load and resize an image, storing it for later automatic unloading.""" image = rl.load_image(image_path) - if image.width == 0 or image.height == 0: - return image - if alpha_premultiply: rl.image_alpha_premultiply(image) + # Scale up load size for sharper rendering, capped at source resolution + if self._scale != 1.0 and width is not None and height is not None: + width = min(int(width * self._scale), image.width) + height = min(int(height * self._scale), image.height) + if width is not None and height is not None: same_dimensions = image.width == width and image.height == height @@ -481,6 +508,10 @@ class GuiApplication: rl.image_resize(image, width, height) else: assert keep_aspect_ratio, "Cannot resize without specifying width and height" + + if flip_x: + rl.image_flip_horizontal(image) + return image def _load_texture_from_image(self, image: rl.Image) -> rl.Texture: @@ -495,11 +526,17 @@ class GuiApplication: return texture def close_ffmpeg(self): + if self._ffmpeg_thread is not None: + # Signal thread to stop, send sentinel, then wait for it to drain + self._ffmpeg_stop_event.set() + self._ffmpeg_queue.put(None) + self._ffmpeg_thread.join(timeout=30) + if self._ffmpeg_proc is not None: self._ffmpeg_proc.stdin.flush() self._ffmpeg_proc.stdin.close() try: - self._ffmpeg_proc.wait(timeout=5) + self._ffmpeg_proc.wait(timeout=30) except subprocess.TimeoutExpired: self._ffmpeg_proc.terminate() self._ffmpeg_proc.wait() @@ -539,17 +576,15 @@ class GuiApplication: def last_mouse_event(self) -> MouseEvent: return self._last_mouse_event - def render(self, render_callback: Callable[[], None] | None = None): + def render(self): try: if self._profile_render_frames > 0: import cProfile - self._render_profiler = cProfile.Profile() self._render_profile_start_time = time.monotonic() self._render_profiler.enable() while not (self._window_close_requested or rl.window_should_close()): - self._mark_progress("gui_app.loop_start") if PC: # Thread is not used on PC, need to manually add mouse events self._mouse._handle_mouse_event() @@ -561,7 +596,6 @@ class GuiApplication: # Skip rendering when screen is off if not self._should_render: - self._mark_progress("gui_app.skip_render") if PC: rl.poll_input_events() time.sleep(1 / self._target_fps) @@ -569,59 +603,43 @@ class GuiApplication: continue if self._render_texture: - self._mark_progress("gui_app.before_begin_texture_mode") rl.begin_texture_mode(self._render_texture) - self._mark_progress("gui_app.after_begin_texture_mode") - self._mark_progress("gui_app.before_clear_background") rl.clear_background(rl.BLACK) - self._mark_progress("gui_app.after_clear_background") else: - self._mark_progress("gui_app.before_begin_drawing") rl.begin_drawing() - self._mark_progress("gui_app.after_begin_drawing") - self._mark_progress("gui_app.before_clear_background") rl.clear_background(rl.BLACK) - self._mark_progress("gui_app.after_clear_background") - # Handle modal overlay rendering and input processing - if self._render_nav_stack(): - self._mark_progress("gui_app.nav_stack") - yield False - elif self._handle_modal_overlay(): - # Allow a Widget to still run a function while overlay is shown - if self._modal_overlay_tick is not None: - self._modal_overlay_tick() - self._mark_progress("gui_app.modal_overlay") - yield False - else: - self._mark_progress("gui_app.frame_ready") - if render_callback is not None: - self._mark_progress("gui_app.before_render_callback") - render_callback() - self._mark_progress("gui_app.after_render_callback") - yield True + if self._scale != 1.0: + rl.rl_push_matrix() + rl.rl_scalef(self._scale, self._scale, 1.0) + + # Allow a Widget to still run a function regardless of the stack depth + for tick in self._nav_stack_ticks: + tick() + + # Only render top widgets + for widget in self._nav_stack[-self._nav_stack_widgets_to_render:]: + widget.render(rl.Rectangle(0, 0, self.width, self.height)) + + yield True + + if self._scale != 1.0: + rl.rl_pop_matrix() if self._render_texture: - self._mark_progress("gui_app.end_texture_mode") rl.end_texture_mode() - self._mark_progress("gui_app.before_present_begin_drawing") rl.begin_drawing() - self._mark_progress("gui_app.after_present_begin_drawing") - self._mark_progress("gui_app.before_present_clear_background") rl.clear_background(rl.BLACK) - self._mark_progress("gui_app.after_present_clear_background") - src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height)) + src_rect = rl.Rectangle(0, 0, float(self._scaled_width), -float(self._scaled_height)) dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height)) texture = self._render_texture.texture if texture: - self._mark_progress("gui_app.before_present_draw_texture") if BURN_IN_MODE and self._burn_in_shader: rl.begin_shader_mode(self._burn_in_shader) rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) rl.end_shader_mode() else: rl.draw_texture_pro(texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE) - self._mark_progress("gui_app.after_present_draw_texture") if self._show_fps: rl.draw_fps(10, 10) @@ -632,21 +650,17 @@ class GuiApplication: if self._grid_size > 0: self._draw_grid() - self._mark_progress("gui_app.end_drawing") rl.end_drawing() - self._mark_progress("gui_app.after_end_drawing") if RECORD: image = rl.load_image_from_texture(self._render_texture.texture) data_size = image.width * image.height * 4 data = bytes(rl.ffi.buffer(image.data, data_size)) - self._ffmpeg_proc.stdin.write(data) - self._ffmpeg_proc.stdin.flush() + self._ffmpeg_queue.put(data) # Async write via background thread rl.unload_image(image) self._monitor_fps() self._frame += 1 - self._mark_progress("gui_app.frame_complete") if self._profile_render_frames > 0 and self._frame >= self._profile_render_frames: self._output_render_profile() @@ -664,61 +678,17 @@ class GuiApplication: def height(self): return self._height - def _handle_modal_overlay(self) -> bool: - if self._modal_overlay.overlay: - if hasattr(self._modal_overlay.overlay, 'render'): - result = self._modal_overlay.overlay.render(rl.Rectangle(0, 0, self.width, self.height)) - elif callable(self._modal_overlay.overlay): - result = self._modal_overlay.overlay() - else: - raise Exception - - # Send show event to Widget - if not self._modal_overlay_shown and hasattr(self._modal_overlay.overlay, 'show_event'): - self._modal_overlay.overlay.show_event() - self._modal_overlay_shown = True - - if result >= 0: - # Clear the overlay and execute the callback - original_modal = self._modal_overlay - self._modal_overlay = ModalOverlay() - if hasattr(original_modal.overlay, 'hide_event'): - original_modal.overlay.hide_event() - if original_modal.callback is not None: - original_modal.callback(result) - return True - else: - self._modal_overlay_shown = False - return False - def _load_fonts(self): - self._ensure_font_atlases() - with as_file(FONT_DIR) as fspath: - for font_weight_file in FontWeight: + for font_weight_file in FontWeight: + with as_file(FONT_DIR) as fspath: fnt_path = fspath / font_weight_file font = rl.load_font(fnt_path.as_posix()) - rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR) + if font_weight_file != FontWeight.UNIFONT: + rl.gen_texture_mipmaps(font.texture) + rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_TRILINEAR) self._fonts[font_weight_file] = font rl.gui_set_font(self._fonts[FontWeight.NORMAL]) - def _ensure_font_atlases(self): - with as_file(FONT_DIR) as fspath: - required_fonts = [fspath / fw.value for fw in FontWeight] - missing_fonts = [font_path.name for font_path in required_fonts if not font_path.exists()] - if not missing_fonts: - return - - process_script = fspath / "process.py" - if not process_script.exists(): - cloudlog.warning(f"Missing font atlases {missing_fonts}, but no generator found at {process_script}") - return - - cloudlog.warning(f"Generating missing font atlases: {missing_fonts}") - try: - subprocess.run([sys.executable, process_script.as_posix()], check=True, cwd=fspath.as_posix()) - except Exception: - cloudlog.exception("Failed to generate font atlases") - def _set_styles(self): rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BORDER_WIDTH, 0) rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE) @@ -737,6 +707,20 @@ class GuiApplication: rl.draw_text_ex = _draw_text_ex_scaled + def _patch_scissor_mode(self): + if self._scale == 1.0: + return + + if not hasattr(rl, "_orig_begin_scissor_mode"): + rl._orig_begin_scissor_mode = rl.begin_scissor_mode + + def _begin_scissor_mode_scaled(x, y, width, height): + return rl._orig_begin_scissor_mode( + int(x * self._scale), int(y * self._scale), + int(math.ceil(width * self._scale)), int(math.ceil(height * self._scale))) + + rl.begin_scissor_mode = _begin_scissor_mode_scaled + def _set_log_callback(self): ffi_libc = cffi.FFI() ffi_libc.cdef(""" @@ -773,6 +757,9 @@ class GuiApplication: else: cloudlog.error(f"raylib: Unknown level {log_level}: {text_str}") + # ensure we get all the logs forwarded to us + rl.set_trace_log_level(rl.TraceLogLevel.LOG_DEBUG) + # Store callback reference self._trace_log_callback = trace_log_callback rl.set_trace_log_callback(self._trace_log_callback) @@ -842,11 +829,11 @@ class GuiApplication: green = "\033[92m" reset = "\033[0m" print(f"\n{green}Rendered {self._frame} frames in {elapsed_ms:.1f} ms{reset}") - print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000 / avg_frame_time:.1f} FPS){reset}") + print(f"{green}Average frame time: {avg_frame_time:.2f} ms ({1000/avg_frame_time:.1f} FPS){reset}") sys.exit(0) def _calculate_auto_scale(self) -> float: - # Create temporary window to query monitor info + # Create temporary window to query monitor info rl.init_window(1, 1, "") w, h = rl.get_monitor_width(0), rl.get_monitor_height(0) rl.close_window() diff --git a/system/ui/lib/emoji.py b/system/ui/lib/emoji.py index 37228e2d4..ad4c272c8 100644 --- a/system/ui/lib/emoji.py +++ b/system/ui/lib/emoji.py @@ -1,12 +1,13 @@ import io import re +import functools +from importlib.resources import as_file from PIL import Image, ImageDraw, ImageFont import pyray as rl from openpilot.system.ui.lib.application import FONT_DIR -_emoji_font: ImageFont.FreeTypeFont | None = None _cache: dict[str, rl.Texture] = {} EMOJI_REGEX = re.compile( @@ -33,11 +34,10 @@ EMOJI_REGEX = re.compile( flags=re.UNICODE ) -def _load_emoji_font() -> ImageFont.FreeTypeFont | None: - global _emoji_font - if _emoji_font is None: - _emoji_font = ImageFont.truetype(str(FONT_DIR.joinpath("NotoColorEmoji.ttf")), 109) - return _emoji_font +@functools.cache +def _load_emoji_font() -> ImageFont.FreeTypeFont: + with as_file(FONT_DIR.joinpath("NotoColorEmoji.ttf")) as font_path: + return ImageFont.truetype(io.BytesIO(font_path.read_bytes()), 109) def find_emoji(text): return [(m.start(), m.end(), m.group()) for m in EMOJI_REGEX.finditer(text)] diff --git a/system/ui/lib/multilang.py b/system/ui/lib/multilang.py index 9b10a8bdc..3c6a6b856 100644 --- a/system/ui/lib/multilang.py +++ b/system/ui/lib/multilang.py @@ -1,7 +1,7 @@ from importlib.resources import files -import os import json -import gettext +import os +import re from openpilot.common.basedir import BASEDIR from openpilot.common.swaglog import cloudlog @@ -16,7 +16,6 @@ TRANSLATIONS_DIR = UI_DIR.joinpath("translations") LANGUAGES_FILE = TRANSLATIONS_DIR.joinpath("languages.json") UNIFONT_LANGUAGES = [ - "ar", "th", "zh-CHT", "zh-CHS", @@ -24,14 +23,137 @@ UNIFONT_LANGUAGES = [ "ja", ] +# Plural form selectors for supported languages +PLURAL_SELECTORS = { + 'en': lambda n: 0 if n == 1 else 1, + 'de': lambda n: 0 if n == 1 else 1, + 'fr': lambda n: 0 if n <= 1 else 1, + 'pt-BR': lambda n: 0 if n <= 1 else 1, + 'es': lambda n: 0 if n == 1 else 1, + 'tr': lambda n: 0 if n == 1 else 1, + 'uk': lambda n: 0 if n % 10 == 1 and n % 100 != 11 else (1 if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14 else 2), + 'th': lambda n: 0, + 'zh-CHT': lambda n: 0, + 'zh-CHS': lambda n: 0, + 'ko': lambda n: 0, + 'ja': lambda n: 0, +} + + +def _parse_quoted(s: str) -> str: + """Parse a PO-format quoted string.""" + s = s.strip() + if not (s.startswith('"') and s.endswith('"')): + raise ValueError(f"Expected quoted string: {s!r}") + s = s[1:-1] + result: list[str] = [] + i = 0 + while i < len(s): + if s[i] == '\\' and i + 1 < len(s): + c = s[i + 1] + if c == 'n': + result.append('\n') + elif c == 't': + result.append('\t') + elif c == '"': + result.append('"') + elif c == '\\': + result.append('\\') + else: + result.append(s[i:i + 2]) + i += 2 + else: + result.append(s[i]) + i += 1 + return ''.join(result) + + +def load_translations(path) -> tuple[dict[str, str], dict[str, list[str]]]: + """Parse a .po file and return (translations, plurals) dicts. + + translations: msgid -> msgstr + plurals: msgid -> [msgstr[0], msgstr[1], ...] + """ + with path.open(encoding='utf-8') as f: + lines = f.readlines() + + translations: dict[str, str] = {} + plurals: dict[str, list[str]] = {} + + # Parser state + msgid = msgid_plural = msgstr = "" + msgstr_plurals: dict[int, str] = {} + field: str | None = None + plural_idx = 0 + + def finish(): + nonlocal msgid, msgid_plural, msgstr, msgstr_plurals, field + if msgid: # skip header (empty msgid) + if msgid_plural: + max_idx = max(msgstr_plurals.keys()) if msgstr_plurals else 0 + plurals[msgid] = [msgstr_plurals.get(i, '') for i in range(max_idx + 1)] + else: + translations[msgid] = msgstr + msgid = msgid_plural = msgstr = "" + msgstr_plurals = {} + field = None + + for raw in lines: + line = raw.strip() + + if not line: + finish() + continue + + if line.startswith('#'): + continue + + if line.startswith('msgid_plural '): + msgid_plural = _parse_quoted(line[len('msgid_plural '):]) + field = 'msgid_plural' + continue + + if line.startswith('msgid '): + msgid = _parse_quoted(line[len('msgid '):]) + field = 'msgid' + continue + + m = re.match(r'msgstr\[(\d+)]\s+(.*)', line) + if m: + plural_idx = int(m.group(1)) + msgstr_plurals[plural_idx] = _parse_quoted(m.group(2)) + field = 'msgstr_plural' + continue + + if line.startswith('msgstr '): + msgstr = _parse_quoted(line[len('msgstr '):]) + field = 'msgstr' + continue + + if line.startswith('"'): + val = _parse_quoted(line) + if field == 'msgid': + msgid += val + elif field == 'msgid_plural': + msgid_plural += val + elif field == 'msgstr': + msgstr += val + elif field == 'msgstr_plural': + msgstr_plurals[plural_idx] += val + + finish() + return translations, plurals + class Multilang: def __init__(self): self._params = Params() if Params is not None else None self._language: str = "en" - self.languages = {} - self.codes = {} - self._translation: gettext.NullTranslations | gettext.GNUTranslations = gettext.NullTranslations() + self.languages: dict[str, str] = {} + self.codes: dict[str, str] = {} + self._translations: dict[str, str] = {} + self._plurals: dict[str, list[str]] = {} + self._plural_selector = PLURAL_SELECTORS.get('en', lambda n: 0) self._load_languages() @property @@ -44,27 +166,30 @@ class Multilang: def setup(self): try: - with TRANSLATIONS_DIR.joinpath(f'app_{self._language}.mo').open('rb') as fh: - translation = gettext.GNUTranslations(fh) - translation.install() - self._translation = translation - cloudlog.warning(f"Loaded translations for language: {self._language}") + po_path = TRANSLATIONS_DIR.joinpath(f'app_{self._language}.po') + self._translations, self._plurals = load_translations(po_path) + self._plural_selector = PLURAL_SELECTORS.get(self._language, lambda n: 0) + cloudlog.debug(f"Loaded translations for language: {self._language}") except FileNotFoundError: cloudlog.error(f"No translation file found for language: {self._language}, using default.") - gettext.install('app') - self._translation = gettext.NullTranslations() + self._translations = {} + self._plurals = {} def change_language(self, language_code: str) -> None: - # Reinstall gettext with the selected language self._params.put("LanguageSetting", language_code) self._language = language_code self.setup() def tr(self, text: str) -> str: - return self._translation.gettext(text) + return self._translations.get(text, text) or text def trn(self, singular: str, plural: str, n: int) -> str: - return self._translation.ngettext(singular, plural, n) + if singular in self._plurals: + idx = self._plural_selector(n) + forms = self._plurals[singular] + if idx < len(forms) and forms[idx]: + return forms[idx] + return singular if n == 1 else plural def _load_languages(self): with LANGUAGES_FILE.open(encoding='utf-8') as f: diff --git a/system/ui/lib/networkmanager.py b/system/ui/lib/networkmanager.py index ffa2ff4db..d2d6b30b1 100644 --- a/system/ui/lib/networkmanager.py +++ b/system/ui/lib/networkmanager.py @@ -3,14 +3,34 @@ from enum import IntEnum # NetworkManager device states class NMDeviceState(IntEnum): + # https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceState UNKNOWN = 0 + UNMANAGED = 10 + UNAVAILABLE = 20 DISCONNECTED = 30 PREPARE = 40 - STATE_CONFIG = 50 + CONFIG = 50 NEED_AUTH = 60 IP_CONFIG = 70 + IP_CHECK = 80 + SECONDARIES = 90 ACTIVATED = 100 DEACTIVATING = 110 + FAILED = 120 + + +class NMDeviceStateReason(IntEnum): + # https://networkmanager.dev/docs/api/1.46/nm-dbus-types.html#NMDeviceStateReason + NONE = 0 + UNKNOWN = 1 + IP_CONFIG_UNAVAILABLE = 5 + NO_SECRETS = 7 + SUPPLICANT_DISCONNECT = 8 + SUPPLICANT_TIMEOUT = 11 + CONNECTION_REMOVED = 38 + USER_REQUESTED = 39 + SSID_NOT_FOUND = 53 + NEW_ACTIVATION = 60 # NetworkManager constants @@ -29,8 +49,6 @@ NM_IP4_CONFIG_IFACE = 'org.freedesktop.NetworkManager.IP4Config' NM_DEVICE_TYPE_WIFI = 2 NM_DEVICE_TYPE_MODEM = 8 -NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8 -NM_DEVICE_STATE_REASON_NEW_ACTIVATION = 60 # https://developer.gnome.org/NetworkManager/1.26/nm-dbus-types.html#NM80211ApFlags NM_802_11_AP_FLAGS_NONE = 0x0 diff --git a/system/ui/lib/scroll_panel2.py b/system/ui/lib/scroll_panel2.py index 0859071da..18fd8a9a6 100644 --- a/system/ui/lib/scroll_panel2.py +++ b/system/ui/lib/scroll_panel2.py @@ -20,6 +20,21 @@ MAX_SPEED = 10000.0 # px/s DEBUG = os.getenv("DEBUG_SCROLL", "0") == "1" +# Weights older (steadier) velocity samples more heavily on release. +# Finger-lift samples are noisy; trusting earlier samples gives consistent fling velocity. +# Reverse-engineered from iOS UIScrollView (tuned at 120Hz touch) by Flutter team: +# https://github.com/flutter/flutter/pull/60501 +# 3 samples β‰ˆ 25ms at 120Hz (iOS) / ~21ms at 140Hz (comma). Scale if touch rate changes. +def weighted_velocity(buffer: deque) -> float: + if len(buffer) >= 3: + return buffer[-3] * 0.6 + buffer[-2] * 0.35 + buffer[-1] * 0.05 + elif len(buffer) == 2: + return buffer[-2] * 0.7 + buffer[-1] * 0.3 + elif len(buffer) == 1: + return buffer[-1] + return 0.0 + + # from https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration class ScrollState(Enum): STEADY = 0 @@ -73,8 +88,14 @@ class GuiScrollPanel2: def _update_state(self, bounds_size: float, content_size: float) -> None: """Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity.""" - if self._state == ScrollState.AUTO_SCROLL: - max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size) + max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size) + + if self._state == ScrollState.STEADY: + # if we find ourselves out of bounds, scroll back in (from external layout dimension changes, etc.) + if self.get_offset() > max_offset or self.get_offset() < min_offset: + self._state = ScrollState.AUTO_SCROLL + + elif self._state == ScrollState.AUTO_SCROLL: # simple exponential return if out of bounds out_of_bounds = self.get_offset() > max_offset or self.get_offset() < min_offset if out_of_bounds and self._handle_out_of_bounds: @@ -145,7 +166,13 @@ class GuiScrollPanel2: # Touch rejection: when releasing finger after swiping and stopping, panel # reports a few erroneous touch events with high velocity, try to ignore. - # If velocity decelerates very quickly, assume user doesn't intend to auto scroll + # If velocity decelerates very quickly, assume user doesn't intend to auto scroll. + # Catches two cases: 1) swipe, stop finger, then lift (stale high velocity in buffer) + # 2) dirty finger lift where finger rotates/slides producing spurious velocity spike. + # TODO: this heuristic false-positives on fast swipes because 140Hz touch polling + # jitter causes velocity to oscillate (not real deceleration). Better approaches: + # - Use evdev kernel timestamps to eliminate velocity oscillation at the source + # - Replace with a time-since-last-event check (40ms timeout) for swipe-stop-lift high_decel = False if len(self._velocity_buffer) > 2: # We limit max to first half since final few velocities can surpass first few @@ -160,6 +187,8 @@ class GuiScrollPanel2: print('deceleration too high, going to STEADY') high_decel = True + self._velocity = weighted_velocity(self._velocity_buffer) + # If final velocity is below some threshold, switch to steady state too low_speed = abs(self._velocity) <= MIN_VELOCITY_FOR_CLICKING * 1.5 # plus some margin diff --git a/system/ui/lib/tests/test_handle_state_change.py b/system/ui/lib/tests/test_handle_state_change.py new file mode 100644 index 000000000..69aae6fdf --- /dev/null +++ b/system/ui/lib/tests/test_handle_state_change.py @@ -0,0 +1,906 @@ +"""Tests for WifiManager._handle_state_change. + +Tests the state machine in isolation by constructing a WifiManager with mocked +DBus, then calling _handle_state_change directly with NM state transitions. +""" +import pytest +from jeepney.low_level import MessageType +from pytest_mock import MockerFixture + +from openpilot.system.ui.lib.networkmanager import NMDeviceState, NMDeviceStateReason +from openpilot.system.ui.lib.wifi_manager import WifiManager, WifiState, ConnectStatus + + +def _make_wm(mocker: MockerFixture, connections=None): + """Create a WifiManager with only the fields _handle_state_change touches.""" + mocker.patch.object(WifiManager, '_initialize') + wm = WifiManager.__new__(WifiManager) + wm._exit = True # prevent stop() from doing anything in __del__ + wm._conn_monitor = mocker.MagicMock() + wm._connections = dict(connections or {}) + wm._wifi_state = WifiState() + wm._user_epoch = 0 + wm._callback_queue = [] + wm._need_auth = [] + wm._activated = [] + wm._update_networks = mocker.MagicMock() + wm._update_active_connection_info = mocker.MagicMock() + wm._get_active_wifi_connection = mocker.MagicMock(return_value=(None, None)) + return wm + + +def fire(wm: WifiManager, new_state: int, prev_state: int = NMDeviceState.UNKNOWN, + reason: int = NMDeviceStateReason.NONE) -> None: + """Feed a state change into the handler.""" + wm._handle_state_change(new_state, prev_state, reason) + + +def fire_wpa_connect(wm: WifiManager) -> None: + """WPA handshake then IP negotiation through ACTIVATED, as seen on device.""" + fire(wm, NMDeviceState.NEED_AUTH) + fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) + fire(wm, NMDeviceState.CONFIG) + fire(wm, NMDeviceState.IP_CONFIG) + fire(wm, NMDeviceState.IP_CHECK) + fire(wm, NMDeviceState.SECONDARIES) + fire(wm, NMDeviceState.ACTIVATED) + + +# --------------------------------------------------------------------------- +# Basic transitions +# --------------------------------------------------------------------------- + +class TestDisconnected: + def test_generic_disconnect_clears_state(self, mocker): + wm = _make_wm(mocker) + wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) + + fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.UNKNOWN) + + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + wm._update_networks.assert_not_called() + + def test_new_activation_is_noop(self, mocker): + """NEW_ACTIVATION means NM is about to connect to another network β€” don't clear.""" + wm = _make_wm(mocker) + wm._wifi_state = WifiState(ssid="OldNet", status=ConnectStatus.CONNECTED) + + fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NEW_ACTIVATION) + + assert wm._wifi_state.ssid == "OldNet" + assert wm._wifi_state.status == ConnectStatus.CONNECTED + + def test_connection_removed_keeps_other_connecting(self, mocker): + """Forget A while connecting to B: CONNECTION_REMOVED for A must not clear B.""" + wm = _make_wm(mocker, connections={"B": "/path/B"}) + wm._set_connecting("B") + + fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED) + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + def test_connection_removed_clears_when_forgotten(self, mocker): + """Forget A: A is no longer in _connections, so state should clear.""" + wm = _make_wm(mocker, connections={}) + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + + fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.CONNECTION_REMOVED) + + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + +class TestDeactivating: + def test_deactivating_noop_for_non_connection_removed(self, mocker): + """DEACTIVATING with non-CONNECTION_REMOVED reason is a no-op.""" + wm = _make_wm(mocker) + wm._wifi_state = WifiState(ssid="Net", status=ConnectStatus.CONNECTED) + + fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED) + + assert wm._wifi_state.ssid == "Net" + assert wm._wifi_state.status == ConnectStatus.CONNECTED + + @pytest.mark.parametrize("status, expected_clears", [ + (ConnectStatus.CONNECTED, True), + (ConnectStatus.CONNECTING, False), + ]) + def test_deactivating_connection_removed(self, mocker, status, expected_clears): + """DEACTIVATING(CONNECTION_REMOVED) clears CONNECTED but preserves CONNECTING. + + CONNECTED: forgetting the current network. The forgotten callback fires between + DEACTIVATING and DISCONNECTED β€” must clear here so the UI doesn't flash "connected" + after the eager _network_forgetting flag resets. + + CONNECTING: forget A while connecting to B. DEACTIVATING fires for A's removal, + but B's CONNECTING state must be preserved. + """ + wm = _make_wm(mocker, connections={"B": "/path/B"}) + wm._wifi_state = WifiState(ssid="B" if status == ConnectStatus.CONNECTING else "A", status=status) + + fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.CONNECTION_REMOVED) + + if expected_clears: + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + else: + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + +class TestPrepareConfig: + def test_user_initiated_skips_dbus_lookup(self, mocker): + """User called _set_connecting('B') β€” PREPARE must not overwrite via DBus. + + Reproduced on device: rapidly tap A then B. PREPARE's DBus lookup returns A's + stale conn_path, overwriting ssid to A for 1-2 frames. UI shows the "connecting" + indicator briefly jump to the wrong network row then back. + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + wm._set_connecting("B") + wm._get_active_wifi_connection.return_value = ("/path/A", {}) + + fire(wm, NMDeviceState.PREPARE) + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + wm._get_active_wifi_connection.assert_not_called() + + @pytest.mark.parametrize("state", [NMDeviceState.PREPARE, NMDeviceState.CONFIG]) + def test_auto_connect_looks_up_ssid(self, mocker, state): + """Auto-connection (ssid=None): PREPARE and CONFIG must look up ssid from NM.""" + wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) + wm._get_active_wifi_connection.return_value = ("/path/auto", {}) + + fire(wm, state) + + assert wm._wifi_state.ssid == "AutoNet" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + def test_auto_connect_dbus_fails(self, mocker): + """Auto-connection but DBus returns None: ssid stays None, status CONNECTING.""" + wm = _make_wm(mocker) + + fire(wm, NMDeviceState.PREPARE) + + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + def test_auto_connect_conn_path_not_in_connections(self, mocker): + """DBus returns a conn_path that doesn't match any known connection.""" + wm = _make_wm(mocker, connections={"Other": "/path/other"}) + wm._get_active_wifi_connection.return_value = ("/path/unknown", {}) + + fire(wm, NMDeviceState.PREPARE) + + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + +class TestNeedAuth: + def test_wrong_password_fires_callback(self, mocker): + """NEED_AUTH+SUPPLICANT_DISCONNECT from CONFIG = real wrong password.""" + wm = _make_wm(mocker) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + wm._set_connecting("SecNet") + + fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, + reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) + + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + assert len(wm._callback_queue) == 1 + wm.process_callbacks() + cb.assert_called_once_with("SecNet") + + def test_failed_no_secrets_fires_callback(self, mocker): + """FAILED+NO_SECRETS = wrong password (weak/gone network). + + Confirmed on device: also fires when a hotspot turns off during connection. + NM can't complete the WPA handshake (AP vanished) and reports NO_SECRETS + rather than SSID_NOT_FOUND. The need_auth callback fires, so the UI shows + "wrong password" β€” a false positive, but same signal path. + + Real device sequence (new connection, hotspot turned off immediately): + PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH) β†’ CONFIG + β†’ NEED_AUTH(CONFIG, NONE) β†’ FAILED(NEED_AUTH, NO_SECRETS) β†’ DISCONNECTED(FAILED, NONE) + """ + wm = _make_wm(mocker) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + wm._set_connecting("WeakNet") + + fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS) + + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + assert len(wm._callback_queue) == 1 + wm.process_callbacks() + cb.assert_called_once_with("WeakNet") + + def test_need_auth_then_failed_no_double_fire(self, mocker): + """Real device sends NEED_AUTH(SUPPLICANT_DISCONNECT) then FAILED(NO_SECRETS) back-to-back. + + The first clears ssid, so the second must not fire a duplicate callback. + Real device sequence: NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) β†’ FAILED(NEED_AUTH, NO_SECRETS) + """ + wm = _make_wm(mocker) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + wm._set_connecting("BadPass") + + fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, + reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) + assert len(wm._callback_queue) == 1 + + fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH, + reason=NMDeviceStateReason.NO_SECRETS) + assert len(wm._callback_queue) == 1 # no duplicate + + wm.process_callbacks() + cb.assert_called_once_with("BadPass") + + def test_no_ssid_no_callback(self, mocker): + """If ssid is None when NEED_AUTH fires, no callback enqueued.""" + wm = _make_wm(mocker) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + + fire(wm, NMDeviceState.NEED_AUTH, reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) + + assert len(wm._callback_queue) == 0 + + def test_interrupted_auth_ignored(self, mocker): + """Switching A->B: NEED_AUTH from A (prev=DISCONNECTED) must not fire callback. + + Reproduced on device: rapidly switching between two saved networks can trigger a + rare false "wrong password" dialog for the previous network, even though both have + correct passwords. The stale NEED_AUTH has prev_state=DISCONNECTED (not CONFIG). + """ + wm = _make_wm(mocker) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + wm._set_connecting("A") + wm._set_connecting("B") + + fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED, + reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + assert len(wm._callback_queue) == 0 + + +class TestPassthroughStates: + """NEED_AUTH (generic), IP_CONFIG, IP_CHECK, SECONDARIES, FAILED (generic) are no-ops.""" + + @pytest.mark.parametrize("state", [ + NMDeviceState.NEED_AUTH, + NMDeviceState.IP_CONFIG, + NMDeviceState.IP_CHECK, + NMDeviceState.SECONDARIES, + NMDeviceState.FAILED, + ]) + def test_passthrough_is_noop(self, mocker, state): + wm = _make_wm(mocker) + wm._set_connecting("Net") + + fire(wm, state, reason=NMDeviceStateReason.NONE) + + assert wm._wifi_state.ssid == "Net" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + assert len(wm._callback_queue) == 0 + + +class TestActivated: + def test_sets_connected(self, mocker): + """ACTIVATED sets status to CONNECTED and fires callback.""" + wm = _make_wm(mocker, connections={"MyNet": "/path/mynet"}) + cb = mocker.MagicMock() + wm.add_callbacks(activated=cb) + wm._set_connecting("MyNet") + wm._get_active_wifi_connection.return_value = ("/path/mynet", {}) + + fire(wm, NMDeviceState.ACTIVATED) + + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "MyNet" + assert len(wm._callback_queue) == 1 + wm.process_callbacks() + cb.assert_called_once() + + def test_conn_path_none_still_connected(self, mocker): + """ACTIVATED but DBus returns None: status CONNECTED, ssid unchanged.""" + wm = _make_wm(mocker) + wm._set_connecting("MyNet") + + fire(wm, NMDeviceState.ACTIVATED) + + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "MyNet" + + def test_activated_side_effects(self, mocker): + """ACTIVATED persists the volatile connection to disk and updates active connection info.""" + wm = _make_wm(mocker, connections={"Net": "/path/net"}) + wm._set_connecting("Net") + wm._get_active_wifi_connection.return_value = ("/path/net", {}) + + fire(wm, NMDeviceState.ACTIVATED) + + wm._conn_monitor.send_and_get_reply.assert_called_once() + wm._update_active_connection_info.assert_called_once() + wm._update_networks.assert_not_called() + + +# --------------------------------------------------------------------------- +# Thread races: _set_connecting on main thread vs _handle_state_change on monitor thread. +# Uses side_effect on the DBus mock to simulate _set_connecting running mid-handler. +# The epoch counter detects that a user action occurred during the slow DBus call +# and discards the stale update. +# --------------------------------------------------------------------------- +# The deterministic fixes (skip DBus lookup when ssid already set, prev_state guard +# on NEED_AUTH, DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED +# guard) shrink these race windows significantly. The epoch counter closes the +# remaining gaps. + +class TestThreadRaces: + def test_prepare_race_user_tap_during_dbus(self, mocker): + """User taps B while PREPARE's DBus call is in flight for auto-connect. + + Monitor thread reads wifi_state (ssid=None), starts DBus call. + Main thread: _set_connecting("B"). Monitor thread writes back stale ssid from DBus. + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + + def user_taps_b_during_dbus(*args, **kwargs): + wm._set_connecting("B") + return ("/path/A", {}) + + wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus + + fire(wm, NMDeviceState.PREPARE) + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + def test_activated_race_user_tap_during_dbus(self, mocker): + """User taps B right as A finishes connecting (ACTIVATED handler running). + + Monitor thread reads wifi_state (A, CONNECTING), starts DBus call. + Main thread: _set_connecting("B"). Monitor thread writes (A, CONNECTED), losing B. + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + wm._set_connecting("A") + + def user_taps_b_during_dbus(*args, **kwargs): + wm._set_connecting("B") + return ("/path/A", {}) + + wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus + + fire(wm, NMDeviceState.ACTIVATED) + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + def test_init_wifi_state_race_user_tap_during_dbus(self, mocker): + """User taps B while _init_wifi_state's DBus calls are in flight. + + _init_wifi_state runs from set_active(True) or worker error paths. It does + 2 DBus calls (device State property + _get_active_wifi_connection) then + unconditionally writes _wifi_state. If the user taps a network during those + calls, _set_connecting("B") is overwritten with stale NM ground truth. + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + wm._wifi_device = "/dev/wifi0" + wm._router_main = mocker.MagicMock() + + state_reply = mocker.MagicMock() + state_reply.body = [('u', NMDeviceState.ACTIVATED)] + wm._router_main.send_and_get_reply.return_value = state_reply + + def user_taps_b_during_dbus(*args, **kwargs): + wm._set_connecting("B") + return ("/path/A", {}) + + wm._get_active_wifi_connection.side_effect = user_taps_b_during_dbus + + wm._init_wifi_state() + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + +# --------------------------------------------------------------------------- +# Full sequences (NM signal order from real devices) +# --------------------------------------------------------------------------- + +class TestFullSequences: + def test_normal_connect(self, mocker): + """User connects to saved network: full happy path. + + Real device sequence (switching from another connected network): + DEACTIVATING(ACTIVATED, NEW_ACTIVATION) β†’ DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) + PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH, NONE) β†’ CONFIG + β†’ IP_CONFIG β†’ IP_CHECK β†’ SECONDARIES β†’ ACTIVATED + """ + wm = _make_wm(mocker, connections={"Home": "/path/home"}) + wm._get_active_wifi_connection.return_value = ("/path/home", {}) + + wm._set_connecting("Home") + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) + fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) + fire(wm, NMDeviceState.CONFIG) + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.IP_CONFIG) + fire(wm, NMDeviceState.IP_CHECK) + fire(wm, NMDeviceState.SECONDARIES) + fire(wm, NMDeviceState.ACTIVATED) + + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "Home" + + def test_wrong_password_then_retry(self, mocker): + """Wrong password β†’ NEED_AUTH β†’ FAILED β†’ NM auto-reconnects to saved network. + + Confirmed on device: wrong password for Shane's iPhone, NM auto-connected to unifi. + + Real device sequence (switching from a connected network): + DEACTIVATING(ACTIVATED, NEW_ACTIVATION) β†’ DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) + β†’ PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) ← WPA handshake + β†’ PREPARE(NEED_AUTH, NONE) β†’ CONFIG + β†’ NEED_AUTH(CONFIG, SUPPLICANT_DISCONNECT) ← wrong password + β†’ FAILED(NEED_AUTH, NO_SECRETS) ← NM gives up + β†’ DISCONNECTED(FAILED, NONE) + β†’ PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH) β†’ CONFIG + β†’ IP_CONFIG β†’ IP_CHECK β†’ SECONDARIES β†’ ACTIVATED ← auto-reconnect to other saved network + """ + wm = _make_wm(mocker, connections={"Sec": "/path/sec"}) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + + wm._set_connecting("Sec") + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) + fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) + fire(wm, NMDeviceState.CONFIG) + + fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.CONFIG, + reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + assert len(wm._callback_queue) == 1 + + # FAILED(NO_SECRETS) follows but ssid is already cleared β€” no double-fire + fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NO_SECRETS) + assert len(wm._callback_queue) == 1 + + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED) + + # Retry + wm._callback_queue.clear() + wm._set_connecting("Sec") + wm._get_active_wifi_connection.return_value = ("/path/sec", {}) + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + + def test_switch_saved_networks(self, mocker): + """Switch from A to B (both saved): NM signal sequence from real device. + + Real device sequence: + DEACTIVATING(ACTIVATED, NEW_ACTIVATION) β†’ DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) + β†’ PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH, NONE) β†’ CONFIG + β†’ IP_CONFIG β†’ IP_CHECK β†’ SECONDARIES β†’ ACTIVATED + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + wm._get_active_wifi_connection.return_value = ("/path/B", {}) + + wm._set_connecting("B") + + fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, + reason=NMDeviceStateReason.NEW_ACTIVATION) + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, + reason=NMDeviceStateReason.NEW_ACTIVATION) + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "B" + + def test_rapid_switch_no_false_wrong_password(self, mocker): + """Switch Aβ†’B quickly: A's interrupted NEED_AUTH must NOT show wrong password. + + NOTE: The late NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) is common when rapidly + switching between networks with wrong/new passwords. Less common when switching between + saved networks with correct passwords. Not guaranteed β€” some switches skip it and go + straight from DISCONNECTED to PREPARE. The prev_state is consistently DISCONNECTED + for stale signals, so the prev_state guard reliably distinguishes them. + + Worst-case signal sequence this protects against: + DEACTIVATING(NEW_ACTIVATION) β†’ DISCONNECTED(NEW_ACTIVATION) + β†’ NEED_AUTH(DISCONNECTED, SUPPLICANT_DISCONNECT) ← A's stale auth failure + β†’ PREPARE β†’ CONFIG β†’ ... β†’ ACTIVATED ← B connects + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + wm._get_active_wifi_connection.return_value = ("/path/B", {}) + + wm._set_connecting("B") + + fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, + reason=NMDeviceStateReason.NEW_ACTIVATION) + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, + reason=NMDeviceStateReason.NEW_ACTIVATION) + fire(wm, NMDeviceState.NEED_AUTH, prev_state=NMDeviceState.DISCONNECTED, + reason=NMDeviceStateReason.SUPPLICANT_DISCONNECT) + + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + assert len(wm._callback_queue) == 0 + + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + + def test_forget_while_connecting(self, mocker): + """Forget the network we're currently connecting to (not yet ACTIVATED). + + Confirmed on device: connected to unifi, tapped Shane's iPhone, then forgot + Shane's iPhone while at CONFIG. NM auto-connected to unifi afterward. + + Real device sequence (switching then forgetting mid-connection): + DEACTIVATING(ACTIVATED, NEW_ACTIVATION) β†’ DISCONNECTED(DEACTIVATING, NEW_ACTIVATION) + β†’ PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH) β†’ CONFIG + β†’ DEACTIVATING(CONFIG, CONNECTION_REMOVED) ← forget at CONFIG + β†’ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) + β†’ PREPARE β†’ CONFIG β†’ ... β†’ ACTIVATED ← NM auto-connects to other saved network + + Note: DEACTIVATING fires from CONFIG (not ACTIVATED). wifi_state.status is + CONNECTING, so the DEACTIVATING handler is a no-op. DISCONNECTED clears state + (ssid removed from _connections by ConnectionRemoved), then PREPARE recovers + via DBus lookup for the auto-connect. + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "Other": "/path/other"}) + wm._get_active_wifi_connection.return_value = ("/path/other", {}) + + wm._set_connecting("A") + + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + assert wm._wifi_state.ssid == "A" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + # User forgets A: ConnectionRemoved processed first, then state changes + del wm._connections["A"] + + fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.CONFIG, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid == "A" + assert wm._wifi_state.status == ConnectStatus.CONNECTING # DEACTIVATING preserves CONNECTING + + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + # NM auto-connects to another saved network + fire(wm, NMDeviceState.PREPARE) + assert wm._wifi_state.ssid == "Other" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "Other" + + def test_forget_connected_network(self, mocker): + """Forget the currently connected network (not switching to another). + + Real device sequence: + DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) β†’ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) + + ConnectionRemoved signal may or may not have been processed before state changes. + Either way, state must clear β€” we're forgetting what we're connected to, not switching. + """ + wm = _make_wm(mocker, connections={"A": "/path/A"}) + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + + fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + # DISCONNECTED follows β€” harmless since state is already cleared + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + def test_forget_A_connect_B(self, mocker): + """Forget A while connecting to B: full signal sequence. + + Real device sequence: + DEACTIVATING(ACTIVATED, CONNECTION_REMOVED) β†’ DISCONNECTED(DEACTIVATING, CONNECTION_REMOVED) + β†’ PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH, NONE) β†’ CONFIG + β†’ IP_CONFIG β†’ IP_CHECK β†’ SECONDARIES β†’ ACTIVATED + + Signal order: + 1. User: _set_connecting("B"), forget("A") removes A from _connections + 2. NewConnection for B arrives β†’ _connections["B"] = ... + 3. DEACTIVATING(CONNECTION_REMOVED) β€” no-op + 4. DISCONNECTED(CONNECTION_REMOVED) β€” B is in _connections, must not clear + 5. PREPARE β†’ CONFIG β†’ NEED_AUTH β†’ PREPARE β†’ CONFIG β†’ ... β†’ ACTIVATED + """ + wm = _make_wm(mocker, connections={"A": "/path/A"}) + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + + wm._set_connecting("B") + del wm._connections["A"] + wm._connections["B"] = "/path/B" + + fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + wm._get_active_wifi_connection.return_value = ("/path/B", {}) + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "B" + + def test_forget_A_connect_B_late_new_connection(self, mocker): + """Forget A, connect B: NewConnection for B arrives AFTER DISCONNECTED. + + This is the worst-case race: B isn't in _connections when DISCONNECTED fires, + so the guard can't protect it and state clears. PREPARE must recover by doing + the DBus lookup (ssid is None at that point). + + Signal order: + 1. User: _set_connecting("B"), forget("A") removes A from _connections + 2. DEACTIVATING(CONNECTION_REMOVED) β€” B NOT in _connections, should be no-op + 3. DISCONNECTED(CONNECTION_REMOVED) β€” B STILL NOT in _connections, clears state + 4. NewConnection for B arrives late β†’ _connections["B"] = ... + 5. PREPARE (ssid=None, so DBus lookup recovers) β†’ CONFIG β†’ ACTIVATED + """ + wm = _make_wm(mocker, connections={"A": "/path/A"}) + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + + wm._set_connecting("B") + del wm._connections["A"] + + fire(wm, NMDeviceState.DEACTIVATING, prev_state=NMDeviceState.ACTIVATED, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.DEACTIVATING, + reason=NMDeviceStateReason.CONNECTION_REMOVED) + # B not in _connections yet, so state clears β€” this is the known edge case + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + # NewConnection arrives late + wm._connections["B"] = "/path/B" + wm._get_active_wifi_connection.return_value = ("/path/B", {}) + + # PREPARE recovers: ssid is None so it looks up from DBus + fire(wm, NMDeviceState.PREPARE) + assert wm._wifi_state.ssid == "B" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "B" + + def test_auto_connect(self, mocker): + """NM auto-connects (no user action, ssid starts None).""" + wm = _make_wm(mocker, connections={"AutoNet": "/path/auto"}) + wm._get_active_wifi_connection.return_value = ("/path/auto", {}) + + fire(wm, NMDeviceState.PREPARE) + assert wm._wifi_state.ssid == "AutoNet" + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + fire(wm, NMDeviceState.CONFIG) + fire_wpa_connect(wm) + assert wm._wifi_state.status == ConnectStatus.CONNECTED + assert wm._wifi_state.ssid == "AutoNet" + + def test_network_lost_during_connection(self, mocker): + """Hotspot turned off while connecting (before ACTIVATED). + + Confirmed on device: started new connection to Shane's iPhone, immediately + turned off the hotspot. NM can't complete WPA handshake and reports + FAILED(NO_SECRETS) β€” same signal as wrong password (false positive). + + Real device sequence: + PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH) β†’ CONFIG + β†’ NEED_AUTH(CONFIG, NONE) β†’ FAILED(NEED_AUTH, NO_SECRETS) β†’ DISCONNECTED(FAILED, NONE) + + Note: no DEACTIVATING, no SUPPLICANT_DISCONNECT. The NEED_AUTH(CONFIG, NONE) is the + normal WPA handshake (not an error). NM gives up with NO_SECRETS because the AP + vanished mid-handshake. + """ + wm = _make_wm(mocker, connections={"Hotspot": "/path/hs"}) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + + wm._set_connecting("Hotspot") + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire(wm, NMDeviceState.NEED_AUTH) # WPA handshake (reason=NONE) + fire(wm, NMDeviceState.PREPARE, prev_state=NMDeviceState.NEED_AUTH) + fire(wm, NMDeviceState.CONFIG) + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + # Second NEED_AUTH(CONFIG, NONE) β€” NM retries handshake, AP vanishing + fire(wm, NMDeviceState.NEED_AUTH) + assert wm._wifi_state.status == ConnectStatus.CONNECTING + + # NM gives up β€” reports NO_SECRETS (same as wrong password) + fire(wm, NMDeviceState.FAILED, prev_state=NMDeviceState.NEED_AUTH, + reason=NMDeviceStateReason.NO_SECRETS) + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + assert len(wm._callback_queue) == 1 + + fire(wm, NMDeviceState.DISCONNECTED, prev_state=NMDeviceState.FAILED) + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + wm.process_callbacks() + cb.assert_called_once_with("Hotspot") + + @pytest.mark.xfail(reason="TODO: FAILED(SSID_NOT_FOUND) should emit error for UI") + def test_ssid_not_found(self, mocker): + """Network drops off while connected β€” hotspot turned off. + + NM docs: SSID_NOT_FOUND (53) = "The WiFi network could not be found" + + Confirmed on device: connected to Shane's iPhone, then turned off the hotspot. + No DEACTIVATING fires β€” NM goes straight from ACTIVATED to FAILED(SSID_NOT_FOUND). + NM retries connecting (PREPARE β†’ CONFIG β†’ ... β†’ FAILED(CONFIG, SSID_NOT_FOUND)) + before finally giving up with DISCONNECTED. + + NOTE: turning off a hotspot during initial connection (before ACTIVATED) typically + produces FAILED(NO_SECRETS) instead of SSID_NOT_FOUND (see test_failed_no_secrets). + + Real device sequence (hotspot turned off while connected): + FAILED(ACTIVATED, SSID_NOT_FOUND) β†’ DISCONNECTED(FAILED, NONE) + β†’ PREPARE β†’ CONFIG β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH) β†’ CONFIG + β†’ NEED_AUTH(CONFIG, NONE) β†’ PREPARE(NEED_AUTH) β†’ CONFIG + β†’ FAILED(CONFIG, SSID_NOT_FOUND) β†’ DISCONNECTED(FAILED, NONE) + + The UI error callback mechanism is intentionally deferred β€” for now just clear state. + """ + wm = _make_wm(mocker, connections={"GoneNet": "/path/gone"}) + cb = mocker.MagicMock() + wm.add_callbacks(need_auth=cb) + + wm._set_connecting("GoneNet") + fire(wm, NMDeviceState.PREPARE) + fire(wm, NMDeviceState.CONFIG) + fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.SSID_NOT_FOUND) + + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + assert wm._wifi_state.ssid is None + + def test_failed_then_disconnected_clears_state(self, mocker): + """After FAILED, NM always transitions to DISCONNECTED to clean up. + + NM docs: FAILED (120) = "failed to connect, cleaning up the connection request" + Full sequence: ... β†’ FAILED(reason) β†’ DISCONNECTED(NONE) + """ + wm = _make_wm(mocker) + wm._set_connecting("Net") + + fire(wm, NMDeviceState.FAILED, reason=NMDeviceStateReason.NONE) + assert wm._wifi_state.status == ConnectStatus.CONNECTING # FAILED(NONE) is a no-op + + fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.NONE) + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + def test_user_requested_disconnect(self, mocker): + """User explicitly disconnects from the network. + + NM docs: USER_REQUESTED (39) = "Device disconnected by user or client" + Expected sequence: DEACTIVATING(USER_REQUESTED) β†’ DISCONNECTED(USER_REQUESTED) + """ + wm = _make_wm(mocker) + wm._wifi_state = WifiState(ssid="MyNet", status=ConnectStatus.CONNECTED) + + fire(wm, NMDeviceState.DEACTIVATING, reason=NMDeviceStateReason.USER_REQUESTED) + fire(wm, NMDeviceState.DISCONNECTED, reason=NMDeviceStateReason.USER_REQUESTED) + + assert wm._wifi_state.ssid is None + assert wm._wifi_state.status == ConnectStatus.DISCONNECTED + + +# --------------------------------------------------------------------------- +# Worker error recovery: DBus errors in activate/connect re-sync with NM +# --------------------------------------------------------------------------- +# Verified on device: when ActivateConnection returns UnknownConnection error, +# NM emits no state signals. The worker error path is the only recovery point. + +class TestWorkerErrorRecovery: + """Worker threads re-sync with NM via _init_wifi_state on DBus errors, + preserving actual NM state instead of blindly clearing to DISCONNECTED.""" + + def _mock_init_restores(self, wm, mocker, ssid, status): + """Replace _init_wifi_state with a mock that simulates NM reporting the given state.""" + mock = mocker.MagicMock( + side_effect=lambda: setattr(wm, '_wifi_state', WifiState(ssid=ssid, status=status)) + ) + wm._init_wifi_state = mock + return mock + + def test_activate_dbus_error_resyncs(self, mocker): + """ActivateConnection returns DBus error while A is connected. + NM rejects the request β€” no state signals emitted. Worker must re-read NM + state to discover A is still connected, not clear to DISCONNECTED. + """ + wm = _make_wm(mocker, connections={"A": "/path/A", "B": "/path/B"}) + wm._wifi_device = "/dev/wifi0" + wm._nm = mocker.MagicMock() + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + wm._router_main = mocker.MagicMock() + + error_reply = mocker.MagicMock() + error_reply.header.message_type = MessageType.error + wm._router_main.send_and_get_reply.return_value = error_reply + + mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED) + + wm.activate_connection("B", block=True) + + mock_init.assert_called_once() + assert wm._wifi_state.ssid == "A" + assert wm._wifi_state.status == ConnectStatus.CONNECTED + + def test_connect_to_network_dbus_error_resyncs(self, mocker): + """AddAndActivateConnection2 returns DBus error while A is connected.""" + wm = _make_wm(mocker, connections={"A": "/path/A"}) + wm._wifi_device = "/dev/wifi0" + wm._nm = mocker.MagicMock() + wm._wifi_state = WifiState(ssid="A", status=ConnectStatus.CONNECTED) + wm._router_main = mocker.MagicMock() + wm._forgotten = [] + + error_reply = mocker.MagicMock() + error_reply.header.message_type = MessageType.error + wm._router_main.send_and_get_reply.return_value = error_reply + + mock_init = self._mock_init_restores(wm, mocker, "A", ConnectStatus.CONNECTED) + + # Run worker thread synchronously + workers = [] + mocker.patch('openpilot.system.ui.lib.wifi_manager.threading.Thread', + side_effect=lambda target, **kw: type('T', (), {'start': lambda self: workers.append(target)})()) + + wm.connect_to_network("B", "password123") + workers[-1]() + + mock_init.assert_called_once() + assert wm._wifi_state.ssid == "A" + assert wm._wifi_state.status == ConnectStatus.CONNECTED diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index 4fcd7c48a..6251c1474 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -1,44 +1,36 @@ import atexit -import os import threading import time import uuid import subprocess +import os import shutil from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import IntEnum from typing import Any +JEEPNY_AVAILABLE = True try: from jeepney import DBusAddress, new_method_call from jeepney.bus_messages import MatchRule, message_bus - from jeepney.io.blocking import open_dbus_connection as open_dbus_connection_blocking + from jeepney.io.blocking import DBusConnection, open_dbus_connection as open_dbus_connection_blocking from jeepney.io.threading import DBusRouter, open_dbus_connection as open_dbus_connection_threading from jeepney.low_level import MessageType from jeepney.wrappers import Properties - JEEPNY_AVAILABLE = True - JEEPNY_IMPORT_ERROR: Exception | None = None -except Exception as e: +except ImportError: JEEPNY_AVAILABLE = False - JEEPNY_IMPORT_ERROR = e - DBusAddress = Any # type: ignore[assignment] - DBusRouter = Any # type: ignore[assignment] - MatchRule = Any # type: ignore[assignment] - MessageType = Any # type: ignore[assignment] - Properties = Any # type: ignore[assignment] + DBusAddress = DBusConnection = DBusRouter = MatchRule = MessageType = Properties = Any + message_bus = None def new_method_call(*_args, **_kwargs): - raise RuntimeError("jeepney is unavailable") - - def message_bus(*_args, **_kwargs): - raise RuntimeError("jeepney is unavailable") + raise RuntimeError("jeepney unavailable") def open_dbus_connection_blocking(*_args, **_kwargs): - raise RuntimeError("jeepney is unavailable") + raise RuntimeError("jeepney unavailable") def open_dbus_connection_threading(*_args, **_kwargs): - raise RuntimeError("jeepney is unavailable") + raise RuntimeError("jeepney unavailable") from openpilot.common.swaglog import cloudlog from openpilot.system.hardware import PC @@ -49,9 +41,8 @@ from openpilot.system.ui.lib.networkmanager import (NM, NM_WIRELESS_IFACE, NM_80 NM_802_11_AP_FLAGS_PRIVACY, NM_802_11_AP_FLAGS_WPS, NM_PATH, NM_IFACE, NM_ACCESS_POINT_IFACE, NM_SETTINGS_PATH, NM_SETTINGS_IFACE, NM_CONNECTION_IFACE, NM_DEVICE_IFACE, - NM_DEVICE_TYPE_WIFI, NM_DEVICE_TYPE_MODEM, NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT, - NM_DEVICE_STATE_REASON_NEW_ACTIVATION, NM_ACTIVE_CONNECTION_IFACE, - NM_IP4_CONFIG_IFACE, NMDeviceState) + NM_DEVICE_TYPE_WIFI, NM_DEVICE_TYPE_MODEM, NM_ACTIVE_CONNECTION_IFACE, + NM_IP4_CONFIG_IFACE, NM_PROPERTIES_IFACE, NMDeviceState, NMDeviceStateReason) try: from openpilot.common.params import Params @@ -65,10 +56,26 @@ SCAN_PERIOD_SECONDS = 5 DESKTOP_FAKE_IP = "192.168.1.42" TRUE_VALUES = {"1", "true", "yes", "on"} +DEBUG = False +_dbus_call_idx = 0 -def _canonicalize_ssid(ssid: str) -> str: - # iPhone hotspots can alternate between unicode and ASCII apostrophes. - return ssid.replace("’", "'") + +def normalize_ssid(ssid: str) -> str: + return ssid.replace("’", "'") # for iPhone hotspots + + +def _wrap_router(router): + def _wrap(orig): + def wrapper(msg, **kw): + global _dbus_call_idx + _dbus_call_idx += 1 + if DEBUG: + h = msg.header.fields + print(f"[DBUS #{_dbus_call_idx}] {h.get(6, '?')} {h.get(3, '?')} {msg.body}") + return orig(msg, **kw) + return wrapper + router.send_and_get_reply = _wrap(router.send_and_get_reply) + router.send = _wrap(router.send) class SecurityType(IntEnum): @@ -105,24 +112,20 @@ def get_security_type(flags: int, wpa_flags: int, rsn_flags: int) -> SecurityTyp class Network: ssid: str strength: int - is_connected: bool security_type: SecurityType - is_saved: bool - ip_address: str = "" # TODO: implement + is_tethering: bool @classmethod - def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_saved: bool) -> "Network": + def from_dbus(cls, ssid: str, aps: list["AccessPoint"], is_tethering: bool) -> "Network": # we only want to show the strongest AP for each Network/SSID strongest_ap = max(aps, key=lambda ap: ap.strength) - is_connected = any(ap.is_connected for ap in aps) security_type = get_security_type(strongest_ap.flags, strongest_ap.wpa_flags, strongest_ap.rsn_flags) return cls( ssid=ssid, - strength=strongest_ap.strength, - is_connected=is_connected and is_saved, + strength=100 if is_tethering else strongest_ap.strength, security_type=security_type, - is_saved=is_saved, + is_tethering=is_tethering, ) @@ -131,14 +134,13 @@ class AccessPoint: ssid: str bssid: str strength: int - is_connected: bool flags: int wpa_flags: int rsn_flags: int ap_path: str @classmethod - def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str, active_ap_path: str) -> "AccessPoint": + def from_dbus(cls, ap_props: dict[str, tuple[str, Any]], ap_path: str) -> "AccessPoint": ssid = bytes(ap_props['Ssid'][1]).decode("utf-8", "replace") bssid = str(ap_props['HwAddress'][1]) strength = int(ap_props['Strength'][1]) @@ -150,7 +152,6 @@ class AccessPoint: ssid=ssid, bssid=bssid, strength=strength, - is_connected=ap_path == active_ap_path, flags=flags, wpa_flags=wpa_flags, rsn_flags=rsn_flags, @@ -158,80 +159,96 @@ class AccessPoint: ) +class ConnectStatus(IntEnum): + DISCONNECTED = 0 + CONNECTING = 1 + CONNECTED = 2 + + +@dataclass(frozen=True) +class WifiState: + ssid: str | None = None + status: ConnectStatus = ConnectStatus.DISCONNECTED + + class WifiManager: def __init__(self): - self._networks: list[Network] = [] # a network can be comprised of multiple APs + self._networks: list[Network] = [] # an unsorted list of available Networks. a Network can be comprised of multiple APs self._active = True # used to not run when not in settings self._exit = False self._fake_networking = False self._nmcli_networking = False - self._dbus_available = False + self._backend_unavailable = False allow_desktop_fake = PC and os.getenv("SP_ALLOW_DESKTOP_FAKE_WIFI", "0").lower() in TRUE_VALUES has_nmcli = shutil.which("nmcli") is not None - if allow_desktop_fake: - self._router_main = None - self._conn_monitor = None - self._nm = None - self._fake_networking = True # DBus connections - elif not JEEPNY_AVAILABLE: - cloudlog.warning(f"jeepney unavailable: {JEEPNY_IMPORT_ERROR}") + if not JEEPNY_AVAILABLE: + cloudlog.warning("jeepney unavailable") self._router_main = None self._conn_monitor = None self._nm = None - if has_nmcli: + if allow_desktop_fake: + cloudlog.warning("Using desktop fake Wi-Fi backend") + self._fake_networking = True + elif has_nmcli: + cloudlog.warning("Using nmcli Wi-Fi backend") self._nmcli_networking = True else: - cloudlog.error("No networking backend available (jeepney missing, nmcli unavailable)") + cloudlog.warning("Wi-Fi backend disabled") + self._backend_unavailable = True + self._exit = True else: try: self._router_main = DBusRouter(open_dbus_connection_threading(bus="SYSTEM")) # used by scanner / general method calls + _wrap_router(self._router_main) self._conn_monitor = open_dbus_connection_blocking(bus="SYSTEM") # used by state monitor thread self._nm = DBusAddress(NM_PATH, bus_name=NM, interface=NM_IFACE) - self._dbus_available = True - except Exception as e: - cloudlog.warning(f"Failed to connect to system D-Bus: {e}") + except Exception: + if allow_desktop_fake: + cloudlog.warning("Failed to connect to system D-Bus; using desktop fake Wi-Fi backend") + self._fake_networking = True + elif has_nmcli: + cloudlog.warning("Failed to connect to system D-Bus; using nmcli Wi-Fi backend") + self._nmcli_networking = True + else: + cloudlog.exception("Failed to connect to system D-Bus") + self._backend_unavailable = True + self._exit = True self._router_main = None self._conn_monitor = None self._nm = None - if has_nmcli: - self._nmcli_networking = True - else: - cloudlog.error("No networking backend available (D-Bus unavailable, nmcli unavailable)") # Store wifi device path self._wifi_device: str | None = None # State - self._connecting_to_ssid: str = "" + self._connections: dict[str, str] = {} # ssid -> connection path, updated via NM signals + self._wifi_state: WifiState = WifiState() + self._user_epoch: int = 0 self._ipv4_address: str = "" self._current_network_metered: MeteredType = MeteredType.UNKNOWN self._tethering_password: str = "" self._ipv4_forward = False - self._last_network_update: float = 0.0 + self._last_network_scan: float = 0.0 self._callback_queue: list[Callable] = [] - self._fake_connected_ssid: str | None = None - self._fake_known_networks: dict[str, dict[str, Any]] = {} self._tethering_ssid = "weedle" if Params is not None: dongle_id = Params().get("DongleId") if dongle_id: self._tethering_ssid += "-" + dongle_id[:4] - if self._fake_networking: - self._init_fake_networking() # Callbacks self._need_auth: list[Callable[[str], None]] = [] self._activated: list[Callable[[], None]] = [] - self._forgotten: list[Callable[[], None]] = [] + self._forgotten: list[Callable[[str | None], None]] = [] self._networks_updated: list[Callable[[list[Network]], None]] = [] self._disconnected: list[Callable[[], None]] = [] - self._lock = threading.Lock() + self._scan_lock = threading.Lock() self._scan_thread = threading.Thread(target=self._network_scanner, daemon=True) self._state_thread = threading.Thread(target=self._monitor_state, daemon=True) self._initialize() @@ -239,80 +256,75 @@ class WifiManager: def _initialize(self): def worker(): - if self._fake_networking: - self._update_networks() - cloudlog.debug("WifiManager initialized in fake networking mode") + if self._backend_unavailable: + cloudlog.warning("WifiManager initialized without D-Bus backend") return + + if self._fake_networking: + self._tethering_password = DEFAULT_TETHERING_PASSWORD + self._enqueue_callbacks(self._networks_updated, self.networks) + cloudlog.debug("WifiManager initialized in desktop fake mode") + return + if self._nmcli_networking: + self._tethering_password = DEFAULT_TETHERING_PASSWORD self._update_networks() self._scan_thread.start() - cloudlog.debug("WifiManager initialized in nmcli networking mode") - return - if not self._dbus_available: - cloudlog.error("WifiManager unavailable: no active networking backend") + cloudlog.debug("WifiManager initialized in nmcli mode") return self._wait_for_wifi_device() + self._init_connections() + if Params is not None and self._tethering_ssid not in self._connections: + self._add_tethering_connection() + + self._init_wifi_state() + self._scan_thread.start() self._state_thread.start() - if Params is not None and self._tethering_ssid not in self._get_connections(): - self._add_tethering_connection() - self._tethering_password = self._get_tethering_password() cloudlog.debug("WifiManager initialized") threading.Thread(target=worker, daemon=True).start() - def _init_fake_networking(self): - primary_ssid = os.getenv("FAKE_WIFI_SSID", "Laptop Wi-Fi") - self._fake_known_networks = { - primary_ssid: {"security": SecurityType.WPA, "saved": True, "strength": 96}, - "Coffee Shop": {"security": SecurityType.OPEN, "saved": False, "strength": 68}, - "Phone Hotspot": {"security": SecurityType.WPA, "saved": False, "strength": 54}, - } - self._fake_connected_ssid = primary_ssid - self._tethering_password = DEFAULT_TETHERING_PASSWORD - self._current_network_metered = MeteredType.NO - self._ipv4_address = DESKTOP_FAKE_IP + def _init_wifi_state(self, block: bool = True): + def worker(): + if self._wifi_device is None: + cloudlog.warning("No WiFi device found") + return - def _update_networks_fake(self): - with self._lock: - networks: list[Network] = [] - for ssid, values in self._fake_known_networks.items(): - networks.append(Network( - ssid=ssid, - strength=int(values["strength"]), - is_connected=ssid == self._fake_connected_ssid, - security_type=values["security"], - is_saved=bool(values["saved"]), - )) + epoch = self._user_epoch - if self._fake_connected_ssid == self._tethering_ssid: - if self._tethering_ssid not in self._fake_known_networks: - networks.append(Network( - ssid=self._tethering_ssid, - strength=100, - is_connected=True, - security_type=SecurityType.WPA, - is_saved=True, - )) - self._ipv4_address = TETHERING_IP_ADDRESS - self._current_network_metered = MeteredType.UNKNOWN - elif self._fake_connected_ssid is None: - self._ipv4_address = "" - self._current_network_metered = MeteredType.UNKNOWN - else: - self._ipv4_address = DESKTOP_FAKE_IP + dev_addr = DBusAddress(self._wifi_device, bus_name=NM, interface=NM_DEVICE_IFACE) + dev_state = self._router_main.send_and_get_reply(Properties(dev_addr).get('State')).body[0][1] - networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower())) - self._networks = networks - self._enqueue_callbacks(self._networks_updated, self._networks) + ssid: str | None = None + status = ConnectStatus.DISCONNECTED + if NMDeviceState.PREPARE <= dev_state <= NMDeviceState.SECONDARIES and dev_state != NMDeviceState.NEED_AUTH: + status = ConnectStatus.CONNECTING + elif dev_state == NMDeviceState.ACTIVATED: + status = ConnectStatus.CONNECTED + + conn_path, _ = self._get_active_wifi_connection() + if conn_path: + ssid = next((s for s, p in self._connections.items() if p == conn_path), None) + + # Discard if user acted during DBus calls + if self._user_epoch != epoch: + return + + self._wifi_state = WifiState(ssid=ssid, status=status) + + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() def add_callbacks(self, need_auth: Callable[[str], None] | None = None, activated: Callable[[], None] | None = None, - forgotten: Callable[[], None] | None = None, + forgotten: Callable[[str], None] | None = None, networks_updated: Callable[[list[Network]], None] | None = None, disconnected: Callable[[], None] | None = None): if need_auth is not None: @@ -326,6 +338,15 @@ class WifiManager: if disconnected is not None: self._disconnected.append(disconnected) + @property + def networks(self) -> list[Network]: + # Sort by connected/connecting, then known, then strength, then alphabetically. This is a pure UI ordering and should not affect underlying state. + return sorted(self._networks, key=lambda n: (n.ssid != self._wifi_state.ssid, not self.is_connection_saved(n.ssid), -n.strength, n.ssid.lower())) + + @property + def wifi_state(self) -> WifiState: + return self._wifi_state + @property def ipv4_address(self) -> str: return self._ipv4_address @@ -334,10 +355,25 @@ class WifiManager: def current_network_metered(self) -> MeteredType: return self._current_network_metered + @property + def connecting_to_ssid(self) -> str | None: + wifi_state = self._wifi_state + return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTING else None + + @property + def connected_ssid(self) -> str | None: + wifi_state = self._wifi_state + return wifi_state.ssid if wifi_state.status == ConnectStatus.CONNECTED else None + @property def tethering_password(self) -> str: return self._tethering_password + def _set_connecting(self, ssid: str | None): + # Called by user action, or sequentially from state change handler + self._user_epoch += 1 + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.DISCONNECTED if ssid is None else ConnectStatus.CONNECTING) + def _enqueue_callbacks(self, cbs: list[Callable], *args): for cb in cbs: self._callback_queue.append(lambda _cb=cb: _cb(*args)) @@ -350,68 +386,199 @@ class WifiManager: def set_active(self, active: bool): self._active = active - if self._fake_networking or self._nmcli_networking: - if active: - self._update_networks() + + if self._backend_unavailable: return - # Scan immediately if we haven't scanned in a while - if active and time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS / 2: - self._last_network_update = 0.0 + if self._nmcli_networking: + if active: + self._update_networks(block=False) + return + + if self._fake_networking: + if active: + self._enqueue_callbacks(self._networks_updated, self.networks) + return + + # Update networks and WiFi state (to self-heal) immediately when activating for UI + if active: + self._init_wifi_state(block=False) + self._update_networks(block=False) def _monitor_state(self): - if not self._dbus_available: - return - - rule = MatchRule( - type="signal", - interface=NM_DEVICE_IFACE, - member="StateChanged", - path=self._wifi_device, + # Filter for signals + rules = ( + MatchRule( + type="signal", + interface=NM_DEVICE_IFACE, + member="StateChanged", + path=self._wifi_device, + ), + MatchRule( + type="signal", + interface=NM_SETTINGS_IFACE, + member="NewConnection", + path=NM_SETTINGS_PATH, + ), + MatchRule( + type="signal", + interface=NM_SETTINGS_IFACE, + member="ConnectionRemoved", + path=NM_SETTINGS_PATH, + ), + MatchRule( + type="signal", + interface=NM_PROPERTIES_IFACE, + member="PropertiesChanged", + path=self._wifi_device, + ), ) - # Filter for StateChanged signal - self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule)) + for rule in rules: + self._conn_monitor.send_and_get_reply(message_bus.AddMatch(rule)) - with self._conn_monitor.filter(rule, bufsize=SIGNAL_QUEUE_SIZE) as q: + with (self._conn_monitor.filter(rules[0], bufsize=SIGNAL_QUEUE_SIZE) as state_q, + self._conn_monitor.filter(rules[1], bufsize=SIGNAL_QUEUE_SIZE) as new_conn_q, + self._conn_monitor.filter(rules[2], bufsize=SIGNAL_QUEUE_SIZE) as removed_conn_q, + self._conn_monitor.filter(rules[3], bufsize=SIGNAL_QUEUE_SIZE) as props_q): while not self._exit: - if not self._active: - time.sleep(1) - continue - - # Block until a matching signal arrives try: - msg = self._conn_monitor.recv_until_filtered(q, timeout=1) + self._conn_monitor.recv_messages(timeout=1) except TimeoutError: continue - new_state, previous_state, change_reason = msg.body + # Connection added/removed + while len(removed_conn_q): + conn_path = removed_conn_q.popleft().body[0] + self._connection_removed(conn_path) + while len(new_conn_q): + conn_path = new_conn_q.popleft().body[0] + self._new_connection(conn_path) - # BAD PASSWORD - if new_state == NMDeviceState.NEED_AUTH and change_reason == NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT and len(self._connecting_to_ssid): - self.forget_connection(self._connecting_to_ssid, block=True) - self._enqueue_callbacks(self._need_auth, self._connecting_to_ssid) - self._connecting_to_ssid = "" - - elif new_state == NMDeviceState.ACTIVATED: - if len(self._activated): + # PropertiesChanged on wifi device (LastScan = scan complete) + while len(props_q): + iface, changed, _ = props_q.popleft().body + if iface == NM_WIRELESS_IFACE and 'LastScan' in changed: self._update_networks() - self._enqueue_callbacks(self._activated) - self._connecting_to_ssid = "" - elif new_state == NMDeviceState.DISCONNECTED and change_reason != NM_DEVICE_STATE_REASON_NEW_ACTIVATION: - self._connecting_to_ssid = "" - self._enqueue_callbacks(self._forgotten) + # Device state changes + while len(state_q): + new_state, previous_state, change_reason = state_q.popleft().body + + self._handle_state_change(new_state, previous_state, change_reason) + + def _handle_state_change(self, new_state: int, prev_state: int, change_reason: int): + # Thread safety: _wifi_state is read/written by both the monitor thread (this handler) + # and the main thread (_set_connecting via connect/activate). PREPARE/CONFIG and ACTIVATED + # have a read-then-write pattern with a slow DBus call in between β€” if _set_connecting + # runs mid-call, the handler would overwrite the user's newer state with stale data. + # + # The _user_epoch counter solves this without locks. _set_connecting increments the epoch + # on every user action. Handlers snapshot the epoch before their DBus call and compare + # after: if it changed, a user action occurred during the call and the stale result is + # discarded. Combined with deterministic fixes (skip DBus lookup when ssid already set, + # DEACTIVATING clears CONNECTED on CONNECTION_REMOVED, CONNECTION_REMOVED guard), + # all known race windows are closed. + + # TODO: Handle (FAILED, SSID_NOT_FOUND) and emit for UI to show error + # Happens when network drops off after starting connection + + if new_state == NMDeviceState.DISCONNECTED: + if change_reason == NMDeviceStateReason.NEW_ACTIVATION: + return + + # Guard: forget A while connecting to B fires CONNECTION_REMOVED. Don't clear B's state + # if B is still a known connection. If B hasn't arrived in _connections yet (late + # NewConnection), state clears here but PREPARE recovers via DBus lookup. + if (change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.ssid and + self._wifi_state.ssid in self._connections): + return + + self._set_connecting(None) + + elif new_state in (NMDeviceState.PREPARE, NMDeviceState.CONFIG): + epoch = self._user_epoch + + if self._wifi_state.ssid is not None: + self._wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) + return + + # Auto-connection when NetworkManager connects to known networks on its own (ssid=None): look up ssid from NM + wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTING) + + conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) + + # Discard if user acted during DBus call + if self._user_epoch != epoch: + return + + if conn_path is None: + cloudlog.warning("Failed to get active wifi connection during PREPARE/CONFIG state") + else: + wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) + + self._wifi_state = wifi_state + + # BAD PASSWORD + # - strong network rejects with NEED_AUTH+SUPPLICANT_DISCONNECT + # - weak/gone network fails with FAILED+NO_SECRETS + # TODO: sometimes on PC it's observed no future signals are fired if mouse is held down blocking wrong password dialog + elif ((new_state == NMDeviceState.NEED_AUTH and change_reason == NMDeviceStateReason.SUPPLICANT_DISCONNECT + and prev_state == NMDeviceState.CONFIG) or + (new_state == NMDeviceState.FAILED and change_reason == NMDeviceStateReason.NO_SECRETS)): + + # prev_state guard: real auth failures come from CONFIG (supplicant handshake). + # Stale NEED_AUTH from a prior connection during network switching arrives with + # prev_state=DISCONNECTED and must be ignored to avoid a false wrong-password callback. + if self._wifi_state.ssid: + self._enqueue_callbacks(self._need_auth, self._wifi_state.ssid) + self._set_connecting(None) + + elif new_state in (NMDeviceState.NEED_AUTH, NMDeviceState.IP_CONFIG, NMDeviceState.IP_CHECK, + NMDeviceState.SECONDARIES, NMDeviceState.FAILED): + pass + + elif new_state == NMDeviceState.ACTIVATED: + # Note that IP address from Ip4Config may not be propagated immediately and could take until the next scan results + epoch = self._user_epoch + wifi_state = replace(self._wifi_state, status=ConnectStatus.CONNECTED) + + conn_path, _ = self._get_active_wifi_connection(self._conn_monitor) + + # Discard if user acted during DBus call + if self._user_epoch != epoch: + return + + if conn_path is None: + cloudlog.warning("Failed to get active wifi connection during ACTIVATED state") + else: + wifi_state = replace(wifi_state, ssid=next((s for s, p in self._connections.items() if p == conn_path), None)) + + self._wifi_state = wifi_state + self._enqueue_callbacks(self._activated) + self._update_active_connection_info() + + # Persist volatile connections (created by AddAndActivateConnection2) to disk + if conn_path is not None: + conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) + save_reply = self._conn_monitor.send_and_get_reply(new_method_call(conn_addr, 'Save')) + if save_reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to persist connection to disk: {save_reply}") + + elif new_state == NMDeviceState.DEACTIVATING: + # Must clear state when forgetting the currently connected network so the UI + # doesn't flash "connected" after the eager "forgetting..." state resets + # (the forgotten callback fires between DEACTIVATING and DISCONNECTED). + # Only clear CONNECTED β€” CONNECTING must be preserved for forget-A-connect-B. + if change_reason == NMDeviceStateReason.CONNECTION_REMOVED and self._wifi_state.status == ConnectStatus.CONNECTED: + self._set_connecting(None) def _network_scanner(self): while not self._exit: if self._active: - if time.monotonic() - self._last_network_update > SCAN_PERIOD_SECONDS: - # Scan for networks every 10 seconds - # TODO: should update when scan is complete (PropertiesChanged), but this is more than good enough for now - self._update_networks() + if time.monotonic() - self._last_network_scan > SCAN_PERIOD_SECONDS: self._request_scan() - self._last_network_update = time.monotonic() + self._last_network_scan = time.monotonic() time.sleep(1 / 2.) def _wait_for_wifi_device(self): @@ -435,7 +602,7 @@ class WifiManager: cloudlog.exception(f"Error getting adapter type {adapter_type}: {e}") return None - def _get_connections(self) -> dict[str, str]: + def _init_connections(self) -> None: settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) known_connections = self._router_main.send_and_get_reply(new_method_call(settings_addr, 'ListConnections')).body[0] @@ -449,13 +616,48 @@ class WifiManager: if "802-11-wireless" in settings: ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") - ssid_key = _canonicalize_ssid(ssid) - if ssid_key != "": - conns[ssid_key] = conn_path - return conns + if ssid != "": + conns[ssid] = conn_path + self._connections = conns - def _get_active_connections(self): - return self._router_main.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1] + def _new_connection(self, conn_path: str): + settings = self._get_connection_settings(conn_path) + + if "802-11-wireless" in settings: + ssid = settings['802-11-wireless']['ssid'][1].decode("utf-8", "replace") + if ssid != "": + self._connections[ssid] = conn_path + + def _connection_removed(self, conn_path: str): + self._connections = {ssid: path for ssid, path in self._connections.items() if path != conn_path} + + def _get_active_connections(self, router: DBusConnection | DBusRouter | None = None): + # Returns list of ActiveConnection + if router is None: + router = self._router_main + + return router.send_and_get_reply(Properties(self._nm).get('ActiveConnections')).body[0][1] + + def _get_active_wifi_connection(self, router: DBusConnection | DBusRouter | None = None) -> tuple[str | None, dict | None]: + # Returns first Connection settings path and ActiveConnection props from ActiveConnections with Type 802-11-wireless + if router is None: + router = self._router_main + + for active_conn in self._get_active_connections(router): + conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) + reply = router.send_and_get_reply(Properties(conn_addr).get_all()) + + if reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to get active connection properties for {active_conn}: {reply}") + continue + + props = reply.body[0] + + conn_path = props.get('Connection', ('o', '/'))[1] + if props.get('Type', ('s', ''))[1] == '802-11-wireless' and conn_path != '/': + return conn_path, props + + return None, None def _get_connection_settings(self, conn_path: str) -> dict: conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) @@ -503,36 +705,26 @@ class WifiManager: self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,))) def connect_to_network(self, ssid: str, password: str, hidden: bool = False): - if not (self._dbus_available or self._fake_networking or self._nmcli_networking): - cloudlog.warning("connect_to_network called with no available networking backend") + if self._backend_unavailable: + cloudlog.warning(f"Ignoring connect_to_network({ssid!r}); Wi-Fi backend unavailable") return - if self._fake_networking: - def worker(): - self._connecting_to_ssid = ssid - security = SecurityType.WPA if password else SecurityType.OPEN - if ssid not in self._fake_known_networks: - self._fake_known_networks[ssid] = {"security": security, "saved": True, "strength": 82} - else: - self._fake_known_networks[ssid]["saved"] = True - self._fake_known_networks[ssid]["security"] = security - self._fake_connected_ssid = ssid - self._connecting_to_ssid = "" - self._update_networks() - self._enqueue_callbacks(self._activated) - threading.Thread(target=worker, daemon=True).start() - return if self._nmcli_networking: + self._set_connecting(ssid) + def worker(): - self._connecting_to_ssid = ssid cmd = ["nmcli", "device", "wifi", "connect", ssid] if password: cmd += ["password", password] if hidden: cmd += ["hidden", "yes"] + result = subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - self._connecting_to_ssid = "" + if result.returncode != 0: + self._set_connecting(None) + self._update_networks() + if result.returncode == 0: self._enqueue_callbacks(self._activated) else: @@ -541,9 +733,22 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() return + if self._fake_networking: + self._set_connecting(ssid) + if not self.is_connection_saved(ssid): + self._connections[ssid] = ssid + if not any(network.ssid == ssid for network in self._networks): + self._networks.append(Network(ssid=ssid, strength=100, security_type=SecurityType.WPA if password else SecurityType.OPEN, is_tethering=False)) + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = DESKTOP_FAKE_IP + self._enqueue_callbacks(self._activated) + self._enqueue_callbacks(self._networks_updated, self.networks) + return + + self._set_connecting(ssid) + def worker(): # Clear all connections that may already exist to the network we are connecting to - self._connecting_to_ssid = ssid self.forget_connection(ssid, block=True) connection = { @@ -572,33 +777,29 @@ class WifiManager: 'psk': ('s', password), } - settings_addr = DBusAddress(NM_SETTINGS_PATH, bus_name=NM, interface=NM_SETTINGS_IFACE) - self._router_main.send_and_get_reply(new_method_call(settings_addr, 'AddConnection', 'a{sa{sv}}', (connection,))) - self.activate_connection(ssid, block=True) + # Volatile connection auto-deletes on disconnect (wrong password, user switches networks) + # Persisted to disk on ACTIVATED via Save() + if self._wifi_device is None: + cloudlog.warning("No WiFi device found") + # TODO: expose a failed connection state in the UI + self._init_wifi_state() + return + + reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'AddAndActivateConnection2', 'a{sa{sv}}ooa{sv}', + (connection, self._wifi_device, "/", {'persist': ('s', 'volatile')}))) + + if reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to add and activate connection for {ssid}: {reply}") + # TODO: expose a failed connection state in the UI + self._init_wifi_state() threading.Thread(target=worker, daemon=True).start() def forget_connection(self, ssid: str, block: bool = False): - if not (self._dbus_available or self._fake_networking or self._nmcli_networking): - cloudlog.warning("forget_connection called with no available networking backend") + if self._backend_unavailable: + cloudlog.warning(f"Ignoring forget_connection({ssid!r}); Wi-Fi backend unavailable") return - if self._fake_networking: - def worker(): - self._fake_known_networks.pop(ssid, None) - was_connected = self._fake_connected_ssid == ssid - if was_connected: - replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None) - self._fake_connected_ssid = replacement - self._update_networks() - self._enqueue_callbacks(self._forgotten) - if was_connected and self._fake_connected_ssid is None: - self._enqueue_callbacks(self._disconnected) - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() - return if self._nmcli_networking: def worker(): try: @@ -618,8 +819,11 @@ class WifiManager: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: cloudlog.warning(f"nmcli forget failed for {ssid}: {e}") + + if self._wifi_state.ssid == ssid: + self._set_connecting(None) self._update_networks() - self._enqueue_callbacks(self._forgotten) + self._enqueue_callbacks(self._forgotten, ssid) if block: worker() @@ -627,15 +831,24 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() return + if self._fake_networking: + self._connections.pop(ssid, None) + if self._wifi_state.ssid == ssid: + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._forgotten, ssid) + self._enqueue_callbacks(self._networks_updated, self.networks) + return + def worker(): - conn_path = self._get_connections().get(_canonicalize_ssid(ssid), None) - if conn_path is not None: + conn_path = self._connections.get(ssid, None) + if conn_path is None: + cloudlog.warning(f"Trying to forget unknown connection: {ssid}") + else: conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Delete')) - if len(self._forgotten): - self._update_networks() - self._enqueue_callbacks(self._forgotten) + self._enqueue_callbacks(self._forgotten, ssid) if block: worker() @@ -643,34 +856,20 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() def activate_connection(self, ssid: str, block: bool = False): - if not (self._dbus_available or self._fake_networking or self._nmcli_networking): - cloudlog.warning("activate_connection called with no available networking backend") + if self._backend_unavailable: + cloudlog.warning(f"Ignoring activate_connection({ssid!r}); Wi-Fi backend unavailable") return - if self._fake_networking: - def worker(): - if ssid not in self._fake_known_networks and ssid != self._tethering_ssid: - return - self._connecting_to_ssid = ssid - if ssid == self._tethering_ssid and ssid not in self._fake_known_networks: - self._fake_known_networks[ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100} - else: - self._fake_known_networks[ssid]["saved"] = True - self._fake_connected_ssid = ssid - self._connecting_to_ssid = "" - self._update_networks() - self._enqueue_callbacks(self._activated) - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() - return if self._nmcli_networking: + self._set_connecting(ssid) + def worker(): - self._connecting_to_ssid = ssid - result = subprocess.run(["nmcli", "connection", "up", "id", ssid], check=False, + conn_id = self._connections.get(ssid, ssid) + result = subprocess.run(["nmcli", "connection", "up", "id", conn_id], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - self._connecting_to_ssid = "" + if result.returncode != 0: + self._set_connecting(None) + self._update_networks() if result.returncode == 0: self._enqueue_callbacks(self._activated) @@ -681,16 +880,33 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() return - def worker(): - conn_path = self._get_connections().get(_canonicalize_ssid(ssid), None) - if conn_path is not None: - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - return + if self._fake_networking: + self._set_connecting(ssid) + if not self.is_connection_saved(ssid): + self._connections[ssid] = ssid + self._wifi_state = WifiState(ssid=ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = DESKTOP_FAKE_IP if ssid != self._tethering_ssid else TETHERING_IP_ADDRESS + self._enqueue_callbacks(self._activated) + self._enqueue_callbacks(self._networks_updated, self.networks) + return - self._connecting_to_ssid = ssid - self._router_main.send(new_method_call(self._nm, 'ActivateConnection', 'ooo', - (conn_path, self._wifi_device, "/"))) + self._set_connecting(ssid) + + def worker(): + conn_path = self._connections.get(ssid, None) + if conn_path is None or self._wifi_device is None: + cloudlog.warning(f"Failed to activate connection for {ssid}: conn_path={conn_path}, wifi_device={self._wifi_device}") + # TODO: expose a failed connection state in the UI + self._init_wifi_state() + return + + reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'ActivateConnection', 'ooo', + (conn_path, self._wifi_device, "/"))) + + if reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to activate connection for {ssid}: {reply}") + # TODO: expose a failed connection state in the UI + self._init_wifi_state() if block: worker() @@ -698,122 +914,65 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() def _deactivate_connection(self, ssid: str): - if self._fake_networking: - if self._fake_connected_ssid == ssid: - self._fake_connected_ssid = None - self._update_networks() - self._enqueue_callbacks(self._disconnected) - return if self._nmcli_networking: - subprocess.run(["nmcli", "connection", "down", "id", ssid], check=False, + conn_id = self._connections.get(ssid, ssid) + subprocess.run(["nmcli", "connection", "down", "id", conn_id], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + self._set_connecting(None) self._update_networks() self._enqueue_callbacks(self._disconnected) return - for conn_path in self._get_active_connections(): - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1] + if self._fake_networking: + if self._wifi_state.ssid == ssid: + self._wifi_state = WifiState() + self._ipv4_address = "" + self._enqueue_callbacks(self._disconnected) + self._enqueue_callbacks(self._networks_updated, self.networks) + return + + for active_conn in self._get_active_connections(): + conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) + reply = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')) + if reply.header.message_type == MessageType.error: + continue # object gone (e.g. rapid connect/disconnect) + + specific_obj_path = reply.body[0][1] if specific_obj_path != "/": ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE) - ap_ssid = bytes(self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')).body[0][1]).decode("utf-8", "replace") + ap_reply = self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')) + if ap_reply.header.message_type == MessageType.error: + continue # AP gone (e.g. mode switch) - if _canonicalize_ssid(ap_ssid) == _canonicalize_ssid(ssid): - self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (conn_path,))) + ap_ssid = bytes(ap_reply.body[0][1]).decode("utf-8", "replace") + + if ap_ssid == ssid: + self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,))) return def is_tethering_active(self) -> bool: - if self._fake_networking: - for network in self._networks: - if network.is_connected: - return bool(network.ssid == self._tethering_ssid) - return False + # Check ssid, not connected_ssid, to also catch connecting state + return self._wifi_state.ssid == self._tethering_ssid - if self._nmcli_networking: - try: - active = subprocess.run( - ["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show", "--active"], - check=False, capture_output=True, text=True, - ) - for line in active.stdout.splitlines(): - parts = self._parse_nmcli_line(line) - if len(parts) < 3 or parts[1] != "802-11-wireless": - continue - if _canonicalize_ssid(parts[2]) == _canonicalize_ssid(self._tethering_ssid): - return True - except Exception as e: - cloudlog.warning(f"nmcli tethering state lookup failed: {e}") - return False - - if not self._dbus_available: - return False - - def decode_ssid(value: Any) -> str: - if isinstance(value, bytes): - return value.decode("utf-8", "replace") - if isinstance(value, str): - return value - if isinstance(value, list): - try: - return bytes(value).decode("utf-8", "replace") - except Exception: - return str(value) - return str(value) - - try: - for conn_path in self._get_active_connections(): - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1] - if conn_type != '802-11-wireless': - continue - - settings_conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1] - if settings_conn_path != "/": - settings = self._get_connection_settings(settings_conn_path) - ssid_value = settings.get('802-11-wireless', {}).get('ssid') - if isinstance(ssid_value, tuple) and len(ssid_value) > 1: - ssid = decode_ssid(ssid_value[1]) - if _canonicalize_ssid(ssid) == _canonicalize_ssid(self._tethering_ssid): - return True - - specific_obj_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('SpecificObject')).body[0][1] - if specific_obj_path != "/": - ap_addr = DBusAddress(specific_obj_path, bus_name=NM, interface=NM_ACCESS_POINT_IFACE) - ap_ssid = decode_ssid(self._router_main.send_and_get_reply(Properties(ap_addr).get('Ssid')).body[0][1]) - if _canonicalize_ssid(ap_ssid) == _canonicalize_ssid(self._tethering_ssid): - return True - except Exception as e: - cloudlog.warning(f"DBus tethering state lookup failed: {e}") - - for network in self._networks: - if network.is_connected: - return bool(network.ssid == self._tethering_ssid) - return False - - def disconnect_network(self, ssid: str, block: bool = False): - if not (self._dbus_available or self._fake_networking or self._nmcli_networking): - cloudlog.warning("disconnect_network called with no available networking backend") - return - - def worker(): - self._deactivate_connection(ssid) - - if block: - worker() - else: - threading.Thread(target=worker, daemon=True).start() + def is_connection_saved(self, ssid: str) -> bool: + return ssid in self._connections def set_tethering_password(self, password: str): - if self._fake_networking: - self._tethering_password = password + if self._backend_unavailable: + cloudlog.warning("Ignoring set_tethering_password(); Wi-Fi backend unavailable") return + if self._nmcli_networking: self._tethering_password = password return + if self._fake_networking: + self._tethering_password = password + return + def worker(): - conn_path = self._get_connections().get(self._tethering_ssid, None) + conn_path = self._connections.get(self._tethering_ssid, None) if conn_path is None: cloudlog.warning('No tethering connection found') return @@ -838,12 +997,13 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() def _get_tethering_password(self) -> str: - if self._fake_networking: - return self._tethering_password + if self._backend_unavailable: + return "" + if self._nmcli_networking: return self._tethering_password or DEFAULT_TETHERING_PASSWORD - conn_path = self._get_connections().get(self._tethering_ssid, None) + conn_path = self._connections.get(self._tethering_ssid, None) if conn_path is None: cloudlog.warning('No tethering connection found') return '' @@ -867,24 +1027,27 @@ class WifiManager: self._ipv4_forward = enabled def set_tethering_active(self, active: bool): - if self._fake_networking: - def worker(): - if active: - if self._tethering_ssid not in self._fake_known_networks: - self._fake_known_networks[self._tethering_ssid] = {"security": SecurityType.WPA, "saved": True, "strength": 100} - self._fake_connected_ssid = self._tethering_ssid - else: - if self._fake_connected_ssid == self._tethering_ssid: - replacement = next((s for s in self._fake_known_networks.keys() if s != self._tethering_ssid), None) - self._fake_connected_ssid = replacement - self._update_networks() - - threading.Thread(target=worker, daemon=True).start() + if self._backend_unavailable: + cloudlog.warning(f"Ignoring set_tethering_active({active}); Wi-Fi backend unavailable") return + if self._nmcli_networking: cloudlog.warning("Tethering control is not supported via nmcli fallback backend") return + if self._fake_networking: + if active: + if self._tethering_ssid not in self._connections: + self._connections[self._tethering_ssid] = self._tethering_ssid + if not any(network.ssid == self._tethering_ssid for network in self._networks): + self._networks.append(Network(ssid=self._tethering_ssid, strength=100, security_type=SecurityType.WPA, is_tethering=True)) + self._wifi_state = WifiState(ssid=self._tethering_ssid, status=ConnectStatus.CONNECTED) + self._ipv4_address = TETHERING_IP_ADDRESS + else: + self._deactivate_connection(self._tethering_ssid) + self._enqueue_callbacks(self._networks_updated, self.networks) + return + def worker(): if active: self.activate_connection(self._tethering_ssid, block=True) @@ -898,81 +1061,49 @@ class WifiManager: threading.Thread(target=worker, daemon=True).start() - def _update_current_network_metered(self) -> None: - if self._nmcli_networking: - self._current_network_metered = MeteredType.UNKNOWN - return - - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - return - - self._current_network_metered = MeteredType.UNKNOWN - for active_conn in self._get_active_connections(): - conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1] - - if conn_type == '802-11-wireless': - conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1] - if conn_path == "/": - continue - - settings = self._get_connection_settings(conn_path) - - if len(settings) == 0: - cloudlog.warning(f'Failed to get connection settings for {conn_path}') - continue - - metered_prop = settings['connection'].get('metered', ('i', 0))[1] - if metered_prop == MeteredType.YES: - self._current_network_metered = MeteredType.YES - elif metered_prop == MeteredType.NO: - self._current_network_metered = MeteredType.NO - return - def set_current_network_metered(self, metered: MeteredType): + if self._backend_unavailable: + cloudlog.warning(f"Ignoring set_current_network_metered({metered}); Wi-Fi backend unavailable") + return + + if self._nmcli_networking: + self._current_network_metered = metered + self._enqueue_callbacks(self._networks_updated, self.networks) + return + if self._fake_networking: self._current_network_metered = metered - self._enqueue_callbacks(self._networks_updated, self._networks) + self._enqueue_callbacks(self._networks_updated, self.networks) return - if self._nmcli_networking: - self._current_network_metered = metered - self._enqueue_callbacks(self._networks_updated, self._networks) - return - - # Keep UI state responsive while the DBus update completes. - self._current_network_metered = metered - self._enqueue_callbacks(self._networks_updated, self._networks) def worker(): - for active_conn in self._get_active_connections(): - conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1] + if self.is_tethering_active(): + return - if conn_type == '802-11-wireless' and not self.is_tethering_active(): - conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1] - if conn_path == "/": - continue + conn_path, _ = self._get_active_wifi_connection() + if conn_path is None: + cloudlog.warning('No active WiFi connection found') + return - settings = self._get_connection_settings(conn_path) + settings = self._get_connection_settings(conn_path) - if len(settings) == 0: - cloudlog.warning(f'Failed to get connection settings for {conn_path}') - return + if len(settings) == 0: + cloudlog.warning(f'Failed to get connection settings for {conn_path}') + return - settings['connection']['metered'] = ('i', int(metered)) + settings['connection']['metered'] = ('i', int(metered)) - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) - reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,))) - if reply.header.message_type == MessageType.error: - cloudlog.warning(f'Failed to update tethering settings: {reply}') - return + conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_CONNECTION_IFACE) + reply = self._router_main.send_and_get_reply(new_method_call(conn_addr, 'Update', 'a{sa{sv}}', (settings,))) + if reply.header.message_type == MessageType.error: + cloudlog.warning(f'Failed to update metered settings: {reply}') threading.Thread(target=worker, daemon=True).start() def _request_scan(self): if self._nmcli_networking: - subprocess.run(["nmcli", "device", "wifi", "rescan"], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(["nmcli", "device", "wifi", "rescan"], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return if self._wifi_device is None: @@ -985,116 +1116,114 @@ class WifiManager: if reply.header.message_type == MessageType.error: cloudlog.warning(f"Failed to request scan: {reply}") - def _update_networks(self): - if self._fake_networking: - self._update_networks_fake() - return - if self._nmcli_networking: - self._update_networks_nmcli() + def _update_networks(self, block: bool = True): + if not self._active: return - with self._lock: - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - return + def worker(): + with self._scan_lock: + if self._nmcli_networking: + self._update_networks_nmcli_locked() + return - # returns '/' if no active AP - wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE) - active_ap_path = self._router_main.send_and_get_reply(Properties(wifi_addr).get('ActiveAccessPoint')).body[0][1] - ap_paths = self._router_main.send_and_get_reply(new_method_call(wifi_addr, 'GetAllAccessPoints')).body[0] + if self._wifi_device is None: + cloudlog.warning("No WiFi device found") + return - aps: dict[str, list[AccessPoint]] = {} + # NOTE: AccessPoints property may exclude hidden APs (use GetAllAccessPoints method if needed) + wifi_addr = DBusAddress(self._wifi_device, NM, interface=NM_WIRELESS_IFACE) + wifi_props_reply = self._router_main.send_and_get_reply(Properties(wifi_addr).get_all()) + if wifi_props_reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to get WiFi properties: {wifi_props_reply}") + return - for ap_path in ap_paths: - ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE) - ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all()) + ap_paths = wifi_props_reply.body[0].get('AccessPoints', ('ao', []))[1] - # some APs have been seen dropping off during iteration - if ap_props.header.message_type == MessageType.error: - cloudlog.warning(f"Failed to get AP properties for {ap_path}") - continue + aps: dict[str, list[AccessPoint]] = {} - try: - ap = AccessPoint.from_dbus(ap_props.body[0], ap_path, active_ap_path) - if ap.ssid == "": + for ap_path in ap_paths: + ap_addr = DBusAddress(ap_path, NM, interface=NM_ACCESS_POINT_IFACE) + ap_props = self._router_main.send_and_get_reply(Properties(ap_addr).get_all()) + + # some APs have been seen dropping off during iteration + if ap_props.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to get AP properties for {ap_path}") continue - if ap.ssid not in aps: - aps[ap.ssid] = [] + try: + ap = AccessPoint.from_dbus(ap_props.body[0], ap_path) + if ap.ssid == "": + continue - aps[ap.ssid].append(ap) - except Exception: - # catch all for parsing errors - cloudlog.exception(f"Failed to parse AP properties for {ap_path}") + if ap.ssid not in aps: + aps[ap.ssid] = [] - known_connections = self._get_connections() - networks = [Network.from_dbus(ssid, ap_list, _canonicalize_ssid(ssid) in known_connections) for ssid, ap_list in aps.items()] - # sort with quantized strength to reduce jumping - networks.sort(key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower())) - self._networks = networks + aps[ap.ssid].append(ap) + except Exception: + # catch all for parsing errors + cloudlog.exception(f"Failed to parse AP properties for {ap_path}") - self._update_ipv4_address() - self._update_current_network_metered() + self._networks = [Network.from_dbus(ssid, ap_list, ssid == self._tethering_ssid) for ssid, ap_list in aps.items()] + self._update_active_connection_info() + self._enqueue_callbacks(self._networks_updated, self.networks) # sorted - self._enqueue_callbacks(self._networks_updated, self._networks) + if block: + worker() + else: + threading.Thread(target=worker, daemon=True).start() - def _update_ipv4_address(self): + def _update_active_connection_info(self): if self._nmcli_networking: - self._ipv4_address = "" - try: - status = subprocess.run( - ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device", "status"], - check=False, capture_output=True, text=True, - ) - wifi_dev = None - for line in status.stdout.splitlines(): - parts = line.split(":") - if len(parts) >= 3 and parts[1] == "wifi" and parts[2].startswith("connected"): - wifi_dev = parts[0] + self._current_network_metered = MeteredType.UNKNOWN + return + + ipv4_address = "" + metered = MeteredType.UNKNOWN + + conn_path, props = self._get_active_wifi_connection() + + if conn_path is not None and props is not None: + # IPv4 address + ip4config_path = props.get('Ip4Config', ('o', '/'))[1] + + if ip4config_path != "/": + ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE) + address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1] + + for entry in address_data: + if 'address' in entry: + ipv4_address = entry['address'][1] break - if wifi_dev: - addr = subprocess.run( - ["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", wifi_dev], - check=False, capture_output=True, text=True, - ) - for row in addr.stdout.splitlines(): - if row: - self._ipv4_address = row.split(":", 1)[-1].split("/", 1)[0] - break - except Exception as e: - cloudlog.warning(f"nmcli ipv4 lookup failed: {e}") - return - if self._wifi_device is None: - cloudlog.warning("No WiFi device found") - return + # Metered status + settings = self._get_connection_settings(conn_path) - self._ipv4_address = "" + if len(settings) > 0: + metered_prop = settings['connection'].get('metered', ('i', 0))[1] - for conn_path in self._get_active_connections(): - conn_addr = DBusAddress(conn_path, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) - conn_type = self._router_main.send_and_get_reply(Properties(conn_addr).get('Type')).body[0][1] - if conn_type == '802-11-wireless': - ip4config_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Ip4Config')).body[0][1] + if metered_prop == MeteredType.YES: + metered = MeteredType.YES + elif metered_prop == MeteredType.NO: + metered = MeteredType.NO - if ip4config_path != "/": - ip4config_addr = DBusAddress(ip4config_path, bus_name=NM, interface=NM_IP4_CONFIG_IFACE) - address_data = self._router_main.send_and_get_reply(Properties(ip4config_addr).get('AddressData')).body[0][1] - - for entry in address_data: - if 'address' in entry: - self._ipv4_address = entry['address'][1] - return + self._ipv4_address = ipv4_address + self._current_network_metered = metered def __del__(self): self.stop() def update_gsm_settings(self, roaming: bool, apn: str, metered: bool): """Update GSM settings for cellular connection""" - if self._fake_networking: + + if self._backend_unavailable: + cloudlog.warning("Ignoring update_gsm_settings(); Wi-Fi backend unavailable") return + if self._nmcli_networking: - cloudlog.warning("GSM settings update is unavailable in nmcli fallback mode") + cloudlog.warning("Ignoring update_gsm_settings(); nmcli backend unavailable for GSM settings") + return + + if self._fake_networking: return def worker(): @@ -1209,77 +1338,116 @@ class WifiManager: out.append("".join(cur)) return out - def _update_networks_nmcli(self): - with self._lock: - networks_by_ssid: dict[str, Network] = {} - saved_ssids: set[str] = set() + def _update_networks_nmcli_locked(self): + networks_by_ssid: dict[str, Network] = {} + saved_connections: dict[str, str] = {} + active_ssid: str | None = None + active_device: str | None = None - try: - saved = subprocess.run( - ["nmcli", "-t", "-f", "NAME,TYPE,802-11-wireless.ssid", "connection", "show"], - check=False, capture_output=True, text=True, - ) - for line in saved.stdout.splitlines(): - parts = self._parse_nmcli_line(line) - if len(parts) >= 3 and parts[1] == "802-11-wireless" and parts[2]: - saved_ssids.add(_canonicalize_ssid(parts[2])) - saved_ssids.add(_canonicalize_ssid(parts[0])) - except Exception as e: - cloudlog.warning(f"nmcli saved networks query failed: {e}") - - try: - result = subprocess.run( - ["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"], - check=False, capture_output=True, text=True, - ) - for line in result.stdout.splitlines(): - parts = self._parse_nmcli_line(line) - if len(parts) < 4: - continue - in_use, ssid, signal, security = parts[:4] - if not ssid: - continue - ssid_key = _canonicalize_ssid(ssid) - try: - strength = int(signal or 0) - except ValueError: - strength = 0 - - security_type = SecurityType.OPEN if security in ("", "--") else SecurityType.WPA - is_connected = in_use.startswith("*") - is_saved = ssid_key in saved_ssids - - existing = networks_by_ssid.get(ssid_key) - should_replace = ( - existing is None or - (is_connected and not existing.is_connected) or - (existing is not None and is_connected == existing.is_connected and strength > existing.strength) - ) - - if should_replace: - prior_saved = existing.is_saved if existing is not None else False - networks_by_ssid[ssid_key] = Network( - ssid=ssid, - strength=strength, - is_connected=is_connected, - security_type=security_type, - is_saved=is_saved or prior_saved, - ) - elif existing is not None: - networks_by_ssid[ssid_key] = Network( - ssid=existing.ssid, - strength=existing.strength, - is_connected=existing.is_connected or is_connected, - security_type=existing.security_type, - is_saved=existing.is_saved or is_saved, - ) - except Exception as e: - cloudlog.warning(f"nmcli scan failed: {e}") - - self._networks = sorted( - networks_by_ssid.values(), - key=lambda n: (-n.is_connected, -round(n.strength / 100 * 2), n.ssid.lower()), + try: + saved = subprocess.run( + ["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"], + check=False, capture_output=True, text=True, ) - self._update_ipv4_address() - self._current_network_metered = MeteredType.UNKNOWN - self._enqueue_callbacks(self._networks_updated, self._networks) + for line in saved.stdout.splitlines(): + parts = self._parse_nmcli_line(line) + if len(parts) < 2 or parts[1] != "802-11-wireless": + continue + + conn_id = parts[0] + ssid_lookup = subprocess.run( + ["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_id], + check=False, capture_output=True, text=True, + ) + ssid = ssid_lookup.stdout.strip() if ssid_lookup.returncode == 0 else "" + if ssid: + saved_connections[ssid] = conn_id + elif conn_id: + # Fallback for older/odd NetworkManager profiles where the connection + # name is the best identifier available. + saved_connections[conn_id] = conn_id + except Exception as e: + cloudlog.warning(f"nmcli saved networks query failed: {e}") + + self._connections = saved_connections + + try: + status = subprocess.run( + ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status"], + check=False, capture_output=True, text=True, + ) + for line in status.stdout.splitlines(): + parts = self._parse_nmcli_line(line) + if len(parts) >= 4 and parts[1] == "wifi": + device, _dev_type, state, connection = parts[:4] + if state.startswith("connected"): + active_device = device + active_ssid = next((ssid for ssid, conn_id in saved_connections.items() if conn_id == connection), + connection if connection not in ("", "--") else None) + break + except Exception as e: + cloudlog.warning(f"nmcli device status query failed: {e}") + + try: + result = subprocess.run( + ["nmcli", "-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY", "device", "wifi", "list", "--rescan", "no"], + check=False, capture_output=True, text=True, + ) + for line in result.stdout.splitlines(): + parts = self._parse_nmcli_line(line) + if len(parts) < 4: + continue + in_use, ssid, signal, security = parts[:4] + if not ssid: + continue + + try: + strength = int(signal or 0) + except ValueError: + strength = 0 + + is_tethering = ssid == self._tethering_ssid + security_type = SecurityType.OPEN if security in ("", "--") else SecurityType.WPA + existing = networks_by_ssid.get(ssid) + if existing is None or strength > existing.strength or in_use.startswith("*"): + networks_by_ssid[ssid] = Network( + ssid=ssid, + strength=100 if is_tethering else strength, + security_type=security_type, + is_tethering=is_tethering, + ) + except Exception as e: + cloudlog.warning(f"nmcli scan failed: {e}") + + if active_ssid and active_ssid not in networks_by_ssid: + previous = next((network for network in self._networks if network.ssid == active_ssid), None) + networks_by_ssid[active_ssid] = Network( + ssid=active_ssid, + strength=previous.strength if previous is not None else 100, + security_type=previous.security_type if previous is not None else SecurityType.WPA, + is_tethering=active_ssid == self._tethering_ssid, + ) + + self._networks = list(networks_by_ssid.values()) + + if active_ssid is not None: + self._wifi_state = WifiState(ssid=active_ssid, status=ConnectStatus.CONNECTED) + elif self._wifi_state.status != ConnectStatus.CONNECTING: + self._wifi_state = WifiState() + + self._ipv4_address = "" + if active_device: + try: + addr = subprocess.run( + ["nmcli", "-t", "-f", "IP4.ADDRESS", "device", "show", active_device], + check=False, capture_output=True, text=True, + ) + for row in addr.stdout.splitlines(): + if row: + self._ipv4_address = row.split(":", 1)[-1].split("/", 1)[0] + break + except Exception as e: + cloudlog.warning(f"nmcli ipv4 lookup failed: {e}") + + self._current_network_metered = MeteredType.UNKNOWN + self._enqueue_callbacks(self._networks_updated, self.networks) diff --git a/system/ui/mici_reset.py b/system/ui/mici_reset.py old mode 100644 new mode 100755 index e5468b827..9cc6e7f3f --- a/system/ui/mici_reset.py +++ b/system/ui/mici_reset.py @@ -1,78 +1,109 @@ #!/usr/bin/env python3 import os import sys -import threading import time +import threading from enum import IntEnum import pyray as rl -from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.slider import SmallSlider -from openpilot.system.ui.widgets.button import SmallButton, FullRoundedButton -from openpilot.system.ui.widgets.label import gui_label, gui_text_box +from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.mici_setup import GreyBigButton, FailedPage +from openpilot.selfdrive.ui.mici.widgets.dialog import BigDialog, BigConfirmationCircleButton USERDATA = "/dev/disk/by-partlabel/userdata" TIMEOUT = 3*60 -PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON")) class ResetMode(IntEnum): USER_RESET = 0 # user initiated a factory reset from openpilot RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover - FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata + TAP_RESET = 2 # user initiated a factory reset by tapping the screen during boot -class ResetState(IntEnum): - NONE = 0 - RESETTING = 1 - FAILED = 2 +class ResetFailedPage(FailedPage): + def __init__(self): + super().__init__(None, "reset failed", "reboot to try again", icon="icons_mici/setup/reset_failed.png") + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + + def _back_enabled(self) -> bool: + return False -class Reset(Widget): +class ResettingPage(BigDialog): + DOT_STEP = 0.6 + + def __init__(self): + super().__init__("resetting device", "this may take up to\na minute...", + gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) + self._show_time = 0.0 + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + self._show_time = rl.get_time() + + def _back_enabled(self) -> bool: + return False + + def _render(self, _): + t = (rl.get_time() - self._show_time) % (self.DOT_STEP * 2) + dots = "." * min(int(t / (self.DOT_STEP / 4)), 3) + self._card.set_value(f"this may take up to\na minute{dots}") + super()._render(_) + + +class Reset(Scroller): def __init__(self, mode): super().__init__() self._mode = mode - self._previous_reset_state = None - self._reset_state = ResetState.NONE + self._previous_active_widget = None + self._reset_failed = False + self._timeout_st = time.monotonic() - self._cancel_button = SmallButton("cancel") - self._cancel_button.set_click_callback(self._cancel_callback) + self._resetting_page = ResettingPage() + self._reset_failed_page = ResetFailedPage() - self._reboot_button = FullRoundedButton("reboot") - self._reboot_button.set_click_callback(self._do_reboot) + self._reset_button = BigConfirmationCircleButton("reset &\nerase", gui_app.texture("icons_mici/settings/device/uninstall.png", 70, 70), + self._start_reset, exit_on_confirm=False, red=True) + self._cancel_button = BigConfirmationCircleButton("cancel", gui_app.texture("icons_mici/setup/cancel.png", 64, 64), + gui_app.request_close, exit_on_confirm=False) + self._reboot_button = BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), + HARDWARE.reboot, exit_on_confirm=False) - self._confirm_slider = SmallSlider("reset", self._confirm) + # show reboot button if in recover mode + self._cancel_button.set_visible(mode != ResetMode.RECOVER) + self._reboot_button.set_visible(mode == ResetMode.RECOVER) - self._render_status = True + main_card = GreyBigButton("factory reset", "resetting erases\nall user content & data", + gui_app.texture("icons_mici/setup/factory_reset.png", 64, 64)) + self._scroller.add_widget(main_card) - def _cancel_callback(self): - self._render_status = False + if mode != ResetMode.USER_RESET: + self._scroller.add_widget(GreyBigButton("", "Resetting erases all user content & data.")) + if mode == ResetMode.RECOVER: + main_card.set_value("user data partition\ncould not be mounted") + elif mode == ResetMode.TAP_RESET: + main_card.set_value("reset triggered by\ntapping the screen") - def _do_reboot(self): - if PC: - return + self._scroller.add_widgets([ + GreyBigButton("", "For a deeper reset, go to\nhttps://flash.comma.ai"), + self._cancel_button, + self._reboot_button, + self._reset_button, + ]) - os.system("sudo reboot") - - def _backup_ssh_params(self): - if PC: - return - - backup_dir = "/cache/reset_backup" - os.system(f"sudo rm -rf {backup_dir}") - os.system(f"sudo mkdir -p {backup_dir}") - for key in ("GithubSshKeys", "SshEnabled"): - os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true") - os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true") + gui_app.add_nav_stack_tick(self._nav_stack_tick) def _do_erase(self): if PC: return - self._backup_ssh_params() - # Removing data and formatting rm = os.system("sudo rm -rf /data/*") os.system(f"sudo umount {USERDATA}") @@ -81,92 +112,42 @@ class Reset(Widget): if rm == 0 or fmt == 0: os.system("sudo reboot") else: - self._reset_state = ResetState.FAILED + self._reset_failed = True - def start_reset(self): - self._reset_state = ResetState.RESETTING - threading.Timer(0.1, self._do_erase).start() + def _start_reset(self): + def do_erase_thread(): + threading.Thread(target=self._do_erase, daemon=True).start() - def _update_state(self): - if self._reset_state != self._previous_reset_state: - self._previous_reset_state = self._reset_state + self._resetting_page.set_shown_callback(do_erase_thread) + gui_app.push_widget(self._resetting_page) + + def _nav_stack_tick(self): + if self._reset_failed: + self._reset_failed = False + gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._reset_failed_page)) + + active_widget = gui_app.get_active_widget() + if active_widget != self._previous_active_widget: + self._previous_active_widget = active_widget self._timeout_st = time.monotonic() - elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT: + elif self._mode != ResetMode.RECOVER and active_widget != self._resetting_page and (time.monotonic() - self._timeout_st) > TIMEOUT: exit(0) - def _render(self, rect: rl.Rectangle): - label_rect = rl.Rectangle(rect.x + 8, rect.y + 8, rect.width, 50) - gui_label(label_rect, "factory reset", 48, font_weight=FontWeight.BOLD, - color=rl.Color(255, 255, 255, int(255 * 0.9))) - - text_rect = rl.Rectangle(rect.x + 8, rect.y + 56, rect.width - 8 * 2, rect.height - 80) - gui_text_box(text_rect, self._get_body_text(), 36, font_weight=FontWeight.ROMAN, line_scale=0.9) - - if self._reset_state != ResetState.RESETTING: - # fade out cancel button as slider is moved, set visible to prevent pressing invisible cancel - self._cancel_button.set_opacity(1.0 - self._confirm_slider.slider_percentage) - self._cancel_button.set_visible(self._confirm_slider.slider_percentage < 0.8) - - if self._mode == ResetMode.RECOVER: - self._cancel_button.set_text("reboot") - self._cancel_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._cancel_button.rect.height, - self._cancel_button.rect.width, - self._cancel_button.rect.height)) - elif self._mode == ResetMode.USER_RESET and self._reset_state != ResetState.FAILED: - self._cancel_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._cancel_button.rect.height, - self._cancel_button.rect.width, - self._cancel_button.rect.height)) - - if self._reset_state != ResetState.FAILED: - self._confirm_slider.render(rl.Rectangle( - rect.x + rect.width - self._confirm_slider.rect.width, - rect.y + rect.height - self._confirm_slider.rect.height, - self._confirm_slider.rect.width, - self._confirm_slider.rect.height)) - else: - self._reboot_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._reboot_button.rect.height, - self._reboot_button.rect.width, - self._reboot_button.rect.height)) - - return self._render_status - - def _confirm(self): - self.start_reset() - - def _get_body_text(self): - if self._reset_state == ResetState.RESETTING: - return "Resetting device... This may take up to a minute." - if self._reset_state == ResetState.FAILED: - return "Reset failed. Reboot to try again." - if self._mode == ResetMode.RECOVER: - return "Unable to mount data partition. It may be corrupted." - return "All content and settings will be erased." - def main(): mode = ResetMode.USER_RESET if len(sys.argv) > 1: if sys.argv[1] == '--recover': mode = ResetMode.RECOVER - elif sys.argv[1] == "--format": - mode = ResetMode.FORMAT + elif sys.argv[1] == '--tap-reset': + mode = ResetMode.TAP_RESET gui_app.init_window("System Reset") reset = Reset(mode) + gui_app.push_widget(reset) - if mode == ResetMode.FORMAT: - reset.start_reset() - - for should_render in gui_app.render(): - if should_render: - if not reset.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)): - break + for _ in gui_app.render(): + pass if __name__ == "__main__": diff --git a/system/ui/mici_setup.py b/system/ui/mici_setup.py old mode 100644 new mode 100755 index 079267b67..52b13cc79 --- a/system/ui/mici_setup.py +++ b/system/ui/mici_setup.py @@ -1,61 +1,52 @@ #!/usr/bin/env python3 -from abc import abstractmethod import os import re +import ssl import threading import time import urllib.request import urllib.error from urllib.parse import urlparse -from enum import IntEnum -import shutil from collections.abc import Callable import pyray as rl from cereal import log +from openpilot.common.filter_simple import BounceFilter +from openpilot.system.hardware import HARDWARE, TICI +from openpilot.common.realtime import config_realtime_process, set_core_affinity +from openpilot.common.swaglog import cloudlog +from openpilot.common.time_helpers import system_time_valid from openpilot.common.utils import run_cmd -from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.wifi_manager import WifiManager -from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2 -from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.button import (IconButton, SmallButton, WideRoundedButton, SmallerRoundedButton, - SmallCircleIconButton, WidishRoundedButton, SmallRedPillButton, - FullRoundedButton) +from openpilot.system.ui.lib.wifi_manager import WifiManager, ConnectStatus +from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.system.ui.widgets.slider import LargerSlider, SmallSlider -from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiUIMici -from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog +from openpilot.system.ui.widgets.scroller import Scroller, NavScroller, ITEM_SPACING +from openpilot.system.ui.widgets.slider import LargerSlider +from openpilot.selfdrive.ui.mici.layouts.settings.network import WifiNetworkButton +from openpilot.selfdrive.ui.mici.layouts.settings.network.wifi_ui import WifiUIMici +from openpilot.selfdrive.ui.mici.widgets.dialog import BigInputDialog, BigConfirmationCircleButton +from openpilot.selfdrive.ui.mici.widgets.button import BigButton, GreyBigButton NetworkType = log.DeviceState.NetworkType -NETWORK_CHECK_URL = "https://openpilot.comma.ai" -DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot" +OPENPILOT_URL = "https://openpilot.comma.ai" USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" -CONTINUE_PATH = "/data/continue.sh" -TMP_CONTINUE_PATH = "/data/continue.sh.new" -INSTALL_PATH = "/data/openpilot" -VALID_CACHE_PATH = "/data/.openpilot_cache" -INSTALLER_SOURCE_PATH = "/usr/comma/installer" INSTALLER_DESTINATION_PATH = "/tmp/installer" INSTALLER_URL_PATH = "/tmp/installer_url" -CONTINUE = """#!/usr/bin/env bash - -cd /data/openpilot -exec ./launch_openpilot.sh -""" - class NetworkConnectivityMonitor: - def __init__(self, should_check: Callable[[], bool] | None = None, check_interval: float = 0.5): + def __init__(self, should_check: Callable[[], bool] | None = None): self.network_connected = threading.Event() self.wifi_connected = threading.Event() + self.recheck_event = threading.Event() self._should_check = should_check or (lambda: True) - self._check_interval = check_interval self._stop_event = threading.Event() + self._last_timesyncd_restart = 0.0 self._thread: threading.Thread | None = None def start(self): @@ -74,35 +65,41 @@ class NetworkConnectivityMonitor: self.network_connected.clear() self.wifi_connected.clear() + def invalidate(self): + self.recheck_event.set() + self.reset() + def _run(self): while not self._stop_event.is_set(): if self._should_check(): try: - request = urllib.request.Request(NETWORK_CHECK_URL, method="HEAD") - urllib.request.urlopen(request, timeout=0.5) + request = urllib.request.Request(OPENPILOT_URL, method="HEAD") + urllib.request.urlopen(request, timeout=2.0) + + # Discard stale result if invalidated during request + if self.recheck_event.is_set(): + self.recheck_event.clear() + continue + self.network_connected.set() if HARDWARE.get_network_type() == NetworkType.wifi: self.wifi_connected.set() + except urllib.error.URLError as e: + if (isinstance(e.reason, ssl.SSLCertVerificationError) and + not system_time_valid() and + time.monotonic() - self._last_timesyncd_restart > 5): + self._last_timesyncd_restart = time.monotonic() + run_cmd(["sudo", "systemctl", "restart", "systemd-timesyncd"]) + self.reset() except Exception: self.reset() else: self.reset() - if self._stop_event.wait(timeout=self._check_interval): + if self._stop_event.wait(timeout=1.0): break -class SetupState(IntEnum): - GETTING_STARTED = 0 - NETWORK_SETUP = 1 - NETWORK_SETUP_CUSTOM_SOFTWARE = 8 - SOFTWARE_SELECTION = 2 - CUSTOM_SOFTWARE = 3 - DOWNLOADING = 4 - DOWNLOAD_FAILED = 5 - CUSTOM_SOFTWARE_WARNING = 6 - - class StartPage(Widget): def __init__(self): super().__init__() @@ -111,35 +108,41 @@ class StartPage(Widget): font_weight=FontWeight.DISPLAY, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - self._start_bg_txt = gui_app.texture("icons_mici/setup/green_button.png", 520, 224) - self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/green_button_pressed.png", 520, 224) - # Match The Galaxy accent palette while keeping existing setup assets/layout intact. - self._start_bg_tint = rl.Color(94, 200, 200, 255) - self._start_bg_pressed_tint = rl.Color(75, 168, 168, 255) + self._start_bg_txt = gui_app.texture("icons_mici/setup/start_button.png", 500, 224, keep_aspect_ratio=False) + self._start_bg_pressed_txt = gui_app.texture("icons_mici/setup/start_button_pressed.png", 500, 224, keep_aspect_ratio=False) + self._scale_filter = BounceFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._click_delay = 0.075 def _render(self, rect: rl.Rectangle): - draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2 - draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2 + scale = self._scale_filter.update(1.07 if self.is_pressed else 1.0) + base_draw_x = rect.x + (rect.width - self._start_bg_txt.width) / 2 + base_draw_y = rect.y + (rect.height - self._start_bg_txt.height) / 2 + draw_x = base_draw_x + (self._start_bg_txt.width * (1 - scale)) / 2 + draw_y = base_draw_y + (self._start_bg_txt.height * (1 - scale)) / 2 texture = self._start_bg_pressed_txt if self.is_pressed else self._start_bg_txt - tint = self._start_bg_pressed_tint if self.is_pressed else self._start_bg_tint - rl.draw_texture(texture, int(draw_x), int(draw_y), tint) + rl.draw_texture_ex(texture, (draw_x, draw_y), 0, scale, rl.WHITE) - self._title.render(rect) + self._title.render(rl.Rectangle(rect.x, rect.y + (draw_y - base_draw_y), rect.width, rect.height)) -class SoftwareSelectionPage(Widget): +class SoftwareSelectionPage(NavWidget): def __init__(self, use_openpilot_callback: Callable, use_custom_software_callback: Callable): super().__init__() - self._openpilot_slider = LargerSlider("slide to use\nstarpilot", use_openpilot_callback) - self._custom_software_slider = LargerSlider("slide to use\ncustom software", use_custom_software_callback, - green=False, shimmer_offset=0.4) + self._openpilot_slider = self._child(LargerSlider("slide to install\nopenpilot", use_openpilot_callback)) + self._openpilot_slider.set_enabled(lambda: self.enabled and not self.is_dismissing) + self._custom_software_slider = self._child(LargerSlider("slide to install\ncustom software", use_custom_software_callback, green=False, shimmer_offset=0.4)) + self._custom_software_slider.set_enabled(lambda: self.enabled and not self.is_dismissing) def show_event(self): super().show_event() - self._openpilot_slider.show_event() - self._custom_software_slider.show_event() + self._nav_bar._alpha = 0.0 + + def _update_state(self): + super()._update_state() + if self.is_dismissing: + self.reset() def reset(self): self._openpilot_slider.reset(reset_shimmer=False) @@ -166,530 +169,347 @@ class SoftwareSelectionPage(Widget): self._custom_software_slider.render(custom_software_rect) -class TermsHeader(Widget): - def __init__(self, text: str, icon_texture: rl.Texture): - super().__init__() - - self._title = UnifiedLabel(text, 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.BOLD, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - line_height=0.8) - self._icon_texture = icon_texture - - self.set_rect(rl.Rectangle(0, 0, gui_app.width - 16 * 2, self._icon_texture.height)) - - def set_title(self, text: str): - self._title.set_text(text) - - def set_icon(self, icon_texture: rl.Texture): - self._icon_texture = icon_texture - - def _render(self, _): - rl.draw_texture_ex(self._icon_texture, rl.Vector2(self._rect.x, self._rect.y), - 0.0, 1.0, rl.WHITE) - - # May expand outside parent rect - title_content_height = self._title.get_content_height(int(self._rect.width - self._icon_texture.width - 16)) - title_rect = rl.Rectangle( - self._rect.x + self._icon_texture.width + 16, - self._rect.y + (self._rect.height - title_content_height) / 2, - self._rect.width - self._icon_texture.width - 16, - title_content_height, - ) - self._title.render(title_rect) - - -class TermsPage(Widget): - ITEM_SPACING = 20 - - def __init__(self, continue_callback: Callable, back_callback: Callable | None = None, - back_text: str = "back", continue_text: str = "accept"): - super().__init__() - - # TODO: use Scroller - self._scroll_panel = GuiScrollPanel2(horizontal=False) - - self._continue_text = continue_text - self._continue_slider: bool = continue_text in ("reboot", "power off") - self._continue_button: WideRoundedButton | FullRoundedButton | SmallSlider - if self._continue_slider: - self._continue_button = SmallSlider(continue_text, confirm_callback=continue_callback) - self._scroll_panel.set_enabled(lambda: not self._continue_button.is_pressed) - elif back_callback is not None: - self._continue_button = WideRoundedButton(continue_text) - else: - self._continue_button = FullRoundedButton(continue_text) - self._continue_button.set_enabled(False) - self._continue_button.set_opacity(0.0) - self._continue_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid) - if not self._continue_slider: - self._continue_button.set_click_callback(continue_callback) - - self._enable_back = back_callback is not None - self._back_button = SmallButton(back_text) - self._back_button.set_opacity(0.0) - self._back_button.set_touch_valid_callback(self._scroll_panel.is_touch_valid) - self._back_button.set_click_callback(back_callback) - - self._scroll_down_indicator = IconButton(gui_app.texture("icons_mici/setup/scroll_down_indicator.png", 64, 78)) - self._scroll_down_indicator.set_enabled(False) - - def reset(self): - self._scroll_panel.set_offset(0) - self._continue_button.set_enabled(False) - self._continue_button.set_opacity(0.0) - self._back_button.set_enabled(False) - self._back_button.set_opacity(0.0) - self._scroll_down_indicator.set_opacity(1.0) - - def show_event(self): - super().show_event() - self.reset() - - @property - @abstractmethod - def _content_height(self): - pass - - @property - def _scrolled_down_offset(self): - return -self._content_height + (self._continue_button.rect.height + 16 + 30) - - @abstractmethod - def _render_content(self, scroll_offset): - pass - - def _render(self, _): - scroll_offset = round(self._scroll_panel.update(self._rect, self._content_height + self._continue_button.rect.height + 16)) - - if scroll_offset <= self._scrolled_down_offset: - # don't show back if not enabled - if self._enable_back: - self._back_button.set_enabled(True) - self._back_button.set_opacity(1.0, smooth=True) - self._continue_button.set_enabled(True) - self._continue_button.set_opacity(1.0, smooth=True) - self._scroll_down_indicator.set_opacity(0.0, smooth=True) - else: - self._back_button.set_enabled(False) - self._back_button.set_opacity(0.0, smooth=True) - self._continue_button.set_enabled(False) - self._continue_button.set_opacity(0.0, smooth=True) - self._scroll_down_indicator.set_opacity(1.0, smooth=True) - - # Render content - self._render_content(scroll_offset) - - # black gradient at top and bottom for scrolling content - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y), - int(self._rect.width), 20, rl.BLACK, rl.BLANK) - rl.draw_rectangle_gradient_v(int(self._rect.x), int(self._rect.y + self._rect.height - 20), - int(self._rect.width), 20, rl.BLANK, rl.BLACK) - - # fade out back button as slider is moved - if self._continue_slider and scroll_offset <= self._scrolled_down_offset: - self._back_button.set_opacity(1.0 - self._continue_button.slider_percentage) - self._back_button.set_visible(self._continue_button.slider_percentage < 0.99) - - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) - - continue_x = self._rect.x + 8 - if self._enable_back: - continue_x = self._rect.x + self._rect.width - self._continue_button.rect.width - 8 - if self._continue_slider: - continue_x += 8 - self._continue_button.render(rl.Rectangle( - continue_x, - self._rect.y + self._rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) - - self._scroll_down_indicator.render(rl.Rectangle( - self._rect.x + self._rect.width - self._scroll_down_indicator.rect.width - 8, - self._rect.y + self._rect.height - self._scroll_down_indicator.rect.height - 8, - self._scroll_down_indicator.rect.width, - self._scroll_down_indicator.rect.height, - )) - - -class CustomSoftwareWarningPage(TermsPage): +class CustomSoftwareWarningPage(NavScroller): def __init__(self, continue_callback: Callable, back_callback: Callable): - super().__init__(continue_callback, back_callback) + super().__init__() + self.set_back_callback(back_callback) - self._title_header = TermsHeader("use caution installing\n3rd party software", - gui_app.texture("icons_mici/setup/warning.png", 66, 60)) - self._body = UnifiedLabel("β€’ It has not been tested by comma.\n" + - "β€’ It may not comply with relevant safety standards.\n" + - "β€’ It may cause damage to your device and/or vehicle.\n", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.ROMAN) + self._continue_button = BigPillButton("next") + self._continue_button.set_click_callback(continue_callback) - self._restore_header = TermsHeader("how to backup &\nrestore", gui_app.texture("icons_mici/setup/restore.png", 60, 60)) - self._restore_body = UnifiedLabel("To restore your device to a factory state later, use https://flash.comma.ai", - 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.ROMAN) - - @property - def _content_height(self): - return self._restore_body.rect.y + self._restore_body.rect.height - self._scroll_panel.get_offset() - - def _render_content(self, scroll_offset): - self._title_header.set_position(self._rect.x + 16, self._rect.y + 8 + scroll_offset) - self._title_header.render() - - body_rect = rl.Rectangle( - self._rect.x + 8, - self._title_header.rect.y + self._title_header.rect.height + self.ITEM_SPACING, - self._rect.width - 50, - self._body.get_content_height(int(self._rect.width - 50)), - ) - self._body.render(body_rect) - - self._restore_header.set_position(self._rect.x + 16, self._body.rect.y + self._body.rect.height + self.ITEM_SPACING) - self._restore_header.render() - - self._restore_body.render(rl.Rectangle( - self._rect.x + 8, - self._restore_header.rect.y + self._restore_header.rect.height + self.ITEM_SPACING, - self._rect.width - 50, - self._restore_body.get_content_height(int(self._rect.width - 50)), - )) + self._scroller.add_widgets([ + GreyBigButton("caution: installing\n3rd party software", "swipe down to go back", + gui_app.texture("icons_mici/setup/warning.png", 64, 58)), + GreyBigButton("", "β€’ It has not been tested by comma."), + GreyBigButton("", "β€’ It may not comply with safety standards."), + GreyBigButton("", "β€’ It may damage your device and/or vehicle."), + GreyBigButton("how to restore to a\nfactory state later", "https://flash.comma.ai", + gui_app.texture("icons_mici/setup/restore.png", 64, 64)), + self._continue_button, + ]) -class DownloadingPage(Widget): +# TODO: unifi with updater's progress page +class DownloadingPage(NavWidget): def __init__(self): super().__init__() - self._title_label = UnifiedLabel("downloading", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + self._title_label = UnifiedLabel("downloading...", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), font_weight=FontWeight.DISPLAY) - self._progress_label = UnifiedLabel("", 128, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35)), + self._progress_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), font_weight=FontWeight.ROMAN, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) self._progress = 0 + def _back_enabled(self) -> bool: + return False + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + self.set_progress(0) + def set_progress(self, progress: int): self._progress = progress self._progress_label.set_text(f"{progress}%") def _render(self, rect: rl.Rectangle): + rl.draw_rectangle_rec(rect, rl.BLACK) self._title_label.render(rl.Rectangle( - rect.x + 20, - rect.y + 10, + rect.x + 12, + rect.y + 2, rect.width, 64, )) self._progress_label.render(rl.Rectangle( - rect.x + 20, - rect.y + 20, + rect.x + 12, + rect.y + 18, rect.width, rect.height, )) -class FailedPage(Widget): - def __init__(self, reboot_callback: Callable, retry_callback: Callable, title: str = "download failed"): +class FailedPage(NavScroller): + def __init__(self, retry_callback: Callable | None, title: str = "download failed", + description: str | None = None, icon: str = "icons_mici/setup/warning.png"): super().__init__() + self.set_back_callback(retry_callback) - self._title_label = UnifiedLabel(title, 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.DISPLAY) - self._reason_label = UnifiedLabel("", 36, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), - font_weight=FontWeight.ROMAN) + self._reason_card = GreyBigButton("", "") + self._reason_card.set_visible(False) - self._reboot_button = SmallRedPillButton("reboot") - self._reboot_button.set_click_callback(reboot_callback) - - self._retry_button = WideRoundedButton("retry") - self._retry_button.set_click_callback(retry_callback) + self._scroller.add_widgets([ + GreyBigButton(title, description or "swipe down to go\nback and try again", + gui_app.texture(icon, 64, 58)), + self._reason_card, + BigConfirmationCircleButton("reboot\ndevice", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70), + HARDWARE.reboot, exit_on_confirm=False), + ]) def set_reason(self, reason: str): - self._reason_label.set_text(reason) - - def _render(self, rect: rl.Rectangle): - self._title_label.render(rl.Rectangle( - rect.x + 8, - rect.y + 10, - rect.width, - 64, - )) - - self._reason_label.render(rl.Rectangle( - rect.x + 8, - rect.y + 10 + 64, - rect.width, - 36, - )) - - self._reboot_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._reboot_button.rect.height, - self._reboot_button.rect.width, - self._reboot_button.rect.height, - )) - - self._retry_button.render(rl.Rectangle( - rect.x + 8 + self._reboot_button.rect.width + 8, - rect.y + rect.height - self._retry_button.rect.height, - self._retry_button.rect.width, - self._retry_button.rect.height, - )) - - -class NetworkSetupState(IntEnum): - MAIN = 0 - WIFI_PANEL = 1 - - -class NetworkSetupPage(Widget): - def __init__(self, wifi_manager, continue_callback: Callable, back_callback: Callable): - super().__init__() - self._wifi_ui = WifiUIMici(wifi_manager, back_callback=lambda: self.set_state(NetworkSetupState.MAIN)) - - self._no_wifi_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_slash.png", 58, 50) - self._wifi_full_txt = gui_app.texture("icons_mici/settings/network/wifi_strength_full.png", 58, 50) - self._waiting_text = "waiting for internet..." - self._network_header = TermsHeader(self._waiting_text, self._no_wifi_txt) - - back_txt = gui_app.texture("icons_mici/setup/back_new.png", 37, 32) - self._back_button = SmallCircleIconButton(back_txt) - self._back_button.set_click_callback(back_callback) - - self._wifi_button = SmallerRoundedButton("wifi") - self._wifi_button.set_click_callback(lambda: self.set_state(NetworkSetupState.WIFI_PANEL)) - - self._continue_button = WidishRoundedButton("continue") - self._continue_button.set_enabled(False) - self._continue_button.set_click_callback(continue_callback) - - self._state = NetworkSetupState.MAIN - self._prev_has_internet = False - - def set_state(self, state: NetworkSetupState): - self._state = state - if state == NetworkSetupState.WIFI_PANEL: - self._wifi_ui.show_event() - - def set_has_internet(self, has_internet: bool): - if has_internet: - self._network_header.set_title("connected to internet") - self._network_header.set_icon(self._wifi_full_txt) - self._continue_button.set_enabled(True) + if reason: + self._reason_card.set_value(reason) + self._reason_card.set_visible(True) else: - self._network_header.set_title(self._waiting_text) - self._network_header.set_icon(self._no_wifi_txt) - self._continue_button.set_enabled(False) + self._reason_card.set_visible(False) - if has_internet and not self._prev_has_internet: - self.set_state(NetworkSetupState.MAIN) - self._prev_has_internet = has_internet + +class BigPillButton(BigButton): + def __init__(self, *args, green: bool = False, disabled_background: bool = False, **kwargs): + self._green = green + self._disabled_background = disabled_background + super().__init__(*args, **kwargs) + + self._label.set_font_size(48) + self._label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_CENTER) + self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) + + def _load_images(self): + if self._green: + self._txt_default_bg = gui_app.texture("icons_mici/setup/start_button.png", 402, 180) + self._txt_pressed_bg = gui_app.texture("icons_mici/setup/start_button_pressed.png", 402, 180) + else: + self._txt_default_bg = gui_app.texture("icons_mici/setup/continue.png", 402, 180) + self._txt_pressed_bg = gui_app.texture("icons_mici/setup/continue_pressed.png", 402, 180) + self._txt_disabled_bg = gui_app.texture("icons_mici/setup/continue_disabled.png", 402, 180) + + def set_green(self, green: bool): + if self._green != green: + self._green = green + self._load_images() + + def _update_label_layout(self): + # Don't change label text size + pass + + def _handle_background(self) -> tuple[rl.Texture, float, float, float]: + txt_bg, btn_x, btn_y, scale = super()._handle_background() + + if self._disabled_background: + txt_bg = self._txt_disabled_bg + return txt_bg, btn_x, btn_y, scale + + +class NetworkSetupPageBase(Scroller): + def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None], + disable_connect_hint: bool = False): + super().__init__() + + self._wifi_manager = WifiManager() + self._wifi_manager.set_active(True) + self._network_monitor = network_monitor + self._custom_software = False + self._wifi_ui = WifiUIMici(self._wifi_manager) + + self._connect_button = GreyBigButton("connect to\ninternet", "swipe down to go back", + gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 56, flip_x=True)) + self._connect_button.set_visible(not disable_connect_hint) + + self._wifi_button = WifiNetworkButton(self._wifi_manager) + self._wifi_button.set_click_callback(lambda: gui_app.push_widget(self._wifi_ui)) + + self._prev_has_internet = False + self._prev_wifi_connected = False + self._pending_has_internet_scroll: float | None = None # stores time to use as delay + self._pending_continue_grow_animation = False + self._pending_wifi_grow_animation = False + + def on_waiting_click(): + offset = (self._wifi_button.rect.x + self._wifi_button.rect.width / 2) - (self._rect.x + self._rect.width / 2) + self._scroller.scroll_to(offset, smooth=True, block_interaction=True) + # trigger grow when wifi button in view + self._pending_wifi_grow_animation = True + + self._waiting_button = BigPillButton("connect to\ncontinue", disabled_background=True) + self._waiting_button.set_click_callback(on_waiting_click) + self._continue_button = BigPillButton("install openpilot", green=True) + self._continue_button.set_click_callback(lambda: continue_callback(self._custom_software)) + + self._scroller.add_widgets([ + self._connect_button, + self._wifi_button, + self._continue_button, + self._waiting_button, + ]) + + gui_app.add_nav_stack_tick(self._nav_stack_tick) def show_event(self): super().show_event() - self._state = NetworkSetupState.MAIN - self._wifi_ui.show_event() + # make sure we populate strength and ip immediately if already have wifi + self._wifi_manager.set_active(True) + self._prev_has_internet = self._has_internet + self._prev_wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED + self._pending_has_internet_scroll = None + self._pending_continue_grow_animation = False + self._pending_wifi_grow_animation = False - def hide_event(self): - super().hide_event() - self._wifi_ui.hide_event() + if self._prev_has_internet or self._prev_wifi_connected: + self.set_shown_callback(lambda: self._scroll_to_end_and_grow()) - def _render(self, _): - if self._state == NetworkSetupState.MAIN: - self._network_header.render(rl.Rectangle( - self._rect.x + 16, - self._rect.y + 16, - self._rect.width - 32, - self._network_header.rect.height, - )) + @property + def _has_internet(self) -> bool: + network_changing = self._wifi_ui.any_network_forgetting or self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTING + if network_changing: + self._network_monitor.invalidate() - self._back_button.render(rl.Rectangle( - self._rect.x + 8, - self._rect.y + self._rect.height - self._back_button.rect.height, - self._back_button.rect.width, - self._back_button.rect.height, - )) + has_internet = (self._network_monitor.network_connected.is_set() and + not network_changing and + not self._network_monitor.recheck_event.is_set()) + return has_internet - self._wifi_button.render(rl.Rectangle( - self._rect.x + 8 + self._back_button.rect.width + 10, - self._rect.y + self._rect.height - self._wifi_button.rect.height, - self._wifi_button.rect.width, - self._wifi_button.rect.height, - )) + def _nav_stack_tick(self): + # Only run tick when this page or its WiFi UI is on the stack + if gui_app.get_active_widget() is not self and not gui_app.widget_in_stack(self._wifi_ui): + self._wifi_manager.process_callbacks() + return - self._continue_button.render(rl.Rectangle( - self._rect.x + self._rect.width - self._continue_button.rect.width - 8, - self._rect.y + self._rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) - else: - self._wifi_ui.render(self._rect) + # Check network state before processing callbacks so forgetting flag + # is still set on the frame the forgotten callback fires + has_internet = self._has_internet + wifi_connected = self._wifi_manager.wifi_state.status == ConnectStatus.CONNECTED + + self._continue_button.set_visible(has_internet) + self._waiting_button.set_visible(not has_internet) + + # TODO: fire show/hide events on visibility changes + if not has_internet: + self._pending_continue_grow_animation = False + self._waiting_button.set_text("waiting for\ninternet..." if wifi_connected else "connect to\ncontinue") + + self._wifi_manager.process_callbacks() + + # Dismiss WiFi UI and scroll on WiFi connect or internet gain + if (has_internet and not self._prev_has_internet) or (wifi_connected and not self._prev_wifi_connected): + # TODO: cancel if connect is transient + self._pending_has_internet_scroll = rl.get_time() + + self._prev_has_internet = has_internet + self._prev_wifi_connected = wifi_connected + + if self._pending_has_internet_scroll is not None: + # Scrolls over to continue button, then grows once in view + elapsed = rl.get_time() - self._pending_has_internet_scroll + if elapsed > 0.7 or gui_app.get_active_widget() is self: # instant scroll + grow if not popping + # Animate WifiUi down first before scroll + self._pending_has_internet_scroll = None + gui_app.pop_widgets_to(self, self._scroll_to_end_and_grow) + + def _scroll_to_end_and_grow(self): + self._scroller._layout() + end_offset = -(self._scroller.content_size - self._rect.width) + remaining = self._scroller.scroll_panel.get_offset() - end_offset + self._scroller.scroll_to(remaining, smooth=True, block_interaction=True) + self._pending_continue_grow_animation = True + + def set_custom_software(self, custom_software: bool): + self._custom_software = custom_software + self._continue_button.set_text("install openpilot" if not custom_software else "choose software") + self._continue_button.set_green(not custom_software) + + def _update_state(self): + super()._update_state() + + if self._pending_continue_grow_animation: + btn_right = self._continue_button.rect.x + self._continue_button.rect.width + visible_right = self._rect.x + self._rect.width + if btn_right < visible_right + 50: + self._pending_continue_grow_animation = False + self._continue_button.trigger_grow_animation() + + if self._pending_wifi_grow_animation and abs(self._wifi_button.rect.x - ITEM_SPACING) < 50: + self._pending_wifi_grow_animation = False + self._wifi_button.trigger_grow_animation() + + +class NetworkSetupPage(NetworkSetupPageBase, NavScroller): + def __init__(self, network_monitor: NetworkConnectivityMonitor, continue_callback: Callable[[bool], None], + back_callback: Callable[[], None] | None): + super().__init__(network_monitor, continue_callback) + self.set_back_callback(back_callback) class Setup(Widget): def __init__(self): super().__init__() - self.state = SetupState.GETTING_STARTED - self.failed_url = "" - self.failed_reason = "" self.download_url = "" self.download_progress = 0 self.download_thread = None - self._wifi_manager = WifiManager() - self._wifi_manager.set_active(True) - self._network_monitor = NetworkConnectivityMonitor( - lambda: self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE) - ) - self._prev_has_internet = False - gui_app.set_modal_overlay_tick(self._modal_overlay_tick) + self._download_failed_reason: str | None = None + + self._network_monitor = NetworkConnectivityMonitor() + self._network_monitor.start() + + def getting_started_button_callback(): + gui_app.push_widget(self._software_selection_page) self._start_page = StartPage() - self._start_page.set_click_callback(self._getting_started_button_callback) + self._start_page.set_click_callback(getting_started_button_callback) + self._start_page.set_enabled(lambda: self.enabled) # for nav stack - self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_button_callback, - self._network_setup_back_button_callback) + self._network_setup_page = NetworkSetupPage(self._network_monitor, self._network_setup_continue_callback, self._pop_to_software_selection) - self._software_selection_page = SoftwareSelectionPage(self._software_selection_continue_button_callback, - self._software_selection_custom_software_button_callback) + self._software_selection_page = SoftwareSelectionPage(self._push_network_setup, lambda: gui_app.push_widget(self._custom_software_warning_page)) - self._download_failed_page = FailedPage(HARDWARE.reboot, self._download_failed_startover_button_callback) + self._download_failed_page = FailedPage(self._pop_to_software_selection, icon="icons_mici/setup/red_warning.png") - self._custom_software_warning_page = CustomSoftwareWarningPage(self._software_selection_custom_software_continue, - self._custom_software_warning_back_button_callback) + self._custom_software_warning_page = CustomSoftwareWarningPage(lambda: self._push_network_setup(True), self._pop_to_software_selection) self._downloading_page = DownloadingPage() - def _modal_overlay_tick(self): - has_internet = self._network_monitor.network_connected.is_set() - if has_internet and not self._prev_has_internet: - gui_app.set_modal_overlay(None) - self._prev_has_internet = has_internet + gui_app.add_nav_stack_tick(self._nav_stack_tick) - def _update_state(self): - self._wifi_manager.process_callbacks() + def _nav_stack_tick(self): + self._downloading_page.set_progress(self.download_progress) - def _set_state(self, state: SetupState): - self.state = state - if self.state == SetupState.SOFTWARE_SELECTION: - self._software_selection_page.reset() - elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: - self._custom_software_warning_page.reset() - - if self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE): - self._network_setup_page.show_event() - self._network_monitor.reset() - self._network_monitor.start() - else: - self._network_setup_page.hide_event() - self._network_monitor.stop() + if self._download_failed_reason is not None: + reason = self._download_failed_reason + self._download_failed_reason = None + self._download_failed_page.set_reason(reason) + gui_app.pop_widgets_to(self._software_selection_page, lambda: gui_app.push_widget(self._download_failed_page)) def _render(self, rect: rl.Rectangle): - if self.state == SetupState.GETTING_STARTED: - self._start_page.render(rect) - elif self.state in (SetupState.NETWORK_SETUP, SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE): - self.render_network_setup(rect) - elif self.state == SetupState.SOFTWARE_SELECTION: - self._software_selection_page.render(rect) - elif self.state == SetupState.CUSTOM_SOFTWARE_WARNING: - self._custom_software_warning_page.render(rect) - elif self.state == SetupState.CUSTOM_SOFTWARE: - self.render_custom_software() - elif self.state == SetupState.DOWNLOADING: - self.render_downloading(rect) - elif self.state == SetupState.DOWNLOAD_FAILED: - self._download_failed_page.render(rect) - - def _custom_software_warning_back_button_callback(self): - self._set_state(SetupState.SOFTWARE_SELECTION) - - def _custom_software_warning_continue_button_callback(self): - self._set_state(SetupState.CUSTOM_SOFTWARE) - - def _getting_started_button_callback(self): - self._set_state(SetupState.SOFTWARE_SELECTION) - - def _software_selection_back_button_callback(self): - self._set_state(SetupState.GETTING_STARTED) - - def _software_selection_continue_button_callback(self): - self.use_openpilot() - - def _software_selection_custom_software_button_callback(self): - self._set_state(SetupState.CUSTOM_SOFTWARE_WARNING) - - def _software_selection_custom_software_continue(self): - self._set_state(SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE) - - def _download_failed_startover_button_callback(self): - self._set_state(SetupState.GETTING_STARTED) - - def _network_setup_back_button_callback(self): - self._set_state(SetupState.SOFTWARE_SELECTION) - - def _network_setup_continue_button_callback(self): - self._network_monitor.stop() - if self.state == SetupState.NETWORK_SETUP: - self.download(DEFAULT_INSTALLER_URL) - elif self.state == SetupState.NETWORK_SETUP_CUSTOM_SOFTWARE: - self._set_state(SetupState.CUSTOM_SOFTWARE) + self._start_page.render(rect) def close(self): self._network_monitor.stop() - def render_network_setup(self, rect: rl.Rectangle): - self._network_setup_page.render(rect) - has_internet = self._network_monitor.network_connected.is_set() - self._prev_has_internet = has_internet - self._network_setup_page.set_has_internet(has_internet) + def _pop_to_software_selection(self): + # reset sliders after dismiss completes + gui_app.pop_widgets_to(self._software_selection_page, self._software_selection_page.reset) - def render_downloading(self, rect: rl.Rectangle): - self._downloading_page.set_progress(self.download_progress) - self._downloading_page.render(rect) + def _push_network_setup(self, custom_software: bool = False): + # to fire the correct continue callback later + self._network_setup_page.set_custom_software(custom_software) + gui_app.push_widget(self._network_setup_page) - def render_custom_software(self): - def handle_keyboard_result(text): - url = text.strip() - if url: - self.download(url) - - def handle_keyboard_exit(result): - if result == DialogResult.CANCEL: - self._set_state(SetupState.SOFTWARE_SELECTION) - - keyboard = BigInputDialog("custom software URL", confirm_callback=handle_keyboard_result) - gui_app.set_modal_overlay(keyboard, callback=handle_keyboard_exit) - - def use_openpilot(self): - if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - with open(TMP_CONTINUE_PATH, "w") as f: - f.write(CONTINUE) - run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) - shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) - shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) - with open(INSTALLER_URL_PATH, "w") as f: - f.write(DEFAULT_INSTALLER_URL) - - # give time for installer UI to take over - time.sleep(0.1) - gui_app.request_close() + def _network_setup_continue_callback(self, custom_software: bool): + if not custom_software: + self._download(OPENPILOT_URL) else: - self._set_state(SetupState.NETWORK_SETUP) + def handle_keyboard_result(text): + url = text.strip() + if url: + self._download(url) - def download(self, url: str): + keyboard = BigInputDialog("custom software URL...", confirm_callback=handle_keyboard_result, auto_return_to_letters="./") + gui_app.push_widget(keyboard) + + def _download(self, url: str): # autocomplete incomplete URLs if re.match("^([^/.]+)/([^/]+)$", url): url = f"https://installer.comma.ai/{url}" parsed = urlparse(url, scheme='https') self.download_url = (urlparse(f"https://{url}") if not parsed.netloc else parsed).geturl() + self.download_progress = 0 - self._set_state(SetupState.DOWNLOADING) + def start_download(): + self.download_thread = threading.Thread(target=self._download_thread, daemon=True) + self.download_thread.start() - self.download_thread = threading.Thread(target=self._download_thread, daemon=True) - self.download_thread.start() + self._downloading_page.set_shown_callback(start_download) + gui_app.push_widget(self._downloading_page) def _download_thread(self): try: @@ -717,7 +537,6 @@ class Setup(Widget): if total_size: self.download_progress = int(downloaded * 100 / total_size) - self._downloading_page.set_progress(self.download_progress) is_elf = False with open(tmpfile, 'rb') as f: @@ -725,46 +544,44 @@ class Setup(Widget): is_elf = header == b'\x7fELF' if not is_elf: - self.download_failed(self.download_url, "No custom software found at this URL.") + self._download_failed_reason = "No custom software found at this URL: " + self.download_url.replace("https://", "", 1) return + # NOTE: currently unused, for future logging + with open(INSTALLER_URL_PATH, "w") as f: + f.write(self.download_url) + # AGNOS might try to execute the installer before this process exits. # Therefore, important to close the fd before renaming the installer. os.close(fd) os.rename(tmpfile, INSTALLER_DESTINATION_PATH) - with open(INSTALLER_URL_PATH, "w") as f: - f.write(self.download_url) - - if os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - # give time for installer UI to take over time.sleep(0.1) gui_app.request_close() except urllib.error.HTTPError as e: if e.code == 409: - error_msg = "Incompatible openpilot version" - self.download_failed(self.download_url, error_msg) + self._download_failed_reason = "Incompatible openpilot version." except Exception: - error_msg = "Invalid URL" - self.download_failed(self.download_url, error_msg) - - def download_failed(self, url: str, reason: str): - self.failed_url = url - self.failed_reason = reason - self._download_failed_page.set_reason(reason) - self._set_state(SetupState.DOWNLOAD_FAILED) + self._download_failed_reason = "Invalid URL: " + self.download_url.replace("https://", "", 1) def main(): + config_realtime_process(0, 51) + # attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off + if TICI: + try: + set_core_affinity([5]) + except OSError: + cloudlog.exception("Failed to set core affinity for setup process") + try: gui_app.init_window("Setup") setup = Setup() - for should_render in gui_app.render(): - if should_render: - setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + gui_app.push_widget(setup) + for _ in gui_app.render(): + pass setup.close() except Exception as e: print(f"Setup error: {e}") diff --git a/system/ui/mici_updater.py b/system/ui/mici_updater.py old mode 100644 new mode 100755 index 2ae2f7cc1..8437e6fa6 --- a/system/ui/mici_updater.py +++ b/system/ui/mici_updater.py @@ -3,179 +3,161 @@ import sys import subprocess import threading import pyray as rl -from enum import IntEnum -from openpilot.system.hardware import HARDWARE +from openpilot.common.realtime import config_realtime_process, set_core_affinity +from openpilot.system.hardware import HARDWARE, TICI +from openpilot.common.swaglog import cloudlog from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.text_measure import measure_text_cached -from openpilot.system.ui.lib.wifi_manager import WifiManager, Network -from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import gui_text_box, gui_label, UnifiedLabel -from openpilot.system.ui.widgets.button import FullRoundedButton -from openpilot.system.ui.mici_setup import NetworkSetupPage, FailedPage, NetworkConnectivityMonitor +from openpilot.system.ui.widgets.nav_widget import NavWidget +from openpilot.system.ui.widgets.scroller import Scroller +from openpilot.system.ui.widgets.label import UnifiedLabel +from openpilot.system.ui.mici_setup import (NetworkSetupPage, FailedPage, NetworkConnectivityMonitor, + GreyBigButton, BigPillButton) -class Screen(IntEnum): - PROMPT = 0 - WIFI = 1 - PROGRESS = 2 - FAILED = 3 +class UpdaterNetworkSetupPage(NetworkSetupPage): + def __init__(self, network_monitor, continue_callback): + super().__init__(network_monitor, continue_callback, back_callback=None) + self._continue_button.set_text("download\n& install") + self._continue_button.set_green(False) -class Updater(Widget): +class ProgressPage(NavWidget): + def __init__(self): + super().__init__() + + self._progress_title_label = UnifiedLabel("", 64, text_color=rl.Color(255, 255, 255, int(255 * 0.9)), + font_weight=FontWeight.DISPLAY, line_height=0.8) + self._progress_percent_label = UnifiedLabel("", 132, text_color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.65)), + font_weight=FontWeight.ROMAN, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM) + + def _back_enabled(self) -> bool: + return False + + def set_progress(self, text: str, value: int): + self._progress_title_label.set_text(text.replace("_", "_\n") + "...") + self._progress_percent_label.set_text(f"{value}%") + + def show_event(self): + super().show_event() + self._nav_bar._alpha = 0.0 # not dismissable + self.set_progress("downloading", 0) + + def _render(self, rect: rl.Rectangle): + rl.draw_rectangle_rec(rect, rl.BLACK) + self._progress_title_label.render(rl.Rectangle( + rect.x + 12, + rect.y + 2, + rect.width, + self._progress_title_label.get_content_height(int(rect.width - 20)), + )) + + self._progress_percent_label.render(rl.Rectangle( + rect.x + 12, + rect.y + 18, + rect.width, + rect.height, + )) + + +class Updater(Scroller): def __init__(self, updater_path, manifest_path): super().__init__() self.updater = updater_path self.manifest = manifest_path - self.current_screen = Screen.PROMPT - self._current_network_strength = -1 self.progress_value = 0 self.progress_text = "loading" self.process = None self.update_thread = None - self._wifi_manager = WifiManager() - self._wifi_manager.set_active(True) + self._update_failed = False - self._network_setup_page = NetworkSetupPage(self._wifi_manager, self._network_setup_continue_callback, - self._network_setup_back_callback) - - self._wifi_manager.add_callbacks(networks_updated=self._on_network_updated) self._network_monitor = NetworkConnectivityMonitor() self._network_monitor.start() - # Buttons - self._continue_button = FullRoundedButton("continue") - self._continue_button.set_click_callback(lambda: self.set_current_screen(Screen.WIFI)) + self._network_setup_page = UpdaterNetworkSetupPage(self._network_monitor, self._network_setup_continue_callback) - self._title_label = UnifiedLabel("update required", 48, text_color=rl.Color(255, 115, 0, 255), - font_weight=FontWeight.DISPLAY) - self._subtitle_label = UnifiedLabel("The download size is approximately 1GB.", 36, - text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - font_weight=FontWeight.ROMAN) + self._progress_page = ProgressPage() - self._update_failed_page = FailedPage(HARDWARE.reboot, self._update_failed_retry_callback, - title="update failed") + self._failed_page = FailedPage(self._retry, title="update failed") - def _network_setup_back_callback(self): - self.set_current_screen(Screen.PROMPT) + self._continue_button = BigPillButton("next") + self._continue_button.set_click_callback(lambda: gui_app.push_widget(self._network_setup_page)) - def _network_setup_continue_callback(self): + self._scroller.add_widgets([ + GreyBigButton("update required", "the download size\nis approximately 1 GB", + gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", 64, 64)), + self._continue_button, + ]) + + gui_app.add_nav_stack_tick(self._nav_stack_tick) + + def _network_setup_continue_callback(self, _): self.install_update() - def _update_failed_retry_callback(self): - self.set_current_screen(Screen.PROMPT) + def _retry(self): + gui_app.pop_widgets_to(self) - def _on_network_updated(self, networks: list[Network]): - self._current_network_strength = next((net.strength for net in networks if net.is_connected), -1) + def _nav_stack_tick(self): + self._progress_page.set_progress(self.progress_text, self.progress_value) - def set_current_screen(self, screen: Screen): - if self.current_screen != screen: - if screen == Screen.PROGRESS: - if self._network_setup_page: - self._network_setup_page.hide_event() - elif screen == Screen.WIFI: - if self._network_setup_page: - self._network_setup_page.show_event() - elif screen == Screen.PROMPT: - if self._network_setup_page: - self._network_setup_page.hide_event() - elif screen == Screen.FAILED: - if self._network_setup_page: - self._network_setup_page.hide_event() - - self.current_screen = screen + if self._update_failed: + self._update_failed = False + self.show_event() + gui_app.pop_widgets_to(self, lambda: gui_app.push_widget(self._failed_page)) def install_update(self): - self.set_current_screen(Screen.PROGRESS) self.progress_value = 0 self.progress_text = "downloading" - # Start the update process in a separate thread - self.update_thread = threading.Thread(target=self._run_update_process) - self.update_thread.daemon = True - self.update_thread.start() + def start_update(): + self.update_thread = threading.Thread(target=self._run_update_process, daemon=True) + self.update_thread.start() + + # Start the update process in a separate thread *after* show animation completes + self._progress_page.set_shown_callback(start_update) + gui_app.push_widget(self._progress_page) def _run_update_process(self): # TODO: just import it and run in a thread without a subprocess - cmd = [self.updater, "--swap", self.manifest] - self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, universal_newlines=True) + try: + cmd = [self.updater, "--swap", self.manifest] + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + text=True, bufsize=1, universal_newlines=True) + except Exception: + self._update_failed = True + return - for line in self.process.stdout: - parts = line.strip().split(":") - if len(parts) == 2: - self.progress_text = parts[0].lower() - try: - self.progress_value = int(float(parts[1])) - except ValueError: - pass + if self.process.stdout is not None: + for line in self.process.stdout: + parts = line.strip().split(":") + if len(parts) == 2: + self.progress_text = parts[0].lower() + try: + self.progress_value = int(float(parts[1])) + except ValueError: + pass exit_code = self.process.wait() if exit_code == 0: HARDWARE.reboot() else: - self.set_current_screen(Screen.FAILED) - - def render_prompt_screen(self, rect: rl.Rectangle): - self._title_label.render(rl.Rectangle( - rect.x + 8, - rect.y - 5, - rect.width, - 48, - )) - - subtitle_width = rect.width - 16 - subtitle_height = self._subtitle_label.get_content_height(int(subtitle_width)) - self._subtitle_label.render(rl.Rectangle( - rect.x + 8, - rect.y + 48, - subtitle_width, - subtitle_height, - )) - - self._continue_button.render(rl.Rectangle( - rect.x + 8, - rect.y + rect.height - self._continue_button.rect.height, - self._continue_button.rect.width, - self._continue_button.rect.height, - )) - - def render_progress_screen(self, rect: rl.Rectangle): - title_rect = rl.Rectangle(self._rect.x + 6, self._rect.y - 5, self._rect.width - 12, self._rect.height - 8) - if ' ' in self.progress_text: - font_size = 62 - else: - font_size = 82 - gui_text_box(title_rect, self.progress_text, font_size, font_weight=FontWeight.DISPLAY, - color=rl.Color(255, 255, 255, int(255 * 0.9))) - - progress_value = f"{self.progress_value}%" - text_height = measure_text_cached(gui_app.font(FontWeight.ROMAN), progress_value, 128).y - progress_rect = rl.Rectangle(self._rect.x + 6, self._rect.y + self._rect.height - text_height + 18, - self._rect.width - 12, text_height) - gui_label(progress_rect, progress_value, 128, font_weight=FontWeight.ROMAN, - color=rl.Color(255, 255, 255, int(255 * 0.9 * 0.35))) - - def _update_state(self): - self._wifi_manager.process_callbacks() - - def _render(self, rect: rl.Rectangle): - if self.current_screen == Screen.PROMPT: - self.render_prompt_screen(rect) - elif self.current_screen == Screen.WIFI: - self._network_setup_page.set_has_internet(self._network_monitor.network_connected.is_set()) - self._network_setup_page.render(rect) - elif self.current_screen == Screen.PROGRESS: - self.render_progress_screen(rect) - elif self.current_screen == Screen.FAILED: - self._update_failed_page.render(rect) + self._update_failed = True def close(self): self._network_monitor.stop() def main(): + config_realtime_process(0, 51) + # attempt to affine. AGNOS will start setup with all cores, should only fail when manually launching with screen off + if TICI: + try: + set_core_affinity([5]) + except OSError: + cloudlog.exception("Failed to set core affinity for updater process") + if len(sys.argv) < 3: print("Usage: updater.py ") sys.exit(1) @@ -186,9 +168,9 @@ def main(): try: gui_app.init_window("System Update") updater = Updater(updater_path, manifest_path) - for should_render in gui_app.render(): - if should_render: - updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + gui_app.push_widget(updater) + for _ in gui_app.render(): + pass updater.close() except Exception as e: print(f"Updater error: {e}") diff --git a/system/ui/reset.py b/system/ui/reset.py index 2800fd962..c32504a5b 100755 --- a/system/ui/reset.py +++ b/system/ui/reset.py @@ -1,13 +1,11 @@ #!/usr/bin/env python3 -from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app import openpilot.system.ui.tici_reset as tici_reset import openpilot.system.ui.mici_reset as mici_reset def main(): - # Use actual hardware type, not UI scale/env flags, to choose reset UI. - # This prevents mici devices from launching tici reset layouts. - if HARDWARE.get_device_type() in ("tici", "tizi"): + if gui_app.big_ui(): tici_reset.main() else: mici_reset.main() diff --git a/system/ui/setup.py b/system/ui/setup.py index f96a426b9..23ffc26aa 100755 --- a/system/ui/setup.py +++ b/system/ui/setup.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -from openpilot.system.hardware import HARDWARE +from openpilot.system.ui.lib.application import gui_app import openpilot.system.ui.tici_setup as tici_setup import openpilot.system.ui.mici_setup as mici_setup def main(): - if HARDWARE.get_device_type() in ("tici", "tizi"): + if gui_app.big_ui(): tici_setup.main() else: mici_setup.main() diff --git a/system/ui/spinner.py b/system/ui/spinner.py index 69f13ac5f..2a48b3889 100755 --- a/system/ui/spinner.py +++ b/system/ui/spinner.py @@ -1,12 +1,7 @@ #!/usr/bin/env python3 -import os import pyray as rl import select -import subprocess import sys -import time -from collections import deque -from pathlib import Path from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -31,28 +26,12 @@ MARGIN_H = 100 FONT_SIZE = 96 LINE_HEIGHT = 104 DARKGRAY = (55, 55, 55, 255) -RESET_TAP_COUNT = 8 -RESET_TAP_WINDOW_S = 4.0 - -# StarPilot variables -GREEN = (23, 134, 68, 242) def clamp(value, min_value, max_value): return max(min(value, max_value), min_value) -def get_device_type() -> str: - model_path = Path("/sys/firmware/devicetree/base/model") - if model_path.is_file(): - try: - model = model_path.read_text().strip("\x00") - return model.split("comma ")[-1].strip().lower() - except Exception: - pass - return "" - - class Spinner(Widget): def __init__(self): super().__init__() @@ -61,10 +40,6 @@ class Spinner(Widget): self._rotation = 0.0 self._progress: int | None = None self._wrapped_lines: list[str] = [] - self._logo_rect = rl.Rectangle(0, 0, 0, 0) - self._tap_times = deque(maxlen=RESET_TAP_COUNT) - self._launch_reset = False - self._allow_reset_gesture = os.path.isfile("/TICI") and get_device_type() not in ("tici", "tizi") def set_text(self, text: str) -> None: if text.isdigit(): @@ -89,7 +64,6 @@ class Spinner(Widget): center = rl.Vector2(rect.width / 2.0, center_y) spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0) comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0) - self._logo_rect = rl.Rectangle(comma_position.x, comma_position.y, TEXTURE_SIZE, TEXTURE_SIZE) delta_time = rl.get_frame_time() self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0 @@ -106,30 +80,13 @@ class Spinner(Widget): rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY) bar.width *= self._progress / 100.0 - rl.draw_rectangle_rounded(bar, 1, 10, GREEN) + rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE) elif self._wrapped_lines: for i, line in enumerate(self._wrapped_lines): text_size = measure_text_cached(gui_app.font(), line, FONT_SIZE) rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT), FONT_SIZE, 0.0, rl.WHITE) - def _handle_mouse_release(self, mouse_pos): - if not self._allow_reset_gesture: - return - - if not rl.check_collision_point_rec(mouse_pos, self._logo_rect): - return - - now = time.monotonic() - self._tap_times.append(now) - if len(self._tap_times) == RESET_TAP_COUNT and (now - self._tap_times[0]) <= RESET_TAP_WINDOW_S: - self._tap_times.clear() - self._launch_reset = True - - @property - def should_launch_reset(self) -> bool: - return self._launch_reset - def _read_stdin(): """Non-blocking read of available lines from stdin.""" @@ -154,28 +111,6 @@ def main(): spinner.set_text(text_list[-1]) spinner.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - if spinner.should_launch_reset: - reset_script = Path(__file__).with_name("reset.py") - try: - proc = subprocess.Popen( - [sys.executable, str(reset_script)], - cwd=str(reset_script.parent), - close_fds=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except OSError: - spinner.set_text("Failed to launch reset UI") - continue - - # Keep spinner alive if reset process exits immediately (prevents blank screen). - time.sleep(0.2) - if proc.poll() is not None: - spinner.set_text("Reset UI failed to start") - continue - - gui_app.request_close() - break if __name__ == "__main__": diff --git a/system/ui/tici_reset.py b/system/ui/tici_reset.py old mode 100644 new mode 100755 index 3eda118ca..a6603d547 --- a/system/ui/tici_reset.py +++ b/system/ui/tici_reset.py @@ -7,21 +7,19 @@ from enum import IntEnum import pyray as rl +from openpilot.system.hardware import PC from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.label import gui_label, gui_text_box -NVME = "/dev/nvme0n1" USERDATA = "/dev/disk/by-partlabel/userdata" TIMEOUT = 3*60 -PC = not (os.path.isfile("/TICI") or os.path.isfile("/EON")) class ResetMode(IntEnum): USER_RESET = 0 # user initiated a factory reset from openpilot RECOVER = 1 # userdata is corrupt for some reason, give a chance to recover - FORMAT = 2 # finish up a factory reset from a tool that doesn't flash an empty partition to userdata class ResetState(IntEnum): @@ -37,35 +35,14 @@ class Reset(Widget): self._mode = mode self._previous_reset_state = None self._reset_state = ResetState.NONE - self._cancel_button = Button("Cancel", self._cancel_callback) + self._cancel_button = Button("Cancel", gui_app.request_close) self._confirm_button = Button("Confirm", self._confirm, button_style=ButtonStyle.PRIMARY) self._reboot_button = Button("Reboot", lambda: os.system("sudo reboot")) - self._render_status = True - - def _cancel_callback(self): - self._render_status = False - - def _backup_ssh_params(self): - if PC: - return - - backup_dir = "/cache/reset_backup" - os.system(f"sudo rm -rf {backup_dir}") - os.system(f"sudo mkdir -p {backup_dir}") - for key in ("GithubSshKeys", "SshEnabled"): - os.system(f"sudo cp /data/params/d/{key} {backup_dir}/{key} 2>/dev/null || true") - os.system(f"sudo chmod 600 {backup_dir}/* 2>/dev/null || true") def _do_erase(self): if PC: return - self._backup_ssh_params() - - # Best effort to wipe NVME - os.system(f"sudo umount {NVME}") - os.system(f"yes | sudo mkfs.ext4 {NVME}") - # Removing data and formatting rm = os.system("sudo rm -rf /data/*") os.system(f"sudo umount {USERDATA}") @@ -76,7 +53,7 @@ class Reset(Widget): else: self._reset_state = ResetState.FAILED - def start_reset(self): + def _start_reset(self): self._reset_state = ResetState.RESETTING threading.Timer(0.1, self._do_erase).start() @@ -87,34 +64,34 @@ class Reset(Widget): elif self._reset_state != ResetState.RESETTING and (time.monotonic() - self._timeout_st) > TIMEOUT: exit(0) - def _render(self, rect: rl.Rectangle): - label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100 * FONT_SCALE) + def _render(self, _): + content_rect = rl.Rectangle(45, 200, self._rect.width - 90, self._rect.height - 245) + + label_rect = rl.Rectangle(content_rect.x + 140, content_rect.y, content_rect.width - 280, 100 * FONT_SCALE) gui_label(label_rect, "System Reset", 100, font_weight=FontWeight.BOLD) - text_rect = rl.Rectangle(rect.x + 140, rect.y + 140, rect.width - 280, rect.height - 90 - 100 * FONT_SCALE) + text_rect = rl.Rectangle(content_rect.x + 140, content_rect.y + 140, content_rect.width - 280, content_rect.height - 90 - 100 * FONT_SCALE) gui_text_box(text_rect, self._get_body_text(), 90) button_height = 160 button_spacing = 50 - button_top = rect.y + rect.height - button_height - button_width = (rect.width - button_spacing) / 2.0 + button_top = content_rect.y + content_rect.height - button_height + button_width = (content_rect.width - button_spacing) / 2.0 if self._reset_state != ResetState.RESETTING: if self._mode == ResetMode.RECOVER: - self._reboot_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height)) + self._reboot_button.render(rl.Rectangle(content_rect.x, button_top, button_width, button_height)) elif self._mode == ResetMode.USER_RESET: - self._cancel_button.render(rl.Rectangle(rect.x, button_top, button_width, button_height)) + self._cancel_button.render(rl.Rectangle(content_rect.x, button_top, button_width, button_height)) if self._reset_state != ResetState.FAILED: - self._confirm_button.render(rl.Rectangle(rect.x + button_width + 50, button_top, button_width, button_height)) + self._confirm_button.render(rl.Rectangle(content_rect.x + button_width + 50, button_top, button_width, button_height)) else: - self._reboot_button.render(rl.Rectangle(rect.x, button_top, rect.width, button_height)) - - return self._render_status + self._reboot_button.render(rl.Rectangle(content_rect.x, button_top, content_rect.width, button_height)) def _confirm(self): if self._reset_state == ResetState.CONFIRM: - self.start_reset() + self._start_reset() else: self._reset_state = ResetState.CONFIRM @@ -131,30 +108,18 @@ class Reset(Widget): def main(): - # Safety fallback: if this module is launched on a small-UI device, - # hand off to the mici reset implementation to avoid off-screen layout. - if not gui_app.big_ui(): - import openpilot.system.ui.mici_reset as mici_reset - mici_reset.main() - return - mode = ResetMode.USER_RESET if len(sys.argv) > 1: if sys.argv[1] == '--recover': mode = ResetMode.RECOVER - elif sys.argv[1] == "--format": - mode = ResetMode.FORMAT gui_app.init_window("System Reset", 20) reset = Reset(mode) - if mode == ResetMode.FORMAT: - reset.start_reset() + gui_app.push_widget(reset) - for should_render in gui_app.render(): - if should_render: - if not reset.render(rl.Rectangle(45, 200, gui_app.width - 90, gui_app.height - 245)): - break + for _ in gui_app.render(): + pass if __name__ == "__main__": diff --git a/system/ui/tici_setup.py b/system/ui/tici_setup.py old mode 100644 new mode 100755 index cd5089e7a..9eefb6af5 --- a/system/ui/tici_setup.py +++ b/system/ui/tici_setup.py @@ -7,16 +7,14 @@ import urllib.request import urllib.error from urllib.parse import urlparse from enum import IntEnum -import shutil import pyray as rl from cereal import log -from openpilot.common.utils import run_cmd from openpilot.system.hardware import HARDWARE from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel from openpilot.system.ui.lib.application import gui_app, FontWeight, FONT_SCALE -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import Button, ButtonStyle, ButtonRadio from openpilot.system.ui.widgets.keyboard import Keyboard from openpilot.system.ui.widgets.label import Label @@ -32,24 +30,12 @@ BODY_FONT_SIZE = 80 BUTTON_HEIGHT = 160 BUTTON_SPACING = 50 -NETWORK_CHECK_URL = "https://openpilot.comma.ai" -DEFAULT_INSTALLER_URL = "https://installer.comma.ai/firestar5683/StarPilot" +OPENPILOT_URL = "https://openpilot.comma.ai" USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}" -CONTINUE_PATH = "/data/continue.sh" -TMP_CONTINUE_PATH = "/data/continue.sh.new" -INSTALL_PATH = "/data/openpilot" -VALID_CACHE_PATH = "/data/.openpilot_cache" -INSTALLER_SOURCE_PATH = "/usr/comma/installer" INSTALLER_DESTINATION_PATH = "/tmp/installer" INSTALLER_URL_PATH = "/tmp/installer_url" -CONTINUE = """#!/usr/bin/env bash - -cd /data/openpilot -exec ./launch_openpilot.sh -""" - class SetupState(IntEnum): LOW_VOLTAGE = 0 @@ -93,7 +79,7 @@ class Setup(Widget): self._getting_started_body_label = Label("Before we get on the road, let's finish installation and cover some details.", BODY_FONT_SIZE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, text_padding=20) - self._software_selection_openpilot_button = ButtonRadio("StarPilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) + self._software_selection_openpilot_button = ButtonRadio("openpilot", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) self._software_selection_custom_software_button = ButtonRadio("Custom Software", self.checkmark, font_size=BODY_FONT_SIZE, text_padding=80) self._software_selection_continue_button = Button("Continue", self._software_selection_continue_button_callback, button_style=ButtonStyle.PRIMARY) @@ -177,7 +163,9 @@ class Setup(Widget): def _software_selection_continue_button_callback(self): if self._software_selection_openpilot_button.selected: - self.use_openpilot() + self.state = SetupState.NETWORK_SETUP + self.stop_network_check_thread.clear() + self.start_network_check() else: self.state = SetupState.CUSTOM_SOFTWARE_WARNING @@ -190,12 +178,12 @@ class Setup(Widget): def _network_setup_continue_button_callback(self): self.stop_network_check_thread.set() if self._software_selection_openpilot_button.selected: - self.download(DEFAULT_INSTALLER_URL) + self.download(OPENPILOT_URL) else: self.state = SetupState.CUSTOM_SOFTWARE def render_low_voltage(self, rect: rl.Rectangle): - rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE) + rl.draw_texture_ex(self.warning, rl.Vector2(rect.x + 150, rect.y + 110), 0.0, 1.0, rl.WHITE) self._low_voltage_title_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE * FONT_SCALE)) self._low_voltage_body_label.render(rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 150, rect.width - 500, BODY_FONT_SIZE * FONT_SCALE * 3)) @@ -219,7 +207,7 @@ class Setup(Widget): while not self.stop_network_check_thread.is_set(): if self.state == SetupState.NETWORK_SETUP: try: - urllib.request.urlopen(NETWORK_CHECK_URL, timeout=2) + urllib.request.urlopen(OPENPILOT_URL, timeout=2.0) self.network_connected.set() if HARDWARE.get_network_type() == NetworkType.wifi: self.wifi_connected.set() @@ -227,7 +215,7 @@ class Setup(Widget): self.wifi_connected.clear() except Exception: self.network_connected.clear() - time.sleep(1) + time.sleep(1.0) def start_network_check(self): if self.network_check_thread is None or not self.network_check_thread.is_alive(): @@ -328,38 +316,20 @@ class Setup(Widget): def render_custom_software(self): def handle_keyboard_result(result): # Enter pressed - if result == 1: + if result == DialogResult.CONFIRM: url = self.keyboard.text self.keyboard.clear() if url: self.download(url) # Cancel pressed - elif result == 0: + elif result == DialogResult.CANCEL: self.state = SetupState.SOFTWARE_SELECTION self.keyboard.reset(min_text_size=1) self.keyboard.set_title("Enter URL", "for Custom Software") - gui_app.set_modal_overlay(self.keyboard, callback=handle_keyboard_result) - - def use_openpilot(self): - if os.path.isdir(INSTALL_PATH) and os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - with open(TMP_CONTINUE_PATH, "w") as f: - f.write(CONTINUE) - run_cmd(["chmod", "+x", TMP_CONTINUE_PATH]) - shutil.move(TMP_CONTINUE_PATH, CONTINUE_PATH) - shutil.copyfile(INSTALLER_SOURCE_PATH, INSTALLER_DESTINATION_PATH) - with open(INSTALLER_URL_PATH, "w") as f: - f.write(DEFAULT_INSTALLER_URL) - - # give time for installer UI to take over - time.sleep(0.1) - gui_app.request_close() - else: - self.state = SetupState.NETWORK_SETUP - self.stop_network_check_thread.clear() - self.start_network_check() + self.keyboard.set_callback(handle_keyboard_result) + gui_app.push_widget(self.keyboard) def download(self, url: str): # autocomplete incomplete URLs @@ -418,9 +388,6 @@ class Setup(Widget): with open(INSTALLER_URL_PATH, "w") as f: f.write(self.download_url) - if os.path.isfile(VALID_CACHE_PATH): - os.remove(VALID_CACHE_PATH) - # give time for installer UI to take over time.sleep(0.1) gui_app.request_close() @@ -443,9 +410,9 @@ def main(): try: gui_app.init_window("Setup", 20) setup = Setup() - for should_render in gui_app.render(): - if should_render: - setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + gui_app.push_widget(setup) + for _ in gui_app.render(): + pass setup.close() except Exception as e: print(f"Setup error: {e}") diff --git a/system/ui/tici_updater.py b/system/ui/tici_updater.py old mode 100644 new mode 100755 index 2e1a8687e..3a3b0987d --- a/system/ui/tici_updater.py +++ b/system/ui/tici_updater.py @@ -67,18 +67,24 @@ class Updater(Widget): def _run_update_process(self): # TODO: just import it and run in a thread without a subprocess - cmd = [self.updater, "--swap", self.manifest] - self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, bufsize=1, universal_newlines=True) + try: + cmd = [self.updater, "--swap", self.manifest] + self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, + text=True, bufsize=1, universal_newlines=True) + except Exception: + self.progress_text = "Update failed" + self.show_reboot_button = True + return - for line in self.process.stdout: - parts = line.strip().split(":") - if len(parts) == 2: - self.progress_text = parts[0] - try: - self.progress_value = int(float(parts[1])) - except ValueError: - pass + if self.process.stdout is not None: + for line in self.process.stdout: + parts = line.strip().split(":") + if len(parts) == 2: + self.progress_text = parts[0] + try: + self.progress_value = int(float(parts[1])) + except ValueError: + pass exit_code = self.process.wait() if exit_code == 0: @@ -160,10 +166,9 @@ def main(): try: gui_app.init_window("System Update") - updater = Updater(updater_path, manifest_path) - for should_render in gui_app.render(): - if should_render: - updater.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) + gui_app.push_widget(Updater(updater_path, manifest_path)) + for _ in gui_app.render(): + pass finally: # Make sure we clean up even if there's an error gui_app.close() diff --git a/system/ui/updater.py b/system/ui/updater.py index 00694c267..a894dd18a 100755 --- a/system/ui/updater.py +++ b/system/ui/updater.py @@ -28,7 +28,7 @@ def _ui_device_type() -> str: def main(): device_type = _ui_device_type() - # The updater stack imports application sizing during module import, so patch the + # The updater imports application sizing during module import, so patch the # hardware probe before importing either UI implementation. HARDWARE.get_device_type = lambda: device_type diff --git a/system/ui/widgets/__init__.py b/system/ui/widgets/__init__.py index b7542851b..4ce1c1b69 100644 --- a/system/ui/widgets/__init__.py +++ b/system/ui/widgets/__init__.py @@ -1,18 +1,22 @@ +from __future__ import annotations + import abc import pyray as rl from enum import IntEnum +from typing import TypeVar from collections.abc import Callable -from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter from openpilot.system.ui.lib.application import gui_app, MousePos, MAX_TOUCH_SLOTS, MouseEvent try: from openpilot.selfdrive.ui.ui_state import device except ImportError: - class Device: awake = True + device = Device() - device = Device() # type: ignore +W = TypeVar('W', bound='Widget') + +DEBUG = False class DialogResult(IntEnum): @@ -25,23 +29,28 @@ class Widget(abc.ABC): def __init__(self): self._rect: rl.Rectangle = rl.Rectangle(0, 0, 0, 0) self._parent_rect: rl.Rectangle | None = None + self._children: list[Widget] = [] + + self._enabled: bool | Callable[[], bool] = True + self._is_visible: bool | Callable[[], bool] = True + self.__is_pressed = [False] * MAX_TOUCH_SLOTS # if current mouse/touch down started within the widget's rectangle self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS - self._enabled: bool | Callable[[], bool] = True - self._is_visible: bool | Callable[[], bool] = True self._touch_valid_callback: Callable[[], bool] | None = None + self._click_delay: float | None = None # seconds to hold is_pressed after release + self._click_release_time: float | None = None self._click_callback: Callable[[], None] | None = None self._multi_touch = False self.__was_awake = True - self._children: list = [] @property def rect(self) -> rl.Rectangle: return self._rect def set_rect(self, rect: rl.Rectangle) -> None: - changed = self._rect.x != rect.x or self._rect.y != rect.y or self._rect.width != rect.width or self._rect.height != rect.height + changed = (self._rect.x != rect.x or self._rect.y != rect.y or + self._rect.width != rect.width or self._rect.height != rect.height) self._rect = rect if changed: self._update_layout_rects() @@ -52,21 +61,8 @@ class Widget(abc.ABC): @property def is_pressed(self) -> bool: - return any(self.__is_pressed) - - @property - def _is_pressed(self) -> bool: - return any(self.__is_pressed) - - @_is_pressed.setter - def _is_pressed(self, value: bool): - if value: - for i, tracked in enumerate(self._Widget__tracking_is_pressed): - if tracked: - self.__is_pressed[i] = True - else: - for i in range(len(self.__is_pressed)): - self.__is_pressed[i] = False + # if actually pressed or holding after release + return any(self.__is_pressed) or self._click_release_time is not None @property def enabled(self) -> bool: @@ -95,7 +91,7 @@ class Widget(abc.ABC): return self._touch_valid_callback() if self._touch_valid_callback else True def set_position(self, x: float, y: float) -> None: - changed = self._rect.x != x or self._rect.y != y + changed = (self._rect.x != x or self._rect.y != y) self._rect = rl.Rectangle(x, y, self._rect.width, self._rect.height) if changed: self._update_layout_rects() @@ -107,26 +103,40 @@ class Widget(abc.ABC): return self._rect return rl.get_collision_rec(self._rect, self._parent_rect) - def render(self, rect: rl.Rectangle = None) -> bool | int | None: + def render(self, rect: rl.Rectangle | None = None) -> bool | int | None: if rect is not None: self.set_rect(rect) self._update_state() + if self._click_release_time is not None and rl.get_time() >= self._click_release_time: + self._click_release_time = None + if not self.is_visible: return None self._layout() ret = self._render(self._rect) + if gui_app.show_touches: + self._draw_debug_rect() + # Keep track of whether mouse down started within the widget's rectangle if self.enabled and self.__was_awake: self._process_mouse_events() + else: + # TODO: ideally we emit release events when going disabled + self.__is_pressed = [False] * MAX_TOUCH_SLOTS + self.__tracking_is_pressed = [False] * MAX_TOUCH_SLOTS self.__was_awake = device.awake return ret + def _draw_debug_rect(self) -> None: + rl.draw_rectangle_lines(int(self._rect.x), int(self._rect.y), + max(int(self._rect.width), 1), max(int(self._rect.height), 1), rl.RED) + def _process_mouse_events(self) -> None: hit_rect = self._hit_rect touch_valid = self._touch_valid() @@ -186,6 +196,8 @@ class Widget(abc.ABC): def _handle_mouse_release(self, mouse_pos: MousePos) -> None: """Optionally handle mouse release events.""" + if self._click_delay is not None: + self._click_release_time = rl.get_time() + self._click_delay if self._click_callback: self._click_callback() @@ -193,225 +205,40 @@ class Widget(abc.ABC): """Optionally handle mouse events. This is called before rendering.""" # Default implementation does nothing, can be overridden by subclasses - def show_event(self): - """Optionally handle show event. Parent must manually call this""" - for child in self._children: - child.show_event() - - def hide_event(self): - """Optionally handle hide event. Parent must manually call this""" - for child in self._children: - child.hide_event() - - def _child(self, widget): - """Register a child widget for lifecycle propagation.""" + def _child(self, widget: W) -> W: + """ + Register a widget as a child. Lifecycle events (show/hide) propagate to registered children. + - If the widget is pushed onto the nav stack, do NOT register it (gui_app manages its lifecycle). + - If the widget is rendered inline in _render(), register it. + """ assert widget not in self._children, f"{type(widget).__name__} already a child of {type(self).__name__}" self._children.append(widget) return widget + _show_hide_depth = 0 + + def show_event(self): + """Called when widget becomes visible. Propagates to registered children.""" + if DEBUG: + print(f"{' ' * Widget._show_hide_depth}show_event: {type(self).__name__}") + Widget._show_hide_depth += 1 + for child in self._children: + child.show_event() + if DEBUG: + Widget._show_hide_depth -= 1 + + def hide_event(self): + """Called when widget is hidden. Propagates to registered children.""" + if DEBUG: + print(f"{' ' * Widget._show_hide_depth}hide_event: {type(self).__name__}") + Widget._show_hide_depth += 1 + for child in self._children: + child.hide_event() + if DEBUG: + Widget._show_hide_depth -= 1 + def dismiss(self, callback: Callable[[], None] | None = None): - """Dismiss this widget from the nav stack.""" + """Immediately dismiss the widget, firing the callback after.""" gui_app.pop_widget() if callback: callback() - - -SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing -START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging -BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away - -NAV_BAR_MARGIN = 6 -NAV_BAR_WIDTH = 205 -NAV_BAR_HEIGHT = 8 - -DISMISS_PUSH_OFFSET = 50 + NAV_BAR_MARGIN + NAV_BAR_HEIGHT # px extra to push down when dismissing -DISMISS_TIME_SECONDS = 1.5 - - -class NavBar(Widget): - def __init__(self): - super().__init__() - self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT)) - self._alpha = 1.0 - self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) - self._fade_time = 0.0 - - def set_alpha(self, alpha: float) -> None: - self._alpha = alpha - self._fade_time = rl.get_time() - - def show_event(self): - super().show_event() - self._alpha = 1.0 - self._alpha_filter.x = 1.0 - self._fade_time = rl.get_time() - - def _render(self, _): - if rl.get_time() - self._fade_time > DISMISS_TIME_SECONDS: - self._alpha = 0.0 - alpha = self._alpha_filter.update(self._alpha) - - # white bar with black border - rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha))) - rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha))) - - -class NavWidget(Widget, abc.ABC): - """ - A full screen widget that supports back navigation by swiping down from the top. - """ - - BACK_TOUCH_AREA_PERCENTAGE = 0.65 - - def __init__(self): - super().__init__() - self._back_callback: Callable[[], None] | None = None - self._back_button_start_pos: MousePos | None = None - self._swiping_away = False # currently swiping away - self._can_swipe_away = True # swipe away is blocked after certain horizontal movement - - self._pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1) - self._playing_dismiss_animation = False - self._trigger_animate_in = False - self._back_enabled: bool | Callable[[], bool] = True - self._nav_bar = NavBar() - - self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) - - self._set_up = False - - @property - def back_enabled(self) -> bool: - return self._back_enabled() if callable(self._back_enabled) else self._back_enabled - - def set_back_enabled(self, enabled: bool | Callable[[], bool]) -> None: - self._back_enabled = enabled - - def set_back_callback(self, callback: Callable[[], None]) -> None: - self._back_callback = callback - - def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: - super()._handle_mouse_event(mouse_event) - - if not self.back_enabled: - self._back_button_start_pos = None - self._swiping_away = False - self._can_swipe_away = True - return - - if mouse_event.left_pressed: - # user is able to swipe away if starting near top of screen, or anywhere if scroller is at top - self._pos_filter.update_alpha(0.04) - in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE - - scroller_at_top = False - vertical_scroller = False - # TODO: -20? snapping in WiFi dialog can make offset not be positive at the top - if hasattr(self, '_scroller'): - scroller_at_top = self._scroller.scroll_panel.get_offset() >= -20 and not self._scroller._horizontal - vertical_scroller = not self._scroller._horizontal - elif hasattr(self, '_scroll_panel'): - scroller_at_top = self._scroll_panel.get_offset() >= -20 and not self._scroll_panel._horizontal - vertical_scroller = not self._scroll_panel._horizontal - - # Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes - if (not vertical_scroller and in_dismiss_area) or scroller_at_top: - self._can_swipe_away = True - self._back_button_start_pos = mouse_event.pos - - elif mouse_event.left_down: - if self._back_button_start_pos is not None: - # block swiping away if too much horizontal or upward movement - horizontal_movement = abs(mouse_event.pos.x - self._back_button_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD - upward_movement = mouse_event.pos.y - self._back_button_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD - if not self._swiping_away and (horizontal_movement or upward_movement): - self._can_swipe_away = False - self._back_button_start_pos = None - - # block horizontal swiping if now swiping away - if self._can_swipe_away: - if mouse_event.pos.y - self._back_button_start_pos.y > START_DISMISSING_THRESHOLD: # type: ignore - self._swiping_away = True - - elif mouse_event.left_released: - self._pos_filter.update_alpha(0.1) - # if far enough, trigger back navigation callback - if self._back_button_start_pos is not None: - if mouse_event.pos.y - self._back_button_start_pos.y > SWIPE_AWAY_THRESHOLD: - self._playing_dismiss_animation = True - - self._back_button_start_pos = None - self._swiping_away = False - - def _update_state(self): - super()._update_state() - - # Disable self's scroller while swiping away - if not self._set_up: - self._set_up = True - if hasattr(self, '_scroller'): - original_enabled = self._scroller._enabled - self._scroller.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled)) - elif hasattr(self, '_scroll_panel'): - original_enabled = self._scroll_panel.enabled - self._scroll_panel.set_enabled(lambda: not self._swiping_away and (original_enabled() if callable(original_enabled) else original_enabled)) - - if self._trigger_animate_in: - self._pos_filter.x = self._rect.height - self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT - self._trigger_animate_in = False - - new_y = 0.0 - - if self._back_button_start_pos is not None: - last_mouse_event = gui_app.last_mouse_event - # push entire widget as user drags it away - new_y = max(last_mouse_event.pos.y - self._back_button_start_pos.y, 0) - if new_y < SWIPE_AWAY_THRESHOLD: - new_y /= 2 # resistance until mouse release would dismiss widget - - if self._swiping_away: - self._nav_bar.set_alpha(1.0) - - if self._playing_dismiss_animation: - new_y = self._rect.height + DISMISS_PUSH_OFFSET - - new_y = round(self._pos_filter.update(new_y)) - if abs(new_y) < 1 and self._pos_filter.velocity.x == 0.0: - new_y = self._pos_filter.x = 0.0 - - if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10: - if self._back_callback is not None: - self._back_callback() - - self._playing_dismiss_animation = False - self._back_button_start_pos = None - self._swiping_away = False - - self.set_position(self._rect.x, new_y) - - def render(self, rect: rl.Rectangle = None) -> bool | int | None: - ret = super().render(rect) - - if self.back_enabled: - bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2 - if self._back_button_start_pos is not None or self._playing_dismiss_animation: - self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._pos_filter.x - else: - self._nav_bar_y_filter.update(NAV_BAR_MARGIN) - - self._nav_bar.set_position(bar_x, round(self._nav_bar_y_filter.x)) - self._nav_bar.render() - - # draw black above widget when dismissing - if self._rect.y > 0: - rl.draw_rectangle(int(self._rect.x), 0, int(self._rect.width), int(self._rect.y), rl.BLACK) - - return ret - - def show_event(self): - super().show_event() - # FIXME: we don't know the height of the rect at first show_event since it's before the first render :( - # so we need this hacky bool for now - self._trigger_animate_in = True - self._nav_bar.show_event() diff --git a/system/ui/widgets/button.py b/system/ui/widgets/button.py index 9c0ea75b4..36ef3beda 100644 --- a/system/ui/widgets/button.py +++ b/system/ui/widgets/button.py @@ -5,7 +5,7 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.widgets import Widget -from openpilot.system.ui.widgets.label import Label, UnifiedLabel +from openpilot.system.ui.widgets.label import Label from openpilot.common.filter_simple import FirstOrderFilter @@ -191,7 +191,7 @@ class IconButton(Widget): color = rl.Color(255, 255, 255, int(255 * 0.9 * 0.35 * self._opacity_filter.x)) draw_x = rect.x + (rect.width - self._texture.width) / 2 draw_y = rect.y + (rect.height - self._texture.height) / 2 - rl.draw_texture(self._texture, int(draw_x), int(draw_y), color) + rl.draw_texture_ex(self._texture, rl.Vector2(draw_x, draw_y), 0.0, 1.0, color) class SmallCircleIconButton(Widget): @@ -219,85 +219,7 @@ class SmallCircleIconButton(Widget): bg_txt = self._icon_bg_pressed_txt if self.is_pressed else self._icon_bg_txt icon_white = white - rl.draw_texture(bg_txt, int(self.rect.x), int(self.rect.y), white) + rl.draw_texture_ex(bg_txt, rl.Vector2(self.rect.x, self.rect.y), 0.0, 1.0, white) icon_x = self.rect.x + (self.rect.width - self._icon_txt.width) / 2 icon_y = self.rect.y + (self.rect.height - self._icon_txt.height) / 2 - rl.draw_texture(self._icon_txt, int(icon_x), int(icon_y), icon_white) - - -class SmallButton(Widget): - def __init__(self, text: str): - super().__init__() - self._opacity_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) - - self._load_assets() - - self._label = UnifiedLabel(text, 36, font_weight=FontWeight.MEDIUM, - text_color=rl.Color(255, 255, 255, int(255 * 0.9)), - alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) - - self._bg_disabled_txt = None - - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 194, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/reset/small_button.png", 194, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/small_button_pressed.png", 194, 100) - - def set_text(self, text: str): - self._label.set_text(text) - - def set_opacity(self, opacity: float, smooth: bool = False): - if smooth: - self._opacity_filter.update(opacity) - else: - self._opacity_filter.x = opacity - - def _render(self, _): - if not self.enabled and self._bg_disabled_txt is not None: - rl.draw_texture(self._bg_disabled_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) - elif self.is_pressed: - rl.draw_texture(self._bg_pressed_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) - else: - rl.draw_texture(self._bg_txt, int(self.rect.x), int(self.rect.y), rl.Color(255, 255, 255, int(255 * self._opacity_filter.x))) - - opacity = 0.9 if self.enabled else 0.35 - self._label.set_color(rl.Color(255, 255, 255, int(255 * opacity * self._opacity_filter.x))) - self._label.render(self._rect) - - -class SmallRedPillButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 194, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/small_red_pill.png", 194, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/small_red_pill_pressed.png", 194, 100) - - -class SmallerRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 150, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/smaller_button.png", 150, 100) - self._bg_disabled_txt = gui_app.texture("icons_mici/setup/smaller_button_disabled.png", 150, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/smaller_button_pressed.png", 150, 100) - - -class WideRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 316, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/medium_button_bg.png", 316, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/medium_button_pressed_bg.png", 316, 100) - - -class WidishRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 250, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/widish_button.png", 250, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/widish_button_pressed.png", 250, 100) - self._bg_disabled_txt = gui_app.texture("icons_mici/setup/widish_button_disabled.png", 250, 100) - - -class FullRoundedButton(SmallButton): - def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 520, 100)) - self._bg_txt = gui_app.texture("icons_mici/setup/reset/wide_button.png", 520, 100) - self._bg_pressed_txt = gui_app.texture("icons_mici/setup/reset/wide_button_pressed.png", 520, 100) + rl.draw_texture_ex(self._icon_txt, rl.Vector2(icon_x, icon_y), 0.0, 1.0, icon_white) diff --git a/system/ui/widgets/confirm_dialog.py b/system/ui/widgets/confirm_dialog.py index 724d71541..354483676 100644 --- a/system/ui/widgets/confirm_dialog.py +++ b/system/ui/widgets/confirm_dialog.py @@ -1,5 +1,5 @@ -from collections.abc import Callable import pyray as rl +from collections.abc import Callable from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import DialogResult @@ -18,7 +18,7 @@ BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) class ConfirmDialog(Widget): - def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False, on_close: Callable[[DialogResult], None] | None = None): + def __init__(self, text: str, confirm_text: str, cancel_text: str | None = None, rich: bool = False, callback: Callable[[DialogResult], None] | None = None): super().__init__() if cancel_text is None: cancel_text = tr("Cancel") @@ -27,8 +27,7 @@ class ConfirmDialog(Widget): self._cancel_button = Button(cancel_text, self._cancel_button_callback) self._confirm_button = Button(confirm_text, self._confirm_button_callback, button_style=ButtonStyle.PRIMARY) self._rich = rich - self._dialog_result = DialogResult.NO_ACTION - self._on_close = on_close + self._callback = callback self._cancel_text = cancel_text self._scroller = Scroller([self._html_renderer], line_separator=False, spacing=0) @@ -38,17 +37,15 @@ class ConfirmDialog(Widget): else: self._html_renderer.parse_html_content(text) - def reset(self): - self._dialog_result = DialogResult.NO_ACTION - self._on_close = on_close - def _cancel_button_callback(self): - self._dialog_result = DialogResult.CANCEL - if self._on_close: self._on_close(self._dialog_result) + gui_app.pop_widget() + if self._callback: + self._callback(DialogResult.CANCEL) def _confirm_button_callback(self): - self._dialog_result = DialogResult.CONFIRM - if self._on_close: self._on_close(self._dialog_result) + gui_app.pop_widget() + if self._callback: + self._callback(DialogResult.CONFIRM) def _render(self, rect: rl.Rectangle): dialog_x = OUTER_MARGIN if not self._rich else RICH_OUTER_MARGIN @@ -78,11 +75,9 @@ class ConfirmDialog(Widget): self._scroller.render(text_rect) if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER): - self._dialog_result = DialogResult.CONFIRM - if self._on_close: self._on_close(self._dialog_result) + self._confirm_button_callback() elif rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE): - self._dialog_result = DialogResult.CANCEL - if self._on_close: self._on_close(self._dialog_result) + self._cancel_button_callback() if self._cancel_text: self._confirm_button.render(confirm_button) @@ -92,8 +87,6 @@ class ConfirmDialog(Widget): full_confirm_button = rl.Rectangle(dialog_rect.x + MARGIN, button_y, full_button_width, BUTTON_HEIGHT) self._confirm_button.render(full_confirm_button) - return self._dialog_result - def alert_dialog(message: str, button_text: str | None = None): if button_text is None: diff --git a/system/ui/widgets/html_render.py b/system/ui/widgets/html_render.py index 7d90d5692..77fca9fe3 100644 --- a/system/ui/widgets/html_render.py +++ b/system/ui/widgets/html_render.py @@ -260,7 +260,7 @@ class HtmlModal(Widget): super().__init__() self._content = HtmlRenderer(file_path=file_path, text=text) self._scroll_panel = GuiScrollPanel() - self._ok_button = Button(tr("OK"), click_callback=lambda: gui_app.set_modal_overlay(None), button_style=ButtonStyle.PRIMARY) + self._ok_button = Button(tr("OK"), click_callback=gui_app.pop_widget, button_style=ButtonStyle.PRIMARY) def _render(self, rect: rl.Rectangle): margin = 50 diff --git a/system/ui/widgets/icon_widget.py b/system/ui/widgets/icon_widget.py new file mode 100644 index 000000000..bf7790b93 --- /dev/null +++ b/system/ui/widgets/icon_widget.py @@ -0,0 +1,16 @@ +import pyray as rl +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget + + +class IconWidget(Widget): + def __init__(self, image_path: str, size: tuple[int, int], opacity: float = 1.0): + super().__init__() + self._texture = gui_app.texture(image_path, size[0], size[1]) + self._opacity = opacity + self.set_rect(rl.Rectangle(0, 0, float(size[0]), float(size[1]))) + self.set_enabled(False) + + def _render(self, _) -> None: + color = rl.Color(255, 255, 255, int(self._opacity * 255)) + rl.draw_texture_ex(self._texture, rl.Vector2(self._rect.x, self._rect.y), 0.0, 1.0, color) diff --git a/system/ui/widgets/input_dialog.py b/system/ui/widgets/input_dialog.py deleted file mode 100644 index b71b0df3f..000000000 --- a/system/ui/widgets/input_dialog.py +++ /dev/null @@ -1,43 +0,0 @@ -from collections.abc import Callable - -from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.keyboard import Keyboard - - -class InputDialog(Widget): - def __init__(self, title: str, default_text: str = "", hint_text: str = "", on_close: Callable[[DialogResult, str], None] | None = None): - super().__init__() - self._default_text = default_text - self._on_close = on_close - self._dialog_result = DialogResult.NO_ACTION - - self._keyboard = Keyboard(callback=self._on_keyboard_result) - self._keyboard.set_title(title) - self._keyboard.set_text(default_text) - - def _on_keyboard_result(self, result: DialogResult): - if self._dialog_result != DialogResult.NO_ACTION: - return - self._dialog_result = result - if self._on_close: - self._on_close(result, self._keyboard.text) - - @property - def result(self) -> DialogResult: - return self._dialog_result - - @property - def text(self) -> str: - return self._keyboard.text - - def show_event(self): - super().show_event() - self._dialog_result = DialogResult.NO_ACTION - self._keyboard.show_event() - self._keyboard.clear() - if self._default_text: - self._keyboard.set_text(self._default_text) - - def _render(self, rect): - self._keyboard.render(rect) - return self._dialog_result diff --git a/system/ui/widgets/keyboard.py b/system/ui/widgets/keyboard.py index 0eec40611..49c59a431 100644 --- a/system/ui/widgets/keyboard.py +++ b/system/ui/widgets/keyboard.py @@ -7,7 +7,7 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget, DialogResult +from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.inputbox import InputBox from openpilot.system.ui.widgets.label import Label @@ -59,14 +59,8 @@ KEYBOARD_LAYOUTS = { class Keyboard(Widget): - def __init__( - self, - max_text_size: int = 255, - min_text_size: int = 0, - password_mode: bool = False, - show_password_toggle: bool = False, - callback: Callable[[DialogResult], None] | None = None, - ): + def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False, + callback: Callable[[DialogResult], None] | None = None): super().__init__() self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase" self._caps_lock = False @@ -86,9 +80,6 @@ class Keyboard(Widget): self._backspace_press_time: float = 0.0 self._backspace_last_repeat: float = 0.0 - self._render_return_status = -1 - self._first_render = False - self._skip_input = False self._cancel_button = Button(lambda: tr("Cancel"), self._cancel_button_callback) self._eye_button = Button("", self._eye_button_callback, button_style=ButtonStyle.TRANSPARENT) @@ -109,18 +100,12 @@ class Keyboard(Widget): for _, key in enumerate(keys): if key in self._key_icons: texture = self._key_icons[key] - self._all_keys[key] = Button( - "", - partial(self._key_callback, key), - icon=texture, - button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, - multi_touch=True, - ) + self._all_keys[key] = Button("", partial(self._key_callback, key), icon=texture, + button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.KEYBOARD, multi_touch=True) else: self._all_keys[key] = Button(key, partial(self._key_callback, key), button_style=ButtonStyle.KEYBOARD, font_size=85, multi_touch=True) - self._all_keys[CAPS_LOCK_KEY] = Button( - "", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY], button_style=ButtonStyle.KEYBOARD, multi_touch=True - ) + self._all_keys[CAPS_LOCK_KEY] = Button("", partial(self._key_callback, CAPS_LOCK_KEY), icon=self._key_icons[CAPS_LOCK_KEY], + button_style=ButtonStyle.KEYBOARD, multi_touch=True) def set_text(self, text: str): self._input_box.text = text @@ -142,39 +127,24 @@ class Keyboard(Widget): def set_callback(self, callback: Callable[[DialogResult], None] | None): self._callback = callback - def show_event(self): - super().show_event() - self._skip_input = True - - def _process_mouse_events(self): - if not self._skip_input: - super()._process_mouse_events() - - def _cancel_button_callback(self): - self.clear() - if self in gui_app._nav_stack: - gui_app.pop_widget() - else: - self._render_return_status = 0 - if self._callback: - self._callback(DialogResult.CANCEL) - def _eye_button_callback(self): self._password_mode = not self._password_mode + def _cancel_button_callback(self): + self.clear() + gui_app.pop_widget() + if self._callback: + self._callback(DialogResult.CANCEL) + def _key_callback(self, k): if k == ENTER_KEY: - if self in gui_app._nav_stack: - gui_app.pop_widget() - else: - self._render_return_status = 1 + gui_app.pop_widget() if self._callback: self._callback(DialogResult.CONFIRM) else: self.handle_key_press(k) def _render(self, rect: rl.Rectangle): - self._skip_input = False rect = rl.Rectangle(rect.x + CONTENT_MARGIN, rect.y + CONTENT_MARGIN, rect.width - 2 * CONTENT_MARGIN, rect.height - 2 * CONTENT_MARGIN) self._title.render(rl.Rectangle(rect.x, rect.y, rect.width, 95)) self._sub_title.render(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60)) @@ -236,8 +206,6 @@ class Keyboard(Widget): self._all_keys[key].set_enabled(is_enabled) self._all_keys[key].render(key_rect) - return self._render_return_status - def _render_input_area(self, input_rect: rl.Rectangle): if self._show_password_toggle: self._input_box.set_password_mode(self._password_mode) @@ -289,7 +257,6 @@ class Keyboard(Widget): def reset(self, min_text_size: int | None = None): if min_text_size is not None: self._min_text_size = min_text_size - self._render_return_status = -1 self._last_shift_press_time = 0 self._backspace_pressed = False self._backspace_press_time = 0.0 @@ -298,15 +265,18 @@ class Keyboard(Widget): if __name__ == "__main__": - gui_app.init_window("Keyboard") - keyboard = Keyboard(min_text_size=8, show_password_toggle=True) - for _ in gui_app.render(): - keyboard.set_title("Keyboard Input", "Type your text below") - result = keyboard.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height)) - if result == 1: + def callback(result: DialogResult): + if result == DialogResult.CONFIRM: print(f"You typed: {keyboard.text}") - gui_app.request_close() - elif result == 0: + elif result == DialogResult.CANCEL: print("Canceled") - gui_app.request_close() + gui_app.request_close() + + gui_app.init_window("Keyboard") + keyboard = Keyboard(min_text_size=8, show_password_toggle=True, callback=callback) + keyboard.set_title("Keyboard Input", "Type your text below") + + gui_app.push_widget(keyboard) + for _ in gui_app.render(): + pass gui_app.close() diff --git a/system/ui/widgets/label.py b/system/ui/widgets/label.py index 173f104ef..7fe25ab51 100644 --- a/system/ui/widgets/label.py +++ b/system/ui/widgets/label.py @@ -27,166 +27,6 @@ class ScrollState(IntEnum): SCROLLING = 1 -# TODO: merge anything new here to master -class MiciLabel(Widget): - def __init__(self, - text: str, - font_size: int = DEFAULT_TEXT_SIZE, - width: int = None, - color: rl.Color = DEFAULT_TEXT_COLOR, - font_weight: FontWeight = FontWeight.NORMAL, - alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, - spacing: int = 0, - line_height: int = None, - elide_right: bool = True, - wrap_text: bool = False, - scroll: bool = False): - super().__init__() - self.text = text - self.wrapped_text: list[str] = [] - self.font_size = font_size - self.width = width - self.color = color - self.font_weight = font_weight - self.alignment = alignment - self.alignment_vertical = alignment_vertical - self.spacing = spacing - self.line_height = line_height if line_height is not None else font_size - self.elide_right = elide_right - self.wrap_text = wrap_text - self._height = 0 - - # Scroll state - self.scroll = scroll - self._needs_scroll = False - self._scroll_offset = 0 - self._scroll_pause_t: float | None = None - self._scroll_state: ScrollState = ScrollState.STARTING - - assert not (self.scroll and self.wrap_text), "Cannot enable both scroll and wrap_text" - assert not (self.scroll and self.elide_right), "Cannot enable both scroll and elide_right" - - self.set_text(text) - - @property - def text_height(self): - return self._height - - def set_font_size(self, font_size: int): - self.font_size = font_size - self.set_text(self.text) - - def set_width(self, width: int): - self.width = width - self._rect.width = width - self.set_text(self.text) - - def set_text(self, txt: str): - self.text = txt - text_size = measure_text_cached(gui_app.font(self.font_weight), self.text, self.font_size, self.spacing) - if self.width is not None: - self._rect.width = self.width - else: - self._rect.width = text_size.x - - if self.wrap_text: - self.wrapped_text = wrap_text(gui_app.font(self.font_weight), self.text, self.font_size, int(self._rect.width)) - self._height = len(self.wrapped_text) * self.line_height - elif self.scroll: - self._needs_scroll = self.scroll and text_size.x > self._rect.width - self._rect.height = text_size.y - - def set_color(self, color: rl.Color): - self.color = color - - def set_font_weight(self, font_weight: FontWeight): - self.font_weight = font_weight - self.set_text(self.text) - - def _render(self, rect: rl.Rectangle): - # Only scissor when we know there is a single scrolling line - if self._needs_scroll: - rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height)) - - font = gui_app.font(self.font_weight) - - text_y_offset = 0 - # Draw the text in the specified rectangle - lines = self.wrapped_text or [self.text] - if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: - lines = lines[::-1] - - for display_text in lines: - text_size = measure_text_cached(font, display_text, self.font_size, self.spacing) - - # Elide text to fit within the rectangle - if self.elide_right and text_size.x > rect.width: - ellipsis = "..." - left, right = 0, len(display_text) - while left < right: - mid = (left + right) // 2 - candidate = display_text[:mid] + ellipsis - candidate_size = measure_text_cached(font, candidate, self.font_size, self.spacing) - if candidate_size.x <= rect.width: - left = mid + 1 - else: - right = mid - display_text = display_text[: left - 1] + ellipsis if left > 0 else ellipsis - text_size = measure_text_cached(font, display_text, self.font_size, self.spacing) - - # Handle scroll state - elif self.scroll and self._needs_scroll: - if self._scroll_state == ScrollState.STARTING: - if self._scroll_pause_t is None: - self._scroll_pause_t = rl.get_time() + 2.0 - if rl.get_time() >= self._scroll_pause_t: - self._scroll_state = ScrollState.SCROLLING - self._scroll_pause_t = None - - elif self._scroll_state == ScrollState.SCROLLING: - self._scroll_offset -= 0.8 / 60. * gui_app.target_fps - # don't fully hide - if self._scroll_offset <= -text_size.x - self._rect.width / 3: - self._scroll_offset = 0 - self._scroll_state = ScrollState.STARTING - self._scroll_pause_t = None - - # Calculate horizontal position based on alignment - text_x = rect.x + { - rl.GuiTextAlignment.TEXT_ALIGN_LEFT: 0, - rl.GuiTextAlignment.TEXT_ALIGN_CENTER: (rect.width - text_size.x) / 2, - rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: rect.width - text_size.x, - }.get(self.alignment, 0) + self._scroll_offset - - # Calculate vertical position based on alignment - text_y = rect.y + { - rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP: 0, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE: (rect.height - text_size.y) / 2, - rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: rect.height - text_size.y, - }.get(self.alignment_vertical, 0) - text_y += text_y_offset - - rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), round(text_y)), self.font_size, self.spacing, self.color) - # Draw 2nd instance for scrolling - if self._needs_scroll and self._scroll_state != ScrollState.STARTING: - text2_scroll_offset = text_size.x + self._rect.width / 3 - rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x + text2_scroll_offset), round(text_y)), self.font_size, self.spacing, self.color) - if self.alignment_vertical == rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM: - text_y_offset -= self.line_height - else: - text_y_offset += self.line_height - - if self._needs_scroll: - # draw black fade on left and right - fade_width = 20 - rl.draw_rectangle_gradient_h(int(rect.x + rect.width - fade_width), int(rect.y), fade_width, int(rect.height), rl.BLANK, rl.BLACK) - if self._scroll_state != ScrollState.STARTING: - rl.draw_rectangle_gradient_h(int(rect.x), int(rect.y), fade_width, int(rect.height), rl.BLACK, rl.BLANK) - - rl.end_scissor_mode() - - # TODO: This should be a Widget class def gui_label( rect: rl.Rectangle, @@ -233,7 +73,7 @@ def gui_label( # Draw the text in the specified rectangle # TODO: add wrapping and proper centering for multiline text - rl.draw_text_ex(font, display_text, rl.Vector2(round(text_x), round(text_y)), font_size, 0, color) + rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color) def gui_text_box( @@ -393,7 +233,7 @@ class Label(Widget): class UnifiedLabel(Widget): """ - Unified label widget that combines functionality from gui_label, gui_text_box, Label, and MiciLabel. + Unified label widget that combines functionality from gui_label, gui_text_box, and Label. Supports: - Emoji rendering @@ -402,11 +242,12 @@ class UnifiedLabel(Widget): - Proper multiline vertical alignment - Height calculation for layout purposes """ - SHIMMER_BAND_WIDTH = 0.3 - SHIMMER_BLUR_RADIUS = 0.12 - SHIMMER_CYCLE_PERIOD = 2.5 - SHIMMER_SWEEP_FRACTION = 0.9 - SHIMMER_LOW_OPACITY = 0.65 + # Shimmer constants + SHIMMER_BAND_WIDTH = 0.3 # shimmer width as fraction of text width + SHIMMER_BLUR_RADIUS = 0.12 # gaussian blur as fraction of text width + SHIMMER_CYCLE_PERIOD = 2.5 # seconds per full shimmer cycle + SHIMMER_SWEEP_FRACTION = 0.9 # fraction of cycle spent sweeping (rest is pause) + SHIMMER_LOW_OPACITY = 0.65 # text opacity at rest, shimmer brings to 1.0 def __init__(self, text: str | Callable[[], str], @@ -439,6 +280,8 @@ class UnifiedLabel(Widget): self._line_height = line_height * 0.9 self._letter_spacing = letter_spacing # 0.1 = 10% self._spacing_pixels = font_size * letter_spacing + + # Shimmer state self._shimmer = shimmer self._shimmer_start_time = 0.0 @@ -477,6 +320,14 @@ class UnifiedLabel(Widget): """Get the current text content.""" return str(_resolve_value(self._text)) + @property + def font_size(self) -> int: + return self._font_size + + @property + def text_width(self) -> float: + return max((s.x for s in self._cached_line_sizes), default=0.0) + def set_text_color(self, color: rl.Color): """Update the text color.""" self._text_color = color @@ -504,7 +355,7 @@ class UnifiedLabel(Widget): new_line_height = line_height * 0.9 if self._line_height != new_line_height: self._line_height = new_line_height - self._cached_text = None + self._cached_text = None # Invalidate cache (affects total height) def set_font_weight(self, font_weight: FontWeight): """Update the font weight.""" @@ -527,7 +378,13 @@ class UnifiedLabel(Widget): self._scroll_pause_t = None self._scroll_state = ScrollState.STARTING + def show_event(self): + super().show_event() + if self._shimmer: + self.reset_shimmer() + def reset_shimmer(self, offset: float = 0.0): + """Reset shimmer animation timing.""" self._shimmer_start_time = rl.get_time() + offset def set_max_width(self, max_width: int | None): @@ -647,25 +504,6 @@ class UnifiedLabel(Widget): return self._cached_total_height return 0.0 - def _compute_shimmer_alpha(self, char_center_x: float, text_left: float, text_width: float) -> float: - if text_width <= 0: - return self.SHIMMER_LOW_OPACITY - - elapsed = rl.get_time() - self._shimmer_start_time - sigma = text_width * self.SHIMMER_BLUR_RADIUS - - t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD - t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0)) - t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) - - margin = text_width * self.SHIMMER_BAND_WIDTH - text_right = text_left + text_width - center = text_right + margin - t * (text_width + 2.0 * margin) - - d = char_center_x - center - shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) if sigma > 0 else 0.0 - return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer - def _render(self, _): """Render the label.""" if self._rect.width <= 0 or self._rect.height <= 0: @@ -792,11 +630,34 @@ class UnifiedLabel(Widget): # draw black fade on left and right fade_width = 20 rl.draw_rectangle_gradient_h(int(self._rect.x + self._rect.width - fade_width), int(self._rect.y), fade_width, int(self._rect.height), rl.BLANK, rl.BLACK) - if self._scroll_state != ScrollState.STARTING: + + # stop drawing left fade once text scrolls past + text_width = visible_sizes[0].x if visible_sizes else 0 + first_copy_in_view = self._scroll_offset + text_width > 0 + draw_left_fade = self._scroll_state != ScrollState.STARTING and first_copy_in_view + if draw_left_fade: rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), fade_width, int(self._rect.height), rl.BLACK, rl.BLANK) rl.end_scissor_mode() + def _shimmer_alpha(self, char_x: float, shimmer_left: float, shimmer_width: float) -> float: + """Compute shimmer opacity multiplier for a character at the given x position.""" + sigma = shimmer_width * self.SHIMMER_BLUR_RADIUS + if sigma <= 0: + return self.SHIMMER_LOW_OPACITY + + elapsed = rl.get_time() - self._shimmer_start_time + t_raw = (elapsed % self.SHIMMER_CYCLE_PERIOD) / self.SHIMMER_CYCLE_PERIOD + t_clamped = max(0.0, min(t_raw / self.SHIMMER_SWEEP_FRACTION, 1.0)) + t = t_clamped * t_clamped * (3.0 - 2.0 * t_clamped) # smoothstep + + margin = shimmer_width * self.SHIMMER_BAND_WIDTH + center = shimmer_left + shimmer_width + margin - t * (shimmer_width + 2.0 * margin) + + d = char_x - center + shimmer = math.exp(-0.5 * d * d / (sigma * sigma)) + return self.SHIMMER_LOW_OPACITY + (1.0 - self.SHIMMER_LOW_OPACITY) * shimmer + def _render_line(self, line, size, emojis, current_y, x_offset=0.0): # Calculate horizontal position if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_LEFT: @@ -809,21 +670,13 @@ class UnifiedLabel(Widget): line_x = self._rect.x + self._text_padding line_x += self._scroll_offset + x_offset - if self._shimmer and not emojis and line: - base_alpha = self._text_color.a / 255.0 - text_width = max(size.x, 1.0) - cursor_x = line_x - for char in line: - char_width = measure_text_cached(self._font, char, self._font_size, self._spacing_pixels).x - char_center_x = cursor_x + char_width / 2.0 - shimmer_alpha = self._compute_shimmer_alpha(char_center_x, line_x, text_width) - char_alpha = int(255 * base_alpha * shimmer_alpha) - char_color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, char_alpha) - rl.draw_text_ex(self._font, char, rl.Vector2(cursor_x, current_y), self._font_size, self._spacing_pixels, char_color) - cursor_x += char_width - return + if self._shimmer: + self._render_line_shimmer(line, line_x, current_y) + else: + # Render line with emojis + self._render_line_normal(line, emojis, line_x, current_y) - # Render line with emojis + def _render_line_normal(self, line, emojis, line_x, current_y): line_pos = rl.Vector2(line_x, current_y) prev_index = 0 @@ -847,3 +700,23 @@ class UnifiedLabel(Widget): text_after = line[prev_index:] if text_after: rl.draw_text_ex(self._font, text_after, line_pos, self._font_size, self._spacing_pixels, self._text_color) + + def _render_line_shimmer(self, line, line_x, current_y): + # Shimmer range based on widest line so sweep is even across all lines + max_width = self.text_width + if self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_RIGHT: + shimmer_left = self._rect.x + self._rect.width - self._text_padding - max_width + elif self._alignment == rl.GuiTextAlignment.TEXT_ALIGN_CENTER: + shimmer_left = self._rect.x + (self._rect.width - max_width) / 2 + else: + shimmer_left = self._rect.x + self._text_padding + + base_a = self._text_color.a / 255.0 + cursor_x = line_x + for ch in line: + char_width = measure_text_cached(self._font, ch, self._font_size, self._spacing_pixels).x + char_center_x = cursor_x + char_width / 2.0 + alpha = int(255 * self._shimmer_alpha(char_center_x, shimmer_left, max_width) * base_a) + color = rl.Color(self._text_color.r, self._text_color.g, self._text_color.b, alpha) + rl.draw_text_ex(self._font, ch, rl.Vector2(cursor_x, current_y), self._font_size, 0, color) + cursor_x += char_width + self._spacing_pixels diff --git a/system/ui/widgets/layouts.py b/system/ui/widgets/layouts.py new file mode 100644 index 000000000..6bbc49e92 --- /dev/null +++ b/system/ui/widgets/layouts.py @@ -0,0 +1,59 @@ +from enum import IntFlag +from openpilot.system.ui.widgets import Widget + + +class Alignment(IntFlag): + LEFT = 0 + # TODO: implement + # H_CENTER = 2 + # RIGHT = 4 + + TOP = 8 + V_CENTER = 16 + BOTTOM = 32 + + +class HBoxLayout(Widget): + """ + A Widget that lays out child Widgets horizontally. + """ + + def __init__(self, widgets: list[Widget] | None = None, spacing: int = 0, + alignment: Alignment = Alignment.LEFT | Alignment.V_CENTER): + super().__init__() + self._spacing = spacing + self._alignment = alignment + + if widgets is not None: + for widget in widgets: + self.add_widget(widget) + + @property + def widgets(self) -> list[Widget]: + return self._children + + def add_widget(self, widget: Widget) -> None: + self._child(widget) + + def _render(self, _): + visible_widgets = [w for w in self._children if w.is_visible] + + cur_offset_x = 0 + + for idx, widget in enumerate(visible_widgets): + spacing = self._spacing if (idx > 0) else 0 + + x = self._rect.x + cur_offset_x + spacing + cur_offset_x += widget.rect.width + spacing + + if self._alignment & Alignment.TOP: + y = self._rect.y + elif self._alignment & Alignment.BOTTOM: + y = self._rect.y + self._rect.height - widget.rect.height + else: # center + y = self._rect.y + (self._rect.height - widget.rect.height) / 2 + + # Update widget position and render + widget.set_position(x, y) + widget.set_parent_rect(self._rect) + widget.render() diff --git a/system/ui/widgets/list_view.py b/system/ui/widgets/list_view.py index 9605d22ce..82613c37c 100644 --- a/system/ui/widgets/list_view.py +++ b/system/ui/widgets/list_view.py @@ -2,7 +2,6 @@ import os import pyray as rl from collections.abc import Callable from abc import ABC -from openpilot.common.params import Params from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.text_measure import measure_text_cached @@ -11,7 +10,6 @@ from openpilot.system.ui.widgets.button import Button, ButtonStyle from openpilot.system.ui.widgets.toggle import Toggle, WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT from openpilot.system.ui.widgets.label import gui_label from openpilot.system.ui.widgets.html_render import HtmlRenderer, ElementType -from openpilot.common.filter_simple import FirstOrderFilter ITEM_BASE_WIDTH = 600 ITEM_BASE_HEIGHT = 170 @@ -59,9 +57,8 @@ class ItemAction(Widget, ABC): class ToggleAction(ItemAction): - def __init__( - self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, callback: Callable[[bool], None] | None = None - ): + def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH, enabled: bool | Callable[[], bool] = True, + callback: Callable[[bool], None] | None = None): super().__init__(width, enabled) self.toggle = Toggle(initial_state=initial_state, callback=callback) @@ -138,15 +135,9 @@ class ButtonAction(ItemAction): value_text = self.value if value_text: value_rect = rl.Rectangle(rect.x, rect.y, rect.width - BUTTON_WIDTH - TEXT_PADDING, rect.height) - gui_label( - value_rect, - value_text, - font_size=ITEM_TEXT_FONT_SIZE, - color=ITEM_TEXT_VALUE_COLOR, - font_weight=FontWeight.NORMAL, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - ) + gui_label(value_rect, value_text, font_size=ITEM_TEXT_FONT_SIZE, color=ITEM_TEXT_VALUE_COLOR, + font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) # TODO: just use the generic Widget click callbacks everywhere, no returning from render pressed = self._pressed @@ -173,15 +164,9 @@ class TextAction(ItemAction): return text_width + TEXT_PADDING def _render(self, rect: rl.Rectangle) -> bool: - gui_label( - self._rect, - self.text, - font_size=ITEM_TEXT_FONT_SIZE, - color=self.color, - font_weight=FontWeight.NORMAL, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - ) + gui_label(self._rect, self.text, font_size=ITEM_TEXT_FONT_SIZE, color=self.color, + font_weight=FontWeight.NORMAL, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE) return False def set_text(self, text: str | Callable[[], str]): @@ -189,14 +174,8 @@ class TextAction(ItemAction): class DualButtonAction(ItemAction): - def __init__( - self, - left_text: str | Callable[[], str], - right_text: str | Callable[[], str], - left_callback: Callable = None, - right_callback: Callable = None, - enabled: bool | Callable[[], bool] = True, - ): + def __init__(self, left_text: str | Callable[[], str], right_text: str | Callable[[], str], left_callback: Callable | None = None, + right_callback: Callable | None = None, enabled: bool | Callable[[], bool] = True): super().__init__(width=0, enabled=enabled) # Width 0 means use full width self.left_button = Button(left_text, click_callback=left_callback, button_style=ButtonStyle.NORMAL, text_padding=0) self.right_button = Button(right_text, click_callback=right_callback, button_style=ButtonStyle.DANGER, text_padding=0) @@ -228,7 +207,7 @@ class DualButtonAction(ItemAction): class MultipleButtonAction(ItemAction): - def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable = None): + def __init__(self, buttons: list[str | Callable[[], str]], button_width: int, selected_index: int = 0, callback: Callable | None = None): super().__init__(width=len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING, enabled=True) self.buttons = buttons self.button_width = button_width @@ -290,164 +269,31 @@ class MultipleButtonAction(ItemAction): self.callback(i) -class CategoryButtonsAction(ItemAction): - def __init__(self, buttons: list[tuple[str | Callable[[], str], Callable]], button_width: int = 150, enabled: bool | Callable[[], bool] = True): - # Calculate width_hint based on actual text content (matches _render logic) - padding = 20 # 10px per side - total_text_width = 0 - - for label, _ in buttons: - text = _resolve_value(label, "") - text_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), text, BUTTON_FONT_SIZE) - total_text_width += text_size.x + padding - - total_width = total_text_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING - - super().__init__(width=total_width, enabled=enabled) - self.buttons = buttons - self._font = gui_app.font(FontWeight.MEDIUM) - - def _render(self, rect: rl.Rectangle): - spacing = RIGHT_ITEM_PADDING - button_y = rect.y + (rect.height - BUTTON_HEIGHT) / 2 - - # Calculate per-button width based on text + padding, scaled to fit - padding = 20 # 10px per side - button_widths = [] - ideal_total = 0 - - for label, _ in self.buttons: - text = _resolve_value(label, "") - text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) - ideal_width = text_size.x + padding - button_widths.append(ideal_width) - ideal_total += ideal_width - - ideal_total += (len(self.buttons) - 1) * spacing - - # Scale proportionally if total exceeds available space - if ideal_total > rect.width: - scale = rect.width / ideal_total - button_widths = [max(80, w * scale) for w in button_widths] - else: - # Ensure minimum width - button_widths = [max(80, w) for w in button_widths] - - # Start from left edge of rect - use cumulative sum for correct positioning - current_button_x = rect.x - - for i, (label, _) in enumerate(self.buttons): - btn_w = button_widths[i] - button_rect = rl.Rectangle(current_button_x, button_y, btn_w, BUTTON_HEIGHT) - - # Check button state - mouse_pos = rl.get_mouse_position() - is_pressed = rl.check_collision_point_rec(mouse_pos, button_rect) and self.enabled and self.is_pressed - - bg_color = rl.Color(57, 57, 57, 255) # Gray - if is_pressed: - bg_color = rl.Color(74, 74, 74, 255) # Dark gray - if not self.enabled: - bg_color = rl.Color(bg_color.r, bg_color.g, bg_color.b, 150) # Dim - - rl.draw_rectangle_rounded(button_rect, 1.0, 20, bg_color) - - # Draw text with proper centering - text = _resolve_value(label, "") - text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) - text_x = current_button_x + (btn_w - text_size.x) / 2 - text_y = button_y + (BUTTON_HEIGHT - text_size.y) / 2 - text_color = rl.Color(228, 228, 228, 255) if self.enabled else rl.Color(150, 150, 150, 255) - rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), BUTTON_FONT_SIZE, 0, text_color) - - # Advance position for next button - current_button_x += btn_w + spacing - - def _handle_mouse_release(self, mouse_pos: MousePos): - spacing = RIGHT_ITEM_PADDING - button_y = self._rect.y + (self._rect.height - BUTTON_HEIGHT) / 2 - - # Calculate per-button width based on text + padding, scaled to fit (same as _render) - padding = 20 # 10px per side - button_widths = [] - ideal_total = 0 - - for label, _ in self.buttons: - text = _resolve_value(label, "") - text_size = measure_text_cached(self._font, text, BUTTON_FONT_SIZE) - ideal_width = text_size.x + padding - button_widths.append(ideal_width) - ideal_total += ideal_width - - ideal_total += (len(self.buttons) - 1) * spacing - - # Scale proportionally if total exceeds available space - if ideal_total > self._rect.width: - scale = self._rect.width / ideal_total - button_widths = [max(80, w * scale) for w in button_widths] - else: - # Ensure minimum width - button_widths = [max(80, w) for w in button_widths] - - # Start from left edge of rect - use cumulative sum for correct positioning - current_button_x = self._rect.x - - for i, (_, callback) in enumerate(self.buttons): - btn_w = button_widths[i] - button_rect = rl.Rectangle(current_button_x, button_y, btn_w, BUTTON_HEIGHT) - if rl.check_collision_point_rec(mouse_pos, button_rect): - if callback: - callback() - # Advance position for next button - current_button_x += btn_w + spacing - - -def category_buttons_item( - title: str | Callable[[], str], - buttons: list[tuple[str | Callable[[], str], Callable]], - description: str | Callable[[], str] | None = None, - icon: str = "", - button_width: int = BUTTON_WIDTH, - enabled: bool | Callable[[], bool] = True, - starpilot_icon: bool = False, -) -> "ListItem": - action = CategoryButtonsAction(buttons, button_width, enabled=enabled) - icon_to_use = "" if starpilot_icon else icon - item = ListItem(title=title, description=description, icon=icon_to_use, action_item=action) - if icon and starpilot_icon: - item.set_icon(icon, starpilot=True) - return item - - class ListItem(Widget): - def __init__( - self, - title: str | Callable[[], str] = "", - icon: str | None = None, - description: str | Callable[[], str] | None = None, - description_visible: bool = False, - callback: Callable | None = None, - action_item: ItemAction | None = None, - ): + def __init__(self, title: str | Callable[[], str] = "", icon: str | None = None, description: str | Callable[[], str] | None = None, + description_visible: bool = False, callback: Callable | None = None, + action_item: ItemAction | None = None): super().__init__() self._title = title self.set_icon(icon) self._description = description self.description_visible = description_visible - self.set_click_callback(callback) + self.callback = callback self.description_opened_callback: Callable | None = None self.action_item = action_item self.set_rect(rl.Rectangle(0, 0, ITEM_BASE_WIDTH, ITEM_BASE_HEIGHT)) self._font = gui_app.font(FontWeight.NORMAL) - self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE}, text_color=ITEM_DESC_TEXT_COLOR) + self._html_renderer = HtmlRenderer(text="", text_size={ElementType.P: ITEM_DESC_FONT_SIZE}, + text_color=ITEM_DESC_TEXT_COLOR) self._parse_description(self.description) # Cached properties for performance self._prev_description: str | None = self.description def show_event(self): + super().show_event() self._set_description_visible(False) def set_description_opened_callback(self, callback: Callable) -> None: @@ -474,7 +320,6 @@ class ListItem(Widget): return self._set_description_visible(not self.description_visible) - super()._handle_mouse_release(mouse_pos) def _set_description_visible(self, visible: bool): if self.description and self.description_visible != visible: @@ -499,7 +344,8 @@ class ListItem(Widget): return # Don't draw items that are not in parent's viewport - if (self._rect.y + self.rect.height) <= self._parent_rect.y or self._rect.y >= (self._parent_rect.y + self._parent_rect.height): + if ((self._rect.y + self.rect.height) <= self._parent_rect.y or + self._rect.y >= (self._parent_rect.y + self._parent_rect.height)): return content_x = self._rect.x + ITEM_PADDING @@ -509,7 +355,7 @@ class ListItem(Widget): if self.title: # Draw icon if present if self.icon: - rl.draw_texture(self._icon_texture, int(content_x), int(self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) // 2), rl.WHITE) + rl.draw_texture_ex(self._icon_texture, rl.Vector2(content_x, self._rect.y + (ITEM_BASE_HEIGHT - self._icon_texture.height) / 2), 0.0, 1.0, rl.WHITE) text_x += ICON_SIZE + ITEM_PADDING # Draw main text @@ -521,7 +367,12 @@ class ListItem(Widget): if self.description_visible: content_width = int(self._rect.width - ITEM_PADDING * 2) description_height = self._html_renderer.get_total_height(content_width) - description_rect = rl.Rectangle(self._rect.x + ITEM_PADDING, self._rect.y + ITEM_DESC_V_OFFSET, content_width, description_height) + description_rect = rl.Rectangle( + self._rect.x + ITEM_PADDING, + self._rect.y + ITEM_DESC_V_OFFSET, + content_width, + description_height + ) self._html_renderer.render(description_rect) # Draw right item if present @@ -530,19 +381,12 @@ class ListItem(Widget): right_rect.y = self._rect.y if self.action_item.render(right_rect) and self.action_item.enabled: # Right item was clicked/activated - if self._click_callback: - self._click_callback() + if self.callback: + self.callback() - def set_icon(self, icon: str | None, starpilot: bool = False): + def set_icon(self, icon: str | None): self.icon = icon - if not icon: - self._icon_texture = None - elif starpilot: - self._icon_texture = gui_app.starpilot_texture(icon, ICON_SIZE, ICON_SIZE) - elif self.icon: - self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) - else: - self._icon_texture = None + self._icon_texture = gui_app.texture(os.path.join("icons", self.icon), ICON_SIZE, ICON_SIZE) if self.icon else None def set_description(self, description: str | Callable[[], str] | None): self._description = description @@ -575,11 +419,15 @@ class ListItem(Widget): right_width = self.action_item.get_width_hint() if right_width == 0: # Full width action (like DualButtonAction) - return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT) + return rl.Rectangle(item_rect.x + ITEM_PADDING, item_rect.y, + item_rect.width - (ITEM_PADDING * 2), ITEM_BASE_HEIGHT) - # Return rect at right edge of item, with action's full width - # The action itself will handle positioning within this rect (right-aligned) - right_x = item_rect.x + item_rect.width - right_width - ITEM_PADDING + # Clip width to available space, never overlapping this Item's title + content_width = item_rect.width - (ITEM_PADDING * 2) + title_width = measure_text_cached(self._font, self.title, ITEM_TEXT_FONT_SIZE).x + right_width = min(content_width - title_width, right_width) + + right_x = item_rect.x + item_rect.width - right_width right_y = item_rect.y return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT) @@ -589,942 +437,32 @@ def simple_item(title: str | Callable[[], str], callback: Callable | None = None return ListItem(title=title, callback=callback) -def toggle_item( - title: str | Callable[[], str], - description: str | Callable[[], str] | None = None, - initial_state: bool = False, - callback: Callable | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, - starpilot_icon: bool = False, -) -> ListItem: +def toggle_item(title: str | Callable[[], str], description: str | Callable[[], str] | None = None, initial_state: bool = False, + callback: Callable | None = None, icon: str = "", enabled: bool | Callable[[], bool] = True) -> ListItem: action = ToggleAction(initial_state=initial_state, enabled=enabled, callback=callback) - icon_to_use = "" if starpilot_icon else icon - item = ListItem(title=title, description=description, action_item=action, icon=icon_to_use) - if icon and starpilot_icon: - item.set_icon(icon, starpilot=True) - return item + return ListItem(title=title, description=description, action_item=action, icon=icon) -def button_item( - title: str | Callable[[], str], - button_text: str | Callable[[], str], - description: str | Callable[[], str] | None = None, - callback: Callable | None = None, - enabled: bool | Callable[[], bool] = True, - icon: str = "", - starpilot_icon: bool = False, -) -> ListItem: +def button_item(title: str | Callable[[], str], button_text: str | Callable[[], str], description: str | Callable[[], str] | None = None, + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: action = ButtonAction(text=button_text, enabled=enabled) - item = ListItem(title=title, description=description, action_item=action, callback=callback, icon=icon if not starpilot_icon else "") - if icon and starpilot_icon: - item.set_icon(icon, starpilot=True) - return item + return ListItem(title=title, description=description, action_item=action, callback=callback) -def text_item( - title: str | Callable[[], str], - value: str | Callable[[], str], - description: str | Callable[[], str] | None = None, - callback: Callable | None = None, - enabled: bool | Callable[[], bool] = True, -) -> ListItem: +def text_item(title: str | Callable[[], str], value: str | Callable[[], str], description: str | Callable[[], str] | None = None, + callback: Callable | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: action = TextAction(text=value, color=ITEM_TEXT_VALUE_COLOR, enabled=enabled) return ListItem(title=title, description=description, action_item=action, callback=callback) -def dual_button_item( - left_text: str | Callable[[], str], - right_text: str | Callable[[], str], - left_callback: Callable = None, - right_callback: Callable = None, - description: str | Callable[[], str] | None = None, - enabled: bool | Callable[[], bool] = True, -) -> ListItem: +def dual_button_item(left_text: str | Callable[[], str], right_text: str | Callable[[], str], + left_callback: Callable | None = None, right_callback: Callable | None = None, + description: str | Callable[[], str] | None = None, enabled: bool | Callable[[], bool] = True) -> ListItem: action = DualButtonAction(left_text, right_text, left_callback, right_callback, enabled) return ListItem(title="", description=description, action_item=action) -def multiple_button_item( - title: str | Callable[[], str], - description: str | Callable[[], str], - buttons: list[str | Callable[[], str]], - selected_index: int, - button_width: int = BUTTON_WIDTH, - callback: Callable = None, - icon: str = "", - starpilot_icon: bool = False, -): +def multiple_button_item(title: str | Callable[[], str], description: str | Callable[[], str], buttons: list[str | Callable[[], str]], selected_index: int, + button_width: int = BUTTON_WIDTH, callback: Callable | None = None, icon: str = ""): action = MultipleButtonAction(buttons, button_width, selected_index, callback=callback) - item = ListItem(title=title, description=description, icon=icon if not starpilot_icon else "", action_item=action) - if icon and starpilot_icon: - item.set_icon(icon, starpilot=True) - return item - - -VALUE_BUTTON_WIDTH = 150 -VALUE_BUTTON_HEIGHT = 80 -VALUE_SLIDER_HEIGHT = 100 -VALUE_DISPLAY_WIDTH = 250 -VALUE_FONT_SIZE = 45 -VALUE_TEXT_COLOR = rl.Color(224, 232, 121, 255) - - -class ValueSliderAction(ItemAction): - def __init__( - self, - value: float | Callable[[], float], - min_val: float = 0, - max_val: float = 100, - step: float = 1, - unit: str = "", - labels: dict[float, str] | None = None, - callback: Callable[[float], None] | None = None, - enabled: bool | Callable[[], bool] = True, - negative: bool = False, - default_value: float | None = None, - fast_increase: bool = False, - is_metric: bool = False, - ): - self._params = Params() - self._value_source = value - self._min_val = min_val - self._max_val = max_val - self._step = step - self._unit = unit - self._labels = labels or {} - self._callback = callback - self._negative = negative - self._default_value = default_value - self._fast_increase = fast_increase - self._is_metric = is_metric - - self._font = gui_app.font(FontWeight.MEDIUM) - self._value_font = gui_app.font(FontWeight.DISPLAY) - - self._decrement_pressed = False - self._increment_pressed = False - self._repeat_timer = 0.0 - self._repeat_delay = 0.5 - self._repeat_interval = 0.1 - - self._params = Params() - - self._metric_multiplier = 1.0 - if self._is_metric: - if self._unit == "mph": - self._metric_multiplier = 1.60934 - elif self._unit == "feet": - self._metric_multiplier = 0.3048 - elif self._unit == "inches": - self._metric_multiplier = 2.54 - - total_width = VALUE_DISPLAY_WIDTH + VALUE_BUTTON_WIDTH * 2 + 20 - super().__init__(width=total_width, enabled=enabled) - - def _get_value(self) -> float: - value = self._value_source - if callable(value): - return float(value()) - if isinstance(value, (int, float)): - return float(value) - if isinstance(value, str) and value: - param_key = value - if self._step == 1: - return float(self._params.get_int(param_key, return_default=True, default=0)) - else: - return float(self._params.get_float(param_key, return_default=True, default=0.0)) - return 0.0 - - def _get_display_text(self, value: float) -> str: - display_val = value - display_unit = self._unit - - if self._is_metric and ui_state.is_metric and self._metric_multiplier != 1.0: - display_val = value * self._metric_multiplier - if self._unit == "mph": - display_unit = "km/h" - elif self._unit == "feet": - display_unit = "m" - elif self._unit == "inches": - display_unit = "cm" - - rounded_value = round(display_val / self._step) * self._step - if self._labels and rounded_value in self._labels: - return self._labels[rounded_value] - if display_unit: - return f"{rounded_value:g}{display_unit}" - return str(rounded_value) - - def _update_value(self, delta: float): - current = self._get_value() - - if self._is_metric and ui_state.is_metric and self._metric_multiplier != 1.0: - delta = delta / self._metric_multiplier - - min_val = _resolve_value(self._min_val, 0) - max_val = _resolve_value(self._max_val, 100) - new_value = max(min_val, min(max_val, current + delta)) - new_value = round(new_value / self._step) * self._step - - if self._callback: - self._callback(new_value) - elif isinstance(self._value_source, str): - param_key = self._value_source - if param_key: - if self._step == 1: - self._params.put_int(param_key, int(new_value)) - else: - self._params.put_float(param_key, new_value) - - def _handle_decrement(self, dt: float): - if self._decrement_pressed: - self._repeat_timer += dt - if self._repeat_timer >= self._repeat_delay: - repeat_count = int((self._repeat_timer - self._repeat_delay) / self._repeat_interval) + 1 - delta = -self._step - if self._fast_increase: - delta *= 5 - for _ in range(repeat_count): - self._update_value(delta) - self._repeat_timer = self._repeat_timer % self._repeat_interval - return True - return False - - def _handle_increment(self, dt: float): - if self._increment_pressed: - self._repeat_timer += dt - if self._repeat_timer >= self._repeat_delay: - repeat_count = int((self._repeat_timer - self._repeat_delay) / self._repeat_interval) + 1 - delta = self._step - if self._fast_increase: - delta *= 5 - for _ in range(repeat_count): - self._update_value(delta) - self._repeat_timer = self._repeat_timer % self._repeat_interval - return True - return False - - def get_width_hint(self) -> float: - return self._rect.width - - def _render(self, rect: rl.Rectangle) -> bool: - value = self._get_value() - display_text = self._get_display_text(value) - - button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 - - dec_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - inc_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - - min_val = _resolve_value(self._min_val, 0) - max_val = _resolve_value(self._max_val, 100) - dec_color = rl.Color(57, 57, 57, 255) if value > min_val else rl.Color(40, 40, 40, 255) - inc_color = rl.Color(57, 57, 57, 255) if value < max_val else rl.Color(40, 40, 40, 255) - - if self._decrement_pressed: - dec_color = rl.Color(74, 74, 74, 255) - if self._increment_pressed: - inc_color = rl.Color(74, 74, 74, 255) - - if not self.enabled: - dec_color = rl.Color(dec_color.r, dec_color.g, dec_color.b, 128) - inc_color = rl.Color(inc_color.r, inc_color.g, inc_color.b, 128) - - rl.draw_rectangle_rounded(dec_btn_rect, 0.3, 10, dec_color) - rl.draw_rectangle_rounded(inc_btn_rect, 0.3, 10, inc_color) - - dec_text = "-" - inc_text = "+" - dec_size = measure_text_cached(self._value_font, dec_text, 50) - inc_size = measure_text_cached(self._value_font, inc_text, 50) - rl.draw_text_ex( - self._value_font, - dec_text, - rl.Vector2(dec_btn_rect.x + (dec_btn_rect.width - dec_size.x) / 2, dec_btn_rect.y + (dec_btn_rect.height - dec_size.y) / 2), - 50, - 0, - rl.WHITE, - ) - rl.draw_text_ex( - self._value_font, - inc_text, - rl.Vector2(inc_btn_rect.x + (inc_btn_rect.width - inc_size.x) / 2, inc_btn_rect.y + (inc_btn_rect.height - inc_size.y) / 2), - 50, - 0, - rl.WHITE, - ) - - value_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - VALUE_DISPLAY_WIDTH - 30, rect.y, VALUE_DISPLAY_WIDTH, rect.height) - gui_label( - value_rect, - display_text, - font_size=VALUE_FONT_SIZE, - color=VALUE_TEXT_COLOR if self.enabled else rl.Color(100, 100, 100, 255), - font_weight=FontWeight.DISPLAY, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - ) - - return False - - def _handle_mouse_release(self, mouse_pos: MousePos): - button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 - - dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - - value = self._get_value() - min_val = _resolve_value(self._min_val, 0) - max_val = _resolve_value(self._max_val, 100) - - # Only one button can be triggered - check all but use elif - if rl.check_collision_point_rec(mouse_pos, inc_btn_rect) and self.enabled and value < max_val: - self._update_value(self._step) - elif rl.check_collision_point_rec(mouse_pos, dec_btn_rect) and self.enabled and value > min_val: - self._update_value(-self._step) - - # Reset all pressed states - self._decrement_pressed = False - self._increment_pressed = False - self._repeat_timer = 0.0 - - def _handle_mouse_event(self, mouse_event): - # Don't call super - we handle everything here to avoid double-processing - button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 - - dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - - # Visual feedback only - set pressed state - if mouse_event.left_pressed: - if rl.check_collision_point_rec(mouse_event.pos, inc_btn_rect) and self.enabled: - self._increment_pressed = True - elif rl.check_collision_point_rec(mouse_event.pos, dec_btn_rect) and self.enabled: - self._decrement_pressed = True - - # Reset on release for visual feedback - if mouse_event.left_released: - self._decrement_pressed = False - self._increment_pressed = False - - -class ValueButtonSliderAction(ValueSliderAction): - def __init__( - self, - value: float | Callable[[], float], - min_val: float = 0, - max_val: float = 100, - step: float = 1, - unit: str = "", - button_text: str = "Reset", - button_callback: Callable | None = None, - labels: dict[float, str] | None = None, - callback: Callable[[float], None] | None = None, - enabled: bool | Callable[[], bool] = True, - negative: bool = False, - default_value: float | None = None, - fast_increase: bool = False, - sub_toggles: list[tuple[str, bool]] | None = None, - is_metric: bool = False, - ): - super().__init__(value, min_val, max_val, step, unit, labels, callback, enabled, negative, default_value, fast_increase, is_metric) - self._button_text = button_text - self._button_callback = button_callback - self._sub_toggles = sub_toggles or [] - self._button_pressed = False - - button_width = 180 - total_width = VALUE_DISPLAY_WIDTH + VALUE_BUTTON_WIDTH * 2 + button_width + 40 - self._rect.width = total_width - - def _render(self, rect: rl.Rectangle) -> bool: - value = self._get_value() - display_text = self._get_display_text(value) - - button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 - button_width = 180 - - dec_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - 40, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - inc_btn_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH - button_width - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - action_btn_rect = rl.Rectangle( - rect.x + rect.width - button_width, button_y + (VALUE_BUTTON_HEIGHT - VALUE_BUTTON_HEIGHT) / 2, button_width, VALUE_BUTTON_HEIGHT - ) - - min_val = _resolve_value(self._min_val, 0) - max_val = _resolve_value(self._max_val, 100) - dec_color = rl.Color(57, 57, 57, 255) if value > min_val else rl.Color(40, 40, 40, 255) - inc_color = rl.Color(57, 57, 57, 255) if value < max_val else rl.Color(40, 40, 40, 255) - - if self._decrement_pressed: - dec_color = rl.Color(74, 74, 74, 255) - if self._increment_pressed: - inc_color = rl.Color(74, 74, 74, 255) - - if not self.enabled: - dec_color = rl.Color(dec_color.r, dec_color.g, dec_color.b, 128) - inc_color = rl.Color(inc_color.r, inc_color.g, inc_color.b, 128) - - rl.draw_rectangle_rounded(dec_btn_rect, 0.3, 10, dec_color) - rl.draw_rectangle_rounded(inc_btn_rect, 0.3, 10, inc_color) - - dec_text = "-" - inc_text = "+" - dec_size = measure_text_cached(self._value_font, dec_text, 50) - inc_size = measure_text_cached(self._value_font, inc_text, 50) - rl.draw_text_ex( - self._value_font, - dec_text, - rl.Vector2(dec_btn_rect.x + (dec_btn_rect.width - dec_size.x) / 2, dec_btn_rect.y + (dec_btn_rect.height - dec_size.y) / 2), - 50, - 0, - rl.WHITE, - ) - rl.draw_text_ex( - self._value_font, - inc_text, - rl.Vector2(inc_btn_rect.x + (inc_btn_rect.width - inc_size.x) / 2, inc_btn_rect.y + (inc_btn_rect.height - inc_size.y) / 2), - 50, - 0, - rl.WHITE, - ) - - value_rect = rl.Rectangle(rect.x + rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - VALUE_DISPLAY_WIDTH - 50, rect.y, VALUE_DISPLAY_WIDTH, rect.height) - gui_label( - value_rect, - display_text, - font_size=VALUE_FONT_SIZE, - color=VALUE_TEXT_COLOR if self.enabled else rl.Color(100, 100, 100, 255), - font_weight=FontWeight.DISPLAY, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - ) - - btn_color = rl.Color(57, 57, 57, 255) - if self._button_pressed: - btn_color = rl.Color(74, 74, 74, 255) - if not self.enabled: - btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) - - rl.draw_rectangle_rounded(action_btn_rect, 0.3, 10, btn_color) - - btn_text = _resolve_value(self._button_text, "Reset") - btn_font = gui_app.font(FontWeight.MEDIUM) - btn_size = measure_text_cached(btn_font, btn_text, 35) - rl.draw_text_ex( - btn_font, - btn_text, - rl.Vector2(action_btn_rect.x + (action_btn_rect.width - btn_size.x) / 2, action_btn_rect.y + (action_btn_rect.height - btn_size.y) / 2), - 35, - 0, - rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), - ) - - return False - - def _handle_mouse_release(self, mouse_pos: MousePos): - button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 - button_width = 180 - - dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - 40, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH - button_width - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - action_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - button_width, button_y, button_width, VALUE_BUTTON_HEIGHT) - - value = self._get_value() - min_val = _resolve_value(self._min_val, 0) - max_val = _resolve_value(self._max_val, 100) - - # Only one button can be triggered - check all but use elif - if rl.check_collision_point_rec(mouse_pos, action_btn_rect) and self.enabled: - if self._button_callback: - self._button_callback() - elif rl.check_collision_point_rec(mouse_pos, inc_btn_rect) and self.enabled and value < max_val: - self._update_value(self._step) - elif rl.check_collision_point_rec(mouse_pos, dec_btn_rect) and self.enabled and value > min_val: - self._update_value(-self._step) - - # Reset all pressed states - self._decrement_pressed = False - self._increment_pressed = False - self._button_pressed = False - self._repeat_timer = 0.0 - - def _handle_mouse_event(self, mouse_event): - # Don't call super - we handle everything here to avoid double-processing - button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 - button_width = 180 - - dec_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH * 2 - button_width - 40, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - inc_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - VALUE_BUTTON_WIDTH - button_width - 20, button_y, VALUE_BUTTON_WIDTH, VALUE_BUTTON_HEIGHT) - action_btn_rect = rl.Rectangle(self._rect.x + self._rect.width - button_width, button_y, button_width, VALUE_BUTTON_HEIGHT) - - # Visual feedback only - set pressed state - if mouse_event.left_pressed: - if rl.check_collision_point_rec(mouse_event.pos, action_btn_rect) and self.enabled: - self._button_pressed = True - elif rl.check_collision_point_rec(mouse_event.pos, inc_btn_rect) and self.enabled: - self._increment_pressed = True - elif rl.check_collision_point_rec(mouse_event.pos, dec_btn_rect) and self.enabled: - self._decrement_pressed = True - - # Reset on release for visual feedback - if mouse_event.left_released: - self._decrement_pressed = False - self._increment_pressed = False - self._button_pressed = False - - -class DualValueSliderAction(ItemAction): - def __init__( - self, - value1: float | Callable[[], float], - value2: float | Callable[[], float], - min_val: float = 0, - max_val: float = 100, - step: float = 1, - unit: str = "", - label1: str = "", - label2: str = "", - callback1: Callable[[float], None] | None = None, - callback2: Callable[[float], None] | None = None, - enabled: bool | Callable[[], bool] = True, - labels: dict[float, str] | None = None, - is_metric: bool = False, - ): - self._value1_source = value1 - self._value2_source = value2 - self._min_val = min_val - self._max_val = max_val - self._step = step - self._unit = unit - self._label1 = label1 - self._label2 = label2 - self._callback1 = callback1 - self._callback2 = callback2 - self._labels = labels or [] - self._params = Params() - - self._metric_multiplier = 1.0 - if is_metric: - if self._unit == "mph": - self._metric_multiplier = 1.60934 - elif self._unit == "feet": - self._metric_multiplier = 0.3048 - elif self._unit == "inches": - self._metric_multiplier = 2.54 - - self._slider1 = ValueSliderAction(value1, min_val, max_val, step, unit, labels, callback1, enabled, is_metric=is_metric) - self._slider2 = ValueSliderAction(value2, min_val, max_val, step, unit, labels, callback2, enabled, is_metric=is_metric) - - total_width = (VALUE_DISPLAY_WIDTH + VALUE_BUTTON_WIDTH * 2 + 20) * 2 + 40 - super().__init__(width=total_width, enabled=enabled) - - def _render(self, rect: rl.Rectangle) -> bool: - half_width = (rect.width - 40) / 2 - - slider1_rect = rl.Rectangle(rect.x, rect.y, half_width, rect.height) - slider2_rect = rl.Rectangle(rect.x + half_width + 40, rect.y, half_width, rect.height) - - if self._label1: - label_rect = rl.Rectangle(slider1_rect.x, slider1_rect.y - 30, half_width, 25) - gui_label(label_rect, self._label1, font_size=30, color=rl.Color(170, 170, 170, 255), font_weight=FontWeight.NORMAL) - - if self._label2: - label_rect = rl.Rectangle(slider2_rect.x, slider2_rect.y - 30, half_width, 25) - gui_label(label_rect, self._label2, font_size=30, color=rl.Color(170, 170, 170, 255), font_weight=FontWeight.NORMAL) - - return False - - def _handle_mouse_event(self, mouse_event): - half_width = (self._rect.width - 40) / 2 - slider1_rect = rl.Rectangle(self._rect.x, self._rect.y, half_width, self._rect.height) - slider2_rect = rl.Rectangle(self._rect.x + half_width + 40, self._rect.y, half_width, self._rect.height) - - adjusted_pos1 = MousePos( - rl.Vector2(mouse_event.pos.x - slider1_rect.x + self._rect.x, mouse_event.pos.y), - mouse_event.left_pressed, - mouse_event.left_released, - mouse_event.right_pressed, - mouse_event.right_released, - ) - adjusted_pos2 = MousePos( - rl.Vector2(mouse_event.pos.x - slider2_rect.x + self._rect.x + half_width + 40, mouse_event.pos.y), - mouse_event.left_pressed, - mouse_event.left_released, - mouse_event.right_pressed, - mouse_event.right_released, - ) - - if 0 <= mouse_event.pos.x - slider1_rect.x < half_width: - self._slider1._handle_mouse_event(adjusted_pos1) - if 0 <= mouse_event.pos.x - slider2_rect.x - half_width - 40 < half_width: - self._slider2._handle_mouse_event(adjusted_pos2) - - -class ButtonToggleAction(ItemAction): - def __init__( - self, - state: bool | Callable[[], bool], - sub_toggles: list[str] | None = None, - sub_toggle_names: list[str] | None = None, - callback: Callable[[bool], None] | None = None, - sub_callbacks: list[Callable] | None = None, - enabled: bool | Callable[[], bool] = True, - exclusive: bool = False, - ): - self._state_source = state - self._sub_toggles = sub_toggles or [] - self._sub_toggle_names = sub_toggle_names or [] - self._callback = callback - self._sub_callbacks = sub_callbacks or [] - self._exclusive = exclusive - self._params = Params() - - self._toggle = Toggle(initial_state=_resolve_value(state, False), callback=callback) - - button_width = 180 - total_width = TOGGLE_WIDTH + len(self._sub_toggles) * (button_width + 10) - super().__init__(width=total_width, enabled=enabled) - - def get_width_hint(self) -> float: - return self._rect.width - - def _render(self, rect: rl.Rectangle) -> bool: - toggle_rect = rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, TOGGLE_WIDTH, TOGGLE_HEIGHT) - self._toggle.set_enabled(self.enabled) - clicked = self._toggle.render(toggle_rect) - - button_width = 180 - button_spacing = 10 - button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 - - x_offset = rect.x + TOGGLE_WIDTH + 20 - - for i, (sub_key, sub_name) in enumerate(zip(self._sub_toggles, self._sub_toggle_names)): - btn_rect = rl.Rectangle(x_offset + i * (button_width + button_spacing), button_y, button_width, VALUE_BUTTON_HEIGHT) - - sub_value = False - if isinstance(sub_key, str) and sub_key: - sub_value = self._params.get_bool(sub_key) - - btn_color = rl.Color(51, 171, 76, 255) if sub_value else rl.Color(57, 57, 57, 255) - if not self.enabled: - btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) - - rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) - - text_size = measure_text_cached(gui_app.font(FontWeight.MEDIUM), sub_name, 30) - rl.draw_text_ex( - gui_app.font(FontWeight.MEDIUM), - sub_name, - rl.Vector2(btn_rect.x + (btn_rect.width - text_size.x) / 2, btn_rect.y + (btn_rect.height - text_size.y) / 2), - 30, - 0, - rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), - ) - - return clicked - - def _handle_mouse_release(self, mouse_pos: MousePos): - button_width = 180 - button_spacing = 10 - button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 - - x_offset = self._rect.x + TOGGLE_WIDTH + 20 - - for i, (sub_key, sub_callback) in enumerate(zip(self._sub_toggles, self._sub_callbacks)): - btn_rect = rl.Rectangle(x_offset + i * (button_width + button_spacing), button_y, button_width, VALUE_BUTTON_HEIGHT) - if rl.check_collision_point_rec(mouse_pos, btn_rect) and self.enabled: - if isinstance(sub_key, str) and sub_key: - current = self._params.get_bool(sub_key) - self._params.put_bool(sub_key, not current) - if sub_callback: - sub_callback() - - -class MultiButtonsAction(ItemAction): - def __init__( - self, - buttons: list[str | Callable[[], str]], - button_callbacks: list[Callable] | None = None, - enabled: bool | Callable[[], bool] = True, - initial_value: str = "", - ): - self._buttons_source = buttons - self._button_callbacks = button_callbacks or [] - self._initial_value = initial_value - - button_width = 180 - total_width = len(buttons) * button_width + (len(buttons) - 1) * RIGHT_ITEM_PADDING - super().__init__(width=total_width, enabled=enabled) - - self._font = gui_app.font(FontWeight.MEDIUM) - - def get_width_hint(self) -> float: - return self._rect.width - - def _render(self, rect: rl.Rectangle) -> bool: - buttons = _resolve_value(self._buttons_source, []) - button_width = 180 - spacing = RIGHT_ITEM_PADDING - button_y = rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2 - - pressed_any = False - - for i, btn_text in enumerate(buttons): - btn_x = rect.x + i * (button_width + spacing) - btn_rect = rl.Rectangle(btn_x, button_y, button_width, VALUE_BUTTON_HEIGHT) - - btn_color = rl.Color(57, 57, 57, 255) - if not self.enabled: - btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) - - rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) - - text = _resolve_value(btn_text, "") - text_size = measure_text_cached(self._font, text, 30) - text_x = btn_rect.x + (btn_rect.width - text_size.x) / 2 - text_y = btn_rect.y + (btn_rect.height - text_size.y) / 2 - rl.draw_text_ex(self._font, text, rl.Vector2(text_x, text_y), 30, 0, rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255)) - - return False - - def _handle_mouse_release(self, mouse_pos: MousePos): - buttons = _resolve_value(self._buttons_source, []) - button_width = 180 - spacing = RIGHT_ITEM_PADDING - button_y = self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2 - - for i, callback in enumerate(self._button_callbacks): - btn_rect = rl.Rectangle(self._rect.x + i * (button_width + spacing), button_y, button_width, VALUE_BUTTON_HEIGHT) - if rl.check_collision_point_rec(mouse_pos, btn_rect) and self.enabled: - if callback: - callback() - - -class SelectionButtonAction(ItemAction): - def __init__( - self, - options: list[str], - selected_index: int = 0, - callback: Callable[[int, str], None] | None = None, - enabled: bool | Callable[[], bool] = True, - ): - self._options = options - self._selected_index = selected_index - self._callback = callback - self._params = Params() - self._button_pressed = False - - button_width = 300 - super().__init__(width=button_width, enabled=enabled) - - self._font = gui_app.font(FontWeight.MEDIUM) - - def get_width_hint(self) -> float: - return self._rect.width - - def _render(self, rect: rl.Rectangle) -> bool: - btn_rect = rl.Rectangle(rect.x, rect.y + (rect.height - VALUE_BUTTON_HEIGHT) / 2, self._rect.width, VALUE_BUTTON_HEIGHT) - - btn_color = rl.Color(57, 57, 57, 255) - if self._button_pressed: - btn_color = rl.Color(74, 74, 74, 255) - if not self.enabled: - btn_color = rl.Color(btn_color.r, btn_color.g, btn_color.b, 128) - - rl.draw_rectangle_rounded(btn_rect, 0.3, 10, btn_color) - - current_option = self._options[self._selected_index] if 0 <= self._selected_index < len(self._options) else "" - text_size = measure_text_cached(self._font, current_option, 35) - rl.draw_text_ex( - self._font, - current_option, - rl.Vector2(btn_rect.x + 20, btn_rect.y + (btn_rect.height - text_size.y) / 2), - 35, - 0, - rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), - ) - - arrow_text = "β–Ό" - arrow_size = measure_text_cached(self._font, arrow_text, 25) - rl.draw_text_ex( - self._font, - arrow_text, - rl.Vector2(btn_rect.x + btn_rect.width - arrow_size.x - 20, btn_rect.y + (btn_rect.height - arrow_size.y) / 2), - 25, - 0, - rl.WHITE if self.enabled else rl.Color(150, 150, 150, 255), - ) - - return self._button_pressed - - def _handle_mouse_release(self, mouse_pos: MousePos): - btn_rect = rl.Rectangle(self._rect.x, self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2, self._rect.width, VALUE_BUTTON_HEIGHT) - if rl.check_collision_point_rec(mouse_pos, btn_rect) and self.enabled and self._button_pressed: - if self._callback: - self._callback(self._selected_index, self._options[self._selected_index] if 0 <= self._selected_index < len(self._options) else "") - self._button_pressed = False - - def _handle_mouse_event(self, mouse_event): - super()._handle_mouse_event(mouse_event) - btn_rect = rl.Rectangle(self._rect.x, self._rect.y + (self._rect.height - VALUE_BUTTON_HEIGHT) / 2, self._rect.width, VALUE_BUTTON_HEIGHT) - if mouse_event.left_pressed: - if rl.check_collision_point_rec(mouse_event.pos, btn_rect) and self.enabled: - self._button_pressed = True - if mouse_event.left_released: - if not rl.check_collision_point_rec(mouse_event.pos, btn_rect): - self._button_pressed = False - - -class LabelAction(ItemAction): - def __init__( - self, - value: str | Callable[[], str], - enabled: bool | Callable[[], bool] = True, - ): - self._value_source = value - self._font = gui_app.font(FontWeight.NORMAL) - initial_text = _resolve_value(value, "") - text_width = measure_text_cached(self._font, initial_text, ITEM_TEXT_FONT_SIZE).x - super().__init__(width=int(text_width + TEXT_PADDING), enabled=enabled) - - def get_width_hint(self) -> float: - text = _resolve_value(self._value_source, "") - text_width = measure_text_cached(self._font, text, ITEM_TEXT_FONT_SIZE).x - return text_width + TEXT_PADDING - - def _render(self, rect: rl.Rectangle) -> bool: - value = _resolve_value(self._value_source, "") - gui_label( - rect, - value, - font_size=ITEM_TEXT_FONT_SIZE, - color=ITEM_TEXT_VALUE_COLOR if self.enabled else rl.Color(100, 100, 100, 255), - font_weight=FontWeight.NORMAL, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - ) - return False - - -def value_item( - title: str | Callable[[], str], - value: float | Callable[[], float], - min_val: float = 0, - max_val: float = 100, - step: float = 1, - unit: str = "", - description: str | Callable[[], str] | None = None, - callback: Callable[[float], None] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, - labels: dict[float, str] | None = None, - negative: bool = False, - default_value: float | None = None, - fast_increase: bool = False, - is_metric: bool = False, -) -> ListItem: - action = ValueSliderAction(value, min_val, max_val, step, unit, labels, callback, enabled, negative, default_value, fast_increase, is_metric) - return ListItem(title=title, description=description, icon=icon, action_item=action) - - -def value_button_item( - title: str | Callable[[], str], - value: float | Callable[[], float], - min_val: float = 0, - max_val: float = 100, - step: float = 1, - unit: str = "", - button_text: str = "Reset", - button_callback: Callable | None = None, - description: str | Callable[[], str] | None = None, - callback: Callable[[float], None] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, - sub_toggles: list[tuple[str, bool]] | None = None, - labels: dict[float, str] | None = None, - negative: bool = False, - default_value: float | None = None, - fast_increase: bool = False, - is_metric: bool = False, -) -> ListItem: - action = ValueButtonSliderAction( - value, min_val, max_val, step, unit, button_text, button_callback, labels, callback, enabled, negative, default_value, fast_increase, sub_toggles, is_metric - ) - return ListItem(title=title, description=description, icon=icon, action_item=action) - - -def dual_value_item( - title: str | Callable[[], str], - value1: float | Callable[[], float], - value2: float | Callable[[], float], - min_val: float = 0, - max_val: float = 100, - step: float = 1, - unit: str = "", - label1: str = "", - label2: str = "", - description: str | Callable[[], str] | None = None, - callback1: Callable[[float], None] | None = None, - callback2: Callable[[float], None] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, - labels: dict[float, str] | None = None, - is_metric: bool = False, -) -> ListItem: - action = DualValueSliderAction(value1, value2, min_val, max_val, step, unit, label1, label2, callback1, callback2, enabled, labels, is_metric) - return ListItem(title=title, description=description, icon=icon, action_item=action) - - -def button_toggle_item( - title: str | Callable[[], str], - state: bool | Callable[[], bool], - sub_toggles: list[str] | None = None, - sub_toggle_names: list[str] | None = None, - description: str | Callable[[], str] | None = None, - callback: Callable[[bool], None] | None = None, - sub_callbacks: list[Callable] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, - exclusive: bool = False, -) -> ListItem: - action = ButtonToggleAction(state, sub_toggles, sub_toggle_names, callback, sub_callbacks, enabled, exclusive) - return ListItem(title=title, description=description, icon=icon, action_item=action) - - -def buttons_item( - title: str | Callable[[], str], - buttons: list[str], - button_callbacks: list[Callable] | None = None, - description: str | Callable[[], str] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, - initial_value: str = "", -) -> ListItem: - action = MultiButtonsAction(buttons, button_callbacks, enabled, initial_value) - return ListItem(title=title, description=description, icon=icon, action_item=action) - - -def selection_button_item( - title: str | Callable[[], str], - options: list[str], - selected_index: int = 0, - description: str | Callable[[], str] | None = None, - callback: Callable[[int, str], None] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, -) -> ListItem: - action = SelectionButtonAction(options, selected_index, callback, enabled) - return ListItem(title=title, description=description, icon=icon, action_item=action) - - -def label_item( - title: str | Callable[[], str], - value: str | Callable[[], str], - description: str | Callable[[], str] | None = None, - icon: str = "", - enabled: bool | Callable[[], bool] = True, -) -> ListItem: - action = LabelAction(value, enabled) return ListItem(title=title, description=description, icon=icon, action_item=action) diff --git a/system/ui/widgets/mici_keyboard.py b/system/ui/widgets/mici_keyboard.py index 7459dc573..75a3c29e6 100644 --- a/system/ui/widgets/mici_keyboard.py +++ b/system/ui/widgets/mici_keyboard.py @@ -38,10 +38,10 @@ def fast_euclidean_distance(dx, dy): class Key(Widget): - def __init__(self, char: str): + def __init__(self, char: str, font_weight: FontWeight = FontWeight.SEMI_BOLD): super().__init__() self.char = char - self._font = gui_app.font(FontWeight.SEMI_BOLD) + self._font = gui_app.font(font_weight) self._x_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) self._y_filter = BounceFilter(0.0, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) self._size_filter = BounceFilter(CHAR_FONT_SIZE, 0.1 * ANIMATION_SCALE, 1 / gui_app.target_fps) @@ -53,20 +53,23 @@ class Key(Widget): self.original_position = rl.Vector2(0, 0) def set_position(self, x: float, y: float, smooth: bool = True): - # TODO: swipe up from NavWidget has the keys lag behind other elements a bit + # Smooth keys within parent rect + base_y = self._parent_rect.y if self._parent_rect else 0.0 + local_y = y - base_y + if not self._position_initialized: self._x_filter.x = x - self._y_filter.x = y + self._y_filter.x = local_y # keep track of original position so dragging around feels consistent. also move touch area down a bit - self.original_position = rl.Vector2(x, y + KEY_TOUCH_AREA_OFFSET) + self.original_position = rl.Vector2(x, local_y + KEY_TOUCH_AREA_OFFSET) self._position_initialized = True if not smooth: self._x_filter.x = x - self._y_filter.x = y + self._y_filter.x = local_y self._rect.x = self._x_filter.update(x) - self._rect.y = self._y_filter.update(y) + self._rect.y = base_y + self._y_filter.update(local_y) def set_alpha(self, alpha: float): self._alpha_filter.update(alpha) @@ -92,12 +95,12 @@ class Key(Widget): self._size_filter.update(size) def _get_font_size(self) -> int: - return int(round(self._size_filter.x)) + return round(self._size_filter.x) class SmallKey(Key): def __init__(self, chars: str): - super().__init__(chars) + super().__init__(chars, FontWeight.BOLD) self._size_filter.x = NUMBER_LAYER_SWITCH_FONT_SIZE def set_font_size(self, size: float): @@ -105,13 +108,15 @@ class SmallKey(Key): class IconKey(Key): - def __init__(self, icon: str, vertical_align: str = "center", char: str = ""): + def __init__(self, icon: str, vertical_align: str = "center", char: str = "", icon_size: tuple[int, int] = (38, 38)): super().__init__(char) - self._icon = gui_app.texture(icon, 38, 38) + self._icon_size = icon_size + self._icon = gui_app.texture(icon, *icon_size) self._vertical_align = vertical_align - def set_icon(self, icon: str): - self._icon = gui_app.texture(icon, 38, 38) + def set_icon(self, icon: str, icon_size: tuple[int, int] | None = None): + size = icon_size if icon_size is not None else self._icon_size + self._icon = gui_app.texture(icon, *size) def _render(self, _): scale = np.interp(self._size_filter.x, [CHAR_FONT_SIZE, CHAR_NEAR_FONT_SIZE], [1, 1.5]) @@ -141,8 +146,9 @@ class CapsState(IntEnum): class MiciKeyboard(Widget): - def __init__(self): + def __init__(self, auto_return_to_letters: str = ""): super().__init__() + self._auto_return_to_letters = auto_return_to_letters lower_chars = [ "qwertyuiop", @@ -167,8 +173,8 @@ class MiciKeyboard(Widget): self._super_special_keys = [[Key(char) for char in row] for row in super_special_chars] # control keys - self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom") - self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png") + self._space_key = IconKey("icons_mici/settings/keyboard/space.png", char=" ", vertical_align="bottom", icon_size=(43, 14)) + self._caps_key = IconKey("icons_mici/settings/keyboard/caps_lower.png", icon_size=(38, 33)) # these two are in different places on some layouts self._123_key, self._123_key2 = SmallKey("123"), SmallKey("123") self._abc_key = SmallKey("abc") @@ -222,6 +228,8 @@ class MiciKeyboard(Widget): for current_row, row in zip(self._current_keys, keys, strict=False): # not all layouts have the same number of keys for current_key, key in zip_repeat(current_row, row): + # reset parent rect for new keys + key.set_parent_rect(self._rect) current_pos = current_key.get_position() key.set_position(current_pos[0], current_pos[1], smooth=False) @@ -259,7 +267,8 @@ class MiciKeyboard(Widget): for key in row: mouse_pos = gui_app.last_mouse_event.pos # approximate distance for comparison is accurate enough - dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - mouse_pos.y) + # use local y coords so parent widget offset (e.g. during NavWidget animate-in) doesn't affect hit testing + dist = abs(key.original_position.x - mouse_pos.x) + abs(key.original_position.y - (mouse_pos.y - self._rect.y)) if dist < closest_key[1]: if self._closest_key[0] is None or key is self._closest_key[0] or dist < self._closest_key[1] - KEY_DRAG_HYSTERESIS: closest_key = (key, dist) @@ -269,14 +278,14 @@ class MiciKeyboard(Widget): self._set_keys(self._upper_keys if cycle else self._lower_keys) if not cycle: self._caps_state = CapsState.LOWER - self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png") + self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lower.png", icon_size=(38, 33)) else: if self._caps_state == CapsState.LOWER: self._caps_state = CapsState.UPPER - self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png") + self._caps_key.set_icon("icons_mici/settings/keyboard/caps_upper.png", icon_size=(38, 33)) elif self._caps_state == CapsState.UPPER: self._caps_state = CapsState.LOCK - self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png") + self._caps_key.set_icon("icons_mici/settings/keyboard/caps_lock.png", icon_size=(39, 38)) else: self._set_uppercase(False) @@ -297,6 +306,10 @@ class MiciKeyboard(Widget): if self._caps_state == CapsState.UPPER: self._set_uppercase(False) + # Switch back to letters after common URL delimiters + if self._closest_key[0].char in self._auto_return_to_letters and self._current_keys in (self._special_keys, self._super_special_keys): + self._set_uppercase(False) + # ensure minimum selected animation time key_selected_dt = rl.get_time() - (self._selected_key_t or 0) cur_t = rl.get_time() @@ -314,7 +327,7 @@ class MiciKeyboard(Widget): self._selected_key_filter.update(self._closest_key[0] is not None) # unselect key after animation plays - if self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t: + if (self._unselect_key_t is not None and rl.get_time() > self._unselect_key_t) or not self.enabled: self._closest_key = (None, float('inf')) self._unselect_key_t = None self._selected_key_t = None @@ -365,6 +378,7 @@ class MiciKeyboard(Widget): key.set_font_size(font_size) # TODO: I like the push amount, so we should clip the pos inside the keyboard rect + key.set_parent_rect(self._rect) key.set_position(key_x, key_y) def _render(self, _): diff --git a/system/ui/widgets/nav_widget.py b/system/ui/widgets/nav_widget.py new file mode 100644 index 000000000..11770bbe5 --- /dev/null +++ b/system/ui/widgets/nav_widget.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import abc +import pyray as rl +from collections.abc import Callable +from openpilot.system.ui.widgets import Widget +from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter +from openpilot.system.ui.lib.application import gui_app, MousePos, MouseEvent + +SWIPE_AWAY_THRESHOLD = 80 # px to dismiss after releasing +START_DISMISSING_THRESHOLD = 40 # px to start dismissing while dragging +BLOCK_SWIPE_AWAY_THRESHOLD = 60 # px horizontal movement to block swipe away + +NAV_BAR_MARGIN = 6 +NAV_BAR_WIDTH = 205 +NAV_BAR_HEIGHT = 8 + +DISMISS_PUSH_OFFSET = NAV_BAR_MARGIN + NAV_BAR_HEIGHT + 50 # px extra to push down when dismissing +DISMISS_ANIMATION_RC = 0.2 # slightly slower for non-user triggered dismiss animation + + +class NavBar(Widget): + FADE_AFTER_SECONDS = 2.0 + + def __init__(self): + super().__init__() + self.set_rect(rl.Rectangle(0, 0, NAV_BAR_WIDTH, NAV_BAR_HEIGHT)) + self._alpha = 1.0 + self._alpha_filter = FirstOrderFilter(1.0, 0.1, 1 / gui_app.target_fps) + self._fade_time = 0.0 + + def set_alpha(self, alpha: float) -> None: + self._alpha = alpha + self._fade_time = rl.get_time() + + def show_event(self): + super().show_event() + self._alpha = 1.0 + self._alpha_filter.x = 1.0 + self._fade_time = rl.get_time() + + def _render(self, _): + if rl.get_time() - self._fade_time > self.FADE_AFTER_SECONDS: + self._alpha = 0.0 + alpha = self._alpha_filter.update(self._alpha) + + # white bar with black border + rl.draw_rectangle_rounded(self._rect, 1.0, 6, rl.Color(255, 255, 255, int(255 * 0.9 * alpha))) + rl.draw_rectangle_rounded_lines_ex(self._rect, 1.0, 6, 2, rl.Color(0, 0, 0, int(255 * 0.3 * alpha))) + + +class NavWidget(Widget, abc.ABC): + """ + A full screen widget that supports back navigation by swiping down from the top. + """ + BACK_TOUCH_AREA_PERCENTAGE = 0.65 + + def __init__(self): + super().__init__() + # State + self._drag_start_pos: MousePos | None = None # cleared after certain amount of horizontal movement + self._dragging_down = False # swiped down enough to trigger dismissing on release + self._playing_dismiss_animation = False # released and animating away + self._y_pos_filter = BounceFilter(0.0, 0.1, 1 / gui_app.target_fps, bounce=1) + + self._back_callback: Callable[[], None] | None = None # persistent callback for user-initiated back navigation + self._dismiss_callback: Callable[[], None] | None = None # transient callback for programmatic dismiss + # TODO: add this functionality to push_widget + self._shown_callback: Callable[[], None] | None = None # transient callback fired after show animation completes + + # TODO: move this state into NavBar + self._nav_bar = self._child(NavBar()) + self._nav_bar_show_time = 0.0 + self._nav_bar_y_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + + def _back_enabled(self) -> bool: + # Children can override this to block swipe away, like when not at + # the top of a vertical scroll panel to prevent erroneous swipes + return True + + def set_back_callback(self, callback: Callable[[], None]) -> None: + self._back_callback = callback + + def set_shown_callback(self, callback: Callable[[], None] | None) -> None: + self._shown_callback = callback + + def _handle_mouse_event(self, mouse_event: MouseEvent) -> None: + super()._handle_mouse_event(mouse_event) + + # Don't let touch events change filter state during dismiss animation + if self._playing_dismiss_animation: + return + + if mouse_event.left_pressed: + # user is able to swipe away if starting near top of screen + self._y_pos_filter.update_alpha(0.04) + in_dismiss_area = mouse_event.pos.y < self._rect.height * self.BACK_TOUCH_AREA_PERCENTAGE + + if in_dismiss_area and self._back_enabled(): + self._drag_start_pos = mouse_event.pos + + elif mouse_event.left_down: + if self._drag_start_pos is not None: + # block swiping away if too much horizontal or upward movement + # block (lock-in) threshold is higher than start dismissing + horizontal_movement = abs(mouse_event.pos.x - self._drag_start_pos.x) > BLOCK_SWIPE_AWAY_THRESHOLD + upward_movement = mouse_event.pos.y - self._drag_start_pos.y < -BLOCK_SWIPE_AWAY_THRESHOLD + + if not (horizontal_movement or upward_movement): + # no blocking movement, check if we should start dismissing + if mouse_event.pos.y - self._drag_start_pos.y > START_DISMISSING_THRESHOLD: + self._dragging_down = True + else: + if not self._dragging_down: + self._drag_start_pos = None + + elif mouse_event.left_released: + # reset rc for either slide up or down animation + self._y_pos_filter.update_alpha(0.1) + + # if far enough, trigger back navigation callback + if self._drag_start_pos is not None: + if mouse_event.pos.y - self._drag_start_pos.y > SWIPE_AWAY_THRESHOLD: + self._playing_dismiss_animation = True + + self._drag_start_pos = None + self._dragging_down = False + + def _update_state(self): + super()._update_state() + + new_y = 0.0 + + if self._dragging_down: + self._nav_bar.set_alpha(1.0) + + # FIXME: disabling this widget on new push_widget still causes this widget to track mouse events without mouse down + if not self.enabled: + self._drag_start_pos = None + + if self._drag_start_pos is not None: + last_mouse_event = gui_app.last_mouse_event + # push entire widget as user drags it away + new_y = max(last_mouse_event.pos.y - self._drag_start_pos.y, 0) + if new_y < SWIPE_AWAY_THRESHOLD: + new_y /= 2 # resistance until mouse release would dismiss widget + + if self._playing_dismiss_animation: + new_y = self._rect.height + DISMISS_PUSH_OFFSET + + new_y = self._y_pos_filter.update(new_y) + if abs(new_y) < 1 and abs(self._y_pos_filter.velocity.x) < 0.5: + new_y = self._y_pos_filter.x = 0.0 + self._y_pos_filter.velocity.x = 0.0 + + if self._shown_callback is not None: + self._shown_callback() + self._shown_callback = None + + if new_y > self._rect.height + DISMISS_PUSH_OFFSET - 10: + gui_app.pop_widget() + + # Only one callback should ever be fired + if self._dismiss_callback is not None: + self._dismiss_callback() + self._dismiss_callback = None + elif self._back_callback is not None: + self._back_callback() + + self._playing_dismiss_animation = False + self._drag_start_pos = None + self._dragging_down = False + + self.set_position(self._rect.x, new_y) + + def _layout(self): + # Dim whatever is behind this widget, fading with position (runs after _update_state so position is correct) + overlay_alpha = int(200 * max(0.0, min(1.0, 1.0 - self._rect.y / self._rect.height))) if self._rect.height > 0 else 0 + rl.draw_rectangle_rec(rl.Rectangle(0, 0, self._rect.width, self._rect.height), rl.Color(0, 0, 0, overlay_alpha)) + + bounce_height = 20 + rl.draw_rectangle_rec(rl.Rectangle(self._rect.x, self._rect.y, self._rect.width, self._rect.height + bounce_height), rl.BLACK) + + def render(self, rect: rl.Rectangle | None = None) -> bool | int | None: + ret = super().render(rect) + + bar_x = self._rect.x + (self._rect.width - self._nav_bar.rect.width) / 2 + nav_bar_delayed = rl.get_time() - self._nav_bar_show_time < 0.4 + # User dragging or dismissing, nav bar follows NavWidget + if self._drag_start_pos is not None or self._playing_dismiss_animation: + self._nav_bar_y_filter.x = NAV_BAR_MARGIN + self._y_pos_filter.x + # Waiting to show + elif nav_bar_delayed: + self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT + # Animate back to top + else: + self._nav_bar_y_filter.update(NAV_BAR_MARGIN) + + self._nav_bar.set_position(bar_x, self._nav_bar_y_filter.x) + self._nav_bar.render() + + return ret + + @property + def is_dismissing(self) -> bool: + return self._dragging_down or self._playing_dismiss_animation + + def dismiss(self, callback: Callable[[], None] | None = None): + """Programmatically trigger the dismiss animation. Calls pop_widget when done, then callback.""" + if not self._playing_dismiss_animation: + self._playing_dismiss_animation = True + self._y_pos_filter.update_alpha(DISMISS_ANIMATION_RC) + self._dismiss_callback = callback + + def show_event(self): + super().show_event() + + # Reset state + self._drag_start_pos = None + self._dragging_down = False + self._playing_dismiss_animation = False + self._dismiss_callback = None + # Start NavWidget off-screen, no matter how tall it is + self._y_pos_filter.update_alpha(0.1) + self._y_pos_filter.x = gui_app.height + self._y_pos_filter.velocity.x = 0.0 + + self._nav_bar_y_filter.x = -NAV_BAR_MARGIN - NAV_BAR_HEIGHT + self._nav_bar_show_time = rl.get_time() diff --git a/system/ui/widgets/network.py b/system/ui/widgets/network.py index f41a04c24..e739eef63 100644 --- a/system/ui/widgets/network.py +++ b/system/ui/widgets/network.py @@ -6,8 +6,8 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel -from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType -from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.lib.wifi_manager import WifiManager, SecurityType, Network, MeteredType, normalize_ssid +from openpilot.system.ui.widgets import DialogResult, Widget from openpilot.system.ui.widgets.button import ButtonStyle, Button from openpilot.system.ui.widgets.confirm_dialog import ConfirmDialog from openpilot.system.ui.widgets.keyboard import Keyboard @@ -22,8 +22,8 @@ try: from openpilot.selfdrive.ui.lib.prime_state import PrimeType except Exception: Params = None - ui_state = None # type: ignore - PrimeType = None # type: ignore + ui_state = None + PrimeType = None NM_DEVICE_STATE_NEED_AUTH = 60 MIN_PASSWORD_LENGTH = 8 @@ -69,17 +69,14 @@ class NetworkUI(Widget): super().__init__() self._wifi_manager = wifi_manager self._current_panel: PanelType = PanelType.WIFI - self._wifi_panel = WifiManagerUI(wifi_manager) - self._advanced_panel = AdvancedNetworkSettings(wifi_manager) - self._nav_button = NavButton(tr("Advanced")) + self._wifi_panel = self._child(WifiManagerUI(wifi_manager)) + self._advanced_panel = self._child(AdvancedNetworkSettings(wifi_manager)) + self._nav_button = self._child(NavButton(tr("Advanced"))) self._nav_button.set_click_callback(self._cycle_panel) def show_event(self): + super().show_event() self._set_current_panel(PanelType.WIFI) - self._wifi_panel.show_event() - - def hide_event(self): - self._wifi_panel.hide_event() def _cycle_panel(self): if self._current_panel == PanelType.WIFI: @@ -187,8 +184,8 @@ class AdvancedNetworkSettings(Widget): self._wifi_manager.update_gsm_settings(roaming_state, self._params.get("GsmApn") or "", self._params.get_bool("GsmMetered")) def _edit_apn(self): - def update_apn(result): - if result != 1: + def update_apn(result: DialogResult): + if result != DialogResult.CONFIRM: return apn = self._keyboard.text.strip() @@ -203,7 +200,8 @@ class AdvancedNetworkSettings(Widget): self._keyboard.reset(min_text_size=0) self._keyboard.set_title(tr("Enter APN"), tr("leave blank for automatic configuration")) self._keyboard.set_text(current_apn) - gui_app.set_modal_overlay(self._keyboard, update_apn) + self._keyboard.set_callback(update_apn) + gui_app.push_widget(self._keyboard) def _toggle_cellular_metered(self): metered = self._cellular_metered_action.get_state() @@ -216,15 +214,18 @@ class AdvancedNetworkSettings(Widget): self._wifi_manager.set_current_network_metered(metered_type) def _connect_to_hidden_network(self): - def connect_hidden(result): - if result != 1: + def connect_hidden(result: DialogResult): + if result != DialogResult.CONFIRM: return ssid = self._keyboard.text if not ssid: return - def enter_password(result): + def enter_password(result: DialogResult): + if result != DialogResult.CONFIRM: + return + password = self._keyboard.text if password == "": # connect without password @@ -235,15 +236,17 @@ class AdvancedNetworkSettings(Widget): self._keyboard.reset(min_text_size=0) self._keyboard.set_title(tr("Enter password"), tr("for \"{}\"").format(ssid)) - gui_app.set_modal_overlay(self._keyboard, enter_password) + self._keyboard.set_callback(enter_password) + gui_app.push_widget(self._keyboard) self._keyboard.reset(min_text_size=1) self._keyboard.set_title(tr("Enter SSID"), "") - gui_app.set_modal_overlay(self._keyboard, connect_hidden) + self._keyboard.set_callback(connect_hidden) + gui_app.push_widget(self._keyboard) def _edit_tethering_password(self): - def update_password(result): - if result != 1: + def update_password(result: DialogResult): + if result != DialogResult.CONFIRM: return password = self._keyboard.text @@ -253,7 +256,8 @@ class AdvancedNetworkSettings(Widget): self._keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH) self._keyboard.set_title(tr("Enter new tethering password"), "") self._keyboard.set_text(self._wifi_manager.tethering_password) - gui_app.set_modal_overlay(self._keyboard, update_password) + self._keyboard.set_callback(update_password) + gui_app.push_widget(self._keyboard) def _update_state(self): self._wifi_manager.process_callbacks() @@ -292,10 +296,12 @@ class WifiManagerUI(Widget): disconnected=self._on_disconnected) def show_event(self): + super().show_event() # start/stop scanning when widget is visible self._wifi_manager.set_active(True) def hide_event(self): + super().hide_event() self._wifi_manager.set_active(False) def _load_icons(self): @@ -311,31 +317,32 @@ class WifiManagerUI(Widget): return if self.state == UIState.NEEDS_AUTH and self._state_network: - self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"), tr("for \"{}\"").format(self._state_network.ssid)) + self.keyboard.set_title(tr("Wrong password") if self._password_retry else tr("Enter password"), + tr("for \"{}\"").format(normalize_ssid(self._state_network.ssid))) self.keyboard.reset(min_text_size=MIN_PASSWORD_LENGTH) - gui_app.set_modal_overlay(self.keyboard, lambda result: self._on_password_entered(cast(Network, self._state_network), result)) + self.keyboard.set_callback(lambda result: self._on_password_entered(cast(Network, self._state_network), result)) + gui_app.push_widget(self.keyboard) elif self.state == UIState.SHOW_FORGET_CONFIRM and self._state_network: - confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel")) - confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(self._state_network.ssid)) - confirm_dialog.reset() - gui_app.set_modal_overlay(confirm_dialog, callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result)) + confirm_dialog = ConfirmDialog("", tr("Forget"), tr("Cancel"), callback=lambda result: self.on_forgot_confirm_finished(self._state_network, result)) + confirm_dialog.set_text(tr("Forget Wi-Fi Network \"{}\"?").format(normalize_ssid(self._state_network.ssid))) + gui_app.push_widget(confirm_dialog) else: self._draw_network_list(rect) - def _on_password_entered(self, network: Network, result: int): - if result == 1: + def _on_password_entered(self, network: Network, result: DialogResult): + if result == DialogResult.CONFIRM: password = self.keyboard.text self.keyboard.clear() if len(password) >= MIN_PASSWORD_LENGTH: self.connect_to_network(network, password) - elif result == 0: + elif result == DialogResult.CANCEL: self.state = UIState.IDLE - def on_forgot_confirm_finished(self, network, result: int): - if result == 1: + def on_forgot_confirm_finished(self, network, result: DialogResult): + if result == DialogResult.CONFIRM: self.forget_network(network) - elif result == 0: + elif result == DialogResult.CANCEL: self.state = UIState.IDLE def _draw_network_list(self, rect: rl.Rectangle): @@ -383,7 +390,7 @@ class WifiManagerUI(Widget): gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER) else: # If the network is saved, show the "Forget" button - if network.is_saved: + if self._wifi_manager.is_connection_saved(network.ssid): forget_btn_rect = rl.Rectangle( security_icon_rect.x - self.btn_width - spacing, rect.y + (ITEM_HEIGHT - 80) / 2, @@ -396,11 +403,11 @@ class WifiManagerUI(Widget): self._draw_signal_strength_icon(signal_icon_rect, network) def _networks_buttons_callback(self, network): - if not network.is_saved and network.security_type != SecurityType.OPEN: + if not self._wifi_manager.is_connection_saved(network.ssid) and network.security_type != SecurityType.OPEN: self.state = UIState.NEEDS_AUTH self._state_network = network self._password_retry = False - elif not network.is_connected: + elif self._wifi_manager.wifi_state.ssid != network.ssid: self.connect_to_network(network) def _forget_networks_buttons_callback(self, network): @@ -410,7 +417,7 @@ class WifiManagerUI(Widget): def _draw_status_icon(self, rect, network: Network): """Draw the status icon based on network's connection state""" icon_file = None - if network.is_connected and self.state != UIState.CONNECTING: + if self._wifi_manager.connected_ssid == network.ssid and self.state != UIState.CONNECTING: icon_file = "icons/checkmark.png" elif network.security_type == SecurityType.UNSUPPORTED: icon_file = "icons/circled_slash.png" @@ -432,7 +439,7 @@ class WifiManagerUI(Widget): def connect_to_network(self, network: Network, password=''): self.state = UIState.CONNECTING self._state_network = network - if network.is_saved and not password: + if self._wifi_manager.is_connection_saved(network.ssid) and not password: self._wifi_manager.activate_connection(network.ssid) else: self._wifi_manager.connect_to_network(network.ssid, password) @@ -445,7 +452,7 @@ class WifiManagerUI(Widget): def _on_network_updated(self, networks: list[Network]): self._networks = networks for n in self._networks: - self._networks_buttons[n.ssid] = Button(n.ssid, partial(self._networks_buttons_callback, n), font_size=55, + self._networks_buttons[n.ssid] = Button(normalize_ssid(n.ssid), partial(self._networks_buttons_callback, n), font_size=55, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT, button_style=ButtonStyle.TRANSPARENT_WHITE_TEXT) self._networks_buttons[n.ssid].set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid()) self._forget_networks_buttons[n.ssid] = Button(tr("Forget"), partial(self._forget_networks_buttons_callback, n), button_style=ButtonStyle.FORGET_WIFI, @@ -463,7 +470,7 @@ class WifiManagerUI(Widget): if self.state == UIState.CONNECTING: self.state = UIState.IDLE - def _on_forgotten(self): + def _on_forgotten(self, _): if self.state == UIState.FORGETTING: self.state = UIState.IDLE @@ -474,10 +481,10 @@ class WifiManagerUI(Widget): def main(): gui_app.init_window("Wi-Fi Manager") - wifi_ui = WifiManagerUI(WifiManager()) + gui_app.push_widget(WifiManagerUI(WifiManager())) for _ in gui_app.render(): - wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100)) + pass gui_app.close() diff --git a/system/ui/widgets/option_dialog.py b/system/ui/widgets/option_dialog.py index 62578d1cf..206400a74 100644 --- a/system/ui/widgets/option_dialog.py +++ b/system/ui/widgets/option_dialog.py @@ -1,5 +1,6 @@ import pyray as rl -from openpilot.system.ui.lib.application import FontWeight +from collections.abc import Callable +from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.lib.multilang import tr from openpilot.system.ui.widgets import Widget, DialogResult from openpilot.system.ui.widgets.button import Button, ButtonStyle @@ -17,13 +18,13 @@ LIST_ITEM_SPACING = 25 class MultiOptionDialog(Widget): - def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM): + def __init__(self, title, options, current="", option_font_weight=FontWeight.MEDIUM, callback: Callable[[DialogResult], None] | None = None): super().__init__() self.title = title self.options = options self.current = current self.selection = current - self._result: DialogResult = DialogResult.NO_ACTION + self._callback = callback # Create scroller with option buttons self.option_buttons = [Button(option, click_callback=lambda opt=option: self._on_option_clicked(opt), @@ -36,7 +37,9 @@ class MultiOptionDialog(Widget): self.select_button = Button(lambda: tr("Select"), click_callback=lambda: self._set_result(DialogResult.CONFIRM), button_style=ButtonStyle.PRIMARY) def _set_result(self, result: DialogResult): - self._result = result + gui_app.pop_widget() + if self._callback: + self._callback(result) def _on_option_clicked(self, option): self.selection = option @@ -74,5 +77,3 @@ class MultiOptionDialog(Widget): select_rect = rl.Rectangle(content_rect.x + button_w + BUTTON_SPACING, button_y, button_w, BUTTON_HEIGHT) self.select_button.set_enabled(self.selection != self.current) self.select_button.render(select_rect) - - return self._result diff --git a/system/ui/widgets/scroller.py b/system/ui/widgets/scroller.py index e941486a4..a3a0d2b38 100644 --- a/system/ui/widgets/scroller.py +++ b/system/ui/widgets/scroller.py @@ -3,38 +3,31 @@ import numpy as np from collections.abc import Callable from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter +from openpilot.common.swaglog import cloudlog from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState from openpilot.system.ui.widgets import Widget +from openpilot.system.ui.widgets.nav_widget import NavWidget ITEM_SPACING = 20 LINE_COLOR = rl.GRAY LINE_PADDING = 40 ANIMATION_SCALE = 0.6 + +MOVE_LIFT = 20 +MOVE_OVERLAY_ALPHA = 0.65 +SCROLL_RC = 0.15 + EDGE_SHADOW_WIDTH = 20 MIN_ZOOM_ANIMATION_TIME = 0.075 # seconds DO_ZOOM = False DO_JELLO = False -SCROLL_BAR = False - - -class LineSeparator(Widget): - def __init__(self, height: int = 1): - super().__init__() - self._rect = rl.Rectangle(0, 0, 0, height) - - def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: - super().set_parent_rect(parent_rect) - self._rect.width = parent_rect.width - - def _render(self, _): - rl.draw_line(int(self._rect.x) + LINE_PADDING, int(self._rect.y), - int(self._rect.x + self._rect.width) - LINE_PADDING, int(self._rect.y), - LINE_COLOR) class ScrollIndicator(Widget): + HORIZONTAL_MARGIN = 4 + def __init__(self): super().__init__() self._txt_scroll_indicator = gui_app.texture("icons_mici/settings/horizontal_scroll_indicator.png", 96, 48) @@ -48,23 +41,23 @@ class ScrollIndicator(Widget): self._viewport = viewport def _render(self, _): - if self._viewport.width <= 0 or self._viewport.height <= 0: - return + # scale indicator width based on content size + indicator_w = float(np.interp(self._content_size, [1000, 3000], [300, 100])) - indicator_w = min(float(np.interp(self._content_size, [1000, 3000], [300, 100])), self._viewport.width) + # position based on scroll ratio + slide_range = self._viewport.width - indicator_w max_scroll = self._content_size - self._viewport.width - if max_scroll > 0: - scroll_ratio = -self._scroll_offset / max_scroll - slide_range = max(self._viewport.width - indicator_w, 0.0) - x = self._viewport.x + scroll_ratio * slide_range - else: - x = self._viewport.x + (self._viewport.width - indicator_w) / 2 + scroll_ratio = (-self._scroll_offset / abs(max_scroll)) if abs(max_scroll) > 1e-3 else 0.0 + x = self._viewport.x + scroll_ratio * slide_range + # don't bounce up when NavWidget shows y = max(self._viewport.y, 0) + self._viewport.height - self._txt_scroll_indicator.height / 2 + # squeeze when overscrolling past edges dest_left = max(x, self._viewport.x) dest_right = min(x + indicator_w, self._viewport.x + self._viewport.width) dest_w = max(indicator_w / 2, dest_right - dest_left) + # keep within viewport after applying minimum width dest_left = min(dest_left, self._viewport.x + self._viewport.width - dest_w) dest_left = max(dest_left, self._viewport.x) @@ -74,23 +67,21 @@ class ScrollIndicator(Widget): rl.Color(255, 255, 255, int(255 * 0.45))) -class Scroller(Widget): - def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = True, spacing: int = ITEM_SPACING, - line_separator: bool = False, pad_start: int = ITEM_SPACING, pad_end: int = ITEM_SPACING, - scroll_indicator: bool = False, edge_shadows: bool = False): +class _Scroller(Widget): + """Should use wrapper below to reduce boilerplate""" + def __init__(self, items: list[Widget], horizontal: bool = True, snap_items: bool = False, spacing: int = ITEM_SPACING, + pad: int = ITEM_SPACING, scroll_indicator: bool = True, edge_shadows: bool = True): super().__init__() self._items: list[Widget] = [] self._horizontal = horizontal self._snap_items = snap_items self._spacing = spacing - self._line_separator = LineSeparator() if line_separator else None - self._pad_start = pad_start - self._pad_end = pad_end + self._pad = pad self._reset_scroll_at_show = True - self._scrolling_to: float | None = None - self._scroll_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps) + self._scrolling_to: tuple[float | None, bool] = (None, False) # target offset, block_interaction + self._scrolling_to_filter = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps) self._zoom_filter = FirstOrderFilter(1.0, 0.2, 1 / gui_app.target_fps) self._zoom_out_t: float = 0.0 @@ -107,22 +98,27 @@ class Scroller(Widget): self.scroll_panel = GuiScrollPanel2(self._horizontal, handle_out_of_bounds=not self._snap_items) self._scroll_enabled: bool | Callable[[], bool] = True - self._txt_vertical_scroll_indicator = gui_app.texture("icons_mici/settings/vertical_scroll_indicator.png", 40, 80) self._show_scroll_indicator = scroll_indicator and self._horizontal self._scroll_indicator = ScrollIndicator() self._edge_shadows = edge_shadows and self._horizontal - for item in items: - self.add_widget(item) + # move animation state + # on move; lift src widget -> wait -> move all -> wait -> drop src widget + self._overlay_filter = FirstOrderFilter(0.0, 0.05, 1 / gui_app.target_fps) + self._move_animations: dict[Widget, FirstOrderFilter] = {} + self._move_lift: dict[Widget, FirstOrderFilter] = {} + # these are used to wait before moving/dropping, also to move onto next part of the animation earlier for timing + self._pending_lift: set[Widget] = set() + self._pending_move: set[Widget] = set() - @property - def items(self) -> list[Widget]: - return self._items + self.add_widgets(items) def set_reset_scroll_at_show(self, scroll: bool): self._reset_scroll_at_show = scroll - def scroll_to(self, pos: float, smooth: bool = False): + def scroll_to(self, pos: float, smooth: bool = False, block_interaction: bool = False): + assert not block_interaction or smooth, "Instant scroll cannot block user interaction" + # already there if abs(pos) < 1: return @@ -130,25 +126,35 @@ class Scroller(Widget): # FIXME: the padding correction doesn't seem correct scroll_offset = self.scroll_panel.get_offset() - pos if smooth: - self._scrolling_to = scroll_offset + self._scrolling_to_filter.x = self.scroll_panel.get_offset() + self._scrolling_to = scroll_offset, block_interaction else: self.scroll_panel.set_offset(scroll_offset) @property def is_auto_scrolling(self) -> bool: - return self._scrolling_to is not None + return self._scrolling_to[0] is not None + + @property + def items(self) -> list[Widget]: + return self._items + + @property + def content_size(self) -> float: + return self._content_size def add_widget(self, item: Widget) -> None: self._items.append(item) - item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled) - def move_item(self, from_index: int, to_index: int) -> None: - if from_index == to_index: - return - if not (0 <= from_index < len(self._items) and 0 <= to_index < len(self._items)): - return - item = self._items.pop(from_index) - self._items.insert(to_index, item) + # preserve original touch valid callback + original_touch_valid_callback = item._touch_valid_callback + item.set_touch_valid_callback(lambda: self.scroll_panel.is_touch_valid() and self.enabled and self._scrolling_to[0] is None + and not self.moving_items and (original_touch_valid_callback() if + original_touch_valid_callback else True)) + + def add_widgets(self, items: list[Widget]) -> None: + for item in items: + self.add_widget(item) def set_scrolling_enabled(self, enabled: bool | Callable[[], bool]) -> None: """Set whether scrolling is enabled (does not affect widget enabled state).""" @@ -156,7 +162,7 @@ class Scroller(Widget): def _update_state(self): if DO_ZOOM: - if self._scrolling_to is not None or self.scroll_panel.state != ScrollState.STEADY: + if self._scrolling_to[0] is not None or self.scroll_panel.state != ScrollState.STEADY: self._zoom_out_t = rl.get_time() + MIN_ZOOM_ANIMATION_TIME self._zoom_filter.update(0.85) else: @@ -166,27 +172,25 @@ class Scroller(Widget): else: self._zoom_filter.update(0.85) - # Cancel auto-scroll if user starts manually scrolling - if self._scrolling_to is not None and (self.scroll_panel.state == ScrollState.PRESSED or self.scroll_panel.state == ScrollState.MANUAL_SCROLL): - self._scrolling_to = None + # Cancel auto-scroll if user starts manually scrolling (unless block_interaction) + if (self.scroll_panel.state in (ScrollState.PRESSED, ScrollState.MANUAL_SCROLL) and + self._scrolling_to[0] is not None and not self._scrolling_to[1]): + self._scrolling_to = None, False - if self._scrolling_to is not None: - self._scroll_filter.update(self._scrolling_to) - self.scroll_panel.set_offset(self._scroll_filter.x) + if self._scrolling_to[0] is not None and len(self._pending_lift) == 0: + self._scrolling_to_filter.update(self._scrolling_to[0]) + self.scroll_panel.set_offset(self._scrolling_to_filter.x) - if abs(self._scroll_filter.x - self._scrolling_to) < 1: - self.scroll_panel.set_offset(self._scrolling_to) - self._scrolling_to = None - else: - # keep current scroll position up to date - self._scroll_filter.x = self.scroll_panel.get_offset() + if abs(self._scrolling_to_filter.x - self._scrolling_to[0]) < 1: + self.scroll_panel.set_offset(self._scrolling_to[0]) + self._scrolling_to = None, False def _get_scroll(self, visible_items: list[Widget], content_size: float) -> float: scroll_enabled = self._scroll_enabled() if callable(self._scroll_enabled) else self._scroll_enabled - self.scroll_panel.set_enabled(scroll_enabled and self.enabled) + self.scroll_panel.set_enabled(scroll_enabled and self.enabled and not self._scrolling_to[1]) self.scroll_panel.update(self._rect, content_size) if not self._snap_items: - return round(self.scroll_panel.get_offset()) + return self.scroll_panel.get_offset() # Snap closest item to center center_pos = self._rect.x + self._rect.width / 2 if self._horizontal else self._rect.y + self._rect.height / 2 @@ -222,29 +226,86 @@ class Scroller(Widget): return self.scroll_panel.get_offset() + @property + def moving_items(self) -> bool: + return len(self._move_animations) > 0 or len(self._move_lift) > 0 + + def move_item(self, from_idx: int, to_idx: int): + assert self._horizontal + if from_idx == to_idx: + return + + if self.moving_items: + cloudlog.warning(f"Already moving items, cannot move from {from_idx} to {to_idx}") + return + + item = self._items.pop(from_idx) + self._items.insert(to_idx, item) + + # store original position in content space of all affected widgets to animate from + for idx in range(min(from_idx, to_idx), max(from_idx, to_idx) + 1): + affected_item = self._items[idx] + self._move_animations[affected_item] = FirstOrderFilter(affected_item.rect.x - self._scroll_offset, SCROLL_RC, 1 / gui_app.target_fps) + self._pending_move.add(affected_item) + + # lift only src widget to make it more clear which one is moving + self._move_lift[item] = FirstOrderFilter(0.0, SCROLL_RC, 1 / gui_app.target_fps) + self._pending_lift.add(item) + + def _do_move_animation(self, item: Widget, target_x: float, target_y: float) -> tuple[float, float]: + # wait a frame before moving so we match potential pending scroll animation + can_start_move = len(self._pending_lift) == 0 + + if item in self._move_lift: + lift_filter = self._move_lift[item] + + # Animate lift + if len(self._pending_move) > 0: + lift_filter.update(MOVE_LIFT) + # start moving when almost lifted + if abs(lift_filter.x - MOVE_LIFT) < 2: + self._pending_lift.discard(item) + else: + # if done moving, animate down + lift_filter.update(0) + if abs(lift_filter.x) < 1: + del self._move_lift[item] + target_y -= lift_filter.x + + # Animate move + if item in self._move_animations: + move_filter = self._move_animations[item] + + # compare/update in content space to match filter + content_x = target_x - self._scroll_offset + if can_start_move: + move_filter.update(content_x) + + # drop when close to target + if abs(move_filter.x - content_x) < 10: + self._pending_move.discard(item) + + # finished moving + if abs(move_filter.x - content_x) < 1: + del self._move_animations[item] + target_x = move_filter.x + self._scroll_offset + + return target_x, target_y + def _layout(self): self._visible_items = [item for item in self._items if item.is_visible] - # Add line separator between items - if self._line_separator is not None: - l = len(self._visible_items) - for i in range(1, len(self._visible_items)): - self._visible_items.insert(l - i, self._line_separator) - self._content_size = sum(item.rect.width if self._horizontal else item.rect.height for item in self._visible_items) self._content_size += self._spacing * (len(self._visible_items) - 1) - self._content_size += self._pad_start + self._pad_end + self._content_size += self._pad * 2 self._scroll_offset = self._get_scroll(self._visible_items, self._content_size) - rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), - int(self._rect.width), int(self._rect.height)) - self._item_pos_filter.update(self._scroll_offset) cur_pos = 0 for idx, item in enumerate(self._visible_items): - spacing = self._spacing if (idx > 0) else self._pad_start + spacing = self._spacing if (idx > 0) else self._pad # Nicely lay out items horizontally/vertically if self._horizontal: x = self._rect.x + cur_pos + spacing @@ -276,60 +337,125 @@ class Scroller(Widget): [self._item_pos_filter.x, self._scroll_offset, self._item_pos_filter.x]) y -= np.clip(jello_offset, -20, 20) + # Animate moves if needed + x, y = self._do_move_animation(item, x, y) + # Update item state - item.set_position(round(x), round(y)) # round to prevent jumping when settling + item.set_position(x, y) item.set_parent_rect(self._rect) + def _render_item(self, item: Widget): + # Skip rendering if not in viewport + if not rl.check_collision_recs(item.rect, self._rect): + return + + # Scale each element around its own origin when scrolling + scale = self._zoom_filter.x + if scale != 1.0: + rl.rl_push_matrix() + rl.rl_scalef(scale, scale, 1.0) + rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, + (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) + item.render() + rl.rl_pop_matrix() + else: + item.render() + def _render(self, _): - for item in self._visible_items: - # Skip rendering if not in viewport - if not rl.check_collision_recs(item.rect, self._rect): + rl.begin_scissor_mode(int(self._rect.x), int(self._rect.y), + int(self._rect.width), int(self._rect.height)) + + for item in reversed(self._visible_items): + if item in self._move_lift: continue + self._render_item(item) - # Scale each element around its own origin when scrolling - scale = self._zoom_filter.x - if scale != 1.0: - rl.rl_push_matrix() - rl.rl_scalef(scale, scale, 1.0) - rl.rl_translatef((1 - scale) * (item.rect.x + item.rect.width / 2) / scale, - (1 - scale) * (item.rect.y + item.rect.height / 2) / scale, 0) - item.render() - rl.rl_pop_matrix() - else: - item.render() + # Dim background if moving items, lifted items are above + self._overlay_filter.update(MOVE_OVERLAY_ALPHA if len(self._pending_move) else 0.0) + if self._overlay_filter.x > 0.01: + rl.draw_rectangle_rec(self._rect, rl.Color(0, 0, 0, int(255 * self._overlay_filter.x))) - # Draw scroll indicator - if SCROLL_BAR and not self._horizontal and len(self._visible_items) > 0: - _real_content_size = self._content_size - self._rect.height + self._txt_vertical_scroll_indicator.height - scroll_bar_y = -self._scroll_offset / _real_content_size * self._rect.height - scroll_bar_y = min(max(scroll_bar_y, self._rect.y), self._rect.y + self._rect.height - self._txt_vertical_scroll_indicator.height) - rl.draw_texture_ex(self._txt_vertical_scroll_indicator, rl.Vector2(self._rect.x, scroll_bar_y), 0, 1.0, rl.WHITE) + for item in self._move_lift: + self._render_item(item) rl.end_scissor_mode() + # Draw edge shadows on top of scroller content if self._edge_shadows: rl.draw_rectangle_gradient_h(int(self._rect.x), int(self._rect.y), EDGE_SHADOW_WIDTH, int(self._rect.height), - rl.Color(0, 0, 0, 166), rl.BLANK) + rl.Color(0, 0, 0, 204), rl.BLANK) right_x = int(self._rect.x + self._rect.width - EDGE_SHADOW_WIDTH) rl.draw_rectangle_gradient_h(right_x, int(self._rect.y), EDGE_SHADOW_WIDTH, int(self._rect.height), - rl.BLANK, rl.Color(0, 0, 0, 166)) + rl.BLANK, rl.Color(0, 0, 0, 204)) + # Draw scroll indicator on top of edge shadows if self._show_scroll_indicator and len(self._visible_items) > 0: self._scroll_indicator.update(self._scroll_offset, self._content_size, self._rect) self._scroll_indicator.render() def show_event(self): super().show_event() + for item in self._items: + item.show_event() + if self._reset_scroll_at_show: self.scroll_panel.set_offset(0.0) - for item in self._items: - item.show_event() + self._overlay_filter.x = 0.0 + self._move_animations.clear() + self._move_lift.clear() + self._pending_lift.clear() + self._pending_move.clear() + self._scrolling_to = None, False + self._scrolling_to_filter.x = 0.0 def hide_event(self): super().hide_event() for item in self._items: item.hide_event() + + +class Scroller(Widget): + """Wrapper for _Scroller so that children do not need to call events or pass down enabled for nav stack.""" + def __init__(self, **kwargs): + super().__init__() + self._scroller = self._child(_Scroller([], **kwargs)) + # pass down enabled to child widget for nav stack + self._scroller.set_enabled(lambda: self.enabled) + + def _render(self, _): + self._scroller.render(self._rect) + + +class NavScroller(NavWidget, Scroller): + """Full screen Scroller that properly supports nav stack w/ animations""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + # pass down enabled to child widget for nav stack + disable while swiping away NavWidget + self._scroller.set_enabled(lambda: self.enabled and not self.is_dismissing) + + def _back_enabled(self) -> bool: + # Vertical scrollers need to be at the top to swipe away to prevent erroneous swipes + # TODO: only used for offroad alerts, remove when horizontal + return self._scroller._horizontal or self._scroller.scroll_panel.get_offset() >= -20 # some tolerance + + +# TODO: only used for a few vertical scrollers, remove when horizontal +class NavRawScrollPanel(NavWidget): + # can swipe anywhere, only when at top + BACK_TOUCH_AREA_PERCENTAGE = 1.0 + + def __init__(self): + super().__init__() + self._scroll_panel = GuiScrollPanel2(horizontal=False) + self._scroll_panel.set_enabled(lambda: self.enabled and not self.is_dismissing) + + def show_event(self): + super().show_event() + self._scroll_panel.set_offset(0) + + def _back_enabled(self) -> bool: + return self._scroll_panel.get_offset() >= -20 diff --git a/system/ui/widgets/selection_dialog.py b/system/ui/widgets/selection_dialog.py deleted file mode 100644 index f984e75f6..000000000 --- a/system/ui/widgets/selection_dialog.py +++ /dev/null @@ -1,290 +0,0 @@ -from enum import IntEnum -from collections.abc import Callable -import pyray as rl - -from openpilot.system.ui.lib.application import gui_app, FontWeight -from openpilot.system.ui.lib.multilang import tr -from openpilot.system.ui.widgets import Widget, DialogResult -from openpilot.system.ui.widgets.button import Button, ButtonStyle -from openpilot.system.ui.widgets.label import Label -from openpilot.system.ui.widgets.scroller_tici import Scroller - -SELECTION_COLOR = rl.Color(70, 91, 234, 255) # #465BEA -HEADER_BG = rl.Color(51, 51, 51, 255) # #333333 -BACKGROUND_COLOR = rl.Color(27, 27, 27, 255) # #1B1B1B -BORDER_COLOR = rl.Color(80, 80, 80, 255) -MARGIN = 40 -OUTER_MARGIN_X = 100 -OUTER_MARGIN_Y = 80 -BUTTON_HEIGHT = 90 - -class SortMode(IntEnum): - ALPHABETICAL = 0 - DATE_NEWEST = 1 - DATE_OLDEST = 2 - FAVORITES = 3 - -class SelectionHeader(Widget): - def __init__(self, text: str, is_expanded: bool, callback: Callable[[str], None]): - super().__init__() - self._text = text - self._is_expanded = is_expanded - self._callback = callback - self._font = gui_app.font(FontWeight.BOLD) - self._font_size = 40 - self._pressed = False - self.set_rect(rl.Rectangle(0, 0, 0, 70)) - - def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: - super().set_parent_rect(parent_rect) - self._rect.width = parent_rect.width - - def _render(self, rect: rl.Rectangle): - # Header background - Match Qt .series-header {#333333} - bg_color = rl.Color(64, 64, 64, 255) if self._pressed else HEADER_BG - rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color) - - # Arrow - Match Qt text-based arrows - arrow = "β–Ό" if self._is_expanded else "β–Ά" - arrow_pos = rl.Vector2(rect.x + 30, rect.y + (rect.height - self._font_size) / 2) - rl.draw_text_ex(self._font, arrow, arrow_pos, self._font_size, 0, rl.WHITE) - - # Text - Match Qt padding-left: 80px - text_pos = rl.Vector2(rect.x + 80, rect.y + (rect.height - self._font_size) / 2) - rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE) - - def _handle_mouse_press(self, mouse_pos): - if rl.check_collision_point_rec(mouse_pos, self._hit_rect): - self._pressed = True - - def _handle_mouse_release(self, mouse_pos): - if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect): - if self._callback: - self._callback(self._text) - self._pressed = False - -class SelectionItem(Widget): - def __init__(self, text: str, is_selected: bool, callback: Callable[[str], None]): - super().__init__() - self._text = text - self._is_selected = is_selected - self._callback = callback - self._font = gui_app.font(FontWeight.MEDIUM) - self._font_size = 48 - self._pressed = False - self.set_rect(rl.Rectangle(0, 0, 0, 110)) - - def set_parent_rect(self, parent_rect: rl.Rectangle) -> None: - super().set_parent_rect(parent_rect) - self._rect.width = parent_rect.width - - def _render(self, rect: rl.Rectangle): - # Background for item - Match Qt .model-option:checked {#465BEA} - if self._is_selected: - bg_color = rl.Color(70, 91, 234, 255) # #465BEA - else: - bg_color = rl.Color(90, 90, 90, 255) if self._pressed else rl.Color(79, 79, 79, 255) # #4F4F4F - - rl.draw_rectangle_rounded(rect, 0.1, 10, bg_color) - - # Selection Border - Match Qt {3px WHITE} - if self._is_selected: - rl.draw_rectangle_rounded_lines_ex(rect, 0.1, 10, 3, rl.WHITE) - - # Text - text_size = rl.measure_text_ex(self._font, self._text, self._font_size, 0) - text_pos = rl.Vector2(rect.x + 40, rect.y + (rect.height - text_size.y) / 2) - rl.draw_text_ex(self._font, self._text, text_pos, self._font_size, 0, rl.WHITE) - - # Indicator (Dot for selection instead of radio) - if self._is_selected: - circle_center = rl.Vector2(rect.x + rect.width - 50, rect.y + rect.height / 2) - rl.draw_circle_v(circle_center, 12, rl.WHITE) - - def _handle_mouse_press(self, mouse_pos): - if rl.check_collision_point_rec(mouse_pos, self._hit_rect): - self._pressed = True - - def _handle_mouse_release(self, mouse_pos): - if self._pressed and rl.check_collision_point_rec(mouse_pos, self._hit_rect): - if self._callback: - self._callback(self._text) - self._pressed = False - -class SelectionDialog(Widget): - def __init__(self, title: str, options, current_selection: str = "", - on_close: Callable[[DialogResult, str], None] | None = None, - model_released_dates: dict[str, str] | None = None, - model_file_to_name: dict[str, str] | None = None, - user_favorites: list[str] | None = None, - community_favorites: list[str] | None = None, - on_favorite_toggled: Callable[[str], None] | None = None, - favorites_editable: bool = True): - super().__init__() - self._title = title - self._options_raw = options - self._selected_value = current_selection - self._on_close = on_close - self._model_released_dates = model_released_dates or {} - self._name_to_file = {v: k for k, v in (model_file_to_name or {}).items()} - self._user_favorites = user_favorites or [] - self._community_favorites = community_favorites or [] - self._on_favorite_toggled = on_favorite_toggled - self._favorites_editable = favorites_editable - - self._sort_mode = SortMode.ALPHABETICAL - self._expanded_series = {s: True for s in (options.keys() if isinstance(options, dict) else [])} - - self._title_label = Label(title, 60, FontWeight.BOLD, text_color=rl.WHITE) - self._sort_button = Button("Alphabetical", self._toggle_sort, button_style=ButtonStyle.NORMAL) - self._cancel_button = Button("Cancel", self._cancel_button_callback) - self._confirm_button = Button("Select", self._confirm_button_callback, button_style=ButtonStyle.PRIMARY) - - self._scroller = None - self._build_scroller() - - def _toggle_sort(self): - self._sort_mode = SortMode((int(self._sort_mode) + 1) % 4) - modes = ["Alphabetical", "Date (Newest)", "Date (Oldest)", "Favorites First"] - self._sort_button.set_text(modes[int(self._sort_mode)]) - self._build_scroller() - - def _toggle_series(self, series: str): - self._expanded_series[series] = not self._expanded_series.get(series, True) - self._build_scroller() - - def _build_scroller(self): - items = [] - - if isinstance(self._options_raw, dict): - series_keys = list(self._options_raw.keys()) - priority_series = ["StarPilot", "Comma", "Experimental"] - sorted_series_keys = [] - for p in priority_series: - if p in series_keys: - sorted_series_keys.append(p) - series_keys.remove(p) - sorted_series_keys.extend(sorted(series_keys)) - - for series in sorted_series_keys: - models = self._options_raw[series] - if not models: - continue - - items.append(SelectionHeader(series, self._expanded_series.get(series, True), self._toggle_series)) - - if self._expanded_series.get(series, True): - sorted_models = list(models) - if self._sort_mode == SortMode.ALPHABETICAL: - sorted_models.sort() - elif self._sort_mode == SortMode.DATE_NEWEST: - def get_date(m): - key = self._name_to_file.get(m, m) - return self._model_released_dates.get(key, "0000-00-00") - sorted_models.sort(key=get_date, reverse=True) - elif self._sort_mode == SortMode.DATE_OLDEST: - def get_date(m): - key = self._name_to_file.get(m, m) - return self._model_released_dates.get(key, "9999-99-99") - sorted_models.sort(key=get_date) - elif self._sort_mode == SortMode.FAVORITES: - def is_fav(m): - key = self._name_to_file.get(m, m) - return key in self._user_favorites or key in self._community_favorites - sorted_models.sort(key=is_fav, reverse=True) - - for model in sorted_models: - key = self._name_to_file.get(model, model) - is_selected = (model == self._selected_value or key == self._selected_value) - items.append(SelectionItem( - text=model, - is_selected=is_selected, - callback=self._on_item_selected - )) - else: - for option in self._options_raw: - items.append(SelectionItem( - text=option, - is_selected=(option == self._selected_value), - callback=self._on_item_selected - )) - - self._scroller = Scroller(items, line_separator=False, spacing=10) - self._scroller.show_event() - - def _toggle_favorite(self, model_name: str): - if not self._favorites_editable: - return - - key = self._name_to_file.get(model_name, model_name) - if self._on_favorite_toggled: - self._on_favorite_toggled(key) - # Update local state for instant feedback - if key in self._user_favorites: - self._user_favorites.remove(key) - else: - self._user_favorites.append(key) - self._build_scroller() - - def _on_item_selected(self, val): - self._selected_value = val - # Instant visual update - if self._scroller: - for item in self._scroller._items: - if isinstance(item, SelectionItem): - item._is_selected = (item._text == val) - - def _cancel_button_callback(self): - gui_app.set_modal_overlay(None) - if self._on_close: - self._on_close(DialogResult.CANCEL, "") - - def _confirm_button_callback(self): - gui_app.set_modal_overlay(None) - if self._on_close: - self._on_close(DialogResult.CONFIRM, self._selected_value) - - def show_event(self): - super().show_event() - if self._scroller: - self._scroller.show_event() - - def _render(self, rect: rl.Rectangle): - # Dim background - rl.draw_rectangle(0, 0, int(rl.get_screen_width()), int(rl.get_screen_height()), rl.Color(0, 0, 0, 180)) - - # Dialog Box - dialog_rect = rl.Rectangle( - rect.x + OUTER_MARGIN_X, - rect.y + OUTER_MARGIN_Y, - rect.width - 2 * OUTER_MARGIN_X, - rect.height - 2 * OUTER_MARGIN_Y, - ) - rl.draw_rectangle_rounded(dialog_rect, 0.04, 12, BACKGROUND_COLOR) - rl.draw_rectangle_rounded_lines_ex(dialog_rect, 0.04, 12, 2, BORDER_COLOR) - - # Title - title_width = dialog_rect.width - 2 * MARGIN - 260 - self._title_label.render(rl.Rectangle(dialog_rect.x + MARGIN, dialog_rect.y + MARGIN, title_width, 80)) - - # Sort Button - self._sort_button.render(rl.Rectangle(dialog_rect.x + dialog_rect.width - MARGIN - 240, dialog_rect.y + MARGIN, 240, 80)) - - # Bottom Buttons - btn_y = dialog_rect.y + dialog_rect.height - BUTTON_HEIGHT - MARGIN - btn_width = (dialog_rect.width - 3 * MARGIN) / 2 - - self._cancel_button.render(rl.Rectangle(dialog_rect.x + MARGIN, btn_y, btn_width, BUTTON_HEIGHT)) - self._confirm_button.render(rl.Rectangle(dialog_rect.x + 2 * MARGIN + btn_width, btn_y, btn_width, BUTTON_HEIGHT)) - - # Scrollable Options List - scroller_y = dialog_rect.y + MARGIN + 80 + 20 - scroller_rect = rl.Rectangle( - dialog_rect.x + MARGIN, - scroller_y, - dialog_rect.width - 2 * MARGIN, - btn_y - scroller_y - 20 - ) - self._scroller.render(scroller_rect) - - return DialogResult.NO_ACTION diff --git a/system/ui/widgets/slider.py b/system/ui/widgets/slider.py index e606c5c37..bf965954f 100644 --- a/system/ui/widgets/slider.py +++ b/system/ui/widgets/slider.py @@ -1,3 +1,4 @@ +import abc from collections.abc import Callable import pyray as rl @@ -5,22 +6,24 @@ import pyray as rl from openpilot.system.ui.lib.application import gui_app, FontWeight from openpilot.system.ui.widgets import Widget from openpilot.system.ui.widgets.label import UnifiedLabel -from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter +from openpilot.common.filter_simple import FirstOrderFilter, BounceFilter -class SmallSlider(Widget): +class SliderBase(Widget, abc.ABC): HORIZONTAL_PADDING = 8 CONFIRM_DELAY = 0.2 PRESSED_SCALE = 1.07 + _bg_txt: rl.Texture + _circle_bg_txt: rl.Texture + _circle_bg_pressed_txt: rl.Texture + _circle_arrow_txt: rl.Texture + def __init__(self, title: str, confirm_callback: Callable | None = None, shimmer_offset: float = 0.0): - # TODO: unify this with BigConfirmationDialogV2 super().__init__() self._confirm_callback = confirm_callback self._shimmer_offset = shimmer_offset - self._font = gui_app.font(FontWeight.DISPLAY) - self._load_assets() self._drag_threshold = -self._rect.width // 2 @@ -37,17 +40,13 @@ class SmallSlider(Widget): self._is_dragging_circle = False - self._label = UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, - alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True) + self._label = self._child(UnifiedLabel(title, font_size=36, font_weight=FontWeight.SEMI_BOLD, text_color=rl.WHITE, + alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, + alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, line_height=0.9, shimmer=True)) + @abc.abstractmethod def _load_assets(self): - self.set_rect(rl.Rectangle(0, 0, 316 + self.HORIZONTAL_PADDING * 2, 100)) - - self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg.png", 316, 100) - self._circle_bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_red_circle.png", 100, 100) - self._circle_bg_pressed_txt = self._circle_bg_txt - self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 37, 32) + ... @property def confirmed(self) -> bool: @@ -57,15 +56,13 @@ class SmallSlider(Widget): super().show_event() self.reset() - def reset(self, reset_shimmer: bool = True): + def reset(self): # reset all slider state self._is_dragging_circle = False + self._circle_press_time = None self._confirmed_time = 0.0 self._confirm_callback_called = False - self._circle_press_time = None - self._circle_scale_filter.x = 1.0 - if reset_shimmer: - self._label.reset_shimmer(self._shimmer_offset) + self._label.reset_shimmer(self._shimmer_offset) def set_opacity(self, opacity: float, smooth: bool = False): if smooth: @@ -114,15 +111,15 @@ class SmallSlider(Widget): activated_pos = int(-self._bg_txt.width + self._circle_bg_txt.width) self._scroll_x_circle = max(min(self._scroll_x_circle, 0), activated_pos) - if self._confirmed_time > 0: + if self.confirmed: # swiped left to confirm self._scroll_x_circle_filter.update(activated_pos) # activate once animation completes, small threshold for small floats if self._scroll_x_circle_filter.x < (activated_pos + 1): if not self._confirm_callback_called and (rl.get_time() - self._confirmed_time) >= self.CONFIRM_DELAY: - self._on_confirm() self._confirm_callback_called = True + self._on_confirm() elif not self._is_dragging_circle: # reset back to right @@ -132,8 +129,6 @@ class SmallSlider(Widget): self._scroll_x_circle_filter.x = self._scroll_x_circle def _render(self, _): - # TODO: iOS text shimmering animation - white = rl.Color(255, 255, 255, int(255 * self._opacity_filter.x)) bg_txt_x = self._rect.x + (self._rect.width - self._bg_txt.width) / 2 @@ -154,21 +149,20 @@ class SmallSlider(Widget): ) self._label.render(label_rect) - circle_pressed = self._is_dragging_circle or self.confirmed or ( - self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075 - ) + # circle and arrow with grow animation + circle_pressed = self._is_dragging_circle or self.confirmed or (self._circle_press_time is not None and rl.get_time() - self._circle_press_time < 0.075) circle_bg_txt = self._circle_bg_pressed_txt if circle_pressed else self._circle_bg_txt scale = self._circle_scale_filter.update(self.PRESSED_SCALE if circle_pressed else 1.0) scaled_btn_x = btn_x + (self._circle_bg_txt.width * (1 - scale)) / 2 scaled_btn_y = btn_y + (self._circle_bg_txt.height * (1 - scale)) / 2 rl.draw_texture_ex(circle_bg_txt, rl.Vector2(scaled_btn_x, scaled_btn_y), 0.0, scale, white) - arrow_x = scaled_btn_x + (self._circle_bg_txt.width * scale - self._circle_arrow_txt.width) / 2 - arrow_y = scaled_btn_y + (self._circle_bg_txt.height * scale - self._circle_arrow_txt.height) / 2 + arrow_x = btn_x + (self._circle_bg_txt.width - self._circle_arrow_txt.width) / 2 + arrow_y = scaled_btn_y + (self._circle_bg_txt.height - self._circle_arrow_txt.height) / 2 rl.draw_texture_ex(self._circle_arrow_txt, rl.Vector2(arrow_x, arrow_y), 0.0, 1.0, white) -class LargerSlider(SmallSlider): +class LargerSlider(SliderBase): def __init__(self, title: str, confirm_callback: Callable | None = None, green: bool = True, shimmer_offset: float = 0.0): self._green = green super().__init__(title, confirm_callback=confirm_callback, shimmer_offset=shimmer_offset) @@ -179,24 +173,24 @@ class LargerSlider(SmallSlider): self._bg_txt = gui_app.texture("icons_mici/setup/small_slider/slider_bg_larger.png", 520, 115) circle_fn = "slider_green_rounded_rectangle" if self._green else "slider_black_rounded_rectangle" self._circle_bg_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}.png", 180, 115) - self._circle_bg_pressed_txt = self._circle_bg_txt + self._circle_bg_pressed_txt = gui_app.texture(f"icons_mici/setup/small_slider/{circle_fn}_pressed.png", 180, 115) self._circle_arrow_txt = gui_app.texture("icons_mici/setup/small_slider/slider_arrow.png", 64, 55) -class BigSlider(SmallSlider): +class BigSlider(SliderBase): def __init__(self, title: str, icon: rl.Texture, confirm_callback: Callable | None = None): self._icon = icon super().__init__(title, confirm_callback=confirm_callback) - self._label = UnifiedLabel(title, font_size=48, font_weight=FontWeight.DISPLAY, text_color=rl.WHITE, - alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE, - line_height=0.875, shimmer=True) + self._label.set_font_size(48) + self._label.set_font_weight(FontWeight.DISPLAY) + self._label.set_line_height(0.875) def _load_assets(self): self.set_rect(rl.Rectangle(0, 0, 520 + self.HORIZONTAL_PADDING * 2, 180)) self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180) self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle.png", 180, 180) - self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_hover.png", 180, 180) + self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_pressed.png", 180, 180) self._circle_arrow_txt = self._icon @@ -206,5 +200,5 @@ class RedBigSlider(BigSlider): self._bg_txt = gui_app.texture("icons_mici/buttons/slider_bg.png", 520, 180) self._circle_bg_txt = gui_app.texture("icons_mici/buttons/button_circle_red.png", 180, 180) - self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_red_hover.png", 180, 180) + self._circle_bg_pressed_txt = gui_app.texture("icons_mici/buttons/button_circle_red_pressed.png", 180, 180) self._circle_arrow_txt = self._icon