From 7745f48100f8b266422957354f799392742825a8 Mon Sep 17 00:00:00 2001 From: "IQ.Lvbs CI [bot]" Date: Wed, 2 Sep 2026 16:46:45 -0500 Subject: [PATCH] IQ.Pilot Release Commit @ 0b96bd5 --- SConstruct | 2 +- .../iqdbc/iqdbc/car/volkswagen/carstate.py | 4 + .../car/volkswagen/tests/test_volkswagen.py | 39 + iqpilot/cereal/messaging/socketmaster.cc | 4 + iqpilot/cereal/services.py | 1 + iqpilot/system/manager/build.py | 16 + .../manager/test/test_services_registry.py | 20 + iqpilot/tools/Brewfile | 1 - iqpilot/tools/cabana/.gitignore | 10 +- iqpilot/tools/cabana/README.md | 118 +-- iqpilot/tools/cabana/SConscript | 194 ++-- iqpilot/tools/cabana/assets/.gitignore | 1 - iqpilot/tools/cabana/assets/assets.qrc | 5 - iqpilot/tools/cabana/assets/cabana-icon.png | Bin 18008 -> 0 bytes iqpilot/tools/cabana/binaryview.cc | 510 --------- iqpilot/tools/cabana/binaryview.h | 104 -- iqpilot/tools/cabana/cabana | 41 +- iqpilot/tools/cabana/cameraview.cc | 125 --- iqpilot/tools/cabana/cameraview.h | 56 - iqpilot/tools/cabana/chart/chart.cc | 770 -------------- iqpilot/tools/cabana/chart/chart.h | 130 --- iqpilot/tools/cabana/chart/chartswidget.cc | 661 ------------ iqpilot/tools/cabana/chart/chartswidget.h | 138 --- iqpilot/tools/cabana/chart/signalselector.cc | 107 -- iqpilot/tools/cabana/chart/signalselector.h | 30 - iqpilot/tools/cabana/chart/sparkline.cc | 100 -- iqpilot/tools/cabana/chart/sparkline.h | 26 - iqpilot/tools/cabana/chart/tiplabel.cc | 58 -- iqpilot/tools/cabana/chart/tiplabel.h | 12 - iqpilot/tools/cabana/commands.cc | 37 +- iqpilot/tools/cabana/commands.h | 32 +- iqpilot/tools/cabana/core/color.h | 1 - iqpilot/tools/cabana/core/observable.h | 83 ++ iqpilot/tools/cabana/core/settings.h | 4 +- iqpilot/tools/cabana/dbc/dbc.cc | 20 +- iqpilot/tools/cabana/dbc/dbc.h | 6 +- iqpilot/tools/cabana/dbc/dbcfile.cc | 2 +- iqpilot/tools/cabana/dbc/dbcmanager.cc | 47 +- iqpilot/tools/cabana/dbc/dbcmanager.h | 23 +- iqpilot/tools/cabana/dbc/dbcqt.cc | 18 - iqpilot/tools/cabana/dbc/dbcqt.h | 27 - iqpilot/tools/cabana/deqt.md | 53 - iqpilot/tools/cabana/detailwidget.cc | 324 ------ iqpilot/tools/cabana/detailwidget.h | 75 -- iqpilot/tools/cabana/historylog.cc | 249 ----- iqpilot/tools/cabana/historylog.h | 81 -- iqpilot/tools/cabana/mainwin.cc | 741 ------------- iqpilot/tools/cabana/mainwin.h | 119 --- iqpilot/tools/cabana/messageswidget.cc | 465 --------- iqpilot/tools/cabana/messageswidget.h | 128 --- iqpilot/tools/cabana/panda.cc | 12 +- iqpilot/tools/cabana/panda.h | 10 +- iqpilot/tools/cabana/routes.cc | 115 +++ iqpilot/tools/cabana/routes.h | 36 + iqpilot/tools/cabana/settings.cc | 393 +------ iqpilot/tools/cabana/settings.h | 37 +- iqpilot/tools/cabana/signalview.cc | 719 ------------- iqpilot/tools/cabana/signalview.h | 153 --- .../tools/cabana/streams/abstractstream.cc | 94 +- iqpilot/tools/cabana/streams/abstractstream.h | 56 +- iqpilot/tools/cabana/streams/devicestream.cc | 79 +- iqpilot/tools/cabana/streams/devicestream.h | 31 +- iqpilot/tools/cabana/streams/livestream.cc | 30 +- iqpilot/tools/cabana/streams/livestream.h | 5 +- iqpilot/tools/cabana/streams/pandastream.cc | 122 +-- iqpilot/tools/cabana/streams/pandastream.h | 25 +- iqpilot/tools/cabana/streams/replaystream.cc | 124 +-- iqpilot/tools/cabana/streams/replaystream.h | 26 +- iqpilot/tools/cabana/streams/routes.cc | 200 ---- iqpilot/tools/cabana/streams/routes.h | 28 - .../tools/cabana/streams/socketcanstream.cc | 65 +- .../tools/cabana/streams/socketcanstream.h | 21 +- iqpilot/tools/cabana/streamselector.cc | 69 -- iqpilot/tools/cabana/streamselector.h | 24 - iqpilot/tools/cabana/test_cabana_konn3kt.py | 78 +- iqpilot/tools/cabana/tests/test_cabana.cc | 162 ++- iqpilot/tools/cabana/tests/test_cabana_ui.py | 11 + iqpilot/tools/cabana/tools/findsignal.cc | 284 ----- iqpilot/tools/cabana/tools/findsignal.h | 66 -- iqpilot/tools/cabana/tools/findsimilarbits.cc | 161 --- iqpilot/tools/cabana/tools/findsimilarbits.h | 36 - iqpilot/tools/cabana/tools/routeinfo.cc | 40 - iqpilot/tools/cabana/tools/routeinfo.h | 8 - iqpilot/tools/cabana/ui/app.cc | 212 ++++ iqpilot/tools/cabana/ui/app.h | 23 + iqpilot/tools/cabana/ui/chart/chart.cc | 790 ++++++++++++++ iqpilot/tools/cabana/ui/chart/chart.h | 142 +++ iqpilot/tools/cabana/ui/chart/chartswidget.cc | 674 ++++++++++++ iqpilot/tools/cabana/ui/chart/chartswidget.h | 168 +++ .../tools/cabana/ui/chart/signalselector.cc | 155 +++ .../tools/cabana/ui/chart/signalselector.h | 46 + iqpilot/tools/cabana/ui/chart/sparkline.cc | 177 ++++ iqpilot/tools/cabana/ui/chart/sparkline.h | 34 + iqpilot/tools/cabana/ui/chart/tiplabel.cc | 69 ++ iqpilot/tools/cabana/ui/chart/tiplabel.h | 35 + iqpilot/tools/cabana/ui/dialogs/filedialog.cc | 222 ++++ iqpilot/tools/cabana/ui/dialogs/filedialog.h | 19 + iqpilot/tools/cabana/ui/dialogs/messagebox.cc | 87 ++ iqpilot/tools/cabana/ui/dialogs/messagebox.h | 19 + .../tools/cabana/ui/dialogs/routesdialog.cc | 124 +++ .../tools/cabana/ui/dialogs/routesdialog.h | 45 + .../tools/cabana/ui/dialogs/settingsdialog.cc | 108 ++ .../tools/cabana/ui/dialogs/settingsdialog.h | 24 + .../tools/cabana/ui/dialogs/streamselector.cc | 323 ++++++ .../tools/cabana/ui/dialogs/streamselector.h | 106 ++ iqpilot/tools/cabana/ui/helpoverlay.cc | 196 ++++ iqpilot/tools/cabana/ui/helpoverlay.h | 24 + iqpilot/tools/cabana/ui/icons.h | 40 + iqpilot/tools/cabana/ui/inistate.cc | 87 ++ iqpilot/tools/cabana/ui/inistate.h | 25 + .../tools/cabana/{cabana.cc => ui/main.cc} | 125 ++- iqpilot/tools/cabana/ui/mainwin.cc | 929 +++++++++++++++++ iqpilot/tools/cabana/ui/mainwin.h | 138 +++ iqpilot/tools/cabana/ui/style.cc | 280 +++++ iqpilot/tools/cabana/ui/threadpool.h | 81 ++ iqpilot/tools/cabana/ui/tools/findsignal.cc | 325 ++++++ iqpilot/tools/cabana/ui/tools/findsignal.h | 59 ++ .../tools/cabana/ui/tools/findsimilarbits.cc | 178 ++++ .../tools/cabana/ui/tools/findsimilarbits.h | 40 + iqpilot/tools/cabana/ui/tools/routeinfo.cc | 50 + iqpilot/tools/cabana/ui/tools/routeinfo.h | 14 + iqpilot/tools/cabana/ui/tools/tooldialog.h | 52 + iqpilot/tools/cabana/ui/util.cc | 466 +++++++++ iqpilot/tools/cabana/ui/util.h | 201 ++++ iqpilot/tools/cabana/ui/widgets/binaryview.cc | 548 ++++++++++ iqpilot/tools/cabana/ui/widgets/binaryview.h | 98 ++ iqpilot/tools/cabana/ui/widgets/cameraview.cc | 176 ++++ iqpilot/tools/cabana/ui/widgets/cameraview.h | 88 ++ .../tools/cabana/ui/widgets/detailwidget.cc | 439 ++++++++ .../tools/cabana/ui/widgets/detailwidget.h | 112 ++ iqpilot/tools/cabana/ui/widgets/historylog.cc | 307 ++++++ iqpilot/tools/cabana/ui/widgets/historylog.h | 59 ++ .../tools/cabana/ui/widgets/messagebytes.cc | 60 ++ .../tools/cabana/ui/widgets/messagebytes.h | 20 + .../tools/cabana/ui/widgets/messageswidget.cc | 512 +++++++++ .../tools/cabana/ui/widgets/messageswidget.h | 93 ++ .../cabana/ui/widgets/scrollabletabbar.cc | 87 ++ .../cabana/ui/widgets/scrollabletabbar.h | 8 + iqpilot/tools/cabana/ui/widgets/signalview.cc | 975 ++++++++++++++++++ iqpilot/tools/cabana/ui/widgets/signalview.h | 201 ++++ iqpilot/tools/cabana/ui/widgets/tabbar.cc | 92 ++ iqpilot/tools/cabana/ui/widgets/tabbar.h | 45 + .../tools/cabana/ui/widgets/videowidget.cc | 612 +++++++++++ iqpilot/tools/cabana/ui/widgets/videowidget.h | 121 +++ iqpilot/tools/cabana/utils/elidedlabel.cc | 29 - iqpilot/tools/cabana/utils/elidedlabel.h | 25 - iqpilot/tools/cabana/utils/export.cc | 2 +- iqpilot/tools/cabana/utils/export.h | 2 +- iqpilot/tools/cabana/utils/strings.cc | 57 + iqpilot/tools/cabana/utils/strings.h | 109 ++ iqpilot/tools/cabana/utils/util.cc | 503 +++------ iqpilot/tools/cabana/utils/util.h | 215 +--- iqpilot/tools/cabana/videowidget.cc | 433 -------- iqpilot/tools/cabana/videowidget.h | 81 -- iqpilot/tools/install_ubuntu_dependencies.sh | 11 +- iqpilot/tools/iq.sh | 28 + iqpilot/tools/jotpluggler/SConscript | 2 +- iqpilot/tools/mac_setup.sh | 21 - iqpilot/tools/replay/framereader.h | 1 + iqpilot/tools/scons/site_tools/pretty.py | 3 - iqpilot/tools/test_tools_local_route.py | 40 +- iqpilot/tools/tests/test_iq_command.py | 46 + 162 files changed, 12835 insertions(+), 9476 deletions(-) delete mode 100644 iqpilot/tools/cabana/assets/.gitignore delete mode 100644 iqpilot/tools/cabana/assets/assets.qrc delete mode 100644 iqpilot/tools/cabana/assets/cabana-icon.png delete mode 100644 iqpilot/tools/cabana/binaryview.cc delete mode 100644 iqpilot/tools/cabana/binaryview.h delete mode 100644 iqpilot/tools/cabana/cameraview.cc delete mode 100644 iqpilot/tools/cabana/cameraview.h delete mode 100644 iqpilot/tools/cabana/chart/chart.cc delete mode 100644 iqpilot/tools/cabana/chart/chart.h delete mode 100644 iqpilot/tools/cabana/chart/chartswidget.cc delete mode 100644 iqpilot/tools/cabana/chart/chartswidget.h delete mode 100644 iqpilot/tools/cabana/chart/signalselector.cc delete mode 100644 iqpilot/tools/cabana/chart/signalselector.h delete mode 100644 iqpilot/tools/cabana/chart/sparkline.cc delete mode 100644 iqpilot/tools/cabana/chart/sparkline.h delete mode 100644 iqpilot/tools/cabana/chart/tiplabel.cc delete mode 100644 iqpilot/tools/cabana/chart/tiplabel.h create mode 100644 iqpilot/tools/cabana/core/observable.h delete mode 100644 iqpilot/tools/cabana/dbc/dbcqt.cc delete mode 100644 iqpilot/tools/cabana/dbc/dbcqt.h delete mode 100644 iqpilot/tools/cabana/deqt.md delete mode 100644 iqpilot/tools/cabana/detailwidget.cc delete mode 100644 iqpilot/tools/cabana/detailwidget.h delete mode 100644 iqpilot/tools/cabana/historylog.cc delete mode 100644 iqpilot/tools/cabana/historylog.h delete mode 100644 iqpilot/tools/cabana/mainwin.cc delete mode 100644 iqpilot/tools/cabana/mainwin.h delete mode 100644 iqpilot/tools/cabana/messageswidget.cc delete mode 100644 iqpilot/tools/cabana/messageswidget.h create mode 100644 iqpilot/tools/cabana/routes.cc create mode 100644 iqpilot/tools/cabana/routes.h delete mode 100644 iqpilot/tools/cabana/signalview.cc delete mode 100644 iqpilot/tools/cabana/signalview.h delete mode 100644 iqpilot/tools/cabana/streams/routes.cc delete mode 100644 iqpilot/tools/cabana/streams/routes.h delete mode 100644 iqpilot/tools/cabana/streamselector.cc delete mode 100644 iqpilot/tools/cabana/streamselector.h create mode 100644 iqpilot/tools/cabana/tests/test_cabana_ui.py delete mode 100644 iqpilot/tools/cabana/tools/findsignal.cc delete mode 100644 iqpilot/tools/cabana/tools/findsignal.h delete mode 100644 iqpilot/tools/cabana/tools/findsimilarbits.cc delete mode 100644 iqpilot/tools/cabana/tools/findsimilarbits.h delete mode 100644 iqpilot/tools/cabana/tools/routeinfo.cc delete mode 100644 iqpilot/tools/cabana/tools/routeinfo.h create mode 100644 iqpilot/tools/cabana/ui/app.cc create mode 100644 iqpilot/tools/cabana/ui/app.h create mode 100644 iqpilot/tools/cabana/ui/chart/chart.cc create mode 100644 iqpilot/tools/cabana/ui/chart/chart.h create mode 100644 iqpilot/tools/cabana/ui/chart/chartswidget.cc create mode 100644 iqpilot/tools/cabana/ui/chart/chartswidget.h create mode 100644 iqpilot/tools/cabana/ui/chart/signalselector.cc create mode 100644 iqpilot/tools/cabana/ui/chart/signalselector.h create mode 100644 iqpilot/tools/cabana/ui/chart/sparkline.cc create mode 100644 iqpilot/tools/cabana/ui/chart/sparkline.h create mode 100644 iqpilot/tools/cabana/ui/chart/tiplabel.cc create mode 100644 iqpilot/tools/cabana/ui/chart/tiplabel.h create mode 100644 iqpilot/tools/cabana/ui/dialogs/filedialog.cc create mode 100644 iqpilot/tools/cabana/ui/dialogs/filedialog.h create mode 100644 iqpilot/tools/cabana/ui/dialogs/messagebox.cc create mode 100644 iqpilot/tools/cabana/ui/dialogs/messagebox.h create mode 100644 iqpilot/tools/cabana/ui/dialogs/routesdialog.cc create mode 100644 iqpilot/tools/cabana/ui/dialogs/routesdialog.h create mode 100644 iqpilot/tools/cabana/ui/dialogs/settingsdialog.cc create mode 100644 iqpilot/tools/cabana/ui/dialogs/settingsdialog.h create mode 100644 iqpilot/tools/cabana/ui/dialogs/streamselector.cc create mode 100644 iqpilot/tools/cabana/ui/dialogs/streamselector.h create mode 100644 iqpilot/tools/cabana/ui/helpoverlay.cc create mode 100644 iqpilot/tools/cabana/ui/helpoverlay.h create mode 100644 iqpilot/tools/cabana/ui/icons.h create mode 100644 iqpilot/tools/cabana/ui/inistate.cc create mode 100644 iqpilot/tools/cabana/ui/inistate.h rename iqpilot/tools/cabana/{cabana.cc => ui/main.cc} (58%) create mode 100644 iqpilot/tools/cabana/ui/mainwin.cc create mode 100644 iqpilot/tools/cabana/ui/mainwin.h create mode 100644 iqpilot/tools/cabana/ui/style.cc create mode 100644 iqpilot/tools/cabana/ui/threadpool.h create mode 100644 iqpilot/tools/cabana/ui/tools/findsignal.cc create mode 100644 iqpilot/tools/cabana/ui/tools/findsignal.h create mode 100644 iqpilot/tools/cabana/ui/tools/findsimilarbits.cc create mode 100644 iqpilot/tools/cabana/ui/tools/findsimilarbits.h create mode 100644 iqpilot/tools/cabana/ui/tools/routeinfo.cc create mode 100644 iqpilot/tools/cabana/ui/tools/routeinfo.h create mode 100644 iqpilot/tools/cabana/ui/tools/tooldialog.h create mode 100644 iqpilot/tools/cabana/ui/util.cc create mode 100644 iqpilot/tools/cabana/ui/util.h create mode 100644 iqpilot/tools/cabana/ui/widgets/binaryview.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/binaryview.h create mode 100644 iqpilot/tools/cabana/ui/widgets/cameraview.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/cameraview.h create mode 100644 iqpilot/tools/cabana/ui/widgets/detailwidget.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/detailwidget.h create mode 100644 iqpilot/tools/cabana/ui/widgets/historylog.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/historylog.h create mode 100644 iqpilot/tools/cabana/ui/widgets/messagebytes.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/messagebytes.h create mode 100644 iqpilot/tools/cabana/ui/widgets/messageswidget.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/messageswidget.h create mode 100644 iqpilot/tools/cabana/ui/widgets/scrollabletabbar.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/scrollabletabbar.h create mode 100644 iqpilot/tools/cabana/ui/widgets/signalview.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/signalview.h create mode 100644 iqpilot/tools/cabana/ui/widgets/tabbar.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/tabbar.h create mode 100644 iqpilot/tools/cabana/ui/widgets/videowidget.cc create mode 100644 iqpilot/tools/cabana/ui/widgets/videowidget.h delete mode 100644 iqpilot/tools/cabana/utils/elidedlabel.cc delete mode 100644 iqpilot/tools/cabana/utils/elidedlabel.h create mode 100644 iqpilot/tools/cabana/utils/strings.cc create mode 100644 iqpilot/tools/cabana/utils/strings.h delete mode 100644 iqpilot/tools/cabana/videowidget.cc delete mode 100644 iqpilot/tools/cabana/videowidget.h diff --git a/SConstruct b/SConstruct index cd6c9981d..ea52c2251 100644 --- a/SConstruct +++ b/SConstruct @@ -25,7 +25,7 @@ import site_init # noqa: F401 # capnp's kj library warns when $PWD is stale (doesn't match the real cwd); keep them in sync os.environ.pop('PWD', None) -Decider('MD5-timestamp') +Decider('MD5' if os.uname().machine == 'aarch64' else 'MD5-timestamp') SetOption('num_jobs', max(1, int(os.cpu_count()/2))) diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/carstate.py b/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/carstate.py index f0913d186..0d8e7f61f 100644 --- a/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/carstate.py +++ b/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/carstate.py @@ -776,6 +776,10 @@ class CarState(CarStateBase): ret.steerFaultTemporary, ret.steerFaultPermanent = False, True return + if self.CP.flags & VolkswagenFlags.MLB: + # MLB LWS zero is vehicle-specific (measured 2.5 deg off centre on an 8R); the EPS angle is what the rack closes its own loop on + ret.steeringAngleDeg = pt_cp.vl["LH_EPS_03"]["EPS_Berechneter_LW"] * (1, -1)[int(pt_cp.vl["LH_EPS_03"]["EPS_VZ_BLW"])] + ret.steeringTorque = pt_cp.vl["LH_EPS_03"]["EPS_Lenkmoment"] * (1, -1)[int(pt_cp.vl["LH_EPS_03"]["EPS_VZ_Lenkmoment"])] ret.steeringPressed = abs(ret.steeringTorque) > self.CCP.STEER_DRIVER_ALLOWANCE diff --git a/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/tests/test_volkswagen.py b/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/tests/test_volkswagen.py index 12af3b91d..94e031b71 100644 --- a/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/tests/test_volkswagen.py +++ b/artifacts/package_sources/iqdbc/iqdbc/car/volkswagen/tests/test_volkswagen.py @@ -511,3 +511,42 @@ def test_mlb_carstate_subscriptions_settle_and_stay_alive(build): assert ret.canValid assert all(parser.can_valid for parser in car.can_parsers.values()) assert not any(parser.bus_timeout for parser in car.can_parsers.values()) + + +def _steering_state(flags, lwi=(0.0, 0), eps=(0.0, 0)): + state = object.__new__(CarState) + state.CP = SimpleNamespace(flags=flags) + state.CCP = SimpleNamespace(STEER_DRIVER_ALLOWANCE=100, hca_status_values={5: "ACTIVE"}) + state.eps_init_complete = True + state.frame = 0 + pt_cp = SimpleNamespace(vl={ + "LWI_01": {"LWI_Lenkradwinkel": lwi[0], "LWI_VZ_Lenkradwinkel": lwi[1], + "LWI_Lenkradw_Geschw": 4.0, "LWI_VZ_Lenkradw_Geschw": 0}, + "LH_EPS_03": {"EPS_Berechneter_LW": eps[0], "EPS_VZ_BLW": eps[1], + "EPS_Lenkmoment": 0.0, "EPS_VZ_Lenkmoment": 0, "EPS_HCA_Status": 5}, + }) + ret = structs.CarState() + state.parse_mlb_mqb_steering_state(ret, pt_cp) + return ret + + +def test_mlb_takes_the_steering_angle_from_the_eps(): + ret = _steering_state(VolkswagenFlags.MLB, lwi=(12.0, 0), eps=(9.6, 0)) + assert ret.steeringAngleDeg == pytest.approx(9.6) + assert ret.steeringRateDeg == pytest.approx(4.0) + + +def test_mlb_eps_angle_honours_its_own_sign_bit(): + ret = _steering_state(VolkswagenFlags.MLB, lwi=(12.0, 0), eps=(9.6, 1)) + assert ret.steeringAngleDeg == pytest.approx(-9.6) + + +def test_mlb_without_hca_eps_keeps_the_steering_wheel_sensor(): + flags = VolkswagenFlags.MLB | VolkswagenFlagsIQ.IQ_MLB_NO_HCA_EPS + ret = _steering_state(flags, lwi=(12.0, 1), eps=(9.6, 0)) + assert ret.steeringAngleDeg == pytest.approx(-12.0) + + +def test_mqb_keeps_the_steering_wheel_sensor(): + ret = _steering_state(0, lwi=(12.0, 0), eps=(9.6, 0)) + assert ret.steeringAngleDeg == pytest.approx(12.0) diff --git a/iqpilot/cereal/messaging/socketmaster.cc b/iqpilot/cereal/messaging/socketmaster.cc index 97fe09643..429186f9f 100644 --- a/iqpilot/cereal/messaging/socketmaster.cc +++ b/iqpilot/cereal/messaging/socketmaster.cc @@ -7,6 +7,10 @@ #include "cereal/services.h" #include "cereal/messaging/messaging.h" +#ifndef SERVICES_REGISTRY_STAMPED +static const char SERVICES_REGISTRY_TAG[] = "IQ_SERVICES_REGISTRY:unstamped"; +#endif + const bool SIMULATION = (getenv("SIMULATION") != nullptr) && (std::string(getenv("SIMULATION")) == "1"); static inline bool inList(const std::vector &list, const char *value) { diff --git a/iqpilot/cereal/services.py b/iqpilot/cereal/services.py index d5ff68dff..c84f1c973 100755 --- a/iqpilot/cereal/services.py +++ b/iqpilot/cereal/services.py @@ -168,6 +168,7 @@ def build_header(): h += ' { "%s", {"%s", %s, %f, %d, %d}},\n' % \ (k, k, should_log, v.frequency, decimation, v.queue_size) h += "};\n" + h += "#define SERVICES_REGISTRY_STAMPED 1\n" h += f'static const char SERVICES_REGISTRY_TAG[] = "{registry_tag()}";\n' h += "#endif\n" diff --git a/iqpilot/system/manager/build.py b/iqpilot/system/manager/build.py index ab624862d..a3445da4f 100755 --- a/iqpilot/system/manager/build.py +++ b/iqpilot/system/manager/build.py @@ -81,6 +81,19 @@ def stale_registry_artifacts(basedir: str = BASEDIR) -> list[str]: return stale +def purge_stale_registry_header(basedir: str = BASEDIR) -> bool: + from iqpilot.cereal.services import registry_tag + header = os.path.join(basedir, REGISTRY_ARTIFACTS[0]) + if not os.path.isfile(header): + return False + with open(header, "rb") as f: + stamped = registry_tag().encode() in f.read() + if stamped: + return False + purge_registry_artifacts([], basedir) + return True + + def purge_registry_artifacts(stale: list[str], basedir: str = BASEDIR) -> None: for rel in set(stale) | set(REGISTRY_ARTIFACTS[:3]): path = os.path.join(basedir, rel) @@ -101,6 +114,9 @@ def build(spinner, dirty: bool = False, minimal: bool = False, show_error_window # building with all cores can result in using too # much memory, so retry with less parallelism + if purge_stale_registry_header(): + cloudlog.error("generated service registry header was stale, regenerating") + compile_output: list[bytes] = [] for n in get_job_sequence(): compile_output.clear() diff --git a/iqpilot/system/manager/test/test_services_registry.py b/iqpilot/system/manager/test/test_services_registry.py index c726fa6db..a0a3e02cd 100644 --- a/iqpilot/system/manager/test/test_services_registry.py +++ b/iqpilot/system/manager/test/test_services_registry.py @@ -43,3 +43,23 @@ def test_purge_removes_the_stale_binary_and_the_messaging_table(tmp_path): assert "iqpilot/cereal/messaging/socketmaster.o" not in remaining assert "iqpilot/cereal/libsocketmaster.a" not in remaining assert "iqpilot/system/loggerd/loggerd" in remaining + + +def test_generated_header_declares_the_stamp_for_the_compile_fallback(): + assert "#define SERVICES_REGISTRY_STAMPED 1" in build_header() + + +def test_pre_build_purge_drops_an_unstamped_or_old_header(tmp_path): + from iqpilot.system.manager.build import purge_stale_registry_header + _write(tmp_path, "iqpilot/cereal/services.h", b"static std::map services = {};\n") + _write(tmp_path, "iqpilot/cereal/messaging/socketmaster.o", b"o") + _write(tmp_path, "iqpilot/cereal/libsocketmaster.a", b"a") + assert purge_stale_registry_header(str(tmp_path)) + assert not (tmp_path / "iqpilot/cereal/services.h").exists() + assert not (tmp_path / "iqpilot/cereal/messaging/socketmaster.o").exists() + assert not (tmp_path / "iqpilot/cereal/libsocketmaster.a").exists() + + _write(tmp_path, "iqpilot/cereal/services.h", build_header().encode()) + assert not purge_stale_registry_header(str(tmp_path)) + assert (tmp_path / "iqpilot/cereal/services.h").exists() + assert not purge_stale_registry_header(str(tmp_path / "nowhere")) diff --git a/iqpilot/tools/Brewfile b/iqpilot/tools/Brewfile index af610be75..115db1848 100644 --- a/iqpilot/tools/Brewfile +++ b/iqpilot/tools/Brewfile @@ -8,7 +8,6 @@ brew "libusb" brew "libtool" brew "llvm" brew "openssl@3.0" -brew "qt@5" brew "zeromq" cask "gcc-arm-embedded" brew "portaudio" diff --git a/iqpilot/tools/cabana/.gitignore b/iqpilot/tools/cabana/.gitignore index 7f9ac0fde..fbbf8d2ac 100644 --- a/iqpilot/tools/cabana/.gitignore +++ b/iqpilot/tools/cabana/.gitignore @@ -1,11 +1,5 @@ -moc_* -*.moc -*.generated.qrc - -assets.cc -bootstrap_icons.cc - _cabana +*.o dbc/car_fingerprint_to_dbc.json -tests/test_cabana tests/test_dbc_core +ui/obj/ diff --git a/iqpilot/tools/cabana/README.md b/iqpilot/tools/cabana/README.md index d8bc30554..50dc1fa08 100644 --- a/iqpilot/tools/cabana/README.md +++ b/iqpilot/tools/cabana/README.md @@ -1,117 +1,21 @@ # Cabana -Cabana is a tool developed to view raw CAN data. One use for this is creating and editing [CAN Dictionaries](http://socialledge.com/sjsu/index.php/DBC_Format) (DBC files), and the tool provides direct integration with iqdbc (a collection of DBC files), allowing you to load the DBC files direct from source, and save to your fork. In addition, you can load routes from [konn3kt](https://konn3kt.com). +Cabana is IQ.Pilot's desktop CAN analysis tool. It uses ImGui and GLFW on macOS and Linux without Qt. -## Usage Instructions +Run it through the project command: ```bash -$ ./cabana -h -Usage: ./cabana [options] route - -Options: - -h, --help Displays help on commandline options. - --help-all Displays help including Qt specific options. - --demo use a demo route instead of providing your own - --auto Auto load the route from the best available source (no video): - internal, openpilotci, comma_api, car_segments, testing_closet - --qcam load qcamera - --ecam load wide road camera - --msgq read can messages from msgq - --panda read can messages from panda - --panda-serial read can messages from panda with given serial - --socketcan read can messages from given SocketCAN device - --zmq read can messages from zmq at the specified ip-address - messages - --data_dir local directory with routes - --no-vipc do not output video - --dbc dbc file to open - -Arguments: - route the drive to replay. find your drives at - konn3kt.com +iq cabana ``` -## Examples +Cabana can open a local route, a Konn3kt route, a Panda, SocketCAN on Linux, local msgq, or a remote ZMQ stream. -### Running Cabana in Demo Mode -To run Cabana using a built-in demo route, use the following command: - -```shell -cabana --demo +```bash +iq cabana "dongle_id|2026-09-02--12-00-00" +iq cabana --panda +iq cabana --msgq +iq cabana --zmq 192.168.1.10 +iq cabana --bridge 192.168.1.10 ``` -### Loading a Specific Route - -To load a specific route for replay, provide the route as an argument: - -```shell -cabana "5beb9b58bd12b691/0000010a--a51155e496" -``` - -Replace "5beb9b58bd12b691/0000010a--a51155e496" with your desired route identifier. - - -### Running Cabana with multiple cameras -To run Cabana with multiple cameras, use the following command: - -```shell -cabana "5beb9b58bd12b691/0000010a--a51155e496" --dcam --ecam -``` - -### Streaming CAN Messages from a comma Device - -[SSH into your device](https://github.com/commaai/openpilot/wiki/SSH) and start the bridge with the following command: - -```shell -cd /data/openpilot -./cereal/messaging/bridge & -``` - -Then Run Cabana with the device's IP address: - -```shell -cabana --zmq -``` - -Replace <ipaddress> with your device's IP address. - -If you can't run the bridge on the device, `--bridge ` runs -`cereal/messaging/bridge` locally against the device instead. - -### Streaming CAN Messages from a Remote Device over konn3kt - -To watch a device that isn't on your network, `tools/cabana/konn3kt_canproxy.py` -re-publishes its live CAN onto a local ZMQ socket: - -```shell -export KONN3KT_JWT="" -./tools/cabana/konn3kt_canproxy.py -cabana --zmq 127.0.0.1 -``` - -The device must have `CanLiveStreaming` enabled so `canlived` is running. See the -header of `konn3kt_canproxy.py` for the full topology. - -While streaming from the device, Cabana will log the CAN messages to a local directory. By default, this directory is ~/cabana_live_stream/. You can change the log directory in Cabana by navigating to menu -> tools -> settings. - -After disconnecting from the device, you can replay the logged CAN messages from the stream selector dialog -> browse local route. - -### Streaming CAN Messages from Panda - -To read CAN messages from a connected Panda, use the following command: - -```shell -cabana --panda -``` - -### Using the Stream Selector Dialog - -If you run Cabana without any arguments, a stream selector dialog will pop up, allowing you to choose the stream. - -```shell -cabana -``` - -## Additional Information - -For more information, see the [openpilot wiki](https://github.com/commaai/openpilot/wiki/Cabana) +The executable is built on demand by `iqpilot/tools/cabana/cabana`. IQ.Pilot prebuilt device checkouts intentionally omit desktop analysis tools. diff --git a/iqpilot/tools/cabana/SConscript b/iqpilot/tools/cabana/SConscript index 1848a7544..721455d7a 100644 --- a/iqpilot/tools/cabana/SConscript +++ b/iqpilot/tools/cabana/SConscript @@ -1,97 +1,19 @@ -import subprocess import os -import shutil - -import iqdbc import sys venv_site_packages = os.path.join(Dir("#").abspath, ".venv", "lib", "python3.12", "site-packages") if os.path.isdir(venv_site_packages) and venv_site_packages not in sys.path: sys.path.insert(0, venv_site_packages) +import imgui +import iqdbc import libusb +from iqpilot.common.basedir import BASEDIR Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') -# Detect Qt - skip build if not available -if arch == "Darwin": - try: - brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() - has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5")) - except (FileNotFoundError, subprocess.CalledProcessError): - has_qt = False -else: - has_qt = shutil.which('qmake') is not None -if not has_qt: - Return() - -qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core"] - -qt_libs = [] -if arch == "Darwin": - qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5" - qt_dirs = [ - os.path.join(qt_env['QTDIR'], "include"), - ] - qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules] - qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")] - qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] - qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin")) -else: - qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip() - qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip() - - qt_env['QTDIR'] = qt_install_prefix - qt_dirs = [ - f"{qt_install_headers}", - ] - - qt_gui_path = os.path.join(qt_install_headers, "QtGui") - qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))] - qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else [] - qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] - - qt_libs = [f"Qt5{m}" for m in qt_modules] -qt_env['QT3DIR'] = qt_env['QTDIR'] -qt_env.Tool('qt3') - -qt_env['CPPPATH'] += qt_dirs -qt_flags = [ - "-D_REENTRANT", - "-DQT_NO_DEBUG", - "-DQT_WIDGETS_LIB", - "-DQT_GUI_LIB", - "-DQT_CORE_LIB", - "-DQT_MESSAGELOGCONTEXT", -] -qt_env['CXXFLAGS'] += qt_flags -qt_env['LIBPATH'] += ['#iqpilot/selfdrive/ui', ] -qt_env['LIBS'] = qt_libs - -base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] - -if arch == "Darwin": - base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] - -cabana_env = qt_env.Clone() -cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] -cabana_env['LIBPATH'] += [libusb.LIB_DIR] - -# IQ.Pilot patch: replay_lib fetches routes over libcurl and signs konn3kt JWTs with -# OpenSSL (tools/replay/api.cc), and visionipc references OpenCL — upstream gets all -# three transitively from its Python downloader / vendored wheels, we link them here. -cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + \ - ['bz2', 'zstd', 'curl', 'ssl', 'crypto', 'usb-1.0'] + base_libs -if arch == "Darwin": - base_frameworks += ['OpenCL'] -else: - cabana_libs += ['OpenCL'] opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % iqdbc.DBC_PATH -cabana_env['CXXFLAGS'] += [opendbc_path] -# embed the bootstrap icons SVG into the binary def build_bootstrap_icons_src(target, source, env): data = open(str(source[0]), 'rb').read() with open(str(target[0]), 'w') as f: @@ -100,48 +22,92 @@ def build_bootstrap_icons_src(target, source, env): f.write('extern const size_t bootstrap_icons_svg_len;\n') f.write('const unsigned char bootstrap_icons_svg[] = {\n') for i in range(0, len(data), 32): - f.write(','.join(str(b) for b in data[i:i+32]) + ',\n') + f.write(','.join(str(b) for b in data[i:i + 32]) + ',\n') f.write('};\n') f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') return None -# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here; the SVG is checked in -# under third_party/bootstrap (see its pull.sh). -bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', '#iqpilot/third_party/bootstrap/bootstrap-icons.svg', - cabana_env.PrettyAction(build_bootstrap_icons_src, 'GEN')) +cabana_env = env.Clone() +cabana_env['CPPPATH'] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +cabana_env['LIBPATH'] += [p for p in [imgui.MESA_DIR, libusb.LIB_DIR] if os.path.isdir(p)] +cabana_env['CXXFLAGS'] += [ + opendbc_path, + "-DGLFW_INCLUDE_NONE", + '-DCABANA_FONTS_DIR=\'"%s"\'' % os.path.join(os.path.realpath(BASEDIR), "iqpilot", "selfdrive", "assets", "fonts"), + '-DBOOTSTRAP_ICONS_TTF=\'"%s"\'' % os.path.join(os.path.realpath(BASEDIR), "iqpilot", "tools", "jotpluggler", "assets", "bootstrap-icons.ttf"), +] -# build assets -assets = "assets/assets.cc" -cabana_env.Command(assets, "assets/assets.qrc", cabana_env.PrettyAction("rcc $SOURCES -o $TARGET", 'RCC')) -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) +bootstrap_icons_src = cabana_env.Command( + 'ui/obj/bootstrap_icons.cc', + '#iqpilot/third_party/bootstrap/bootstrap-icons.svg', + cabana_env.PrettyAction(build_bootstrap_icons_src, 'GEN'), +) -cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] +core_srcs = [ + 'streams/pandastream.cc', + 'streams/devicestream.cc', + 'streams/livestream.cc', + 'streams/abstractstream.cc', + 'streams/replaystream.cc', + 'dbc/dbc.cc', + 'dbc/dbcfile.cc', + 'dbc/dbcmanager.cc', + 'utils/export.cc', + 'utils/util.cc', + 'utils/strings.cc', + 'commands.cc', + 'settings.cc', + 'routes.cc', + 'panda.cc', +] if arch != "Darwin": - cabana_srcs += ['streams/socketcanstream.cc'] -cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) -cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) + core_srcs += ['streams/socketcanstream.cc'] + +core_objects = [cabana_env.Object('ui/obj/' + src.replace('/', '_')[:-3], src) for src in core_srcs] +ui_objects = core_objects + [cabana_env.Object('ui/obj/bootstrap_icons', bootstrap_icons_src)] +ui_objects += cabana_env.Glob('ui/*.cc') +ui_objects += cabana_env.Glob('ui/widgets/*.cc') +ui_objects += cabana_env.Glob('ui/dialogs/*.cc') +ui_objects += cabana_env.Glob('ui/chart/*.cc') +ui_objects += cabana_env.Glob('ui/tools/*.cc') + +cabana_libs = [ + replay_lib, + common, + messaging, + visionipc, + cereal, + File(f"{imgui.LIB_DIR}/libimgui.a"), + File(f"{imgui.LIB_DIR}/libglfw3.a"), +] + ffmpeg_libs + ['bz2', 'zstd', 'curl', 'ssl', 'crypto', 'm', 'pthread', 'usb-1.0'] + +if arch == "Darwin": + cabana_env['FRAMEWORKS'] = ['OpenGL', 'OpenCL', 'Cocoa', 'IOKit', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'Security', 'VideoToolbox'] +else: + cabana_libs += ['GL', 'OpenCL', 'dl'] + +cabana = cabana_env.Program('_cabana', ui_objects, LIBS=cabana_libs) if GetOption('extras'): - # This target deliberately uses the base environment and links no Qt libraries. - # It prevents Qt dependencies from creeping back into the DBC core. - dbc_core_test_env = env.Clone() - dbc_core_test_env['CXXFLAGS'] += [opendbc_path] - dbc_core_test_objects = [ - dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), - dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), - dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), - dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + test_env = env.Clone() + test_env['CXXFLAGS'] += [opendbc_path] + test_objects = [ + test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), + test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), + test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), + test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + test_env.Object('tests/dbc_core_strings', 'utils/strings.cc'), + test_env.Object('tests/dbc_core_util', 'utils/util.cc'), + test_env.Object('tests/dbc_core_icons', bootstrap_icons_src), + test_env.Object('tests/dbc_core_routes', 'routes.cc'), ] - dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) + test_env.Program('tests/test_dbc_core', test_objects, LIBS=[replay_lib, common, 'curl', 'ssl', 'crypto']) output_json_file = 'iqpilot/tools/cabana/dbc/car_fingerprint_to_dbc.json' -generate_dbc = cabana_env.Command('#' + output_json_file, - ['dbc/generate_dbc_json.py'], - cabana_env.PrettyAction("python3 iqpilot/tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file, - 'GEN', logfile='${TARGET}.log')) -cabana_env.Depends(generate_dbc, ["#iqpilot/common", "#iqpilot/cereal"]) +generate_dbc = cabana_env.Command( + '#' + output_json_file, + ['dbc/generate_dbc_json.py'], + cabana_env.PrettyAction('"%s" iqpilot/tools/cabana/dbc/generate_dbc_json.py --out %s' % (sys.executable, output_json_file), 'GEN', logfile='${TARGET}.log'), +) +cabana_env.Depends(generate_dbc, ['#iqpilot/common', '#iqpilot/cereal', os.path.dirname(iqdbc.__file__)]) +cabana_env.Depends(cabana, generate_dbc) diff --git a/iqpilot/tools/cabana/assets/.gitignore b/iqpilot/tools/cabana/assets/.gitignore deleted file mode 100644 index 283034ca8..000000000 --- a/iqpilot/tools/cabana/assets/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.cc diff --git a/iqpilot/tools/cabana/assets/assets.qrc b/iqpilot/tools/cabana/assets/assets.qrc deleted file mode 100644 index 009d63f00..000000000 --- a/iqpilot/tools/cabana/assets/assets.qrc +++ /dev/null @@ -1,5 +0,0 @@ - - - cabana-icon.png - - diff --git a/iqpilot/tools/cabana/assets/cabana-icon.png b/iqpilot/tools/cabana/assets/cabana-icon.png deleted file mode 100644 index 86add806fd1cabcd145ceef74a3995bc609d34fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18008 zcmV)CK*GO?P)PyA07*naRCr#^T?LpO<<)*iuIpY|_l+kcAwmLy1PB(S6iIO{MOw7Dv_%Wli&Lz4 za0`|YG(d>EZnCb|-R92x&pC59rTu|~mO=+MyLzg%c;yj0#)^6M0 z_Q9HM8a z#bb~}8KNj6ole7Mvq6$1IGs-PbanH0Nfe=K8edj89OmyPqft{8HL$65>st>$^}+*- zSFBhGpzyzN?LVjj#`yjAlYV;o&-UMY>ZJmNdY8+ETrP)1JOPi_14S*ePzMJCsHiAM zHk(0F(ICq*1VMlXjTPW_yOE41kV+}P-7GdEV%qCbY7Nk-sWV2Z)xja-&g)ECOnar?S zY$y~8{5KK$h*cDvLaZ9NO`di0@-7cX72_50ox-=_k^pPllPb5A<@sNV^I z!)PWU%ZNpzFv=3_4m+EgLa_*k!--5L4X@XOSS*TQFo=eRMub9PR903Z7E7Rz&%@<$ zK`APbL<6FcC@gj>3)ybBBQQ7sS(1>?=b$JGR8@u5>OdkMN0C0m=|Dc8MX{(tQvpeo zkjZ7cAA08bv!8wC^@ZP;BEAm=2-jb7;ROdDuG6Ap8$A%`K zFQB`#6T?T2Vz836UWR>XI%fL~p9$;opL-1iBC(Ih34323SUi-{0O2z7OJY`Z&Jx5Hmm1y8vj zW{VB!REmY?u-jQ!Mxzm3-JPhese##IK`xs`JQim=U0z;}LP0?|6hd`f4OF#=&dv_x zvss3vUay~>wW?}}Cz22}4TXG;g(@3N2m}YgyYkh{d6)Dl+LbOja|j zHajw@G@~HNAhUq1W)rXYd|rWp%#Fj2-tI1T@PqyRXlZIfAQ0s9NawPsuBnA48z9RP ztX3P+w3C7Z5H%Hg)g+Y?=@v`N2+;-2S*M3(@_)ZisdGzS2 zNALg3dVfWQhs=$lww;d(6H!Tz<)if*?qA!ep-ryVq7!A{vb# zl}@o!Ba=Z`-060~;c_7zjU$;#!tF1Ir_6^;Cdn5siV_Pvok^jxx)w&+fLJs}CV&H} zk)uapFc5&N%tH|_`Z~MW7o;;;6bgA%R#l>ZV33iL&1z?VU@{re-rmkiHd!njQPcZ& z_jF^@o|BNyrjbsk(ACw2VNJ~pYZVQ!*sLJ9+`eNwa;Y>#XpF)}j2Z=_(HuSM?9)fB zU*9(HpD5u!Qow~jJ@e$tFFfz*0!6RMBs?AuN2G&;{ivy`=2c4pmZBCQsC4pChytJq zK!v{?9o=2<_{*UxDhFe#svw)rFpA0M^K6R=YAX3Wl7xPs!DhF!qc(^}c>G?Zk}1ec zRghCH@_V>lE)4V!urDxMt^ByjXvSb)Kd)?)#e`TW!sdvaxXkx~@7~YK-IRfEe5Zzs!sI95RKwyB) z0ugFvgA73g67duSNrb<=9N|cm1G4^}9yB&Ipr@~&cThAIgV|_-)9vQnL@@;^hJre$ z!-jY)#!+r{bv45lQb;D9gWKsuHlG98CN-CbY&J4FB7ag=R>l!D`4d89sbm_WEW_jT zKr3o68jK7Ag24c6Rx6A)3!A-cHlzOT?gyqm_T0UQrHBEn;wB z0A7y=k}Sb&weZd&lSR9V0yDeSf^Z}ZNtR(U6KYbK;LD~mFq&lkoqz%DKzeTq+-TBUg&b*|g++E<$>i7!&<-MnmRDDzZQFL{ zaSBSF&BvCu4lMd?83N%Lws&+QkxH|GNH`SL`aET*uc^kNv-U%CLmk6egDfMT$-wD$ z@*)Wb11PT~N3SuWA_Nr(Qqbk#z|L;7vNP}Q>|!2@;tq=1%gS8n>+R*$PJtl#F*b{e z0%#f>b_X_X-HiIWM)iVU-#B{Bn$6#ePx@90IQyiz`~LF0vp*qwUteF3L?Xos7etZm zbCI^S(ZIHsPzDLf;c`MP7Lm*7urZU%B9qQAw9E0&Sq`d_sWcjg4MW?G?ab>C|MS7J zRd{W|BE*yW=j_fwbo7XpCj9oAD;ZU}%iJ&+4KT1AFqzQa*2X)t zzOjK`RnT~UmT~}L&&W=ugUq_h>W|JA+-JSWL{o=~14c*;3 z55616ZYyB)=+Ra4?)^iT+vS!*;V|rW2dq{Lb27A2%vLj-4yU_}p&i*>@&g8|3DHmp zF1LFp^dxv#U0I2(+qR;vwjN4RMPGj(n;U|u+d8{(%U$;)8c*!TC4BW$XqC>KHU($R zJ%*!R3hMY`RSk(m43!m?>|f|Qylyv}rBEcyE1eWj*VKf9T107yXd=i%5Rk&IKE6P$7MiB-Fs0NPyz$g393A26Fu9+kGR1|3ooybMQSu~}d? z8KjA2a;sI94CN1V&1nWiRMf>t=eXR_(ML(^$SR1_-^tx;87%k;Ns z5D^K7;BwlTKA|1l-Q9`$x_ZbaBMe3}1_lS1YszHvpUgUX?#$h}h}}}aN3TBr?ucQ{ zhxGOJGNDC?!)P$Eqa#~M*qaapIb(8kbdaMW0*!*0d@%>7vy1~QQcffsL{D2gY8vaI zXd>)R7h=gc&bsIdtlhYIch37;Kj++&=VHd>JyGUz@{S-IPQakKsR@E8p|YwP;ZT@? zf}_lhqN<{|tB27T?QnWcUvD3ifV7a*qKf|he)!A%fGk52B#x?+$rv<2!t*b^c;&C} zyz39UI?-J!;Oa{*IQg{Wj($4Hj#Po!W@RB0zIQoX$mg;gd|53PRsvDHw4w-6P+UNM zfN(#p@JuGfFfSSoQC0*tmy?4lRTJ>@8*ah2wvJt$?|=07Q-6FE4xPCVC~nE6GpMbt zfzf2-T!d2881+QLVbs^vF&U>8@|`;=(Y%(%WWh3*LNI-*%P;iiAg`Tb+)YR3Y zKmfzx)ajH&8reb)R;!uKDEYOe%a>!!_%Z#>lV??P#P>DFH!7f~b>nBbbZT$6OV7(v za)V5c*-Q*&j?If8XrS=6sDOn}KL-cT9nlcv7dE$U z$BAbX)BF7$fBo%E7(H?X;_(EUo0}mMb)CsVmIPGR)nl-?6SguZ!hr}zj2(+eFo;wv z%ruE48If1=Fq+KJ1QC^l2BTqE%yy}IIDW5*UGA>DlP+2{Y}TPxt4(@s0^ zj@y2{D4R?%Tr-;qS}GtvFl<;0`g?i_cruJ5p_f$>#_M1+o8WRfnbNkJOt6}baCys_ z#r4-zAsz@Z8A#Fh%P+lvDO08*lgVM^$Puu)%GhZq31}pu3~$XwGrBsvkWXjO*w}#O zpL~R2w0_0>xs1`49?9C@(8X0^+dGfe-d&ei|{OXRcL`7ezfD2DQ z>E{PcpL{O`CR}?X0uc(^1+XnoB;trh!(2LIFWsxROmU?jg5#0 z!*F`aIFM->F%ru@{REwDZK$ko#BC2hf%fj6A4CE4p=Td93m%sPCPFp&JhJIHYO1R+ zddwcoC&gn?5bQ3ms>GU=D=>WAcvwwFeE#`o*l+)Xkf6V-Dw|Ux=j>J+K3TMgfy&GS zXQO@FW_0anW8iV`OAE$yghHGC^AP@xkW?~wNix-U&E5qt= zGAF-j>o&mU$3w5Y^@A#aKKz);qhXRo*i2?PttLn%;6S#Vpf%Cw1fhwHBNC7jOH{wq z+uh5kNH&>ZFdGpG>iG^TpXurD;Q*E3X;*JIOrn5LID+@qcZ~?-a@)Rq(f_&wgkKys zBbF=V9ppTT9Gfz2I)Z)uoRlCki};Xr>(-&5s2DlC8J(SN7(RRiwrtsgrp6|;wzfhx znPD~>JG4v!we_`VZ|g*Lg^v+@PhTIJn}@MqAl6nYs))rx$mMhJR@NXAiNa(s zaC)?*Wf+2?0Qv_5$QKmMoH+}v+qUv^C|AC;E#L|R@u3UctFiWHlg8kv$H zG(}r9lg%TO%j1*Y=*}YAA6g3sk7)+MZ9-?`hBt6guK*c!ResLKb@vazY_Y>(w{Y;4 zj>q8lm7%A%4;8ib*xI@Ssz7XT2E&_%VQXtEa(T*)7j?uaima^QU=U`1`JD?lwO{j9 z3b3EQ{~ih1Xhm<|AVQ%SR6*eMTTN{ZJWdya!G354BMUehPr)DvXsYp}vA%&5CInHb zb|)N(Q_UsRVu5*mm(Ro01f>aScS(`~?|1$1=4VI<%`OY7Tz166VT@?5LugS?IhDU(5&+XBgA zzn_3`=l zyd@l>h7sj%Wa4pD`8`O*<1m>m)P#U^D#=A&ieNxTEzI)jydH=pX23Q@yk&GbWVJGbG)qfY~+94v1PkaT}xn!u|v| zDfLG*1aUutp@2|41&h;%o`DDir2vOnhTrRfld?{M09TfgNy_FjFdAf}iC-}okt--r zxllxd%jv?$-C?dg_+L0|l7I%g5l)K{`E(Wn2VYhwRC{Egw4I8qD5I&mg0GKq+-6zi z`lL`Q3z<@wiVE2P)YsHwpl<;6bv2x1vY1PidTIgbRGN|DU_ABMvQXjdFDt;Zuh)>K zNQ)drE?eLf9l=aWev?@ljD{f_HN+!n1hWNXxcEwgMbzLe({rbyq9PSb!eErSoI}eK zubzd=?Ls(4CQan@UuC5qpLItN%l=RFNI(_^OsaIkAZUF4#0|OK4k${F>qZ0p15gYm zI86qor3I)QIVUoCB$OhT zh|n?xjsfVMvw0O(qr_Eek!YMN6x5=EmZmyv3B<8(;NQ=9eV03jgfQAEz)oq>WEv8{ z)fHqusGKDh4kIrbVH7k7q6S$KVX~T#%@&YUR9FonnMD?ifJK}7ze_p(NAESI&cleONNg=s-T_v#6JC!Sn>VbZDlLdADL_S4Wd$U&iD{N_ zG7XC;Le`3Kcs_Aa=u-&(qtQa?W96aSUShaW&0)YrD1Sj%2 zxXOL_$&HUdt{V-bnnvp<@4)3N!^bZ@ib$vv4woMVO@WI{)v#&r-Fe3!4kiVd?w$Wh zHn@2?gk%td9j$N~0IS)IUZR(qhC_6D5$WkbF5ZW2D^?}AIwW12fq5*x~RB53=iFac0iw|JTp3{&s*ichlhqPdgUpV(rmoRz% zqefkR|GZ84L=^dW0Af*r5bZ-QrdKw{Qwg{#8jue6qi5;6*!1bAh-ETJf)tQMwbugK zY)9kRaWIGm?AW-D1DZ0A5B;Gi#!TA>!>1pC*Y3WF12C8^IPj#iFl_1}_~g}R@Z_WS z{!hTsORu>ZgYBEpx_A*JEsF`Wk3n^%58iSgUVZ#NWK)E41g5ITPoILC#%8R1_f7P6 z^#R#5v}^{hno690&zms%8X#x_(uD&1RIOM<-?kO-)C@x;l7f%QF(Kf%iB6Mn_RaU6 zIs3#59;1?RRm*ex5GWs-&q5LfWYZ}~CJTg23@abI3GGWiK{$~@S~R1OieW@^IlSI- z)Q=pAWFm*nYgQs1i^5Bha3G4k4w{47$+Pj&@2_K;+Fad;Q-AYUsGuJ39=wHzji6NcK{h&&?`qsNJ zxOoM3eD*G&INm7LeIvHhz6)Cpd9_7Bqkqx zBu4IYIG(!sV$K`bt6K2Wzs}?6c;y>UEu?_u*zx%S zh=nZnJoYR&%o@slm00}p6BvwUVUUWP#NTtj{owZbvGM(PFc65KNVP<%6ub>}IQ5S& z!r`ZK8z94VqY|%Z3e*%$<9La_NT?sa@!SoV8C@_dZI)=V2o4E9w@~FXR29pjs9DD44 z3?94T0;YUz<;^(v-WRy+W91u9;*|&gi1MakIOwd)@%yWOhDh)~D!2c))JHJ|K~jG^sp?0Iq(J3O;}PwP%H=HmM02f?Z)@lhAb&L|>GgFhGPs zP+>q3pWSsa?|?)ii>$?pL?ntmr%u4|y$|4ogpwFUf9qx>LUB0U4g_OaOgeZD#?AUM zp8U;4OwU?;b-4I%uOUMUc=KtzeDCj2(KH-KUV1BcvCO;YcC26a$-gDYfA>Yi`&@D3 zpU|`JQ*}gK6cY(S}SSiP3wE#<>0GAVMvpi692IZ$>d z#LA8shpGD?&b(7k`&N8B|GC|JEr6<;I-Gmyuh72gLv()e0pwy4v(LW@g=jyVWj?G~ z@B;dJLV!`?rooXDCv#4_bLA&kyK*%g1`$Fg2YW?1esufuaMX|C&*n}|LF7`-q4^b5 zra80)Qjy>m;gxNwTGTXwsOLhFY`;dI1B)ndl7gU@q^ih4#qI+-E&u=^07*naRFc15 zg6;3UfkZlk2#hEcvKT#f48|XLI70nB9C`=awjdEo!tS;sm`G#F{zsr`#$kB*_N$P| z6(Be~xbms@QBX8&{P<1y(Iv0jDeinGEceTCE~2iKXfV(#JhQ359#;oS0*ugLHj3QINRX83^GRz2saD>1 zCE6Cgj6^zxxL`$I$za&9;h1vR5xfIpJv|7tZRLcv!)3={B8dt69Ewqg%*BhhUZ&U9 z+kLqFiT6=}gzZaS!!x&C4SQt`&i%tY_6yle7P(A{1x^js*=(A7zo<5es?G>g)5%NF zAWAZ{5~?6;OGcC^5u(QLx#2waIlFW0chKROamcagUGo8YmMny%s5tE6Un3prg3al{ z+66Bn6pTWo{I(=v%+wihmscUaV=cCASdUCF1hJ5Zt)d)9-|;M5_2c#4=F$?NMnU!l zBu`D_ia54rG0q(hysyS!NS_Vb9xQ{4NY zNfO3R+ZS%HADQlL*tBL9vY{}vTn>(M9}c_aNjMw!WOJ@C+98uZWY~3d$%|bD;?co? z@WdunfuIo$%333)PxsyQB$hLQae~2HsVhK}(7XOqJapYTaQMn`!F}_gr~=pF z(27?IIW8Ti#yDzwr5Ys)>#1>C-_1Ilqb;q`843ayk5M0THkrU}=l_@q!QDB2deN1r zYMFqZ&)-1z@(&@Xc^r7buaOCKz**+Us<&T2I-BR+Cz_2Iwbu-e^s_zNv3c!kq^bWn zpJSyQc+216YSEkgNOlZr_=JaIEIlW}4T7Oyhw%8uVpb4f`ll1+=awKSdu$bgR>b-T zuR!~UFCm#MAZxN99Z#WocrEt%(N7Q<>_fb_2f-a%P)KKCFLPrs9LJc0jz;6OgE9ZN zS0PUYtd+Gm=YhAN3PucW_zaI+b2hAHeq8v-yHJWEhwikBN!TPz>K`MMLgs{&z|z;3 z2QLe?qyREK6zCKRIi#Xt-2BtScSrUd7@c$3b*O9{i_TA9Mfb7~A!-T^IR6Hu`#a$D z`LTAv%SdJNP$|o2k}-Pk{kS7P-Ms^w)~rNoFu)3Mc)d92#=pZod>Y$)imPb#bN%d) z@745baoQKraB#iw$Oa`Q34)W-hb;DgSv6#9=?y3-TXWyVX#en4Br`>1BqK73G%H~G zkv~Bw*pFCW7ed>&LdoXgpkW%3D8|nIG3uuujMr|toCRvDs>9j$y$MZ_5!|vA4_|R6 zEM-1i^w>fun#4AoObI1E_&fiuLZj7BCttb{{+Xi{Hju0wR#rBRSfCfbIcxV6U@;hQ z_7%T|zhM-*K6(k=OW)^i`B~>(kMuw%oL(y;m%aIhIgL{3ku-N(hb)%FB556z_^^?9HhsR%ClK)QJK)M^s zSJK-^C}QpX7o+3j*O5pSkd@6y#gk|mUWaK%o`B$B4-$i&2(`ANm?s7J&=-kd!okO( zdeQ-SV%t~Xq07&J+3m%JtN>#PH?NB&+VJQxC8ZtEJ(aVIVLEb z4I!c-*R**4vyYJiKDK-O`wb~z=&XmPOX)U@f-(hb@4pb8AJ0c3tsrkOBNjXkFZm5zb;Ho{?o;Sr_92_wz0SGb;-I|EfKb79-N+C0=Tcl6t&-UQ|26pP=7y?fi8sF^c_G7=nMog zWzJkwPMnF?Z@ZMYu(`4kXWsh;6j4TI$4dO|m#4D=e*VY@P(%|W94?Dwb2xNHUqUm2 zrXw|Sz`UAN&axGXy7|ecQ@Hy0eUM7*y5ybeK`YCgIN|bJ;i?;swznR`;L4An5gB*J z70}Wl9&NDs{a2C88xT(?VKRuAaqs~!njF}+Y6-%w%U`uk`Hyv>1F zLXf$eytcxQ#&HvoiKUQAgpmpcdDWV%PDJu~G*8(FQw~21FVPNAa*+IWIQM~f0Z~G{ za}6H6=qIo^%W&}%A0sEw4jAG>^pzknX4b(QUkdkw(240m7uBz#s6r{wJfH-Z&|iAw zgxx|w_2qs@s)AFl`xETdjc9%2VGJyP4=5;@dd6iKXkCMRIEqN;M#Ks-cl%gm0i#9@ zgJzI1uys2xlq}7-P;;<2JedBA8_~4?2_-W_D2-Zp1$OMZFV@*nlIbrGpHeQgOr2Rb z_m;;x8P>73$imWSNDT%-MzAMwA2>MTlofHF*TazHF3PV?l4+yg6^C@&~UW>CSBeZ!1ikUQ~9CH@(u^{YL1J*5lA9>Y`R3-(B1dM2DhS_OD-=VmpW}39nRX`|e3h?($xTURib8~_-MDY9ftHqh>%!K&AP52> zo!%7-fJiT-Tn0iWiflRoMUy%EmFd|6DI7vd6_62(Q1dj!N`Td5hhQ_IkV-+xW|3Dk zoN9;=W zypvA3>^E5P+(XFc6FBA8zro_G#fB$-kHIyI82}u5;u+YmbTNW~0XR)E(y$_yioq(1 zm@s2+tY5km#$pDUtcGMJ4P$|(J(@7`z(cWO@u#rZ9Z*dUu9hATj^1?Br3$q_q6 z$X)AJy!e+h4xh^k_~nc5J=QRK!kIa$?SYKMhi|~4#PP|SFF+JzOx$C<2sRl6j+fcmEk5zWK^76|l!1dtlP!saUh{4Px%_qw_9;z08NzZ#;{5M?2T; zjGsCUy{%heqynr&2nE54a6Af|Y{aDLdt%GVl~C0JQmG>1nJmmyNoX-*-0Xv}=Ce;> za(bYc?5L%cHP=QP#k1w`#VacXl zH+qk$@u1P~f{FTp4M3T@3^o%9OW@r=4J0(JE7{eFsA}LU?=p`annpY^;Tb|-3Icrt zaCtp22u5V{8IHoaO;-T7@=|H0%jHLwijxg0j5a^&C+!RGnDOwB9zS?goxkGC&>w%> z*uF+;UpM}h8!m>xgY~iy6dHt05@ot;X8=LxN`9Q8P#u*FQ8WTi#$rWL`c2b=@d}#- zA*BFWGICOz2I!~^Xt6TX6lEQ{#glQEXylFA%mEnV z3l)f_GI)0B)?M}+*i8mZsrQ5GZ4I)(L!xLA7)m>j3X+M^AlBAiC}JR;<7=b_6hiV$ zQt0xT>A=J)j!B3&<~ADO%qb#)P&tP%|^$;e9<9DmE*=#6D=zy9Q7uKOni9Dn8Y zb5B42+~+%9`a52||BrB+3`h}wFY4(toz)-(>w;%KgR3S;_&RZ+se0r}f^m7=JTWDi zj55~N3vF`_?A1zmRdoK{%P61sb7QlW0Tn8GE6t1A1Y zqEv{zB(Ob>5euAb1NRYDj= zsIXZb++j$~7&KFd1V%v-mfb8(XRn-88Q9*ax4pdiHz+y6# z5-a+ECNgbo=jn4)4J)_zV!@i$T{_-6w;iJ!>S426pd>;N^I4e5XBlKrn-bYH5+qG? z;T2abqM#LEpvYAaF&GMPQFq>8n zjg;4SLo$}BKQ287dTL<_*a2hM@j?uM3+ zV##NVxf{i37P$qskjwL|HtN`+oJ?K=V(9|j-`umy#G_RfQSY{(wyG9Yl{N6%4OqS8 zLs$$lvz(kJB%aAc*Gi^F)GIj1L>7zOaFU_1HnOD4fK3aTBT@*(0VVsugqjM2q>p8z z3@Xi@$P^XWy?)g0xi=Dyx}X2;&YSQ5N(D5JY94Xm@Tx6Js29a#8hNNZtd8OX%7Ss$ zj134S8z`R^2L&J*k{mlbzakH5MVY@mq`k~+6FkP zN3pYS-?AFjRh6(itWb&>&ONgWV$&ya`%f&D#v|{o;GOtQ2X(<8IAJ7;`2s?LAV!Sb z19r0!b|Hbz^&5D8G)bS$EG7Tw%t{F#J(mlP#&fE|07D{wL_%kitdC08&7!VF-DK-K zoD@JvO)wf^v^r3bTqA#(H+PP( zoumSz(s$i9tL)75JdW-w=w=L5>P}{ehT@TJq^o4UiA)I%{h(E6wV*GY!i4<}Lb4#j zX{M@%?HD(H6sQz{6i*nR%niLZo=R~8#FHPa;EVfa$SLB8y(e(lJeii>z5vqMB1VlE z2fs^3=bFV_ghdXMh0m5&=DrBp0hF1edRIb5MM2`e0U8=ei%J&&`HqrBAV*A#Pn20c z)Wj{QB09UfpcsrOP|<_g_00Umt55mzIsZQejB2VrWI~nWUF1@nl_Hxta`YIU60&;L zDvo|>Fp+)|y6K@z6ya-K0Xj=bwKXQOWbgTJ`Ibw-#rW$`#T$pSU`OH8N*iE^NoE{SaOX+cpF9!=6^D9u(t zWgjx+Si1B0e+r<#9JANRK?CxB=0VwH=q83v zl2dJ8%1Y@)Mk1G;Q!0^uCc!EKxB!*f`z=-~?KbFCb193(rYw1<0QngDzZu* z^WS<0cmMH^@RfVn_R{<|p7o#2aR>g-Zn&0N$!|Ky1e|sBF=(i*h1G0fvX5Gd1|l)s z_`Ba@;>c0hd&+cJoi^0la`@=om-J+ho?vFWfG&}CAMyWrItS6Lv%{ryqHcin+?JkA zBw6WaC5r}*lQ?rh6Bz|jhs{3ys;l0k7ZUDfY)e~^QIh@4q0=-5=29j3t3jkKk|2uZ9c^86cg+K#PZC0#Zv=UBxC5E+(!H&Ux zy#4V|@=y$VZ0*Jkxa+Y;(AC$wOW`-w*5cSh4&#+>F@V~$K zJU(5qd*dsoO_+$8Q}^Owg4A+KriX@&P#i(`eY4P=mUu^)xm=vcvn9)yW5@RGXx*|E zXPSbt~u_6+kk7vSTnRL2%G=Cs?Yl{ZfSj!-XNilZy0cb*KQ9ofiGQn1wXZk633H z*1q)`27*0E7X^&<_)%9;iT*$jwnXA+sjWv(D2SKeeVb9wt`57!in(*&H7yIuu1s!1(*{ZQ9$`Xtw z5w5B-VjT7SG?`U4t$MUig3x;>XmOH{Bn+iHal(5%rJ|G9G~ zUi`=VSCKu-km!pF5%_S`R+4uPwX{e0?#3(d3Gdx8!4dlv&BfnpBV;| zL7#k1ciRwYwx@m*IJ&RFWAmQDi?7Yc{*x!;!ZS|77pph3!{#oQd;xu(+cD|1bKssd z6-806#vsA&JQx+}zYm#bTJ#oPDVfA%#L5pwUg zrXi~ox#NTI4+)-c_9eKf*=IH5jMGlX zk54~|tFL$GpdlH@9(pL>8IQPp${U-FCbsqDba}tDoek&qvm z_cZ3cJ|Aw26~DXgYApPG6;lVa)Ts(Cv}q-dJnup@AAd4CR2_2u!|LwrcHx~d^miSg z-u3IN4|?&z2X}cJtld%o3Ffg?n^%?j{1bIUucMm+IoVPGLQE)aL&gZDYHLCR(Oe!q zPdwCoLBt7mjoOr}hY-%VFBJ;uu zFc@Gq=xB$KkBROJBgqhDiGwc^^w7f4IW~<42GVioy?@2#wlzCFTMgz#~!a=^H9LWOMVDKmae;W?WBm$7rgVAYfeAmvfWuwyR85!Qkd5j z7$_E1H$hP9_~sTgiWUVCCQ4=s;7SVO^@*d&78S$`Mf5%W5C+z)fXm_F5yhLkdT`QD zPDN8wGvB}Kua7*4kCuG)|IU}3YI9vZX6-eN?K;;+@U5z3jz8!-1#9#?GBf(hmvwVf zx?vnc5JmE5@3p4kc zj>+TqC|xF%p%bE_RZXj&gk0JMtOQ*VWQK->PgDu5W~K{x;3}TZt$ z`&J5|6P|YT(PPfM>u>8s6?9`+JuoRz)wKGlRD+zffl5pS3H`|&dR}@8Yud9xoSzgW!8Bf0aJeI9qg~{W_W7@=tI{lnic(#yUh()xnZp(=hCgC!!ClkY_ zMpp>=1Fq~S*?K)fr2$bA?+!v%1Q2A|07257F)bW=|6pJcOO~xhGMhtD6i~=y5ZbsL zr~m10SoWC0rj-Ly;$tZnQKA}j^Tr@Oe$9xECrp|)Yu0k| zHbeJanbJHAT7?gWVZ)i!qKY=!83Tz7+MaqCAHDDt%A5{3Y<5(Xxv>AN+3;7_Ft5Xv z&4mJwJEIbIDm5HRX0QNB0bjD|yoy=)y5Q+|R>qK0SXuwzW^@x&a=;}LO;>=(K7)pG zrxOXRUA>0Wh=b`QPr3@NU5?Z5dJtmEsL~D)7+f%=s1?z*_)|__rc%lD6Mwn0sie0DAM`{+fvsM~_$`$|4;&Y~NYvvfJT5{uEA465pdq8saGhp|@VbdyhYi zN}mgZ!4MiNEAiu_jzMipi+=xKnO3~s2S8u6@`w7t=(A{j(W)n56H3v|iyr>-4rDW< zn;gzYl{#kFN^=cq37t{lREeP_{+SV5$w$y_4Hv!jHWPR4gCTsld>M?fAg+DpWuT@W z(5O5fpnf6>DnkSPc<$WOpeUL6`rd)Ma5(&}gp;0UHM7`Fjknzw+9*Cf5J}1^P)uK~xgzDt&z4MvvW& zW9Iw_jiW|l`}Xaqs;+`UDZql>1ICRRl+7sh1F!;kr4!2HP>5rUQu_m&Trw*pc!Hlg z^wiH+7ry=)+A%{gl%6o;oayg`-><#px7;EXPp0{9ejbB_-`xK=gpngq5L6iHd{iJ+ zETXNw1MgpdY45_%7Y_&Ut<27sCHZgffT1^WI2_*b4Go)5I_xN4uiFjR$v;JoIdTE^ zyawOra6E(B@(P@M>~UxwK8&Yv>8rHVdc;)~^fNVR3^ixy{Q;bh zBl3%%L%U?C8iQ`&OsuT#aCL#}P?I?V9yHWsu&X0a_%2j*Z z_=h{GCTR4kk5L!sMMRO%kx1dM4?V>9?4p%Srr_{{55_(d$8+jZxAUcJSt*IZJEGL} z$m!=&z&F&k#DvID#J96XXQ-o-6i_lZB}1$8HznSRoif!X>B8q7C`$O{uWsV`rg??N z%&71=tho0tf945b+ZMhDt!bF@*tV^QEqmnNT}HUO68zBLzf%R!n>Ngx*>w5ESARC5 zZF^-#G9XA5a+w?+p7#_Z9?Fc_Ep{9#pxrtD({U=(3%?;P*l)7U`5hZ&= zir^|bUiEbP9E6l^CQ(WbF%;FEHOqf!;z-FnaUfZ0@}NEl-YuexoB#YLL}E$3KSEPs zl{H*+?s*tBY7`A0|Kh^yubI6oZfAD||6fx;>5YXuF1h5!#^K|CRVWk${mvSiezzhD zmgu&HoV3v6NmbEj%yB$X$~fur)*sh9l=L0JO?%wFRO042a4MOR5+B2r2}DPfRz2Bx z&RY*f$fO`9(e(Bc+L3x1QdczH7l$|{x{vXO4IA>GE?<7i%M0dzEv5Wzh5ns7;cxoz z+S*#r&6i#Ks;|tmpUGIH?1)R%a3pvNnuc}&*YcEd z(_Eu7R2EWVD9NPgw^-33Dt|8tekV0iDs3bM4^bFQHPB5(vsta9r~A>LU4H2WUzbez zPXzzp-U0t~Zj;7M8hztcSG`c?c8}$YFRfnYR7$p8U*Ytnr}2tvVW(0^@Dvwtxk!l+ z(<=k?FP#$5oiV3MS>U?RY3I?XNcx%PVGKpeJ85KkZV3quDW;>V>#eJ9zva}mGAC95Wcz@Rx2{Xr2Pue$FI)+5WS#^na*IjeWtF8lOuA ztE?!F27d+6dXO0!j&R-Q<@N+(R$k3Ho)%tosk$hVx*yQq((y`59S}Zz!1_ z`kffip-6bBF}9Iew0{%%~ZSH z#7UE7NgO+-Wq6$i%@2TvUmHrVp;n(H1PrX%xVb|Tq;(&B{@KbvUu0uze=FTucDMQY fT}tdbe!~9&ZRyor+0M!j00000NkvXXu0mjfA#`jm diff --git a/iqpilot/tools/cabana/binaryview.cc b/iqpilot/tools/cabana/binaryview.cc deleted file mode 100644 index 5e919dc6a..000000000 --- a/iqpilot/tools/cabana/binaryview.cc +++ /dev/null @@ -1,510 +0,0 @@ -#include "tools/cabana/binaryview.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include - -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" - -// BinaryView - -const int CELL_HEIGHT = 36; -const int VERTICAL_HEADER_WIDTH = 30; -inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); } - -BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { - model = new BinaryViewModel(this); - setModel(model); - delegate = new BinaryItemDelegate(this); - setItemDelegate(delegate); - horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); - verticalHeader()->setSectionsClickable(false); - verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - verticalHeader()->setDefaultSectionSize(CELL_HEIGHT); - horizontalHeader()->hide(); - setShowGrid(false); - setMouseTracking(true); - setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); - - addShortcuts(); - setWhatsThis(R"( - Binary View
- - Shortcuts
- Delete Signal: -  x , -  Backspace , -  Delete 
- Change endianness:  e 
- Change signedness:  s 
- Open chart: -  c , -  p , -  g  - )"); -} - -void BinaryView::addShortcuts() { - // Delete (x, backspace, delete) - QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this); - QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this); - QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this); - QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); - QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); - hovered_sig = nullptr; - } - }); - - // Change endianness (e) - QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this); - QObject::connect(shortcut_endian, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_little_endian = !s.is_little_endian; - emit editSignal(hovered_sig, s); - } - }); - - // Change signedness (s) - QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this); - QObject::connect(shortcut_sign, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - cabana::Signal s = *hovered_sig; - s.is_signed = !s.is_signed; - emit editSignal(hovered_sig, s); - } - }); - - // Open chart (c, p, g) - QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this); - QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this); - QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this); - QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated); - QObject::connect(shortcut_plot, &QShortcut::activated, [=]{ - if (hovered_sig != nullptr) { - emit showChart(model->msg_id, hovered_sig, true, false); - } - }); -} - -QSize BinaryView::minimumSizeHint() const { - return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, - CELL_HEIGHT * std::min(model->rowCount(), 10) + 2}; -} - -void BinaryView::highlight(const cabana::Signal *sig) { - if (sig != hovered_sig) { - for (int i = 0; i < model->items.size(); ++i) { - auto &item_sigs = model->items[i].sigs; - auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); }; - if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) { - auto index = model->index(i / model->columnCount(), i % model->columnCount()); - emit model->dataChanged(index, index, {Qt::DisplayRole}); - } - } - - hovered_sig = sig; - emit signalHovered(hovered_sig); - } -} - -void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(last_mouse_pos); - if (!anchor_index.isValid() || !index.isValid()) - return; - - QItemSelection selection; - auto [start, size, is_lb] = getSelection(index); - for (int i = 0; i < size; ++i) { - int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; - selection << QItemSelectionRange{model->index(pos / 8, pos % 8)}; - } - selectionModel()->select(selection, flags); -} - -void BinaryView::mousePressEvent(QMouseEvent *event) { - resize_sig = nullptr; - if (auto index = indexAt(last_mouse_pos = event->pos()); index.isValid() && index.column() != 8) { - anchor_index = index; - auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); - int bit_pos = get_bit_pos(anchor_index); - for (auto s : item->sigs) { - if (bit_pos == s->lsb || bit_pos == s->msb) { - int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); - anchor_index = model->index(idx / 8, idx % 8); - resize_sig = s; - break; - } - } - } - event->accept(); -} - -void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(pos); index.isValid()) { - auto item = (BinaryViewModel::Item *)index.internalPointer(); - const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); - highlight(sig); - } -} - -void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(last_mouse_pos = event->pos()); - QTableView::mouseMoveEvent(event); -} - -void BinaryView::mouseReleaseEvent(QMouseEvent *event) { - QTableView::mouseReleaseEvent(event); - - auto release_index = indexAt(event->pos()); - if (release_index.isValid() && anchor_index.isValid()) { - if (selectionModel()->hasSelection()) { - auto sig = resize_sig ? *resize_sig : cabana::Signal{}; - std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); - resize_sig ? emit editSignal(resize_sig, sig) - : UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig)); - } else { - auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); - if (item && item->sigs.size() > 0) - emit signalClicked(item->sigs.back()); - } - } - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; -} - -void BinaryView::leaveEvent(QEvent *event) { - highlight(nullptr); - QTableView::leaveEvent(event); -} - -void BinaryView::setMessage(const MessageId &message_id) { - model->msg_id = message_id; - verticalScrollBar()->setValue(0); - refresh(); -} - -void BinaryView::refresh() { - clearSelection(); - anchor_index = QModelIndex(); - resize_sig = nullptr; - hovered_sig = nullptr; - model->refresh(); - if (underMouse()) highlightPosition(last_mouse_pos); -} - -std::set BinaryView::getOverlappingSignals() const { - std::set overlapping; - for (const auto &item : model->items) { - if (item.sigs.size() > 1) { - for (auto s : item.sigs) { - if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); - } - } - } - return overlapping; -} - -std::tuple BinaryView::getSelection(QModelIndex index) { - if (index.column() == 8) { - index = model->index(index.row(), 7); - } - bool is_lb = true; - if (resize_sig) { - is_lb = resize_sig->is_little_endian; - } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { - is_lb = index < anchor_index; - } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { - is_lb = !(index < anchor_index); - } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { - is_lb = true; - } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { - is_lb = false; - } - - int cur_bit_pos = get_bit_pos(index); - int anchor_bit_pos = get_bit_pos(anchor_index); - int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index)); - int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; - return {start_bit, size, is_lb}; -} - -// BinaryViewModel - -void BinaryViewModel::refresh() { - beginResetModel(); - bit_flip_tracker = {}; - items.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - row_count = dbc_msg->size; - items.resize(row_count * column_count); - for (auto sig : dbc_msg->getSignals()) { - for (int j = 0; j < sig->size; ++j) { - int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; - int idx = column_count * (pos / 8) + pos % 8; - if (idx >= items.size()) { - fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", - sig->name.c_str(), sig->start_bit, sig->size); - break; - } - if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true; - if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true; - - auto &sigs = items[idx].sigs; - sigs.push_back(sig); - if (sigs.size() > 1) { - std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); - } - } - } - } else { - row_count = can->lastMessage(msg_id).dat.size(); - items.resize(row_count * column_count); - } - endResetModel(); - updateState(); -} - -void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) { - auto &item = items[row * column_count + col]; - item.valid = true; - if (item.val != val || item.bg_color != color) { - item.val = val; - item.bg_color = color; - auto idx = index(row, col); - emit dataChanged(idx, idx, {Qt::DisplayRole}); - } -} - -void BinaryViewModel::updateState() { - const auto &last_msg = can->lastMessage(msg_id); - const auto &binary = last_msg.dat; - // Handle size changes in binary data - if (binary.size() > row_count) { - beginInsertRows({}, row_count, binary.size() - 1); - row_count = binary.size(); - items.resize(row_count * column_count); - endInsertRows(); - } - - auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size()); - // Find the maximum bit flip count across the message - uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero - for (const auto &row : bit_flips) { - for (uint32_t count : row) { - max_bit_flip_count = std::max(max_bit_flip_count, count); - } - } - - const double max_alpha = 255.0; - const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts - const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits - const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling - const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); - - for (size_t i = 0; i < binary.size(); ++i) { - for (int j = 0; j < 8; ++j) { - auto &item = items[i * column_count + j]; - int bit_val = (binary[i] >> (7 - j)) & 1; - - double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; - uint32_t flip_count = bit_flips[i][j]; - if (flip_count > 0) { - double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; - double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; - alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); - } - - auto color = item.bg_color; - color.setAlpha(alpha); - updateItem(i, j, bit_val, color); - } - updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); - } -} - -const std::vector> &BinaryViewModel::getBitFlipChanges(size_t msg_size) { - // Return cached results if time range and data are unchanged - auto time_range = can->timeRange(); - if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty()) - return bit_flip_tracker.flip_counts; - - bit_flip_tracker.time_range = time_range; - bit_flip_tracker.flip_counts.assign(msg_size, std::array{}); - - // Iterate over events within the specified time range and calculate bit flips - auto [first, last] = can->eventsInRange(msg_id, time_range); - if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts; - - std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); - for (auto it = std::next(first); it != last; ++it) { - const CanEvent *event = *it; - int size = std::min(msg_size, event->size); - for (int i = 0; i < size; ++i) { - const uint8_t diff = event->dat[i] ^ prev_values[i]; - if (!diff) continue; - - auto &bit_flips = bit_flip_tracker.flip_counts[i]; - for (int bit = 0; bit < 8; ++bit) { - if (diff & (1u << bit)) ++bit_flips[7 - bit]; - } - prev_values[i] = event->dat[i]; - } - } - - return bit_flip_tracker.flip_counts; -} - -QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Vertical) { - switch (role) { - case Qt::DisplayRole: return section; - case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0); - case Qt::TextAlignmentRole: return Qt::AlignCenter; - } - } - return {}; -} - -QVariant BinaryViewModel::data(const QModelIndex &index, int role) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - return role == Qt::ToolTipRole && item && !item->sigs.empty() ? signalToolTip(item->sigs.back()) : QVariant(); -} - -// BinaryItemDelegate - -BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - small_font.setPixelSize(8); - hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - hex_font.setBold(true); - - bin_text_table[0].setText("0"); - bin_text_table[1].setText("1"); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, hex_font); - } -} - -bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const { - if (!index.isValid()) return false; - auto model = (const BinaryViewModel*)(index.model()); - int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - if (idx < 0 || idx >= (int)model->items.size()) return false; - auto &s = model->items[idx].sigs; - return std::find(s.begin(), s.end(), sig) != s.end(); -} - -void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - BinaryView *bin_view = (BinaryView *)parent(); - painter->save(); - - if (index.column() == 8) { - if (item->valid) { - painter->setFont(hex_font); - painter->fillRect(option.rect, item->bg_color); - } - } else if (option.state & QStyle::State_Selected) { - auto color = bin_view->resize_sig ? toQColor(bin_view->resize_sig->color) : option.palette.color(QPalette::Active, QPalette::Highlight); - painter->fillRect(option.rect, color); - painter->setPen(option.palette.color(QPalette::BrightText)); - } else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing - if (item->sigs.size() > 0) { - for (auto &s : item->sigs) { - if (s == bin_view->hovered_sig) { - painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness - } else { - drawSignalCell(painter, option, index, s); - } - } - } else if (item->valid && item->bg_color.alpha() > 0) { - painter->fillRect(option.rect, item->bg_color); - } - auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text; - painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role)); - } - - if (item->sigs.size() > 1) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern)); - } else if (!item->valid) { - painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern)); - } - if (item->valid) { - utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]); - } - if (item->is_msb || item->is_lsb) { - painter->setFont(small_font); - painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L"); - } - painter->restore(); -} - -// Draw border on edge of signal -void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option, - const QModelIndex &index, const cabana::Signal *sig) const { - bool draw_left = !hasSignal(index, -1, 0, sig); - bool draw_top = !hasSignal(index, 0, -1, sig); - bool draw_right = !hasSignal(index, 1, 0, sig); - bool draw_bottom = !hasSignal(index, 0, 1, sig); - - const int spacing = 2; - QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing); - QRegion subtract; - if (!draw_top) { - if (!draw_left && !hasSignal(index, -1, -1, sig)) { - subtract += QRect{rc.left(), rc.top(), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { - subtract += QRect{rc.right() - 2, rc.top(), 3, spacing}; - } - } - if (!draw_bottom) { - if (!draw_left && !hasSignal(index, -1, 1, sig)) { - subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing}; - } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { - subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing}; - } - } - painter->setClipRegion(QRegion(rc).subtracted(subtract)); - - auto item = (const BinaryViewModel::Item *)index.internalPointer(); - QColor color = toQColor(sig->color); - color.setAlpha(item->bg_color.alpha()); - // Mixing the signal color with the Base background color to fade it - painter->fillRect(rc, option.palette.color(QPalette::Base)); - painter->fillRect(rc, color); - - // Draw edges - color = toQColor(sig->color.darker(125)); - painter->setPen(QPen(color, 1)); - if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft()); - if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight()); - if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight()); - if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight()); - - if (!subtract.isEmpty()) { - // fill gaps inside corners. - painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin)); - for (auto &r : subtract) { - painter->drawRect(r); - } - } -} diff --git a/iqpilot/tools/cabana/binaryview.h b/iqpilot/tools/cabana/binaryview.h deleted file mode 100644 index c49067a1a..000000000 --- a/iqpilot/tools/cabana/binaryview.h +++ /dev/null @@ -1,104 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -class BinaryItemDelegate : public QStyledItemDelegate { -public: - BinaryItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const; - void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const; - - QFont small_font, hex_font; - std::array hex_text_table; - std::array bin_text_table; -}; - -class BinaryViewModel : public QAbstractTableModel { -public: - BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {} - void refresh(); - void updateState(); - void updateItem(int row, int col, uint8_t val, const QColor &color); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; } - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override { - return createIndex(row, column, (void *)&items[row * column_count + column]); - } - Qt::ItemFlags flags(const QModelIndex &index) const override { - return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable; - } - const std::vector> &getBitFlipChanges(size_t msg_size); - - struct BitFlipTracker { - std::optional> time_range; - std::vector> flip_counts; - } bit_flip_tracker; - - struct Item { - QColor bg_color = QColor(102, 86, 169, 255); - bool is_msb = false; - bool is_lsb = false; - uint8_t val; - std::vector sigs; - bool valid = false; - }; - std::vector items; - bool heatmap_live_mode = true; - MessageId msg_id; - int row_count = 0; - const int column_count = 9; -}; - -class BinaryView : public QTableView { - Q_OBJECT - -public: - BinaryView(QWidget *parent = nullptr); - void setMessage(const MessageId &message_id); - void highlight(const cabana::Signal *sig); - std::set getOverlappingSignals() const; - void updateState() { model->updateState(); } - void paintEvent(QPaintEvent *event) override { - is_message_active = can->isMessageActive(model->msg_id); - QTableView::paintEvent(event); - } - QSize minimumSizeHint() const override; - void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); } - -signals: - void signalClicked(const cabana::Signal *sig); - void signalHovered(const cabana::Signal *sig); - void editSignal(const cabana::Signal *origin_s, cabana::Signal &s); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void addShortcuts(); - void refresh(); - std::tuple getSelection(QModelIndex index); - void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void leaveEvent(QEvent *event) override; - void highlightPosition(const QPoint &pt); - - QModelIndex anchor_index; - QPoint last_mouse_pos{-1, -1}; - BinaryViewModel *model; - BinaryItemDelegate *delegate; - bool is_message_active = false; - const cabana::Signal *resize_sig = nullptr; - const cabana::Signal *hovered_sig = nullptr; - friend class BinaryItemDelegate; -}; diff --git a/iqpilot/tools/cabana/cabana b/iqpilot/tools/cabana/cabana index ad83e9724..837f5229b 100755 --- a/iqpilot/tools/cabana/cabana +++ b/iqpilot/tools/cabana/cabana @@ -1,38 +1,9 @@ #!/usr/bin/env bash -set -e +set -euo pipefail -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -ROOT="$(cd "$DIR/../../../" && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -install_qt() { - if [[ "$(uname)" == "Darwin" ]]; then - brew install qt@5 - brew link qt@5 || true - else - SUDO="" - if [[ ! $(id -u) -eq 0 ]]; then - SUDO="sudo" - fi - $SUDO apt-get install -y --no-install-recommends \ - qtbase5-dev \ - qtbase5-dev-tools \ - qttools5-dev-tools \ - libqt5charts5-dev \ - libqt5svg5-dev \ - libqt5serialbus5-dev \ - libqt5x11extras5-dev \ - libqt5opengl5-dev - fi -} - -# Install Qt if not found -if ! command -v qmake &> /dev/null; then - echo "Qt not found, installing dependencies..." - install_qt -fi - -# Build _cabana -cd "$ROOT" -scons -u iqpilot/tools/cabana/_cabana iqpilot/cereal/messaging/bridge - -exec "$DIR/_cabana" "$@" +cd "$REPO_ROOT" +uv run --extra tools scons -u iqpilot/tools/cabana/_cabana iqpilot/cereal/messaging/bridge +exec "$SCRIPT_DIR/_cabana" "$@" diff --git a/iqpilot/tools/cabana/cameraview.cc b/iqpilot/tools/cabana/cameraview.cc deleted file mode 100644 index a8df373dc..000000000 --- a/iqpilot/tools/cabana/cameraview.cc +++ /dev/null @@ -1,125 +0,0 @@ -#include "tools/cabana/cameraview.h" - -#include -#include -#include -#include - -#include -#include - -#include "common/yuv.h" - -CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { - setAttribute(Qt::WA_OpaquePaintEvent); - qRegisterMetaType>("availableStreams"); - QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection); - QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection); - QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); -} - -CameraWidget::~CameraWidget() { - stopVipcThread(); -} - -void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread.joinable()) { - clearFrames(); - vipc_exit = false; - vipc_thread = std::thread(&CameraWidget::vipcThread, this); - } -} - -void CameraWidget::stopVipcThread() { - vipc_exit = true; - if (vipc_thread.joinable()) { - vipc_thread.join(); - } -} - -void CameraWidget::availableStreamsUpdated(std::set streams) { - available_streams = streams; -} - -void CameraWidget::paintEvent(QPaintEvent *event) { - QPainter p(this); - p.fillRect(rect(), bg); - - std::lock_guard lk(frame_lock); - if (rgb_frame.isNull()) return; - - // Scale for aspect ratio - float widget_ratio = (float)width() / height(); - float frame_ratio = (float)rgb_frame.width() / rgb_frame.height(); - int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); - int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); - QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h); - - p.setRenderHint(QPainter::SmoothPixmapTransform); - if (active_stream_type == VISION_STREAM_DRIVER) { - // mirror driver camera horizontally - const qreal cx = video_rect.x() + video_rect.width() / 2.0; - p.translate(cx, 0); - p.scale(-1, 1); - p.translate(-cx, 0); - } - p.drawImage(video_rect, rgb_frame); -} - -void CameraWidget::vipcFrameReceived() { - update(); -} - -void CameraWidget::vipcThread() { - VisionStreamType cur_stream = requested_stream_type; - std::unique_ptr vipc_client; - VisionIpcBufExtra frame_meta = {}; - - while (!vipc_exit) { - if (!vipc_client || cur_stream != requested_stream_type) { - clearFrames(); - fprintf(stderr, "connecting to stream %d, was connected to %d\n", - (int)requested_stream_type, (int)cur_stream); - cur_stream = requested_stream_type; - vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false)); - } - active_stream_type = cur_stream; - - if (!vipc_client->connected) { - clearFrames(); - auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); - if (streams.empty()) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - emit vipcAvailableStreamsUpdated(streams); - - if (!vipc_client->connect(false)) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - continue; - } - } - - if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { - // NV12 -> RGBA once per frame on the receive thread; paint just draws the image - if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) { - rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888); - } - yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, - rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height); - { - std::lock_guard lk(frame_lock); - rgb_frame.swap(rgb_back); - } - emit vipcThreadFrameReceived(); - } - } -} - -void CameraWidget::clearFrames() { - std::lock_guard lk(frame_lock); - rgb_frame = QImage(); - rgb_back = QImage(); - available_streams.clear(); -} diff --git a/iqpilot/tools/cabana/cameraview.h b/iqpilot/tools/cabana/cameraview.h deleted file mode 100644 index d11df554b..000000000 --- a/iqpilot/tools/cabana/cameraview.h +++ /dev/null @@ -1,56 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "cereal/visionstream.h" -#include "msgq/visionipc/visionipc_client.h" - -class CameraWidget : public QWidget { - Q_OBJECT - -public: - explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); - ~CameraWidget(); - void setStreamType(VisionStreamType type) { requested_stream_type = type; } - VisionStreamType getStreamType() { return active_stream_type; } - void stopVipcThread(); - -signals: - void clicked(); - void vipcThreadFrameReceived(); - void vipcAvailableStreamsUpdated(std::set); - -protected: - void paintEvent(QPaintEvent *event) override; - void showEvent(QShowEvent *event) override; - void hideEvent(QHideEvent *event) override { stopVipcThread(); } - void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); } - void vipcThread(); - void clearFrames(); - - QColor bg = Qt::black; - QImage rgb_frame; // written by vipc thread, drawn by GUI thread; guarded by frame_lock - QImage rgb_back; // vipc thread only - - std::string stream_name; - std::atomic active_stream_type; - std::atomic requested_stream_type; - std::set available_streams; - std::thread vipc_thread; - std::atomic vipc_exit = false; - std::mutex frame_lock; - -protected slots: - void vipcFrameReceived(); - void availableStreamsUpdated(std::set streams); -}; - -Q_DECLARE_METATYPE(std::set); diff --git a/iqpilot/tools/cabana/chart/chart.cc b/iqpilot/tools/cabana/chart/chart.cc deleted file mode 100644 index 8496c2699..000000000 --- a/iqpilot/tools/cabana/chart/chart.cc +++ /dev/null @@ -1,770 +0,0 @@ -#include "tools/cabana/chart/chart.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" - -const int AXIS_X_TOP_MARGIN = 4; -const int X_TICK_COUNT = 5; -const double MIN_ZOOM_SECONDS = 0.01; // 10ms -// Define a small value of epsilon to compare double values -const float EPSILON = 0.000001; -static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); } - -static QMargins layoutMargins(const QStyle *style) { - return { - style->pixelMetric(QStyle::PM_LayoutLeftMargin), - style->pixelMetric(QStyle::PM_LayoutTopMargin), - style->pixelMetric(QStyle::PM_LayoutRightMargin), - style->pixelMetric(QStyle::PM_LayoutBottomMargin), - }; -} - -ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) - : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { - series_type = (SeriesType)settings.chart_series_type; - align_to = 50; - setMouseTracking(true); - tip_label = new TipLabel(this); - createToolButtons(); - signal_value_font.setPointSize(9); - - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated); -} - -void ChartView::createToolButtons() { - close_btn = new ToolButton("x", tr("Remove Chart"), this); - - menu = new QMenu(this); - // series types - auto change_series_group = new QActionGroup(menu); - change_series_group->setExclusive(true); - QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QAction *act = new QAction(types[i], change_series_group); - act->setData(i); - act->setCheckable(true); - act->setChecked(i == (int)series_type); - menu->addAction(act); - } - menu->addSeparator(); - menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); - split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - - manage_btn = new ToolButton("list", "", this); - manage_btn->setMenu(menu); - manage_btn->setPopupMode(QToolButton::InstantPopup); - manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - - close_act = new QAction(tr("Close"), this); - QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered); - QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) { - setSeriesType((SeriesType)action->data().toInt()); - }); -} - -QSize ChartView::sizeHint() const { - return {CHART_MIN_WIDTH, settings.chart_height}; -} - -void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { - if (hasSignal(msg_id, sig)) return; - - sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); - updateSeries(sig); - updateTitle(); - emit charts_widget->seriesChanged(); -} - -bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { - return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); -} - -void ChartView::removeIf(std::function predicate) { - int prev_size = sigs.size(); - sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end()); - if (sigs.empty()) { - charts_widget->removeChart(this); - } else if (sigs.size() != prev_size) { - emit charts_widget->seriesChanged(); - updateAxisY(); - updateTitle(); - } -} - -void ChartView::signalUpdated(const cabana::Signal *sig) { - auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; }); - if (it != sigs.end()) { - if (it->color != toQColor(sig->color)) { - it->color = uniqueColor(toQColor(sig->color), sig); - } - updateTitle(); - updateSeries(sig); - } -} - -void ChartView::msgUpdated(MessageId id) { - if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) { - updateTitle(); - } -} - -void ChartView::manageSignals() { - SignalSelector dlg(tr("Manage Chart"), this); - for (auto &s : sigs) { - dlg.addSelected(s.msg_id, s.sig); - } - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - for (auto s : items) { - addSignal(s->msg_id, s->sig); - } - removeIf([&](auto &s) { - return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; }); - }); - } -} - -void ChartView::resizeEvent(QResizeEvent *event) { - QWidget::resizeEvent(event); - const auto margins = layoutMargins(style()); - QPixmap grip = utils::icon("grip-horizontal"); - move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio()); - close_btn->resize(close_btn->sizeHint()); - manage_btn->resize(manage_btn->sizeHint()); - close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top()); - manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top()); - updatePlotArea(align_to, true); -} - -void ChartView::updatePlotArea(int left_pos, bool force) { - if (align_to != left_pos || force) { - align_to = left_pos; - - const auto margins = layoutMargins(style()); - QFont bold_font = font(); - bold_font.setBold(true); - QFontMetrics fm(font()), bfm(bold_font); - const int marker_size = fm.height() - 4; - const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3; - const int legend_left = move_icon_rect.right() + margins.left(); - const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10); - - // layout legend entries left-to-right, wrapping between the move icon and the buttons - legend_rects.clear(); - int x = legend_left, y = margins.top(); - for (auto &s : sigs) { - int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) + - fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString())); - w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons - if (x + w > legend_right && x > legend_left) { - x = legend_left; - y += row_height; - } - legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height())); - x += w + 12; - } - - // add top space for the legend and signal values - int adjust_top = (y + row_height) - margins.top(); - adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); - // add right space for x-axis label - QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5}; - plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(), - -x_label_size.width() / 2 - margins.right(), - -x_label_size.height() - margins.bottom()); - resetChartCache(); - } -} - -void ChartView::updateTitle() { - split_chart_act->setEnabled(sigs.size() > 1); - updatePlotArea(align_to, true); -} - -void ChartView::updatePlot(double cur, double min, double max) { - cur_sec = cur; - if (min != x_min || max != x_max) { - x_min = min; - x_max = max; - updateAxisY(); - // update tooltip - if (tooltip_x >= 0) { - showTip(secondsAtPoint({tooltip_x, 0})); - } - resetChartCache(); - } - update(); -} - -void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals) { - vals.reserve(vals.size() + events.capacity()); - step_vals.reserve(step_vals.size() + events.capacity() * 2); - - double value = 0; - for (const CanEvent *e : events) { - if (sig->getValue(e->dat, e->size, &value)) { - const double ts = can->toSeconds(e->mono_time); - vals.emplace_back(ts, value); - if (!step_vals.empty()) - step_vals.emplace_back(ts, step_vals.back().y()); - step_vals.emplace_back(ts, value); - } - } -} - -void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { - for (auto &s : sigs) { - if (!sig || s.sig == sig) { - if (!msg_new_events) { - s.vals.clear(); - s.step_vals.clear(); - } - auto events = msg_new_events ? msg_new_events : &can->eventsMap(); - auto it = events->find(s.msg_id); - if (it == events->end() || it->second.empty()) continue; - - if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x()) { - appendCanEvents(s.sig, it->second, s.vals, s.step_vals); - } else { - std::vector vals, step_vals; - appendCanEvents(s.sig, it->second, vals, step_vals); - s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan), - vals.begin(), vals.end()); - s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan), - step_vals.begin(), step_vals.end()); - } - - if (!can->liveStreaming()) { - s.segment_tree.build(s.vals); - } - } - } - updateAxisY(); - // invoke resetChartCache in ui thread - QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection); -} - -// auto zoom on yaxis -void ChartView::updateAxisY() { - if (sigs.empty()) return; - - double min = std::numeric_limits::max(); - double max = std::numeric_limits::lowest(); - QString unit = QString::fromStdString(sigs[0].sig->unit); - - for (auto &s : sigs) { - if (!s.visible) continue; - - // Only show unit when all signals have the same unit - if (unit != QString::fromStdString(s.sig->unit)) { - unit.clear(); - } - - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); - s.min = std::numeric_limits::max(); - s.max = std::numeric_limits::lowest(); - if (can->liveStreaming()) { - for (auto it = first; it != last; ++it) { - if (it->y() < s.min) s.min = it->y(); - if (it->y() > s.max) s.max = it->y(); - } - } else { - std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); - } - min = std::min(min, s.min); - max = std::max(max, s.max); - } - if (min == std::numeric_limits::max()) min = 0; - if (max == std::numeric_limits::lowest()) max = 0; - - if (y_unit != unit) { - y_unit = unit; - y_label_width = 0; // recalc width - } - - double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; - auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); - if (min_y != y_min || max_y != y_max || y_label_width == 0) { - y_min = min_y; - y_max = max_y; - y_tick_count = tick_count; - y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); - - QFontMetrics fm(font()); - int max_label_width = 0; - for (int i = 0; i < tick_count; i++) { - qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1)); - max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision))); - } - - int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); - y_label_width = title_spacing + max_label_width + 15; - emit axisYLabelWidthChanged(y_label_width); - } -} - -std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) { - qreal range = niceNumber((max - min), true); // range with ceiling - qreal step = niceNumber(range / (tick_count - 1), false); - min = std::floor(min / step); - max = std::ceil(max / step); - tick_count = int(max - min) + 1; - return {min * step, max * step, tick_count}; -} - -int ChartView::xAxisPrecision() const { - return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2); -} - -// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n -qreal ChartView::niceNumber(qreal x, bool ceiling) { - qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x - qreal q = x / z; //q<10 && q>=1; - if (ceiling) { - if (q <= 1.0) q = 1; - else if (q <= 2.0) q = 2; - else if (q <= 5.0) q = 5; - else q = 10; - } else { - if (q < 1.5) q = 1; - else if (q < 3.0) q = 2; - else if (q < 7.0) q = 5; - else q = 10; - } - return q * z; -} - -void ChartView::contextMenuEvent(QContextMenuEvent *event) { - QMenu context_menu(this); - context_menu.addActions(menu->actions()); - context_menu.addSeparator(); - context_menu.addAction(charts_widget->undo_zoom_action); - context_menu.addAction(charts_widget->redo_zoom_action); - context_menu.addSeparator(); - context_menu.addAction(close_act); - context_menu.exec(event->globalPos()); -} - -void ChartView::mousePressEvent(QMouseEvent *event) { - press_pos = event->pos(); - if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) { - charts_widget->startChartDrag(this, event->globalPos()); - } else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) { - // Save current playback state when scrubbing - resume_after_scrub = !can->isPaused(); - if (resume_after_scrub) { - can->pause(true); - } - mouse_mode = MouseMode::Scrub; - } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { - mouse_mode = MouseMode::Rubber; - rubber_rect = QRect(); - } else { - QWidget::mousePressEvent(event); - } -} - -void ChartView::mouseMoveEvent(QMouseEvent *ev) { - // Scrubbing - if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { - if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); - } - } - - if (mouse_mode == MouseMode::Rubber) { - // horizontal selection, clamped to the plot area - int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); - int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); - rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height()); - update(); - } - - clearTrackPoints(); - if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) { - charts_widget->showValueTip(secondsAtPoint(ev->pos())); - } else if (tip_label->isVisible()) { - charts_widget->showValueTip(-1); - } - QWidget::mouseMoveEvent(ev); -} - -void ChartView::mouseReleaseEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) { - mouse_mode = MouseMode::None; - // Prevent zooming/seeking past the end of the route - double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds()); - double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds()); - if (rubber_rect.width() <= 0) { - // no rubber dragged, seek to mouse position - can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds())); - } else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { - charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max})); - } - rubber_rect = QRect(); - update(); - } else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) { - // toggle series visibility by clicking its legend entry - for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { - if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) { - sigs[i].visible = !sigs[i].visible; - updateAxisY(); - updateTitle(); - break; - } - } - } else if (event->button() == Qt::RightButton) { - charts_widget->zoom_undo_stack.undo(); - } else { - QWidget::mouseReleaseEvent(event); - } - - // Resume playback if we were scrubbing - if (mouse_mode == MouseMode::Scrub) { - mouse_mode = MouseMode::None; - if (resume_after_scrub) { - can->pause(false); - resume_after_scrub = false; - } - } -} - -void ChartView::takeSignalsFrom(ChartView *source) { - for (auto &s : source->sigs) { - sigs.push_back(std::move(s)); - sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig); - } - source->sigs.clear(); - updateAxisY(); - updateTitle(); - charts_widget->removeChart(source); -} - -void ChartView::showTip(double sec) { - QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height()); - QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area); - if (visible_rect.isEmpty()) { - tip_label->hide(); - return; - } - - tooltip_x = xPos(sec); - qreal x = -1; - QStringList text_list; - for (auto &s : sigs) { - if (s.visible) { - QString value = "--"; - // use reverse iterator to find last item <= sec. - auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; }); - if (it != s.vals.crend() && it->x() >= x_min) { - value = QString::fromStdString(s.sig->formatValue(it->y(), false)); - s.track_pt = *it; - x = std::max(x, xPos(it->x())); - } - QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; - QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); - QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); - text_list << QString("%2%3 (%4, %5)") - .arg(s.color.name(), name, value, min, max); - } - } - if (x < 0) { - x = tooltip_x; - } - QPoint pt(x, plot_area.top()); - text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3)); - QString text = "

" % text_list.join("
") % "

"; - tip_label->showText(pt, text, this, visible_rect); - update(); -} - -void ChartView::hideTip() { - clearTrackPoints(); - tooltip_x = -1; - tip_label->hide(); - update(); -} - -void ChartView::resetChartCache() { - chart_pixmap = QPixmap(); - update(); -} - -void ChartView::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.setRenderHints(QPainter::Antialiasing); - - // the static layer is invalidated on x-range change and data merge, so cache it in live mode too - const qreal dpr = devicePixelRatioF(); - if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) { - chart_pixmap = QPixmap(size() * dpr); - chart_pixmap.setDevicePixelRatio(dpr); - QPainter p(&chart_pixmap); - p.setRenderHints(QPainter::Antialiasing); - p.setFont(font()); - drawStaticLayer(&p); - } - painter.drawPixmap(QPoint(), chart_pixmap); - - if (can_drop) { - painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); - painter.drawRect(rect()); - } - drawForeground(&painter); -} - -void ChartView::drawStaticLayer(QPainter *painter) { - painter->fillRect(rect(), palette().color(QPalette::Base)); - painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal")); - drawAxes(painter); - drawLegend(painter); - drawSeries(painter); -} - -void ChartView::drawAxes(QPainter *painter) { - const QColor text_color = palette().color(QPalette::Text); - QColor grid_color = text_color; - grid_color.setAlpha(50); - QFontMetrics fm(font()); - painter->setFont(font()); - - // y grid lines and tick labels - for (int i = 0; i < y_tick_count; ++i) { - double value = y_min + i * (y_max - y_min) / (y_tick_count - 1); - qreal y = yPos(value); - painter->setPen(grid_color); - painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y)); - painter->setPen(text_color); - QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height()); - painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision)); - } - - // rotated y axis title (unit) - if (!y_unit.isEmpty()) { - painter->save(); - painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y()); - painter->rotate(-90); - painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()), - Qt::AlignCenter, y_unit); - painter->restore(); - } - - // x grid lines and tick labels - const int x_precision = xAxisPrecision(); - for (int i = 0; i < X_TICK_COUNT; ++i) { - double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1); - qreal x = xPos(sec); - painter->setPen(grid_color); - painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom())); - painter->setPen(text_color); - QString label = QString::number(sec, 'f', x_precision); - QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height()); - painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label); - } -} - -void ChartView::drawLegend(QPainter *painter) { - QColor title_color = palette().color(QPalette::WindowText); - // Draw message details in similar color, but slightly fade it to the background - QColor msg_color = title_color; - msg_color.setAlpha(180); - QFont bold_font = font(); - bold_font.setBold(true); - const int marker_size = QFontMetrics(font()).height() - 4; - - for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { - const auto &s = sigs[i]; - const QRect &r = legend_rects[i]; - painter->setPen(Qt::NoPen); - painter->setBrush(s.color); - QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size); - series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect); - - bold_font.setStrikeOut(!s.visible); - QFont normal_font = font(); - normal_font.setStrikeOut(!s.visible); - - qreal x = r.left() + marker_size + 5; - painter->setFont(bold_font); - painter->setPen(title_color); - QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x); - painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name); - x += QFontMetrics(bold_font).horizontalAdvance(name); - painter->setFont(normal_font); - painter->setPen(msg_color); - QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()), - Qt::ElideRight, r.right() - x); - painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg); - } -} - -void ChartView::drawSeries(QPainter *painter) { - painter->save(); - painter->setClipRect(plot_area); - for (auto &s : sigs) { - if (!s.visible) continue; - - // visible points in vals to compute point density - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); - int num_points = std::max(last - first, 1); - double pixels_per_point = 0; - if (first != last) { - const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last; - pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points; - } - - if (series_type == SeriesType::Scatter) { - qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; - painter->setPen(Qt::NoPen); - painter->setBrush(s.color); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius); - } - } else { - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan); - if (begin != points.cbegin()) --begin; - auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan); - if (end != points.cend()) ++end; - if (begin == end) continue; - - std::vector polyline; - polyline.reserve(end - begin); - for (auto it = begin; it != end; ++it) { - polyline.emplace_back(xPos(it->x()), yPos(it->y())); - } - painter->setPen(QPen(s.color, 2)); - painter->setBrush(Qt::NoBrush); - painter->drawPolyline(polyline.data(), polyline.size()); - - // show points when zoomed in enough - if (num_points == 1 || pixels_per_point > 20) { - painter->setPen(Qt::NoPen); - painter->setBrush(s.color); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4); - } - } - } - } - painter->restore(); -} - -void ChartView::drawForeground(QPainter *painter) { - drawTimeline(painter); - drawSignalValue(painter); - // draw track points - painter->setPen(Qt::NoPen); - qreal track_line_x = -1; - for (auto &s : sigs) { - if (!s.track_pt.isNull() && s.visible) { - painter->setBrush(s.color.darker(125)); - QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y())); - painter->drawEllipse(pos, 5.5, 5.5); - track_line_x = std::max(track_line_x, pos.x()); - } - } - if (track_line_x > 0) { - painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()}); - } - - drawRubberBandTimeRange(painter); -} - -void ChartView::drawRubberBandTimeRange(QPainter *painter) { - if (rubber_rect.width() <= 1) return; - - // selection rect - QColor highlight = palette().color(QPalette::Highlight); - QColor fill = highlight; - fill.setAlpha(50); - painter->fillRect(rubber_rect, fill); - painter->setPen(highlight); - painter->setBrush(Qt::NoBrush); - painter->drawRect(rubber_rect); - - // time labels at the bottom corners - painter->setPen(Qt::white); - painter->setFont(font()); - for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { - QString sec = QString::number(secondsAtPoint(pt), 'f', 2); - auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); - pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); - painter->fillRect(r, Qt::gray); - painter->drawText(r, Qt::AlignCenter, sec); - } -} - -void ChartView::drawTimeline(QPainter *painter) { - // draw vertical time line - qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right()); - painter->setPen(QPen(palette().color(QPalette::Text), 1)); - painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0}); - - // draw current time under the axis-x - QString time_str = QString::number(cur_sec, 'f', 2); - QSize time_str_size = QFontMetrics(font()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); - QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size); - QPainterPath path; - path.addRoundedRect(time_str_rect, 3, 3); - painter->fillPath(path, utils::isDarkTheme() ? Qt::darkGray : Qt::gray); - painter->setPen(palette().color(QPalette::BrightText)); - painter->setFont(font()); - painter->drawText(time_str_rect, Qt::AlignCenter, time_str); -} - -void ChartView::drawSignalValue(QPainter *painter) { - painter->setFont(signal_value_font); - painter->setPen(palette().color(QPalette::Text)); - for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { - const auto &s = sigs[i]; - auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec, - [](auto &p, double x) { return p.x() > x + EPSILON; }); - QString value = (it != s.vals.crend() && it->x() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; - QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size()); - QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width()); - painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val); - } -} - -QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const { - for (auto &s : sigs) { - if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) { - // use different color to distinguish it from others. - auto last_color = sigs.back().color; - static thread_local std::mt19937 rng{std::random_device{}()}; - std::uniform_int_distribution sat(35, 99); - std::uniform_int_distribution val(85, 99); - color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0), - sat(rng) / 100.0, - val(rng) / 100.0); - break; - } - } - return color; -} - -void ChartView::setSeriesType(SeriesType type) { - if (type != series_type) { - series_type = type; - menu->actions()[(int)type]->setChecked(true); - updateTitle(); - } -} diff --git a/iqpilot/tools/cabana/chart/chart.h b/iqpilot/tools/cabana/chart/chart.h deleted file mode 100644 index b03623475..000000000 --- a/iqpilot/tools/cabana/chart/chart.h +++ /dev/null @@ -1,130 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include - -#include "tools/cabana/chart/tiplabel.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -enum class SeriesType { - Line = 0, - StepLine, - Scatter -}; - -class ChartsWidget; -class ChartView : public QWidget { - Q_OBJECT - -public: - ChartView(const std::pair &x_range, ChartsWidget *parent = nullptr); - void addSignal(const MessageId &msg_id, const cabana::Signal *sig); - bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; - void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); - void updatePlot(double cur, double min, double max); - void setSeriesType(SeriesType type); - void updatePlotArea(int left, bool force = false); - void showTip(double sec); - void hideTip(); - double secondsAtPoint(const QPointF &pt) const { - return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1); - } - - struct SigItem { - MessageId msg_id; - const cabana::Signal *sig = nullptr; - QColor color; - bool visible = true; - std::vector vals; - std::vector step_vals; - QPointF track_pt{}; - SegmentTree segment_tree; - double min = 0; - double max = 0; - }; - -signals: - void axisYLabelWidthChanged(int w); - -private slots: - void signalUpdated(const cabana::Signal *sig); - void manageSignals(); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } - void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } - -private: - void appendCanEvents(const cabana::Signal *sig, const std::vector &events, - std::vector &vals, std::vector &step_vals); - void createToolButtons(); - void contextMenuEvent(QContextMenuEvent *event) override; - void mousePressEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - void resizeEvent(QResizeEvent *event) override; - QSize sizeHint() const override; - void updateAxisY(); - void updateTitle(); - void resetChartCache(); - void paintEvent(QPaintEvent *event) override; - void drawStaticLayer(QPainter *painter); - void drawAxes(QPainter *painter); - void drawLegend(QPainter *painter); - void drawSeries(QPainter *painter); - void drawForeground(QPainter *painter); - void drawSignalValue(QPainter *painter); - void drawTimeline(QPainter *painter); - void drawRubberBandTimeRange(QPainter *painter); - int xAxisPrecision() const; - std::tuple getNiceAxisNumbers(qreal min, qreal max, int tick_count); - qreal niceNumber(qreal x, bool ceiling); - QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const; - void removeIf(std::function predicate); - void takeSignalsFrom(ChartView *source); - void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); } - inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; } - inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); } - inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); } - - // layout - QRect plot_area; - QRect move_icon_rect; - std::vector legend_rects; - // axes - double x_min; - double x_max; - double y_min = 0; - double y_max = 1; - int y_tick_count = 3; - int y_precision = 0; - QString y_unit; - int y_label_width = 0; - int align_to = 0; - // interaction - enum class MouseMode { None, Rubber, Scrub }; - MouseMode mouse_mode = MouseMode::None; - QPoint press_pos; - QRect rubber_rect; - bool resume_after_scrub = false; - - QMenu *menu; - QAction *split_chart_act; - QAction *close_act; - ToolButton *manage_btn; - ToolButton *close_btn; - TipLabel *tip_label; - std::vector sigs; - double cur_sec = 0; - SeriesType series_type = SeriesType::Line; - QPixmap chart_pixmap; - bool can_drop = false; - double tooltip_x = -1; - QFont signal_value_font; - ChartsWidget *charts_widget; - friend class ChartsWidget; -}; diff --git a/iqpilot/tools/cabana/chart/chartswidget.cc b/iqpilot/tools/cabana/chart/chartswidget.cc deleted file mode 100644 index 3144c5132..000000000 --- a/iqpilot/tools/cabana/chart/chartswidget.cc +++ /dev/null @@ -1,661 +0,0 @@ -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chart.h" - -const int MAX_COLUMN_COUNT = 4; -const int CHART_SPACING = 4; - -ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { - align_timer = new QTimer(this); - auto_scroll_timer = new QTimer(this); - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - // toolbar - toolbar = new QToolBar(tr("Charts"), this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - auto new_plot_btn = new ToolButton("file-plus", tr("New Chart")); - auto new_tab_btn = new ToolButton("window-stack", tr("New Tab")); - toolbar->addWidget(new_plot_btn); - toolbar->addWidget(new_tab_btn); - toolbar->addWidget(title_label = new QLabel()); - title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0); - - auto chart_type_action = toolbar->addAction(""); - QMenu *chart_type_menu = new QMenu(this); - auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")}; - for (int i = 0; i < types.size(); ++i) { - QString type_text = types[i]; - chart_type_menu->addAction(type_text, this, [=]() { - settings.chart_series_type = i; - chart_type_action->setText("Type: " + type_text); - settingChanged(); - }); - } - chart_type_action->setText("Type: " + types[settings.chart_series_type]); - chart_type_action->setMenu(chart_type_menu); - qobject_cast(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup); - - QMenu *menu = new QMenu(this); - for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { - menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); }); - } - columns_action = toolbar->addAction(""); - columns_action->setMenu(menu); - qobject_cast(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup); - - QWidget *spacer = new QWidget(this); - spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - range_lb_action = toolbar->addWidget(range_lb = new QLabel(this)); - range_slider = new LogSlider(1000, Qt::Horizontal, this); - range_slider->setFixedWidth(150 * qApp->devicePixelRatio()); - range_slider->setToolTip(tr("Set the chart range")); - range_slider->setRange(1, settings.max_cached_minutes * 60); - range_slider->setSingleStep(1); - range_slider->setPageStep(60); // 1 min - range_slider_action = toolbar->addWidget(range_slider); - - // zoom controls - undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); }); - redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); }); - undo_zoom_action->setEnabled(false); - redo_zoom_action->setEnabled(false); - zoom_undo_stack.setCallbacks({.index_changed = [this]() { - undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); - redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); - }}); - reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); - reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - - toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts"))); - toolbar->addWidget(dock_btn = new ToolButton("")); - main_layout->addWidget(toolbar); - - // tabbar - tabbar = new TabBar(this); - tabbar->setAutoHide(true); - tabbar->setExpanding(false); - tabbar->setDrawBase(true); - tabbar->setUsesScrollButtons(true); - main_layout->addWidget(tabbar); - - // charts - charts_container = new ChartsContainer(this); - charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); - charts_scroll = new QScrollArea(this); - charts_scroll->viewport()->setBackgroundRole(QPalette::Base); - charts_scroll->setFrameStyle(QFrame::NoFrame); - charts_scroll->setWidgetResizable(true); - charts_scroll->setWidget(charts_container); - charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - main_layout->addWidget(charts_scroll); - - // chart drag preview - drag_preview = new QLabel(this); - drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents); - drag_preview->hide(); - - // init settings - current_theme = settings.theme; - column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); - max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); - display_range = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range); - range_slider->setValue(max_chart_range); - updateToolBar(); - - align_timer->setSingleShot(true); - QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); - QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll); - QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged); - QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState); - QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState); - QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged); - QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange); - QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart); - QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll); - QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset); - QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged); - QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab); - QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) updateLayout(true); - }); - QObject::connect(dock_btn, &QToolButton::clicked, this, &ChartsWidget::toggleChartsDocking); - - setIsDocked(true); - newTab(); - qApp->installEventFilter(this); - setWhatsThis(tr(R"( - Chart View
- Click: Click to seek to a corresponding time.
- Drag: Zoom into the chart.
- Shift + Drag: Scrub through the chart to view values.
- Right Mouse: Open the context menu.
- )")); -} - -void ChartsWidget::newTab() { - static int tab_unique_id = 0; - int idx = tabbar->addTab(""); - tabbar->setTabData(idx, tab_unique_id++); - tabbar->setCurrentIndex(idx); - updateTabBar(); -} - -void ChartsWidget::removeTab(int index) { - int id = tabbar->tabData(index).toInt(); - for (auto &c : tab_charts[id]) { - removeChart(c); - } - tab_charts.erase(id); - tabbar->removeTab(index); - updateTabBar(); -} - -void ChartsWidget::updateTabBar() { - for (int i = 0; i < tabbar->count(); ++i) { - const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()]; - tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size())); - } -} - -void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - std::vector> futures; - for (auto c : charts) { - futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events)); - } - for (auto &f : futures) f.get(); -} - -void ChartsWidget::timeRangeChanged(const std::optional> &time_range) { - updateToolBar(); - updateState(); -} - -void ChartsWidget::zoomReset() { - can->setTimeRange(std::nullopt); - zoom_undo_stack.clear(); -} - -QRect ChartsWidget::chartVisibleRect(ChartView *chart) { - const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size()); - return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size())); -} - -void ChartsWidget::showValueTip(double sec) { - emit showTip(sec); - if (sec < 0 && !value_tip_visible_) return; - - value_tip_visible_ = sec >= 0; - for (auto c : currentCharts()) { - value_tip_visible_ ? c->showTip(sec) : c->hideTip(); - } -} - -void ChartsWidget::updateState() { - if (charts.empty()) return; - - const auto &time_range = can->timeRange(); - const double cur_sec = can->currentSec(); - if (!time_range.has_value()) { - double pos = (cur_sec - display_range.first) / std::max(1.0, max_chart_range); - if (pos < 0 || pos > 0.8) { - display_range.first = std::max(can->minSeconds(), cur_sec - max_chart_range * 0.1); - } - double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds()); - display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range); - display_range.second = display_range.first + max_chart_range; - } - - const auto &range = time_range ? *time_range : display_range; - for (auto c : charts) { - c->updatePlot(cur_sec, range.first, range.second); - } -} - -void ChartsWidget::setMaxChartRange(int value) { - max_chart_range = settings.chart_range = range_slider->value(); - updateToolBar(); - updateState(); -} - -void ChartsWidget::setIsDocked(bool docked) { - is_docked = docked; - dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square"); - dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window")); -} - -void ChartsWidget::updateToolBar() { - title_label->setText(tr("Charts: %1").arg(charts.size())); - columns_action->setText(tr("Columns: %1").arg(column_count)); - range_lb->setText(utils::formatSeconds(max_chart_range)); - - bool is_zoomed = can->timeRange().has_value(); - range_lb_action->setVisible(!is_zoomed); - range_slider_action->setVisible(!is_zoomed); - undo_zoom_action->setVisible(is_zoomed); - redo_zoom_action->setVisible(is_zoomed); - reset_zoom_action->setVisible(is_zoomed); - reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : ""); - remove_all_btn->setEnabled(!charts.empty()); -} - -void ChartsWidget::settingChanged() { - if (std::exchange(current_theme, settings.theme) != current_theme) { - undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); - redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); - } - if (range_slider->maximum() != settings.max_cached_minutes * 60) { - range_slider->setRange(1, settings.max_cached_minutes * 60); - } - for (auto c : charts) { - c->setFixedHeight(settings.chart_height); - c->setSeriesType((SeriesType)settings.chart_series_type); - c->resetChartCache(); - } -} - -ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { - for (auto c : charts) - if (c->hasSignal(id, sig)) return c; - return nullptr; -} - -ChartView *ChartsWidget::createChart(int pos) { - auto chart = new ChartView(can->timeRange().value_or(display_range), this); - chart->setFixedHeight(settings.chart_height); - chart->setMinimumWidth(CHART_MIN_WIDTH); - chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); - QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start)); - pos = std::clamp(pos, 0, (int)charts.size()); - charts.insert(charts.begin() + pos, chart); - currentCharts().insert(currentCharts().begin() + pos, chart); - updateLayout(true); - updateToolBar(); - return chart; -} - -void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { - ChartView *chart = findChart(id, sig); - if (show && !chart) { - chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); - chart->addSignal(id, sig); - updateState(); - } else if (!show && chart) { - chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); - } -} - -void ChartsWidget::splitChart(ChartView *src_chart) { - if (src_chart->sigs.size() > 1) { - int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1; - for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) { - auto c = createChart(pos); - // Restore to the original color - it->color = toQColor(it->sig->color); - c->sigs.emplace_back(std::move(*it)); - c->updateAxisY(); - c->updateTitle(); - it = src_chart->sigs.erase(it); - } - src_chart->updateAxisY(); - src_chart->updateTitle(); - updateState(); - QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); - } -} - -QStringList ChartsWidget::serializeChartIds() const { - QStringList chart_ids; - for (auto c : charts) { - QStringList ids; - for (const auto& s : c->sigs) - ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name)); - chart_ids += ids.join(','); - } - std::reverse(chart_ids.begin(), chart_ids.end()); - return chart_ids; -} - -void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { - for (const auto& chart_id : chart_ids) { - int index = 0; - for (const auto& part : chart_id.split(',')) { - const auto sig_parts = part.split('|'); - if (sig_parts.size() != 2) continue; - MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString()); - if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1].toStdString())) - showChart(msg_id, sig, true, index++ > 0); - } - } -} - -void ChartsWidget::setColumnCount(int n) { - n = std::clamp(n, 1, MAX_COLUMN_COUNT); - if (column_count != n) { - column_count = settings.chart_column_count = n; - updateToolBar(); - updateLayout(); - } -} - -void ChartsWidget::updateLayout(bool force) { - auto charts_layout = charts_container->charts_layout; - int n = MAX_COLUMN_COUNT; - for (; n > 1; --n) { - if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break; - } - - bool show_column_cb = n > 1; - columns_action->setVisible(show_column_cb); - - n = std::min(column_count, n); - auto ¤t_charts = currentCharts(); - if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) { - current_column_count = n; - charts_container->setUpdatesEnabled(false); - for (auto c : charts) { - c->setVisible(false); - } - for (int i = 0; i < current_charts.size(); ++i) { - charts_layout->addWidget(current_charts[i], i / n, i % n); - if (current_charts[i]->sigs.empty()) { - // the chart will be resized after add signal. delay setVisible to reduce flicker. - QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); }); - } else { - current_charts[i]->setVisible(true); - } - } - charts_container->setUpdatesEnabled(true); - } -} - -void ChartsWidget::startChartDrag(ChartView *chart, const QPoint &global_pos) { - stopAutoScroll(); - drag = {.source = chart, .press_pos = global_pos}; - QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation); - drag_preview->setPixmap(px); - drag_preview->resize(px.size() / px.devicePixelRatio()); -} - -void ChartsWidget::dragChartMove(const QPoint &global_pos) { - if (!drag.active) { - if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return; - drag.active = true; - drag_preview->show(); - drag_preview->raise(); - } - drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5)); - - // hovering a tab switches to it so the chart can be dropped into another tab - int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos)); - if (tab >= 0 && tab != tabbar->currentIndex()) { - tabbar->setCurrentIndex(tab); - } - - const QPoint container_pos = charts_container->mapFromGlobal(global_pos); - ChartView *target = nullptr; - for (auto c : currentCharts()) { - if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) { - target = c; - break; - } - } - if (std::exchange(drop_target, target) != target) { - for (auto c : charts) c->setDropHighlight(c == target); - } - bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); - bool on_background = !target && in_viewport && !charts_container->childAt(container_pos); - charts_container->drawDropIndicator(on_background ? container_pos : QPoint()); - - if (in_viewport) { - startAutoScroll(global_pos); - } -} - -void ChartsWidget::cancelChartDrag() { - drag = {}; - stopAutoScroll(); - drag_preview->hide(); - charts_container->drawDropIndicator({}); - if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false); -} - -void ChartsWidget::dragChartRelease(const QPoint &global_pos) { - ChartView *source = drag.source; - bool active = drag.active; - ChartView *target = drop_target; - cancelChartDrag(); - if (!active) return; - - const QPoint container_pos = charts_container->mapFromGlobal(global_pos); - bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); - if (target) { - // merge source into target - target->takeSignalsFrom(source); - } else if (in_viewport && !charts_container->childAt(container_pos)) { - // reorder within the current tab - auto w = charts_container->getDropAfter(container_pos); - if (w != source) { - for (auto &[_, list] : tab_charts) { - list.erase(std::remove(list.begin(), list.end(), source), list.end()); - } - auto &cur = currentCharts(); - int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; - cur.insert(cur.begin() + to, source); - updateLayout(true); - updateTabBar(); - } - } -} - -void ChartsWidget::startAutoScroll(const QPoint &global_pos) { - auto_scroll_pos = global_pos; - auto_scroll_timer->start(50); -} - -void ChartsWidget::stopAutoScroll() { - auto_scroll_timer->stop(); - auto_scroll_count = 0; -} - -void ChartsWidget::doAutoScroll() { - QScrollBar *scroll = charts_scroll->verticalScrollBar(); - if (auto_scroll_count < scroll->pageStep()) { - ++auto_scroll_count; - } - - int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); - QRect area = charts_scroll->viewport()->rect(); - - if (pos.y() - area.top() < settings.chart_height / 2) { - scroll->setValue(value - auto_scroll_count); - } else if (area.bottom() - pos.y() < settings.chart_height / 2) { - scroll->setValue(value + auto_scroll_count); - } - if (value == scroll->value()) { - stopAutoScroll(); - } else if (chartDragActive()) { - // refresh the drop indicator/target at the new scroll position - dragChartMove(auto_scroll_pos); - } -} - -QSize ChartsWidget::minimumSizeHint() const { - return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height()); -} - -void ChartsWidget::newChart() { - SignalSelector dlg(tr("New Chart"), this); - if (dlg.exec() == QDialog::Accepted) { - auto items = dlg.seletedItems(); - if (!items.empty()) { - auto c = createChart(); - for (auto it : items) { - c->addSignal(it->msg_id, it->sig); - } - updateState(); - } - } -} - -void ChartsWidget::removeChart(ChartView *chart) { - if (drag.source == chart) cancelChartDrag(); - if (drop_target == chart) drop_target = nullptr; - charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end()); - chart->deleteLater(); - for (auto &[_, list] : tab_charts) { - list.erase(std::remove(list.begin(), list.end(), chart), list.end()); - } - updateToolBar(); - updateLayout(true); - alignCharts(); - emit seriesChanged(); -} - -void ChartsWidget::removeAll() { - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - tab_charts.clear(); - - if (!charts.empty()) { - for (auto c : charts) { - delete c; - } - charts.clear(); - emit seriesChanged(); - } - zoomReset(); -} - -void ChartsWidget::alignCharts() { - int plot_left = 0; - for (auto c : charts) { - plot_left = std::max(plot_left, c->y_label_width); - } - plot_left = std::max((plot_left / 10) * 10 + 10, 50); - for (auto c : charts) { - c->updatePlotArea(plot_left); - } -} - -bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { - // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch - if (chartDragActive()) { - if (e->type() == QEvent::MouseMove) { - dragChartMove(static_cast(e)->globalPos()); - return true; - } else if (e->type() == QEvent::MouseButtonRelease && static_cast(e)->button() == Qt::LeftButton) { - dragChartRelease(static_cast(e)->globalPos()); - return false; // let the release through so Qt clears the implicit mouse grab - } else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) { - return true; // swallow other buttons during the drag - } - } - - if (!value_tip_visible_) return false; - - if (e->type() == QEvent::MouseMove) { - bool on_tip = qobject_cast(o) != nullptr; - auto global_pos = static_cast(e)->globalPos(); - - for (const auto &c : charts) { - auto local_pos = c->mapFromGlobal(global_pos); - if (c->plot_area.contains(local_pos)) { - if (on_tip) { - showValueTip(c->secondsAtPoint(local_pos)); - } - return false; - } - } - - showValueTip(-1); - } else if (e->type() == QEvent::Wheel) { - if (auto tip = qobject_cast(o)) { - // Forward the event to the parent widget - QCoreApplication::sendEvent(tip->parentWidget(), e); - } - } - return false; -} - -bool ChartsWidget::event(QEvent *event) { - bool back_button = false; - switch (event->type()) { - case QEvent::Resize: - updateLayout(); - break; - case QEvent::MouseButtonPress: - back_button = static_cast(event)->button() == Qt::BackButton; - break; - case QEvent::NativeGesture: - back_button = (static_cast(event)->value() == 180); - break; - case QEvent::WindowDeactivate: - case QEvent::FocusOut: - if (chartDragActive()) cancelChartDrag(); - showValueTip(-1); - default: - break; - } - - if (back_button) { - zoom_undo_stack.undo(); - return true; // Return true since the event has been handled - } - return QFrame::event(event); -} - -// ChartsContainer - -ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setBackgroundRole(QPalette::Window); - QVBoxLayout *charts_main_layout = new QVBoxLayout(this); - charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); - charts_layout = new QGridLayout(); - charts_layout->setSpacing(CHART_SPACING); - charts_main_layout->addLayout(charts_layout); - charts_main_layout->addStretch(0); -} - -void ChartsContainer::paintEvent(QPaintEvent *ev) { - if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { - QRect r = geometry(); - r.setHeight(CHART_SPACING); - if (auto insert_after = getDropAfter(drop_indictor_pos)) { - r.moveTop(insert_after->geometry().bottom()); - } - - QPainter p(this); - p.fillRect(r, palette().highlight()); - } -} - -ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const { - auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) { - auto area = c->geometry(); - return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom(); - }); - return it == charts_widget->currentCharts().crend() ? nullptr : *it; -} diff --git a/iqpilot/tools/cabana/chart/chartswidget.h b/iqpilot/tools/cabana/chart/chartswidget.h deleted file mode 100644 index 8b3003dcd..000000000 --- a/iqpilot/tools/cabana/chart/chartswidget.h +++ /dev/null @@ -1,138 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/signalselector.h" -#include "tools/cabana/commands.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -const int CHART_MIN_WIDTH = 300; - -class ChartView; -class ChartsWidget; - -class ChartsContainer : public QWidget { -public: - ChartsContainer(ChartsWidget *parent); - void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } - void paintEvent(QPaintEvent *ev) override; - ChartView *getDropAfter(const QPoint &pos) const; - - QGridLayout *charts_layout; - ChartsWidget *charts_widget; - QPoint drop_indictor_pos; -}; - -class ChartsWidget : public QFrame { - Q_OBJECT - -public: - ChartsWidget(QWidget *parent = nullptr); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } - QStringList serializeChartIds() const; - void restoreChartsFromIds(const QStringList &chart_ids); - -public slots: - void setColumnCount(int n); - void removeAll(); - void timeRangeChanged(const std::optional> &time_range); - void setIsDocked(bool dock); - -signals: - void toggleChartsDocking(); - void seriesChanged(); - void showTip(double seconds); - -private: - QSize minimumSizeHint() const override; - bool event(QEvent *event) override; - void alignCharts(); - void newChart(); - ChartView *createChart(int pos = 0); - void removeChart(ChartView *chart); - void splitChart(ChartView *chart); - QRect chartVisibleRect(ChartView *chart); - void eventsMerged(const MessageEventsMap &new_events); - void updateState(); - void zoomReset(); - void startChartDrag(ChartView *chart, const QPoint &global_pos); - void dragChartMove(const QPoint &global_pos); - void dragChartRelease(const QPoint &global_pos); - void cancelChartDrag(); - bool chartDragActive() const { return drag.source != nullptr; } - void startAutoScroll(const QPoint &global_pos); - void stopAutoScroll(); - void doAutoScroll(); - void updateToolBar(); - void updateTabBar(); - void setMaxChartRange(int value); - void updateLayout(bool force = false); - void settingChanged(); - void showValueTip(double sec); - bool eventFilter(QObject *obj, QEvent *event) override; - void newTab(); - void removeTab(int index); - inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } - ChartView *findChart(const MessageId &id, const cabana::Signal *sig); - - QLabel *title_label; - QLabel *range_lb; - LogSlider *range_slider; - QAction *range_lb_action; - QAction *range_slider_action; - bool is_docked = true; - ToolButton *dock_btn; - - QToolBar *toolbar; - QAction *undo_zoom_action; - QAction *redo_zoom_action; - QAction *reset_zoom_action; - ToolButton *reset_zoom_btn; - UndoStack zoom_undo_stack; - - ToolButton *remove_all_btn; - std::vector charts; - std::unordered_map> tab_charts; - TabBar *tabbar; - ChartsContainer *charts_container; - QScrollArea *charts_scroll; - uint32_t max_chart_range = 0; - std::pair display_range; - QAction *columns_action; - int column_count = 1; - int current_column_count = 0; - struct ChartDrag { - ChartView *source = nullptr; - QPoint press_pos; // global - bool active = false; - } drag; - QLabel *drag_preview; - ChartView *drop_target = nullptr; - int auto_scroll_count = 0; - QPoint auto_scroll_pos; - QTimer *auto_scroll_timer; - QTimer *align_timer; - int current_theme = 0; - bool value_tip_visible_ = false; - friend class ChartView; - friend class ChartsContainer; -}; - -class ZoomCommand : public UndoCommand { -public: - ZoomCommand(std::pair range) : range(range) { - prev_range = can->timeRange(); - } - void undo() override { can->setTimeRange(prev_range); } - void redo() override { can->setTimeRange(range); } - std::optional> prev_range, range; -}; diff --git a/iqpilot/tools/cabana/chart/signalselector.cc b/iqpilot/tools/cabana/chart/signalselector.cc deleted file mode 100644 index 85832e796..000000000 --- a/iqpilot/tools/cabana/chart/signalselector.cc +++ /dev/null @@ -1,107 +0,0 @@ -#include "tools/cabana/chart/signalselector.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" - -SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) { - setWindowTitle(title); - QGridLayout *main_layout = new QGridLayout(this); - - // left column - main_layout->addWidget(new QLabel(tr("Available Signals")), 0, 0); - main_layout->addWidget(msgs_combo = new QComboBox(this), 1, 0); - msgs_combo->setEditable(true); - msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); - msgs_combo->setInsertPolicy(QComboBox::NoInsert); - - main_layout->addWidget(available_list = new QListWidget(this), 2, 0); - - // buttons - QVBoxLayout *btn_layout = new QVBoxLayout(); - QPushButton *add_btn = new QPushButton(utils::icon("chevron-right"), "", this); - add_btn->setEnabled(false); - QPushButton *remove_btn = new QPushButton(utils::icon("chevron-left"), "", this); - remove_btn->setEnabled(false); - btn_layout->addStretch(0); - btn_layout->addWidget(add_btn); - btn_layout->addWidget(remove_btn); - btn_layout->addStretch(0); - main_layout->addLayout(btn_layout, 0, 1, 3, 1); - - // right column - main_layout->addWidget(new QLabel(tr("Selected Signals")), 0, 2); - main_layout->addWidget(selected_list = new QListWidget(this), 1, 2, 2, 1); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox, 3, 2); - - for (const auto &[id, _] : can->lastMessages()) { - if (auto m = dbc()->msg(id)) { - msgs_combo->addItem(QString("%1 (%2)").arg(QString::fromStdString(m->name)).arg(QString::fromStdString(id.toString())), QVariant::fromValue(id)); - } - } - msgs_combo->model()->sort(0); - msgs_combo->setCurrentIndex(-1); - - QObject::connect(msgs_combo, qOverload(&QComboBox::currentIndexChanged), this, &SignalSelector::updateAvailableList); - QObject::connect(available_list, &QListWidget::currentRowChanged, [=](int row) { add_btn->setEnabled(row != -1); }); - QObject::connect(selected_list, &QListWidget::currentRowChanged, [=](int row) { remove_btn->setEnabled(row != -1); }); - QObject::connect(available_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::add); - QObject::connect(selected_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::remove); - QObject::connect(add_btn, &QPushButton::clicked, [this]() { if (auto item = available_list->currentItem()) add(item); }); - QObject::connect(remove_btn, &QPushButton::clicked, [this]() { if (auto item = selected_list->currentItem()) remove(item); }); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void SignalSelector::add(QListWidgetItem *item) { - auto it = (ListItem *)item; - addItemToList(selected_list, it->msg_id, it->sig, true); - delete item; -} - -void SignalSelector::remove(QListWidgetItem *item) { - auto it = (ListItem *)item; - if (it->msg_id == msgs_combo->currentData().value()) { - addItemToList(available_list, it->msg_id, it->sig); - } - delete item; -} - -void SignalSelector::updateAvailableList(int index) { - if (index == -1) return; - available_list->clear(); - MessageId msg_id = msgs_combo->itemData(index).value(); - auto selected_items = seletedItems(); - for (auto s : dbc()->msg(msg_id)->getSignals()) { - bool is_selected = std::any_of(selected_items.begin(), selected_items.end(), - [sig = s, &msg_id](auto it) { return it->msg_id == msg_id && it->sig == sig; }); - if (!is_selected) { - addItemToList(available_list, msg_id, s); - } - } -} - -void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name)); - if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); - - QLabel *label = new QLabel(text); - label->setContentsMargins(5, 0, 5, 0); - auto new_item = new ListItem(id, sig, parent); - new_item->setSizeHint(label->sizeHint()); - parent->setItemWidget(new_item, label); -} - -std::vector SignalSelector::seletedItems() { - std::vector ret; - for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i)); - return ret; -} diff --git a/iqpilot/tools/cabana/chart/signalselector.h b/iqpilot/tools/cabana/chart/signalselector.h deleted file mode 100644 index 5b6e37e56..000000000 --- a/iqpilot/tools/cabana/chart/signalselector.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class SignalSelector : public QDialog { -public: - struct ListItem : public QListWidgetItem { - ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {} - MessageId msg_id; - const cabana::Signal *sig; - }; - - SignalSelector(QString title, QWidget *parent); - std::vector seletedItems(); - inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } - -private: - void updateAvailableList(int index); - void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false); - void add(QListWidgetItem *item); - void remove(QListWidgetItem *item); - - QComboBox *msgs_combo; - QListWidget *available_list; - QListWidget *selected_list; -}; diff --git a/iqpilot/tools/cabana/chart/sparkline.cc b/iqpilot/tools/cabana/chart/sparkline.cc deleted file mode 100644 index f5bef0fc2..000000000 --- a/iqpilot/tools/cabana/chart/sparkline.cc +++ /dev/null @@ -1,100 +0,0 @@ -#include "tools/cabana/chart/sparkline.h" - -#include -#include -#include - -void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) { - if (first == last || size.isEmpty()) { - pixmap = QPixmap(); - return; - } - - points_.clear(); - min_val = std::numeric_limits::max(); - max_val = std::numeric_limits::lowest(); - points_.reserve(std::distance(first, last)); - - uint64_t start_time = (*first)->mono_time; - double value = 0.0; - for (auto it = first; it != last; ++it) { - if (sig->getValue((*it)->dat, (*it)->size, &value)) { - min_val = std::min(min_val, value); - max_val = std::max(max_val, value); - points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value); - } - } - - if (points_.empty()) { - pixmap = QPixmap(); - return; - } - - freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(toQColor(sig->color), range, size); -} - -void Sparkline::render(const QColor &color, int range, QSize size) { - // Adjust for flat lines - bool is_flat_line = min_val == max_val; - if (is_flat_line) { - min_val -= 1.0; - max_val += 1.0; - } - - // Calculate scaling - const double xscale = (size.width() - 1) / (double)range; - const double yscale = (size.height() - 3) / (max_val - min_val); - bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0; - - // Transform or downsample points - render_points_.reserve(points_.size()); - render_points_.clear(); - if (draw_individual_points) { - for (const auto &p : points_) { - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale); - } - } else if (is_flat_line) { - double y = size.height() / 2.0; - render_points_.emplace_back(0.0, y); - render_points_.emplace_back(points_.back().x() * xscale, y); - } else { - double prev_y = points_.front().y(); - render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - bool in_flat = false; - - for (size_t i = 1; i < points_.size(); ++i) { - const auto &p = points_[i]; - double y = p.y(); - if (std::abs(y - prev_y) < 1e-6) { - in_flat = true; - } else { - if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale); - render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale); - in_flat = false; - } - prev_y = y; - } - if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale); - } - - // Render to pixmap - qreal dpr = qApp->devicePixelRatio(); - const QSize pixmap_size = size * dpr; - if (pixmap.size() != pixmap_size) { - pixmap = QPixmap(pixmap_size); - } - pixmap.setDevicePixelRatio(dpr); - pixmap.fill(Qt::transparent); - QPainter painter(&pixmap); - painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500); - painter.setPen(color); - painter.drawPolyline(render_points_.data(), render_points_.size()); - - painter.setPen(QPen(color, 3)); - if (draw_individual_points) { - painter.drawPoints(render_points_.data(), render_points_.size()); - } else { - painter.drawPoint(render_points_.back()); - } -} diff --git a/iqpilot/tools/cabana/chart/sparkline.h b/iqpilot/tools/cabana/chart/sparkline.h deleted file mode 100644 index 7f30047d0..000000000 --- a/iqpilot/tools/cabana/chart/sparkline.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/streams/abstractstream.h" - -class Sparkline { -public: - void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size); - inline double freq() const { return freq_; } - bool isEmpty() const { return pixmap.isNull(); } - - QPixmap pixmap; - double min_val = 0; - double max_val = 0; - -private: - void render(const QColor &color, int range, QSize size); - - std::vector points_; - std::vector render_points_; - double freq_ = 0; -}; diff --git a/iqpilot/tools/cabana/chart/tiplabel.cc b/iqpilot/tools/cabana/chart/tiplabel.cc deleted file mode 100644 index 233fa8037..000000000 --- a/iqpilot/tools/cabana/chart/tiplabel.cc +++ /dev/null @@ -1,58 +0,0 @@ -#include "tools/cabana/chart/tiplabel.h" - -#include - -#include -#include -#include - -#include "tools/cabana/settings.h" -#include "tools/cabana/utils/util.h" - -TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) { - setAttribute(Qt::WA_ShowWithoutActivating); - setAttribute(Qt::WA_TransparentForMouseEvents); - - setForegroundRole(QPalette::ToolTipText); - setBackgroundRole(QPalette::ToolTipBase); - - QFont font; - font.setPointSizeF(8.34563465); - setFont(font); - auto palette = QToolTip::palette(); - if (!utils::isDarkTheme()) { - palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base)); - palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush - } - setPalette(palette); - ensurePolished(); - setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this)); - setTextFormat(Qt::RichText); -} - -void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) { - setText(text); - if (!text.isEmpty()) { - QSize extra(1, 1); - resize(sizeHint() + extra); - QPoint tip_pos(pt.x() + 8, rect.top() + 2); - if (tip_pos.x() + size().width() >= rect.right()) { - tip_pos.rx() = pt.x() - size().width() - 8; - } - if (rect.contains({tip_pos, size()})) { - move(w->mapToGlobal(tip_pos)); - setVisible(true); - return; - } - } - setVisible(false); -} - -void TipLabel::paintEvent(QPaintEvent *ev) { - QStylePainter p(this); - QStyleOptionFrame opt; - opt.init(this); - p.drawPrimitive(QStyle::PE_PanelTipLabel, opt); - p.end(); - QLabel::paintEvent(ev); -} diff --git a/iqpilot/tools/cabana/chart/tiplabel.h b/iqpilot/tools/cabana/chart/tiplabel.h deleted file mode 100644 index cc96aa986..000000000 --- a/iqpilot/tools/cabana/chart/tiplabel.h +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once - -#include - -class TipLabel : public QLabel { - Q_OBJECT - -public: - TipLabel(QWidget *parent = nullptr); - void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect); - void paintEvent(QPaintEvent *ev) override; -}; diff --git a/iqpilot/tools/cabana/commands.cc b/iqpilot/tools/cabana/commands.cc index b47bf90b0..69747494a 100644 --- a/iqpilot/tools/cabana/commands.cc +++ b/iqpilot/tools/cabana/commands.cc @@ -1,11 +1,12 @@ #include "tools/cabana/commands.h" +#include #include -// UndoStack + void UndoStack::push(UndoCommand *cmd) { - commands_.resize(index_); // drop any redoable commands + commands_.resize(index_); if (clean_index_ > index_) clean_index_ = -1; commands_.emplace_back(cmd); cmd->redo(); @@ -28,22 +29,22 @@ void UndoStack::clear() { bool was_clean = isClean(); commands_.clear(); index_ = clean_index_ = 0; - if (callbacks_.index_changed) callbacks_.index_changed(); - if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true); + indexChanged(); + if (!was_clean) cleanChanged(true); } void UndoStack::setClean() { if (!isClean()) { clean_index_ = index_; - if (callbacks_.clean_changed) callbacks_.clean_changed(true); + cleanChanged(true); } } void UndoStack::setIndex(int index) { bool was_clean = isClean(); index_ = index; - if (callbacks_.index_changed) callbacks_.index_changed(); - if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean()); + indexChanged(); + if (isClean() != was_clean) cleanChanged(isClean()); } UndoStack *UndoStack::instance() { @@ -51,19 +52,7 @@ UndoStack *UndoStack::instance() { return &undo_stack; } -QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) { - UndoStack::instance()->setCallbacks({ - .index_changed = [this]() { emit indexChanged(); }, - .clean_changed = [this](bool clean) { emit cleanChanged(clean); }, - }); -} -QtUndoNotifier *undoNotifier() { - static QtUndoNotifier notifier; - return ¬ifier; -} - -// EditMsgCommand EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, const std::string &comment) @@ -90,7 +79,7 @@ void EditMsgCommand::redo() { dbc()->updateMsg(id, new_name, new_size, new_node, new_comment); } -// RemoveMsgCommand + RemoveMsgCommand::RemoveMsgCommand(const MessageId &id) : id(id) { if (auto msg = dbc()->msg(id)) { @@ -112,7 +101,7 @@ void RemoveMsgCommand::redo() { dbc()->removeMsg(id); } -// AddSigCommand + AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig) : id(id), signal(sig) { @@ -134,7 +123,7 @@ void AddSigCommand::redo() { dbc()->addSignal(id, signal); } -// RemoveSigCommand + RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig) : id(id) { sigs.push_back(*sig); @@ -151,13 +140,13 @@ RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *si void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); } void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(id, s.name); } -// EditSignalCommand + EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig) : id(id) { sigs.push_back({*sig, new_sig}); if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) { - // convert all multiplexed signals to normal signals + auto msg = dbc()->msg(id); assert(msg); for (const auto &s : msg->sigs) { diff --git a/iqpilot/tools/cabana/commands.h b/iqpilot/tools/cabana/commands.h index 200a4f2f5..437ab3c52 100644 --- a/iqpilot/tools/cabana/commands.h +++ b/iqpilot/tools/cabana/commands.h @@ -1,13 +1,11 @@ #pragma once -#include #include #include #include #include -#include - +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" @@ -21,12 +19,7 @@ public: class UndoStack { public: - struct Callbacks { - std::function index_changed; - std::function clean_changed; - }; - - void push(UndoCommand *cmd); // takes ownership and calls redo() + void push(UndoCommand *cmd); void undo(); void redo(); void clear(); @@ -36,31 +29,18 @@ public: bool canRedo() const { return index_ < (int)commands_.size(); } std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; } std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; } - void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } static UndoStack *instance(); + Observable<> indexChanged; + Observable cleanChanged; + private: void setIndex(int index); std::vector> commands_; int index_ = 0; int clean_index_ = 0; - Callbacks callbacks_; }; -// emits Qt signals for the global undo stack -class QtUndoNotifier : public QObject { - Q_OBJECT - -public: - explicit QtUndoNotifier(QObject *parent = nullptr); - -signals: - void indexChanged(); - void cleanChanged(bool clean); -}; - -QtUndoNotifier *undoNotifier(); - class EditMsgCommand : public UndoCommand { public: EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, @@ -116,5 +96,5 @@ public: private: const MessageId id; - std::vector> sigs; // {old_sig, new_sig} + std::vector> sigs; }; diff --git a/iqpilot/tools/cabana/core/color.h b/iqpilot/tools/cabana/core/color.h index c29704dd4..47e7370c5 100644 --- a/iqpilot/tools/cabana/core/color.h +++ b/iqpilot/tools/cabana/core/color.h @@ -60,7 +60,6 @@ struct CabanaColor { return r == other.r && g == other.g && b == other.b && a == other.a; } -private: struct Hsv { float hue; float saturation; float value; }; Hsv hsv() const { const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f; diff --git a/iqpilot/tools/cabana/core/observable.h b/iqpilot/tools/cabana/core/observable.h new file mode 100644 index 000000000..6f6389933 --- /dev/null +++ b/iqpilot/tools/cabana/core/observable.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace observable_detail { +struct HandlerTable { + virtual ~HandlerTable() = default; + virtual void erase(int id) = 0; +}; +} + + +class Connection { +public: + Connection() = default; + Connection(std::weak_ptr table, int id) : table_(std::move(table)), id_(id) {} + Connection(Connection &&other) noexcept { *this = std::move(other); } + Connection &operator=(Connection &&other) noexcept { + if (this != &other) { + disconnect(); + table_ = std::move(other.table_); + id_ = std::exchange(other.id_, -1); + } + return *this; + } + Connection(const Connection &) = delete; + Connection &operator=(const Connection &) = delete; + ~Connection() { disconnect(); } + + void disconnect() { + if (auto table = table_.lock()) table->erase(id_); + table_.reset(); + id_ = -1; + } + +private: + std::weak_ptr table_; + int id_ = -1; +}; + +using Connections = std::vector; + + +template +class Observable { +public: + using Handler = std::function; + + Observable() = default; + Observable(const Observable &) = delete; + Observable &operator=(const Observable &) = delete; + + [[nodiscard]] Connection connect(Handler handler) { + int id = table_->next_id++; + table_->handlers.emplace(id, std::make_shared(std::move(handler))); + return Connection(table_, id); + } + + void operator()(Args... args) const { + auto table = table_; + std::vector ids; + ids.reserve(table->handlers.size()); + for (const auto &[id, _] : table->handlers) ids.push_back(id); + for (int id : ids) { + auto it = table->handlers.find(id); + if (it == table->handlers.end()) continue; + auto handler = it->second; + (*handler)(args...); + } + } + +private: + struct Table : observable_detail::HandlerTable { + std::map> handlers; + int next_id = 0; + void erase(int id) override { handlers.erase(id); } + }; + std::shared_ptr table_ = std::make_shared
(); +}; diff --git a/iqpilot/tools/cabana/core/settings.h b/iqpilot/tools/cabana/core/settings.h index cad152de9..3df79891d 100644 --- a/iqpilot/tools/cabana/core/settings.h +++ b/iqpilot/tools/cabana/core/settings.h @@ -5,18 +5,18 @@ constexpr int LIGHT_THEME = 1; constexpr int DARK_THEME = 2; +constexpr int STREAM_UPDATE_FPS = 30; struct CabanaSettingsState { enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE }; bool absolute_time = false; - int fps = 10; int max_cached_minutes = 30; int chart_height = 200; int chart_column_count = 1; int chart_range = 3 * 60; int chart_series_type = 0; - int theme = 0; + int theme = LIGHT_THEME; int sparkline_range = 15; bool multiple_lines_hex = false; bool log_livestream = true; diff --git a/iqpilot/tools/cabana/dbc/dbc.cc b/iqpilot/tools/cabana/dbc/dbc.cc index c8a794c73..759ff8877 100644 --- a/iqpilot/tools/cabana/dbc/dbc.cc +++ b/iqpilot/tools/cabana/dbc/dbc.cc @@ -14,7 +14,7 @@ int numDecimals(double value) { } } -// cabana::Msg + cabana::Msg::~Msg() { for (auto s : sigs) { @@ -77,7 +77,7 @@ int cabana::Msg::indexOf(const cabana::Signal *sig) const { std::string cabana::Msg::newSignalName() { std::string new_name; - for (int i = 1; /**/; ++i) { + for (int i = 1; ; ++i) { new_name = "NEW_SIGNAL_" + std::to_string(i); if (sig(new_name) == nullptr) break; } @@ -91,7 +91,7 @@ void cabana::Msg::update() { mask.assign(size, 0x00); multiplexor = nullptr; - // sort signals + std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return std::tie(r->type, l->multiplex_value, l->start_bit, l->name) < std::tie(l->type, r->multiplex_value, r->start_bit, r->name); @@ -103,7 +103,7 @@ void cabana::Msg::update() { } sig->update(); - // update mask + int i = sig->msb / 8; int bits = sig->size; while (i >= 0 && i < size && bits > 0) { @@ -131,7 +131,7 @@ void cabana::Msg::update() { } } -// cabana::Signal + void cabana::Signal::update() { updateMsbLsb(*this); @@ -150,7 +150,7 @@ void cabana::Signal::update() { } std::string cabana::Signal::formatValue(double value, bool with_unit) const { - // Show enum string + int64_t raw_value = round((value - offset) / factor); for (const auto &[val, desc] : val_desc) { if (std::abs(raw_value - val) < 1e-6) { @@ -185,7 +185,7 @@ bool cabana::Signal::operator==(const cabana::Signal &other) const { multiplex_value == other.multiplex_value && type == other.type && receiver_name == other.receiver_name; } -// helper functions + double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig) { const int msb_byte = sig.msb / 8; @@ -194,11 +194,11 @@ double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal const int lsb_byte = sig.lsb / 8; uint64_t val = 0; - // Fast path: signal fits in a single byte + if (msb_byte == lsb_byte) { val = (data[msb_byte] >> (sig.lsb & 7)) & ((1ULL << sig.size) - 1); } else { - // Multi-byte case: signal spans across multiple bytes + int bits = sig.size; int i = msb_byte; const int step = sig.is_little_endian ? -1 : 1; @@ -212,7 +212,7 @@ double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal } } - // Sign extension (if needed) + if (sig.is_signed && (val & (1ULL << (sig.size - 1)))) { val |= ~((1ULL << sig.size) - 1); } diff --git a/iqpilot/tools/cabana/dbc/dbc.h b/iqpilot/tools/cabana/dbc/dbc.h index 585325d39..5bd7cd619 100644 --- a/iqpilot/tools/cabana/dbc/dbc.h +++ b/iqpilot/tools/cabana/dbc/dbc.h @@ -50,7 +50,7 @@ public: int precision = 0; CabanaColor color; - // Multiplexed + int multiplex_value = 0; Signal *multiplexor = nullptr; }; @@ -81,9 +81,9 @@ public: cabana::Signal *multiplexor = nullptr; }; -} // namespace cabana +} + -// Helper functions double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig); void updateMsbLsb(cabana::Signal &s); inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; } diff --git a/iqpilot/tools/cabana/dbc/dbcfile.cc b/iqpilot/tools/cabana/dbc/dbcfile.cc index 99a5b7182..305b68eb6 100644 --- a/iqpilot/tools/cabana/dbc/dbcfile.cc +++ b/iqpilot/tools/cabana/dbc/dbcfile.cc @@ -43,7 +43,7 @@ bool commentComplete(const std::string &line) { return false; } -} // namespace +} DBCFile::DBCFile(const std::string &dbc_file_name) { std::ifstream file(dbc_file_name, std::ios::binary); diff --git a/iqpilot/tools/cabana/dbc/dbcmanager.cc b/iqpilot/tools/cabana/dbc/dbcmanager.cc index 7a95a4f80..bb185efd8 100644 --- a/iqpilot/tools/cabana/dbc/dbcmanager.cc +++ b/iqpilot/tools/cabana/dbc/dbcmanager.cc @@ -17,7 +17,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name return false; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); return true; } @@ -32,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const std::string &name, const s return false; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); return true; } @@ -40,26 +40,26 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::close(DBCFile *dbc_file) { for (auto &[_, f] : dbc_files) { if (f.get() == dbc_file) f = nullptr; } - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::closeAll() { dbc_files.clear(); - if (callbacks_.file_changed) callbacks_.file_changed(); + fileChanged(); } void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->addSignal(sig)) { - if (callbacks_.signal_added) callbacks_.signal_added(id, s); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + signalAdded(id, s); + maskUpdated(); } } } @@ -67,8 +67,8 @@ void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->updateSignal(sig_name, sig)) { - if (callbacks_.signal_updated) callbacks_.signal_updated(s); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + signalUpdated(s); + maskUpdated(); } } } @@ -76,26 +76,26 @@ void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { - if (callbacks_.signal_removed) callbacks_.signal_removed(s); + signalRemoved(s); m->removeSignal(sig_name); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + maskUpdated(); } } } void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) { auto dbc_file = findDBCFile(id); - assert(dbc_file); // This should be impossible + assert(dbc_file); dbc_file->updateMsg(id, name, size, node, comment); - if (callbacks_.msg_updated) callbacks_.msg_updated(id); + msgUpdated(id); } void DBCManager::removeMsg(const MessageId &id) { auto dbc_file = findDBCFile(id); - assert(dbc_file); // This should be impossible + assert(dbc_file); dbc_file->removeMsg(id); - if (callbacks_.msg_removed) callbacks_.msg_removed(id); - if (callbacks_.mask_updated) callbacks_.mask_updated(); + msgRemoved(id); + maskUpdated(); } std::string DBCManager::newMsgName(const MessageId &id) { @@ -126,7 +126,7 @@ cabana::Msg *DBCManager::msg(uint8_t source, const std::string &name) { } std::vector DBCManager::signalNames() { - // Used for autocompletion + std::set names; for (auto &f : allDBCFiles()) { for (auto &[_, m] : f->getMessages()) { @@ -141,12 +141,19 @@ std::vector DBCManager::signalNames() { } int DBCManager::nonEmptyDBCCount() { - auto files = allDBCFiles(); - return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); }); + return nonEmptyDBCFiles().size(); +} + +std::vector DBCManager::nonEmptyDBCFiles() { + std::vector files; + for (auto f : allDBCFiles()) { + if (!f->isEmpty()) files.push_back(f); + } + return files; } DBCFile *DBCManager::findDBCFile(const uint8_t source) { - // Find DBC file that matches id.source, fall back to SOURCE_ALL if no specific DBC is found + auto it = dbc_files.count(source) ? dbc_files.find(source) : dbc_files.find(-1); return it != dbc_files.end() ? it->second.get() : nullptr; } diff --git a/iqpilot/tools/cabana/dbc/dbcmanager.h b/iqpilot/tools/cabana/dbc/dbcmanager.h index 5a09fae03..a729986f4 100644 --- a/iqpilot/tools/cabana/dbc/dbcmanager.h +++ b/iqpilot/tools/cabana/dbc/dbcmanager.h @@ -1,12 +1,12 @@ #pragma once -#include #include #include #include #include #include +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcfile.h" typedef std::set SourceSet; @@ -15,16 +15,6 @@ inline bool operator<(const std::shared_ptr &l, const std::shared_ptr signal_added; - std::function signal_removed; - std::function signal_updated; - std::function msg_updated; - std::function msg_removed; - std::function file_changed; - std::function mask_updated; - }; - DBCManager() = default; bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr); bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error = nullptr); @@ -49,16 +39,23 @@ public: std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); + std::vector nonEmptyDBCFiles(); const SourceSet sources(const DBCFile *dbc_file) const; DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } + + Observable signalAdded; + Observable signalRemoved; + Observable signalUpdated; + Observable msgUpdated; + Observable msgRemoved; + Observable<> fileChanged; + Observable<> maskUpdated; private: std::map> dbc_files; - Callbacks callbacks_; }; DBCManager *dbc(); diff --git a/iqpilot/tools/cabana/dbc/dbcqt.cc b/iqpilot/tools/cabana/dbc/dbcqt.cc deleted file mode 100644 index 4354caf3f..000000000 --- a/iqpilot/tools/cabana/dbc/dbcqt.cc +++ /dev/null @@ -1,18 +0,0 @@ -#include "tools/cabana/dbc/dbcqt.h" - -QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) { - dbc()->setCallbacks({ - .signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); }, - .signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); }, - .signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); }, - .msg_updated = [this](MessageId id) { emit msgUpdated(id); }, - .msg_removed = [this](MessageId id) { emit msgRemoved(id); }, - .file_changed = [this]() { emit DBCFileChanged(); }, - .mask_updated = [this]() { emit maskUpdated(); }, - }); -} - -QtDBCNotifier *dbcNotifier() { - static QtDBCNotifier notifier; - return ¬ifier; -} diff --git a/iqpilot/tools/cabana/dbc/dbcqt.h b/iqpilot/tools/cabana/dbc/dbcqt.h deleted file mode 100644 index b889f854e..000000000 --- a/iqpilot/tools/cabana/dbc/dbcqt.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -Q_DECLARE_METATYPE(MessageId) -Q_DECLARE_METATYPE(ValueDescription) - -class QtDBCNotifier : public QObject { - Q_OBJECT - -public: - explicit QtDBCNotifier(QObject *parent = nullptr); - -signals: - void signalAdded(MessageId id, const cabana::Signal *sig); - void signalRemoved(const cabana::Signal *sig); - void signalUpdated(const cabana::Signal *sig); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id); - void DBCFileChanged(); - void maskUpdated(); -}; - -QtDBCNotifier *dbcNotifier(); diff --git a/iqpilot/tools/cabana/deqt.md b/iqpilot/tools/cabana/deqt.md deleted file mode 100644 index 3bef2d7ab..000000000 --- a/iqpilot/tools/cabana/deqt.md +++ /dev/null @@ -1,53 +0,0 @@ -we're migrating cabana away from Qt and to eventually entirely use imgui - -we are doing it incrementally, in small pieces that are easy to execute and verify. -we will repeat this until we're all done. - -# Cabana Qt API inventory - -these are all still in cabana. we remove them from this list once they're gone. -each bullet is an atomic unit of work. - -our workflow is: -- pick the easiest of the bulleted items from below -- implement it and make] sure it builds -- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test -- then implement the fixes from the above reviewer agents - -some rules -- do not add more Qt usage ever - -- `QObject`, `QMetaObject`, `QMetaType` -- `QApplication`, `QCoreApplication`, `QGuiApplication` -- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` -- `QVariant` -- `QTimer` -- `QWidget`, `QMainWindow`, `QWindow` -- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` -- `QFileDialog` -- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction` -- `QToolBar`, `QToolButton`, `QPushButton` -- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton` -- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` -- `QLabel`, `QGroupBox`, `QFrame` -- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` -- `QDockWidget`, `QStatusBar`, `QProgressBar` -- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` -- `QSizePolicy` -- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` -- `QAbstractItemView`, `QTableView`, `QTreeView` -- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` -- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` -- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` -- `QValidator`, `QIntValidator` -- `QColor`, `QRgb`, `QPalette` -- `QBrush`, `QPen` -- `QPainter`, `QPainterPath`, `QStylePainter` -- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` -- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` -- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` -- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` -- `QSize`, `QSizeF` -- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` -- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` -- `QKeySequence`, `QShortcut`, `QToolTip` diff --git a/iqpilot/tools/cabana/detailwidget.cc b/iqpilot/tools/cabana/detailwidget.cc deleted file mode 100644 index 6b62f5495..000000000 --- a/iqpilot/tools/cabana/detailwidget.cc +++ /dev/null @@ -1,324 +0,0 @@ -#include "tools/cabana/detailwidget.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/mainwin.h" - -// DetailWidget - -DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - - // tabbar - tabbar = new TabBar(this); - tabbar->setUsesScrollButtons(true); - tabbar->setAutoHide(true); - tabbar->setContextMenuPolicy(Qt::CustomContextMenu); - main_layout->addWidget(tabbar); - - createToolBar(); - - // warning - warning_widget = new QWidget(this); - QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget); - warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop); - warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft); - warning_widget->hide(); - main_layout->addWidget(warning_widget); - - // msg widget - splitter = new QSplitter(Qt::Vertical, this); - splitter->addWidget(binary_view = new BinaryView(this)); - splitter->addWidget(signal_view = new SignalView(charts, this)); - binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); - signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); - splitter->setStretchFactor(0, 0); - splitter->setStretchFactor(1, 1); - - tab_widget = new QTabWidget(this); - tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}"); - tab_widget->setTabPosition(QTabWidget::South); - tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg"); - tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs"); - main_layout->addWidget(tab_widget); - - QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered); - QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); }); - QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal); - QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart); - QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); - QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); - QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh); - QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); - QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { - if (index != -1) { - setMessage(tabbar->tabData(index).value()); - } - }); - QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab); - QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState); -} - -void DetailWidget::createToolBar() { - QToolBar *toolbar = new QToolBar(this); - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - toolbar->addWidget(name_label = new ElidedLabel(this)); - name_label->setStyleSheet("QLabel{font-weight:bold;}"); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - -// Heatmap label and radio buttons - toolbar->addWidget(new QLabel(tr("Heatmap:"), this)); - auto *heatmap_live = new QRadioButton(tr("Live"), this); - auto *heatmap_all = new QRadioButton(tr("All"), this); - heatmap_live->setChecked(true); - - toolbar->addWidget(heatmap_live); - toolbar->addWidget(heatmap_all); - - // Edit and remove buttons - toolbar->addSeparator(); - toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg); - action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg); - - layout()->addWidget(toolbar); - - connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); }); - connect(can, &AbstractStream::timeRangeChanged, this, [=](const std::optional> &range) { - auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All"; - heatmap_all->setText(text); - (range ? heatmap_all : heatmap_live)->setChecked(true); - }); -} - -void DetailWidget::showTabBarContextMenu(const QPoint &pt) { - int index = tabbar->tabAt(pt); - if (index >= 0) { - QMenu menu(this); - menu.addAction(tr("Close Other Tabs")); - if (menu.exec(tabbar->mapToGlobal(pt))) { - tabbar->moveTab(index, 0); - tabbar->setCurrentIndex(0); - while (tabbar->count() > 1) { - tabbar->removeTab(1); - } - } - } -} - -int DetailWidget::findOrAddTab(const MessageId& message_id) { - int index = tabbar->count() - 1; - for (/**/; index >= 0; --index) { - if (tabbar->tabData(index).value() == message_id) break; - } - if (index == -1) { - index = tabbar->addTab(QString::fromStdString(message_id.toString())); - tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); - } - return index; -} - -void DetailWidget::setMessage(const MessageId &message_id) { - if (std::exchange(msg_id, message_id) == message_id) return; - - tabbar->blockSignals(true); - int index = findOrAddTab(message_id); - tabbar->setCurrentIndex(index); - tabbar->blockSignals(false); - - setUpdatesEnabled(false); - signal_view->setMessage(msg_id); - binary_view->setMessage(msg_id); - history_log->setMessage(msg_id); - refresh(); - setUpdatesEnabled(true); -} - -std::pair DetailWidget::serializeMessageIds() const { - QStringList msgs; - for (int i = 0; i < tabbar->count(); ++i) { - MessageId id = tabbar->tabData(i).value(); - msgs.append(QString::fromStdString(id.toString())); - } - return std::make_pair(QString::fromStdString(msg_id.toString()), msgs); -} - -void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) { - tabbar->blockSignals(true); - for (const auto& str_id : msg_ids) { - MessageId id = MessageId::fromString(str_id.toStdString()); - if (dbc()->msg(id) != nullptr) - findOrAddTab(id); - } - tabbar->blockSignals(false); - - auto active_id = MessageId::fromString(active_msg_id.toStdString()); - if (dbc()->msg(active_id) != nullptr) - setMessage(active_id); -} - -void DetailWidget::refresh() { - QStringList warnings; - auto msg = dbc()->msg(msg_id); - if (msg) { - if (msg_id.source == INVALID_SOURCE) { - warnings.push_back(tr("No messages received.")); - } else if (msg->size != can->lastMessage(msg_id).dat.size()) { - warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size)); - } - for (auto s : binary_view->getOverlappingSignals()) { - warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name))); - } - } - QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id)); - name_label->setText(msg_name); - name_label->setToolTip(msg_name); - action_remove_msg->setEnabled(msg != nullptr); - - if (!warnings.isEmpty()) { - warning_label->setText(warnings.join('\n')); - warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle")); - } - warning_widget->setVisible(!warnings.isEmpty()); -} - -void DetailWidget::updateState(const std::set *msgs) { - if ((msgs && !msgs->count(msg_id))) - return; - - if (tab_widget->currentIndex() == 0) - binary_view->updateState(); - else - history_log->updateState(); -} - -void DetailWidget::editMsg() { - auto msg = dbc()->msg(msg_id); - int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); - EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); - if (dlg.exec()) { - UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), - dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString())); - } -} - -void DetailWidget::removeMsg() { - UndoStack::instance()->push(new RemoveMsgCommand(msg_id)); -} - -// EditMessageDialog - -EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent) - : original_name(title), msg_id(msg_id), QDialog(parent) { - setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString()))); - QFormLayout *form_layout = new QFormLayout(this); - - form_layout->addRow("", error_label = new QLabel); - error_label->setVisible(false); - form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this)); - name_edit->setValidator(new NameValidator(name_edit)); - - form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this)); - size_spin->setRange(1, CAN_MAX_DATA_BYTES); - size_spin->setValue(size); - - form_layout->addRow(tr("Node"), node = new QLineEdit(this)); - node->setValidator(new NameValidator(name_edit)); - form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this)); - form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); - - if (auto msg = dbc()->msg(msg_id)) { - node->setText(QString::fromStdString(msg->transmitter)); - comment_edit->setText(QString::fromStdString(msg->comment)); - } - validateName(name_edit->text()); - setFixedWidth(parent->width() * 0.9); - connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName); - connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); -} - -void EditMessageDialog::validateName(const QString &text) { - bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0; - error_label->setVisible(false); - if (!text.isEmpty() && valid && text != original_name) { - valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr; - if (!valid) { - error_label->setText(tr("Name already exists")); - error_label->setVisible(true); - } - } - btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid); -} - -// CenterWidget - -CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->addWidget(welcome_widget = createWelcomeWidget()); -} - -DetailWidget* CenterWidget::ensureDetailWidget() { - if (!detail_widget) { - delete welcome_widget; - welcome_widget = nullptr; - layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this)); - } - return detail_widget; -} - -void CenterWidget::clear() { - delete detail_widget; - detail_widget = nullptr; - if (!welcome_widget) { - layout()->addWidget(welcome_widget = createWelcomeWidget()); - } -} - -QWidget *CenterWidget::createWelcomeWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *main_layout = new QVBoxLayout(w); - main_layout->addStretch(0); - QLabel *logo = new QLabel("CABANA"); - logo->setAlignment(Qt::AlignCenter); - logo->setStyleSheet("font-size:50px;font-weight:bold;"); - main_layout->addWidget(logo); - - auto newShortcutRow = [](const QString &title, const QString &key) { - QHBoxLayout *hlayout = new QHBoxLayout(); - auto btn = new QToolButton(); - btn->setText(key); - btn->setEnabled(false); - hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight); - hlayout->addWidget(btn, 0, Qt::AlignLeft); - return hlayout; - }; - - auto lb = new QLabel(tr("<-Select a message to view details")); - lb->setAlignment(Qt::AlignHCenter); - main_layout->addWidget(lb); - main_layout->addLayout(newShortcutRow("Pause", "Space")); - main_layout->addLayout(newShortcutRow("Help", "F1")); - main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1")); - main_layout->addStretch(0); - - w->setStyleSheet("QLabel{color:darkGray;}"); - w->setBackgroundRole(QPalette::Base); - w->setAutoFillBackground(true); - return w; -} diff --git a/iqpilot/tools/cabana/detailwidget.h b/iqpilot/tools/cabana/detailwidget.h deleted file mode 100644 index 0fe1535c7..000000000 --- a/iqpilot/tools/cabana/detailwidget.h +++ /dev/null @@ -1,75 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include "tools/cabana/binaryview.h" -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/historylog.h" -#include "tools/cabana/signalview.h" -#include "tools/cabana/utils/elidedlabel.h" - -class EditMessageDialog : public QDialog { -public: - EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent); - void validateName(const QString &text); - - MessageId msg_id; - QString original_name; - QDialogButtonBox *btn_box; - QLineEdit *name_edit; - QLineEdit *node; - QTextEdit *comment_edit; - QLabel *error_label; - QSpinBox *size_spin; -}; - -class DetailWidget : public QWidget { - Q_OBJECT - -public: - DetailWidget(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &message_id); - void refresh(); - std::pair serializeMessageIds() const; - void restoreTabs(const QString active_msg_id, const QStringList &msg_ids); - -private: - void createToolBar(); - int findOrAddTab(const MessageId& message_id); - void showTabBarContextMenu(const QPoint &pt); - void editMsg(); - void removeMsg(); - void updateState(const std::set *msgs = nullptr); - - MessageId msg_id; - QLabel *warning_icon, *warning_label; - ElidedLabel *name_label; - QWidget *warning_widget; - TabBar *tabbar; - QTabWidget *tab_widget; - QAction *action_remove_msg; - LogsWidget *history_log; - BinaryView *binary_view; - SignalView *signal_view; - ChartsWidget *charts; - QSplitter *splitter; -}; - -class CenterWidget : public QWidget { - Q_OBJECT -public: - CenterWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } - DetailWidget* getDetailWidget() { return detail_widget; } - DetailWidget* ensureDetailWidget(); - void clear(); - -private: - QWidget *createWelcomeWidget(); - DetailWidget *detail_widget = nullptr; - QWidget *welcome_widget = nullptr; -}; diff --git a/iqpilot/tools/cabana/historylog.cc b/iqpilot/tools/cabana/historylog.cc deleted file mode 100644 index 3fd569c64..000000000 --- a/iqpilot/tools/cabana/historylog.cc +++ /dev/null @@ -1,249 +0,0 @@ -#include "tools/cabana/historylog.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include - -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/export.h" - -QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { - const auto &m = messages[index.row()]; - const int col = index.column(); - if (role == Qt::DisplayRole) { - if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3); - if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); - } else if (role == Qt::TextAlignmentRole) { - return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); - } - - if (isHexMode() && col == 1) { - if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors)); - if (role == BytesRole) return QVariant::fromValue((void *)(&m.data)); - } - return {}; -} - -void HistoryLogModel::setMessage(const MessageId &message_id) { - msg_id = message_id; - reset(); -} - -void HistoryLogModel::reset() { - beginResetModel(); - sigs.clear(); - if (auto dbc_msg = dbc()->msg(msg_id)) { - sigs = dbc_msg->getSignals(); - } - messages.clear(); - hex_colors = {}; - endResetModel(); - setFilter(0, "", nullptr); -} - -QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal) { - if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { - if (section == 0) return "Time"; - if (isHexMode()) return "Data"; - - QString name = QString::fromStdString(sigs[section - 1]->name); - QString unit = QString::fromStdString(sigs[section - 1]->unit); - return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit); - } else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) { - // Alpha-blend the signal color with the background to ensure contrast - QColor sigColor = toQColor(sigs[section - 1]->color); - sigColor.setAlpha(128); - return QBrush(sigColor); - } - } - return {}; -} - -void HistoryLogModel::setHexMode(bool hex) { - hex_mode = hex; - reset(); -} - -void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function cmp) { - filter_sig_idx = sig_idx; - filter_value = value.toDouble(); - filter_cmp = value.isEmpty() ? nullptr : cmp; - updateState(true); -} - -void HistoryLogModel::updateState(bool clear) { - if (clear && !messages.empty()) { - beginRemoveRows({}, 0, messages.size() - 1); - messages.clear(); - endRemoveRows(); - } - uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1; - fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time); -} - -bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const { - const auto &events = can->events(msg_id); - return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time; -} - -void HistoryLogModel::fetchMore(const QModelIndex &parent) { - if (!messages.empty()) - fetchData(messages.end(), messages.back().mono_time, 0); -} - -void HistoryLogModel::fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { - const auto &events = can->events(msg_id); - auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { - return ts > e->mono_time; - }); - - std::vector msgs; - std::vector values(sigs.size()); - msgs.reserve(batch_size); - for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { - const CanEvent *e = *first; - for (int i = 0; i < sigs.size(); ++i) { - sigs[i]->getValue(e->dat, e->size, &values[i]); - } - if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) { - msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); - if (msgs.size() >= batch_size && min_time == 0) { - break; - } - } - } - - if (!msgs.empty()) { - if (isHexMode() && (min_time > 0 || messages.empty())) { - const auto freq = can->lastMessage(msg_id).freq; - const std::vector no_mask; - for (auto &m : msgs) { - hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); - m.colors = hex_colors.colors; - } - } - int pos = std::distance(messages.begin(), insert_pos); - beginInsertRows({}, pos , pos + msgs.size() - 1); - messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); - endInsertRows(); - } -} - -// HeaderView - -QSize HeaderView::sectionSizeFromContents(int logicalIndex) const { - static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + QSize(10, 6); - if (logicalIndex == 0) { - return time_col_size; - } else { - int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1)); - QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString(); - const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' ')); - QSize size = rect.size() + QSize{10, 6}; - return QSize{qMax(size.width(), default_size), size.height()}; - } -} - -void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const { - auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole); - if (bg_role.isValid()) { - painter->fillRect(rect, bg_role.value()); - } - QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString(); - painter->setPen(palette().color(utils::isDarkTheme() ? QPalette::BrightText : QPalette::Text)); - painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' ')); -} - -// LogsWidget - -LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - - QWidget *toolbar = new QWidget(this); - toolbar->setAutoFillBackground(true); - QHBoxLayout *h = new QHBoxLayout(toolbar); - - filters_widget = new QWidget(this); - QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget); - filter_layout->setContentsMargins(0, 0, 0, 0); - filter_layout->addWidget(display_type_cb = new QComboBox(this)); - filter_layout->addWidget(signals_cb = new QComboBox(this)); - filter_layout->addWidget(comp_box = new QComboBox(this)); - filter_layout->addWidget(value_edit = new QLineEdit(this)); - h->addWidget(filters_widget); - h->addStretch(0); - export_btn = new ToolButton("filetype-csv", tr("Export to CSV file...")); - h->addWidget(export_btn, 0, Qt::AlignRight); - - display_type_cb->addItems({"Signal", "Hex"}); - display_type_cb->setToolTip(tr("Display signal value or raw hex value")); - comp_box->addItems({">", "=", "!=", "<"}); - value_edit->setClearButtonEnabled(true); - value_edit->setValidator(new DoubleValidator(this)); - - main_layout->addWidget(toolbar); - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - main_layout->addWidget(line); - main_layout->addWidget(logs = new QTableView(this)); - logs->setModel(model = new HistoryLogModel(this)); - logs->setItemDelegate(delegate = new MessageBytesDelegate(this)); - logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this)); - logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap); - logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed); - logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height()); - logs->setFrameShape(QFrame::NoFrame); - - QObject::connect(display_type_cb, qOverload(&QComboBox::activated), model, &HistoryLogModel::setHexMode); - QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged())); - QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); - QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); - QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset); - QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); - QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); -} - -void LogsWidget::modelReset() { - signals_cb->clear(); - for (auto s : model->sigs) { - signals_cb->addItem(QString::fromStdString(s->name)); - } - export_btn->setEnabled(false); - value_edit->clear(); - comp_box->setCurrentIndex(0); - filters_widget->setVisible(!model->sigs.empty()); -} - -void LogsWidget::filterChanged() { - if (value_edit->text().isEmpty() && !value_edit->isModified()) return; - - std::function cmp = nullptr; - switch (comp_box->currentIndex()) { - case 0: cmp = std::greater{}; break; - case 1: cmp = std::equal_to{}; break; - case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal - case 3: cmp = std::less{}; break; - } - model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp); -} - -void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); - QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))), - dir, tr("csv (*.csv)")); - if (!fn.isEmpty()) { - model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id) - : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); - } -} diff --git a/iqpilot/tools/cabana/historylog.h b/iqpilot/tools/cabana/historylog.h deleted file mode 100644 index 1d3200b20..000000000 --- a/iqpilot/tools/cabana/historylog.h +++ /dev/null @@ -1,81 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -class HeaderView : public QHeaderView { -public: - HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {} - QSize sectionSizeFromContents(int logicalIndex) const override; - void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const; -}; - -class HistoryLogModel : public QAbstractTableModel { - Q_OBJECT - -public: - HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) {} - void setMessage(const MessageId &message_id); - void updateState(bool clear = false); - void setFilter(int sig_idx, const QString &value, std::function cmp); - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - void fetchMore(const QModelIndex &parent) override; - bool canFetchMore(const QModelIndex &parent) const override; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); } - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; } - inline bool isHexMode() const { return sigs.empty() || hex_mode; } - void reset(); - void setHexMode(bool hex_mode); - - struct Message { - uint64_t mono_time = 0; - std::vector sig_values; - std::vector data; - std::vector colors; - }; - - void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); - - MessageId msg_id; - CanData hex_colors; - const int batch_size = 50; - int filter_sig_idx = -1; - double filter_value = 0; - std::function filter_cmp = nullptr; - std::deque messages; - std::vector sigs; - bool hex_mode = false; -}; - -class LogsWidget : public QFrame { - Q_OBJECT - -public: - LogsWidget(QWidget *parent); - void setMessage(const MessageId &message_id) { model->setMessage(message_id); } - void updateState() { model->updateState(); } - void showEvent(QShowEvent *event) override { model->updateState(true); } - -private slots: - void filterChanged(); - void exportToCSV(); - void modelReset(); - -private: - QTableView *logs; - HistoryLogModel *model; - QComboBox *signals_cb, *comp_box, *display_type_cb; - QLineEdit *value_edit; - QWidget *filters_widget; - ToolButton *export_btn; - MessageBytesDelegate *delegate; -}; diff --git a/iqpilot/tools/cabana/mainwin.cc b/iqpilot/tools/cabana/mainwin.cc deleted file mode 100644 index 76a73c6f1..000000000 --- a/iqpilot/tools/cabana/mainwin.cc +++ /dev/null @@ -1,741 +0,0 @@ -#include "tools/cabana/mainwin.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "third_party/json11/json11.hpp" -#include "tools/cabana/commands.h" -#include "tools/cabana/streamselector.h" -#include "tools/cabana/tools/findsignal.h" -#include "tools/cabana/utils/export.h" -// IQ.Pilot patch: iqpilot has no py_downloader; installDownloadProgressHandler still -// lives in replay/util.h here. -#include "tools/replay/util.h" - -MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() { - loadFingerprints(); - createDockWindows(); - setCentralWidget(center_widget = new CenterWidget(this)); - createActions(); - createStatusBar(); - createShortcuts(); - - // save default window state to allow resetting it - default_state = utils::toBytes(saveState()); - - // restore states; restoreGeometry() itself corrects stale off-screen geometry - restoreGeometry(utils::qbytes(settings.geometry)); - restoreState(utils::qbytes(settings.window_state)); - - // install handlers - static auto static_main_win = this; - qRegisterMetaType("uint64_t"); - qRegisterMetaType("SourceSet"); - installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { - emit static_main_win->updateProgressBar(cur, total, success); - }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { - emit static_main_win->showMessage(QString::fromStdString(msg), 2000); - }); - - setStyleSheet(QString(R"(QMainWindow::separator { - width: %1px; /* when vertical */ - height: %1px; /* when horizontal */ - })").arg(style()->pixelMetric(QStyle::PM_SplitterWidth))); - - QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); - QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); - QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged); - QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); - - QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); - show(); -} - -void MainWindow::loadFingerprints() { - std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString()); - if (!json_file) return; - const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; - std::string err; - auto doc = json11::Json::parse(contents, err); - if (!err.empty() || !doc.is_object()) return; - fingerprint_to_dbc.clear(); - for (const auto &kv : doc.object_items()) { - if (kv.second.is_string()) { - fingerprint_to_dbc.emplace(kv.first, kv.second.string_value()); - } - } -} - -void MainWindow::createActions() { - // File menu - QMenu *file_menu = menuBar()->addMenu(tr("&File")); - file_menu->addAction(tr("Open Stream..."), this, &MainWindow::selectAndOpenStream); - close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream); - export_to_csv_act = file_menu->addAction(tr("Export to CSV..."), this, &MainWindow::exportToCSV); - close_stream_act->setEnabled(false); - export_to_csv_act->setEnabled(false); - file_menu->addSeparator(); - - file_menu->addAction(tr("New DBC File"), [this]() { newFile(); }, QKeySequence::New); - file_menu->addAction(tr("Open DBC File..."), [this]() { openFile(); }, QKeySequence::Open); - - manage_dbcs_menu = file_menu->addMenu(tr("Manage &DBC Files")); - QObject::connect(manage_dbcs_menu, &QMenu::aboutToShow, this, &MainWindow::updateLoadSaveMenus); - - open_recent_menu = file_menu->addMenu(tr("Open &Recent")); - QObject::connect(open_recent_menu, &QMenu::aboutToShow, this, &MainWindow::updateRecentFileMenu); - - file_menu->addSeparator(); - QMenu *load_iqdbc_menu = file_menu->addMenu(tr("Load DBC from commaai/iqdbc")); - // load_iqdbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - std::vector dbc_names; - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { - if (entry.is_regular_file() && entry.path().extension() == ".dbc") { - dbc_names.push_back(entry.path().filename().string()); - } - } - std::sort(dbc_names.begin(), dbc_names.end()); - for (const auto &dbc_name : dbc_names) { - QString name = QString::fromStdString(dbc_name); - load_iqdbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); - } - - file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); - - file_menu->addSeparator(); - save_dbc = file_menu->addAction(tr("Save DBC..."), this, &MainWindow::save, QKeySequence::Save); - save_dbc_as = file_menu->addAction(tr("Save DBC As..."), this, &MainWindow::saveAs, QKeySequence::SaveAs); - copy_dbc_to_clipboard = file_menu->addAction(tr("Copy DBC To Clipboard"), this, &MainWindow::saveToClipboard); - - file_menu->addSeparator(); - file_menu->addAction(tr("Settings..."), this, &MainWindow::setOption, QKeySequence::Preferences); - - file_menu->addSeparator(); - file_menu->addAction(tr("E&xit"), qApp, &QApplication::closeAllWindows, QKeySequence::Quit); - - // Edit Menu - QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); - undo_act->setShortcuts(QKeySequence::Undo); - redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); - redo_act->setShortcuts(QKeySequence::Redo); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); - updateUndoRedoActions(); - - // View Menu - QMenu *view_menu = menuBar()->addMenu(tr("&View")); - auto act = view_menu->addAction(tr("Full Screen"), this, &MainWindow::toggleFullScreen, QKeySequence::FullScreen); - addAction(act); - view_menu->addSeparator(); - view_menu->addAction(messages_dock->toggleViewAction()); - view_menu->addAction(video_dock->toggleViewAction()); - view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); - - // Tools Menu - tools_menu = menuBar()->addMenu(tr("&Tools")); - tools_menu->addAction(tr("Find &Similar Bits"), this, &MainWindow::findSimilarBits); - tools_menu->addAction(tr("&Find Signal"), this, &MainWindow::findSignal); - - // Help Menu - QMenu *help_menu = menuBar()->addMenu(tr("&Help")); - help_menu->addAction(tr("Help"), this, &MainWindow::onlineHelp, QKeySequence::HelpContents); - help_menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt); -} - -void MainWindow::createDockWindows() { - messages_dock = new QDockWidget(tr("MESSAGES"), this); - messages_dock->setObjectName("MessagesPanel"); - messages_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea); - messages_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::LeftDockWidgetArea, messages_dock); - - video_dock = new QDockWidget("", this); - video_dock->setObjectName(tr("VideoPanel")); - video_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); - video_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); - addDockWidget(Qt::RightDockWidgetArea, video_dock); -} - -void MainWindow::createDockWidgets() { - messages_widget = new MessagesWidget(this); - messages_dock->setWidget(messages_widget); - QObject::connect(messages_widget, &MessagesWidget::titleChanged, messages_dock, &QDockWidget::setWindowTitle); - QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage); - - // right panel - charts_widget = new ChartsWidget(this); - QWidget *charts_container = new QWidget(this); - charts_layout = new QVBoxLayout(charts_container); - charts_layout->setContentsMargins(0, 0, 0, 0); - charts_layout->addWidget(charts_widget); - - // splitter between video and charts - video_splitter = new QSplitter(Qt::Vertical, this); - video_widget = new VideoWidget(this); - video_splitter->addWidget(video_widget); - - video_splitter->addWidget(charts_container); - video_splitter->setStretchFactor(1, 1); - video_splitter->restoreState(utils::qbytes(settings.video_splitter_state)); - video_splitter->handle(1)->setEnabled(!can->liveStreaming()); - video_dock->setWidget(video_splitter); - QObject::connect(charts_widget, &ChartsWidget::toggleChartsDocking, this, &MainWindow::toggleChartsDocking); - QObject::connect(charts_widget, &ChartsWidget::showTip, video_widget, &VideoWidget::showThumbnail); -} - -void MainWindow::createStatusBar() { - progress_bar = new QProgressBar(); - progress_bar->setRange(0, 100); - progress_bar->setTextVisible(true); - progress_bar->setFixedSize({300, 16}); - progress_bar->setVisible(false); - statusBar()->addWidget(new QLabel(tr("For Help, Press F1"))); - statusBar()->addPermanentWidget(progress_bar); - statusBar()->addPermanentWidget(status_label = new QLabel(this)); - updateStatus(); -} - -void MainWindow::createShortcuts() { - auto shortcut = new QShortcut(QKeySequence(Qt::Key_Space), this, nullptr, nullptr, Qt::ApplicationShortcut); - QObject::connect(shortcut, &QShortcut::activated, this, []() { - if (can) can->pause(!can->isPaused()); - }); - // TODO: add more shortcuts here. -} - -void MainWindow::undoStackCleanChanged(bool clean) { - setWindowModified(!clean); -} - -void MainWindow::updateUndoRedoActions() { - auto stack = UndoStack::instance(); - undo_act->setEnabled(stack->canUndo()); - undo_act->setText(stack->canUndo() ? tr("&Undo %1").arg(QString::fromStdString(stack->undoText())) : tr("&Undo")); - redo_act->setEnabled(stack->canRedo()); - redo_act->setText(stack->canRedo() ? tr("&Redo %1").arg(QString::fromStdString(stack->redoText())) : tr("&Redo")); -} - -void MainWindow::DBCFileChanged() { - UndoStack::instance()->clear(); - - // Update file menu - int cnt = dbc()->nonEmptyDBCCount(); - save_dbc->setText(cnt > 1 ? tr("Save %1 DBCs...").arg(cnt) : tr("Save DBC...")); - save_dbc->setEnabled(cnt > 0); - save_dbc_as->setEnabled(cnt == 1); - // TODO: Support clipboard for multiple files - copy_dbc_to_clipboard->setEnabled(cnt == 1); - manage_dbcs_menu->setEnabled(dynamic_cast(can) == nullptr); - - QStringList title; - for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); - } - setWindowFilePath(title.join(" | ")); - - QTimer::singleShot(0, this, &::MainWindow::restoreSessionState); -} - -void MainWindow::selectAndOpenStream() { - StreamSelector dlg(this); - if (dlg.exec()) { - openStream(dlg.stream(), dlg.dbcFile()); - } else if (!can) { - openStream(new DummyStream(this)); - } -} - -void MainWindow::closeStream() { - openStream(new DummyStream(this)); - if (dbc()->nonEmptyDBCCount() > 0) { - emit dbcNotifier()->DBCFileChanged(); - } - statusBar()->showMessage(tr("stream closed")); -} - -void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())); - QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)")); - if (!fn.isEmpty()) { - utils::exportToCSV(fn.toStdString()); - } -} - -void MainWindow::newFile(SourceSet s) { - closeFile(s); - dbc()->open(s, std::string(""), std::string("")); -} - -void MainWindow::openFile(SourceSet s) { - remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); - if (!fn.isEmpty()) { - loadFile(fn, s); - } -} - -void MainWindow::loadFile(const QString &fn, SourceSet s) { - if (!fn.isEmpty()) { - closeFile(s); - - std::string error; - if (dbc()->open(s, fn.toStdString(), &error)) { - updateRecentFiles(fn); - statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000); - } else { - QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn)); - msg_box.setDetailedText(QString::fromStdString(error)); - msg_box.exec(); - } - } -} - -void MainWindow::loadDBCFromOpendbc(const QString &name) { - loadFile(QString("%1/%2").arg(OPENDBC_FILE_PATH, name)); -} - -void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { - std::string text; - if (!utils::getClipboardText(&text)) { - QMessageBox::warning(this, tr("Load From Clipboard"), tr("No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland).")); - return; - } - if (text.empty()) { - QMessageBox::warning(this, tr("Load From Clipboard"), tr("Clipboard is empty.")); - return; - } - - closeFile(s); - - std::string error; - bool ret = dbc()->open(s, std::string(""), text, &error); - if (ret && dbc()->nonEmptyDBCCount() > 0) { - QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!")); - } else { - QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format.")); - msg_box.setDetailedText(QString::fromStdString(error)); - msg_box.exec(); - } -} - -void MainWindow::openStream(AbstractStream *stream, const QString &dbc_file) { - if (can) { - QObject::connect(can, &QObject::destroyed, this, [=]() { startStream(stream, dbc_file); }); - can->deleteLater(); - } else { - startStream(stream, dbc_file); - } -} - -void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { - center_widget->clear(); - delete messages_widget; - delete video_splitter; - - can = stream; - can->setParent(this); // take ownership - can->start(); - - loadFile(dbc_file); - statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000); - - bool has_stream = dynamic_cast(can) == nullptr; - close_stream_act->setEnabled(has_stream); - export_to_csv_act->setEnabled(has_stream); - tools_menu->setEnabled(has_stream); - createDockWidgets(); - - video_dock->setWindowTitle(QString::fromStdString(can->routeName())); - if (can->liveStreaming() || video_splitter->sizes()[0] == 0) { - // display video at minimum size. - video_splitter->setSizes({1, 1}); - } - // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount()) { - newFile(); - } - - QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged); - - if (has_stream) { - auto wait_dlg = new QProgressDialog( - can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."), - tr("&Abort"), 0, 100, this); - wait_dlg->setWindowModality(Qt::WindowModal); - wait_dlg->setFixedSize(400, wait_dlg->sizeHint().height()); - QObject::connect(wait_dlg, &QProgressDialog::canceled, this, &MainWindow::close); - QObject::connect(can, &AbstractStream::eventsMerged, wait_dlg, &QProgressDialog::deleteLater); - QObject::connect(this, &MainWindow::updateProgressBar, wait_dlg, [=](uint64_t cur, uint64_t total, bool success) { - wait_dlg->setValue((int)((cur / (double)total) * 100)); - }); - } -} - -void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { - video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(QString::fromStdString(can->routeName())) - .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); - // Don't overwrite already loaded DBC - auto it = fingerprint_to_dbc.find(car_fingerprint.toStdString()); - if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc.end()) { - QTimer::singleShot(0, this, [this, dbc_name = QString::fromStdString(it->second)]() { - loadDBCFromOpendbc(dbc_name + ".dbc"); - }); - } - } -} - -void MainWindow::save() { - // Save all open DBC files - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFile(dbc_file); - } -} - -void MainWindow::saveAs() { - // Save as all open DBC files. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileAs(dbc_file); - } -} - -void MainWindow::closeFile(SourceSet s) { - remindSaveChanges(); - if (s == SOURCE_ALL) { - dbc()->closeAll(); - } else { - dbc()->close(s); - } -} - -void MainWindow::closeFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - remindSaveChanges(); - dbc()->close(dbc_file); - // Ensure we always have at least one file open - if (dbc()->dbcCount() == 0) { - newFile(); - } -} - -void MainWindow::saveFile(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - if (!dbc_file->filename.empty()) { - dbc_file->save(); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved"), 2000); - } else if (!dbc_file->isEmpty()) { - saveFileAs(dbc_file); - } -} - -void MainWindow::saveFileAs(DBCFile *dbc_file) { - QString title = tr("Save File (bus: %1)").arg(QString::fromStdString(toString(dbc()->sources(dbc_file)))); - QString fn = QFileDialog::getSaveFileName(this, title, QString::fromStdString((std::filesystem::path(settings.last_dir) / "untitled.dbc").string()), tr("DBC (*.dbc)")); - if (!fn.isEmpty()) { - dbc_file->saveAs(fn.toStdString()); - UndoStack::instance()->setClean(); - statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); - updateRecentFiles(fn); - } -} - -void MainWindow::saveToClipboard() { - // Copy all open DBC files to clipboard. Should not be called with more than 1 file open - for (auto dbc_file : dbc()->allDBCFiles()) { - if (dbc_file->isEmpty()) continue; - saveFileToClipboard(dbc_file); - } -} - -void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { - assert(dbc_file != nullptr); - if (utils::setClipboardText(dbc_file->generateDBC())) { - QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); - } else { - QMessageBox::warning(this, tr("Copy To Clipboard"), tr("Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland).")); - } -} - -void MainWindow::updateLoadSaveMenus() { - manage_dbcs_menu->clear(); - - for (int source : can->sources) { - if (source >= 64) continue; // Sent and blocked buses are handled implicitly - - SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; - - QMenu *bus_menu = new QMenu(this); - bus_menu->addAction(tr("New DBC File..."), [=]() { newFile(ss); }); - bus_menu->addAction(tr("Open DBC File..."), [=]() { openFile(ss); }); - bus_menu->addAction(tr("Load DBC From Clipboard..."), [=]() { loadFromClipboard(ss, false); }); - - // Show sub-menu for each dbc for this source. - auto dbc_file = dbc()->findDBCFile(source); - if (dbc_file) { - bus_menu->addSeparator(); - bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(toString(dbc()->sources(dbc_file))) + ")")->setEnabled(false); - bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); }); - bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); }); - bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); }); - bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); }); - bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); }); - } - bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); - - manage_dbcs_menu->addMenu(bus_menu); - } -} - -void MainWindow::updateRecentFiles(const QString &fn) { - const std::string filename = fn.toStdString(); - settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), filename), settings.recent_files.end()); - settings.recent_files.insert(settings.recent_files.begin(), filename); - while (settings.recent_files.size() > MAX_RECENT_FILES) { - settings.recent_files.pop_back(); - } - settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); -} - -void MainWindow::updateRecentFileMenu() { - open_recent_menu->clear(); - - int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); - if (!num_recent_files) { - open_recent_menu->addAction(tr("No Recent Files"))->setEnabled(false); - return; - } - - for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QString::fromStdString(std::filesystem::path(settings.recent_files[i]).filename().string())); - open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); }); - } -} - -void MainWindow::remindSaveChanges() { - while (!UndoStack::instance()->isClean()) { - QString text = tr("You have unsaved changes. Press ok to save them, cancel to discard."); - int ret = QMessageBox::question(this, tr("Unsaved Changes"), text, QMessageBox::Ok | QMessageBox::Cancel); - if (ret != QMessageBox::Ok) break; - save(); - } - UndoStack::instance()->clear(); -} - -void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { - if (success && cur < total) { - progress_bar->setValue((cur / (double)total) * 100); - progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str())); - progress_bar->show(); - } else { - progress_bar->hide(); - } -} - -void MainWindow::updateStatus() { - status_label->setText(tr("Cached Minutes:%1 FPS:%2").arg(settings.max_cached_minutes).arg(settings.fps)); -} - -bool MainWindow::eventFilter(QObject *obj, QEvent *event) { - if (obj == floating_window && event->type() == QEvent::Close) { - toggleChartsDocking(); - return true; - } - return QMainWindow::eventFilter(obj, event); -} - -void MainWindow::toggleChartsDocking() { - if (floating_window) { - // Dock the charts widget back to the main window - floating_window->removeEventFilter(this); - charts_layout->insertWidget(0, charts_widget, 1); - floating_window->deleteLater(); - floating_window = nullptr; - charts_widget->setIsDocked(true); - } else { - // Float the charts widget in a separate window - floating_window = new QWidget(this, Qt::Window); - floating_window->setWindowTitle("Charts"); - floating_window->setLayout(new QVBoxLayout()); - floating_window->layout()->addWidget(charts_widget); - floating_window->installEventFilter(this); - floating_window->showMaximized(); - charts_widget->setIsDocked(false); - } -} - -void MainWindow::closeEvent(QCloseEvent *event) { - remindSaveChanges(); - - installDownloadProgressHandler(nullptr); - installMessageHandler(nullptr); - - if (floating_window) - floating_window->deleteLater(); - - // save states - settings.geometry = utils::toBytes(saveGeometry()); - settings.window_state = utils::toBytes(saveState()); - if (can && !can->liveStreaming()) { - settings.video_splitter_state = utils::toBytes(video_splitter->saveState()); - } - if (messages_widget) { - settings.message_header_state = messages_widget->saveHeaderState(); - } - - saveSessionState(); - settings.save(); - QWidget::closeEvent(event); -} - -void MainWindow::setOption() { - SettingsDlg dlg(this); - dlg.exec(); -} - -void MainWindow::findSimilarBits() { - FindSimilarBitsDlg *dlg = new FindSimilarBitsDlg(this); - QObject::connect(dlg, &FindSimilarBitsDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::findSignal() { - FindSignalDlg *dlg = new FindSignalDlg(this); - QObject::connect(dlg, &FindSignalDlg::openMessage, messages_widget, &MessagesWidget::selectMessage); - dlg->show(); -} - -void MainWindow::onlineHelp() { - if (auto help = findChild()) { - help->close(); - } else { - help = new HelpOverlay(this); - help->setGeometry(rect()); - help->show(); - help->raise(); - } -} - -void MainWindow::toggleFullScreen() { - if (isFullScreen()) { - menuBar()->show(); - statusBar()->show(); - showNormal(); - showMaximized(); - } else { - menuBar()->hide(); - statusBar()->hide(); - showFullScreen(); - } -} - -void MainWindow::saveSessionState() { - settings.recent_dbc_file = ""; - settings.active_msg_id = ""; - settings.selected_msg_ids.clear(); - settings.active_charts.clear(); - - for (auto &f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; } - - if (auto *detail = center_widget->getDetailWidget()) { - auto [active_id, ids] = detail->serializeMessageIds(); - settings.active_msg_id = active_id.toStdString(); - settings.selected_msg_ids.clear(); - for (const auto &id : ids) settings.selected_msg_ids.push_back(id.toStdString()); - } - if (charts_widget) { - settings.active_charts.clear(); - for (const auto &id : charts_widget->serializeChartIds()) settings.active_charts.push_back(id.toStdString()); - } -} - -void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; - - QString dbc_file; - for (auto& f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } - if (dbc_file.toStdString() != settings.recent_dbc_file) return; - - if (!settings.selected_msg_ids.empty()) { - QStringList ids; - for (const auto &id : settings.selected_msg_ids) ids.push_back(QString::fromStdString(id)); - center_widget->ensureDetailWidget()->restoreTabs(QString::fromStdString(settings.active_msg_id), ids); - } - - if (charts_widget != nullptr && !settings.active_charts.empty()) { - QStringList ids; - for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); - charts_widget->restoreChartsFromIds(ids); - } -} - -// HelpOverlay -HelpOverlay::HelpOverlay(MainWindow *parent) : QWidget(parent) { - setAttribute(Qt::WA_NoSystemBackground, true); - setAttribute(Qt::WA_TranslucentBackground, true); - setAttribute(Qt::WA_DeleteOnClose); - parent->installEventFilter(this); -} - -void HelpOverlay::paintEvent(QPaintEvent *event) { - QPainter painter(this); - painter.fillRect(rect(), QColor(0, 0, 0, 50)); - auto parent = parentWidget(); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); - drawHelpForWidget(painter, parent->findChild()); -} - -void HelpOverlay::drawHelpForWidget(QPainter &painter, QWidget *w) { - if (w && w->isVisible() && !w->whatsThis().isEmpty()) { - QPoint pt = mapFromGlobal(w->mapToGlobal(w->rect().center())); - if (rect().contains(pt)) { - QTextDocument document; - document.setHtml(w->whatsThis()); - QSize doc_size = document.size().toSize(); - QPoint topleft = {pt.x() - doc_size.width() / 2, pt.y() - doc_size.height() / 2}; - painter.translate(topleft); - painter.fillRect(QRect{{0, 0}, doc_size}, palette().toolTipBase()); - document.drawContents(&painter); - painter.translate(-topleft); - } - } -} - -bool HelpOverlay::eventFilter(QObject *obj, QEvent *event) { - if (obj == parentWidget() && event->type() == QEvent::Resize) { - QResizeEvent *resize_event = (QResizeEvent *)(event); - setGeometry(QRect{QPoint(0, 0), resize_event->size()}); - } - return false; -} - -void HelpOverlay::mouseReleaseEvent(QMouseEvent *event) { - close(); -} diff --git a/iqpilot/tools/cabana/mainwin.h b/iqpilot/tools/cabana/mainwin.h deleted file mode 100644 index 279ea3b96..000000000 --- a/iqpilot/tools/cabana/mainwin.h +++ /dev/null @@ -1,119 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/detailwidget.h" -#include "tools/cabana/messageswidget.h" -#include "tools/cabana/videowidget.h" -#include "tools/cabana/tools/findsimilarbits.h" - -class MainWindow : public QMainWindow { - Q_OBJECT - -public: - MainWindow(AbstractStream *stream, const QString &dbc_file); - void toggleChartsDocking(); - void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); } - void loadFile(const QString &fn, SourceSet s = SOURCE_ALL); - ChartsWidget *charts_widget = nullptr; - -public slots: - void selectAndOpenStream(); - void openStream(AbstractStream *stream, const QString &dbc_file = {}); - void closeStream(); - void exportToCSV(); - - void newFile(SourceSet s = SOURCE_ALL); - void openFile(SourceSet s = SOURCE_ALL); - void loadDBCFromOpendbc(const QString &name); - void save(); - void saveAs(); - void saveToClipboard(); - -signals: - void showMessage(const QString &msg, int timeout); - void updateProgressBar(uint64_t cur, uint64_t total, bool success); - -protected: - void startStream(AbstractStream *stream, QString dbc_file); - bool eventFilter(QObject *obj, QEvent *event) override; - void remindSaveChanges(); - void closeFile(SourceSet s = SOURCE_ALL); - void closeFile(DBCFile *dbc_file); - void saveFile(DBCFile *dbc_file); - void saveFileAs(DBCFile *dbc_file); - void saveFileToClipboard(DBCFile *dbc_file); - void loadFingerprints(); - void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); - void updateRecentFiles(const QString &fn); - void updateRecentFileMenu(); - void createActions(); - void createDockWindows(); - void createStatusBar(); - void createShortcuts(); - void closeEvent(QCloseEvent *event) override; - void DBCFileChanged(); - void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); - void setOption(); - void findSimilarBits(); - void findSignal(); - void undoStackCleanChanged(bool clean); - void updateUndoRedoActions(); - void onlineHelp(); - void toggleFullScreen(); - void updateStatus(); - void updateLoadSaveMenus(); - void createDockWidgets(); - void eventsMerged(); - void saveSessionState(); - void restoreSessionState(); - - VideoWidget *video_widget = nullptr; - QDockWidget *video_dock; - QDockWidget *messages_dock; - MessagesWidget *messages_widget = nullptr; - CenterWidget *center_widget; - QWidget *floating_window = nullptr; - QVBoxLayout *charts_layout; - QProgressBar *progress_bar; - QLabel *status_label; - std::unordered_map fingerprint_to_dbc; - QSplitter *video_splitter = nullptr; - enum { MAX_RECENT_FILES = 15 }; - QMenu *open_recent_menu = nullptr; - QMenu *manage_dbcs_menu = nullptr; - QMenu *tools_menu = nullptr; - QAction *close_stream_act = nullptr; - QAction *export_to_csv_act = nullptr; - QAction *save_dbc = nullptr; - QAction *save_dbc_as = nullptr; - QAction *copy_dbc_to_clipboard = nullptr; - QAction *undo_act = nullptr; - QAction *redo_act = nullptr; - QString car_fingerprint; - std::vector default_state; -}; - -class HelpOverlay : public QWidget { - Q_OBJECT -public: - HelpOverlay(MainWindow *parent); - -protected: - void drawHelpForWidget(QPainter &painter, QWidget *w); - void paintEvent(QPaintEvent *event) override; - void mouseReleaseEvent(QMouseEvent *event) override; - bool eventFilter(QObject *obj, QEvent *event) override; -}; diff --git a/iqpilot/tools/cabana/messageswidget.cc b/iqpilot/tools/cabana/messageswidget.cc deleted file mode 100644 index b07e3ea0b..000000000 --- a/iqpilot/tools/cabana/messageswidget.cc +++ /dev/null @@ -1,465 +0,0 @@ -#include "tools/cabana/messageswidget.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" - -MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - // toolbar - main_layout->addWidget(createToolBar()); - // message table - main_layout->addWidget(view = new MessageView(this)); - view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex)); - view->setModel(model = new MessageListModel(this)); - view->setHeader(header = new MessageViewHeader(this)); - view->setSortingEnabled(true); - view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder); - view->setAllColumnsShowFocus(true); - view->setEditTriggers(QAbstractItemView::NoEditTriggers); - view->setItemsExpandable(false); - view->setIndentation(0); - view->setRootIsDecorated(false); - - // Must be called before setting any header parameters to avoid overriding - restoreHeaderState(settings.message_header_state); - header->setSectionsMovable(true); - header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed); - header->setStretchLastSection(true); - header->setContextMenuPolicy(Qt::CustomContextMenu); - - // signals/slots - QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow); - QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); - QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); - QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified); - QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &MessageListModel::dbcModified); - QObject::connect(model, &MessageListModel::modelReset, [this]() { - if (current_msg_id) { - selectMessage(*current_msg_id); - } - view->updateBytesSectionSize(); - updateTitle(); - }); - QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex ¤t, const QModelIndex &previous) { - if (current.isValid() && current.row() < model->items_.size()) { - const auto &id = model->items_[current.row()].id; - if (!current_msg_id || id != *current_msg_id) { - current_msg_id = id; - emit msgSelectionChanged(*current_msg_id); - } - } - }); - - setWhatsThis(tr(R"( - Message View
- - Byte color
- constant changing
- increasing
- decreasing
- Shortcuts
- Horizontal Scrolling:  shift+wheel  - )")); -} - -QWidget *MessagesWidget::createToolBar() { - QWidget *toolbar = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(toolbar); - layout->setContentsMargins(0, 9, 0, 0); - layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted")); - layout->addWidget(suppress_clear = new QPushButton()); - suppress_clear->setToolTip(tr("Clear suppressed")); - layout->addStretch(1); - QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this); - suppress_defined_signals->setToolTip(tr("Suppress defined signals")); - suppress_defined_signals->setChecked(settings.suppress_defined_signals); - layout->addWidget(suppress_defined_signals); - - auto view_button = new ToolButton("three-dots", tr("View...")); - view_button->setMenu(menu); - view_button->setPopupMode(QToolButton::InstantPopup); - view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - layout->addWidget(view_button); - - QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted); - QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals); - - suppressHighlighted(); - return toolbar; -} - -void MessagesWidget::updateTitle() { - auto stats = std::accumulate( - model->items_.begin(), model->items_.end(), std::pair(), - [](const auto &pair, const auto &item) { - auto m = dbc()->msg(item.id); - return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; - }); - emit titleChanged(tr("%1 Messages (%2 DBC Messages, %3 Signals)") - .arg(model->items_.size()).arg(stats.first).arg(stats.second)); -} - -void MessagesWidget::selectMessage(const MessageId &msg_id) { - auto it = std::find_if(model->items_.cbegin(), model->items_.cend(), - [&msg_id](auto &item) { return item.id == msg_id; }); - if (it != model->items_.cend()) { - view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0)); - } -} - -void MessagesWidget::suppressHighlighted() { - int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); - suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear")); - suppress_clear->setEnabled(n > 0); -} - -void MessagesWidget::headerContextMenuEvent(const QPoint &pos) { - menu->exec(header->mapToGlobal(pos)); -} - -void MessagesWidget::menuAboutToShow() { - menu->clear(); - for (int i = 0; i < header->count(); ++i) { - int logical_index = header->logicalIndex(i); - auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(), - [=](bool checked) { header->setSectionHidden(logical_index, !checked); }); - action->setCheckable(true); - action->setChecked(!header->isSectionHidden(logical_index)); - // Can't hide the name column - action->setEnabled(logical_index > 0); - } - menu->addSeparator(); - auto action = menu->addAction(tr("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes); - action->setCheckable(true); - action->setChecked(settings.multiple_lines_hex); - - action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages); - action->setCheckable(true); - action->setChecked(model->show_inactive_messages); -} - -void MessagesWidget::setMultiLineBytes(bool multi) { - settings.multiple_lines_hex = multi; - delegate->setMultipleLines(multi); - view->updateBytesSectionSize(); - view->doItemsLayout(); -} - -// MessageListModel - -QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const { - if (orientation == Qt::Horizontal && role == Qt::DisplayRole) { - switch (section) { - case Column::NAME: return tr("Name"); - case Column::SOURCE: return tr("Bus"); - case Column::ADDRESS: return tr("ID"); - case Column::NODE: return tr("Node"); - case Column::FREQ: return tr("Freq"); - case Column::COUNT: return tr("Count"); - case Column::DATA: return tr("Bytes"); - } - } - return {}; -} - -QVariant MessageListModel::data(const QModelIndex &index, int role) const { - if (!index.isValid() || index.row() >= items_.size()) return {}; - - auto getFreq = [](float freq) { - if (freq > 0) { - return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2); - } else { - return QStringLiteral("--"); - } - }; - - const static QString NA = QStringLiteral("N/A"); - const auto &item = items_[index.row()]; - if (role == Qt::DisplayRole) { - switch (index.column()) { - case Column::NAME: return item.name; - case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : NA; - case Column::ADDRESS: return toHexString(item.id.address); - case Column::NODE: return item.node; - case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA; - case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA; - case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA; - } - } else if (role == ColorsRole) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors)); - } else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) { - return QVariant::fromValue((void*)(&can->lastMessage(item.id).dat)); - } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { - auto msg = dbc()->msg(item.id); - auto tooltip = item.name; - if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; - return tooltip; - } - return {}; -} - -void MessageListModel::setFilterStrings(const std::map &filters) { - filters_ = filters; - filterAndSort(); -} - -void MessageListModel::showInactiveMessages(bool show) { - show_inactive_messages = show; - filterAndSort(); -} - -void MessageListModel::dbcModified() { - dbc_messages_.clear(); - for (const auto &[_, m] : dbc()->getMessages(-1)) { - dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); - } - filterAndSort(); -} - -void MessageListModel::sortItems(std::vector &items) { - auto compare = [this](const auto &l, const auto &r) { - switch (sort_column) { - case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); - case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); - case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); - case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); - case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); - case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); - default: return false; // Default case to suppress compiler warning - } - }; - - if (sort_order == Qt::DescendingOrder) - std::stable_sort(items.rbegin(), items.rend(), compare); - else - std::stable_sort(items.begin(), items.end(), compare); -} - -static bool parseRange(const QString &filter, uint32_t value, int base = 10) { - // Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf}) - unsigned int min = std::numeric_limits::min(); - unsigned int max = std::numeric_limits::max(); - auto s = filter.split('-'); - bool ok = s.size() >= 1 && s.size() <= 2; - if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base); - if (ok && s.size() == 1) { - max = min; - } else if (ok && s.size() == 2 && !s[1].isEmpty()) { - max = s[1].toUInt(&ok, base); - } - return ok && value >= min && value <= max; -} - -bool MessageListModel::match(const MessageListModel::Item &item) { - if (filters_.empty()) - return true; - - bool match = true; - const auto &data = can->lastMessage(item.id); - for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { - const QString &txt = it->second; - switch (it->first) { - case Column::NAME: { - match = item.name.contains(txt, Qt::CaseInsensitive); - if (!match) { - const auto m = dbc()->msg(item.id); - match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), - [&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); - } - break; - } - case Column::SOURCE: - match = parseRange(txt, item.id.source); - break; - case Column::ADDRESS: - match = toHexString(item.id.address).contains(txt, Qt::CaseInsensitive); - match = match || parseRange(txt, item.id.address, 16); - break; - case Column::NODE: - match = item.node.contains(txt, Qt::CaseInsensitive); - break; - case Column::FREQ: - match = parseRange(txt, data.freq); - break; - case Column::COUNT: - match = parseRange(txt, data.count); - break; - case Column::DATA: - match = utils::toHex(data.dat).contains(txt, Qt::CaseInsensitive); - break; - } - } - return match; -} - -bool MessageListModel::filterAndSort() { - // merge CAN and DBC messages - std::vector all_messages; - all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); - auto dbc_msgs = dbc_messages_; - for (const auto &[id, m] : can->lastMessages()) { - all_messages.push_back(id); - dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); - } - all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); - - // filter and sort - std::vector items; - items.reserve(all_messages.size()); - for (const auto &id : all_messages) { - if (show_inactive_messages || can->isMessageActive(id)) { - auto msg = dbc()->msg(id); - Item item = {.id = id, - .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), - .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; - if (match(item)) - items.emplace_back(item); - } - } - sortItems(items); - - if (items_ != items) { - beginResetModel(); - items_ = std::move(items); - endResetModel(); - return true; - } - return false; -} - -void MessageListModel::msgsReceived(const std::set *new_msgs, bool has_new_ids) { - if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) && - ++sort_threshold_ == settings.fps)) { - sort_threshold_ = 0; - if (filterAndSort()) return; - } - - // Update viewport - emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1)); -} - -void MessageListModel::sort(int column, Qt::SortOrder order) { - if (column != Column::DATA) { - sort_column = column; - sort_order = order; - filterAndSort(); - } -} - -// MessageView - -void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const auto &item = ((MessageListModel*)model())->items_[index.row()]; - if (!can->isMessageActive(item.id)) { - QStyleOptionViewItem custom_option = option; - custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text)); - auto color = QApplication::palette().color(QPalette::HighlightedText); - color.setAlpha(100); - custom_option.palette.setBrush(QPalette::HighlightedText, color); - QTreeView::drawRow(painter, custom_option, index); - } else { - QTreeView::drawRow(painter, option, index); - } - - QPen oldPen = painter->pen(); - const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this); - painter->setPen(QColor::fromRgba(static_cast(gridHint))); - // Draw bottom border for the row - painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight()); - // Draw vertical borders for each column - for (int i = 0; i < header()->count(); ++i) { - int sectionX = header()->sectionViewportPosition(i); - painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom()); - } - painter->setPen(oldPen); -} - -void MessageView::setModel(QAbstractItemModel *model) { - QTreeView::setModel(model); - // Bypass the slow call to QTreeView::dataChanged. - // QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView. - QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); - QObject::connect(model, &QAbstractItemModel::dataChanged, this, - [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); -} - -void MessageView::updateBytesSectionSize() { - auto delegate = ((MessageBytesDelegate *)itemDelegate()); - int max_bytes = 8; - if (!delegate->multipleLines()) { - for (const auto &[_, m] : can->lastMessages()) { - max_bytes = std::max(max_bytes, m.dat.size()); - } - } - setUniformRowHeights(!delegate->multipleLines()); - header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width()); -} - -void MessageView::wheelEvent(QWheelEvent *event) { - if (event->modifiers() == Qt::ShiftModifier) { - QApplication::sendEvent(horizontalScrollBar(), event); - } else { - QTreeView::wheelEvent(event); - } -} - -// MessageViewHeader - -MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) { - QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions); - QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions); -} - -void MessageViewHeader::updateFilters() { - std::map filters; - for (int i = 0; i < (int)editors.size(); i++) { - if (!editors[i]->text().isEmpty()) { - filters[i] = editors[i]->text(); - } - } - qobject_cast(model())->setFilterStrings(filters); -} - -void MessageViewHeader::updateHeaderPositions() { - QSize sz = QHeaderView::sizeHint(); - for (int i = 0; i < (int)editors.size(); i++) { - int h = editors[i]->sizeHint().height(); - editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); - editors[i]->setHidden(isSectionHidden(i)); - } -} - -void MessageViewHeader::updateGeometries() { - for (int i = (int)editors.size(); i < count(); i++) { - QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); - auto edit = new QLineEdit(this); - edit->setClearButtonEnabled(true); - edit->setPlaceholderText(tr("Filter %1").arg(column_name)); - - QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - editors.push_back(edit); - } - setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); - - QHeaderView::updateGeometries(); - updateHeaderPositions(); -} - -QSize MessageViewHeader::sizeHint() const { - QSize sz = QHeaderView::sizeHint(); - return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; -} diff --git a/iqpilot/tools/cabana/messageswidget.h b/iqpilot/tools/cabana/messageswidget.h deleted file mode 100644 index 0a9cd256d..000000000 --- a/iqpilot/tools/cabana/messageswidget.h +++ /dev/null @@ -1,128 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -class MessageListModel : public QAbstractTableModel { -Q_OBJECT - -public: - enum Column { - NAME = 0, - SOURCE, - ADDRESS, - NODE, - FREQ, - COUNT, - DATA, - }; - - MessageListModel(QObject *parent) : QAbstractTableModel(parent) {} - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } - void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const std::map &filters); - void showInactiveMessages(bool show); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - bool filterAndSort(); - void dbcModified(); - - struct Item { - MessageId id; - QString name; - QString node; - bool operator==(const Item &other) const { - return id == other.id && name == other.name && node == other.node; - } - }; - std::vector items_; - bool show_inactive_messages = true; - -private: - void sortItems(std::vector &items); - bool match(const MessageListModel::Item &id); - - std::map filters_; - std::set dbc_messages_; - int sort_column = 0; - Qt::SortOrder sort_order = Qt::AscendingOrder; - int sort_threshold_ = 0; -}; - -class MessageView : public QTreeView { - Q_OBJECT -public: - MessageView(QWidget *parent) : QTreeView(parent) {} - void updateBytesSectionSize(); - void setModel(QAbstractItemModel *model) override; - -protected: - void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {} - void wheelEvent(QWheelEvent *event) override; -}; - -class MessageViewHeader : public QHeaderView { - // https://stackoverflow.com/a/44346317 - Q_OBJECT -public: - MessageViewHeader(QWidget *parent); - void updateHeaderPositions(); - void updateGeometries() override; - QSize sizeHint() const override; - void updateFilters(); - - std::vector editors; -}; - -class MessagesWidget : public QWidget { - Q_OBJECT - -public: - MessagesWidget(QWidget *parent); - void selectMessage(const MessageId &message_id); - std::vector saveHeaderState() const { - const auto state = view->header()->saveState(); - return {state.begin(), state.end()}; - } - bool restoreHeaderState(const std::vector &state) const { - return view->header()->restoreState({(const char *)state.data(), (int)state.size()}); - } - void suppressHighlighted(); - -signals: - void msgSelectionChanged(const MessageId &message_id); - void titleChanged(const QString &title); - -protected: - QWidget *createToolBar(); - void headerContextMenuEvent(const QPoint &pos); - void menuAboutToShow(); - void setMultiLineBytes(bool multi); - void updateTitle(); - - MessageView *view; - MessageViewHeader *header; - MessageBytesDelegate *delegate; - std::optional current_msg_id; - MessageListModel *model; - QPushButton *suppress_add; - QPushButton *suppress_clear; - QMenu *menu; -}; diff --git a/iqpilot/tools/cabana/panda.cc b/iqpilot/tools/cabana/panda.cc index 0612d6774..c3b00d091 100644 --- a/iqpilot/tools/cabana/panda.cc +++ b/iqpilot/tools/cabana/panda.cc @@ -122,7 +122,7 @@ void Panda::set_data_speed_kbps(uint16_t bus, uint16_t speed) { bool Panda::can_receive(std::vector& out_vec) { - // Check if enough space left in buffer to store RECV_SIZE data + assert(receive_buffer_size + RECV_SIZE <= sizeof(receive_buffer)); int recv = bulk_read(0x81, &receive_buffer[receive_buffer_size], RECV_SIZE); @@ -151,7 +151,7 @@ bool Panda::unpack_can_buffer(uint8_t *data, uint32_t &size, std::vector size) { - // we don't have all the data for this message yet + break; } @@ -177,7 +177,7 @@ bool Panda::unpack_can_buffer(uint8_t *data, uint32_t &size, std::vector list(bool usb_only=false); - // Panda functionality + cereal::PandaState::PandaType get_hw_type(); void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U); void send_heartbeat(bool engaged); @@ -71,18 +71,18 @@ public: void can_reset_communications(); private: - // USB connection members + libusb_context *ctx = nullptr; libusb_device_handle *dev_handle = nullptr; std::string hw_serial_str; std::atomic connected_flag = true; std::atomic comms_healthy_flag = true; - // CAN buffer members + uint8_t receive_buffer[RECV_SIZE + sizeof(can_header) + 64]; uint32_t receive_buffer_size = 0; - // Internal methods + bool init_usb_connection(const std::string& serial); void cleanup_usb(); void handle_usb_issue(int err, const char func[]); diff --git a/iqpilot/tools/cabana/routes.cc b/iqpilot/tools/cabana/routes.cc new file mode 100644 index 000000000..888b30a4c --- /dev/null +++ b/iqpilot/tools/cabana/routes.cc @@ -0,0 +1,115 @@ +#include "tools/cabana/routes.h" + +#include +#include +#include +#include + +#include "json11/json11.hpp" +#include "tools/replay/api.h" + +namespace routes { + +std::pair checkApiResponse(const std::string &result) { + if (result.empty()) return {false, 500}; + std::string err; + auto doc = json11::Json::parse(result, err); + if (!err.empty()) return {false, 500}; + if (doc.is_object() && doc["error"].is_string()) { + return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; + } + return {true, 0}; +} + +int64_t nowUnixMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +int64_t parseIsoToUnixMs(const std::string &s) { + std::string bytes = s; + if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); + int millis = 0; + auto dot = bytes.find('.'); + if (dot != std::string::npos) { + std::string frac = bytes.substr(dot + 1); + bytes = bytes.substr(0, dot); + while (frac.size() < 3) frac.push_back('0'); + millis = std::atoi(frac.substr(0, 3).c_str()); + } + std::tm tm{}; + const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); + if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); + if (!ret) return 0; + time_t secs = timegm(&tm); + if (secs == static_cast(-1)) return 0; + return static_cast(secs) * 1000 + millis; +} + +std::string formatUnixMs(int64_t ms) { + time_t secs = static_cast(ms / 1000); + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + return buf; +} + +std::vector parseDevices(const std::string &json) { + std::vector devices; + std::string err; + auto doc = json11::Json::parse(json, err); + if (err.empty() && doc.is_array()) { + for (const auto &device : doc.array_items()) { + devices.push_back({device["dongle_id"].string_value()}); + } + } + return devices; +} + +std::vector parseRoutes(const std::string &json, bool preserved) { + std::vector items; + std::string err; + auto doc = json11::Json::parse(json, err); + if (err.empty() && doc.is_array()) { + for (const auto &route : doc.array_items()) { + RouteInfo info; + info.name = route["fullname"].string_value(); + if (preserved) { + info.start_ms = parseIsoToUnixMs(route["start_time"].string_value()); + info.end_ms = parseIsoToUnixMs(route["end_time"].string_value()); + } else { + info.start_ms = static_cast(route["start_time_utc_millis"].number_value()); + info.end_ms = static_cast(route["end_time_utc_millis"].number_value()); + } + items.push_back(std::move(info)); + } + } + return items; +} + +void fetchDevices(DevicesCallback callback) { + std::thread([callback = std::move(callback)]() { + std::string result = CommaApi2::getDevices(); + auto [success, error_code] = checkApiResponse(result); + callback(success ? parseDevices(result) : std::vector{}, success, error_code); + }).detach(); +} + +void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback) { + const bool preserved = period_days == -1; + int64_t start_ms = 0, end_ms = 0; + if (!preserved) { + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period_days) * 24LL * 60LL * 60LL * 1000LL; + } + + std::thread([dongle_id, start_ms, end_ms, preserved, callback = std::move(callback)]() { + std::string result = CommaApi2::getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + auto [success, error_code] = checkApiResponse(result); + callback(success ? parseRoutes(result, preserved) : std::vector{}, success, error_code); + }).detach(); +} + +} diff --git a/iqpilot/tools/cabana/routes.h b/iqpilot/tools/cabana/routes.h new file mode 100644 index 000000000..421c9e67f --- /dev/null +++ b/iqpilot/tools/cabana/routes.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace routes { + +struct DeviceInfo { + std::string dongle_id; +}; + +struct RouteInfo { + std::string name; + int64_t start_ms = 0; + int64_t end_ms = 0; +}; + +using DevicesCallback = std::function devices, bool success, int error_code)>; +using RoutesCallback = std::function routes, bool success, int error_code)>; + +std::pair checkApiResponse(const std::string &result); + +int64_t nowUnixMs(); +int64_t parseIsoToUnixMs(const std::string &s); +std::string formatUnixMs(int64_t ms); + +std::vector parseDevices(const std::string &json); +std::vector parseRoutes(const std::string &json, bool preserved); + +void fetchDevices(DevicesCallback callback); +void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback); + +} diff --git a/iqpilot/tools/cabana/settings.cc b/iqpilot/tools/cabana/settings.cc index 8a86f62a2..a87c0342c 100644 --- a/iqpilot/tools/cabana/settings.cc +++ b/iqpilot/tools/cabana/settings.cc @@ -2,41 +2,23 @@ #include #include -#include -#include -#include #include #include #include #include #include -#include -#include #include #include -#include #include #include #include -#ifdef __APPLE__ -#include -#endif - -#include -#include -#include -#include -#include #include -#include "third_party/json11/json11.hpp" +#include "json11/json11.hpp" #include "tools/cabana/utils/util.h" -const int MIN_CACHE_MINIUTES = 30; -const int MAX_CACHE_MINIUTES = 120; - Settings settings; namespace { @@ -150,256 +132,6 @@ bool preserveCorruptSettings() { return true; } -// TODO: Remove the legacy QSettings migration after users have had time to migrate to cabana.json. -struct LegacyValue { - std::vector strings; - std::string bytes; - bool is_byte_array = false; -}; - -using LegacySettings = std::map; - -int hexDigit(char c) { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; -} - -#ifndef __APPLE__ - -void appendUtf8(std::string &result, uint32_t codepoint) { - if (codepoint <= 0x7f) { - result.push_back(codepoint); - } else if (codepoint <= 0x7ff) { - result.push_back(0xc0 | (codepoint >> 6)); - result.push_back(0x80 | (codepoint & 0x3f)); - } else if (codepoint <= 0xffff) { - result.push_back(0xe0 | (codepoint >> 12)); - result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); - result.push_back(0x80 | (codepoint & 0x3f)); - } else { - result.push_back(0xf0 | (codepoint >> 18)); - result.push_back(0x80 | ((codepoint >> 12) & 0x3f)); - result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); - result.push_back(0x80 | (codepoint & 0x3f)); - } -} - -LegacyValue decodeIniValue(std::string_view encoded) { - std::vector> decoded(1); - std::vector quoted(1, false); - bool in_quotes = false; - - for (size_t i = 0; i < encoded.size();) { - char c = encoded[i++]; - if (c == '"') { - in_quotes = !in_quotes; - quoted.back() = true; - } else if (c == ',' && !in_quotes) { - decoded.emplace_back(); - quoted.push_back(false); - while (i < encoded.size() && (encoded[i] == ' ' || encoded[i] == '\t')) ++i; - } else if (c == '\\' && i < encoded.size()) { - c = encoded[i++]; - static const std::map escapes = { - {'a', '\a'}, {'b', '\b'}, {'f', '\f'}, {'n', '\n'}, {'r', '\r'}, {'t', '\t'}, - {'v', '\v'}, {'"', '"'}, {'?', '?'}, {'\'', '\''}, {'\\', '\\'}, - }; - if (auto it = escapes.find(c); it != escapes.end()) { - decoded.back().push_back(static_cast(it->second)); - } else if (c == 'x' && i < encoded.size() && hexDigit(encoded[i]) >= 0) { - uint32_t value = 0; - while (i < encoded.size() && hexDigit(encoded[i]) >= 0) value = (value << 4) + hexDigit(encoded[i++]); - decoded.back().push_back(value & 0xffff); - } else if (c >= '0' && c <= '7') { - uint32_t value = c - '0'; - while (i < encoded.size() && encoded[i] >= '0' && encoded[i] <= '7') value = (value << 3) + (encoded[i++] - '0'); - decoded.back().push_back(value & 0xffff); - } - } else { - decoded.back().push_back(static_cast(c)); - } - } - - LegacyValue result; - for (size_t i = 0; i < decoded.size(); ++i) { - auto &value = decoded[i]; - if (!quoted[i]) { - while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(value.begin()); - while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) value.pop_back(); - } - - std::string string_value; - for (size_t j = 0; j < value.size(); ++j) { - uint32_t codepoint = value[j]; - if (codepoint >= 0xd800 && codepoint <= 0xdbff && j + 1 < value.size() && value[j + 1] >= 0xdc00 && value[j + 1] <= 0xdfff) { - codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (value[++j] - 0xdc00); - } - appendUtf8(string_value, codepoint); - } - result.strings.push_back(std::move(string_value)); - } - - if (result.strings.size() == 1 && result.strings[0] == "@Invalid()") { - result.strings.clear(); - } else if (decoded.size() == 1) { - static constexpr std::string_view prefix = "@ByteArray("; - const auto &value = decoded[0]; - if (value.size() >= prefix.size() + 1 && std::equal(prefix.begin(), prefix.end(), value.begin()) && value.back() == ')') { - result.is_byte_array = true; - result.bytes.reserve(value.size() - prefix.size() - 1); - for (size_t i = prefix.size(); i + 1 < value.size(); ++i) result.bytes.push_back(value[i] & 0xff); - } - } - if (!result.is_byte_array) { - for (auto &value : result.strings) { - if (value.compare(0, 2, "@@") == 0) value.erase(0, 1); - } - } - return result; -} - -LegacySettings loadLegacySettings() { - auto path = settingsFile(); - path.replace_filename("cabana.conf"); - std::ifstream input(path); - if (!input) return {}; - - LegacySettings settings; - bool in_general_section = false; - std::string line; - while (std::getline(input, line)) { - if (!line.empty() && line.back() == '\r') line.pop_back(); - if (line == "[General]") { - in_general_section = true; - continue; - } - if (!line.empty() && line.front() == '[') { - in_general_section = false; - continue; - } - if (!in_general_section || line.empty() || line.front() == ';') continue; - if (auto separator = line.find('='); separator != std::string::npos) { - settings[line.substr(0, separator)] = decodeIniValue(std::string_view(line).substr(separator + 1)); - } - } - return settings; -} - -#else - -std::string cfStringToUtf8(CFStringRef value) { - CFIndex length = CFStringGetLength(value); - CFIndex size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; - std::string result(size, '\0'); - if (!CFStringGetCString(value, result.data(), size, kCFStringEncodingUTF8)) return {}; - result.resize(strlen(result.c_str())); - return result; -} - -LegacyValue cfStringValue(CFStringRef string) { - LegacyValue value; - if (CFStringHasPrefix(string, CFSTR("@ByteArray(")) && CFStringHasSuffix(string, CFSTR(")"))) { - CFRange range{11, CFStringGetLength(string) - 12}; - std::vector data(range.length); - CFStringGetCharacters(string, range, data.data()); - value.is_byte_array = true; - value.bytes.reserve(data.size()); - for (UniChar c : data) value.bytes.push_back(c & 0xff); - } else { - std::string string_value = cfStringToUtf8(string); - if (string_value.compare(0, 2, "@@") == 0) string_value.erase(0, 1); - value.strings.push_back(std::move(string_value)); - } - return value; -} - -LegacySettings loadLegacySettings() { - LegacySettings settings; - CFDictionaryRef values = CFPreferencesCopyMultiple(nullptr, CFSTR("com.cabana"), - kCFPreferencesCurrentUser, kCFPreferencesAnyHost); - if (values == nullptr) return settings; - - CFIndex count = CFDictionaryGetCount(values); - std::vector keys(count); - std::vector objects(count); - CFDictionaryGetKeysAndValues(values, keys.data(), objects.data()); - for (CFIndex i = 0; i < count; ++i) { - if (CFGetTypeID(keys[i]) != CFStringGetTypeID()) continue; - std::string key = cfStringToUtf8(static_cast(keys[i])); - CFTypeRef object = objects[i]; - LegacyValue value; - if (CFGetTypeID(object) == CFBooleanGetTypeID()) { - value.strings.push_back(CFBooleanGetValue(static_cast(object)) ? "true" : "false"); - } else if (CFGetTypeID(object) == CFNumberGetTypeID()) { - int number = 0; - if (CFNumberGetValue(static_cast(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number)); - } else if (CFGetTypeID(object) == CFStringGetTypeID()) { - value = cfStringValue(static_cast(object)); - } else if (CFGetTypeID(object) == CFDataGetTypeID()) { - auto data = static_cast(object); - value.is_byte_array = true; - value.bytes.assign(reinterpret_cast(CFDataGetBytePtr(data)), CFDataGetLength(data)); - } else if (CFGetTypeID(object) == CFArrayGetTypeID()) { - auto array = static_cast(object); - for (CFIndex j = 0; j < CFArrayGetCount(array); ++j) { - CFTypeRef item = CFArrayGetValueAtIndex(array, j); - if (CFGetTypeID(item) != CFStringGetTypeID()) { - value.strings.clear(); - break; - } - auto item_value = cfStringValue(static_cast(item)); - if (item_value.strings.size() != 1) { - value.strings.clear(); - break; - } - value.strings.push_back(std::move(item_value.strings[0])); - } - } - if (!value.strings.empty() || value.is_byte_array || CFGetTypeID(object) == CFArrayGetTypeID()) { - settings.emplace(std::move(key), std::move(value)); - } - } - CFRelease(values); - return settings; -} - -#endif - -template -void readLegacySetting(const LegacySettings &legacy_settings, const char *key, T &value) { - auto it = legacy_settings.find(key); - if (it == legacy_settings.end() || it->second.strings.size() != 1) return; - const auto &stored = it->second.strings[0]; - - if constexpr (std::is_same_v) { - if (stored == "true") value = true; - if (stored == "false") value = false; - } else if constexpr (std::is_integral_v || std::is_enum_v) { - int number = 0; - auto [end, error] = std::from_chars(stored.data(), stored.data() + stored.size(), number); - if (error == std::errc{} && end == stored.data() + stored.size()) value = static_cast(number); - } -} - -void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::string &value) { - auto it = legacy_settings.find(key); - if (it != legacy_settings.end() && it->second.strings.size() == 1) value = it->second.strings[0]; -} - -void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { - auto it = legacy_settings.find(key); - if (it != legacy_settings.end() && !it->second.is_byte_array) value = it->second.strings; -} - -void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { - auto it = legacy_settings.find(key); - if (it != legacy_settings.end() && it->second.is_byte_array) { - value.assign(it->second.bytes.begin(), it->second.bytes.end()); - } -} - template void readSetting(const json11::Json::object &settings_json, const char *key, T &value) { auto it = settings_json.find(key); @@ -431,20 +163,6 @@ void readSetting(const json11::Json::object &settings_json, const char *key, std value = std::move(stored); } -void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { - auto it = settings_json.find(key); - if (it == settings_json.end() || !it->second.is_string()) return; - - const auto &hex = it->second.string_value(); - if (hex.size() % 2 == 0 && std::all_of(hex.begin(), hex.end(), [](unsigned char c) { return std::isxdigit(c); })) { - value.clear(); - value.reserve(hex.size() / 2); - for (size_t i = 0; i < hex.size(); i += 2) { - value.push_back((hexDigit(hex[i]) << 4) | hexDigit(hex[i + 1])); - } - } -} - template void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) { if constexpr (std::is_same_v) { @@ -462,32 +180,17 @@ void writeSetting(json11::Json::object &settings_json, const char *key, const st settings_json[key] = value; } -void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { - static const char digits[] = "0123456789abcdef"; - std::string hex; - hex.reserve(value.size() * 2); - for (uint8_t b : value) { - hex.push_back(digits[b >> 4]); - hex.push_back(digits[b & 0xf]); - } - settings_json[key] = hex; -} - template void settingsOp(Store &s, SettingOperation op) { op(s, "absolute_time", settings.absolute_time); - op(s, "fps", settings.fps); op(s, "max_cached_minutes", settings.max_cached_minutes); op(s, "chart_height", settings.chart_height); op(s, "chart_range", settings.chart_range); op(s, "chart_column_count", settings.chart_column_count); op(s, "last_dir", settings.last_dir); op(s, "last_route_dir", settings.last_route_dir); - op(s, "window_state", settings.window_state); - op(s, "geometry", settings.geometry); - op(s, "video_splitter_state", settings.video_splitter_state); op(s, "recent_files", settings.recent_files); - op(s, "message_header_state", settings.message_header_state); + op(s, "ui_state", settings.ui_state); op(s, "chart_series_type", settings.chart_series_type); op(s, "theme", settings.theme); op(s, "sparkline_range", settings.sparkline_range); @@ -502,7 +205,7 @@ void settingsOp(Store &s, SettingOperation op) { op(s, "active_charts", settings.active_charts); } -} // namespace +} Settings::Settings() { last_dir = last_route_dir = utils::homePath(); @@ -511,17 +214,10 @@ Settings::Settings() { if (stored_settings.valid) { if (stored_settings.exists) { settingsOp(stored_settings.values, [](const auto &s, const char *key, auto &value) { readSetting(s, key, value); }); - } else { - auto legacy_settings = loadLegacySettings(); - settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); } } - fps = std::clamp(fps, 1, 100); + if (theme != LIGHT_THEME && theme != DARK_THEME) theme = LIGHT_THEME; } - -// Must be called before main() returns: json11's internal statistics are constructed on first -// use at runtime, so they are destroyed before this pre-main global. Saving from ~Settings -// would use them after destruction and corrupt the heap. void Settings::save() { if (!ensureSettingsDirectory()) return; @@ -535,84 +231,3 @@ void Settings::save() { settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); }); saveSettings(stored_settings.values); } - -// SettingsDlg - -SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Settings")); - QVBoxLayout *main_layout = new QVBoxLayout(this); - QGroupBox *groupbox = new QGroupBox("General"); - QFormLayout *form_layout = new QFormLayout(groupbox); - - form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this)); - theme->setToolTip(tr("You may need to restart cabana after changes theme")); - theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")}); - theme->setCurrentIndex(settings.theme); - - form_layout->addRow("FPS", fps = new QSpinBox(this)); - fps->setRange(10, 100); - fps->setSingleStep(10); - fps->setValue(settings.fps); - - form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this)); - cached_minutes->setRange(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES); - cached_minutes->setSingleStep(1); - cached_minutes->setValue(settings.max_cached_minutes); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("New Signal Settings"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this)); - drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")}); - drag_direction->setCurrentIndex(settings.drag_direction); - main_layout->addWidget(groupbox); - - groupbox = new QGroupBox("Chart"); - form_layout = new QFormLayout(groupbox); - form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this)); - chart_height->setRange(100, 500); - chart_height->setSingleStep(10); - chart_height->setValue(settings.chart_height); - main_layout->addWidget(groupbox); - - log_livestream = new QGroupBox(tr("Enable live stream logging"), this); - log_livestream->setCheckable(true); - log_livestream->setChecked(settings.log_livestream); - QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); - log_path->setReadOnly(true); - auto browse_btn = new QPushButton(tr("B&rowse...")); - path_layout->addWidget(browse_btn); - main_layout->addWidget(log_livestream); - - auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - main_layout->addWidget(buttonBox); - setFixedSize(400, sizeHint().height()); - - QObject::connect(browse_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getExistingDirectory( - this, tr("Log File Location"), - QString::fromStdString(utils::homePath()), - QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); - if (!fn.isEmpty()) { - log_path->setText(fn); - } - }); - QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDlg::save); -} - -void SettingsDlg::save() { - if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) { - // set theme before emit changed - utils::setTheme(settings.theme); - } - settings.fps = fps->value(); - settings.max_cached_minutes = cached_minutes->value(); - settings.chart_height = chart_height->value(); - settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text().toStdString(); - settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); - emit settings.changed(); - QDialog::accept(); -} diff --git a/iqpilot/tools/cabana/settings.h b/iqpilot/tools/cabana/settings.h index 7357ecf4f..ca7890865 100644 --- a/iqpilot/tools/cabana/settings.h +++ b/iqpilot/tools/cabana/settings.h @@ -1,45 +1,18 @@ #pragma once -#include -#include - -#include -#include -#include -#include -#include +#include +#include "tools/cabana/core/observable.h" #include "tools/cabana/core/settings.h" -class Settings : public QObject, public CabanaSettingsState { - Q_OBJECT - +class Settings : public CabanaSettingsState { public: Settings(); void save(); - // Qt frontend layout state. This intentionally stays outside CabanaSettingsState. - std::vector geometry; - std::vector video_splitter_state; - std::vector window_state; - std::vector message_header_state; + std::string ui_state; -signals: - void changed(); -}; - -class SettingsDlg : public QDialog { -public: - SettingsDlg(QWidget *parent); - void save(); - QSpinBox *fps; - QSpinBox *cached_minutes; - QSpinBox *chart_height; - QComboBox *chart_series_type; - QComboBox *theme; - QGroupBox *log_livestream; - QLineEdit *log_path; - QComboBox *drag_direction; + Observable<> changed; }; extern Settings settings; diff --git a/iqpilot/tools/cabana/signalview.cc b/iqpilot/tools/cabana/signalview.cc deleted file mode 100644 index ebb537414..000000000 --- a/iqpilot/tools/cabana/signalview.cc +++ /dev/null @@ -1,719 +0,0 @@ -#include "tools/cabana/signalview.h" -#include "tools/cabana/dbc/dbcqt.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/utils/util.h" - -// SignalModel - -static QString signalTypeToString(cabana::Signal::Type type) { - if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; - else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; - else return "Normal Signal"; -} - -SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved); -} - -void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)}; - root_item->children.insert(root_item->children.begin() + pos, parent_item); - QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", - "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; - for (int i = 0; i < std::size(titles); ++i) { - auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; - parent_item->children.push_back(item); - if (item->type == Item::ExtraInfo) { - parent_item = item; - } - } -} - -void SignalModel::setMessage(const MessageId &id) { - msg_id = id; - filter_str = ""; - refresh(); -} - -void SignalModel::setFilter(const QString &txt) { - filter_str = txt; - refresh(); -} - -void SignalModel::refresh() { - beginResetModel(); - root.reset(new SignalModel::Item); - if (auto msg = dbc()->msg(msg_id)) { - for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { - insertItem(root.get(), root->children.size(), s); - } - } - } - endResetModel(); -} - -SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const { - auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr; - return item ? item : root.get(); -} - -int SignalModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid() && parent.column() > 0) return 0; - - return getItem(parent)->children.size(); -} - -Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const { - if (!index.isValid()) return Qt::NoItemFlags; - - auto item = getItem(index); - Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled; - if (index.column() == 1 && item->children.empty()) { - flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable; - } - if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) { - flags &= ~Qt::ItemIsEnabled; - } - return flags; -} - -int SignalModel::signalRow(const cabana::Signal *sig) const { - for (int i = 0; i < root->children.size(); ++i) { - if (root->children[i]->sig == sig) return i; - } - return -1; -} - -QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const { - if (parent.isValid() && parent.column() != 0) return {}; - - auto parent_item = getItem(parent); - if (parent_item && row < parent_item->children.size()) { - return createIndex(row, column, parent_item->children[row]); - } - return {}; -} - -QModelIndex SignalModel::parent(const QModelIndex &index) const { - if (!index.isValid()) return {}; - Item *parent_item = getItem(index)->parent; - return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item); -} - -QVariant SignalModel::data(const QModelIndex &index, int role) const { - if (index.isValid()) { - const Item *item = getItem(index); - if (role == Qt::DisplayRole || role == Qt::EditRole) { - if (index.column() == 0) { - return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title; - } else { - switch (item->type) { - case Item::Sig: return item->sig_val; - case Item::Name: return QString::fromStdString(item->sig->name); - case Item::Size: return item->sig->size; - case Item::Node: return QString::fromStdString(item->sig->receiver_name); - case Item::SignalType: return signalTypeToString(item->sig->type); - case Item::MultiplexValue: return item->sig->multiplex_value; - case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset)); - case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor)); - case Item::Unit: return QString::fromStdString(item->sig->unit); - case Item::Comment: return QString::fromStdString(item->sig->comment); - case Item::Min: return QString::fromStdString(doubleToString(item->sig->min)); - case Item::Max: return QString::fromStdString(doubleToString(item->sig->max)); - case Item::Desc: { - QStringList val_desc; - for (auto &[val, desc] : item->sig->val_desc) { - val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc)); - } - return val_desc.join(" "); - } - default: break; - } - } - } else if (role == Qt::CheckStateRole && index.column() == 1) { - if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked; - if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked; - } else if (role == Qt::ToolTipRole && item->type == Item::Sig) { - return (index.column() == 0) ? signalToolTip(item->sig) : QString(); - } - } - return {}; -} - -bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) { - if (role != Qt::EditRole && role != Qt::CheckStateRole) return false; - - Item *item = getItem(index); - cabana::Signal s = *item->sig; - switch (item->type) { - case Item::Name: s.name = value.toString().toStdString(); break; - case Item::Size: s.size = value.toInt(); break; - case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break; - case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; - case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; - case Item::Endian: s.is_little_endian = value.toBool(); break; - case Item::Signed: s.is_signed = value.toBool(); break; - case Item::Offset: s.offset = value.toDouble(); break; - case Item::Factor: s.factor = value.toDouble(); break; - case Item::Unit: s.unit = value.toString().toStdString(); break; - case Item::Comment: s.comment = value.toString().toStdString(); break; - case Item::Min: s.min = value.toDouble(); break; - case Item::Max: s.max = value.toDouble(); break; - case Item::Desc: s.val_desc = value.value(); break; - default: return false; - } - bool ret = saveSignal(item->sig, s); - emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - return ret; -} - -bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { - auto msg = dbc()->msg(msg_id); - if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { - QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name)); - QMessageBox::warning(nullptr, tr("Failed to save signal"), text); - return false; - } - - if (s.is_little_endian != origin_s->is_little_endian) { - s.start_bit = flipBitPos(s.start_bit); - } - UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); - return true; -} - -void SignalModel::handleMsgChanged(MessageId id) { - if (id.address == msg_id.address) { - refresh(); - } -} - -void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id == msg_id) { - if (filter_str.isEmpty()) { - int i = dbc()->msg(msg_id)->indexOf(sig); - beginInsertRows({}, i, i); - insertItem(root.get(), i, sig); - endInsertRows(); - } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { - refresh(); - } - } -} - -void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole}); - - if (filter_str.isEmpty()) { - // move row when the order changes. - int to = dbc()->msg(msg_id)->indexOf(sig); - if (to != row) { - beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - auto item = root->children[row]; - root->children.erase(root->children.begin() + row); - root->children.insert(root->children.begin() + to, item); - endMoveRows(); - } - } - } -} - -void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { - if (int row = signalRow(sig); row != -1) { - beginRemoveRows({}, row, row); - delete root->children[row]; - root->children.erase(root->children.begin() + row); - endRemoveRows(); - } -} - -// SignalItemDelegate - -SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { - name_validator = new NameValidator(this); - node_validator = new NodeValidator(this); - double_validator = new DoubleValidator(this); - - label_font.setPointSize(8); - minmax_font.setPixelSize(10); -} - -QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - int width = option.widget->size().width() / 2; - if (index.column() == 0) { - int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8; - auto text = index.data(Qt::DisplayRole).toString(); - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { - text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2; - } - width = std::min(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing); - } - return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2}; -} - -void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) { - QRect geom = option.rect; - geom.setLeft(geom.right() - editor->sizeHint().width()); - editor->setGeometry(geom); - button_size = geom.size(); - return; - } - QStyledItemDelegate::updateEditorGeometry(editor, option, index); -} - -void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - auto item = static_cast(index.internalPointer()); - - QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - painter->setRenderHint(QPainter::Antialiasing); - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - if (index.column() == 0) { - if (item->type == SignalModel::Item::Sig) { - // color label - QPainterPath path; - QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.height()}; - path.addRoundedRect(icon_rect, 3, 3); - painter->setPen(item->highlight ? Qt::white : Qt::black); - painter->setFont(label_font); - painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0))); - painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1)); - - rect.setLeft(icon_rect.right() + h_margin * 2); - // multiplexer indicator - if (item->sig->type != cabana::Signal::Type::Normal) { - QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value); - QRect indicator_rect{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.height()}; - painter->setBrush(Qt::gray); - painter->setPen(Qt::NoPen); - painter->drawRoundedRect(indicator_rect, 3, 3); - painter->setPen(Qt::white); - painter->drawText(indicator_rect, Qt::AlignCenter, indicator); - rect.setLeft(indicator_rect.right() + h_margin * 2); - } - } else { - rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3); - } - - // name - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - painter->setFont(option.font); - painter->drawText(rect, option.displayAlignment, text); - } else if (index.column() == 1) { - if (!item->sparkline.pixmap.isNull()) { - QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio(); - painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap); - // min-max value - painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text)); - rect.adjust(sparkline_size.width() + 1, 0, 0, 0); - int value_adjust = 10; - if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) { - painter->drawLine(rect.topLeft(), rect.bottomLeft()); - rect.adjust(5, -v_margin, 0, v_margin); - painter->setFont(minmax_font); - QString min = QString::number(item->sparkline.min_val); - QString max = QString::number(item->sparkline.max_val); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max); - painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min); - QFontMetrics fm(minmax_font); - value_adjust = std::max(fm.horizontalAdvance(min), fm.horizontalAdvance(max)) + 5; - } else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) { - // display freq of multiplexed signal - painter->setFont(label_font); - QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2); - painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq); - value_adjust = QFontMetrics(label_font).horizontalAdvance(freq) + 10; - } - // signal value - painter->setFont(option.font); - rect.adjust(value_adjust, 0, -button_size.width(), 0); - auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width()); - painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text); - } else { - QStyledItemDelegate::paint(painter, option, index); - } - } -} - -QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || - item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || - item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { - QLineEdit *e = new QLineEdit(parent); - e->setFrame(false); - if (item->type == SignalModel::Item::Name) e->setValidator(name_validator); - else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); - else e->setValidator(double_validator); - - return e; - } else if (item->type == SignalModel::Item::Size) { - QSpinBox *spin = new QSpinBox(parent); - spin->setFrame(false); - spin->setRange(1, CAN_MAX_DATA_BYTES); - return spin; - } else if (item->type == SignalModel::Item::SignalType) { - QComboBox *c = new QComboBox(parent); - c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); - if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); - } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { - c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); - } - return c; - } else if (item->type == SignalModel::Item::Desc) { - ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(QString::fromStdString(item->sig->name)); - if (dlg.exec()) { - ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); - } - return nullptr; - } - return QStyledItemDelegate::createEditor(parent, option, index); -} - -void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const { - auto item = (SignalModel::Item *)index.internalPointer(); - if (item->type == SignalModel::Item::SignalType) { - model->setData(index, ((QComboBox*)editor)->currentData().toInt()); - return; - } - QStyledItemDelegate::setModelData(editor, model, index); -} - -// SignalView - -SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - // title bar - QWidget *title_bar = new QWidget(this); - QHBoxLayout *hl = new QHBoxLayout(title_bar); - hl->addWidget(signal_count_lb = new QLabel()); - filter_edit = new QLineEdit(this); - filter_edit->setValidator(new NonWhitespaceValidator(this)); - filter_edit->setClearButtonEnabled(true); - filter_edit->setPlaceholderText(tr("Filter Signal")); - hl->addWidget(filter_edit); - hl->addStretch(1); - - // WARNING: increasing the maximum range can result in severe performance degradation. - // 30s is a reasonable value at present. - const int max_range = 30; // 30s - settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range); - hl->addWidget(sparkline_label = new QLabel()); - hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this)); - sparkline_range_slider->setRange(1, max_range); - sparkline_range_slider->setValue(settings.sparkline_range); - sparkline_range_slider->setToolTip(tr("Sparkline time range")); - - auto collapse_btn = new ToolButton("dash-square", tr("Collapse All")); - collapse_btn->setIconSize({12, 12}); - hl->addWidget(collapse_btn); - - // tree view - tree = new TreeView(this); - tree->setModel(model = new SignalModel(this)); - tree->setItemDelegate(delegate = new SignalItemDelegate(this)); - tree->setFrameShape(QFrame::NoFrame); - tree->setHeaderHidden(true); - tree->setMouseTracking(true); - tree->setExpandsOnDoubleClick(false); - tree->setEditTriggers(QAbstractItemView::AllEditTriggers); - tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); - tree->header()->setStretchLastSection(true); - tree->setMinimumHeight(300); - - // Use a distinctive background for the whole row containing a QSpinBox or QLineEdit - QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb); - tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor)); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - main_layout->addWidget(title_bar); - main_layout->addWidget(tree); - updateToolBar(); - - QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter); - QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange); - QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll); - QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked); - QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); }); - QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); - QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); - QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); - QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); - QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState); - QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) { - if (logicalIndex == 1) { - value_column_width = newSize; - updateState(); - } - }); - - setWhatsThis(tr(R"( - Signal view
- - )")); -} - -void SignalView::setMessage(const MessageId &id) { - max_value_width = 0; - filter_edit->clear(); - model->setMessage(id); -} - -void SignalView::rowsChanged() { - for (int i = 0; i < model->rowCount(); ++i) { - auto index = model->index(i, 1); - if (!tree->indexWidget(index)) { - QWidget *w = new QWidget(this); - QHBoxLayout *h = new QHBoxLayout(w); - int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin); - int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin); - h->setContentsMargins(0, v_margin, -h_margin, v_margin); - h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing)); - - auto remove_btn = new ToolButton("x", tr("Remove signal")); - auto plot_btn = new ToolButton("graph-up", ""); - plot_btn->setCheckable(true); - h->addWidget(plot_btn); - h->addWidget(remove_btn); - - tree->setIndexWidget(index, w); - auto sig = model->getItem(index)->sig; - QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, sig)); }); - QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) { - emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier); - }); - } - } - updateToolBar(); - updateChartState(); - updateState(); -} - -void SignalView::rowClicked(const QModelIndex &index) { - auto item = model->getItem(index); - if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { - auto expand_index = model->index(index.row(), 0, index.parent()); - tree->setExpanded(expand_index, !tree->isExpanded(expand_index)); - } -} - -void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { - if (int row = model->signalRow(sig); row != -1) { - auto idx = model->index(row, 0); - if (expand) { - tree->setExpanded(idx, !tree->isExpanded(idx)); - } - tree->scrollTo(idx, QAbstractItemView::PositionAtTop); - tree->setCurrentIndex(idx); - } -} - -void SignalView::updateChartState() { - int i = 0; - for (auto item : model->root->children) { - bool chart_opened = charts->hasSignal(model->msg_id, item->sig); - auto buttons = tree->indexWidget(model->index(i, 1))->findChildren(); - if (buttons.size() > 0) { - buttons[0]->setChecked(chart_opened); - buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot")); - } - ++i; - } -} - -void SignalView::signalHovered(const cabana::Signal *sig) { - auto &children = model->root->children; - for (int i = 0; i < children.size(); ++i) { - bool highlight = children[i]->sig == sig; - if (std::exchange(children[i]->highlight, highlight) != highlight) { - emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole}); - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } - } -} - -void SignalView::updateToolBar() { - signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount())); - sparkline_label->setText(utils::formatSeconds(settings.sparkline_range)); -} - -void SignalView::setSparklineRange(int value) { - settings.sparkline_range = value; - updateToolBar(); - updateState(); -} - -void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { - if (id.address == model->msg_id.address) { - selectSignal(sig); - } -} - -void SignalView::handleSignalUpdated(const cabana::Signal *sig) { - if (int row = model->signalRow(sig); row != -1) - updateState(); -} - -std::pair SignalView::visibleSignalRange() { - auto topLevelIndex = [](QModelIndex index) { - while (index.isValid() && index.parent().isValid()) index = index.parent(); - return index; - }; - - const auto viewport_rect = tree->viewport()->rect(); - QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft()); - if (first_visible.parent().isValid()) { - first_visible = topLevelIndex(first_visible); - first_visible = first_visible.siblingAtRow(first_visible.row() + 1); - } - - QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight())); - if (!last_visible.isValid()) { - last_visible = model->index(model->rowCount() - 1, 0); - } - return {first_visible, last_visible}; -} - -void SignalView::updateState(const std::set *msgs) { - const auto &last_msg = can->lastMessage(model->msg_id); - if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return; - - for (auto item : model->root->children) { - double value = 0; - if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { - item->sig_val = QString::fromStdString(item->sig->formatValue(value)); - max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); - } - } - - auto [first_visible, last_visible] = visibleSignalRange(); - if (first_visible.isValid() && last_visible.isValid()) { - const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-000.00") + 5; - int available_width = value_column_width - delegate->button_size.width(); - int value_width = std::min(max_value_width + min_max_width, available_width / 2); - QSize size(available_width - value_width, - delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2); - - auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts)); - std::vector> futures; - for (int i = first_visible.row(); i <= last_visible.row(); ++i) { - auto item = model->getItem(model->index(i, 1)); - futures.push_back(std::async(std::launch::async, - &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); - } - for (auto &f : futures) f.get(); - } - - for (int i = 0; i < model->rowCount(); ++i) { - emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole}); - } -} - -void SignalView::resizeEvent(QResizeEvent* event) { - updateState(); - QFrame::resizeEvent(event); -} - -// ValueDescriptionDlg - -ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) { - QHBoxLayout *toolbar_layout = new QHBoxLayout(); - QPushButton *add = new QPushButton(utils::icon("plus"), ""); - QPushButton *remove = new QPushButton(utils::icon("dash"), ""); - remove->setEnabled(false); - toolbar_layout->addWidget(add); - toolbar_layout->addWidget(remove); - toolbar_layout->addStretch(0); - - table = new QTableWidget(descriptions.size(), 2, this); - table->setItemDelegate(new Delegate(this)); - table->setHorizontalHeaderLabels({"Value", "Description"}); - table->horizontalHeader()->setStretchLastSection(true); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed); - table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - - int row = 0; - for (auto &[val, desc] : descriptions) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); - ++row; - } - - auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addLayout(toolbar_layout); - main_layout->addWidget(table); - main_layout->addWidget(btn_box); - setMinimumWidth(500); - - QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save); - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(add, &QPushButton::clicked, [this]() { - table->setRowCount(table->rowCount() + 1); - table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem); - table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem); - }); - QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); }); - QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() { - remove->setEnabled(table->currentRow() != -1); - }); -} - -void ValueDescriptionDlg::save() { - for (int i = 0; i < table->rowCount(); ++i) { - QString val = table->item(i, 0)->text().trimmed(); - QString desc = table->item(i, 1)->text().trimmed(); - if (!val.isEmpty() && !desc.isEmpty()) { - val_desc.push_back({val.toDouble(), desc.toStdString()}); - } - } - QDialog::accept(); -} - -QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const { - QLineEdit *edit = new QLineEdit(parent); - edit->setFrame(false); - if (index.column() == 0) { - edit->setValidator(new DoubleValidator(parent)); - } - return edit; -} diff --git a/iqpilot/tools/cabana/signalview.h b/iqpilot/tools/cabana/signalview.h deleted file mode 100644 index bccfbab0c..000000000 --- a/iqpilot/tools/cabana/signalview.h +++ /dev/null @@ -1,153 +0,0 @@ -#pragma once - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/chart/chartswidget.h" -#include "tools/cabana/chart/sparkline.h" - -class SignalModel : public QAbstractItemModel { - Q_OBJECT -public: - struct Item { - enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~Item() { for (auto c : children) delete c; } - inline int row() { - auto it = std::find(parent->children.begin(), parent->children.end(), this); - return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; - } - - Type type = Type::Root; - Item *parent = nullptr; - std::vector children; - - const cabana::Signal *sig = nullptr; - QString title; - bool highlight = false; - QString sig_val = "-"; - Sparkline sparkline; - }; - - SignalModel(QObject *parent); - int rowCount(const QModelIndex &parent = QModelIndex()) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; } - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; - QModelIndex parent(const QModelIndex &index) const override; - Qt::ItemFlags flags(const QModelIndex &index) const override; - bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; - void setMessage(const MessageId &id); - void setFilter(const QString &txt); - bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); - Item *getItem(const QModelIndex &index) const; - int signalRow(const cabana::Signal *sig) const; - -private: - void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void handleSignalRemoved(const cabana::Signal *sig); - void handleMsgChanged(MessageId id); - void refresh(); - - MessageId msg_id; - QString filter_str; - std::unique_ptr root; - friend class SignalView; - friend class SignalItemDelegate; -}; - -class ValueDescriptionDlg : public QDialog { -public: - ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent); - ValueDescription val_desc; - -private: - struct Delegate : public QStyledItemDelegate { - Delegate(QWidget *parent) : QStyledItemDelegate(parent) {} - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - }; - - void save(); - QTableWidget *table; -}; - -class SignalItemDelegate : public QStyledItemDelegate { -public: - SignalItemDelegate(QObject *parent); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override; - - QValidator *name_validator, *double_validator, *node_validator; - QFont label_font, minmax_font; - const int color_label_width = 18; - mutable QSize button_size; -}; - -class SignalView : public QFrame { - Q_OBJECT - -public: - SignalView(ChartsWidget *charts, QWidget *parent); - void setMessage(const MessageId &id); - void signalHovered(const cabana::Signal *sig); - void updateChartState(); - void selectSignal(const cabana::Signal *sig, bool expand = false); - void rowClicked(const QModelIndex &index); - SignalModel *model = nullptr; - -signals: - void highlight(const cabana::Signal *sig); - void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); - -private: - void rowsChanged(); - void resizeEvent(QResizeEvent* event) override; - void updateToolBar(); - void setSparklineRange(int value); - void handleSignalAdded(MessageId id, const cabana::Signal *sig); - void handleSignalUpdated(const cabana::Signal *sig); - void updateState(const std::set *msgs = nullptr); - std::pair visibleSignalRange(); - - struct TreeView : public QTreeView { - TreeView(QWidget *parent) : QTreeView(parent) {} - void rowsInserted(const QModelIndex &parent, int start, int end) override { - ((SignalView *)parentWidget())->rowsChanged(); - // update widget geometries in QTreeView::rowsInserted - QTreeView::rowsInserted(parent, start, end); - } - void setModel(QAbstractItemModel *m) override { - QTreeView::setModel(m); - // Bypass the slow call to QTreeView::dataChanged. - QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr); - QObject::connect(m, &QAbstractItemModel::dataChanged, this, - [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); - } - void leaveEvent(QEvent *event) override { - emit static_cast(parentWidget())->highlight(nullptr); - QTreeView::leaveEvent(event); - } - }; - int max_value_width = 0; - int value_column_width = 0; - TreeView *tree; - QLabel *sparkline_label; - QSlider *sparkline_range_slider; - QLineEdit *filter_edit; - ChartsWidget *charts; - QLabel *signal_count_lb; - SignalItemDelegate *delegate; -}; diff --git a/iqpilot/tools/cabana/streams/abstractstream.cc b/iqpilot/tools/cabana/streams/abstractstream.cc index c58a98084..ac461f8f8 100644 --- a/iqpilot/tools/cabana/streams/abstractstream.cc +++ b/iqpilot/tools/cabana/streams/abstractstream.cc @@ -1,26 +1,51 @@ #include "tools/cabana/streams/abstractstream.h" -#include "tools/cabana/dbc/dbcqt.h" +#include #include #include -#include #include "common/timing.h" #include "tools/cabana/settings.h" -static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB +static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; AbstractStream *can = nullptr; -AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { - assert(parent != nullptr); +AbstractStream::AbstractStream() { event_buffer_ = std::make_unique(EVENT_NEXT_BUFFER_SIZE); - QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection); - QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo); - QObject::connect(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; }); - QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); + + connections_.push_back(seekedTo.connect([this](double sec) { updateLastMsgsTo(sec); })); + connections_.push_back(seeking.connect([this](double sec) { current_sec_ = sec; })); + connections_.push_back(dbc()->fileChanged.connect([this]() { updateMasks(); })); + connections_.push_back(dbc()->maskUpdated.connect([this]() { updateMasks(); })); +} + +void AbstractStream::postToMainThread(std::function fn) { + utils::runOnMainThread([alive = std::weak_ptr(alive_), fn = std::move(fn)]() { + if (!alive.expired()) fn(); + }); +} + +void AbstractStream::postToMainThreadAndWait(std::function fn) { + assert(!utils::isMainThread()); + std::unique_lock lock(mutex_); + if (exiting_) return; + auto done = std::make_shared(false); + postToMainThread([this, alive = std::weak_ptr(alive_), done, fn = std::move(fn)]() { + fn(); + if (alive.expired()) return; + std::lock_guard lk(mutex_); + *done = true; + wait_cv_.notify_all(); + }); + wait_cv_.wait(lock, [&]() { return *done || exiting_; }); +} + +void AbstractStream::cancelWaits() { + std::lock_guard lk(mutex_); + exiting_ = true; + wait_cv_.notify_all(); } void AbstractStream::updateMasks() { @@ -34,7 +59,7 @@ void AbstractStream::updateMasks() { masks_[{.source = (uint8_t)s, .address = address}] = m.mask; } } - // clear bit change counts + for (auto &[id, m] : messages_) { auto &mask = masks_[id]; const int size = std::min(mask.size(), m.last_changes.size()); @@ -97,9 +122,8 @@ void AbstractStream::updateLastMessages() { if (sources.size() != prev_src_size) { updateMasks(); - emit sourcesUpdated(sources); } - emit msgsReceived(&msgs, prev_msg_size != last_msgs.size()); + msgsReceived(&msgs, prev_msg_size != last_msgs.size()); } void AbstractStream::setTimeRange(const std::optional> &range) { @@ -107,7 +131,7 @@ void AbstractStream::setTimeRange(const std::optional> if (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) { seekTo(time_range_->first); } - emit timeRangeChanged(time_range_); + timeRangeChanged(time_range_); } void AbstractStream::updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size) { @@ -132,7 +156,7 @@ bool AbstractStream::isMessageActive(const MessageId &id) const { if (id.source == INVALID_SOURCE) { return false; } - // Check if the message is active based on time difference and frequency + const auto &m = lastMessage(id); float delta = currentSec() - m.ts; @@ -140,7 +164,7 @@ bool AbstractStream::isMessageActive(const MessageId &id) const { return delta < 1.5; } - return delta < (5.0 / m.freq) + (1.0 / settings.fps); + return delta < (5.0 / m.freq) + (1.0 / STREAM_UPDATE_FPS); } void AbstractStream::updateLastMsgsTo(double sec) { @@ -154,7 +178,7 @@ void AbstractStream::updateLastMsgsTo(double sec) { if (it != ev.begin()) { auto &m = msgs[id]; double freq = 0; - // Keep suppressed bits. + if (auto old_m = messages_.find(id); old_m != messages_.end()) { freq = old_m->second.freq; m.last_changes.reserve(old_m->second.last_changes.size()); @@ -175,16 +199,16 @@ void AbstractStream::updateLastMsgsTo(double sec) { std::any_of(messages_.cbegin(), messages_.cend(), [this](const auto &m) { return !last_msgs.count(m.first); }); last_msgs = messages_; - emit msgsReceived(nullptr, id_changed); + msgsReceived(nullptr, id_changed); std::lock_guard lk(mutex_); seek_finished_ = true; - seek_finished_cv_.notify_one(); + wait_cv_.notify_all(); } void AbstractStream::waitForSeekFinshed() { std::unique_lock lock(mutex_); - seek_finished_cv_.wait(lock, [this]() { return seek_finished_; }); + wait_cv_.wait(lock, [this]() { return seek_finished_ || exiting_; }); seek_finished_ = false; } @@ -203,11 +227,15 @@ void AbstractStream::mergeEvents(const std::vector &events) { static MessageEventsMap msg_events; std::for_each(msg_events.begin(), msg_events.end(), [](auto &e) { e.second.clear(); }); - // Group events by message ID + for (auto e : events) { msg_events[{.source = e->src, .address = e->address}].push_back(e); } + insertEvents(events, msg_events); +} + +void AbstractStream::insertEvents(const std::vector &events, const MessageEventsMap &msg_events) { if (!events.empty()) { for (const auto &[id, new_e] : msg_events) { if (!new_e.empty()) { @@ -218,16 +246,16 @@ void AbstractStream::mergeEvents(const std::vector &events) { } auto pos = std::upper_bound(all_events_.cbegin(), all_events_.cend(), events.front()->mono_time, CompareCanEvent()); all_events_.insert(pos, events.cbegin(), events.cend()); - emit eventsMerged(msg_events); + eventsMerged(msg_events); } } std::pair AbstractStream::eventsInRange(const MessageId &id, std::optional> time_range) const { - const auto &events = can->events(id); + const auto &events = this->events(id); if (!time_range) return {events.begin(), events.end()}; - auto first = std::lower_bound(events.begin(), events.end(), can->toMonoTime(time_range->first), CompareCanEvent()); - auto last = std::upper_bound(first, events.end(), can->toMonoTime(time_range->second), CompareCanEvent()); + auto first = std::lower_bound(events.begin(), events.end(), toMonoTime(time_range->first), CompareCanEvent()); + auto last = std::upper_bound(first, events.end(), toMonoTime(time_range->second), CompareCanEvent()); return {first, last}; } @@ -248,7 +276,7 @@ inline CabanaColor blend(const CabanaColor &a, const CabanaColor &b) { return CabanaColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); } -// Calculate the frequency from the past one minute data + double calc_freq(const MessageId &msg_id, double current_sec) { auto [first, last] = can->eventsInRange(msg_id, std::make_pair(current_sec - 59, current_sec)); int count = std::distance(first, last); @@ -258,7 +286,7 @@ double calc_freq(const MessageId &msg_id, double current_sec) { return duration > std::numeric_limits::epsilon() ? (count - 1) / duration : 0.0; } -} // namespace +} void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const int size, double current_sec, double playback_speed, const std::vector &mask, double in_freq) { @@ -291,21 +319,21 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in const uint8_t cur = can_data[i] & mask_byte; if (last != cur) { const int delta = cur - last; - // Keep track if signal is changing randomly, or mostly moving in the same direction + last_change.same_delta_counter += std::signbit(delta) == std::signbit(last_change.delta) ? 1 : -4; last_change.same_delta_counter = std::clamp(last_change.same_delta_counter, 0, 16); const double delta_t = ts - last_change.ts; - // Mostly moves in the same direction, color based on delta up/down + if (delta_t * freq > periodic_threshold || last_change.same_delta_counter > 8) { - // Last change was while ago, choose color based on delta up or down + colors[i] = getColor(cur > last ? CYAN : RED); } else { - // Periodic changes + colors[i] = blend(colors[i], getColor(GREYISH_BLUE)); } - // Track bit level changes + auto &row_bit_flips = bit_flip_counts[i]; const uint8_t diff = (cur ^ last); for (int bit = 0; bit < 8; bit++) { @@ -317,7 +345,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in last_change.ts = ts; last_change.delta = delta; } else { - // Fade out + colors[i].setAlphaF(std::max(0.0f, colors[i].alphaF() - alpha_delta)); } } diff --git a/iqpilot/tools/cabana/streams/abstractstream.h b/iqpilot/tools/cabana/streams/abstractstream.h index dbbf45146..004f1fdc4 100644 --- a/iqpilot/tools/cabana/streams/abstractstream.h +++ b/iqpilot/tools/cabana/streams/abstractstream.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -14,16 +15,15 @@ #include "cereal/messaging/messaging.h" #include "tools/cabana/core/can_data.h" +#include "tools/cabana/core/observable.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -class AbstractStream : public QObject { - Q_OBJECT - +class AbstractStream { public: - AbstractStream(QObject *parent); - virtual ~AbstractStream() {} + AbstractStream(); + virtual ~AbstractStream() = default; virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} @@ -56,22 +56,25 @@ public: void clearSuppressed(); void suppressDefinedSignals(bool suppress); -signals: - void paused(); - void resume(); - void seeking(double sec); - void seekedTo(double sec); - void timeRangeChanged(const std::optional> &range); - void eventsMerged(const MessageEventsMap &events_map); - void msgsReceived(const std::set *new_msgs, bool has_new_ids); - void sourcesUpdated(const SourceSet &s); - void privateUpdateLastMsgsSignal(); -public: + Observable<> paused; + Observable<> resume; + Observable seeking; + Observable seekedTo; + Observable> &> timeRangeChanged; + Observable eventsMerged; + Observable *, bool> msgsReceived; + Observable error; + SourceSet sources; protected: + void postToMainThread(std::function fn); + void postToMainThreadAndWait(std::function fn); + void cancelWaits(); + void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); } void mergeEvents(const std::vector &events); + void insertEvents(const std::vector &events, const MessageEventsMap &msg_events); const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); void waitForSeekFinshed(); @@ -87,33 +90,24 @@ private: MessageEventsMap events_; std::unordered_map last_msgs; std::unique_ptr event_buffer_; + std::shared_ptr alive_ = std::make_shared(true); + Connections connections_; + - // Members accessed in multiple threads. (mutex protected) std::mutex mutex_; - std::condition_variable seek_finished_cv_; + std::condition_variable wait_cv_; bool seek_finished_ = false; + bool exiting_ = false; std::set new_msgs_; std::unordered_map messages_; std::unordered_map> masks_; }; -class AbstractOpenStreamWidget : public QWidget { - Q_OBJECT -public: - AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {} - virtual AbstractStream *open() = 0; - -signals: - void enableOpenButton(bool); -}; - class DummyStream : public AbstractStream { - Q_OBJECT public: - DummyStream(QObject *parent) : AbstractStream(parent) {} std::string routeName() const override { return "No Stream"; } void start() override {} }; -// A global pointer referring to the unique AbstractStream object + extern AbstractStream *can; diff --git a/iqpilot/tools/cabana/streams/devicestream.cc b/iqpilot/tools/cabana/streams/devicestream.cc index 352910121..b370770e3 100644 --- a/iqpilot/tools/cabana/streams/devicestream.cc +++ b/iqpilot/tools/cabana/streams/devicestream.cc @@ -1,5 +1,6 @@ #include "tools/cabana/streams/devicestream.h" +#include #include #include #include @@ -9,22 +10,17 @@ #include #include #include +#include #include #include #include "cereal/services.h" - -#include -#include -#include -#include - #include "tools/cabana/utils/util.h" -// DeviceStream -DeviceStream::DeviceStream(QObject *parent, Mode mode, QString address) - : mode_(mode), address_(address.isEmpty() ? "127.0.0.1" : address), LiveStream(parent) { + +DeviceStream::DeviceStream(Mode mode, std::string address) + : mode_(mode), address_(address.empty() ? "127.0.0.1" : std::move(address)) { } DeviceStream::~DeviceStream() { @@ -43,7 +39,7 @@ void DeviceStream::stopBridge() { bridge_pid = -1; return; } - usleep(100000); // 100ms, up to ~3s + usleep(100000); } ::kill(bridge_pid, SIGKILL); ::waitpid(bridge_pid, nullptr, 0); @@ -53,17 +49,14 @@ void DeviceStream::stopBridge() { void DeviceStream::start() { if (mode_ == Mode::Bridge) { stopBridge(); - const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / - "../../cereal/messaging/bridge").lexically_normal().string(); - const std::string addr = address_.toStdString(); + const std::string path = (executableDir() / "../../cereal/messaging/bridge").lexically_normal().string(); const char *can_filter = "/\"can/\""; - // Self-pipe: write end is CLOEXEC so it closes on successful exec. If exec - // fails, the child writes errno and the parent aborts stream start. + + int err_pipe[2] = {-1, -1}; if (::pipe(err_pipe) != 0) { - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + error(std::string("Failed to start bridge: ") + strerror(errno)); return; } @@ -71,7 +64,7 @@ void DeviceStream::start() { if (pid == 0) { ::close(err_pipe[0]); ::fcntl(err_pipe[1], F_SETFD, FD_CLOEXEC); - execl(path.c_str(), path.c_str(), addr.c_str(), can_filter, static_cast(nullptr)); + execl(path.c_str(), path.c_str(), address_.c_str(), can_filter, static_cast(nullptr)); const int err = errno; (void)!::write(err_pipe[1], &err, sizeof(err)); _exit(127); @@ -80,8 +73,7 @@ void DeviceStream::start() { ::close(err_pipe[1]); if (pid < 0) { ::close(err_pipe[0]); - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + error(std::string("Failed to start bridge: ") + strerror(errno)); return; } @@ -89,11 +81,10 @@ void DeviceStream::start() { const ssize_t n = ::read(err_pipe[0], &exec_errno, sizeof(exec_errno)); ::close(err_pipe[0]); if (n == static_cast(sizeof(exec_errno))) { - // Child failed to exec; reap and surface the error. + int status = 0; ::waitpid(pid, &status, 0); - QMessageBox::warning(nullptr, tr("Error"), - tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); + error(std::string("Failed to start bridge: ") + strerror(exec_errno)); return; } @@ -104,16 +95,13 @@ void DeviceStream::start() { } void DeviceStream::streamThread() { - // Bridge mode republishes into local msgq, so only the direct Zmq mode talks ZMQ. - // (Upstream sets ZMQ=1 for its bridge path too, which reads nothing — the bridge - // publishes to msgq.) mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ"); - const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1"; + const std::string socket_address = mode_ == Mode::Zmq ? address_ : "127.0.0.1"; std::unique_ptr context(Context::create()); - std::unique_ptr sock(SubSocket::create(context.get(), "can", address, false, true, services.at("can").queue_size)); + std::unique_ptr sock(SubSocket::create(context.get(), "can", socket_address, false, true, services.at("can").queue_size)); assert(sock != NULL); - // run as fast as messages come in + while (!exit_) { std::unique_ptr msg(sock->receive(true)); if (!msg) { @@ -123,36 +111,3 @@ void DeviceStream::streamThread() { handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); } } - -// OpenDeviceWidget - -OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QRadioButton *msgq = new QRadioButton(tr("MSGQ")); - QRadioButton *zmq = new QRadioButton(tr("ZMQ")); - QRadioButton *bridge = new QRadioButton(tr("Bridge")); - zmq->setToolTip(tr("Subscribe directly to a ZMQ 'can' publisher: a device running " - "cereal/messaging/bridge, or konn3kt_canproxy.py on 127.0.0.1.")); - bridge->setToolTip(tr("Run cereal/messaging/bridge locally against the device and read msgq.")); - ip_address = new QLineEdit(this); - ip_address->setPlaceholderText(tr("Enter device Ip Address")); - ip_address->setValidator(new IpAddressValidator(this)); - - group = new QButtonGroup(this); - group->addButton(msgq, static_cast(DeviceStream::Mode::Msgq)); - group->addButton(zmq, static_cast(DeviceStream::Mode::Zmq)); - group->addButton(bridge, static_cast(DeviceStream::Mode::Bridge)); - - QFormLayout *form_layout = new QFormLayout(this); - form_layout->addRow(msgq); - form_layout->addRow(zmq, ip_address); - form_layout->addRow(bridge); - QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - if (checked) ip_address->setEnabled(button != msgq); - }); - zmq->setChecked(true); -} - -AbstractStream *OpenDeviceWidget::open() { - auto mode = static_cast(group->checkedId()); - return new DeviceStream(qApp, mode, mode == DeviceStream::Mode::Msgq ? "" : ip_address->text()); -} diff --git a/iqpilot/tools/cabana/streams/devicestream.h b/iqpilot/tools/cabana/streams/devicestream.h index 4c87147bf..660cbeeb2 100644 --- a/iqpilot/tools/cabana/streams/devicestream.h +++ b/iqpilot/tools/cabana/streams/devicestream.h @@ -2,28 +2,17 @@ #include "tools/cabana/streams/livestream.h" +#include #include -// IQ.Pilot patch: upstream (#38484) folded the ZMQ path into "fork a local bridge and -// read msgq". iqpilot needs the direct ZMQ attach kept as a first-class mode, because -// tools/cabana/konn3kt_canproxy.py publishes a remote device's CAN onto a LOCAL ZMQ -// "can" socket and Cabana attaches to it — see that script's header for the topology. -// So the mode is explicit rather than inferred from whether an address was entered: -// -// Msgq - local msgq, no address (cabana running on the device) -// Zmq - ZMQ subscribe straight to
(konn3kt_canproxy, or `bridge` on the device) -// Bridge - fork cereal/messaging/bridge
, (upstream's convenience path) -// which ZMQ-subscribes there and republishes -// to local msgq, then read msgq class DeviceStream : public LiveStream { - Q_OBJECT public: enum class Mode { Msgq, Zmq, Bridge }; - DeviceStream(QObject *parent, Mode mode = Mode::Msgq, QString address = {}); + DeviceStream(Mode mode = Mode::Msgq, std::string address = {}); ~DeviceStream(); inline std::string routeName() const override { - return "Live Streaming From " + address_.toStdString(); + return "Live Streaming From " + address_; } protected: @@ -32,17 +21,5 @@ protected: void stopBridge(); pid_t bridge_pid = -1; const Mode mode_; - const QString address_; -}; - -class OpenDeviceWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenDeviceWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *ip_address; - QButtonGroup *group; + const std::string address_; }; diff --git a/iqpilot/tools/cabana/streams/livestream.cc b/iqpilot/tools/cabana/streams/livestream.cc index 019a67e8f..2694f6b2c 100644 --- a/iqpilot/tools/cabana/streams/livestream.cc +++ b/iqpilot/tools/cabana/streams/livestream.cc @@ -9,6 +9,7 @@ #include "common/timing.h" #include "common/util.h" +#include "tools/cabana/settings.h" struct LiveStream::Logger { Logger() : start_ts(seconds_since_epoch()), segment_num(-1) {} @@ -21,12 +22,9 @@ struct LiveStream::Logger { localtime_r(&start_time, &local_time); std::ostringstream date; date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S"); - QString dir = QString("%1/%2--%3") - .arg(QString::fromStdString(settings.log_path)) - .arg(QString::fromStdString(date.str())) - .arg(n); - util::create_directories(dir.toStdString(), 0755); - fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out)); + std::string dir = settings.log_path + "/" + date.str() + "--" + std::to_string(n); + util::create_directories(dir, 0755); + fs.reset(new std::ofstream(dir + "/rlog", std::ios::binary | std::ios::out)); } auto bytes = data.asBytes(); @@ -38,7 +36,7 @@ struct LiveStream::Logger { uint64_t start_ts; }; -LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { +LiveStream::LiveStream() { if (settings.log_livestream) { logger = std::make_unique(); } @@ -50,7 +48,6 @@ LiveStream::~LiveStream() { void LiveStream::start() { begin_date_time = std::chrono::system_clock::now(); - fps_ = settings.fps; exit_ = false; stream_thread = std::thread(&LiveStream::streamThread, this); update_thread = std::thread(&LiveStream::updateThread, this); @@ -64,15 +61,15 @@ void LiveStream::stop() { void LiveStream::updateThread() { while (!exit_) { - std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); - // coalesce: skip the emit if the main thread hasn't processed the previous one yet. + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / STREAM_UPDATE_FPS)); + if (!update_pending_.exchange(true)) { - emit privateUpdateLastMsgsSignal(); + requestUpdateLastMessages(); } } } -// called in streamThread + void LiveStream::handleEvent(kj::ArrayPtr data) { if (logger) { logger->write(data); @@ -89,12 +86,11 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -// called on the main thread by the queued privateUpdateLastMsgsSignal connection + void LiveStream::updateLastMessages() { update_pending_ = false; - fps_ = settings.fps; { - // merge events received from live stream thread. + std::lock_guard lk(lock); mergeEvents(received_events_); uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; @@ -142,10 +138,10 @@ void LiveStream::seekTo(double sec) { first_update_ts = nanos_since_boot(); current_event_ts = first_event_ts = std::min(sec * 1e9 + begin_event_ts, lastest_event_ts); post_last_event = (first_event_ts == lastest_event_ts); - emit seekedTo((current_event_ts - begin_event_ts) / 1e9); + seekedTo((current_event_ts - begin_event_ts) / 1e9); } void LiveStream::pause(bool pause) { paused_ = pause; - emit(pause ? paused() : resume()); + pause ? paused() : resume(); } diff --git a/iqpilot/tools/cabana/streams/livestream.h b/iqpilot/tools/cabana/streams/livestream.h index 5d65b1743..1a17d481f 100644 --- a/iqpilot/tools/cabana/streams/livestream.h +++ b/iqpilot/tools/cabana/streams/livestream.h @@ -9,10 +9,8 @@ #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { - Q_OBJECT - public: - LiveStream(QObject *parent); + LiveStream(); virtual ~LiveStream(); void start() override; void stop(); @@ -39,7 +37,6 @@ private: std::mutex lock; std::thread stream_thread, update_thread; std::atomic update_pending_ = false; - std::atomic fps_ = 10; std::vector received_events_; std::chrono::system_clock::time_point begin_date_time; diff --git a/iqpilot/tools/cabana/streams/pandastream.cc b/iqpilot/tools/cabana/streams/pandastream.cc index 7ccb18a75..5b12f6217 100644 --- a/iqpilot/tools/cabana/streams/pandastream.cc +++ b/iqpilot/tools/cabana/streams/pandastream.cc @@ -4,13 +4,7 @@ #include #include -#include -#include -#include -#include -#include - -PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { +PandaStream::PandaStream(PandaStreamConfig config_) : config(config_) { if (!connect()) { throw std::runtime_error("Failed to connect to panda"); } @@ -30,12 +24,12 @@ bool PandaStream::connect() { for (int bus = 0; bus < config.bus_config.size(); bus++) { panda->set_can_speed_kbps(bus, config.bus_config[bus].can_speed_kbps); - // CAN-FD + if (panda->hw_type == cereal::PandaState::PandaType::RED_PANDA || panda->hw_type == cereal::PandaState::PandaType::RED_PANDA_V2) { if (config.bus_config[bus].can_fd) { panda->set_data_speed_kbps(bus, config.bus_config[bus].data_speed_kbps); } else { - // Hack to disable can-fd by setting data speed to a low value + panda->set_data_speed_kbps(bus, 10); } } @@ -77,113 +71,3 @@ void PandaStream::streamThread() { panda->send_heartbeat(false); } } - -// OpenPandaWidget - -OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - form_layout = new QFormLayout(this); - if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); - form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); - QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); - return; - } - - QHBoxLayout *serial_layout = new QHBoxLayout(); - serial_layout->addWidget(serial_edit = new QComboBox()); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred); - serial_layout->addWidget(refresh); - form_layout->addRow(tr("Serial"), serial_layout); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials); - QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm); - - // Populate serials - refreshSerials(); - buildConfigForm(); -} - -void OpenPandaWidget::refreshSerials() { - serial_edit->clear(); - for (auto serial : Panda::list()) { - serial_edit->addItem(QString::fromStdString(serial)); - } -} - -void OpenPandaWidget::buildConfigForm() { - for (int i = form_layout->rowCount() - 1; i > 0; --i) { - form_layout->removeRow(i); - } - - QString serial = serial_edit->currentText(); - bool has_fd = false; - bool has_panda = !serial.isEmpty(); - if (has_panda) { - try { - Panda panda(serial.toStdString()); - has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); - } catch (const std::exception& e) { - fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); - has_panda = false; - } - } - - if (has_panda) { - config.serial = serial.toStdString(); - config.bus_config.resize(3); - for (int i = 0; i < config.bus_config.size(); i++) { - QHBoxLayout *bus_layout = new QHBoxLayout; - - // CAN Speed - bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):"))); - QComboBox *can_speed = new QComboBox; - for (int j = 0; j < std::size(speeds); j++) { - can_speed->addItem(QString::number(speeds[j])); - - if (data_speeds[j] == config.bus_config[i].can_speed_kbps) { - can_speed->setCurrentIndex(j); - } - } - QObject::connect(can_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];}); - bus_layout->addWidget(can_speed); - - // CAN-FD Speed - if (has_fd) { - QCheckBox *enable_fd = new QCheckBox("CAN-FD"); - bus_layout->addWidget(enable_fd); - bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):"))); - QComboBox *data_speed = new QComboBox; - for (int j = 0; j < std::size(data_speeds); j++) { - data_speed->addItem(QString::number(data_speeds[j])); - - if (data_speeds[j] == config.bus_config[i].data_speed_kbps) { - data_speed->setCurrentIndex(j); - } - } - - data_speed->setEnabled(false); - bus_layout->addWidget(data_speed); - - QObject::connect(data_speed, qOverload(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];}); - QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled); - QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;}); - } - - form_layout->addRow(tr("Bus %1:").arg(i), bus_layout); - } - } else { - config.serial = ""; - form_layout->addWidget(new QLabel(tr("No panda found"))); - } -} - -AbstractStream *OpenPandaWidget::open() { - try { - return new PandaStream(qApp, config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what())); - return nullptr; - } -} diff --git a/iqpilot/tools/cabana/streams/pandastream.h b/iqpilot/tools/cabana/streams/pandastream.h index f8847f65e..760b5c7b1 100644 --- a/iqpilot/tools/cabana/streams/pandastream.h +++ b/iqpilot/tools/cabana/streams/pandastream.h @@ -3,15 +3,9 @@ #include #include -#include -#include - #include "tools/cabana/streams/livestream.h" #include "tools/cabana/panda.h" -const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; -const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; - struct BusConfig { int can_speed_kbps = 500; int data_speed_kbps = 2000; @@ -24,9 +18,8 @@ struct PandaStreamConfig { }; class PandaStream : public LiveStream { - Q_OBJECT public: - PandaStream(QObject *parent, PandaStreamConfig config_ = {}); + PandaStream(PandaStreamConfig config_ = {}); ~PandaStream() { stop(); } inline std::string routeName() const override { return "Panda: " + config.serial; @@ -39,19 +32,3 @@ protected: std::unique_ptr panda; PandaStreamConfig config = {}; }; - -class OpenPandaWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenPandaWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshSerials(); - void buildConfigForm(); - - QComboBox *serial_edit; - QFormLayout *form_layout; - PandaStreamConfig config = {}; -}; diff --git a/iqpilot/tools/cabana/streams/replaystream.cc b/iqpilot/tools/cabana/streams/replaystream.cc index dbd44b54e..504620e8d 100644 --- a/iqpilot/tools/cabana/streams/replaystream.cc +++ b/iqpilot/tools/cabana/streams/replaystream.cc @@ -1,28 +1,28 @@ #include "tools/cabana/streams/replaystream.h" -#include - -#include -#include -#include -#include -#include +#include #include "common/timing.h" #include "common/util.h" -#include "tools/cabana/streams/routes.h" +#include "tools/cabana/settings.h" -ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { +ReplayStream::ReplayStream() { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); op_prefix = std::make_unique(); - QObject::connect(&settings, &Settings::changed, this, [this]() { + settings_connection_ = settings.changed.connect([this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); }); } +ReplayStream::~ReplayStream() { + cancelWaits(); +} + + + void ReplayStream::mergeSegments() { auto event_data = replay->getEventData(); for (const auto &[n, seg] : event_data->segments) { @@ -31,58 +31,59 @@ void ReplayStream::mergeSegments() { std::vector new_events; new_events.reserve(seg->log->events.size()); + MessageEventsMap msg_events; for (const Event &e : seg->log->events) { if (e.which == cereal::Event::Which::CAN) { capnp::FlatArrayMessageReader reader(e.data); auto event = reader.getRoot(); for (const auto &c : event.getCan()) { - new_events.push_back(newEvent(e.mono_time, c)); + const CanEvent *ce = newEvent(e.mono_time, c); + new_events.push_back(ce); + msg_events[{.source = ce->src, .address = ce->address}].push_back(ce); } } } - mergeEvents(new_events); + postToMainThreadAndWait([&]() { insertEvents(new_events, msg_events); }); } } } bool ReplayStream::loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags, bool auto_source) { - replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, + replay.reset(new Replay(route, {"can", "narrowRoadEncodeIdx", "cabinEncodeIdx", "wideRoadEncodeIdx", "carParams"}, {}, nullptr, replay_flags, data_dir, auto_source)); replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); - // Forward replay callbacks to corresponding Qt signals. - replay->onSeeking = [this](double sec) { emit seeking(sec); }; + + replay->onSeeking = [this](double sec) { postToMainThread([this, sec]() { seeking(sec); }); }; replay->onSeekedTo = [this](double sec) { - emit seekedTo(sec); + postToMainThread([this, sec]() { seekedTo(sec); }); waitForSeekFinshed(); }; - replay->onQLogLoaded = [this](std::shared_ptr qlog) { emit qLogLoaded(qlog); }; - replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); }; + replay->onQLogLoaded = [this](std::shared_ptr qlog) { postToMainThread([this, qlog]() { qLogLoaded(qlog); }); }; + replay->onSegmentsMerged = [this]() { mergeSegments(); }; bool success = replay->load(); if (!success) { + std::string message; if (replay->lastRouteError() == RouteLoadError::Unauthorized) { auto auth_content = util::read_file(util::getenv("HOME") + "/.comma/auth.json"); - QString message; if (auth_content.empty()) { message = "Authentication Required. Please run the following command to authenticate:\n\n" - "python3 openpilot/tools/lib/auth.py\n\n" + "python3 iqpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { - message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(QString::fromStdString(route)); + message = "Access Denied. You do not have permission to access route:\n\n" + route + "\n\n" + "This is likely a private route."; } - QMessageBox::warning(nullptr, tr("Access Denied"), message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { - QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); + message = "Unable to load the route:\n\n " + route + ".\n\nPlease check your network connection and try again."; } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { - QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); + message = "The specified route could not be found:\n\n " + route + ".\n\nPlease check the route name and try again."; } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); + message = "Failed to load route: '" + route + "'"; } + error(message); } return success; } @@ -101,8 +102,8 @@ bool ReplayStream::eventFilter(const Event *event) { } double ts = millis_since_boot(); - if ((ts - prev_update_ts) > (1000.0 / settings.fps)) { - emit privateUpdateLastMsgsSignal(); + if ((ts - prev_update_ts) > (1000.0 / STREAM_UPDATE_FPS)) { + requestUpdateLastMessages(); prev_update_ts = ts; } return true; @@ -110,66 +111,5 @@ bool ReplayStream::eventFilter(const Event *event) { void ReplayStream::pause(bool pause) { replay->pause(pause); - emit(pause ? paused() : resume()); -} - - -// OpenReplayWidget - -OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QGridLayout *grid_layout = new QGridLayout(this); - grid_layout->addWidget(new QLabel(tr("Route")), 0, 0); - grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1); - route_edit->setPlaceholderText(tr("Enter route name or browse for local/remote route")); - auto browse_remote_btn = new QPushButton(tr("Remote route..."), this); - grid_layout->addWidget(browse_remote_btn, 0, 2); - auto browse_local_btn = new QPushButton(tr("Local route..."), this); - grid_layout->addWidget(browse_local_btn, 0, 3); - - QHBoxLayout *camera_layout = new QHBoxLayout(); - for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")}) - camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this))); - cameras[0]->setChecked(true); - camera_layout->addStretch(1); - grid_layout->addItem(camera_layout, 1, 1); - - setMinimumWidth(550); - QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); - if (!dir.isEmpty()) { - route_edit->setText(dir); - settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); - } - }); - QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { - RoutesDialog route_dlg(this); - if (route_dlg.exec()) { - route_edit->setText(route_dlg.route()); - } - }); -} - -AbstractStream *OpenReplayWidget::open() { - QString route = route_edit->text(); - QString data_dir; - if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) { - data_dir = route.mid(0, idx + 1); - route = route.mid(idx + 1); - } - - bool is_valid_format = Route::parseRoute(route.toStdString()).str.size() > 0; - if (!is_valid_format) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route)); - } else { - auto replay_stream = std::make_unique(qApp); - uint32_t flags = REPLAY_FLAG_NONE; - if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_DCAM; - if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM; - if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - - if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { - return replay_stream.release(); - } - } - return nullptr; + pause ? paused() : resume(); } diff --git a/iqpilot/tools/cabana/streams/replaystream.h b/iqpilot/tools/cabana/streams/replaystream.h index eecd34571..698f84044 100644 --- a/iqpilot/tools/cabana/streams/replaystream.h +++ b/iqpilot/tools/cabana/streams/replaystream.h @@ -1,22 +1,17 @@ #pragma once -#include #include #include #include -#include #include "common/prefix.h" #include "tools/cabana/streams/abstractstream.h" #include "tools/replay/replay.h" -Q_DECLARE_METATYPE(std::shared_ptr); - class ReplayStream : public AbstractStream { - Q_OBJECT - public: - ReplayStream(QObject *parent); + ReplayStream(); + ~ReplayStream(); void start() override { replay->start(); } bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); bool eventFilter(const Event *event); @@ -36,24 +31,13 @@ public: inline bool isPaused() const override { return replay->isPaused(); } void pause(bool pause) override; -signals: - void qLogLoaded(std::shared_ptr qlog); + + Observable> qLogLoaded; private: void mergeSegments(); std::unique_ptr replay = nullptr; + Connection settings_connection_; std::set processed_segments; std::unique_ptr op_prefix; }; - -class OpenReplayWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenReplayWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - QLineEdit *route_edit; - std::vector cameras; -}; diff --git a/iqpilot/tools/cabana/streams/routes.cc b/iqpilot/tools/cabana/streams/routes.cc deleted file mode 100644 index 977039966..000000000 --- a/iqpilot/tools/cabana/streams/routes.cc +++ /dev/null @@ -1,200 +0,0 @@ -#include "tools/cabana/streams/routes.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include "third_party/json11/json11.hpp" -// IQ.Pilot patch: iqpilot's tools/replay has no py_downloader — device and route -// listing come from the konn3kt API over libcurl. CommaApi2 returns the same JSON -// shapes and the same {"error": ...} envelope upstream's PyDownloader produces, so -// only the call sites change. -#include "tools/replay/api.h" - -namespace { - -// Parse a konn3kt API JSON response into (success, error_code). -std::pair checkApiResponse(const std::string &result) { - if (result.empty()) return {false, 500}; - std::string err; - auto doc = json11::Json::parse(result, err); - if (!err.empty()) return {false, 500}; - if (doc.is_object() && doc["error"].is_string()) { - return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; - } - return {true, 0}; -} - -int64_t nowUnixMs() { - return std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); -} - -// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. -int64_t parseIsoToUnixMs(const std::string &s) { - std::string bytes = s; - if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); - int millis = 0; - auto dot = bytes.find('.'); - if (dot != std::string::npos) { - std::string frac = bytes.substr(dot + 1); - bytes = bytes.substr(0, dot); - while (frac.size() < 3) frac.push_back('0'); - millis = std::atoi(frac.substr(0, 3).c_str()); - } - std::tm tm{}; - const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); - if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); - if (!ret) return 0; - tm.tm_isdst = -1; - time_t secs = timegm(&tm); - if (secs == static_cast(-1)) return 0; - return static_cast(secs) * 1000 + millis; -} - -QString formatUnixMs(int64_t ms) { - time_t secs = static_cast(ms / 1000); - std::tm tm{}; - localtime_r(&secs, &tm); - char buf[64]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - return QString::fromUtf8(buf); -} - -} // namespace - -// The RouteListWidget class extends QListWidget to display a custom message when empty -class RouteListWidget : public QListWidget { -public: - RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {} - void setEmptyText(const QString &text) { - empty_text_ = text; - viewport()->update(); - } - void paintEvent(QPaintEvent *event) override { - QListWidget::paintEvent(event); - if (count() == 0) { - QPainter painter(viewport()); - painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_); - } - } - QString empty_text_ = tr("No items"); -}; - -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Remote routes")); - - QFormLayout *layout = new QFormLayout(this); - layout->addRow(tr("Device"), device_list_ = new QComboBox(this)); - layout->addRow(period_selector_ = new QComboBox(this)); - layout->addRow(route_list_ = new RouteListWidget(this)); - auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - layout->addRow(button_box); - - device_list_->addItem(tr("Loading...")); - period_selector_->addItem(tr("Last week"), 7); - period_selector_->addItem(tr("Last 2 weeks"), 14); - period_selector_->addItem(tr("Last month"), 30); - period_selector_->addItem(tr("Last 6 months"), 180); - period_selector_->addItem(tr("Preserved"), -1); - - connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); - connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - - // Fetch devices - std::thread([this, alive = std::weak_ptr(alive_)]() { - std::string result = CommaApi2::getDevices(); - QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { - if (!alive.expired()) parseDeviceList(r, response.first, response.second); - }, Qt::QueuedConnection); - }).detach(); -} - -void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { - if (success) { - device_list_->clear(); - std::string err; - auto doc = json11::Json::parse(json.toStdString(), err); - if (err.empty() && doc.is_array()) { - for (const auto &device : doc.array_items()) { - QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); - device_list_->addItem(dongle_id, dongle_id); - } - } - } else { - QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with tools/lib/auth.py") : tr("Network error")); - reject(); - } -} - -void RoutesDialog::fetchRoutes() { - if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull()) - return; - - route_list_->clear(); - route_list_->setEmptyText(tr("Loading...")); - - std::string did = device_list_->currentText().toStdString(); - int period = period_selector_->currentData().toInt(); - - bool preserved = (period == -1); - int64_t start_ms = 0, end_ms = 0; - if (!preserved) { - end_ms = nowUnixMs(); - start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; - } - - int request_id = ++fetch_id_; - std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { - std::string result = CommaApi2::getDeviceRoutes(did, start_ms, end_ms, preserved); - QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { - if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); - }, Qt::QueuedConnection); - }).detach(); -} - -void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { - if (success) { - std::string err; - auto doc = json11::Json::parse(json.toStdString(), err); - if (err.empty() && doc.is_array()) { - for (const auto &route : doc.array_items()) { - int64_t from_ms = 0, to_ms = 0; - if (period_selector_->currentData().toInt() == -1) { - from_ms = parseIsoToUnixMs(route["start_time"].string_value()); - to_ms = parseIsoToUnixMs(route["end_time"].string_value()); - } else { - from_ms = static_cast(route["start_time_utc_millis"].number_value()); - to_ms = static_cast(route["end_time_utc_millis"].number_value()); - } - const int mins = static_cast((to_ms - from_ms) / 60000); - auto item = new QListWidgetItem(QString("%1 %2min").arg(formatUnixMs(from_ms)).arg(mins)); - item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value())); - route_list_->addItem(item); - } - } - if (route_list_->count() > 0) route_list_->setCurrentRow(0); - } else { - QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection.")); - reject(); - } - route_list_->setEmptyText(tr("No items")); -} - -QString RoutesDialog::route() { - auto current_item = route_list_->currentItem(); - return current_item ? current_item->data(Qt::UserRole).toString() : ""; -} diff --git a/iqpilot/tools/cabana/streams/routes.h b/iqpilot/tools/cabana/streams/routes.h deleted file mode 100644 index 6ed145603..000000000 --- a/iqpilot/tools/cabana/streams/routes.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -class RouteListWidget; - -class RoutesDialog : public QDialog { - Q_OBJECT -public: - RoutesDialog(QWidget *parent); - QString route(); - -protected: - void parseDeviceList(const QString &json, bool success, int error_code); - void parseRouteList(const QString &json, bool success, int error_code); - void fetchRoutes(); - - QComboBox *device_list_; - QComboBox *period_selector_; - RouteListWidget *route_list_; - std::atomic fetch_id_{0}; - // expires on destruction; guards main-thread callbacks from detached worker threads - std::shared_ptr alive_ = std::make_shared(true); -}; diff --git a/iqpilot/tools/cabana/streams/socketcanstream.cc b/iqpilot/tools/cabana/streams/socketcanstream.cc index b616e7f24..aba1386b6 100644 --- a/iqpilot/tools/cabana/streams/socketcanstream.cc +++ b/iqpilot/tools/cabana/streams/socketcanstream.cc @@ -8,15 +8,8 @@ #include #include -#include -#include -#include -#include -#include -#include - -SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { +SocketCanStream::SocketCanStream(SocketCanStreamConfig config_) : config(config_) { if (!available()) { throw std::runtime_error("SocketCAN not available"); } @@ -49,7 +42,7 @@ bool SocketCanStream::connect() { return false; } - // Enable CAN-FD + int fd_enable = 1; setsockopt(sock_fd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &fd_enable, sizeof(fd_enable)); @@ -72,8 +65,8 @@ bool SocketCanStream::connect() { return false; } - // Set read timeout so the thread can check for interruption - struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms + + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); return true; @@ -86,7 +79,7 @@ void SocketCanStream::streamThread() { ssize_t nbytes = read(sock_fd, &frame, sizeof(frame)); if (nbytes <= 0) continue; - uint8_t len = (nbytes == CAN_MTU) ? frame.len : frame.len; // works for both CAN and CAN-FD + uint8_t len = (nbytes == CAN_MTU) ? frame.len : frame.len; MessageBuilder msg; auto evt = msg.initEvent(); @@ -98,51 +91,3 @@ void SocketCanStream::streamThread() { handleEvent(capnp::messageToFlatArray(msg)); } } - -OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { - QVBoxLayout *main_layout = new QVBoxLayout(this); - main_layout->addStretch(1); - - QFormLayout *form_layout = new QFormLayout(); - - QHBoxLayout *device_layout = new QHBoxLayout(); - device_edit = new QComboBox(); - device_edit->setFixedWidth(300); - device_layout->addWidget(device_edit); - - QPushButton *refresh = new QPushButton(tr("Refresh")); - refresh->setFixedWidth(100); - device_layout->addWidget(refresh); - form_layout->addRow(tr("Device"), device_layout); - main_layout->addLayout(form_layout); - - main_layout->addStretch(1); - - QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); - - // Populate devices - refreshDevices(); -} - -void OpenSocketCanWidget::refreshDevices() { - device_edit->clear(); - // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) - std::error_code ec; - for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { - std::ifstream type_file(entry.path() / "type"); - int type = 0; - if (type_file >> type && type == 280) { - device_edit->addItem(QString::fromStdString(entry.path().filename().string())); - } - } -} - -AbstractStream *OpenSocketCanWidget::open() { - try { - return new SocketCanStream(qApp, config); - } catch (std::exception &e) { - QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what())); - return nullptr; - } -} diff --git a/iqpilot/tools/cabana/streams/socketcanstream.h b/iqpilot/tools/cabana/streams/socketcanstream.h index 3c5cd184f..edf79317e 100644 --- a/iqpilot/tools/cabana/streams/socketcanstream.h +++ b/iqpilot/tools/cabana/streams/socketcanstream.h @@ -1,17 +1,14 @@ #pragma once -#include - #include "tools/cabana/streams/livestream.h" struct SocketCanStreamConfig { - std::string device = ""; // TODO: support multiple devices/buses at once + std::string device = ""; }; class SocketCanStream : public LiveStream { - Q_OBJECT public: - SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {}); + SocketCanStream(SocketCanStreamConfig config_ = {}); ~SocketCanStream(); static bool available(); @@ -26,17 +23,3 @@ protected: SocketCanStreamConfig config = {}; int sock_fd = -1; }; - -class OpenSocketCanWidget : public AbstractOpenStreamWidget { - Q_OBJECT - -public: - OpenSocketCanWidget(QWidget *parent = nullptr); - AbstractStream *open() override; - -private: - void refreshDevices(); - - QComboBox *device_edit; - SocketCanStreamConfig config = {}; -}; diff --git a/iqpilot/tools/cabana/streamselector.cc b/iqpilot/tools/cabana/streamselector.cc deleted file mode 100644 index 7e8adc568..000000000 --- a/iqpilot/tools/cabana/streamselector.cc +++ /dev/null @@ -1,69 +0,0 @@ -#include "tools/cabana/streamselector.h" - -#include - -#include -#include -#include - -#include "tools/cabana/streams/devicestream.h" -#include "tools/cabana/streams/pandastream.h" -#include "tools/cabana/streams/replaystream.h" -#ifdef __linux__ -#include "tools/cabana/streams/socketcanstream.h" -#endif - -StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { - setWindowTitle(tr("Open stream")); - QVBoxLayout *layout = new QVBoxLayout(this); - tab = new QTabWidget(this); - layout->addWidget(tab); - - QHBoxLayout *dbc_layout = new QHBoxLayout(); - dbc_file = new QLineEdit(this); - dbc_file->setReadOnly(true); - dbc_file->setPlaceholderText(tr("Choose a dbc file to open")); - QPushButton *file_btn = new QPushButton(tr("Browse...")); - dbc_layout->addWidget(new QLabel(tr("dbc File"))); - dbc_layout->addWidget(dbc_file); - dbc_layout->addWidget(file_btn); - layout->addLayout(dbc_layout); - - QFrame *line = new QFrame(this); - line->setFrameStyle(QFrame::HLine | QFrame::Sunken); - layout->addWidget(line); - - btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel); - layout->addWidget(btn_box); - - addStreamWidget(new OpenReplayWidget, tr("&Replay")); - addStreamWidget(new OpenPandaWidget, tr("&Panda")); -#ifdef __linux__ - if (SocketCanStream::available()) { - addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN")); - } -#endif - addStreamWidget(new OpenDeviceWidget, tr("&Device")); - - QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() { - setEnabled(false); - if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) { - accept(); - } - setEnabled(true); - }); - QObject::connect(file_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); - if (!fn.isEmpty()) { - dbc_file->setText(fn); - settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); - } - }); -} - -void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w, const QString &title) { - tab->addTab(w, title); - auto open_btn = btn_box->button(QDialogButtonBox::Open); - QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled); -} diff --git a/iqpilot/tools/cabana/streamselector.h b/iqpilot/tools/cabana/streamselector.h deleted file mode 100644 index 0919195e4..000000000 --- a/iqpilot/tools/cabana/streamselector.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "tools/cabana/streams/abstractstream.h" - -class StreamSelector : public QDialog { - Q_OBJECT - -public: - StreamSelector(QWidget *parent = nullptr); - void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title); - QString dbcFile() const { return dbc_file->text(); } - AbstractStream *stream() const { return stream_; } - -private: - AbstractStream *stream_ = nullptr; - QLineEdit *dbc_file; - QTabWidget *tab; - QDialogButtonBox *btn_box; -}; diff --git a/iqpilot/tools/cabana/test_cabana_konn3kt.py b/iqpilot/tools/cabana/test_cabana_konn3kt.py index c07870eb3..4fb9ac4c3 100644 --- a/iqpilot/tools/cabana/test_cabana_konn3kt.py +++ b/iqpilot/tools/cabana/test_cabana_konn3kt.py @@ -2,6 +2,7 @@ import json import subprocess from pathlib import Path + CABANA_DIR = Path(__file__).parent CABANA_BIN = CABANA_DIR / "_cabana" @@ -16,85 +17,76 @@ class TestCabanaBinary: result = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60) assert result.returncode == 0, result.stderr assert "Usage:" in result.stderr + assert "--zmq" in result.stderr + assert "--bridge" in result.stderr - def test_help_documents_both_live_can_modes(self): - assert CABANA_BIN.exists(), "cabana not built (scons -u iqpilot/tools/cabana/_cabana)" - # --zmq attaches directly (konn3kt_canproxy); --bridge is upstream's local-bridge path - out = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60).stderr - assert "--zmq" in out - assert "--bridge" in out - assert "konn3kt_canproxy.py" in out - - def test_launcher_builds_the_right_targets(self): + def test_launcher_builds_current_targets(self): launcher = read("cabana") - # iqpilot is not nested under openpilot/ - assert "scons -u iqpilot/tools/cabana/_cabana iqpilot/cereal/messaging/bridge" in launcher + assert "iqpilot/tools/cabana/_cabana" in launcher + assert "iqpilot/cereal/messaging/bridge" in launcher assert "openpilot/tools/cabana" not in launcher class TestKonn3ktIntegration: - def test_routes_dialog_uses_konn3kt_api(self): - # upstream shells into tools/lib via PyDownloader; iqpilot goes through CommaApi2 - routes = read("streams/routes.cc") + def test_routes_use_konn3kt_api(self): + routes = read("routes.cc") assert "PyDownloader::" not in routes assert "py_downloader.h" not in routes assert "CommaApi2::getDevices()" in routes assert "CommaApi2::getDeviceRoutes(" in routes - def test_no_duplicate_qt_api_client(self): - # the old Qt HttpRequest/JWT client was folded into tools/replay/api.cc - assert not (CABANA_DIR / "utils" / "api.cc").exists() - assert not (CABANA_DIR / "utils" / "api.h").exists() - def test_no_comma_endpoints(self): - for name in ("streams/routes.cc", "cabana.cc", "README.md"): + for name in ("routes.cc", "ui/main.cc", "README.md"): body = read(name) assert "connect.comma.ai" not in body, name assert "api.comma.ai" not in body, name - def test_dbc_menu_points_at_iqdbc(self): - mainwin = read("mainwin.cc") - assert "commaai/iqdbc" in mainwin + def test_dbc_menu_uses_iqdbc(self): + mainwin = read("ui/mainwin.cc") + assert "IQ.Pilot iqdbc" in mainwin assert "commaai/opendbc" not in mainwin def test_dbc_json_generator_imports_iqdbc(self): - gen = read("dbc/generate_dbc_json.py") - assert "from iqdbc.car" in gen - assert "from opendbc.car" not in gen + generator = read("dbc/generate_dbc_json.py") + assert "from iqdbc.car" in generator + assert "from opendbc.car" not in generator def test_generated_dbc_json_covers_iqdbc_platforms(self): - # built by scons; cabana reads it to auto-select a DBC per car fingerprint path = CABANA_DIR / "dbc" / "car_fingerprint_to_dbc.json" - assert path.is_file(), "run scons -u iqpilot/tools/cabana" + assert path.is_file(), "run scons -u iqpilot/tools/cabana/_cabana" mapping = json.loads(path.read_text()) assert len(mapping) > 100 - assert all(isinstance(v, str) and v for v in mapping.values()) + assert all(isinstance(value, str) and value for value in mapping.values()) def test_canproxy_targets_konn3kt(self): proxy = read("konn3kt_canproxy.py") assert "konn3kt" in proxy - # the proxy publishes on a local ZMQ 'can' socket that --zmq attaches to assert 'os.environ["ZMQ"] = "1"' in proxy class TestDeviceStreamModes: - """The ZMQ attach is what konn3kt_canproxy feeds; upstream #38484 replaced it with a - bridge fork. Both must exist, and only the direct attach may set ZMQ=1.""" - def test_three_modes_exist(self): header = read("streams/devicestream.h") assert "enum class Mode { Msgq, Zmq, Bridge };" in header def test_only_zmq_mode_talks_zmq(self): - src = read("streams/devicestream.cc") - assert 'mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ")' in src + source = read("streams/devicestream.cc") + assert 'mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ")' in source - def test_zmq_mode_subscribes_to_the_given_address(self): - # regression guard: upstream hardcodes 127.0.0.1 and relies on the bridge fork, - # which never reads the canproxy publisher - src = read("streams/devicestream.cc") - assert 'const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1";' in src + def test_zmq_mode_uses_requested_address(self): + source = read("streams/devicestream.cc") + assert 'const std::string socket_address = mode_ == Mode::Zmq ? address_ : "127.0.0.1";' in source - def test_only_bridge_mode_forks_the_bridge(self): - src = read("streams/devicestream.cc") - assert "if (mode_ == Mode::Bridge) {" in src + def test_only_bridge_mode_forks_bridge(self): + source = read("streams/devicestream.cc") + assert "if (mode_ == Mode::Bridge) {" in source + + +class TestFrontendRemoval: + def test_qt_frontend_is_absent(self): + assert not (CABANA_DIR / "cabana.cc").exists() + assert not (CABANA_DIR / "mainwin.cc").exists() + + def test_imgui_frontend_is_present(self): + assert (CABANA_DIR / "ui" / "app.cc").is_file() + assert (CABANA_DIR / "ui" / "main.cc").is_file() diff --git a/iqpilot/tools/cabana/tests/test_cabana.cc b/iqpilot/tools/cabana/tests/test_cabana.cc index 92fa6d7fa..bcf99a024 100644 --- a/iqpilot/tools/cabana/tests/test_cabana.cc +++ b/iqpilot/tools/cabana/tests/test_cabana.cc @@ -1,10 +1,15 @@ +#include +#include +#include #include #include #include "common/tests/native_test.h" #include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/routes.h" +#include "tools/cabana/utils/strings.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; @@ -30,7 +35,7 @@ void test_generate_dbc() { } void test_comment_order() { - // Ensure that message comments are followed by signal comments and in the correct order + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX @@ -91,7 +96,7 @@ VAL_ 160 signal_1 0 "disabled" 1.2 "initializing" 2 "fault"; CM_ BO_ 160 "message comment" ; CM_ SG_ 160 signal_1 "signal comment"; -CM_ SG_ 160 signal_2 "multiple line comment +CM_ SG_ 160 signal_2 "multiple line comment 1 2 "; @@ -125,9 +130,9 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); auto &sig_2 = msg->sigs[1]; - REQUIRE(sig_2->comment == "multiple line comment \n1\n2"); + REQUIRE(sig_2->comment == "multiple line comment\n1\n2"); + - // multiplexed signals msg = file.msg(162); REQUIRE(msg != nullptr); REQUIRE(msg->sigs.size() == 2); @@ -138,42 +143,20 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(msg->sigs[1]->size == 1); REQUIRE(msg->sigs[1]->receiver_name == "XXX"); - // escaped quotes + REQUIRE(msg->comment == "message comment with \"escaped quotes\""); REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\""); } -void test_parse_iqdbc() { - std::vector errors; - int parsed = 0; - for (const auto &entry : std::filesystem::recursive_directory_iterator(OPENDBC_FILE_PATH)) { - if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue; - try { - auto dbc = DBCFile(entry.path().string()); - ++parsed; - } catch (std::exception &e) { - errors.push_back(e.what()); - } - } - // guard against OPENDBC_FILE_PATH pointing somewhere empty, which would make the - // loop above pass vacuously - REQUIRE(parsed > 100); - std::ostringstream details; - for (const auto &error : errors) details << error << '\n'; - if (!errors.empty()) std::cerr << details.str(); - REQUIRE(errors.empty()); -} - void test_dbc_manager() { DBCManager manager; int files_changed = 0; int signals_added = 0; int masks_updated = 0; - manager.setCallbacks({ - .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, - .file_changed = [&]() { ++files_changed; }, - .mask_updated = [&]() { ++masks_updated; }, - }); + Connections connections; + connections.push_back(manager.signalAdded.connect([&](MessageId, const cabana::Signal *) { ++signals_added; })); + connections.push_back(manager.fileChanged.connect([&]() { ++files_changed; })); + connections.push_back(manager.maskUpdated.connect([&]() { ++masks_updated; })); std::string error; REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); @@ -191,14 +174,129 @@ void test_dbc_manager() { REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); } +void test_format_seconds() { + REQUIRE(utils::formatSeconds(0) == "00:00"); + REQUIRE(utils::formatSeconds(59.4) == "00:59"); + REQUIRE(utils::formatSeconds(-1) == "00:00"); + REQUIRE(utils::formatSeconds(61.234, true) == "01:01.234"); + REQUIRE(utils::formatSeconds(3599.9) == "59:59"); + REQUIRE(utils::formatSeconds(3601) == "01:00:01"); + REQUIRE(utils::formatSeconds(3601.5, true) == "01:00:01.500"); + + const char *tz = getenv("TZ"); + const bool had_tz = tz != nullptr; + const std::string saved_tz = had_tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(utils::formatSeconds(0, false, true) == "1970-01-01 00:00:00"); + REQUIRE(utils::formatSeconds(1700000000.123, true, true) == "2023-11-14 22:13:20.123"); + if (had_tz) { + setenv("TZ", saved_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_to_hex() { + REQUIRE(utils::toHex({}) == ""); + REQUIRE(utils::toHex({0x00, 0x0f, 0xab, 0xff}) == "000FABFF"); + REQUIRE(utils::toHex({0x01, 0x02, 0x03}, ' ') == "01 02 03"); + + REQUIRE(utils::toHexString(0) == "0x00"); + REQUIRE(utils::toHexString(0xf) == "0x0F"); + REQUIRE(utils::toHexString(0x1ab) == "0x1AB"); + REQUIRE(utils::toHexString(0x1fffffff) == "0x1FFFFFFF"); +} + +void test_signal_tooltip() { + cabana::Signal sig{}; + sig.name = "speed"; + sig.start_bit = 3; + sig.size = 12; + sig.msb = 14; + sig.lsb = 3; + sig.is_little_endian = true; + sig.is_signed = false; + REQUIRE(utils::signalToolTip(&sig) == R"( + speed
+ Start Bit: 3 Size: 12
+ MSB: 14 LSB: 3
+ Little Endian: Y Signed: N
+ )"); +} + +void test_route_timestamps() { + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05Z") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02 03:04:05") == 1704164645000); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.4Z") == 1704164645400); + REQUIRE(routes::parseIsoToUnixMs("2024-01-02T03:04:05.123456Z") == 1704164645123); + REQUIRE(routes::parseIsoToUnixMs("") == 0); + REQUIRE(routes::parseIsoToUnixMs("not a timestamp") == 0); + + + const char *tz = getenv("TZ"); + const std::string prev_tz = tz ? tz : ""; + setenv("TZ", "UTC", 1); + tzset(); + REQUIRE(routes::formatUnixMs(1704164645123) == "2024-01-02 03:04:05"); + if (tz) { + setenv("TZ", prev_tz.c_str(), 1); + } else { + unsetenv("TZ"); + } + tzset(); +} + +void test_route_api_response() { + REQUIRE(routes::checkApiResponse("") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("not json") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse(R"({"error": "unauthorized"})") == std::make_pair(false, 401)); + REQUIRE(routes::checkApiResponse(R"({"error": "server error"})") == std::make_pair(false, 500)); + REQUIRE(routes::checkApiResponse("[]") == std::make_pair(true, 0)); + REQUIRE(routes::checkApiResponse(R"({"dongle_id": "aaaa"})") == std::make_pair(true, 0)); +} + +void test_route_json() { + auto devices = routes::parseDevices(R"([{"dongle_id": "aaaa"}, {"dongle_id": "bbbb"}])"); + REQUIRE(devices.size() == 2); + REQUIRE(devices[0].dongle_id == "aaaa"); + REQUIRE(devices[1].dongle_id == "bbbb"); + REQUIRE(routes::parseDevices("not json").empty()); + REQUIRE(routes::parseDevices(R"({"error": "unauthorized"})").empty()); + + auto list = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time_utc_millis": 1704164645000, "end_time_utc_millis": 1704165245000}])", false); + REQUIRE(list.size() == 1); + REQUIRE(list[0].name == "aaaa|2024-01-02--03-04-05"); + REQUIRE(list[0].start_ms == 1704164645000); + REQUIRE(list[0].end_ms == 1704165245000); + + + auto preserved = routes::parseRoutes( + R"([{"fullname": "aaaa|2024-01-02--03-04-05", "start_time": "2024-01-02T03:04:05Z", "end_time": "2024-01-02T03:14:05Z"}])", true); + REQUIRE(preserved.size() == 1); + REQUIRE(preserved[0].start_ms == 1704164645000); + REQUIRE(preserved[0].end_ms == 1704165245000); + + REQUIRE(routes::parseRoutes("not json", false).empty()); +} + void test_cabana_core() { + test_format_seconds(); + test_to_hex(); + test_signal_tooltip(); test_generate_dbc(); test_comment_order(); test_preserve_original_header(); test_escaped_quotes(); test_parse_dbc(); - test_parse_iqdbc(); test_dbc_manager(); + test_route_timestamps(); + test_route_api_response(); + test_route_json(); } int main() { diff --git a/iqpilot/tools/cabana/tests/test_cabana_ui.py b/iqpilot/tools/cabana/tests/test_cabana_ui.py new file mode 100644 index 000000000..b017b37ab --- /dev/null +++ b/iqpilot/tools/cabana/tests/test_cabana_ui.py @@ -0,0 +1,11 @@ +import subprocess +from pathlib import Path + +CABANA_DIR = Path(__file__).parent.parent + + +class TestCabanaUi: + def test_help(self): + result = subprocess.run(["./_cabana", "-h"], cwd=CABANA_DIR, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr diff --git a/iqpilot/tools/cabana/tools/findsignal.cc b/iqpilot/tools/cabana/tools/findsignal.cc deleted file mode 100644 index 4511af7c0..000000000 --- a/iqpilot/tools/cabana/tools/findsignal.cc +++ /dev/null @@ -1,284 +0,0 @@ -#include "tools/cabana/tools/findsignal.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -// FindSignalModel - -QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const { - static QString titles[] = {"Id", "Start Bit, size", "(time, value)"}; - if (role != Qt::DisplayRole) return {}; - return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1); -} - -QVariant FindSignalModel::data(const QModelIndex &index, int role) const { - if (role == Qt::DisplayRole) { - const auto &s = filtered_signals[index.row()]; - switch (index.column()) { - case 0: return QString::fromStdString(s.id.toString()); - case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size); - case 2: return s.values.join(" "); - } - } - return {}; -} - -void FindSignalModel::search(std::function cmp) { - beginResetModel(); - - std::mutex lock; - const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; - filtered_signals.clear(); - filtered_signals.reserve(prev_sigs.size()); - - unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); - size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads; - std::vector threads; - for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) { - size_t start = t * chunk; - size_t end = std::min(start + chunk, (size_t)prev_sigs.size()); - threads.emplace_back([&, start, end]() { - for (size_t i = start; i < end; ++i) { - const auto &s = prev_sigs[i]; - const auto &events = can->events(s.id); - auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); - auto last = events.cend(); - if (last_time < std::numeric_limits::max()) { - last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); - } - - auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); - if (it != last) { - auto values = s.values; - values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); - std::lock_guard lk(lock); - filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); - } - } - }); - } - for (auto &th : threads) th.join(); - - histories.push_back(filtered_signals); - - endResetModel(); -} - -void FindSignalModel::undo() { - if (!histories.empty()) { - beginResetModel(); - histories.pop_back(); - filtered_signals.clear(); - if (!histories.empty()) filtered_signals = histories.back(); - endResetModel(); - } -} - -void FindSignalModel::reset() { - beginResetModel(); - histories.clear(); - filtered_signals.clear(); - initial_signals.clear(); - endResetModel(); -} - -// FindSignalDlg -FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find Signal")); - setAttribute(Qt::WA_DeleteOnClose); - QVBoxLayout *main_layout = new QVBoxLayout(this); - - // Messages group - message_group = new QGroupBox(tr("Messages"), this); - QFormLayout *message_layout = new QFormLayout(message_group); - message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit()); - bus_edit->setPlaceholderText(tr("comma-separated values. Leave blank for all")); - message_layout->addRow(tr("Address"), address_edit = new QLineEdit()); - address_edit->setPlaceholderText(tr("comma-separated hex values. Leave blank for all")); - QHBoxLayout *hlayout = new QHBoxLayout(); - hlayout->addWidget(first_time_edit = new QLineEdit("0")); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(last_time_edit = new QLineEdit("MAX")); - hlayout->addWidget(new QLabel("seconds")); - hlayout->addStretch(0); - message_layout->addRow(tr("Time"), hlayout); - - // Signal group - properties_group = new QGroupBox(tr("Signal")); - QFormLayout *property_layout = new QFormLayout(properties_group); - property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(min_size = new QSpinBox); - hlayout->addWidget(new QLabel("-")); - hlayout->addWidget(max_size = new QSpinBox); - hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian"))); - hlayout->addWidget(is_signed = new QCheckBox(tr("Signed"))); - hlayout->addStretch(0); - min_size->setRange(1, 64); - max_size->setRange(1, 64); - min_size->setValue(8); - max_size->setValue(8); - litter_endian->setChecked(true); - property_layout->addRow(tr("Size"), hlayout); - property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0")); - property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0")); - - // find group - QGroupBox *find_group = new QGroupBox(tr("Find signal"), this); - QVBoxLayout *vlayout = new QVBoxLayout(find_group); - hlayout = new QHBoxLayout(); - hlayout->addWidget(new QLabel(tr("Value"))); - hlayout->addWidget(compare_cb = new QComboBox(this)); - hlayout->addWidget(value1 = new QLineEdit); - hlayout->addWidget(to_label = new QLabel("-")); - hlayout->addWidget(value2 = new QLineEdit); - hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this)); - hlayout->addWidget(search_btn = new QPushButton(tr("Find"))); - hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this)); - vlayout->addLayout(hlayout); - - compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"}); - value1->setFocus(Qt::OtherFocusReason); - value2->setVisible(false); - to_label->setVisible(false); - undo_btn->setEnabled(false); - reset_btn->setEnabled(false); - - auto double_validator = new DoubleValidator(this); - for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) { - edit->setValidator(double_validator); - } - - vlayout->addWidget(view = new QTableView(this)); - view->setContextMenuPolicy(Qt::CustomContextMenu); - view->horizontalHeader()->setStretchLastSection(true); - view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection); - view->setSelectionBehavior(QAbstractItemView::SelectRows); - view->setModel(model = new FindSignalModel(this)); - - hlayout = new QHBoxLayout(); - hlayout->addWidget(message_group); - hlayout->addWidget(properties_group); - main_layout->addLayout(hlayout); - main_layout->addWidget(find_group); - main_layout->addWidget(stats_label = new QLabel()); - - setMinimumSize({700, 650}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search); - QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo); - QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset); - QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset); - QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested); - QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id); - }); - QObject::connect(compare_cb, qOverload(&QComboBox::currentIndexChanged), [=](int index) { - to_label->setVisible(index == compare_cb->count() - 1); - value2->setVisible(index == compare_cb->count() - 1); - }); -} - -void FindSignalDlg::search() { - if (model->histories.empty()) { - setInitialSignals(); - } - auto v1 = value1->text().toDouble(); - auto v2 = value2->text().toDouble(); - std::function cmp = nullptr; - switch (compare_cb->currentIndex()) { - case 0: cmp = [v1](double v) { return v == v1;}; break; - case 1: cmp = [v1](double v) { return v > v1;}; break; - case 2: cmp = [v1](double v) { return v >= v1;}; break; - case 3: cmp = [v1](double v) { return v != v1;}; break; - case 4: cmp = [v1](double v) { return v < v1;}; break; - case 5: cmp = [v1](double v) { return v <= v1;}; break; - case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; - } - properties_group->setEnabled(false); - message_group->setEnabled(false); - search_btn->setEnabled(false); - stats_label->setVisible(false); - search_btn->setText("Finding ...."); - QTimer::singleShot(0, this, [=]() { model->search(cmp); }); -} - -void FindSignalDlg::setInitialSignals() { - std::set buses; - for (auto bus : bus_edit->text().trimmed().split(",")) { - bus = bus.trimmed(); - if (!bus.isEmpty()) buses.insert(bus.toUShort()); - } - - std::set addresses; - for (auto addr : address_edit->text().trimmed().split(",")) { - addr = addr.trimmed(); - if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); - } - - cabana::Signal sig{}; - sig.is_little_endian = litter_endian->isChecked(); - sig.is_signed = is_signed->isChecked(); - sig.factor = factor_edit->text().toDouble(); - sig.offset = offset_edit->text().toDouble(); - - double first_time_val = first_time_edit->text().toDouble(); - double last_time_val = last_time_edit->text().toDouble(); - auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); - uint64_t first_time = can->toMonoTime(first_sec); - model->last_time = std::numeric_limits::max(); - if (last_sec > 0) { - model->last_time = can->toMonoTime(last_sec); - } - model->initial_signals.clear(); - - for (const auto &[id, m] : can->lastMessages()) { - if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { - const auto &events = can->events(id); - auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); - if (e != events.cend()) { - const int total_size = m.dat.size() * 8; - for (int size = min_size->value(); size <= max_size->value(); ++size) { - for (int start = 0; start <= total_size - size; ++start) { - FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; - s.sig.start_bit = start; - s.sig.size = size; - updateMsbLsb(s.sig); - s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); - model->initial_signals.push_back(s); - } - } - } - } - } -} - -void FindSignalDlg::modelReset() { - properties_group->setEnabled(model->histories.empty()); - message_group->setEnabled(model->histories.empty()); - search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next")); - reset_btn->setEnabled(!model->histories.empty()); - undo_btn->setEnabled(model->histories.size() > 1); - search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty()); - stats_label->setVisible(true); - stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size())); -} - -void FindSignalDlg::customMenuRequested(const QPoint &pos) { - if (auto index = view->indexAt(pos); index.isValid()) { - QMenu menu(this); - menu.addAction(tr("Create Signal")); - if (menu.exec(view->mapToGlobal(pos))) { - auto &s = model->filtered_signals[index.row()]; - UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); - emit openMessage(s.id); - } - } -} diff --git a/iqpilot/tools/cabana/tools/findsignal.h b/iqpilot/tools/cabana/tools/findsignal.h deleted file mode 100644 index 239a08c9c..000000000 --- a/iqpilot/tools/cabana/tools/findsignal.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/commands.h" -#include "tools/cabana/settings.h" - -class FindSignalModel : public QAbstractTableModel { -public: - struct SearchSignal { - MessageId id = {}; - uint64_t mono_time = 0; - cabana::Signal sig = {}; - double value = 0.; - QStringList values; - }; - - FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {} - QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; - QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; - int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; } - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); } - void search(std::function cmp); - void reset(); - void undo(); - - std::vector filtered_signals; - std::vector initial_signals; - std::vector> histories; - uint64_t last_time = std::numeric_limits::max(); -}; - -class FindSignalDlg : public QDialog { - Q_OBJECT -public: - FindSignalDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &id); - -private: - void search(); - void modelReset(); - void setInitialSignals(); - void customMenuRequested(const QPoint &pos); - - QLineEdit *value1, *value2, *factor_edit, *offset_edit; - QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit; - QComboBox *compare_cb; - QSpinBox *min_size, *max_size; - QCheckBox *litter_endian, *is_signed; - QPushButton *search_btn, *reset_btn, *undo_btn; - QGroupBox *properties_group, *message_group; - QTableView *view; - QLabel *to_label, *stats_label; - FindSignalModel *model; -}; diff --git a/iqpilot/tools/cabana/tools/findsimilarbits.cc b/iqpilot/tools/cabana/tools/findsimilarbits.cc deleted file mode 100644 index 8062b6119..000000000 --- a/iqpilot/tools/cabana/tools/findsimilarbits.cc +++ /dev/null @@ -1,161 +0,0 @@ -#include "tools/cabana/tools/findsimilarbits.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" -#include "tools/cabana/streams/abstractstream.h" - -FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) { - setWindowTitle(tr("Find similar bits")); - setAttribute(Qt::WA_DeleteOnClose); - - QVBoxLayout *main_layout = new QVBoxLayout(this); - - QHBoxLayout *src_layout = new QHBoxLayout(); - src_bus_combo = new QComboBox(this); - find_bus_combo = new QComboBox(this); - for (auto cb : {src_bus_combo, find_bus_combo}) { - for (uint8_t bus : can->sources) { - cb->addItem(QString::number(bus), bus); - } - } - - msg_cb = new QComboBox(this); - // TODO: update when src_bus_combo changes - for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(QString::fromStdString(msg.name), address); - } - msg_cb->model()->sort(0); - msg_cb->setCurrentIndex(0); - - byte_idx_sb = new QSpinBox(this); - byte_idx_sb->setFixedWidth(50); - byte_idx_sb->setRange(0, 63); - - bit_idx_sb = new QSpinBox(this); - bit_idx_sb->setFixedWidth(50); - bit_idx_sb->setRange(0, 7); - - src_layout->addWidget(new QLabel(tr("Bus"))); - src_layout->addWidget(src_bus_combo); - src_layout->addWidget(msg_cb); - src_layout->addWidget(new QLabel(tr("Byte Index"))); - src_layout->addWidget(byte_idx_sb); - src_layout->addWidget(new QLabel(tr("Bit Index"))); - src_layout->addWidget(bit_idx_sb); - src_layout->addStretch(0); - - QHBoxLayout *find_layout = new QHBoxLayout(); - find_layout->addWidget(new QLabel(tr("Bus"))); - find_layout->addWidget(find_bus_combo); - find_layout->addWidget(new QLabel(tr("Equal"))); - equal_combo = new QComboBox(this); - equal_combo->addItems({"Yes", "No"}); - find_layout->addWidget(equal_combo); - min_msgs = new QLineEdit(this); - min_msgs->setValidator(new QIntValidator(this)); - min_msgs->setText("100"); - find_layout->addWidget(new QLabel(tr("Min msg count"))); - find_layout->addWidget(min_msgs); - search_btn = new QPushButton(tr("&Find"), this); - find_layout->addWidget(search_btn); - find_layout->addStretch(0); - - QGridLayout *grid_layout = new QGridLayout(); - grid_layout->addWidget(new QLabel("Find From:"), 0, 0); - grid_layout->addLayout(src_layout, 0, 1); - grid_layout->addWidget(new QLabel("Find In:"), 1, 0); - grid_layout->addLayout(find_layout, 1, 1); - main_layout->addLayout(grid_layout); - - table = new QTableWidget(this); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->horizontalHeader()->setStretchLastSection(true); - main_layout->addWidget(table); - - setMinimumSize({700, 500}); - QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find); - QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) { - if (index.isValid()) { - MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)}; - emit openMessage(msg_id); - } - }); -} - -void FindSimilarBitsDlg::find() { - search_btn->setEnabled(false); - table->clear(); - uint32_t selected_address = msg_cb->currentData().toUInt(); - auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(), - find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt()); - table->setRowCount(msg_mismatched.size()); - table->setColumnCount(6); - table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}); - for (int i = 0; i < msg_mismatched.size(); ++i) { - auto &m = msg_mismatched[i]; - table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16))); - table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx))); - table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx))); - table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches))); - table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total))); - table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2))); - } - search_btn->setEnabled(true); -} - -std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - std::unordered_map> mismatches; - std::unordered_map msg_count; - const auto &events = can->allEvents(); - int bit_to_find = -1; - for (const CanEvent *e : events) { - if (e->src == bus) { - if (e->address == selected_address && e->size > byte_idx) { - bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; - } - } - if (e->src == find_bus) { - ++msg_count[e->address]; - if (bit_to_find == -1) continue; - - auto &mismatched = mismatches[e->address]; - if (mismatched.size() < e->size * 8) { - mismatched.resize(e->size * 8); - } - for (int i = 0; i < e->size; ++i) { - for (int j = 0; j < 8; ++j) { - int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; - mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); - } - } - } - } - - std::vector result; - result.reserve(mismatches.size()); - for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { - auto &mismatched = it->second; - for (int i = 0; i < (int)mismatched.size(); ++i) { - if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); - } - } - } - } - std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); - return result; -} diff --git a/iqpilot/tools/cabana/tools/findsimilarbits.h b/iqpilot/tools/cabana/tools/findsimilarbits.h deleted file mode 100644 index 345136065..000000000 --- a/iqpilot/tools/cabana/tools/findsimilarbits.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbcmanager.h" - -class FindSimilarBitsDlg : public QDialog { - Q_OBJECT - -public: - FindSimilarBitsDlg(QWidget *parent); - -signals: - void openMessage(const MessageId &msg_id); - -private: - struct mismatched_struct { - uint32_t address, byte_idx, bit_idx, mismatches, total; - float perc; - }; - std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, - bool equal, int min_msgs_cnt); - void find(); - - QTableWidget *table; - QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo; - QSpinBox *byte_idx_sb, *bit_idx_sb; - QPushButton *search_btn; - QLineEdit *min_msgs; -}; diff --git a/iqpilot/tools/cabana/tools/routeinfo.cc b/iqpilot/tools/cabana/tools/routeinfo.cc deleted file mode 100644 index 77a0e065c..000000000 --- a/iqpilot/tools/cabana/tools/routeinfo.cc +++ /dev/null @@ -1,40 +0,0 @@ -#include "tools/cabana/tools/routeinfo.h" -#include -#include -#include -#include -#include "tools/cabana/streams/replaystream.h" - -RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) { - auto *replay = qobject_cast(can)->getReplay(); - setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name()))); - - auto *table = new QTableWidget(replay->route().segments().size(), 7, this); - table->setToolTip(tr("Click on a row to seek to the corresponding segment.")); - table->setEditTriggers(QAbstractItemView::NoEditTriggers); - table->setSelectionBehavior(QAbstractItemView::SelectRows); - table->setSelectionMode(QAbstractItemView::SingleSelection); - table->setHorizontalHeaderLabels({"", "rlog", "fcam", "ecam", "dcam", "qlog", "qcam"}); - table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - table->verticalHeader()->setVisible(false); - table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - - int row = 0; - for (const auto &[seg_num, seg] : replay->route().segments()) { - table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num))); - table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes")); - table->setItem(row, 2, new QTableWidgetItem(seg.road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes")); - table->setItem(row, 4, new QTableWidgetItem(seg.driver_cam.empty() ? "--" : "Yes")); - table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes")); - table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes")); - ++row; - } - table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width()); - table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2); - - connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); }); - - QVBoxLayout *layout = new QVBoxLayout(this); - layout->addWidget(table); -} diff --git a/iqpilot/tools/cabana/tools/routeinfo.h b/iqpilot/tools/cabana/tools/routeinfo.h deleted file mode 100644 index 36f32b4bf..000000000 --- a/iqpilot/tools/cabana/tools/routeinfo.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once -#include - -class RouteInfoDlg : public QDialog { - Q_OBJECT -public: - RouteInfoDlg(QWidget *parent = nullptr); -}; diff --git a/iqpilot/tools/cabana/ui/app.cc b/iqpilot/tools/cabana/ui/app.cc new file mode 100644 index 000000000..9eb2944d7 --- /dev/null +++ b/iqpilot/tools/cabana/ui/app.cc @@ -0,0 +1,212 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_impl_glfw.h" +#include "imgui_impl_opengl3.h" +#include "imgui_impl_opengl3_loader.h" +#include "implot.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/mainwin.h" +#include "tools/cabana/utils/util.h" + +namespace { + +std::atomic g_signal_exit{false}; +std::vector g_key_events; +void keyCallback(GLFWwindow *window, int key, int scancode, int action, int mods) { + ImGui_ImplGlfw_KeyCallback(window, key, scancode, action, mods); + if (action == GLFW_PRESS) g_key_events.push_back({key, mods}); +} + + + +GLFWwindow *g_focus_lost_window = nullptr; + + + +void windowFocusCallback(GLFWwindow *w, int f) { +#ifdef __APPLE__ + ImGui_ImplGlfw_WindowFocusCallback(w, f); +#else + if (f) { + g_focus_lost_window = nullptr; + ImGui_ImplGlfw_WindowFocusCallback(w, f); + } else { + g_focus_lost_window = w; + } +#endif +} +bool anyMouseButtonDown(GLFWwindow *w) { + for (int b = GLFW_MOUSE_BUTTON_1; b <= GLFW_MOUSE_BUTTON_LAST; ++b) { + if (glfwGetMouseButton(w, b) == GLFW_PRESS) return true; + } + return false; +} +void deliverPendingFocusLoss() { + if (g_focus_lost_window == nullptr || anyMouseButtonDown(g_focus_lost_window)) return; + ImGui_ImplGlfw_WindowFocusCallback(g_focus_lost_window, GLFW_FALSE); + g_focus_lost_window = nullptr; +} + +void hookViewportCallbacks() { + for (ImGuiViewport *viewport : ImGui::GetPlatformIO().Viewports) { + if (viewport->PlatformHandle == nullptr || viewport == ImGui::GetMainViewport()) continue; + glfwSetKeyCallback((GLFWwindow *)viewport->PlatformHandle, keyCallback); + } +} + +void glfwErrorCallback(int error, const char *description) { + fprintf(stderr, "GLFW error %d: %s\n", error, description); +} + + + +void renderFrame(GLFWwindow *window, MainWindow *win) { + glfwPollEvents(); + deliverPendingFocusLoss(); + utils::drainMainThreadQueue(); + + int fb_w = 0, fb_h = 0; + glfwGetFramebufferSize(window, &fb_w, &fb_h); + + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplGlfw_NewFrame(); + ImGui::NewFrame(); + win->draw(); + ImGui::Render(); + + const ImVec4 &bg = ImGui::GetStyle().Colors[ImGuiCol_WindowBg]; + glViewport(0, 0, fb_w, fb_h); + glClearColor(bg.x, bg.y, bg.z, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) { + GLFWwindow *backup_context = glfwGetCurrentContext(); + ImGui::UpdatePlatformWindows(); + hookViewportCallbacks(); + ImGui::RenderPlatformWindowsDefault(); + glfwMakeContextCurrent(backup_context); + } + glfwSwapBuffers(window); +} + +class GlfwRuntime { +public: + GlfwRuntime() { + glfwSetErrorCallback(glfwErrorCallback); +#ifdef __APPLE__ + setMacAppName("Cabana"); +#endif + if (!glfwInit()) throw std::runtime_error("glfwInit failed"); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#ifdef __APPLE__ + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + window_ = glfwCreateWindow(1600, 900, "Cabana", nullptr, nullptr); + if (window_ == nullptr) { + glfwTerminate(); + throw std::runtime_error("glfwCreateWindow failed"); + } + glfwMakeContextCurrent(window_); + glfwSwapInterval(1); + } + + ~GlfwRuntime() { + if (window_ != nullptr) glfwDestroyWindow(window_); + glfwTerminate(); + } + + GlfwRuntime(const GlfwRuntime &) = delete; + GlfwRuntime &operator=(const GlfwRuntime &) = delete; + GLFWwindow *window() const { return window_; } + +private: + GLFWwindow *window_ = nullptr; +}; + +class ImGuiRuntime { +public: + explicit ImGuiRuntime(GLFWwindow *window) { + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImPlot::CreateContext(); + ImGuiIO &io = ImGui::GetIO(); + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + io.ConfigFlags |= ImGuiConfigFlags_ViewportsEnable; + io.ConfigViewportsNoDecoration = false; + io.IniFilename = nullptr; + io.LogFilename = nullptr; + if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplGlfw_InitForOpenGL failed"); + } + glfwSetKeyCallback(window, keyCallback); + glfwSetWindowFocusCallback(window, windowFocusCallback); + if (!ImGui_ImplOpenGL3_Init("#version 330")) { + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + throw std::runtime_error("ImGui_ImplOpenGL3_Init failed"); + } + } + + ~ImGuiRuntime() { + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplGlfw_Shutdown(); + ImPlot::DestroyContext(); + ImGui::DestroyContext(); + } + + ImGuiRuntime(const ImGuiRuntime &) = delete; + ImGuiRuntime &operator=(const ImGuiRuntime &) = delete; +}; + +} + +std::vector takeKeyEvents() { + return std::exchange(g_key_events, {}); +} + +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file) { + try { + + UnixSignalHandler signal_handler([]() { g_signal_exit = true; }); + + GlfwRuntime glfw; + ImGuiRuntime imgui(glfw.window()); + loadFonts(); + applyTheme(settings.theme); + inistate::addSettingsHandler(); + inistate::load(); + inistate::applyWindowGeometry(glfw.window()); + + MainWindow win(glfw.window(), std::move(stream), std::move(stream_loader), dbc_file); + while (!win.exited()) { + if (g_signal_exit.exchange(false)) { + printf("\nexiting...\n"); + win.close(); + } else if (glfwWindowShouldClose(glfw.window())) { + glfwSetWindowShouldClose(glfw.window(), GLFW_FALSE); + win.close(); + } + renderFrame(glfw.window(), &win); + } + return 0; + } catch (const std::exception &e) { + fprintf(stderr, "%s\n", e.what()); + return 1; + } +} diff --git a/iqpilot/tools/cabana/ui/app.h b/iqpilot/tools/cabana/ui/app.h new file mode 100644 index 000000000..dd29b3f59 --- /dev/null +++ b/iqpilot/tools/cabana/ui/app.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" + + +using StreamLoader = std::function()>; + + + +int run(std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + + + +struct KeyEvent { + int key; + int mods; +}; +std::vector takeKeyEvents(); diff --git a/iqpilot/tools/cabana/ui/chart/chart.cc b/iqpilot/tools/cabana/ui/chart/chart.cc new file mode 100644 index 000000000..d5a6bba01 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/chart.cc @@ -0,0 +1,790 @@ +#define IMGUI_DEFINE_MATH_OPERATORS +#include "tools/cabana/ui/chart/chart.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int AXIS_X_TOP_MARGIN = 4; +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; +const double EPSILON = 1e-6; +constexpr ImVec4 LAYOUT_MARGINS{8, 6, 8, 6}; +static inline bool xLessThan(const ImPlotPoint &p, double x) { return p.x < (x - EPSILON); } +static inline bool isNull(const ImPlotPoint &p) { return p.x == 0 && p.y == 0; } + +static std::string formatNumber(double value, int precision) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + return buf; +} + + +static int axisPrecision(double range, int tick_count, int min_precision) { + return std::max(int(-std::floor(std::log10(range / (tick_count - 1)))), min_precision); +} + +static void addTextEllipsis(ImDrawList *dl, ImFont *font, ImU32 col, const ImVec2 &pos, float max_x, const std::string &text) { + const float size = ImGui::GetFontSize(); + ImGui::PushFont(font, 0.0f); + ImGui::RenderTextEllipsis(dl, pos, ImVec2(max_x, pos.y + size), max_x, text.c_str(), nullptr, nullptr); + ImGui::PopFont(); +} + +ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) + : x_min_(x_range.first), x_max_(x_range.second), charts_widget_(parent) { + series_type_ = (SeriesType)settings.chart_series_type; + + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { signalRemoved(sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { signalUpdated(sig); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { msgRemoved(id); })); +} + +void ChartView::drawMenuActions() { + + const float indent = ImGui::GetFontSize(); + float label_width = ImGui::CalcTextSize("Manage Signals").x; + for (const char *type : SERIES_TYPE_NAMES) label_width = std::max(label_width, ImGui::CalcTextSize(type).x); + for (int i = 0; i < (int)std::size(SERIES_TYPE_NAMES); ++i) { + if (radioMenuItem(SERIES_TYPE_NAMES[i], i == (int)series_type_, indent + label_width + indent)) { + setSeriesType((SeriesType)i); + } + } + ImGui::Separator(); + ImGui::Indent(indent); + if (ImGui::MenuItem("Manage Signals")) manageSignals(); + if (ImGui::MenuItem("Split Chart", nullptr, false, sigs_.size() > 1)) charts_widget_->splitChart(this); + ImGui::Unindent(indent); +} + + +void ChartView::createToolButtons() { + ImGui::SetCursorScreenPos(layout_.close_btn_rect.Min); + bool close_clicked = toolButton("close_btn", icon::X, "Remove Chart"); + + ImGui::SetCursorScreenPos(layout_.manage_btn_rect.Min); + if (toolButton("manage_btn", icon::LIST, "")) ImGui::OpenPopup("manage_menu"); + if (ImGui::BeginPopup("manage_menu")) { + drawMenuActions(); + ImGui::EndPopup(); + } + + if (close_clicked) charts_widget_->removeChart(this); +} + +void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { + if (hasSignal(msg_id, sig)) return; + + sigs_.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(sig->color)}); + updateSeries(sig); + charts_widget_->seriesChanged(); +} + +bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const { + return std::any_of(sigs_.cbegin(), sigs_.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; }); +} + +void ChartView::removeIf(std::function predicate) { + int prev_size = sigs_.size(); + sigs_.erase(std::remove_if(sigs_.begin(), sigs_.end(), predicate), sigs_.end()); + if (sigs_.empty()) { + charts_widget_->removeChart(this); + } else if (sigs_.size() != prev_size) { + charts_widget_->seriesChanged(); + updateAxisY(); + } +} + +void ChartView::signalUpdated(const cabana::Signal *sig) { + auto it = std::find_if(sigs_.begin(), sigs_.end(), [sig](auto &s) { return s.sig == sig; }); + if (it != sigs_.end()) { + if (!(it->color == sig->color)) { + it->color = uniqueColor(sig->color, sig); + } + updateSeries(sig); + } +} + +void ChartView::manageSignals() { + auto dlg = std::make_unique("Manage Chart"); + for (auto &s : sigs_) { + dlg->addSelected(s.msg_id, s.sig); + } + + charts_widget_->execSignalSelector(std::move(dlg), this, [this](SignalSelector &selector) { + const auto &items = selector.selectedItems(); + for (const auto &s : items) { + addSignal(s.msg_id, s.sig); + } + removeIf([&](auto &s) { + return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it.msg_id && s.sig == it.sig; }); + }); + }); +} + +void ChartView::updateLayout() { + const ImVec2 grip = ImGui::CalcTextSize(icon::GRIP_HORIZONTAL); + const ImVec2 top_left = layout_.rect.Min + ImVec2(LAYOUT_MARGINS.x, LAYOUT_MARGINS.y); + layout_.move_icon_rect = ImRect(top_left, top_left + grip); + const ImVec2 pad = ImGui::GetStyle().FramePadding * 2; + const ImVec2 close_size = ImGui::CalcTextSize(icon::X) + pad; + const ImVec2 manage_size = ImGui::CalcTextSize(icon::LIST) + pad; + const ImVec2 close_min(layout_.rect.Max.x - LAYOUT_MARGINS.z - close_size.x, top_left.y); + layout_.close_btn_rect = ImRect(close_min, close_min + close_size); + const ImVec2 manage_min(close_min.x - manage_size.x - ImGui::GetStyle().ItemSpacing.x, top_left.y); + layout_.manage_btn_rect = ImRect(manage_min, manage_min + manage_size); + + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float fm_height = ImGui::GetTextLineHeight(); + const int marker_size = markerSize(); + const int row_height = std::max(marker_size, fm_height) + fm_height + 3; + const int legend_left = layout_.move_icon_rect.Max.x + LAYOUT_MARGINS.x; + const int legend_right = std::max(layout_.manage_btn_rect.Min.x - LAYOUT_MARGINS.z, legend_left + 10); + + + layout_.legend_rects.clear(); + int x = legend_left, y = top_left.y; + for (auto &s : sigs_) { + int w = marker_size + 5 + bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x + + ImGui::CalcTextSize(msgLabel(s.msg_id).c_str()).x; + w = std::min(w, legend_right - legend_left); + if (x + w > legend_right && x > legend_left) { + x = legend_left; + y += row_height; + } + layout_.legend_rects.emplace_back(ImVec2(x, y), ImVec2(x + w, y + std::max(marker_size, fm_height))); + x += w + 12; + } + + + int adjust_top = (y + row_height) - top_left.y; + adjust_top = std::max(adjust_top, layout_.manage_btn_rect.Max.y - layout_.rect.Min.y + LAYOUT_MARGINS.y); + layout_.header_bottom = layout_.rect.Min.y + adjust_top + LAYOUT_MARGINS.y; +} + +void ChartView::updatePlot(double cur, double min, double max) { + cur_sec_ = cur; + if (min != x_min_ || max != x_max_) { + x_min_ = min; + x_max_ = max; + updateAxisY(); + if (tooltip_x_ >= 0) { + showTip(secondsAtPoint({(float)tooltip_x_, 0})); + } + } +} + +void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals) { + vals.reserve(vals.size() + events.size()); + step_vals.reserve(step_vals.size() + events.size() * 2); + + double value = 0; + for (const CanEvent *e : events) { + if (sig->getValue(e->dat, e->size, &value)) { + const double ts = can->toSeconds(e->mono_time); + vals.emplace_back(ts, value); + if (!step_vals.empty()) + step_vals.emplace_back(ts, step_vals.back().y); + step_vals.emplace_back(ts, value); + } + } +} + +void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) { + for (auto &s : sigs_) { + if (!sig || s.sig == sig) { + if (!msg_new_events) { + s.vals.clear(); + s.step_vals.clear(); + } + auto events = msg_new_events ? msg_new_events : &can->eventsMap(); + auto it = events->find(s.msg_id); + if (it == events->end() || it->second.empty()) continue; + + if (s.vals.empty() || can->toSeconds(it->second.back()->mono_time) > s.vals.back().x) { + appendCanEvents(s.sig, it->second, s.vals, s.step_vals); + } else { + std::vector vals, step_vals; + appendCanEvents(s.sig, it->second, vals, step_vals); + if (vals.empty()) continue; + s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x, xLessThan), + vals.begin(), vals.end()); + s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x, xLessThan), + step_vals.begin(), step_vals.end()); + } + + if (!can->liveStreaming()) { + s.segment_tree.build(s.vals.size(), [&vals = s.vals](int i) { return vals[i].y; }); + } + } + } + updateAxisY(); +} + +std::pair ChartView::visibleRange(const std::vector &points) const { + auto first = std::lower_bound(points.cbegin(), points.cend(), x_min_, xLessThan); + auto last = std::lower_bound(first, points.cend(), x_max_, xLessThan); + return {first, last}; +} + +const ImPlotPoint *ChartView::lastPointBefore(const SigItem &s, double sec) const { + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x > x + EPSILON; }); + return it != s.vals.crend() && it->x >= x_min_ ? &*it : nullptr; +} + +void ChartView::updateAxisY() { + if (sigs_.empty()) return; + + double min = std::numeric_limits::max(); + double max = std::numeric_limits::lowest(); + std::string unit = sigs_[0].sig->unit; + + for (auto &s : sigs_) { + if (!s.visible) continue; + + + if (unit != s.sig->unit) { + unit.clear(); + } + + auto [first, last] = visibleRange(s.vals); + s.min = std::numeric_limits::max(); + s.max = std::numeric_limits::lowest(); + if (can->liveStreaming()) { + for (auto it = first; it != last; ++it) { + if (it->y < s.min) s.min = it->y; + if (it->y > s.max) s.max = it->y; + } + } else { + std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last)); + } + min = std::min(min, s.min); + max = std::max(max, s.max); + } + if (min == std::numeric_limits::max()) min = 0; + if (max == std::numeric_limits::lowest()) max = 0; + + y_unit_ = unit; + + double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; + auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); + if (min_y != y_min_ || max_y != y_max_) { + y_min_ = min_y; + y_max_ = max_y; + y_tick_count_ = tick_count; + y_precision_ = axisPrecision(max_y - min_y, tick_count, 0); + } +} + +std::tuple ChartView::getNiceAxisNumbers(double min, double max, int tick_count) { + double range = niceNumber((max - min), true); + double step = niceNumber(range / (tick_count - 1), false); + min = std::floor(min / step); + max = std::ceil(max / step); + tick_count = int(max - min) + 1; + return {min * step, max * step, tick_count}; +} + +int ChartView::xAxisPrecision() const { + return axisPrecision(x_max_ - x_min_, X_TICK_COUNT, 2); +} + + +double ChartView::niceNumber(double x, bool ceiling) { + double z = std::pow(10, std::floor(std::log10(x))); + double q = x / z; + if (ceiling) { + if (q <= 1.0) q = 1; + else if (q <= 2.0) q = 2; + else if (q <= 5.0) q = 5; + else q = 10; + } else { + if (q < 1.5) q = 1; + else if (q < 3.0) q = 2; + else if (q < 7.0) q = 5; + else q = 10; + } + return q * z; +} + +void ChartView::drawContextMenu() { + if (drawing_ghost_) return; + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + !ImGui::IsAnyItemActive()) { + ImGui::OpenPopup("context_menu"); + } + context_menu_id_ = ImGui::GetID("context_menu"); + if (ImGui::BeginPopup("context_menu")) { + drawMenuActions(); + + const float indent = ImGui::GetFontSize(); + ImGui::Indent(indent); + ImGui::Separator(); + + if (can->timeRange().has_value()) { + const std::string undo_text = std::string(icon::ARROW_COUNTERCLOCKWISE) + " Undo Zoom"; + const std::string redo_text = std::string(icon::ARROW_CLOCKWISE) + " Redo Zoom"; + if (ImGui::MenuItem(undo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canUndo())) charts_widget_->zoom_undo_stack_.undo(); + if (ImGui::MenuItem(redo_text.c_str(), nullptr, false, charts_widget_->zoom_undo_stack_.canRedo())) charts_widget_->zoom_undo_stack_.redo(); + ImGui::Separator(); + } + if (ImGui::MenuItem("Close")) charts_widget_->removeChart(this); + ImGui::Unindent(indent); + ImGui::EndPopup(); + } +} + +void ChartView::handleMousePress() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + + const bool widget_pressed = ImGui::IsMouseClicked(ImGuiMouseButton_Left) && layout_.rect.Contains(pos) && + ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + !layout_.close_btn_rect.Contains(pos) && !layout_.manage_btn_rect.Contains(pos); + if (!widget_pressed) return; + press_pos_ = pos; + if (layout_.move_icon_rect.Contains(pos)) return; + + if (ImGui::GetIO().KeyShift) { + + resume_after_scrub_ = !can->isPaused(); + if (resume_after_scrub_) { + can->pause(true); + } + mouse_mode_ = MouseMode::Scrub; + } else if (layout_.plot_area.Contains(pos)) { + mouse_mode_ = MouseMode::Rubber; + rubber_rect_ = ImRect(); + } +} + +void ChartView::handleMouseMove() { + if (drawing_ghost_) return; + const ImVec2 pos = ImGui::GetMousePos(); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + + if (delta.x == 0 && delta.y == 0) return; + + if (mouse_mode_ == MouseMode::None && !layout_.rect.Contains(pos)) return; + + if (mouse_mode_ == MouseMode::Scrub && ImGui::GetIO().KeyShift) { + if (layout_.plot_area.Contains(pos)) { + can->seekTo(std::clamp(secondsAtPoint(pos), can->minSeconds(), can->maxSeconds())); + } + } + + if (mouse_mode_ == MouseMode::Rubber) { + + float left = std::clamp(std::min(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + float right = std::clamp(std::max(press_pos_.x, pos.x), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + rubber_rect_ = ImRect(ImVec2(left, layout_.plot_area.Min.y), ImVec2(right, layout_.plot_area.Max.y)); + } + + clearTrackPoints(); + if (mouse_mode_ != MouseMode::Rubber && layout_.plot_area.Contains(pos) && (layout_.plot_hovered || mouse_mode_ != MouseMode::None) && + ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + charts_widget_->showValueTip(secondsAtPoint(pos)); + } else if (tip_label_.isVisible()) { + charts_widget_->showValueTip(-1); + } +} + +void ChartView::handleMouseRelease() { + if (drawing_ghost_) return; + const bool left_released = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + const bool right_released = ImGui::IsMouseReleased(ImGuiMouseButton_Right) && layout_.rect.Contains(ImGui::GetMousePos()); + if (!left_released && !right_released) return; + if (left_released && mouse_mode_ == MouseMode::Rubber) { + mouse_mode_ = MouseMode::None; + + double min = std::clamp(secondsAtPoint(rubber_rect_.Min), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect_.Max), can->minSeconds(), can->maxSeconds()); + if (rubber_rect_.GetWidth() <= 0) { + + can->seekTo(std::clamp(secondsAtPoint(press_pos_), can->minSeconds(), can->maxSeconds())); + } else if (rubber_rect_.GetWidth() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + charts_widget_->zoom_undo_stack_.push(new ZoomCommand({min, max})); + } + rubber_rect_ = ImRect(); + } else if (right_released && !ImGui::IsPopupOpen(context_menu_id_, ImGuiPopupFlags_None)) { + charts_widget_->zoom_undo_stack_.undo(); + } + + if (mouse_mode_ == MouseMode::Scrub) { + mouse_mode_ = MouseMode::None; + if (resume_after_scrub_) { + can->pause(false); + resume_after_scrub_ = false; + } + } +} + +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs_) { + sigs_.push_back(std::move(s)); + sigs_.back().color = uniqueColor(sigs_.back().color, sigs_.back().sig); + } + source->sigs_.clear(); + updateAxisY(); + charts_widget_->removeChart(source); +} + +std::vector ChartView::takeExtraSignals() { + std::vector extra; + for (auto it = sigs_.begin() + 1; it != sigs_.end(); ++it) { + it->color = it->sig->color; + extra.push_back(std::move(*it)); + } + sigs_.resize(1); + updateAxisY(); + return extra; +} + +void ChartView::adoptSignal(SigItem s) { + sigs_.push_back(std::move(s)); + updateAxisY(); +} + +void ChartView::showTip(double sec) { + ImRect tip_area(ImVec2(layout_.rect.Min.x, layout_.plot_area.Min.y), ImVec2(layout_.rect.Max.x, layout_.plot_area.Max.y)); + ImRect visible_rect = charts_widget_->chartVisibleRect(this); + visible_rect.ClipWith(tip_area); + if (visible_rect.GetWidth() <= 0 || visible_rect.GetHeight() <= 0) { + tip_label_.hide(); + return; + } + + tooltip_x_ = xPos(sec); + float x = -1; + std::vector text_list; + for (auto &s : sigs_) { + if (s.visible) { + std::string value = "--"; + if (const ImPlotPoint *pt = lastPointBefore(s, sec)) { + value = s.sig->formatValue(pt->y, false); + s.track_pt = *pt; + x = std::max(x, xPos(pt->x)); + } + std::string name = sigs_.size() > 1 ? s.sig->name + ": " : ""; + std::string min = s.min == std::numeric_limits::max() ? "--" : utils::toString(s.min); + std::string max = s.max == std::numeric_limits::lowest() ? "--" : utils::toString(s.max); + text_list.push_back({.has_marker = true, .marker = toImU32(s.color), .name = name, .bold = value, .rest = " (" + min + ", " + max + ")"}); + } + } + if (x < 0) { + x = tooltip_x_; + } + ImVec2 pt(x, layout_.plot_area.Min.y); + text_list.insert(text_list.begin(), TipLine{.name = formatNumber(secondsAtPoint({x, 0}), 3)}); + tip_label_.showText(pt, text_list, visible_rect); +} + +void ChartView::hideTip() { + clearTrackPoints(); + tooltip_x_ = -1; + tip_label_.hide(); +} + +void ChartView::draw(float width) { + ImGui::PushID(this); + width = std::max(width, (float)CHART_MIN_WIDTH); + layout_.plot_hovered = false; + + const ImVec2 tile_pos = ImGui::GetCursorScreenPos(); + const ImVec2 tile_size(width, (float)settings.chart_height); + layout_.rect = ImRect(tile_pos, tile_pos + tile_size); + if (ImGui::BeginChild("chart", tile_size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + updateLayout(); + paint(); + drawContextMenu(); + } + ImGui::EndChild(); + + const ImRect visible_rect = charts_widget_->chartVisibleRect(this); + if (!drawing_ghost_ && visible_rect.GetWidth() > 0 && visible_rect.GetHeight() > 0) tip_label_.draw(); + ImGui::PopID(); +} + +void ChartView::drawGhost(float width) { + + drawing_ghost_ = true; + const Layout saved = layout_; + draw(width); + layout_ = saved; + drawing_ghost_ = false; +} + +void ChartView::paint() { + drawStaticLayer(); + + if (can_drop_) { + ImGui::GetWindowDrawList()->AddRect(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_Header), 0.0f, 0, 4.0f); + } +} + +void ChartView::drawStaticLayer() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->AddRectFilled(layout_.rect.Min, layout_.rect.Max, ImGui::GetColorU32(ImGuiCol_ChildBg)); + ImGui::SetCursorScreenPos(layout_.move_icon_rect.Min); + ImGui::InvisibleButton("grip", layout_.move_icon_rect.GetSize()); + if (ImGui::IsItemActivated()) charts_widget_->startChartDrag(this, ImGui::GetMousePos()); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + painter->AddText(layout_.move_icon_rect.Min, ImGui::GetColorU32(ImGuiCol_Text), icon::GRIP_HORIZONTAL); + createToolButtons(); + drawLegend(); + drawSignalValue(); + drawAxes(); +} + +void ChartView::drawAxes() { + ImGui::SetCursorScreenPos(ImVec2(layout_.rect.Min.x, layout_.header_bottom)); + const float plot_h = std::max(layout_.rect.Max.y - layout_.header_bottom - LAYOUT_MARGINS.w, 10.0f); + ImPlot::PushStyleVar(ImPlotStyleVar_PlotPadding, ImVec2(LAYOUT_MARGINS.x, AXIS_X_TOP_MARGIN)); + ImPlot::PushStyleColor(ImPlotCol_PlotBg, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_FrameBg, ImVec4(0, 0, 0, 0)); + + + const bool dark = isDarkTheme(); + ImVec4 grid_color; + if (dark) { + grid_color = colorRgb(DarkTheme::light.r, DarkTheme::light.g, DarkTheme::light.b); + } else { + grid_color = ImGui::GetStyleColorVec4(ImGuiCol_Text); + grid_color.w = 50.0f / 255.0f; + } + ImPlot::PushStyleColor(ImPlotCol_AxisGrid, grid_color); + ImPlot::PushStyleColor(ImPlotCol_PlotBorder, grid_color); + ImPlot::PushStyleColor(ImPlotCol_AxisTick, ImVec4(0, 0, 0, 0)); + ImPlot::PushStyleColor(ImPlotCol_AxisText, ImGui::GetStyleColorVec4(ImGuiCol_Text)); + ImPlot::PushStyleVar(ImPlotStyleVar_MajorTickLen, ImVec2(0, 0)); + + ImPlot::PushStyleVar(ImPlotStyleVar_MajorGridSize, dark ? ImVec2(2.0f, 2.0f) : ImVec2(1.0f, 1.0f)); + const ImPlotFlags flags = ImPlotFlags_NoTitle | ImPlotFlags_NoLegend | ImPlotFlags_NoMenus | ImPlotFlags_NoMouseText | + ImPlotFlags_NoBoxSelect | ImPlotFlags_NoInputs | ImPlotFlags_NoFrame; + const ImPlotAxisFlags axis_flags = ImPlotAxisFlags_NoMenus | ImPlotAxisFlags_NoHighlight | ImPlotAxisFlags_NoSideSwitch | ImPlotAxisFlags_Lock; + + const float x_label_width = ImGui::CalcTextSize(formatNumber(x_max_, xAxisPrecision()).c_str()).x + 5; + if (ImPlot::BeginPlot("##plot", ImVec2(layout_.rect.GetWidth() - x_label_width / 2, plot_h), flags)) { + ImPlot::SetupAxis(ImAxis_X1, nullptr, axis_flags); + ImPlot::SetupAxis(ImAxis_Y1, y_unit_.empty() ? nullptr : y_unit_.c_str(), axis_flags); + ImPlot::SetupAxisLimits(ImAxis_X1, x_min_, x_max_, ImPlotCond_Always); + ImPlot::SetupAxisLimits(ImAxis_Y1, y_min_, y_max_, ImPlotCond_Always); + + ImPlot::SetupAxisFormat(ImAxis_Y1, ("%." + std::to_string(y_precision_) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_Y1, y_min_, y_max_, y_tick_count_); + ImPlot::SetupAxisFormat(ImAxis_X1, ("%." + std::to_string(xAxisPrecision()) + "f").c_str()); + ImPlot::SetupAxisTicks(ImAxis_X1, x_min_, x_max_, X_TICK_COUNT); + ImPlot::SetupFinish(); + + layout_.plot_area = ImRect(ImPlot::GetPlotPos(), ImPlot::GetPlotPos() + ImPlot::GetPlotSize()); + + layout_.plot_hovered = layout_.plot_area.Contains(ImGui::GetMousePos()) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); + drawSeries(); + handleMousePress(); + handleMouseMove(); + handleMouseRelease(); + drawForeground(); + ImPlot::EndPlot(); + } + ImPlot::PopStyleColor(6); + ImPlot::PopStyleVar(3); +} + +void ChartView::drawLegend() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 title_color = ImGui::GetColorU32(ImGuiCol_Text); + + const ImU32 msg_color = withAlpha(title_color, 180); + ImFont *bold = boldFont(); + ImFont *normal = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float marker_size = markerSize(); + + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImRect &r = layout_.legend_rects[i]; + + ImGui::PushID(i); + ImGui::SetCursorScreenPos(r.Min); + if (ImGui::InvisibleButton("legend", ImVec2(std::max(r.GetWidth(), 1.0f), std::max(r.GetHeight(), 1.0f))) && + mouse_mode_ == MouseMode::None && sigs_.size() > 1) { + sigs_[i].visible = !sigs_[i].visible; + updateAxisY(); + } + ImGui::PopID(); + + if (series_type_ == SeriesType::Scatter) { + painter->AddCircleFilled(r.Min + ImVec2(marker_size / 2.0f, 2.0f + marker_size / 2.0f), marker_size / 2.0f, toImU32(s.color)); + } else { + drawColorMarker(painter, r.Min, toImU32(s.color)); + } + + float x = r.Min.x + marker_size + 5; + const float text_y = r.GetCenter().y - font_size / 2.0f; + addTextEllipsis(painter, bold, title_color, ImVec2(x, text_y), r.Max.x, s.sig->name); + float name_w = std::min(bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, s.sig->name.c_str()).x, r.Max.x - x); + x += name_w; + const std::string msg = msgLabel(s.msg_id); + addTextEllipsis(painter, normal, msg_color, ImVec2(x, text_y), r.Max.x, msg); + if (!s.visible) { + const float y = r.GetCenter().y; + painter->AddLine(ImVec2(r.Min.x + marker_size + 5, y), ImVec2(std::min(x + ImGui::CalcTextSize(msg.c_str()).x, r.Max.x), y), title_color); + } + } +} + +void ChartView::drawSeries() { + for (int i = 0; i < sigs_.size(); ++i) { + auto &s = sigs_[i]; + if (!s.visible) continue; + + + auto [first, last] = visibleRange(s.vals); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const ImPlotPoint &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x) - xPos(first->x)) / num_points; + } + + const std::string label = "##sig" + std::to_string(i); + ImPlotSpec spec; + spec.LineColor = toImVec4(s.color); + spec.Stride = sizeof(ImPlotPoint); + if (series_type_ == SeriesType::Scatter) { + float radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + spec.Marker = ImPlotMarker_Circle; + spec.MarkerSize = radius; + if (first != last) ImPlot::PlotScatter(label.c_str(), &first->x, &first->y, last - first, spec); + } else { + const auto &points = series_type_ == SeriesType::StepLine ? s.step_vals : s.vals; + + auto [begin, end] = visibleRange(points); + if (begin != points.cbegin()) --begin; + if (end != points.cend()) ++end; + if (begin == end) continue; + + spec.LineWeight = 2; + ImPlot::PlotLine(label.c_str(), &begin->x, &begin->y, end - begin, spec); + + + if ((num_points == 1 || pixels_per_point > 20) && first != last) { + ImPlotSpec dots; + dots.LineColor = toImVec4(s.color); + dots.Stride = sizeof(ImPlotPoint); + dots.Marker = ImPlotMarker_Circle; + dots.MarkerSize = 4; + ImPlot::PlotScatter((label + "_pts").c_str(), &first->x, &first->y, last - first, dots); + } + } + } +} + +void ChartView::drawForeground() { + drawTimeline(); + ImDrawList *painter = ImPlot::GetPlotDrawList(); + ImPlot::PushPlotClipRect(); + float track_line_x = -1; + for (auto &s : sigs_) { + if (!isNull(s.track_pt) && s.visible) { + ImVec2 pos(xPos(s.track_pt.x), yPos(s.track_pt.y)); + painter->AddCircleFilled(pos, 5.5f, toImU32(s.color.darker(125))); + track_line_x = std::max(track_line_x, pos.x); + } + } + if (track_line_x > 0) { + const ImU32 dark_gray = IM_COL32(0x80, 0x80, 0x80, 0xff); + for (float y = layout_.plot_area.Min.y; y < layout_.plot_area.Max.y; y += 8) { + painter->AddLine(ImVec2(track_line_x, y), ImVec2(track_line_x, std::min(y + 4, layout_.plot_area.Max.y)), dark_gray, 1.0f); + } + } + ImPlot::PopPlotClipRect(); + + drawRubberBandTimeRange(); +} + +void ChartView::drawRubberBandTimeRange() { + if (rubber_rect_.GetWidth() <= 1) return; + + ImDrawList *painter = ImPlot::GetPlotDrawList(); + + const ImU32 highlight = withAlpha(ImGui::GetColorU32(ImGuiCol_Header), 255); + painter->AddRectFilled(rubber_rect_.Min, rubber_rect_.Max, withAlpha(highlight, 50)); + painter->AddRect(rubber_rect_.Min, rubber_rect_.Max, highlight); + + + const ImU32 white = IM_COL32_WHITE; + const ImU32 gray = IM_COL32(0xa0, 0xa0, 0xa4, 0xff); + painter = ImGui::GetWindowDrawList(); + painter->PushClipRect(layout_.rect.Min, layout_.rect.Max); + for (const auto &pt : {rubber_rect_.GetBL(), rubber_rect_.GetBR()}) { + std::string sec = formatNumber(secondsAtPoint(pt), 2); + ImVec2 size = ImGui::CalcTextSize(sec.c_str()) + ImVec2(12, AXIS_X_TOP_MARGIN * 2); + ImVec2 top_left = pt.x == rubber_rect_.Min.x ? ImVec2(pt.x - size.x, pt.y + 2) : ImVec2(pt.x, pt.y + 2); + painter->AddRectFilled(top_left, top_left + size, gray); + painter->AddText(top_left + ImVec2(6, AXIS_X_TOP_MARGIN), white, sec.c_str()); + } + painter->PopClipRect(); +} + +void ChartView::drawTimeline() { + ImDrawList *painter = ImPlot::GetPlotDrawList(); + float x = std::clamp(xPos(cur_sec_), layout_.plot_area.Min.x, layout_.plot_area.Max.x); + painter->AddLine(ImVec2(x, layout_.plot_area.Min.y - 1.0f), ImVec2(x, layout_.plot_area.Max.y + 1.0f), ImGui::GetColorU32(ImGuiCol_Text), 1.0f); + + std::string time_str = formatNumber(cur_sec_, 2); + ImVec2 time_str_size = ImGui::CalcTextSize(time_str.c_str()) + ImVec2(8, 2); + ImVec2 time_str_pos(x - time_str_size.x / 2.0f, layout_.plot_area.Max.y + AXIS_X_TOP_MARGIN); + const bool dark = isDarkTheme(); + painter->AddRectFilled(time_str_pos, time_str_pos + time_str_size, dark ? IM_COL32(0x80, 0x80, 0x80, 0xff) : IM_COL32(0xa0, 0xa0, 0xa4, 0xff), 3.0f); + painter->AddText(time_str_pos + ImVec2(4, 1), IM_COL32_WHITE, time_str.c_str()); +} + +void ChartView::drawSignalValue() { + ImDrawList *painter = ImGui::GetWindowDrawList(); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + for (int i = 0; i < sigs_.size() && i < layout_.legend_rects.size(); ++i) { + const auto &s = sigs_[i]; + const ImPlotPoint *pt = lastPointBefore(s, cur_sec_); + std::string value = pt ? s.sig->formatValue(pt->y) : "--"; + const ImVec2 value_min = layout_.legend_rects[i].GetBL() - ImVec2(0, 1); + ImRect value_rect(value_min, value_min + layout_.legend_rects[i].GetSize()); + float w = ImGui::CalcTextSize(value.c_str()).x; + if (w <= value_rect.GetWidth()) { + painter->AddText(ImVec2(value_rect.GetCenter().x - w / 2, value_rect.Min.y), color, value.c_str()); + } else { + addTextEllipsis(painter, ImGui::GetFont(), color, value_rect.Min, value_rect.Max.x, value); + } + } +} + +CabanaColor ChartView::uniqueColor(CabanaColor color, const cabana::Signal *exclude) const { + for (auto &s : sigs_) { + if (s.sig != exclude && std::abs(color.hsv().hue - s.color.hsv().hue) < 0.1) { + + auto last_color = sigs_.back().color; + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution sat(35, 99); + std::uniform_int_distribution val(85, 99); + color = CabanaColor::fromHsv(std::fmod(last_color.hsv().hue + 60 / 360.0, 1.0), + sat(rng) / 100.0, + val(rng) / 100.0, + color.a / 255.0f); + break; + } + } + return color; +} diff --git a/iqpilot/tools/cabana/ui/chart/chart.h b/iqpilot/tools/cabana/ui/chart/chart.h new file mode 100644 index 000000000..c3b7ce61c --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/chart.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "implot.h" + +#include "tools/cabana/ui/chart/tiplabel.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +enum class SeriesType { + Line = 0, + StepLine, + Scatter +}; +inline constexpr const char *SERIES_TYPE_NAMES[] = {"Line", "Step Line", "Scatter"}; + + +inline std::string msgLabel(const MessageId &id) { return " " + msgName(id) + " " + id.toString(); } + +class ChartsWidget; +class ChartView { +public: + struct SigItem { + MessageId msg_id; + const cabana::Signal *sig = nullptr; + CabanaColor color; + bool visible = true; + std::vector vals; + std::vector step_vals; + ImPlotPoint track_pt{}; + SegmentTree segment_tree; + double min = 0; + double max = 0; + }; + + ChartView(const std::pair &x_range, ChartsWidget *parent); + void addSignal(const MessageId &msg_id, const cabana::Signal *sig); + bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const; + void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr); + void updatePlot(double cur, double min, double max); + void setSeriesType(SeriesType type) { series_type_ = type; } + void showTip(double sec); + void hideTip(); + void draw(float width); + void drawGhost(float width); + void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + + std::vector takeExtraSignals(); + void adoptSignal(SigItem s); + void setDropHighlight(bool highlight) { can_drop_ = highlight; } + const std::vector &signals() const { return sigs_; } + const ImRect &rect() const { return layout_.rect; } + bool plotHovered() const { return layout_.plot_hovered; } + double secondsAtPoint(const ImVec2 &pt) const { + return x_min_ + (pt.x - layout_.plot_area.Min.x) * (x_max_ - x_min_) / std::max(layout_.plot_area.GetWidth(), 1.0f); + } + +private: + using PointIter = std::vector::const_iterator; + + void signalUpdated(const cabana::Signal *sig); + void manageSignals(); + void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } + void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } + + void appendCanEvents(const cabana::Signal *sig, const std::vector &events, + std::vector &vals, std::vector &step_vals); + void createToolButtons(); + void drawContextMenu(); + void handleMousePress(); + void handleMouseMove(); + void handleMouseRelease(); + void updateLayout(); + void updateAxisY(); + void paint(); + void drawStaticLayer(); + void drawAxes(); + void drawLegend(); + void drawSeries(); + void drawForeground(); + void drawSignalValue(); + void drawTimeline(); + void drawRubberBandTimeRange(); + void drawMenuActions(); + int xAxisPrecision() const; + std::tuple getNiceAxisNumbers(double min, double max, int tick_count); + double niceNumber(double x, bool ceiling); + CabanaColor uniqueColor(CabanaColor color, const cabana::Signal *exclude = nullptr) const; + + const ImPlotPoint *lastPointBefore(const SigItem &s, double sec) const; + + std::pair visibleRange(const std::vector &points) const; + inline void clearTrackPoints() { for (auto &s : sigs_) s.track_pt = {}; } + inline float xPos(double sec) const { return layout_.plot_area.Min.x + (sec - x_min_) / (x_max_ - x_min_) * layout_.plot_area.GetWidth(); } + inline float yPos(double val) const { return layout_.plot_area.Max.y - (val - y_min_) / (y_max_ - y_min_) * layout_.plot_area.GetHeight(); } + + + struct Layout { + ImRect rect; + ImRect plot_area; + ImRect move_icon_rect; + ImRect close_btn_rect; + ImRect manage_btn_rect; + std::vector legend_rects; + float header_bottom = 0; + bool plot_hovered = false; + } layout_; + + double x_min_; + double x_max_; + double y_min_ = 0; + double y_max_ = 1; + int y_tick_count_ = 3; + int y_precision_ = 0; + std::string y_unit_; + + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode_ = MouseMode::None; + ImVec2 press_pos_; + ImRect rubber_rect_; + bool resume_after_scrub_ = false; + bool drawing_ghost_ = false; + ImGuiID context_menu_id_ = 0; + + TipLabel tip_label_; + std::vector sigs_; + double cur_sec_ = 0; + SeriesType series_type_ = SeriesType::Line; + bool can_drop_ = false; + double tooltip_x_ = -1; + ChartsWidget *charts_widget_; + Connections connections_; +}; diff --git a/iqpilot/tools/cabana/ui/chart/chartswidget.cc b/iqpilot/tools/cabana/ui/chart/chartswidget.cc new file mode 100644 index 000000000..a605ca20b --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/chartswidget.cc @@ -0,0 +1,674 @@ +#define IMGUI_DEFINE_MATH_OPERATORS +#include "tools/cabana/ui/chart/chartswidget.h" + +#include "tools/cabana/ui/threadpool.h" + +#include +#include +#include +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +const int MAX_COLUMN_COUNT = 4; +const int CHART_SPACING = 4; +const int START_DRAG_DISTANCE = 10; +const float LAYOUT_HORIZONTAL_SPACING = 6.0f; +const float MIN_RANGE_SLIDER_WIDTH = 40.0f; + +bool LogSlider::draw(const char *label, float width) { + return fusionSliderInt(label, &pos_, min_, max_, width); +} + +ChartsWidget::ChartsWidget() { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + + tabbar_.setAutoHide(true); + tabbar_.setUsesScrollButtons(true); + tabbar_.setTabsClosable(true); + + column_count_ = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); + max_chart_range_ = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60); + display_range_ = std::make_pair(can->minSeconds(), can->minSeconds() + max_chart_range_); + range_slider_.setValue(max_chart_range_); + + connections_.push_back(dbc()->fileChanged.connect([this]() { removeAll(); })); + connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &events) { eventsMerged(events); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { updateState(); })); + connections_.push_back(can->seeking.connect([this](double) { updateState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { updateState(); })); + connections_.push_back(settings.changed.connect([this]() { settingChanged(); })); + connections_.push_back(seriesChanged.connect([this]() { updateTabBar(); })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) removeTab(1); + } + ImGui::EndPopup(); + } + })); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index != -1) updateLayout(); + })); + + setIsDocked(true); + newTab(); +} + +ChartsWidget::~ChartsWidget() = default; + +std::string ChartsWidget::whatsThis() const { + return R"( + Chart View
+ Click: Click to seek to a corresponding time.
+ Drag: Zoom into the chart.
+ Shift + Drag: Scrub through the chart to view values.
+ Right Mouse: Open the context menu.
+ )"; +} + +void ChartsWidget::newTab() { + static int tab_unique_id = 0; + int idx = tabbar_.addTab(""); + tabbar_.setTabData(idx, tab_unique_id++); + tabbar_.setCurrentIndex(idx); + updateTabBar(); +} + +void ChartsWidget::removeTab(int index) { + int id = tabbar_.tabData(index); + for (auto &c : std::vector(tab_charts_[id])) { + removeChart(c); + } + tab_charts_.erase(id); + tabbar_.removeTab(index); + updateTabBar(); +} + +void ChartsWidget::updateTabBar() { + for (int i = 0; i < tabbar_.count(); ++i) { + const auto &charts_in_tab = tab_charts_[tabbar_.tabData(i)]; + tabbar_.setTabText(i, "Tab " + std::to_string(i + 1) + " (" + std::to_string((int)charts_in_tab.size()) + ")"); + } +} + +void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { + std::vector> futures; + for (auto &c : charts_) { + futures.push_back(ThreadPool::instance().run([c = c.get(), &new_events]() { c->updateSeries(nullptr, &new_events); })); + } + for (auto &f : futures) f.get(); +} + +void ChartsWidget::zoomReset() { + can->setTimeRange(std::nullopt); + zoom_undo_stack_.clear(); +} + +ImRect ChartsWidget::chartVisibleRect(ChartView *chart) { + ImRect r = chart->rect(); + r.ClipWith(charts_scroll_viewport_); + return r; +} + +void ChartsWidget::showValueTip(double sec) { + if (chartDragActive()) sec = -1; + showTip(sec); + if (sec < 0 && !value_tip_visible_) return; + + value_tip_visible_ = sec >= 0; + for (auto c : currentCharts()) { + value_tip_visible_ ? c->showTip(sec) : c->hideTip(); + } +} + +void ChartsWidget::updateState() { + if (charts_.empty()) return; + + const auto &time_range = can->timeRange(); + const double cur_sec = can->currentSec(); + if (!time_range.has_value()) { + double pos = (cur_sec - display_range_.first) / std::max(1.0, max_chart_range_); + if (pos < 0 || pos > 0.8) { + display_range_.first = std::max(can->minSeconds(), cur_sec - max_chart_range_ * 0.1); + } + double max_sec = std::min(display_range_.first + max_chart_range_, can->maxSeconds()); + display_range_.first = std::max(can->minSeconds(), max_sec - max_chart_range_); + display_range_.second = display_range_.first + max_chart_range_; + } + + const auto &range = time_range ? *time_range : display_range_; + for (auto &c : charts_) { + c->updatePlot(cur_sec, range.first, range.second); + } +} + +void ChartsWidget::setMaxChartRange(int value) { + max_chart_range_ = settings.chart_range = value; + updateState(); +} + +void ChartsWidget::setIsDocked(bool docked) { + is_docked_ = docked; + if (!docked) float_window_init_ = true; +} + +void ChartsWidget::drawToolBar() { + beginToolbar(); + float slider_width = 150.0f; + const bool is_zoomed = can->timeRange().has_value(); + + + std::vector items; + items.push_back({toolbarButtonWidth(icon::PLUS_SQUARE), [this]() { + if (toolButton("new_plot_btn", icon::PLUS_SQUARE, "New Chart")) newChart(); + }}); + items.push_back({toolbarButtonWidth(icon::WINDOW_STACK), [this]() { + if (toolButton("new_tab_btn", icon::WINDOW_STACK, "New Tab")) newTab(); + }}); + const std::string title_label = "Charts: " + std::to_string(charts_.size()); + items.push_back({ImGui::CalcTextSize(title_label.c_str()).x + LAYOUT_HORIZONTAL_SPACING, [&title_label]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title_label.c_str()); + ImGui::SameLine(0.0f, LAYOUT_HORIZONTAL_SPACING); + ImGui::Dummy(ImVec2(0.0f, 0.0f)); + }}); + + const int type_count = (int)std::size(SERIES_TYPE_NAMES); + const std::string chart_type_text = std::string("Type: ") + SERIES_TYPE_NAMES[std::clamp(settings.chart_series_type, 0, type_count - 1)]; + items.push_back({menuButtonWidth(chart_type_text), [this, &chart_type_text]() { + menuButton("chart_type", chart_type_text, "chart_type_menu"); + if (ImGui::BeginPopup("chart_type_menu")) { + for (int i = 0; i < type_count; ++i) { + if (ImGui::MenuItem(SERIES_TYPE_NAMES[i])) { + settings.chart_series_type = i; + settingChanged(); + } + } + ImGui::EndPopup(); + } + }}); + + const std::string columns_action_text = "Columns: " + std::to_string(column_count_); + if (columns_action_visible_) { + items.push_back({menuButtonWidth(columns_action_text), [this, &columns_action_text]() { + menuButton("columns", columns_action_text, "columns_menu"); + if (ImGui::BeginPopup("columns_menu")) { + for (int i = 0; i < MAX_COLUMN_COUNT; ++i) { + if (ImGui::MenuItem(std::to_string(i + 1).c_str())) setColumnCount(i + 1); + } + ImGui::EndPopup(); + } + }}); + } + + + const size_t spacer_index = items.size(); + size_t slider_index = (size_t)-1; + const std::string range_lb = is_zoomed ? std::string() : utils::formatSeconds(max_chart_range_); + std::string reset_zoom_text; + if (!is_zoomed) { + items.push_back({ImGui::CalcTextSize(range_lb.c_str()).x, [&range_lb]() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(range_lb.c_str()); + }}); + slider_index = items.size(); + items.push_back({slider_width, [this, &slider_width]() { + if (range_slider_.draw("##range_slider", slider_width)) setMaxChartRange(range_slider_.value()); + ImGui::SetItemTooltip("Set the chart range"); + }}); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "%.2f-%.2f", can->timeRange()->first, can->timeRange()->second); + reset_zoom_text = buf; + items.push_back({toolbarButtonWidth(icon::ARROW_COUNTERCLOCKWISE), [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canUndo()); + if (toolButton("undo_zoom", icon::ARROW_COUNTERCLOCKWISE, "Undo Zoom")) zoom_undo_stack_.undo(); + ImGui::EndDisabled(); + }}); + items.push_back({toolbarButtonWidth(icon::ARROW_CLOCKWISE), [this]() { + ImGui::BeginDisabled(!zoom_undo_stack_.canRedo()); + if (toolButton("redo_zoom", icon::ARROW_CLOCKWISE, "Redo Zoom")) zoom_undo_stack_.redo(); + ImGui::EndDisabled(); + }}); + items.push_back({toolbarButtonWidth(std::string(icon::ZOOM_OUT) + " " + reset_zoom_text), [this, &reset_zoom_text]() { + if (toolButton("reset_zoom_btn", icon::ZOOM_OUT, "Reset Zoom", reset_zoom_text.c_str())) zoomReset(); + }}); + } + items.push_back({toolbarButtonWidth(icon::X_SQUARE), [this]() { + ImGui::BeginDisabled(charts_.empty()); + if (toolButton("remove_all_btn", icon::X_SQUARE, "Remove all charts")) removeAll(); + ImGui::EndDisabled(); + }}); + const char *dock_btn_icon = is_docked_ ? icon::ARROW_UP_RIGHT_SQUARE : icon::ARROW_DOWN_LEFT_SQUARE; + items.push_back({toolbarButtonWidth(dock_btn_icon), [this, dock_btn_icon]() { + if (toolButton("dock_btn", dock_btn_icon, is_docked_ ? "Float the charts window" : "Dock the charts window")) toggleChartsDocking(); + }}); + + + if (slider_index != (size_t)-1) { + const float shrink = std::min(slider_width - MIN_RANGE_SLIDER_WIDTH, toolbarWidth(items, spacer_index) - ImGui::GetContentRegionAvail().x); + if (shrink > 0.0f) { + slider_width -= shrink; + items[slider_index].width = slider_width; + } + } + drawToolbar(items, spacer_index); + endToolbar(); +} + +void ChartsWidget::settingChanged() { + if (range_slider_.maximum() != settings.max_cached_minutes * 60) { + range_slider_.setRange(1, settings.max_cached_minutes * 60); + } + for (auto &c : charts_) { + c->setSeriesType((SeriesType)settings.chart_series_type); + } +} + +ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) { + for (auto &c : charts_) + if (c->hasSignal(id, sig)) return c.get(); + return nullptr; +} + +ChartView *ChartsWidget::createChart(int pos) { + auto chart = std::make_unique(can->timeRange().value_or(display_range_), this); + ChartView *ptr = chart.get(); + pos = std::clamp(pos, 0, (int)charts_.size()); + charts_.insert(charts_.begin() + pos, std::move(chart)); + currentCharts().insert(currentCharts().begin() + pos, ptr); + updateLayout(); + return ptr; +} + +void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { + ChartView *chart = findChart(id, sig); + if (show && !chart) { + chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart(); + chart->addSignal(id, sig); + updateState(); + } else if (!show && chart) { + chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; }); + } +} + +void ChartsWidget::splitChart(ChartView *src_chart) { + if (src_chart->signals().size() > 1) { + auto it = std::find_if(charts_.begin(), charts_.end(), [src_chart](auto &c) { return c.get() == src_chart; }); + const int pos = it - charts_.begin() + 1; + for (auto &s : src_chart->takeExtraSignals()) { + createChart(pos)->adoptSignal(std::move(s)); + } + updateState(); + } +} + +std::vector ChartsWidget::serializeChartIds() const { + std::vector chart_ids; + for (auto &c : charts_) { + std::string ids; + for (const auto &s : c->signals()) { + if (!ids.empty()) ids += ','; + ids += s.msg_id.toString() + "|" + s.sig->name; + } + chart_ids.push_back(ids); + } + std::reverse(chart_ids.begin(), chart_ids.end()); + return chart_ids; +} + +void ChartsWidget::restoreChartsFromIds(const std::vector &chart_ids) { + for (const auto &chart_id : chart_ids) { + int index = 0; + for (const auto &part : utils::split(chart_id, ',')) { + const size_t sep = part.find('|'); + if (sep == std::string::npos) continue; + MessageId msg_id = MessageId::fromString(part.substr(0, sep)); + if (auto *msg = dbc()->msg(msg_id)) + if (auto *sig = msg->sig(part.substr(sep + 1))) + showChart(msg_id, sig, true, index++ > 0); + } + } +} + +void ChartsWidget::setColumnCount(int n) { + n = std::clamp(n, 1, MAX_COLUMN_COUNT); + if (column_count_ != n) { + column_count_ = settings.chart_column_count = n; + updateLayout(); + } +} + +void ChartsWidget::updateLayout() { + + const float container_width = charts_container_.geometry().GetWidth(); + if (container_width <= 0) return; + + int n = MAX_COLUMN_COUNT; + for (; n > 1; --n) { + if ((n * CHART_MIN_WIDTH + (n - 1) * CHART_SPACING) < container_width) break; + } + + columns_action_visible_ = n > 1; + current_column_count_ = std::min(column_count_, n); +} + +void ChartsWidget::startChartDrag(ChartView *chart, const ImVec2 &global_pos) { + stopAutoScroll(); + drag_ = {.source = chart, .press_pos = global_pos}; + showValueTip(-1); + + drag_preview_size_ = ImVec2(CHART_MIN_WIDTH, (float)settings.chart_height); +} + +void ChartsWidget::dragChartMove(const ImVec2 &global_pos) { + if (!drag_.active) { + ImVec2 d = global_pos - drag_.press_pos; + if (std::abs(d.x) + std::abs(d.y) < START_DRAG_DISTANCE) return; + drag_.active = true; + drag_preview_visible_ = true; + } + drag_preview_pos_ = global_pos + ImVec2(5, 5); + + + int tab = tabbar_.tabAt(global_pos); + if (tab >= 0 && tab != tabbar_.currentIndex()) { + tabbar_.setCurrentIndex(tab); + } + + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag_.source && c->rect().Contains(global_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target_, target) != target) { + for (auto &c : charts_) c->setDropHighlight(c.get() == target); + } + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + bool on_background = !target && in_viewport && !charts_container_.childAt(global_pos); + charts_container_.setDropIndicator(on_background ? global_pos : ImVec2()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag_ = {}; + stopAutoScroll(); + drag_preview_visible_ = false; + charts_container_.setDropIndicator({}); + if (auto target = std::exchange(drop_target_, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const ImVec2 &global_pos) { + ChartView *source = drag_.source; + bool active = drag_.active; + ChartView *target = drop_target_; + cancelChartDrag(); + if (!active) return; + + bool in_viewport = charts_scroll_viewport_.Contains(global_pos); + if (target) { + + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container_.childAt(global_pos)) { + + auto w = charts_container_.getDropAfter(global_pos); + if (w != source) { + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), source), list.end()); + } + auto &cur = currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, source); + updateLayout(); + updateTabBar(); + } + } +} + +void ChartsWidget::drawDragPreview() { + if (!drag_preview_visible_ || !drag_.source) return; + + + ImGui::SetNextWindowPos(drag_preview_pos_); + ImGui::SetNextWindowSize(drag_preview_size_); + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, 0.5f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDocking; + if (ImGui::Begin("##chart_drag_ghost", nullptr, flags)) { + drag_.source->drawGhost(drag_preview_size_.x); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +void ChartsWidget::startAutoScroll(const ImVec2 &global_pos) { + auto_scroll_pos_ = global_pos; + if (!auto_scroll_timer_active_) auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + auto_scroll_timer_active_ = true; +} + +void ChartsWidget::stopAutoScroll() { + auto_scroll_timer_active_ = false; + auto_scroll_count_ = 0; +} + +void ChartsWidget::doAutoScroll() { + if (!charts_scroll_) return; + const int page_step = charts_scroll_viewport_.GetHeight(); + if (auto_scroll_count_ < page_step) { + ++auto_scroll_count_; + } + + int value = charts_scroll_->Scroll.y; + ImVec2 pos = auto_scroll_pos_; + ImRect area = charts_scroll_viewport_; + + int new_value = value; + if (pos.y - area.Min.y < settings.chart_height / 2) { + new_value = value - auto_scroll_count_; + } else if (area.Max.y - pos.y < settings.chart_height / 2) { + new_value = value + auto_scroll_count_; + } + new_value = std::clamp(new_value, 0, charts_scroll_->ScrollMax.y); + if (new_value != value) ImGui::SetScrollY(charts_scroll_, new_value); + if (value == new_value) { + stopAutoScroll(); + } else if (chartDragActive()) { + + dragChartMove(auto_scroll_pos_); + } +} + +void ChartsWidget::newChart() { + execSignalSelector(std::make_unique("New Chart"), nullptr, [this](SignalSelector &dlg) { + const auto &items = dlg.selectedItems(); + if (!items.empty()) { + auto c = createChart(); + for (const auto &it : items) { + c->addSignal(it.msg_id, it.sig); + } + updateState(); + } + }); +} + +void ChartsWidget::execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted) { + signal_selector_ = std::move(dlg); + signal_selector_owner_ = owner; + signal_selector_accepted_ = std::move(accepted); + signal_selector_->open(); +} + +void ChartsWidget::removeChart(ChartView *chart) { + if (drag_.source == chart) cancelChartDrag(); + if (drop_target_ == chart) drop_target_ = nullptr; + if (signal_selector_owner_ == chart) { + signal_selector_owner_ = nullptr; + signal_selector_accepted_ = nullptr; + } + auto it = std::find_if(charts_.begin(), charts_.end(), [chart](auto &c) { return c.get() == chart; }); + if (it != charts_.end()) { + deleted_charts_.push_back(std::move(*it)); + charts_.erase(it); + } + for (auto &[_, list] : tab_charts_) { + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); + } + updateLayout(); + seriesChanged(); +} + +void ChartsWidget::removeAll() { + while (tabbar_.count() > 1) { + tabbar_.removeTab(1); + } + std::vector all; + for (auto &c : charts_) all.push_back(c.get()); + for (auto c : all) removeChart(c); + tab_charts_.clear(); + zoomReset(); +} + +void ChartsWidget::handleEvents() { + + if (ImGui::IsMouseClicked(3) && ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) { + zoom_undo_stack_.undo(); + } + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow)) { + if (chartDragActive()) cancelChartDrag(); + showValueTip(-1); + } + + + if (chartDragActive()) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + dragChartMove(ImGui::GetMousePos()); + } else { + dragChartRelease(ImGui::GetMousePos()); + } + } + + if (!value_tip_visible_) return; + + + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (!any_plot_hovered_ && + (delta.x != 0 || delta.y != 0 || !ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows))) { + showValueTip(-1); + } +} + +void ChartsWidget::draw() { + deleted_charts_.clear(); + + + if (float_window_init_ && !is_docked_) { + float_window_init_ = false; + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + const ImVec2 size(viewport->WorkSize.x * 0.6f, viewport->WorkSize.y * 0.6f); + ImGui::SetWindowSize(size); + ImGui::SetWindowPos(viewport->WorkPos + (viewport->WorkSize - size) * 0.5f); + } + ImGui::PushID(this); + if (auto_scroll_timer_active_ && ImGui::GetTime() >= auto_scroll_timer_next_) { + auto_scroll_timer_next_ = ImGui::GetTime() + 0.05; + doAutoScroll(); + } + + + handleEvents(); + + drawToolBar(); + tabbar_.draw(); + + any_plot_hovered_ = false; + if (ImGui::BeginChild("charts_scroll", ImVec2(0, 0), ImGuiChildFlags_None, 0)) { + charts_scroll_ = ImGui::GetCurrentWindow(); + charts_scroll_viewport_ = charts_scroll_->InnerRect; + charts_container_.draw(); + } + ImGui::EndChild(); + + drawDragPreview(); + + if (signal_selector_ && !signal_selector_->draw()) { + auto dlg = std::move(signal_selector_); + auto accepted = std::move(signal_selector_accepted_); + signal_selector_owner_ = nullptr; + if (dlg->accepted() && accepted) accepted(*dlg); + } + ImGui::PopID(); +} + +void ChartsContainer::draw() { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 start = ImGui::GetCursorScreenPos(); + geometry_ = ImRect(start, start + ImVec2(window->InnerRect.GetWidth(), 0)); + charts_widget_->updateLayout(); + + const int n = std::max(charts_widget_->current_column_count_, 1); + const float spacing = CHART_SPACING; + const float width = (geometry_.GetWidth() - (n - 1) * spacing) / n; + const ImVec2 origin = ImGui::GetCursorScreenPos() + ImVec2(0, CHART_SPACING); + auto current_charts = charts_widget_->currentCharts(); + float bottom = origin.y; + const bool aligned = ImPlot::BeginAlignedPlots("charts_align", true); + for (int i = 0; i < current_charts.size(); ++i) { + ImVec2 pos = origin + ImVec2((i % n) * (width + spacing), (i / n) * (settings.chart_height + spacing)); + ImGui::SetCursorScreenPos(pos); + current_charts[i]->draw(width); + bottom = std::max(bottom, pos.y + settings.chart_height); + if (current_charts[i]->plotHovered()) charts_widget_->any_plot_hovered_ = true; + } + if (aligned) ImPlot::EndAlignedPlots(); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom)); + ImGui::Dummy(ImVec2(geometry_.GetWidth(), CHART_SPACING)); + geometry_.Max.y = bottom + CHART_SPACING; + drawDropIndicator(); +} + +void ChartsContainer::drawDropIndicator() { + if (!(drop_indicator_pos_.x == 0 && drop_indicator_pos_.y == 0) && !childAt(drop_indicator_pos_)) { + ImRect r = geometry_; + r.Max.y = r.Min.y + CHART_SPACING; + if (auto insert_after = getDropAfter(drop_indicator_pos_)) { + float h = r.GetHeight(); + r.Min.y = insert_after->rect().Max.y; + r.Max.y = r.Min.y + h; + } + + ImGui::GetWindowDrawList()->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_Header)); + } +} + +ChartView *ChartsContainer::getDropAfter(const ImVec2 &pos) const { + const auto &charts = charts_widget_->currentCharts(); + auto it = std::find_if(charts.crbegin(), charts.crend(), [&pos](auto c) { + const ImRect &area = c->rect(); + return pos.x >= area.Min.x && pos.x <= area.Max.x && pos.y >= area.Max.y; + }); + return it == charts.crend() ? nullptr : *it; +} + +ChartView *ChartsContainer::childAt(const ImVec2 &pos) const { + for (auto c : charts_widget_->currentCharts()) { + if (c->rect().Contains(pos)) return c; + } + return nullptr; +} diff --git a/iqpilot/tools/cabana/ui/chart/chartswidget.h b/iqpilot/tools/cabana/ui/chart/chartswidget.h new file mode 100644 index 000000000..7bbd5bad3 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/chartswidget.h @@ -0,0 +1,168 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/chart/signalselector.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/utils/util.h" + +const int CHART_MIN_WIDTH = 300; + + +class LogSlider { +public: + LogSlider(double factor) : scale_(factor) {} + + void setRange(double min, double max) { + scale_.setRange(min, max); + min_ = min; + max_ = max; + setValue(pos_); + } + int value() const { return scale_.value(pos_, minimum(), maximum()); } + void setValue(int v) { pos_ = scale_.position(v, minimum(), maximum()); } + int minimum() const { return min_; } + int maximum() const { return max_; } + bool draw(const char *label, float width); + +private: + LogScale scale_; + int min_ = 0; + int max_ = 1; + int pos_ = 0; +}; + +class ChartView; +class ChartsWidget; + +class ChartsContainer { +public: + ChartsContainer(ChartsWidget *parent) : charts_widget_(parent) {} + void setDropIndicator(const ImVec2 &pt) { drop_indicator_pos_ = pt; } + void draw(); + ChartView *getDropAfter(const ImVec2 &pos) const; + ChartView *childAt(const ImVec2 &pos) const; + const ImRect &geometry() const { return geometry_; } + +private: + void drawDropIndicator(); + + ImRect geometry_; + ChartsWidget *charts_widget_; + ImVec2 drop_indicator_pos_; +}; + +class ChartsWidget { +public: + ChartsWidget(); + ~ChartsWidget(); + void draw(); + void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge); + inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; } + std::vector serializeChartIds() const; + void restoreChartsFromIds(const std::vector &chart_ids); + std::string whatsThis() const; + + void setColumnCount(int n); + void removeAll(); + void setIsDocked(bool dock); + + Observable<> toggleChartsDocking; + Observable<> seriesChanged; + Observable showTip; + +private: + void handleEvents(); + void newChart(); + ChartView *createChart(int pos = 0); + void removeChart(ChartView *chart); + void splitChart(ChartView *chart); + ImRect chartVisibleRect(ChartView *chart); + void eventsMerged(const MessageEventsMap &new_events); + void updateState(); + void zoomReset(); + void startChartDrag(ChartView *chart, const ImVec2 &global_pos); + void dragChartMove(const ImVec2 &global_pos); + void dragChartRelease(const ImVec2 &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag_.source != nullptr; } + void startAutoScroll(const ImVec2 &global_pos); + void stopAutoScroll(); + void doAutoScroll(); + void drawToolBar(); + void updateTabBar(); + void setMaxChartRange(int value); + void updateLayout(); + void settingChanged(); + void showValueTip(double sec); + void newTab(); + void removeTab(int index); + inline std::vector ¤tCharts() { return tab_charts_[tabbar_.tabData(tabbar_.currentIndex())]; } + ChartView *findChart(const MessageId &id, const cabana::Signal *sig); + + void execSignalSelector(std::unique_ptr dlg, ChartView *owner, std::function accepted); + void drawDragPreview(); + + LogSlider range_slider_{1000}; + bool is_docked_ = true; + bool float_window_init_ = false; + + UndoStack zoom_undo_stack_; + + std::vector> charts_; + std::unordered_map> tab_charts_; + TabBar tabbar_; + ChartsContainer charts_container_{this}; + ImGuiWindow *charts_scroll_ = nullptr; + ImRect charts_scroll_viewport_; + int max_chart_range_ = 0; + std::pair display_range_; + bool columns_action_visible_ = false; + int column_count_ = 1; + int current_column_count_ = 0; + struct ChartDrag { + ChartView *source = nullptr; + ImVec2 press_pos; + bool active = false; + } drag_; + + ImVec2 drag_preview_pos_; + ImVec2 drag_preview_size_; + bool drag_preview_visible_ = false; + ChartView *drop_target_ = nullptr; + int auto_scroll_count_ = 0; + ImVec2 auto_scroll_pos_; + bool auto_scroll_timer_active_ = false; + double auto_scroll_timer_next_ = 0; + bool value_tip_visible_ = false; + bool any_plot_hovered_ = false; + std::vector> deleted_charts_; + std::unique_ptr signal_selector_; + ChartView *signal_selector_owner_ = nullptr; + std::function signal_selector_accepted_; + Connections connections_; + friend class ChartView; + friend class ChartsContainer; +}; + +class ZoomCommand : public UndoCommand { +public: + ZoomCommand(std::pair range) : range(range) { + prev_range = can->timeRange(); + } + void undo() override { can->setTimeRange(prev_range); } + void redo() override { can->setTimeRange(range); } + std::optional> prev_range, range; +}; diff --git a/iqpilot/tools/cabana/ui/chart/signalselector.cc b/iqpilot/tools/cabana/ui/chart/signalselector.cc new file mode 100644 index 000000000..84db16ea8 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/signalselector.cc @@ -0,0 +1,155 @@ +#include "tools/cabana/ui/chart/signalselector.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/chart/chart.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +SignalSelector::SignalSelector(std::string title) : title_(std::move(title)) { + for (const auto &[id, _] : can->lastMessages()) { + if (auto m = dbc()->msg(id)) { + msgs_combo_.push_back({m->name + " (" + id.toString() + ")", id}); + } + } + std::sort(msgs_combo_.begin(), msgs_combo_.end(), [](auto &a, auto &b) { return a.text < b.text; }); +} + +bool SignalSelector::draw() { + if (!open_) return false; + const std::string popup_id = title_ + "###SignalSelector"; + if (!show_) { + ImGui::OpenPopup(popup_id.c_str()); + show_ = true; + } + setNextDialogWindow(ImVec2(700.0f, 450.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_NoSavedSettings)) { + open_ = false; + return false; + } + + const float btn_w = ImGui::GetFrameHeight() + 8.0f; + const float column_w = (ImGui::GetContentRegionAvail().x - btn_w - ImGui::GetStyle().ItemSpacing.x * 2) / 2; + + const float lists_h = ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() * 3; + + ImGui::BeginGroup(); + ImGui::TextUnformatted("Available Signals"); + + const char *preview = msgs_combo_index_ >= 0 ? msgs_combo_[msgs_combo_index_].text.c_str() : "Select a msg..."; + ImGui::SetNextItemWidth(column_w); + if (ImGui::BeginCombo("##msgs_combo", preview)) { + if (ImGui::IsWindowAppearing()) { + msgs_combo_filter_.clear(); + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(-FLT_MIN); + inputText("##msgs_filter", &msgs_combo_filter_, "Select a msg..."); + for (int i = 0; i < (int)msgs_combo_.size(); ++i) { + if (!msgs_combo_filter_.empty() && !utils::containsCI(msgs_combo_[i].text, msgs_combo_filter_)) continue; + if (ImGui::Selectable(msgs_combo_[i].text.c_str(), i == msgs_combo_index_)) { + msgs_combo_index_ = i; + updateAvailableList(i); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndCombo(); + } + bool add_dbl = false; + drawList("##available_list", available_list_, &available_row_, false, &add_dbl, ImVec2(column_w, lists_h)); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::Dummy(ImVec2(btn_w, (lists_h + ImGui::GetFrameHeightWithSpacing() * 2) / 2 - ImGui::GetFrameHeight())); + ImGui::BeginDisabled(available_row_ == -1); + bool add_clicked = ImGui::Button(icon::CHEVRON_RIGHT, ImVec2(btn_w, 0)); + ImGui::EndDisabled(); + ImGui::BeginDisabled(selected_row_ == -1); + bool remove_clicked = ImGui::Button(icon::CHEVRON_LEFT, ImVec2(btn_w, 0)); + ImGui::EndDisabled(); + ImGui::EndGroup(); + + ImGui::SameLine(); + ImGui::BeginGroup(); + ImGui::TextUnformatted("Selected Signals"); + bool remove_dbl = false; + drawList("##selected_list", selected_list_, &selected_row_, true, &remove_dbl, ImVec2(column_w, lists_h + ImGui::GetFrameHeightWithSpacing())); + bool rejected = false; + dialogButtons("OK", &accepted_, &rejected); + const bool done = accepted_ || rejected; + ImGui::EndGroup(); + + if ((add_dbl || add_clicked) && available_row_ >= 0 && available_row_ < (int)available_list_.size()) { + add(available_row_); + } else if ((remove_dbl || remove_clicked) && selected_row_ >= 0 && selected_row_ < (int)selected_list_.size()) { + remove(selected_row_); + } + + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + return open_; +} + +void SignalSelector::drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size) { + if (!ImGui::BeginListBox(id, size)) return; + for (int i = 0; i < (int)list.size(); ++i) { + const auto &item = list[i]; + ImGui::PushID(i); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + if (ImGui::Selectable("##item", i == *current_row)) *current_row = i; + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + *current_row = i; + *double_clicked = true; + } + + ImDrawList *dl = ImGui::GetWindowDrawList(); + float x = pos.x + 5; + drawColorMarker(dl, ImVec2(x, pos.y), toImU32(item.sig->color)); + x += markerSize() + 4; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_Text), item.sig->name.c_str()); + if (show_msg_name) { + x += ImGui::CalcTextSize(item.sig->name.c_str()).x; + dl->AddText(ImVec2(x, pos.y), ImGui::GetColorU32(ImGuiCol_TextDisabled), msgLabel(item.msg_id).c_str()); + } + ImGui::PopID(); + } + ImGui::EndListBox(); +} + +void SignalSelector::add(int row) { + const auto &item = available_list_[row]; + selected_list_.emplace_back(item.msg_id, item.sig); + available_list_.erase(available_list_.begin() + row); + available_row_ = -1; +} + +void SignalSelector::remove(int row) { + const auto &item = selected_list_[row]; + if (msgs_combo_index_ >= 0 && item.msg_id == msgs_combo_[msgs_combo_index_].id) { + available_list_.emplace_back(item.msg_id, item.sig); + } + selected_list_.erase(selected_list_.begin() + row); + selected_row_ = -1; +} + +void SignalSelector::updateAvailableList(int index) { + if (index == -1) return; + available_list_.clear(); + available_row_ = -1; + MessageId msg_id = msgs_combo_[index].id; + for (auto s : dbc()->msg(msg_id)->getSignals()) { + bool is_selected = std::any_of(selected_list_.begin(), selected_list_.end(), + [sig = s, &msg_id](auto &it) { return it.msg_id == msg_id && it.sig == sig; }); + if (!is_selected) { + available_list_.emplace_back(msg_id, s); + } + } +} diff --git a/iqpilot/tools/cabana/ui/chart/signalselector.h b/iqpilot/tools/cabana/ui/chart/signalselector.h new file mode 100644 index 000000000..e2fc24221 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/signalselector.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" + + +class SignalSelector { +public: + struct ListItem { + ListItem(const MessageId &msg_id, const cabana::Signal *sig) : msg_id(msg_id), sig(sig) {} + MessageId msg_id; + const cabana::Signal *sig; + }; + + SignalSelector(std::string title); + const std::vector &selectedItems() const { return selected_list_; } + inline void addSelected(const MessageId &id, const cabana::Signal *sig) { selected_list_.emplace_back(id, sig); } + void open() { open_ = true; show_ = false; accepted_ = false; } + bool draw(); + bool accepted() const { return accepted_; } + +private: + void updateAvailableList(int index); + void add(int row); + void remove(int row); + void drawList(const char *id, std::vector &list, int *current_row, bool show_msg_name, bool *double_clicked, const ImVec2 &size); + + struct ComboItem { + std::string text; + MessageId id; + }; + std::string title_; + std::vector msgs_combo_; + int msgs_combo_index_ = -1; + std::string msgs_combo_filter_; + std::vector available_list_; + std::vector selected_list_; + int available_row_ = -1; + int selected_row_ = -1; + bool accepted_ = false; + bool open_ = false; + bool show_ = false; +}; diff --git a/iqpilot/tools/cabana/ui/chart/sparkline.cc b/iqpilot/tools/cabana/ui/chart/sparkline.cc new file mode 100644 index 000000000..b0083fee2 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/sparkline.cc @@ -0,0 +1,177 @@ +#include "tools/cabana/ui/chart/sparkline.h" + +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, + double window_end) { + if (first == last || sz.x <= 0 || sz.y <= 0) { + render_points_.clear(); + size = {}; + return; + } + + points_.clear(); + min_val = std::numeric_limits::max(); + max_val = std::numeric_limits::lowest(); + points_.reserve(std::distance(first, last)); + + + + + const double window_start = window_end - range; + double value = 0.0; + for (auto it = first; it != last; ++it) { + if (sig->getValue((*it)->dat, (*it)->size, &value)) { + double x = can->toSeconds((*it)->mono_time) - window_start; + + + if (x >= 0.0) { + min_val = std::min(min_val, value); + max_val = std::max(max_val, value); + } + points_.push_back({x, value}); + } + } + if (min_val > max_val) { + for (const auto &p : points_) { + min_val = std::min(min_val, p.y); + max_val = std::max(max_val, p.y); + } + } + + if (points_.empty()) { + render_points_.clear(); + size = {}; + return; + } + + freq_ = points_.size() / std::max(points_.back().x - points_.front().x, 1.0); + render(sig->color, range, sz, window_end); +} + +void Sparkline::render(const CabanaColor &color, int range, ImVec2 sz, double window_end) { + bool is_flat_line = min_val == max_val; + if (is_flat_line) { + min_val -= 1.0; + max_val += 1.0; + } + + const double xscale = (sz.x - 1) / (double)range; + const double yscale = (sz.y - 3) / (max_val - min_val); + const double span = points_.back().x - points_.front().x; + bool draw_individual_points = (span * xscale / points_.size()) > 8.0; + + + render_points_.reserve(points_.size()); + render_points_.clear(); + if (draw_individual_points) { + for (const auto &p : points_) { + render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - p.y) * yscale); + } + } else if (is_flat_line) { + double y = sz.y / 2.0; + render_points_.emplace_back(points_.front().x * xscale, y); + render_points_.emplace_back(points_.back().x * xscale, y); + } else { + double prev_y = points_.front().y; + render_points_.emplace_back(points_.front().x * xscale, 1.0 + (max_val - prev_y) * yscale); + bool in_flat = false; + + for (size_t i = 1; i < points_.size(); ++i) { + const auto &p = points_[i]; + double y = p.y; + if (std::abs(y - prev_y) < 1e-6) { + in_flat = true; + } else { + if (in_flat) render_points_.emplace_back(points_[i - 1].x * xscale, 1.0 + (max_val - prev_y) * yscale); + render_points_.emplace_back(p.x * xscale, 1.0 + (max_val - y) * yscale); + in_flat = false; + } + prev_y = y; + } + if (in_flat) render_points_.emplace_back(points_.back().x * xscale, 1.0 + (max_val - prev_y) * yscale); + } + + size = sz; + CabanaColor line_color = color; + if (!isDarkTheme()) { + auto [h, s, v] = color.hsv(); + line_color = CabanaColor::fromHsv(h, std::min(1.0f, s * 2.0f), v * 0.7f, color.a / 255.0f); + } + color_ = toImU32(line_color); + draw_individual_points_ = draw_individual_points; + window_end_ = window_end; + xscale_ = xscale; +} + +void Sparkline::draw(ImDrawList *draw_list, ImVec2 pos) const { + if (render_points_.empty()) return; + + + + + const float shift = std::clamp((float)((can->currentSec() - window_end_) * xscale_), 0.0f, size.x); + + + const float px = 1.0f / std::max(1.0f, ImGui::GetIO().DisplayFramebufferScale.x); + auto snap = [&](float x) { return std::floor(x / px) * px; }; + + + + + + ImVec2 offset(pos.x - shift, pos.y); + const double k = offset.x - (window_end_ * xscale_ - (size.x - 1)); + offset.x += snap(k) - k; + auto point_at = [&](const ImVec2 &p) { return ImVec2(offset.x + p.x, offset.y + p.y); }; + + draw_list->PushClipRect(pos, ImVec2(pos.x + size.x, pos.y + size.y), true); + + + auto draw_point = [&](const ImVec2 &p) { draw_list->AddRectFilled(ImVec2(p.x - 1.5f, p.y - 1.5f), ImVec2(p.x + 1.5f, p.y + 1.5f), color_); }; + + if (draw_individual_points_) { + for (const auto &p : render_points_) { + draw_list->PathLineTo(point_at(p)); + draw_point(point_at(p)); + } + draw_list->PathStroke(color_, ImDrawFlags_None, 1.5f); + } else { + + + std::vector pts; + pts.reserve(render_points_.size()); + float col = -1e9f; + for (const auto &p : render_points_) { + ImVec2 sp = point_at(p); + float c = snap(sp.x); + if (c != col) { + pts.push_back(sp); + col = c; + } + } + + + + + auto steep = [&](size_t i) { return std::abs(pts[i + 1].y - pts[i].y) > 2.0f * std::abs(pts[i + 1].x - pts[i].x) + px; }; + const ImDrawListFlags saved = draw_list->Flags; + size_t i = 0; + while (i + 1 < pts.size()) { + const bool is_steep = steep(i); + size_t j = i + 1; + while (j + 1 < pts.size() && steep(j) == is_steep) ++j; + draw_list->Flags = is_steep ? (saved & ~ImDrawListFlags_AntiAliasedLines) : saved; + for (size_t n = i; n <= j; ++n) draw_list->PathLineTo(pts[n]); + draw_list->PathStroke(color_, ImDrawFlags_None, 1.0f); + i = j; + } + draw_list->Flags = saved; + draw_point(point_at(render_points_.back())); + } + draw_list->PopClipRect(); +} diff --git a/iqpilot/tools/cabana/ui/chart/sparkline.h b/iqpilot/tools/cabana/ui/chart/sparkline.h new file mode 100644 index 000000000..c2bcd7cf9 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/sparkline.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbc.h" +#include "tools/cabana/streams/abstractstream.h" + +class Sparkline { +public: + void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, ImVec2 sz, double window_end); + inline double freq() const { return freq_; } + bool isEmpty() const { return render_points_.empty(); } + + void draw(ImDrawList *draw_list, ImVec2 pos) const; + + ImVec2 size = {}; + double min_val = 0; + double max_val = 0; + +private: + struct Point { + double x, y; + }; + void render(const CabanaColor &color, int range, ImVec2 sz, double window_end); + + std::vector points_; + std::vector render_points_; + ImU32 color_ = 0; + double window_end_ = 0; + double xscale_ = 0; + bool draw_individual_points_ = false; + double freq_ = 0; +}; diff --git a/iqpilot/tools/cabana/ui/chart/tiplabel.cc b/iqpilot/tools/cabana/ui/chart/tiplabel.cc new file mode 100644 index 000000000..328b3aad6 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/tiplabel.cc @@ -0,0 +1,69 @@ +#define IMGUI_DEFINE_MATH_OPERATORS +#include "tools/cabana/ui/chart/tiplabel.h" + +#include +#include + +#include "tools/cabana/ui/util.h" + +ImVec2 TipLabel::layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const { + ImFont *bold = boldFont(); + const float font_size = ImGui::GetFontSize(); + const float line_height = ImGui::GetTextLineHeight(); + ImVec2 size(0, 0); + float y = origin.y; + for (const auto &line : text_) { + float x = origin.x; + if (line.has_marker) { + if (p) drawColorMarker(p, ImVec2(x, y), line.marker); + x += markerSize() + 4; + } + if (p) p->AddText(ImVec2(x, y), fg, line.name.c_str()); + x += ImGui::CalcTextSize(line.name.c_str()).x; + if (!line.bold.empty()) { + if (p) p->AddText(bold, font_size, ImVec2(x, y), fg, line.bold.c_str()); + x += bold->CalcTextSizeA(font_size, FLT_MAX, 0.0f, line.bold.c_str()).x; + } + if (p) p->AddText(ImVec2(x, y), fg, line.rest.c_str()); + x += ImGui::CalcTextSize(line.rest.c_str()).x; + size.x = std::max(size.x, x - origin.x); + y += line_height; + } + size.y = y - origin.y; + return size; +} + +ImVec2 TipLabel::sizeHint() const { + return layoutLines(nullptr, ImVec2(0, 0), 0) + ImVec2(MARGIN * 2, MARGIN * 2); +} + +void TipLabel::showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect) { + text_ = text; + if (!text_.empty()) { + ImVec2 extra(1, 1); + size_ = sizeHint() + extra; + ImVec2 tip_pos(pt.x + 8, rect.Min.y + 2); + if (tip_pos.x + size_.x >= rect.Max.x) { + tip_pos.x = pt.x - size_.x - 8; + } + if (rect.Contains(ImRect(tip_pos, tip_pos + size_))) { + pos_ = tip_pos; + visible_ = true; + return; + } + } + visible_ = false; +} + +void TipLabel::draw() { + if (!visible_) return; + + ImDrawList *p = ImGui::GetForegroundDrawList(); + const bool dark = isDarkTheme(); + const ImU32 bg = dark ? ImGui::GetColorU32(ImGuiCol_PopupBg) : ImGui::GetColorU32(ImGuiCol_ChildBg); + const ImU32 fg = dark ? ImGui::GetColorU32(ImGuiCol_Text) : IM_COL32(0x40, 0x40, 0x44, 0xff); + + p->AddRectFilled(pos_, pos_ + size_, bg); + p->AddRect(pos_, pos_ + size_, ImGui::GetColorU32(ImGuiCol_Border)); + layoutLines(p, pos_ + ImVec2(MARGIN, MARGIN), fg); +} diff --git a/iqpilot/tools/cabana/ui/chart/tiplabel.h b/iqpilot/tools/cabana/ui/chart/tiplabel.h new file mode 100644 index 000000000..2aa1ab814 --- /dev/null +++ b/iqpilot/tools/cabana/ui/chart/tiplabel.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + + +struct TipLine { + bool has_marker = false; + ImU32 marker = 0; + std::string name; + std::string bold; + std::string rest; +}; + +class TipLabel { +public: + void showText(const ImVec2 &pt, const std::vector &text, const ImRect &rect); + void hide() { visible_ = false; } + bool isVisible() const { return visible_; } + void draw(); + +private: + + ImVec2 layoutLines(ImDrawList *p, const ImVec2 &origin, ImU32 fg) const; + ImVec2 sizeHint() const; + + static constexpr float MARGIN = 2.0f; + std::vector text_; + ImVec2 pos_; + ImVec2 size_; + bool visible_ = false; +}; diff --git a/iqpilot/tools/cabana/ui/dialogs/filedialog.cc b/iqpilot/tools/cabana/ui/dialogs/filedialog.cc new file mode 100644 index 000000000..86ce045bb --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/filedialog.cc @@ -0,0 +1,222 @@ +#include "tools/cabana/ui/dialogs/filedialog.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" + +namespace fs = std::filesystem; + +namespace FileDialog { + +namespace { + + + + +bool naturalLess(const std::string &a, const std::string &b) { + auto skip = [](const std::string &s, size_t &i) { + while (i < s.size() && !isalnum(static_cast(s[i]))) ++i; + }; + size_t i = 0, j = 0; + for (;;) { + skip(a, i); + skip(b, j); + if (i >= a.size() || j >= b.size()) break; + if (isdigit(static_cast(a[i])) && isdigit(static_cast(b[j]))) { + size_t ie = i, je = j; + while (ie < a.size() && isdigit(static_cast(a[ie]))) ++ie; + while (je < b.size() && isdigit(static_cast(b[je]))) ++je; + const unsigned long long na = std::stoull(a.substr(i, ie - i)), nb = std::stoull(b.substr(j, je - j)); + if (na != nb) return na < nb; + i = ie; + j = je; + } else { + const int ca = tolower(static_cast(a[i])), cb = tolower(static_cast(b[j])); + if (ca != cb) return ca < cb; + ++i; + ++j; + } + } + const bool a_done = i >= a.size(), b_done = j >= b.size(); + if (a_done != b_done) return a_done; + return a < b; +} + +enum class Mode { OpenFile, SaveFile, Directory }; + +struct State { + bool active = false; + Mode mode = Mode::OpenFile; + std::string title; + std::string extension; + fs::path dir; + std::string dir_input; + std::string filename; + std::vector entries; + Callback callback; +}; + +State g_state; +PopupOwner g_owner; + +void listDir() { + State &s = g_state; + s.entries.clear(); + std::error_code ec; + for (const auto &entry : fs::directory_iterator(s.dir, ec)) { + const std::string name = entry.path().filename().string(); + if (name.empty() || name[0] == '.') continue; + const bool is_dir = entry.is_directory(ec); + if (!is_dir && s.mode == Mode::Directory) continue; + if (!is_dir && !s.extension.empty() && entry.path().extension() != s.extension) continue; + s.entries.push_back(entry); + } + std::sort(s.entries.begin(), s.entries.end(), [](const auto &a, const auto &b) { + std::error_code sort_ec; + const bool da = a.is_directory(sort_ec), db = b.is_directory(sort_ec); + return da != db ? da : naturalLess(a.path().filename().string(), b.path().filename().string()); + }); + s.dir_input = s.dir.string(); +} + +void setDir(const fs::path &dir) { + std::error_code ec; + fs::path d = fs::is_directory(dir, ec) ? fs::absolute(dir, ec) : fs::current_path(ec); + g_state.dir = d.lexically_normal(); + listDir(); +} + +void start(Mode mode, const std::string &title, const fs::path &dir, const std::string &filename, + const std::string &extension, Callback cb) { + State &s = g_state; + s = State{}; + s.active = true; + s.mode = mode; + s.title = title; + s.extension = extension; + s.filename = filename; + s.callback = std::move(cb); + g_owner.reset(); + setDir(dir); +} + +void finish(const std::string &path) { + Callback cb = std::move(g_state.callback); + g_state = State{}; + g_owner.reset(); + if (cb) cb(path); +} + +void accept(const fs::path &path) { + if (g_state.mode == Mode::SaveFile) { + std::error_code ec; + if (fs::exists(path, ec)) { + const std::string name = path.filename().string(); + MessageBox::question(g_state.title, name + " already exists.\nDo you want to replace it?", [path](bool ok) { + if (ok) finish(path.string()); + }); + return; + } + } + finish(path.string()); +} + +} + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb) { + start(Mode::OpenFile, title, dir, "", extension, std::move(cb)); +} + +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb) { + const fs::path p(default_path); + start(Mode::SaveFile, title, p.parent_path(), p.filename().string(), extension, std::move(cb)); +} + +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb) { + start(Mode::Directory, title, dir, "", "", std::move(cb)); +} + +void draw() { + State &s = g_state; + if (!s.active) return; + const std::string popup_id = s.title + "###FileDialog"; + if (!beginDialog(popup_id.c_str(), &g_owner, ImVec2(640.0f, 480.0f), 0)) return; + + if (ImGui::Button("Up")) setDir(s.dir.parent_path()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (inputText("##dir", &s.dir_input, "", ImGuiInputTextFlags_EnterReturnsTrue)) setDir(s.dir_input); + + const float footer = ImGui::GetFrameHeightWithSpacing() * (s.mode == Mode::Directory ? 1.0f : 2.0f) + ImGui::GetStyle().ItemSpacing.y; + bool ok = false, cancel = false; + fs::path result, pending_dir; + ImGui::BeginChild("entries", ImVec2(0, -footer), ImGuiChildFlags_Borders); + std::error_code dir_ec; + for (size_t i = 0; i < s.entries.size(); ++i) { + const auto &entry = s.entries[i]; + const bool is_dir = entry.is_directory(dir_ec); + const std::string name = entry.path().filename().string(); + const std::string label = (is_dir ? std::string(icon::FOLDER) : std::string(icon::FILE_EARMARK)) + " " + name; + ImGui::PushID(static_cast(i)); + const bool selected = !is_dir && name == s.filename; + if (ImGui::Selectable(label.c_str(), selected, ImGuiSelectableFlags_AllowDoubleClick)) { + const bool double_clicked = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); + if (is_dir) { + if (double_clicked) { + pending_dir = entry.path(); + } else if (s.mode == Mode::Directory) { + s.filename = name; + } + } else { + s.filename = name; + if (double_clicked && s.mode == Mode::OpenFile) { + result = entry.path(); + ok = true; + } + } + } + ImGui::PopID(); + if (ok || !pending_dir.empty()) break; + } + ImGui::EndChild(); + if (!pending_dir.empty()) setDir(pending_dir); + + if (s.mode != Mode::Directory) { + ImGui::SetNextItemWidth(-90.0f); + if (inputText("##name", &s.filename, "File name", ImGuiInputTextFlags_EnterReturnsTrue)) ok = true; + ImGui::SameLine(); + ImGui::TextDisabled("%s", s.extension.empty() ? "*" : ("*" + s.extension).c_str()); + } + const char *accept_label = s.mode == Mode::SaveFile ? "Save" : (s.mode == Mode::Directory ? "Choose" : "Open"); + dialogButtons(accept_label, &ok, &cancel); + + if (ok && result.empty()) { + if (s.mode == Mode::Directory) { + result = s.filename.empty() ? s.dir : s.dir / s.filename; + } else if (!s.filename.empty()) { + result = fs::path(s.filename).is_absolute() ? fs::path(s.filename) : s.dir / s.filename; + if (s.mode == Mode::SaveFile && !s.extension.empty() && result.extension().empty()) result += s.extension; + if (s.mode == Mode::OpenFile && !fs::is_regular_file(result, dir_ec)) ok = false; + } else { + ok = false; + } + } + if (ok || cancel) ImGui::CloseCurrentPopup(); + + MessageBox::draw(); + if (!s.active) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (cancel) { + finish(""); + } else if (ok) { + accept(result); + } +} + +} diff --git a/iqpilot/tools/cabana/ui/dialogs/filedialog.h b/iqpilot/tools/cabana/ui/dialogs/filedialog.h new file mode 100644 index 000000000..f8deff414 --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/filedialog.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include +#include + + +namespace FileDialog { + +using Callback = std::function; + +void getOpenFileName(const std::string &title, const std::string &dir, const std::string &extension, Callback cb); +void getSaveFileName(const std::string &title, const std::string &default_path, const std::string &extension, Callback cb); +void getExistingDirectory(const std::string &title, const std::string &dir, Callback cb); + +void draw(); + +} diff --git a/iqpilot/tools/cabana/ui/dialogs/messagebox.cc b/iqpilot/tools/cabana/ui/dialogs/messagebox.cc new file mode 100644 index 000000000..ad03bdf59 --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/messagebox.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/dialogs/messagebox.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/util.h" + +namespace MessageBox { + +namespace { + +struct Box { + std::string title; + std::string text; + std::string detailed_text; + bool has_cancel = false; + std::function on_result; +}; + +std::deque g_queue; +bool g_show_details = false; +PopupOwner g_owner; + +void push(Box box) { g_queue.push_back(std::move(box)); } + +std::function wrap(std::function on_close) { + if (!on_close) return nullptr; + return [on_close = std::move(on_close)](bool) { on_close(); }; +} + +} + +void information(const std::string &title, const std::string &text, std::function on_close) { + push({.title = title, .text = text, .on_result = wrap(std::move(on_close))}); +} + +void warning(const std::string &title, const std::string &text, const std::string &detailed_text, + std::function on_close) { + push({.title = title, .text = text, .detailed_text = detailed_text, .on_result = wrap(std::move(on_close))}); +} + +void question(const std::string &title, const std::string &text, std::function on_result) { + push({.title = title, .text = text, .has_cancel = true, .on_result = std::move(on_result)}); +} + +void draw() { + if (g_queue.empty()) return; + Box &box = g_queue.front(); + const std::string popup_id = box.title + "###MessageBox"; + const bool first = g_owner.popup_id == 0; + if (!g_owner.begin(popup_id.c_str())) return; + + const ImGuiStyle &style = ImGui::GetStyle(); + const float min_width = ImGui::CalcTextSize(box.title.c_str()).x + style.FramePadding.x * 2 + style.WindowPadding.x * 2; + ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(FLT_MAX, FLT_MAX)); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (!ImGui::BeginPopupModal(popup_id.c_str(), nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) return; + if (first) g_show_details = false; + bool result = false, done = false; + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + 480.0f); + ImGui::TextUnformatted(box.text.c_str()); + ImGui::PopTextWrapPos(); + if (g_show_details) { + ImGui::InputTextMultiline("##details", box.detailed_text.data(), box.detailed_text.size() + 1, + ImVec2(480.0f, 160.0f), ImGuiInputTextFlags_ReadOnly); + } + ImGui::Separator(); + if (!box.detailed_text.empty()) { + + if (ImGui::Button(g_show_details ? "Hide Details..." : "Show Details...")) g_show_details = !g_show_details; + ImGui::SameLine(); + } + dialogButtons("OK", &result, &done, true, box.has_cancel ? "Cancel" : nullptr); + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) result = true; + if (result) done = true; + if (done) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (done) { + g_owner.reset(); + Box finished = std::move(g_queue.front()); + g_queue.pop_front(); + if (finished.on_result) finished.on_result(result); + } +} + +} diff --git a/iqpilot/tools/cabana/ui/dialogs/messagebox.h b/iqpilot/tools/cabana/ui/dialogs/messagebox.h new file mode 100644 index 000000000..7b7591ee7 --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/messagebox.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + + + +namespace MessageBox { + + +void information(const std::string &title, const std::string &text, std::function on_close = nullptr); +void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "", + std::function on_close = nullptr); + +void question(const std::string &title, const std::string &text, std::function on_result); + +void draw(); + +} diff --git a/iqpilot/tools/cabana/ui/dialogs/routesdialog.cc b/iqpilot/tools/cabana/ui/dialogs/routesdialog.cc new file mode 100644 index 000000000..144c873ba --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/routesdialog.cc @@ -0,0 +1,124 @@ +#include "tools/cabana/ui/dialogs/routesdialog.h" + +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { +const char *PERIOD_NAMES[] = {"Last week", "Last 2 weeks", "Last month", "Last 6 months", "Preserved"}; +const int PERIOD_DAYS[] = {7, 14, 30, 180, -1}; +} + +void RoutesDialog::open(std::function on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + s_ = State{}; + alive_ = std::make_shared(true); + + routes::fetchDevices([this, alive = std::weak_ptr(alive_)](std::vector devices, bool success, int error_code) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() { + setDeviceList(devices, success, error_code); + })); + }); +} + +void RoutesDialog::setDeviceList(const std::vector &devices, bool success, int error_code) { + if (success) { + s_.devices.clear(); + for (const auto &device : devices) s_.devices.push_back(device.dongle_id); + s_.devices_loaded = true; + s_.device_index = 0; + fetchRoutes(); + } else { + + MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with iqpilot/tools/lib/auth.py" : "Network error", "", + utils::guarded(alive_, [this]() { finish(false); })); + } +} + +void RoutesDialog::fetchRoutes() { + if (!s_.devices_loaded || s_.devices.empty()) return; + + s_.routes.clear(); + s_.route_index = -1; + s_.empty_text = "Loading..."; + + const int request_id = ++s_.fetch_id; + auto on_routes = [this, alive = std::weak_ptr(alive_), request_id](std::vector list, bool success, int) { + utils::runOnMainThread(utils::guarded(alive.lock(), [this, list = std::move(list), success, request_id]() { + if (s_.fetch_id == request_id) setRouteList(list, success); + })); + }; + routes::fetchRoutes(s_.devices[s_.device_index], PERIOD_DAYS[s_.period_index], std::move(on_routes)); +} + +void RoutesDialog::setRouteList(const std::vector &list, bool success) { + if (success) { + for (const auto &route : list) { + const int mins = static_cast((route.end_ms - route.start_ms) / 60000); + s_.routes.push_back({routes::formatUnixMs(route.start_ms) + " " + std::to_string(mins) + "min", route.name}); + } + if (!s_.routes.empty()) s_.route_index = 0; + } else { + MessageBox::warning("Error", "Failed to fetch routes. Check your network connection.", "", + utils::guarded(alive_, [this]() { finish(false); })); + } + s_.empty_text = "No items"; +} + +void RoutesDialog::finish(bool accepted) { + alive_.reset(); + open_ = false; + auto on_done = std::move(on_done_); + if (on_done) on_done(accepted, accepted && s_.route_index >= 0 ? s_.routes[s_.route_index].name : ""); +} + +void RoutesDialog::draw() { + if (!open_) return; + if (!beginDialog("Remote routes", &popup_, ImVec2(480.0f, 420.0f))) return; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (s_.devices_loaded) { + if (comboBox("##device", &s_.device_index, s_.devices)) fetchRoutes(); + } else { + int idx = 0; + ImGui::BeginDisabled(); + comboBox("##device", &idx, {"Loading..."}); + ImGui::EndDisabled(); + } + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::Combo("##period", &s_.period_index, PERIOD_NAMES, IM_ARRAYSIZE(PERIOD_NAMES))) fetchRoutes(); + + bool accepted = false, rejected = false; + const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("routes", ImVec2(0, -footer), ImGuiChildFlags_Borders); + if (s_.routes.empty()) { + const ImVec2 size = ImGui::CalcTextSize(s_.empty_text.c_str()); + const ImVec2 avail = ImGui::GetContentRegionAvail(); + ImGui::SetCursorPos(ImVec2((avail.x - size.x) * 0.5f, (avail.y - size.y) * 0.5f)); + ImGui::TextUnformatted(s_.empty_text.c_str()); + } + for (int i = 0; i < static_cast(s_.routes.size()); ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(s_.routes[i].label.c_str(), s_.route_index == i, ImGuiSelectableFlags_AllowDoubleClick)) { + s_.route_index = i; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) accepted = true; + } + ImGui::PopID(); + } + ImGui::EndChild(); + + dialogButtons("OK", &accepted, &rejected); + MessageBox::draw(); + if (accepted || rejected || !open_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) finish(accepted); +} diff --git a/iqpilot/tools/cabana/ui/dialogs/routesdialog.h b/iqpilot/tools/cabana/ui/dialogs/routesdialog.h new file mode 100644 index 000000000..d7a3d341f --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/routesdialog.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/routes.h" +#include "tools/cabana/ui/util.h" + + +class RoutesDialog { +public: + void open(std::function on_done); + void draw(); + +private: + void setDeviceList(const std::vector &devices, bool success, int error_code); + void setRouteList(const std::vector &list, bool success); + void fetchRoutes(); + void finish(bool accepted); + + struct RouteItem { + std::string label; + std::string name; + }; + + struct State { + bool devices_loaded = false; + std::vector devices; + int device_index = 0; + int period_index = 0; + std::vector routes; + int route_index = -1; + std::string empty_text = "No items"; + int fetch_id = 0; + }; + + bool open_ = false; + PopupOwner popup_; + State s_; + std::function on_done_; + + std::shared_ptr alive_; +}; diff --git a/iqpilot/tools/cabana/ui/dialogs/settingsdialog.cc b/iqpilot/tools/cabana/ui/dialogs/settingsdialog.cc new file mode 100644 index 000000000..3b6597a6d --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/settingsdialog.cc @@ -0,0 +1,108 @@ +#include "tools/cabana/ui/dialogs/settingsdialog.h" + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int MIN_CACHE_MINUTES = 30; +const int MAX_CACHE_MINUTES = 120; + + +enum FormLabel { THEME, CACHED_MINUTES, DRAG_DIRECTION, CHART_HEIGHT, FORM_LABEL_COUNT }; +const char *FORM_LABELS[FORM_LABEL_COUNT] = {"Color Theme", "Max Cached Minutes", "Drag Direction", "Chart Height"}; + +float formLabelWidth() { + float w = 0.0f; + for (const char *label : FORM_LABELS) w = std::max(w, ImGui::CalcTextSize(label).x); + return w + ImGui::GetStyle().ItemSpacing.x * 2; +} + +void formRow(FormLabel label, float label_width) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(FORM_LABELS[label]); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); +} + +} + +void SettingsDialog::open() { + theme_ = settings.theme; + cached_minutes_ = settings.max_cached_minutes; + drag_direction_ = settings.drag_direction; + chart_height_ = settings.chart_height; + log_livestream_ = settings.log_livestream; + log_path_ = settings.log_path; + open_ = true; + popup_.reset(); +} + +void SettingsDialog::draw() { + if (!open_) return; + if (!beginDialog("Settings", &popup_, ImVec2(400.0f, 0.0f))) return; + const float label_width = formLabelWidth(); + + ImGui::SeparatorText("General"); + static const char *themes[] = {"Light", "Dark"}; + formRow(THEME, label_width); + int theme_index = theme_ - LIGHT_THEME; + if (ImGui::Combo("##theme", &theme_index, themes, IM_ARRAYSIZE(themes))) theme_ = theme_index + LIGHT_THEME; + formRow(CACHED_MINUTES, label_width); + + if (ImGui::InputInt("##cached_minutes", &cached_minutes_, 1, 10)) { + cached_minutes_ = std::clamp(cached_minutes_, MIN_CACHE_MINUTES, MAX_CACHE_MINUTES); + } + + ImGui::SeparatorText("New Signal Settings"); + static const char *directions[] = {"MSB First", "LSB First", "Always Little Endian", "Always Big Endian"}; + formRow(DRAG_DIRECTION, label_width); + ImGui::Combo("##drag_direction", &drag_direction_, directions, IM_ARRAYSIZE(directions)); + + ImGui::SeparatorText("Chart"); + formRow(CHART_HEIGHT, label_width); + if (ImGui::InputInt("##chart_height", &chart_height_, 10, 10)) chart_height_ = std::clamp(chart_height_, 100, 500); + + checkBox("Enable live stream logging", &log_livestream_); + ImGui::BeginDisabled(!log_livestream_); + ImGui::SetNextItemWidth(-90.0f); + inputText("##log_path", &log_path_, "", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getExistingDirectory("Log File Location", utils::homePath(), [this](const std::string &fn) { + if (!fn.empty()) log_path_ = fn; + }); + } + ImGui::EndDisabled(); + + ImGui::Separator(); + bool accepted = false, done = false; + dialogButtons("OK", &accepted, &done); + if (accepted) { + save(); + done = true; + } + FileDialog::draw(); + if (done) { + open_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); +} + +void SettingsDialog::save() { + if (std::exchange(settings.theme, theme_) != settings.theme) applyTheme(settings.theme); + settings.max_cached_minutes = cached_minutes_; + settings.chart_height = chart_height_; + settings.log_livestream = log_livestream_; + settings.log_path = log_path_; + settings.drag_direction = (Settings::DragDirection)drag_direction_; + settings.changed(); +} diff --git a/iqpilot/tools/cabana/ui/dialogs/settingsdialog.h b/iqpilot/tools/cabana/ui/dialogs/settingsdialog.h new file mode 100644 index 000000000..c9947a91a --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/settingsdialog.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "tools/cabana/core/settings.h" +#include "tools/cabana/ui/util.h" + +class SettingsDialog { +public: + void open(); + void draw(); + +private: + void save(); + + bool open_ = false; + PopupOwner popup_; + int theme_ = 0; + int cached_minutes_ = 0; + int drag_direction_ = 0; + int chart_height_ = 0; + bool log_livestream_ = false; + std::string log_path_; +}; diff --git a/iqpilot/tools/cabana/ui/dialogs/streamselector.cc b/iqpilot/tools/cabana/ui/dialogs/streamselector.cc new file mode 100644 index 000000000..4aa3c8960 --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/streamselector.cc @@ -0,0 +1,323 @@ +#include "tools/cabana/ui/dialogs/streamselector.h" + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/streams/devicestream.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +void OpenReplayWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Route"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-250.0f); + inputText("##route", &route_, "Enter route name or browse for local/remote route"); + ImGui::SameLine(); + if (ImGui::Button("Remote route...")) { + routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) { + if (accepted) route_ = route; + })); + } + ImGui::SameLine(); + if (ImGui::Button("Local route...")) { + FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) { + if (!dir.empty()) { + route_ = dir; + settings.last_route_dir = std::filesystem::absolute(dir).parent_path().string(); + } + })); + } + checkBox("Road camera", &cameras_[0]); + ImGui::SameLine(); + checkBox("Driver camera", &cameras_[1]); + ImGui::SameLine(); + checkBox("Wide road camera", &cameras_[2]); +} + +void OpenReplayWidget::drawPopups() { + routes_dialog_.draw(); +} + +std::unique_ptr OpenReplayWidget::open() { + std::string route = route_; + std::string data_dir; + if (auto idx = route.rfind('/'); idx != std::string::npos && util::file_exists(route)) { + data_dir = route.substr(0, idx + 1); + route = route.substr(idx + 1); + } + + bool is_valid_format = Route::parseRoute(route).str.size() > 0; + if (!is_valid_format) { + MessageBox::warning("Warning", "Invalid route format: '" + route + "'"); + } else { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + }); + uint32_t flags = REPLAY_FLAG_NONE; + if (cameras_[1]) flags |= REPLAY_FLAG_DCAM; + if (cameras_[2]) flags |= REPLAY_FLAG_ECAM; + if (flags == REPLAY_FLAG_NONE && !cameras_[0]) flags = REPLAY_FLAG_NO_VIPC; + + if (replay_stream->loadRoute(route, data_dir, flags)) { + return replay_stream; + } + } + return nullptr; +} + +namespace { +const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U}; +const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U}; +} + +OpenPandaWidget::OpenPandaWidget() { + if (can && dynamic_cast(can) != nullptr) { + already_connected_ = true; + return; + } + refreshSerials(); + buildConfigForm(); +} + +void OpenPandaWidget::refreshSerials() { + serials_ = Panda::list(); + serial_index_ = 0; +} + +void OpenPandaWidget::buildConfigForm() { + std::string serial = serial_index_ < static_cast(serials_.size()) ? serials_[serial_index_] : ""; + has_fd_ = false; + has_panda_ = !serial.empty(); + if (has_panda_) { + try { + Panda panda(serial); + has_fd_ = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); + } catch (const std::exception &e) { + fprintf(stderr, "failed to open panda %s\n", serial.c_str()); + has_panda_ = false; + } + } + + if (has_panda_) { + config.serial = serial; + config.bus_config.resize(3); + can_speed_index_.assign(3, 0); + data_speed_index_.assign(3, 0); + for (int i = 0; i < 3; i++) { + for (int j = 0; j < static_cast(std::size(speeds)); j++) { + if (speeds[j] == config.bus_config[i].can_speed_kbps) can_speed_index_[i] = j; + } + for (int j = 0; j < static_cast(std::size(data_speeds)); j++) { + if (data_speeds[j] == config.bus_config[i].data_speed_kbps) data_speed_index_[i] = j; + } + } + } else { + config.serial = ""; + } +} + +void OpenPandaWidget::draw() { + if (already_connected_) { + ImGui::Text("Already connected to %s.", can->routeName().c_str()); + ImGui::TextUnformatted("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."); + return; + } + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Serial"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-100.0f); + if (comboBox("##serial", &serial_index_, serials_)) buildConfigForm(); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) { + refreshSerials(); + buildConfigForm(); + } + + if (!has_panda_) { + ImGui::TextUnformatted("No panda found"); + return; + } + for (int i = 0; i < static_cast(config.bus_config.size()); i++) { + ImGui::PushID(i); + ImGui::AlignTextToFramePadding(); + ImGui::Text("Bus %d:", i); + ImGui::SameLine(); + ImGui::TextUnformatted("CAN Speed (kbps):"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##can_speed", &can_speed_index_[i], speeds, (int)std::size(speeds))) { + config.bus_config[i].can_speed_kbps = speeds[can_speed_index_[i]]; + } + if (has_fd_) { + ImGui::SameLine(); + checkBox("CAN-FD", &config.bus_config[i].can_fd); + ImGui::SameLine(); + ImGui::TextUnformatted("Data Speed (kbps):"); + ImGui::SameLine(); + ImGui::BeginDisabled(!config.bus_config[i].can_fd); + ImGui::SetNextItemWidth(90.0f); + if (comboBox("##data_speed", &data_speed_index_[i], data_speeds, (int)std::size(data_speeds))) { + config.bus_config[i].data_speed_kbps = data_speeds[data_speed_index_[i]]; + } + ImGui::EndDisabled(); + } + ImGui::PopID(); + } +} + +std::unique_ptr OpenPandaWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to panda: '") + e.what() + "'"); + return nullptr; + } +} + +void OpenDeviceWidget::draw() { + ImGui::RadioButton("MSGQ", &mode_, 0); + ImGui::RadioButton("ZMQ", &mode_, 1); + ImGui::RadioButton("Bridge", &mode_, 2); + + const float label_width = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x + + std::max(ImGui::CalcTextSize("MSGQ").x, ImGui::CalcTextSize("ZMQ").x) + + ImGui::GetStyle().ItemInnerSpacing.x; + ImGui::SameLine(label_width); + ImGui::BeginDisabled(mode_ == 0); + ImGui::SetNextItemWidth(-1.0f); + validatedText("##ip", &ip_address_, validateIpAddress, "Enter device Ip Address", ipValidator); + ImGui::EndDisabled(); +} + +std::unique_ptr OpenDeviceWidget::open() { + std::string ip = ip_address_.empty() ? "127.0.0.1" : ip_address_; + const DeviceStream::Mode modes[] = {DeviceStream::Mode::Msgq, DeviceStream::Mode::Zmq, DeviceStream::Mode::Bridge}; + return std::make_unique(modes[mode_], ip); +} + +#ifdef __linux__ + +OpenSocketCanWidget::OpenSocketCanWidget() { + refreshDevices(); +} + +void OpenSocketCanWidget::refreshDevices() { + devices_.clear(); + + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + devices_.push_back(entry.path().filename().string()); + } + } + device_index_ = 0; + config.device = devices_.empty() ? "" : devices_[0]; +} + +void OpenSocketCanWidget::draw() { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Device"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(300.0f); + if (comboBox("##device", &device_index_, devices_)) config.device = devices_[device_index_]; + ImGui::SameLine(); + if (ImGui::Button("Refresh", ImVec2(100.0f, 0.0f))) refreshDevices(); +} + +std::unique_ptr OpenSocketCanWidget::open() { + try { + return std::make_unique(config); + } catch (std::exception &e) { + MessageBox::warning("Warning", std::string("Failed to connect to SocketCAN device: '") + e.what() + "'"); + return nullptr; + } +} +#endif + +void StreamSelector::open(Callback on_done) { + on_done_ = std::move(on_done); + open_ = true; + popup_.reset(); + first_frame_ = true; + dbc_file_.clear(); + widgets_.clear(); + widgets_.push_back(std::make_unique()); + widgets_.push_back(std::make_unique()); +#ifdef __linux__ + if (SocketCanStream::available()) { + widgets_.push_back(std::make_unique()); + } +#endif + widgets_.push_back(std::make_unique()); +} + +void StreamSelector::draw() { + if (!open_) return; + if (!beginDialog("Open stream", &popup_, ImVec2(640.0f, 0.0f))) return; + + AbstractOpenStreamWidget *current = nullptr; + if (ImGui::BeginTabBar("streams")) { + for (auto &w : widgets_) { + + ImGuiTabItemFlags tab_flags = (first_frame_ && w == widgets_.front()) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(w->title(), nullptr, tab_flags)) { + current = w.get(); + ImGui::BeginChild("tab", ImVec2(0, 130.0f)); + w->draw(); + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } + first_frame_ = false; + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("dbc File"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-90.0f); + inputText("##dbc", &dbc_file_, "Choose a dbc file to open", ImGuiInputTextFlags_ReadOnly); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this](const std::string &fn) { + if (!fn.empty()) { + dbc_file_ = fn; + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); + } + }); + } + ImGui::Separator(); + + bool accepted = false, rejected = false; + std::unique_ptr stream; + bool open_clicked = false; + dialogButtons("Open", &open_clicked, &rejected, current != nullptr && current->openEnabled()); + if (open_clicked) { + if (stream = current->open(); stream) accepted = true; + } + + + if (current) current->drawPopups(); + FileDialog::draw(); + MessageBox::draw(); + + if (accepted || rejected) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + if (accepted || rejected) { + open_ = false; + widgets_.clear(); + auto on_done = std::move(on_done_); + if (on_done) on_done(std::move(stream), dbc_file_); + } +} diff --git a/iqpilot/tools/cabana/ui/dialogs/streamselector.h b/iqpilot/tools/cabana/ui/dialogs/streamselector.h new file mode 100644 index 000000000..c7a043e87 --- /dev/null +++ b/iqpilot/tools/cabana/ui/dialogs/streamselector.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/streams/pandastream.h" +#ifdef __linux__ +#include "tools/cabana/streams/socketcanstream.h" +#endif +#include "tools/cabana/ui/dialogs/routesdialog.h" +#include "tools/cabana/ui/util.h" + +class AbstractOpenStreamWidget { +public: + virtual ~AbstractOpenStreamWidget() = default; + virtual const char *title() const = 0; + virtual void draw() = 0; + + + virtual void drawPopups() {} + virtual std::unique_ptr open() = 0; + virtual bool openEnabled() const { return true; } +}; + +class OpenReplayWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Replay"; } + void draw() override; + void drawPopups() override; + std::unique_ptr open() override; + +private: + std::string route_; + bool cameras_[3] = {true, false, false}; + RoutesDialog routes_dialog_; + + std::shared_ptr alive_ = std::make_shared(true); +}; + +class OpenPandaWidget : public AbstractOpenStreamWidget { +public: + OpenPandaWidget(); + const char *title() const override { return "Panda"; } + void draw() override; + std::unique_ptr open() override; + bool openEnabled() const override { return !already_connected_; } + +private: + void refreshSerials(); + void buildConfigForm(); + + bool already_connected_ = false; + std::vector serials_; + int serial_index_ = 0; + bool has_panda_ = false; + bool has_fd_ = false; + std::vector can_speed_index_, data_speed_index_; + PandaStreamConfig config = {}; +}; + +class OpenDeviceWidget : public AbstractOpenStreamWidget { +public: + const char *title() const override { return "Device"; } + void draw() override; + std::unique_ptr open() override; + +private: + int mode_ = 1; + std::string ip_address_; +}; + +#ifdef __linux__ +class OpenSocketCanWidget : public AbstractOpenStreamWidget { +public: + OpenSocketCanWidget(); + const char *title() const override { return "SocketCAN"; } + void draw() override; + std::unique_ptr open() override; + +private: + void refreshDevices(); + + std::vector devices_; + int device_index_ = 0; + SocketCanStreamConfig config = {}; +}; +#endif + +class StreamSelector { +public: + using Callback = std::function stream, const std::string &dbc_file)>; + + void open(Callback on_done); + void draw(); + +private: + bool open_ = false; + PopupOwner popup_; + bool first_frame_ = false; + std::string dbc_file_; + std::vector> widgets_; + Callback on_done_; +}; diff --git a/iqpilot/tools/cabana/ui/helpoverlay.cc b/iqpilot/tools/cabana/ui/helpoverlay.cc new file mode 100644 index 000000000..339952990 --- /dev/null +++ b/iqpilot/tools/cabana/ui/helpoverlay.cc @@ -0,0 +1,196 @@ +#include "tools/cabana/ui/helpoverlay.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/util.h" + +namespace { +struct HelpRun { + std::string text; + bool bold = false; + bool chip = false; + bool swatch = false; + ImU32 color = 0; +}; + +ImU32 helpColor(const std::string &name) { + if (name == "gray") return IM_COL32(128, 128, 128, 255); + if (name == "blue") return IM_COL32(0, 0, 255, 255); + if (name == "red") return IM_COL32(255, 0, 0, 255); + unsigned rgb = 0; + if (name.size() == 7 && name[0] == '#' && sscanf(name.c_str() + 1, "%6x", &rgb) == 1) { + return IM_COL32((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255); + } + return 0; +} + +std::vector> parseHelpHtml(const std::string &raw) { + std::vector> lines(1); + HelpRun style; + std::vector span_stack; + bool prev_space = true; + std::string pending; + auto flush = [&]() { + if (!pending.empty()) { + HelpRun run = style; + run.text = pending; + lines.back().push_back(run); + pending.clear(); + } + }; + auto push_swatch = [&](ImU32 color) { + flush(); + HelpRun run = style; + run.swatch = true; + if (color) run.color = color; + lines.back().push_back(run); + prev_space = false; + }; + for (size_t i = 0; i < raw.size(); ++i) { + const char c = raw[i]; + if (c == '<') { + const size_t close = raw.find('>', i); + if (close == std::string::npos) break; + const std::string tag = raw.substr(i + 1, close - i - 1); + i = close; + if (tag.compare(0, 3, "!--") == 0) continue; + flush(); + if (tag == "b") { + style.bold = true; + } else if (tag == "/b") { + style.bold = false; + } else if (tag.compare(0, 2, "br") == 0) { + lines.emplace_back(); + prev_space = true; + } else if (tag.compare(0, 4, "span") == 0) { + span_stack.push_back(style); + const size_t st = tag.find("style=\""); + if (st != std::string::npos) { + const std::string css = tag.substr(st + 7, tag.find('"', st + 7) - st - 7); + size_t pos = 0; + while (pos < css.size()) { + const size_t semi = css.find(';', pos); + const std::string decl = css.substr(pos, semi == std::string::npos ? std::string::npos : semi - pos); + const size_t colon = decl.find(':'); + if (colon != std::string::npos) { + const std::string key = decl.substr(0, colon), value = decl.substr(colon + 1); + if (key == "color") style.color = helpColor(value); + if (key == "background-color") style.chip = true; + } + if (semi == std::string::npos) break; + pos = semi + 1; + } + } + } else if (tag == "/span") { + if (!span_stack.empty()) { + style = span_stack.back(); + span_stack.pop_back(); + } + } + } else if (c == '&') { + static const std::pair entities[] = {{" ", " "}}; + bool matched = false; + for (const auto &[name, text] : entities) { + if (raw.compare(i, strlen(name), name) == 0) { + pending += text; + prev_space = false; + i += strlen(name) - 1; + matched = true; + break; + } + } + if (!matched && raw.compare(i, 7, "■") == 0) { + push_swatch(0); + i += 6; + matched = true; + } + if (!matched) { + pending += c; + prev_space = false; + } + } else if (isspace(static_cast(c))) { + if (!prev_space) pending += ' '; + prev_space = true; + } else if (c == '#' && i + 6 < raw.size() && helpColor(raw.substr(i, 7)) != 0) { + push_swatch(helpColor(raw.substr(i, 7))); + i += 6; + } else { + pending += c; + prev_space = false; + } + } + flush(); + for (auto &line : lines) { + if (!line.empty() && !line.back().text.empty() && line.back().text.back() == ' ') line.back().text.pop_back(); + if (!line.empty() && !line.front().text.empty() && line.front().text.front() == ' ') line.front().text.erase(0, 1); + } + while (!lines.empty() && lines.back().empty()) lines.pop_back(); + return lines; +} +} + +void HelpOverlay::toggle() { + visible_ = !visible_; + opened_frame_ = ImGui::GetFrameCount(); +} + +void HelpOverlay::add(const std::string &text, const ImRect &rect) { + if (visible_) texts_.emplace_back(text, rect); +} + +void HelpOverlay::draw() { + if (!visible_) return; + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImDrawList *dl = ImGui::GetForegroundDrawList(); + const ImRect work_rect(viewport->WorkPos, ImVec2(viewport->WorkPos.x + viewport->WorkSize.x, viewport->WorkPos.y + viewport->WorkSize.y)); + dl->AddRectFilled(viewport->Pos, ImVec2(viewport->Pos.x + viewport->Size.x, viewport->Pos.y + viewport->Size.y), IM_COL32(0, 0, 0, 50)); + ImFont *font = ImGui::GetFont(); + ImFont *bold_font = boldFont() ? boldFont() : font; + const float font_size = ImGui::GetFontSize(); + const float line_h = ImGui::GetTextLineHeightWithSpacing(); + auto run_width = [&](const HelpRun &r) { + if (r.swatch) return font_size; + return (r.bold ? bold_font : font)->CalcTextSizeA(font_size, FLT_MAX, 0.0f, r.text.c_str()).x; + }; + for (const auto &[raw, rect] : texts_) { + if (raw.empty()) continue; + const auto lines = parseHelpHtml(raw); + float width = 0; + for (const auto &line : lines) { + float w = 0; + for (const auto &r : line) w += run_width(r); + width = std::max(width, w); + } + const ImVec2 size(width, lines.size() * line_h); + const ImVec2 center((rect.Min.x + rect.Max.x) * 0.5f, (rect.Min.y + rect.Max.y) * 0.5f); + if (!work_rect.Contains(center)) continue; + const ImVec2 min(center.x - size.x * 0.5f - 8.0f, center.y - size.y * 0.5f - 8.0f); + const ImVec2 max(center.x + size.x * 0.5f + 8.0f, center.y + size.y * 0.5f + 8.0f); + + const ImU32 tooltip_base = isDarkTheme() ? ImGui::GetColorU32(ImGuiCol_PopupBg) : IM_COL32(255, 255, 220, 255); + dl->AddRectFilled(min, max, tooltip_base); + float y = min.y + 8.0f; + for (const auto &line : lines) { + float x = min.x + 8.0f; + for (const auto &r : line) { + const float w = run_width(r); + const ImU32 color = r.color ? r.color : ImGui::GetColorU32(ImGuiCol_Text); + if (r.swatch) { + dl->AddRectFilled(ImVec2(x + 2, y + 3), ImVec2(x + font_size - 2, y + font_size - 1), color); + } else { + if (r.chip) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + font_size), IM_COL32(211, 211, 211, 255)); + dl->AddText(r.bold ? bold_font : font, font_size, ImVec2(x, y), color, r.text.c_str()); + } + x += w; + } + y += line_h; + } + } + texts_.clear(); + + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && ImGui::GetFrameCount() != opened_frame_) visible_ = false; +} diff --git a/iqpilot/tools/cabana/ui/helpoverlay.h b/iqpilot/tools/cabana/ui/helpoverlay.h new file mode 100644 index 000000000..6f7f02935 --- /dev/null +++ b/iqpilot/tools/cabana/ui/helpoverlay.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "imgui_internal.h" + + + + +class HelpOverlay { +public: + void toggle(); + bool visible() const { return visible_; } + + void add(const std::string &text, const ImRect &rect); + void draw(); + +private: + std::vector> texts_; + bool visible_ = false; + int opened_frame_ = -1; +}; diff --git a/iqpilot/tools/cabana/ui/icons.h b/iqpilot/tools/cabana/ui/icons.h new file mode 100644 index 000000000..369efcb24 --- /dev/null +++ b/iqpilot/tools/cabana/ui/icons.h @@ -0,0 +1,40 @@ +#pragma once + + +namespace icon { +constexpr const char ARROW_CLOCKWISE[] = "\xef\x84\x96"; +constexpr const char ARROW_COUNTERCLOCKWISE[] = "\xef\x84\x97"; +constexpr const char ARROW_DOWN_LEFT_SQUARE[] = "\xef\x84\x9d"; +constexpr const char ARROW_UP_RIGHT_SQUARE[] = "\xef\x85\x83"; +constexpr const char CHEVRON_LEFT[] = "\xef\x8a\x84"; +constexpr const char CHEVRON_RIGHT[] = "\xef\x8a\x85"; +constexpr const char DASH[] = "\xef\x8b\xaa"; +constexpr const char DASH_SQUARE[] = "\xef\x8b\xa9"; +constexpr const char EXCLAMATION_TRIANGLE[] = "\xef\x8c\xbb"; +constexpr const char FAST_FORWARD[] = "\xef\x9f\xb4"; +constexpr const char FILETYPE_CSV[] = "\xef\x9d\x83"; +constexpr const char FOLDER[] = "\xef\x8f\x99"; +constexpr const char FILE_EARMARK[] = "\xef\x8e\x92"; +constexpr const char FILE_EARMARK_RULED[] = "\xef\x8e\x85"; +constexpr const char PLUS_SQUARE[] = "\xef\x93\xbd"; +constexpr const char GRAPH_UP[] = "\xef\x8f\xb2"; +constexpr const char GRIP_HORIZONTAL[] = "\xef\x8f\xbd"; +constexpr const char INFO_CIRCLE[] = "\xef\x90\xb1"; +constexpr const char LIST[] = "\xef\x91\xb9"; +constexpr const char PAUSE[] = "\xef\x93\x84"; +constexpr const char PENCIL[] = "\xef\x93\x8b"; +constexpr const char PLAY[] = "\xef\x93\xb5"; +constexpr const char PLUS[] = "\xef\x93\xbe"; +constexpr const char RAQUO[] = "\xc2\xbb"; +constexpr const char REPEAT[] = "\xef\xa0\x93"; +constexpr const char REPEAT_1[] = "\xef\xa0\x92"; +constexpr const char REWIND[] = "\xef\xa0\x99"; +constexpr const char SKIP_END[] = "\xef\x95\x98"; +constexpr const char STOPWATCH[] = "\xef\x96\x97"; +constexpr const char THREE_DOTS[] = "\xef\x97\x94"; +constexpr const char WINDOW_STACK[] = "\xef\x9b\x92"; +constexpr const char X[] = "\xef\x98\xaa"; +constexpr const char X_LG[] = "\xef\x99\x99"; +constexpr const char X_SQUARE[] = "\xef\x98\xa9"; +constexpr const char ZOOM_OUT[] = "\xef\x98\xad"; +} diff --git a/iqpilot/tools/cabana/ui/inistate.cc b/iqpilot/tools/cabana/ui/inistate.cc new file mode 100644 index 000000000..379475da9 --- /dev/null +++ b/iqpilot/tools/cabana/ui/inistate.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/inistate.h" + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" + +namespace inistate { + +MainWindowState main_window; + +namespace { + +void *readOpen(ImGuiContext *, ImGuiSettingsHandler *, const char *name) { + return strcmp(name, "MainWindow") == 0 ? (void *)&main_window : nullptr; +} + +void readLine(ImGuiContext *, ImGuiSettingsHandler *, void *entry, const char *line) { + auto *state = (MainWindowState *)entry; + int x = 0, y = 0, flag = 0; + float ratio = 0.0f; + if (sscanf(line, "Pos=%d,%d", &x, &y) == 2) { + state->pos[0] = x; + state->pos[1] = y; + } else if (sscanf(line, "Size=%d,%d", &x, &y) == 2) { + state->size[0] = x; + state->size[1] = y; + state->has_geometry = true; + } else if (sscanf(line, "Maximized=%d", &flag) == 1) { + state->maximized = flag != 0; + } else if (sscanf(line, "VideoSplitterRatio=%f", &ratio) == 1) { + state->video_splitter_ratio = ratio; + } else if (sscanf(line, "MessagesVisible=%d", &flag) == 1) { + state->messages_visible = flag != 0; + } else if (sscanf(line, "VideoVisible=%d", &flag) == 1) { + state->video_visible = flag != 0; + } +} + +void writeAll(ImGuiContext *, ImGuiSettingsHandler *handler, ImGuiTextBuffer *buf) { + buf->appendf("[%s][MainWindow]\n", handler->TypeName); + if (main_window.has_geometry) { + buf->appendf("Pos=%d,%d\n", main_window.pos[0], main_window.pos[1]); + buf->appendf("Size=%d,%d\n", main_window.size[0], main_window.size[1]); + } + buf->appendf("Maximized=%d\n", main_window.maximized ? 1 : 0); + buf->appendf("VideoSplitterRatio=%.4f\n", main_window.video_splitter_ratio); + buf->appendf("MessagesVisible=%d\n", main_window.messages_visible ? 1 : 0); + buf->appendf("VideoVisible=%d\n", main_window.video_visible ? 1 : 0); + buf->append("\n"); +} + +} + +void addSettingsHandler() { + ImGuiSettingsHandler handler; + handler.TypeName = "Cabana"; + handler.TypeHash = ImHashStr("Cabana"); + handler.ReadOpenFn = readOpen; + handler.ReadLineFn = readLine; + handler.WriteAllFn = writeAll; + ImGui::AddSettingsHandler(&handler); +} + +void load() { + if (!settings.ui_state.empty()) + ImGui::LoadIniSettingsFromMemory(settings.ui_state.data(), settings.ui_state.size()); +} + +void applyWindowGeometry(GLFWwindow *window) { + if (main_window.has_geometry && main_window.size[0] > 0 && main_window.size[1] > 0) { + glfwSetWindowPos(window, main_window.pos[0], main_window.pos[1]); + glfwSetWindowSize(window, main_window.size[0], main_window.size[1]); + } + if (main_window.maximized) glfwMaximizeWindow(window); +} + +std::string save() { + return std::string(ImGui::SaveIniSettingsToMemory()); +} + +} diff --git a/iqpilot/tools/cabana/ui/inistate.h b/iqpilot/tools/cabana/ui/inistate.h new file mode 100644 index 000000000..8756b2fe7 --- /dev/null +++ b/iqpilot/tools/cabana/ui/inistate.h @@ -0,0 +1,25 @@ +#pragma once +#include + +struct GLFWwindow; + +namespace inistate { + +struct MainWindowState { + int pos[2] = {0, 0}; + int size[2] = {0, 0}; + bool maximized = false; + bool has_geometry = false; + float video_splitter_ratio = -1.0f; + bool messages_visible = true; + bool video_visible = true; +}; + +extern MainWindowState main_window; + +void addSettingsHandler(); +void load(); +void applyWindowGeometry(GLFWwindow *window); +std::string save(); + +} diff --git a/iqpilot/tools/cabana/cabana.cc b/iqpilot/tools/cabana/ui/main.cc similarity index 58% rename from iqpilot/tools/cabana/cabana.cc rename to iqpilot/tools/cabana/ui/main.cc index 28cca75a0..02284a853 100644 --- a/iqpilot/tools/cabana/cabana.cc +++ b/iqpilot/tools/cabana/ui/main.cc @@ -1,17 +1,23 @@ #include #include +#include #include +#include #include -#include - -#include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" #ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" #endif +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/utils/util.h" + +#ifdef __GLIBC__ +#include +#endif namespace { @@ -19,11 +25,12 @@ struct CabanaArgs { bool demo = false; bool auto_source = false; bool qcam = false; - bool ecam = false; - bool dcam = false; + bool wide_road = false; + bool cabin = false; bool msgq = false; bool panda = false; bool no_vipc = false; + bool no_cache = false; std::string panda_serial; std::string socketcan; std::string zmq; @@ -37,7 +44,7 @@ void printUsage(const char *argv0) { fprintf(stderr, "Usage: %s [options] [route]\n" "\n" - " route the drive to replay. find your drives at konn3kt.com\n" + " route local path or Konn3kt route to replay\n" "\n" "Options:\n" " --help show this help\n" @@ -45,25 +52,23 @@ void printUsage(const char *argv0) { " --auto Auto load the route from the best available source (no video):\n" " internal, openpilotci, comma_api, car_segments, testing_closet\n" " --qcam load qcamera\n" - " --ecam load wide road camera\n" - " --dcam load driver camera\n" + " --wide-road load wide road camera (alias: --ecam)\n" + " --cabin load cabin camera (alias: --dcam)\n" " --msgq read can messages from the msgq\n" " --panda read can messages from panda\n" " --panda-serial read can messages from panda with given serial\n" #ifdef __linux__ " --socketcan read can messages from given SocketCAN device\n" #endif - " --zmq subscribe to a zmq 'can' publisher at the specified ip-address\n" - " (a device running cereal/messaging/bridge, or\n" - " tools/cabana/konn3kt_canproxy.py on 127.0.0.1)\n" - " --bridge run cereal/messaging/bridge locally against the device\n" + " --zmq read can messages from zmq at the specified ip-address\n" + " --bridge bridge remote ZMQ into local msgq\n" " --data_dir local directory with routes\n" " --no-vipc do not output video\n" + " --no-cache turn off the local route file cache\n" " --dbc dbc file to open\n", argv0); } -// Returns true if value was consumed from argv[i+1]. bool takeValue(int argc, char *argv[], int &i, std::string &out) { if (i + 1 >= argc) { fprintf(stderr, "error: %s requires a value\n", argv[i]); @@ -73,9 +78,7 @@ bool takeValue(int argc, char *argv[], int &i, std::string &out) { return true; } -// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). -int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { - ok = false; +std::optional parseArgs(int argc, char *argv[], CabanaArgs &args) { for (int i = 1; i < argc; ++i) { const char *a = argv[i]; if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { @@ -87,10 +90,10 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { args.auto_source = true; } else if (std::strcmp(a, "--qcam") == 0) { args.qcam = true; - } else if (std::strcmp(a, "--ecam") == 0) { - args.ecam = true; - } else if (std::strcmp(a, "--dcam") == 0) { - args.dcam = true; + } else if (std::strcmp(a, "--wide-road") == 0 || std::strcmp(a, "--ecam") == 0) { + args.wide_road = true; + } else if (std::strcmp(a, "--cabin") == 0 || std::strcmp(a, "--dcam") == 0) { + args.cabin = true; } else if (std::strcmp(a, "--msgq") == 0) { args.msgq = true; } else if (std::strcmp(a, "--panda") == 0) { @@ -100,8 +103,7 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { args.panda = true; } else if (std::strcmp(a, "--socketcan") == 0) { if (!takeValue(argc, argv, i, args.socketcan)) return 1; -#ifdef __linux__ -#else +#ifndef __linux__ fprintf(stderr, "error: --socketcan is only supported on Linux\n"); return 1; #endif @@ -113,6 +115,8 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { if (!takeValue(argc, argv, i, args.data_dir)) return 1; } else if (std::strcmp(a, "--no-vipc") == 0) { args.no_vipc = true; + } else if (std::strcmp(a, "--no-cache") == 0) { + args.no_cache = true; } else if (std::strcmp(a, "--dbc") == 0) { if (!takeValue(argc, argv, i, args.dbc)) return 1; } else if (a[0] == '-') { @@ -127,69 +131,76 @@ int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { return 1; } } - ok = true; - return 0; + return std::nullopt; } -} // namespace +} int main(int argc, char *argv[]) { - QCoreApplication::setApplicationName("Cabana"); - initApp(argc, argv, false); - QApplication app(argc, argv); - app.setApplicationDisplayName("Cabana"); - //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui +#ifdef __GLIBC__ - UnixSignalHandler signalHandler; - utils::setTheme(settings.theme); + + mallopt(M_ARENA_MAX, 1); +#endif + + std::error_code ec; + std::filesystem::current_path(executableDir(), ec); CabanaArgs args; - bool args_ok = false; - if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { - return code; - } + if (auto code = parseArgs(argc, argv, args)) return *code; - AbstractStream *stream = nullptr; + std::unique_ptr stream; + StreamLoader stream_loader; if (args.msgq) { - stream = new DeviceStream(&app, DeviceStream::Mode::Msgq); + stream = std::make_unique(); } else if (!args.zmq.empty()) { - stream = new DeviceStream(&app, DeviceStream::Mode::Zmq, QString::fromStdString(args.zmq)); + stream = std::make_unique(DeviceStream::Mode::Zmq, args.zmq); } else if (!args.bridge.empty()) { - stream = new DeviceStream(&app, DeviceStream::Mode::Bridge, QString::fromStdString(args.bridge)); - } else if (args.panda || !args.panda_serial.empty()) { + stream = std::make_unique(DeviceStream::Mode::Bridge, args.bridge); + } else if (args.panda) { try { - stream = new PandaStream(&app, {.serial = args.panda_serial}); + stream = std::make_unique(PandaStreamConfig{.serial = args.panda_serial}); } catch (std::exception &e) { fprintf(stderr, "%s\n", e.what()); - return 0; + return 1; } #ifdef __linux__ - } else if (SocketCanStream::available() && !args.socketcan.empty()) { - stream = new SocketCanStream(&app, {.device = args.socketcan}); + } else if (!args.socketcan.empty()) { + if (!SocketCanStream::available()) { + fprintf(stderr, "error: SocketCAN is not available on this system\n"); + return 1; + } + stream = std::make_unique(SocketCanStreamConfig{.device = args.socketcan}); #endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; - if (args.ecam) replay_flags |= REPLAY_FLAG_ECAM; + if (args.wide_road) replay_flags |= REPLAY_FLAG_ECAM; if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; - if (args.dcam) replay_flags |= REPLAY_FLAG_DCAM; + if (args.cabin) replay_flags |= REPLAY_FLAG_DCAM; if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.no_cache) replay_flags |= REPLAY_FLAG_NO_FILE_CACHE; - QString route; + std::string route; if (!args.route.empty()) { - route = QString::fromStdString(args.route); + route = args.route; } else if (args.demo) { route = DEMO_ROUTE; } - if (!route.isEmpty()) { - auto replay_stream = std::make_unique(&app); - if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { - return 0; - } - stream = replay_stream.release(); + if (!route.empty()) { + stream_loader = [route, data_dir = args.data_dir, replay_flags, auto_source = args.auto_source]() -> std::unique_ptr { + auto replay_stream = std::make_unique(); + Connection err = replay_stream->error.connect([](const std::string &msg) { + fprintf(stderr, "%s\n", msg.c_str()); + utils::runOnMainThread([msg]() { MessageBox::warning("Error", msg); }); + }); + if (!replay_stream->loadRoute(route, data_dir, replay_flags, auto_source)) { + return nullptr; + } + return replay_stream; + }; } } - MainWindow w(stream, QString::fromStdString(args.dbc)); - return app.exec(); + return run(std::move(stream), std::move(stream_loader), args.dbc); } diff --git a/iqpilot/tools/cabana/ui/mainwin.cc b/iqpilot/tools/cabana/ui/mainwin.cc new file mode 100644 index 000000000..ceb3aaaa3 --- /dev/null +++ b/iqpilot/tools/cabana/ui/mainwin.cc @@ -0,0 +1,929 @@ +#include "tools/cabana/ui/mainwin.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include + +#include "json11/json11.hpp" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/inistate.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/tools/findsignal.h" +#include "tools/cabana/ui/tools/findsimilarbits.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/util.h" +#include "tools/replay/util.h" + +namespace { + +constexpr const char *VIDEO_PANEL = "###VideoPanel"; +constexpr const char *CENTER_PANEL = "###CenterWidget"; +constexpr const char *CHARTS_WINDOW = "Charts###ChartsWindow"; +} + +MainWindow::MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, + const std::string &dbc_file) : window_(window) { + can = &dummy_; + video_splitter_ratio_ = inistate::main_window.video_splitter_ratio; + messages_visible_ = inistate::main_window.messages_visible; + video_visible_ = inistate::main_window.video_visible; + loadFingerprints(); + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + opendbc_names_.push_back(entry.path().filename().string()); + } + } + std::sort(opendbc_names_.begin(), opendbc_names_.end()); + + + installDownloadProgressHandler([this](uint64_t cur, uint64_t total, bool success) { + utils::runOnMainThread([this, cur, total, success]() { updateDownloadProgress(cur, total, success); }); + }); + installMessageHandler([this](ReplyMsgType type, const std::string &msg) { + fprintf(stderr, "%s\n", msg.c_str()); + utils::runOnMainThread([this, msg]() { showStatusMessage(msg, 2000); }); + }); + + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcFileChanged(); })); + connections_.push_back(UndoStack::instance()->cleanChanged.connect([this](bool clean) { + window_modified_ = !clean; + updateWindowTitle(); + })); + + startup_stream_ = std::move(stream); + startup_loader_ = std::move(stream_loader); + nextFrame([this, dbc_file]() { + if (startup_loader_) { + loadStartupStream(dbc_file); + } else { + startup_stream_ ? openStream(std::move(startup_stream_), dbc_file) : selectAndOpenStream(); + } + }); +} + +void MainWindow::loadFingerprints() { + std::ifstream json_file((executableDir() / "dbc/car_fingerprint_to_dbc.json")); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; + std::string err; + auto doc = json11::Json::parse(contents, err); + if (!err.empty() || !doc.is_object()) return; + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc_.emplace(kv.first, kv.second.string_value()); + } + } +} + +void MainWindow::drawFileMenu() { + const bool has_stream = hasStream(); + if (ImGui::MenuItem("Open Stream...")) selectAndOpenStream(); + if (ImGui::MenuItem("Close stream", nullptr, false, has_stream)) closeStream(); + if (ImGui::MenuItem("Export to CSV...", nullptr, false, has_stream)) exportToCSV(); + ImGui::Separator(); + + if (ImGui::MenuItem("New DBC File", "Ctrl+N")) newFile(); + if (ImGui::MenuItem("Open DBC File...", "Ctrl+O")) openFile(); + + if (ImGui::BeginMenu("Manage DBC Files", has_stream)) { + drawManageDBCsMenu(); + ImGui::EndMenu(); + } + if (ImGui::BeginMenu("Open Recent")) { + drawRecentFilesMenu(); + ImGui::EndMenu(); + } + + ImGui::Separator(); + if (ImGui::BeginMenu("Load DBC from IQ.Pilot iqdbc")) { + for (const auto &name : opendbc_names_) { + if (ImGui::MenuItem(name.c_str())) loadDBCFromOpendbc(name); + } + ImGui::EndMenu(); + } + if (ImGui::MenuItem("Load DBC From Clipboard")) loadFromClipboard(); + + ImGui::Separator(); + const int cnt = dbc()->nonEmptyDBCCount(); + const std::string save_text = cnt > 1 ? "Save " + std::to_string(cnt) + " DBCs..." : "Save DBC..."; + if (ImGui::MenuItem(save_text.c_str(), "Ctrl+S", false, cnt > 0)) save(); + if (ImGui::MenuItem("Save DBC As...", "Ctrl+Shift+S", false, cnt == 1)) saveAs(); + + if (ImGui::MenuItem("Copy DBC To Clipboard", nullptr, false, cnt == 1)) saveToClipboard(); + + ImGui::Separator(); + if (ImGui::MenuItem("Settings...")) openSettings(); + + ImGui::Separator(); + if (ImGui::MenuItem("Exit", "Ctrl+Q")) close(); +} + +void MainWindow::drawMenuBar() { + if (!ImGui::BeginMainMenuBar()) return; + if (ImGui::BeginMenu("File")) { + drawFileMenu(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Edit")) { + auto stack = UndoStack::instance(); + const std::string undo_text = stack->canUndo() ? "Undo " + stack->undoText() : "Undo"; + const std::string redo_text = stack->canRedo() ? "Redo " + stack->redoText() : "Redo"; + if (ImGui::MenuItem(undo_text.c_str(), "Ctrl+Z", false, stack->canUndo())) stack->undo(); + if (ImGui::MenuItem(redo_text.c_str(), "Ctrl+Shift+Z", false, stack->canRedo())) stack->redo(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + if (ImGui::MenuItem("Full Screen", "Ctrl+F11")) toggleFullScreen(); + ImGui::Separator(); + ImGui::MenuItem(messages_widget_ ? messages_widget_->title().c_str() : "MESSAGES", nullptr, &messages_visible_); + ImGui::MenuItem(video_dock_title_.empty() ? "##video_dock" : video_dock_title_.c_str(), nullptr, &video_visible_); + ImGui::Separator(); + if (ImGui::MenuItem("Reset Window Layout")) { + messages_visible_ = video_visible_ = true; + reset_layout_ = true; + } + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Tools", hasStream())) { + if (ImGui::MenuItem("Find Similar Bits")) findSimilarBits(); + if (ImGui::MenuItem("Find Signal")) findSignal(); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("Help", "F1")) toggleHelp(); + ImGui::EndMenu(); + } + ImGui::EndMainMenuBar(); +} + +void MainWindow::createDockWidgets() { + widget_connections_.clear(); + messages_widget_ = std::make_unique(); + widget_connections_.push_back(messages_widget_->msgSelectionChanged.connect([this](const MessageId &id) { center_widget_.setMessage(id); })); + + charts_widget_ = std::make_unique(); + center_widget_.setChartsWidget(charts_widget_.get()); + video_widget_ = std::make_unique(); + widget_connections_.push_back(charts_widget_->toggleChartsDocking.connect([this]() { toggleChartsDocking(); })); + widget_connections_.push_back(charts_widget_->showTip.connect([this](double sec) { video_widget_->showThumbnail(sec); })); +} + +void MainWindow::showStatusMessage(const std::string &msg, int timeout_ms) { + status_bar_.message = msg; + status_bar_.message_until = timeout_ms > 0 ? ImGui::GetTime() + timeout_ms / 1000.0 : 0; +} + +void MainWindow::updateWindowTitle() { + std::string title; + for (auto f : dbc()->allDBCFiles()) { + if (!title.empty()) title += " | "; + title += "(" + toString(dbc()->sources(f)) + ") " + f->name(); + } + if (window_modified_) title += "*"; + if (!title.empty()) title += " \xe2\x80\x94 "; + title += "Cabana"; + glfwSetWindowTitle(window_, title.c_str()); +} + +void MainWindow::dbcFileChanged() { + UndoStack::instance()->clear(); + updateWindowTitle(); + nextFrame([this]() { restoreSessionState(); }); +} + +void MainWindow::selectAndOpenStream() { + stream_selector_.open([this](std::unique_ptr stream, const std::string &dbc_file) { + if (stream) { + openStream(std::move(stream), dbc_file); + } else if (!stream_) { + openStream(std::make_unique()); + } + }); +} + + +void MainWindow::loadStartupStream(const std::string &dbc_file) { + wait_dlg_.text = "Loading route..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; + ThreadPool::instance().run([this, dbc_file, loader = std::move(startup_loader_)]() { + AbstractStream *loaded = nullptr; + std::string error; + try { + loaded = loader().release(); + } catch (const std::exception &e) { + + error = e.what(); + } + utils::runOnMainThread([this, dbc_file, loaded, error]() { + wait_dlg_.open = false; + std::unique_ptr stream(loaded); + if (!error.empty()) { + fprintf(stderr, "%s\n", error.c_str()); + MessageBox::warning("Failed to load route", error); + } + stream ? openStream(std::move(stream), dbc_file) : openStream(std::make_unique()); + }); + }); +} + +void MainWindow::closeStream() { + openStream(std::make_unique()); + if (dbc()->nonEmptyDBCCount() > 0) { + dbc()->fileChanged(); + } + showStatusMessage("stream closed"); +} + +void MainWindow::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + ".csv"; + FileDialog::getSaveFileName("Export stream to CSV file", dir, ".csv", [](const std::string &fn) { + if (!fn.empty()) { + utils::exportToCSV(fn); + } + }); +} + +void MainWindow::newFile(SourceSet s) { + closeFile(s, [s]() { dbc()->open(s, std::string(""), std::string("")); }); +} + +void MainWindow::openFile(SourceSet s) { + remindSaveChanges([this, s]() { + FileDialog::getOpenFileName("Open File", settings.last_dir, ".dbc", [this, s](const std::string &fn) { + if (!fn.empty()) { + loadFile(fn, s); + } + }); + }); +} + +void MainWindow::loadFile(const std::string &fn, SourceSet s, std::function then) { + if (!fn.empty()) { + closeFile(s, [this, fn, s, then]() { + std::string error; + if (dbc()->open(s, fn, &error)) { + updateRecentFiles(fn); + showStatusMessage("DBC File " + fn + " loaded", 2000); + if (then) then(); + } else { + MessageBox::warning("Failed to load DBC file", "Failed to parse DBC file " + fn, error, then); + } + }); + } else if (then) { + then(); + } +} + +void MainWindow::loadDBCFromOpendbc(const std::string &name) { + loadFile(std::string(OPENDBC_FILE_PATH) + "/" + name); +} + +void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + MessageBox::warning("Load From Clipboard", "No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland)."); + return; + } + if (text.empty()) { + MessageBox::warning("Load From Clipboard", "Clipboard is empty."); + return; + } + + closeFile(s, [s, text]() { + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); + if (ret && dbc()->nonEmptyDBCCount() > 0) { + MessageBox::information("Load From Clipboard", "DBC Successfully Loaded!"); + } else { + MessageBox::warning("Failed to load DBC from clipboard", "Make sure that you paste the text with correct format.", error); + } + }); +} + +MainWindow::~MainWindow() { + installDownloadProgressHandler(nullptr); + installMessageHandler(nullptr); + releaseStream(); + can = nullptr; +} + + + + +void MainWindow::releaseStream() { + tool_dialogs_.clear(); + wait_dlg_.connection.disconnect(); + wait_dlg_.open = false; + widget_connections_.clear(); + charts_widget_.reset(); + video_widget_.reset(); + center_widget_.clear(); + messages_widget_.reset(); + stream_connections_.clear(); + stream_.reset(); + can = &dummy_; +} + +void MainWindow::openStream(std::unique_ptr stream, const std::string &dbc_file) { + releaseStream(); + startStream(std::move(stream), dbc_file); +} + +void MainWindow::startStream(std::unique_ptr stream, const std::string &dbc_file) { + stream_ = std::move(stream); + can = stream_.get(); + stream_connections_.push_back(can->error.connect([](const std::string &msg) { + MessageBox::warning("Error", msg); + })); + can->start(); + + loadFile(dbc_file, SOURCE_ALL, [this]() { + showStatusMessage("Stream [" + can->routeName() + "] started", 2000); + createDockWidgets(); + + video_dock_title_ = can->routeName(); + + if (!dbc()->nonEmptyDBCCount()) { + newFile(); + } + + stream_connections_.push_back(can->eventsMerged.connect([this](const MessageEventsMap &) { eventsMerged(); })); + + if (hasStream()) { + wait_dlg_.text = can->liveStreaming() ? "Waiting for the live stream to start..." : "Loading segment data..."; + wait_dlg_.value = 0; + wait_dlg_.open = true; + wait_dlg_.show_at = ImGui::GetTime() + 4.0; + wait_dlg_.connection = can->eventsMerged.connect([this](const MessageEventsMap &) { + wait_dlg_.open = false; + wait_dlg_.connection.disconnect(); + }); + } + }); +} + +void MainWindow::eventsMerged() { + const std::string fingerprint = can->carFingerprint(); + if (!can->liveStreaming() && std::exchange(car_fingerprint_, fingerprint) != fingerprint) { + video_dock_title_ = "ROUTE: " + can->routeName() + " FINGERPRINT: " + (car_fingerprint_.empty() ? "Unknown Car" : car_fingerprint_); + + auto it = fingerprint_to_dbc_.find(car_fingerprint_); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc_.end()) { + nextFrame([this, dbc_name = it->second]() { loadDBCFromOpendbc(dbc_name + ".dbc"); }); + } + } +} + +void MainWindow::saveFiles(bool as, std::function then) { + const std::vector files = dbc()->nonEmptyDBCFiles(); + auto next = std::make_shared>(); + *next = [this, as, files, next, then](size_t i) { + if (i >= files.size()) { + if (then) then(); + return; + } + auto cb = [next, i]() { (*next)(i + 1); }; + as ? saveFileAs(files[i], cb) : saveFile(files[i], cb); + }; + (*next)(0); +} + +void MainWindow::save(std::function then) { + saveFiles(false, std::move(then)); +} + +void MainWindow::saveAs(std::function then) { + saveFiles(true, std::move(then)); +} + +void MainWindow::closeFile(SourceSet s, std::function then) { + remindSaveChanges([s, then]() { + if (s == SOURCE_ALL) { + dbc()->closeAll(); + } else { + dbc()->close(s); + } + if (then) then(); + }); +} + +void MainWindow::closeFile(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + remindSaveChanges([this, dbc_file]() { + dbc()->close(dbc_file); + + if (dbc()->dbcCount() == 0) { + newFile(); + } + }); +} + +void MainWindow::saveFile(DBCFile *dbc_file, std::function then) { + assert(dbc_file != nullptr); + if (!dbc_file->filename.empty()) { + dbc_file->save(); + UndoStack::instance()->setClean(); + showStatusMessage("File saved", 2000); + if (then) then(); + } else if (!dbc_file->isEmpty()) { + saveFileAs(dbc_file, then); + } else if (then) { + then(); + } +} + +void MainWindow::saveFileAs(DBCFile *dbc_file, std::function then) { + std::string title = "Save File (bus: " + toString(dbc()->sources(dbc_file)) + ")"; + std::string default_path = (std::filesystem::path(settings.last_dir) / "untitled.dbc").string(); + FileDialog::getSaveFileName(title, default_path, ".dbc", [this, dbc_file, then](const std::string &fn) { + if (!fn.empty()) { + dbc_file->saveAs(fn); + UndoStack::instance()->setClean(); + showStatusMessage("File saved as " + fn, 2000); + updateRecentFiles(fn); + } + if (then) then(); + }); +} + +void MainWindow::saveToClipboard() { + + for (auto dbc_file : dbc()->nonEmptyDBCFiles()) { + saveFileToClipboard(dbc_file); + } +} + +void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { + assert(dbc_file != nullptr); + copyToClipboard(dbc_file->generateDBC()); +} + +void MainWindow::copyToClipboard(const std::string &text) { + if (utils::setClipboardText(text)) { + MessageBox::information("Copy To Clipboard", "DBC Successfully copied!"); + } else { + MessageBox::warning("Copy To Clipboard", "Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland)."); + } +} + +void MainWindow::drawManageDBCsMenu() { + for (int source : can->sources) { + if (source >= 64) continue; + + SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)}; + + auto dbc_file = dbc()->findDBCFile(source); + const std::string title = "Bus " + std::to_string(source) + " (" + (dbc_file ? dbc_file->name() : "No DBCs loaded") + ")"; + ImGui::PushID(source); + if (ImGui::BeginMenu(title.c_str())) { + if (ImGui::MenuItem("New DBC File...")) newFile(ss); + if (ImGui::MenuItem("Open DBC File...")) openFile(ss); + if (ImGui::MenuItem("Load DBC From Clipboard...")) loadFromClipboard(ss, false); + + + if (dbc_file) { + ImGui::Separator(); + ImGui::MenuItem((dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")").c_str(), nullptr, false, false); + if (ImGui::MenuItem("Save...")) saveFile(dbc_file); + if (ImGui::MenuItem("Save As...")) saveFileAs(dbc_file); + if (ImGui::MenuItem("Copy to Clipboard...")) saveFileToClipboard(dbc_file); + if (ImGui::MenuItem("Remove from this bus...")) closeFile(ss, {}); + if (ImGui::MenuItem("Remove from all buses...")) closeFile(dbc_file); + } + ImGui::EndMenu(); + } + ImGui::PopID(); + } +} + +void MainWindow::updateRecentFiles(const std::string &fn) { + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), fn), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), fn); + while (settings.recent_files.size() > MAX_RECENT_FILES) { + settings.recent_files.pop_back(); + } + settings.last_dir = std::filesystem::absolute(fn).parent_path().string(); +} + +void MainWindow::drawRecentFilesMenu() { + int num_recent_files = std::min(settings.recent_files.size(), MAX_RECENT_FILES); + if (!num_recent_files) { + ImGui::MenuItem("No Recent Files", nullptr, false, false); + return; + } + + for (int i = 0; i < num_recent_files; ++i) { + std::string text = std::to_string(i + 1) + " " + std::filesystem::path(settings.recent_files[i]).filename().string(); + ImGui::PushID(i); + if (ImGui::MenuItem(text.c_str())) loadFile(settings.recent_files[i]); + ImGui::PopID(); + } +} + +void MainWindow::remindSaveChanges(std::function then) { + if (UndoStack::instance()->isClean()) { + UndoStack::instance()->clear(); + if (then) then(); + return; + } + std::string text = "You have unsaved changes. Press ok to save them, cancel to discard."; + MessageBox::question("Unsaved Changes", text, [this, then](bool ok) { + if (ok) { + save([this, then]() { remindSaveChanges(then); }); + } else { + UndoStack::instance()->clear(); + if (then) then(); + } + }); +} + +void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) { + const double fraction = total > 0 ? cur / (double)total : 0.0; + if (wait_dlg_.open) wait_dlg_.value = (int)(fraction * 100); + if (success && cur < total) { + status_bar_.progress_value = fraction; + status_bar_.progress_text = "Downloading " + std::to_string((int)(fraction * 100)) + "% (" + formattedDataSize(total) + ")"; + status_bar_.progress_visible = true; + } else { + status_bar_.progress_visible = false; + } +} + +void MainWindow::toggleChartsDocking() { + charts_floating_ = !charts_floating_; + charts_widget_->setIsDocked(!charts_floating_); +} + +void MainWindow::close() { + if (closing_) return; + closing_ = true; + remindSaveChanges([this]() { finishClose(); }); +} + +void MainWindow::finishClose() { + + auto &state = inistate::main_window; + state.maximized = glfwGetWindowAttrib(window_, GLFW_MAXIMIZED); + if (full_screen_) { +#ifndef __APPLE__ + + state.pos[0] = windowed_rect_[0]; state.pos[1] = windowed_rect_[1]; + state.size[0] = windowed_rect_[2]; state.size[1] = windowed_rect_[3]; +#endif + } else if (!state.maximized) { + glfwGetWindowPos(window_, &state.pos[0], &state.pos[1]); + glfwGetWindowSize(window_, &state.size[0], &state.size[1]); + } + state.has_geometry = state.size[0] > 0 && state.size[1] > 0; + state.video_splitter_ratio = video_splitter_ratio_; + state.messages_visible = messages_visible_; + state.video_visible = video_visible_; + settings.ui_state = inistate::save(); + + saveSessionState(); + settings.save(); + exited_ = true; +} + +void MainWindow::openSettings() { + settings_dialog_.open(); +} + +void MainWindow::findSimilarBits() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::findSignal() { + auto dlg = std::make_unique(); + dlg->connections_.push_back(dlg->openMessage.connect([this](const MessageId &id) { messages_widget_->selectMessage(id); })); + tool_dialogs_.push_back(std::move(dlg)); +} + +void MainWindow::toggleHelp() { + help_overlay_.toggle(); +} + +void MainWindow::toggleFullScreen() { +#ifdef __APPLE__ + toggleNativeFullScreen(window_); +#else + full_screen_ = !full_screen_; + if (full_screen_) { + glfwGetWindowPos(window_, &windowed_rect_[0], &windowed_rect_[1]); + glfwGetWindowSize(window_, &windowed_rect_[2], &windowed_rect_[3]); + GLFWmonitor *monitor = glfwGetPrimaryMonitor(); + const GLFWvidmode *mode = glfwGetVideoMode(monitor); + glfwSetWindowMonitor(window_, monitor, 0, 0, mode->width, mode->height, mode->refreshRate); + } else { + glfwSetWindowMonitor(window_, nullptr, windowed_rect_[0], windowed_rect_[1], windowed_rect_[2], windowed_rect_[3], 0); + glfwMaximizeWindow(window_); + } +#endif +} + +void MainWindow::saveSessionState() { + settings.recent_dbc_file = ""; + settings.active_msg_id = ""; + settings.selected_msg_ids.clear(); + settings.active_charts.clear(); + + const auto files = dbc()->nonEmptyDBCFiles(); + if (!files.empty()) settings.recent_dbc_file = files.front()->filename; + + if (auto *detail = center_widget_.getDetailWidget()) { + auto [active_id, ids] = detail->serializeMessageIds(); + settings.active_msg_id = active_id; + settings.selected_msg_ids = ids; + } + if (charts_widget_) { + settings.active_charts = charts_widget_->serializeChartIds(); + } +} + +void MainWindow::restoreSessionState() { + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; + + if (dbc()->nonEmptyDBCFiles().front()->filename != settings.recent_dbc_file) return; + + if (!settings.selected_msg_ids.empty()) { + center_widget_.ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + } + + if (charts_widget_ != nullptr && !settings.active_charts.empty()) { + charts_widget_->restoreChartsFromIds(settings.active_charts); + } +} + +void MainWindow::handleShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + for (const KeyEvent &e : takeKeyEvents()) { + const bool ctrl = e.mods & (GLFW_MOD_CONTROL | GLFW_MOD_SUPER); + const bool shift = e.mods & GLFW_MOD_SHIFT; + + if (e.key == GLFW_KEY_SPACE && !ctrl && can && !io.WantTextInput) can->pause(!can->isPaused()); + if (e.key == GLFW_KEY_F1) toggleHelp(); + if (e.key == GLFW_KEY_F11 && ctrl) toggleFullScreen(); + + if (e.key == GLFW_KEY_ESCAPE && full_screen_ && !io.WantTextInput && + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + toggleFullScreen(); + } + if (!ctrl) continue; + if (e.key == GLFW_KEY_N) newFile(); + if (e.key == GLFW_KEY_O) openFile(); + if (e.key == GLFW_KEY_S) { + if (shift) { + if (dbc()->nonEmptyDBCCount() == 1) saveAs(); + } else if (dbc()->nonEmptyDBCCount() > 0) { + save(); + } + } + + if (e.key == GLFW_KEY_Z && !io.WantTextInput) shift ? UndoStack::instance()->redo() : UndoStack::instance()->undo(); + if (e.key == GLFW_KEY_Q) close(); + } +} + +void MainWindow::drawStatusBar() { + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::GetStyle().Colors[ImGuiCol_MenuBarBg]); + ImGui::BeginChild("status_bar", ImVec2(0, ImGui::GetFrameHeight()), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + + + const float width = ImGui::GetContentRegionAvail().x; + const float pad = ImGui::GetStyle().WindowPadding.x; + ImGui::SetCursorPosX(pad); + ImGui::AlignTextToFramePadding(); + + auto &bar = status_bar_; + if (!bar.message.empty() && (bar.message_until == 0 || ImGui::GetTime() < bar.message_until)) { + ImGui::TextUnformatted(bar.message.c_str()); + } else { + bar.message.clear(); + ImGui::TextUnformatted("For Help, Press F1"); + } + if (bar.progress_visible) { + ImGui::SameLine(width - pad - 300.0f); + ImGui::ProgressBar(bar.progress_value, ImVec2(300.0f, 16.0f), bar.progress_text.c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void MainWindow::drawWaitDialog() { + const char *id = "###WaitDialog"; + if (wait_dlg_.open && !ImGui::IsPopupOpen(id) && ImGui::GetTime() >= wait_dlg_.show_at) ImGui::OpenPopup(id); + if (!ImGui::IsPopupOpen(id)) return; + ImGui::SetNextWindowSize(ImVec2(400.0f, 0.0f), ImGuiCond_Always); + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + if (ImGui::BeginPopupModal(id, nullptr, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::TextUnformatted(wait_dlg_.text.c_str()); + + ImGui::ProgressBar(wait_dlg_.value / 100.0f, ImVec2(-1.0f, 0.0f), wait_dlg_.value == 0 ? "" : (const char *)nullptr); + bool abort = false, rejected = false; + dialogButtons("Abort", &abort, &rejected, true, nullptr); + if (abort || rejected) { + wait_dlg_.open = false; + close(); + } + if (!wait_dlg_.open) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +void MainWindow::drawDockspace() { + const ImGuiViewport *viewport = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(viewport->WorkPos); + ImGui::SetNextWindowSize(viewport->WorkSize); + ImGui::SetNextWindowViewport(viewport->ID); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoDocking | ImGuiWindowFlags_NoBackground | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; + ImGui::Begin("##host", nullptr, flags); + ImGui::PopStyleVar(3); + + + + const float status_height = full_screen_ ? 0.0f : ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y; + const ImVec2 dock_size(ImGui::GetContentRegionAvail().x, ImGui::GetContentRegionAvail().y - status_height); + const ImGuiID dock_id = ImGui::GetID("cabana_dockspace"); + if (reset_layout_ || ImGui::DockBuilderGetNode(dock_id) == nullptr) { + + ImGui::DockBuilderRemoveNode(dock_id); + ImGui::DockBuilderAddNode(dock_id, ImGuiDockNodeFlags_DockSpace); + ImGui::DockBuilderSetNodeSize(dock_id, dock_size); + ImGuiID center = dock_id, left = 0, right = 0; + ImGui::DockBuilderSplitNode(center, ImGuiDir_Left, 0.28f, &left, ¢er); + ImGui::DockBuilderSplitNode(center, ImGuiDir_Right, 0.4f, &right, ¢er); + ImGui::DockBuilderDockWindow(MESSAGES_PANEL_ID, left); + ImGui::DockBuilderDockWindow(VIDEO_PANEL, right); + ImGui::DockBuilderDockWindow(CENTER_PANEL, center); + ImGui::DockBuilderGetNode(center)->LocalFlags |= ImGuiDockNodeFlags_NoTabBar; + ImGui::DockBuilderFinish(dock_id); + reset_layout_ = false; + } + + const float min_panel_width = SignalView::minimumWidth() + (ImGui::GetStyle().WindowPadding.x + ImGui::GetStyle().WindowBorderSize) * 2; + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(min_panel_width, ImGui::GetStyle().WindowMinSize.y)); + ImGui::DockSpace(dock_id, dock_size); + ImGui::PopStyleVar(); + if (!full_screen_) drawStatusBar(); + ImGui::End(); +} + +namespace { + + +bool floatingOut() { return ImGui::GetWindowViewport() != ImGui::GetMainViewport(); } + + + +void setNextPanelClass() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + window_class.DockNodeFlagsOverrideSet = ImGuiDockNodeFlags_NoWindowMenuButton; + ImGui::SetNextWindowClass(&window_class); +} +} + +void MainWindow::drawMessagesPanel() { + const std::string name = messages_widget_->title() + MESSAGES_PANEL_ID; + setNextPanelClass(); + if (ImGui::Begin(name.c_str(), &messages_visible_)) { + help_overlay_.add(messages_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + messages_widget_->draw(); + } + const bool floating = floatingOut(); + ImGui::End(); + if (!messages_visible_ && floating) messages_visible_ = reset_layout_ = true; +} + +void MainWindow::drawVideoPanel() { + const std::string name = video_dock_title_ + VIDEO_PANEL; + setNextPanelClass(); + const bool video_open = ImGui::Begin(name.c_str(), &video_visible_); + const bool floating = floatingOut(); + if (!video_open) { + video_widget_->setVisible(false); + } else { + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const bool live = can->liveStreaming(); + + const float video_padding = ImGui::GetStyle().WindowPadding.y * 2.0f; + const float default_h = video_widget_->defaultHeight(avail.x) + video_padding; + const float video_hint = video_splitter_ratio_ >= 0.0f ? avail.y * video_splitter_ratio_ : default_h; + float video_h = charts_floating_ ? avail.y : std::clamp(video_hint, 0.0f, avail.y - 1.0f); + if (live) video_h = default_h; + + if (!charts_floating_ && !live) { + const float min_h = std::min(video_widget_->sizeHintHeight() + video_padding, avail.y - 1.0f); + video_h = video_h < min_h / 2 ? 0.0f : std::max(video_h, min_h); + } + if (video_h > 0.0f) { + ImGui::BeginChild("video", ImVec2(0, video_h), ImGuiChildFlags_Borders); + help_overlay_.add(video_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + video_widget_->draw(); + ImGui::EndChild(); + } else { + video_widget_->setVisible(false); + } + if (!charts_floating_) { + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + ImGui::InvisibleButton("##splitter", ImVec2(-1.0f, ImGui::GetStyle().WindowPadding.x)); + if (ImGui::IsItemActive() && !live) { + + const float top = ImGui::GetWindowPos().y + ImGui::GetCursorStartPos().y; + video_splitter_ratio_ = std::clamp((ImGui::GetMousePos().y - top) / avail.y, 0.0f, 1.0f); + } + if (ImGui::IsItemHovered() && !live) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + + ImGui::BeginChild("charts", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImGui::PopStyleVar(); + help_overlay_.add(charts_widget_->whatsThis(), ImGui::GetCurrentWindow()->Rect()); + charts_widget_->draw(); + ImGui::EndChild(); + } + } + ImGui::End(); + if (!video_visible_ && floating) video_visible_ = reset_layout_ = true; +} + +void MainWindow::draw() { +#ifdef __APPLE__ + full_screen_ = isNativeFullScreen(window_); +#endif + auto pending = std::move(next_frame_); + next_frame_.clear(); + for (auto &fn : pending) fn(); + + if (ImGui::GetTopMostPopupModal() == nullptr) { + handleShortcuts(); + } else { + takeKeyEvents(); + } + if (!full_screen_) drawMenuBar(); + drawDockspace(); + + + if (ImGui::Begin(CENTER_PANEL, nullptr, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) { + center_widget_.draw(); + if (auto *detail = center_widget_.getDetailWidget(); detail && help_overlay_.visible()) { + for (const auto &[text, rect] : detail->helpRects()) help_overlay_.add(text, rect); + } + } + ImGui::End(); + if (messages_widget_ && messages_visible_) drawMessagesPanel(); + if (video_widget_ && !video_visible_) video_widget_->setVisible(false); + if (video_widget_ && video_visible_) drawVideoPanel(); + if (charts_widget_ && charts_floating_) { + bool open = true; + ImGui::SetNextWindowSize(ImGui::GetMainViewport()->WorkSize, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + if (ImGui::Begin(CHARTS_WINDOW, &open, ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse)) charts_widget_->draw(); + ImGui::End(); + if (!open) toggleChartsDocking(); + } + for (auto it = tool_dialogs_.begin(); it != tool_dialogs_.end();) { + it = (*it)->draw() ? it + 1 : tool_dialogs_.erase(it); + } + + stream_selector_.draw(); + settings_dialog_.draw(); + drawWaitDialog(); + FileDialog::draw(); + MessageBox::draw(); + help_overlay_.draw(); + + + + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + ImGuiWindow *top = topPopupWindow(); + if (top != nullptr && !(top->Flags & ImGuiWindowFlags_Modal)) ImGui::ClosePopupToLevel(GImGui->OpenPopupStack.Size - 1, true); + } +} diff --git a/iqpilot/tools/cabana/ui/mainwin.h b/iqpilot/tools/cabana/ui/mainwin.h new file mode 100644 index 000000000..27fd94b16 --- /dev/null +++ b/iqpilot/tools/cabana/ui/mainwin.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/app.h" +#include "tools/cabana/ui/dialogs/settingsdialog.h" +#include "tools/cabana/ui/dialogs/streamselector.h" +#include "tools/cabana/ui/helpoverlay.h" +#include "tools/cabana/ui/tools/tooldialog.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/detailwidget.h" +#include "tools/cabana/ui/widgets/messageswidget.h" +#include "tools/cabana/ui/widgets/videowidget.h" + +struct GLFWwindow; + +class MainWindow { +public: + MainWindow(GLFWwindow *window, std::unique_ptr stream, StreamLoader stream_loader, const std::string &dbc_file); + ~MainWindow(); + void draw(); + void toggleChartsDocking(); + void close(); + bool exited() const { return exited_; } + void showStatusMessage(const std::string &msg, int timeout_ms = 0); + void loadFile(const std::string &fn, SourceSet s = SOURCE_ALL, std::function then = {}); + + void selectAndOpenStream(); + void openStream(std::unique_ptr stream, const std::string &dbc_file = {}); + void closeStream(); + void exportToCSV(); + + void newFile(SourceSet s = SOURCE_ALL); + void openFile(SourceSet s = SOURCE_ALL); + void loadDBCFromOpendbc(const std::string &name); + void save(std::function then = {}); + void saveAs(std::function then = {}); + void saveToClipboard(); + +private: + bool hasStream() const { return dynamic_cast(can) == nullptr; } + void releaseStream(); + void startStream(std::unique_ptr stream, const std::string &dbc_file); + void loadStartupStream(const std::string &dbc_file); + void remindSaveChanges(std::function then); + void closeFile(SourceSet s, std::function then); + void closeFile(DBCFile *dbc_file); + void saveFiles(bool as, std::function then); + void saveFile(DBCFile *dbc_file, std::function then = {}); + void saveFileAs(DBCFile *dbc_file, std::function then = {}); + void saveFileToClipboard(DBCFile *dbc_file); + void copyToClipboard(const std::string &text); + void loadFingerprints(); + void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true); + void updateRecentFiles(const std::string &fn); + void dbcFileChanged(); + void updateDownloadProgress(uint64_t cur, uint64_t total, bool success); + void openSettings(); + void findSimilarBits(); + void findSignal(); + void toggleHelp(); + void toggleFullScreen(); + void updateWindowTitle(); + void eventsMerged(); + void saveSessionState(); + void restoreSessionState(); + void finishClose(); + void nextFrame(std::function fn) { next_frame_.push_back(std::move(fn)); } + void createDockWidgets(); + + void handleShortcuts(); + void drawMenuBar(); + void drawFileMenu(); + void drawManageDBCsMenu(); + void drawRecentFilesMenu(); + void drawDockspace(); + void drawMessagesPanel(); + void drawVideoPanel(); + void drawStatusBar(); + void drawWaitDialog(); + + GLFWwindow *window_; + std::unique_ptr startup_stream_; + StreamLoader startup_loader_; + std::unique_ptr stream_; + DummyStream dummy_; + std::unique_ptr messages_widget_; + CenterWidget center_widget_; + std::unique_ptr video_widget_; + std::unique_ptr charts_widget_; + StreamSelector stream_selector_; + SettingsDialog settings_dialog_; + HelpOverlay help_overlay_; + std::unordered_map fingerprint_to_dbc_; + std::vector opendbc_names_; + enum { MAX_RECENT_FILES = 15 }; + std::string car_fingerprint_; + std::string video_dock_title_; + bool messages_visible_ = true; + bool video_visible_ = true; + bool reset_layout_ = false; + bool full_screen_ = false; +#ifndef __APPLE__ + int windowed_rect_[4] = {0, 0, 1600, 900}; +#endif + bool charts_floating_ = false; + float video_splitter_ratio_ = -1.0f; + std::vector> tool_dialogs_; + bool closing_ = false; + bool exited_ = false; + bool window_modified_ = false; + struct StatusBar { + std::string message; + double message_until = 0; + bool progress_visible = false; + float progress_value = 0; + std::string progress_text; + } status_bar_; + + struct WaitDialog { + bool open = false; + double show_at = 0; + std::string text; + int value = 0; + Connection connection; + } wait_dlg_; + std::vector> next_frame_; + Connections connections_; + Connections stream_connections_; + Connections widget_connections_; +}; diff --git a/iqpilot/tools/cabana/ui/style.cc b/iqpilot/tools/cabana/ui/style.cc new file mode 100644 index 000000000..839102d27 --- /dev/null +++ b/iqpilot/tools/cabana/ui/style.cc @@ -0,0 +1,280 @@ +#include "tools/cabana/ui/app.h" + +#include +#include +#include + +#include "implot.h" +#include "tools/cabana/core/settings.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/util.h" + +namespace fs = std::filesystem; + +namespace { +bool g_dark = false; +ImFont *g_ui_font = nullptr; +ImFont *g_bold_font = nullptr; +ImFont *g_mono_font = nullptr; +ImFont *g_large_font = nullptr; + +void addIconFont(float size, ImFont *base) { + ImFontConfig cfg; + cfg.MergeMode = base != nullptr; + cfg.GlyphMinAdvanceX = size; + if (base != nullptr) { + ImFontBaked *baked = base->GetFontBaked(size); + const float center = baked != nullptr ? (baked->Ascent + baked->Descent) * 0.5f : size * 0.5f; + cfg.GlyphOffset.y = std::round(size * 0.5f - center); + } + static const ImWchar ranges[] = {0xF000, 0xF8FF, 0}; + ImGui::GetIO().Fonts->AddFontFromFileTTF(BOOTSTRAP_ICONS_TTF, size, &cfg, ranges); +} + +ImFont *addFont(const fs::path &path, float size) { + ImFontConfig cfg; + cfg.OversampleH = 2; + cfg.OversampleV = 2; + ImFont *font = ImGui::GetIO().Fonts->AddFontFromFileTTF(path.c_str(), size, &cfg); + if (font != nullptr) addIconFont(size, font); + return font; +} +} + +void loadFonts() { + ImGuiIO &io = ImGui::GetIO(); + const fs::path fonts = fs::path(CABANA_FONTS_DIR); + g_ui_font = addFont(fonts / "Inter-Regular.ttf", 16.0f); + g_bold_font = addFont(fonts / "Inter-SemiBold.ttf", 16.0f); + g_mono_font = addFont(fonts / "JetBrainsMono-Medium.ttf", 15.0f); + g_large_font = addFont(fonts / "Inter-Bold.ttf", 50.0f); + if (g_ui_font != nullptr) io.FontDefault = g_ui_font; + if (g_bold_font == nullptr) g_bold_font = g_ui_font; + if (g_mono_font == nullptr) g_mono_font = g_ui_font; + if (g_large_font == nullptr) g_large_font = g_bold_font; +} + +void applyTheme(int theme) { + const bool dark = theme == DARK_THEME; + g_dark = dark; + if (dark) { + ImGui::StyleColorsDark(); + ImPlot::StyleColorsDark(); + } else { + ImGui::StyleColorsLight(); + ImPlot::StyleColorsLight(); + } + + ImGuiStyle &style = ImGui::GetStyle(); + style.WindowRounding = 0.0f; + style.ChildRounding = 0.0f; + style.PopupRounding = 0.0f; + style.FrameRounding = 2.0f; + style.GrabRounding = 2.0f; + style.ScrollbarRounding = 2.0f; + style.TabRounding = 2.0f; + style.WindowBorderSize = 1.0f; + style.FrameBorderSize = 1.0f; + style.TabBorderSize = 1.0f; + style.WindowPadding = ImVec2(8.0f, 7.0f); + style.FramePadding = ImVec2(6.0f, 3.0f); + style.ItemSpacing = ImVec2(8.0f, 5.0f); + style.ScrollbarSize = 14.0f; + style.GrabMinSize = 13.0f; + + auto c = [](const CabanaColor &col, float a = 1.0f) { return colorRgb(col.r, col.g, col.b, a); }; + ImVec4 *colors = style.Colors; + if (dark) { + + + const ImVec4 highlight = c(DarkTheme::highlight); + const ImVec4 outline = colorRgb(0x5a, 0x5d, 0x60); + colors[ImGuiCol_WindowBg] = c(DarkTheme::window); + colors[ImGuiCol_ChildBg] = c(DarkTheme::base); + colors[ImGuiCol_PopupBg] = c(DarkTheme::base); + colors[ImGuiCol_MenuBarBg] = c(DarkTheme::window); + colors[ImGuiCol_DockingEmptyBg] = c(DarkTheme::window); + colors[ImGuiCol_Text] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_TextDisabled] = colorRgb(0x8c, 0x8c, 0x8c); + colors[ImGuiCol_Border] = outline; + colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_FrameBg] = colorRgb(0x2e, 0x30, 0x32); + colors[ImGuiCol_FrameBgHovered] = colorRgb(0x3a, 0x3d, 0x40); + colors[ImGuiCol_FrameBgActive] = colorRgb(0x45, 0x48, 0x4b); + colors[ImGuiCol_Button] = c(DarkTheme::button); + colors[ImGuiCol_ButtonHovered] = colorRgb(0x52, 0x56, 0x59); + colors[ImGuiCol_ButtonActive] = colorRgb(0x2b, 0x2d, 0x30); + colors[ImGuiCol_Header] = highlight; + colors[ImGuiCol_HeaderHovered] = c(DarkTheme::highlight, 0.8f); + colors[ImGuiCol_HeaderActive] = highlight; + colors[ImGuiCol_CheckMark] = c(DarkTheme::bright_text); + colors[ImGuiCol_SliderGrab] = colorRgb(0x8f, 0x92, 0x95); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0xa8, 0xab, 0xae); + colors[ImGuiCol_ScrollbarBg] = c(DarkTheme::window); + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0x70, 0x73, 0x76); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0x85, 0x88, 0x8b); + colors[ImGuiCol_ScrollbarGrabActive] = c(DarkTheme::light); + colors[ImGuiCol_Separator] = outline; + colors[ImGuiCol_SeparatorHovered] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_SeparatorActive] = highlight; + colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_ResizeGripHovered] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_ResizeGripActive] = highlight; + colors[ImGuiCol_Tab] = c(DarkTheme::window); + colors[ImGuiCol_TabHovered] = colorRgb(0x4b, 0x4e, 0x52); + colors[ImGuiCol_TabSelected] = c(DarkTheme::base); + colors[ImGuiCol_TabSelectedOverline] = highlight; + colors[ImGuiCol_TabDimmed] = c(DarkTheme::window); + colors[ImGuiCol_TabDimmedSelected] = c(DarkTheme::base); + colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TitleBg] = c(DarkTheme::window); + colors[ImGuiCol_TitleBgActive] = c(DarkTheme::window); + colors[ImGuiCol_TitleBgCollapsed] = c(DarkTheme::window); + colors[ImGuiCol_TableHeaderBg] = c(DarkTheme::window); + colors[ImGuiCol_TableBorderStrong] = outline; + colors[ImGuiCol_TableBorderLight] = colorRgb(0x23, 0x26, 0x28); + colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TableRowBgAlt] = colorRgb(0xff, 0xff, 0xff, 0.06f); + colors[ImGuiCol_TextSelectedBg] = c(DarkTheme::highlight, 0.6f); + colors[ImGuiCol_DockingPreview] = c(DarkTheme::highlight, 0.5f); + colors[ImGuiCol_NavCursor] = highlight; + colors[ImGuiCol_PlotLines] = c(DarkTheme::text); + colors[ImGuiCol_PlotHistogram] = highlight; + colors[ImGuiCol_DragDropTarget] = highlight; + } else { + const ImVec4 window = colorRgb(0xef, 0xef, 0xef); + const ImVec4 base = colorRgb(0xff, 0xff, 0xff); + const ImVec4 outline = colorRgb(0xb9, 0xb9, 0xb9); + const ImVec4 highlight = colorRgb(0x30, 0x8c, 0xc6); + colors[ImGuiCol_WindowBg] = window; + colors[ImGuiCol_ChildBg] = base; + colors[ImGuiCol_PopupBg] = colorRgb(0xfb, 0xfb, 0xfb); + colors[ImGuiCol_MenuBarBg] = window; + colors[ImGuiCol_DockingEmptyBg] = window; + colors[ImGuiCol_Text] = colorRgb(0x00, 0x00, 0x00); + colors[ImGuiCol_TextDisabled] = colorRgb(0xbe, 0xbe, 0xbe); + colors[ImGuiCol_Border] = outline; + colors[ImGuiCol_BorderShadow] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_FrameBg] = base; + colors[ImGuiCol_FrameBgHovered] = colorRgb(0xf7, 0xf7, 0xf7); + colors[ImGuiCol_FrameBgActive] = colorRgb(0xef, 0xef, 0xef); + colors[ImGuiCol_Button] = colorRgb(0xf3, 0xf3, 0xf3); + colors[ImGuiCol_ButtonHovered] = colorRgb(0xf9, 0xf9, 0xf9); + colors[ImGuiCol_ButtonActive] = colorRgb(0xdc, 0xdc, 0xdc); + colors[ImGuiCol_Header] = highlight; + colors[ImGuiCol_HeaderHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.8f); + colors[ImGuiCol_HeaderActive] = highlight; + colors[ImGuiCol_CheckMark] = colorRgb(0x3b, 0x3b, 0x3b); + colors[ImGuiCol_SliderGrab] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_SliderGrabActive] = colorRgb(0xc4, 0xc4, 0xc4); + colors[ImGuiCol_ScrollbarBg] = window; + colors[ImGuiCol_ScrollbarGrab] = colorRgb(0xc8, 0xc8, 0xc8); + colors[ImGuiCol_ScrollbarGrabHovered] = colorRgb(0xb4, 0xb4, 0xb4); + colors[ImGuiCol_ScrollbarGrabActive] = colorRgb(0xa0, 0xa0, 0xa0); + colors[ImGuiCol_Separator] = outline; + colors[ImGuiCol_SeparatorHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); + colors[ImGuiCol_SeparatorActive] = highlight; + colors[ImGuiCol_ResizeGrip] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_ResizeGripHovered] = colorRgb(0x30, 0x8c, 0xc6, 0.6f); + colors[ImGuiCol_ResizeGripActive] = highlight; + colors[ImGuiCol_Tab] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabHovered] = colorRgb(0xf5, 0xf5, 0xf5); + colors[ImGuiCol_TabSelected] = base; + colors[ImGuiCol_TabSelectedOverline] = highlight; + colors[ImGuiCol_TabDimmed] = colorRgb(0xe2, 0xe2, 0xe2); + colors[ImGuiCol_TabDimmedSelected] = base; + colors[ImGuiCol_TabDimmedSelectedOverline] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TitleBg] = window; + colors[ImGuiCol_TitleBgActive] = window; + colors[ImGuiCol_TitleBgCollapsed] = window; + colors[ImGuiCol_TableHeaderBg] = colorRgb(0xf2, 0xf2, 0xf2); + colors[ImGuiCol_TableBorderStrong] = outline; + colors[ImGuiCol_TableBorderLight] = colorRgb(0xd8, 0xd8, 0xd8); + colors[ImGuiCol_TableRowBg] = colorRgb(0, 0, 0, 0.0f); + colors[ImGuiCol_TableRowBgAlt] = colorRgb(0, 0, 0, 0.03f); + colors[ImGuiCol_TextSelectedBg] = colorRgb(0x30, 0x8c, 0xc6, 0.35f); + colors[ImGuiCol_DockingPreview] = colorRgb(0x30, 0x8c, 0xc6, 0.5f); + colors[ImGuiCol_NavCursor] = highlight; + colors[ImGuiCol_PlotLines] = colorRgb(0x3b, 0x3b, 0x3b); + colors[ImGuiCol_PlotHistogram] = highlight; + colors[ImGuiCol_DragDropTarget] = highlight; + } + + colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0, 0, 0, 0); + colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0, 0, 0, 0); +} + +bool isDarkTheme() { return g_dark; } + +ImU32 highlightedTextColor() { + return g_dark ? IM_COL32(DarkTheme::window_text.r, DarkTheme::window_text.g, DarkTheme::window_text.b, 255) + : IM_COL32(255, 255, 255, 255); +} + +ImU32 paletteBrightText() { + return g_dark ? IM_COL32(DarkTheme::bright_text.r, DarkTheme::bright_text.g, DarkTheme::bright_text.b, 255) + : IM_COL32(255, 255, 255, 255); +} + +void drawSliderHandle(ImDrawList *p, const ImRect &r) { + const bool dark = isDarkTheme(); + const ImU32 top = dark ? IM_COL32(0x3e, 0x41, 0x43, 255) : IM_COL32(255, 255, 255, 255); + const ImU32 bottom = dark ? IM_COL32(0x39, 0x3c, 0x3e, 255) : IM_COL32(0xf0, 0xf0, 0xf0, 255); + + const ImU32 outline_top = dark ? IM_COL32(0xa3, 0xa3, 0xa3, 255) : IM_COL32(0xab, 0xab, 0xab, 255); + const ImU32 outline_bottom = dark ? IM_COL32(0x9c, 0x9c, 0x9c, 255) : IM_COL32(0xa4, 0xa4, 0xa4, 255); + p->AddRectFilled(r.Min, r.Max, top, 2.0f); + p->AddRectFilled(ImVec2(r.Min.x, r.GetCenter().y), r.Max, bottom, 2.0f, ImDrawFlags_RoundCornersBottom); + p->AddRect(r.Min, r.Max, outline_bottom, 2.0f, 0, 1.0f); + + const float c = 2.0f; + p->AddRectFilled(ImVec2(r.Min.x + c, r.Min.y), ImVec2(r.Max.x - c, r.Min.y + 1.0f), outline_top); + p->AddRectFilled(ImVec2(r.Min.x, r.Min.y + c), ImVec2(r.Min.x + 1.0f, r.Max.y - c), outline_top); + p->AddRectFilled(ImVec2(r.Min.x + c, r.Max.y - 1.0f), ImVec2(r.Max.x - c, r.Max.y), outline_bottom); + p->AddRectFilled(ImVec2(r.Max.x - 1.0f, r.Min.y + c), ImVec2(r.Max.x, r.Max.y - c), outline_bottom); +} + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width) { + + const ImU32 groove_col = isDarkTheme() ? IM_COL32(0x2a, 0x2c, 0x2e, 255) : IM_COL32(0xc4, 0xc4, 0xc4, 255); + const ImU32 fill_col = ImGui::GetColorU32(ImGuiCol_Header); + ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrab, IM_COL32_BLACK_TRANS); + ImGui::PushStyleColor(ImGuiCol_SliderGrabActive, IM_COL32_BLACK_TRANS); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::SetNextItemWidth(width); + bool changed = ImGui::SliderInt(label, v, min, max, "", ImGuiSliderFlags_NoInput); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(5); + + const ImVec2 bb_min = ImGui::GetItemRectMin(), bb_max = ImGui::GetItemRectMax(); + const float cy = (bb_min.y + bb_max.y) * 0.5f; + const float groove_h = SLIDER_THICKNESS * 0.5f; + const float handle_h = std::min(SLIDER_THICKNESS, bb_max.y - bb_min.y); + const float x0 = bb_min.x + SLIDER_LENGTH * 0.5f, x1 = bb_max.x - SLIDER_LENGTH * 0.5f; + const float t = max > min ? (float)(*v - min) / (float)(max - min) : 0.0f; + const float hx = x0 + (x1 - x0) * t; + ImDrawList *dl = ImGui::GetWindowDrawList(); + const float groove_y0 = cy - groove_h * 0.5f, groove_y1 = cy + groove_h * 0.5f; + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(bb_max.x, groove_y1), groove_col, groove_h * 0.5f); + dl->AddRectFilled(ImVec2(bb_min.x, groove_y0), ImVec2(hx, groove_y1), fill_col, groove_h * 0.5f); + drawSliderHandle(dl, ImRect(ImVec2(hx - SLIDER_LENGTH * 0.5f, cy - handle_h * 0.5f), + ImVec2(hx + SLIDER_LENGTH * 0.5f, cy + handle_h * 0.5f))); + return changed; +} + +ImFont *boldFont() { return g_bold_font; } +ImFont *monoFont() { return g_mono_font; } + +void pushMonoFont(float size) { + if (!g_mono_font) return; + size > 0.0f ? ImGui::PushFont(g_mono_font, size) : ImGui::PushFont(g_mono_font); +} +void popMonoFont() { if (g_mono_font) ImGui::PopFont(); } +void pushBoldFont() { if (g_bold_font) ImGui::PushFont(g_bold_font); } +void popBoldFont() { if (g_bold_font) ImGui::PopFont(); } +void pushLargeFont() { if (g_large_font) ImGui::PushFont(g_large_font); } +void popLargeFont() { if (g_large_font) ImGui::PopFont(); } diff --git a/iqpilot/tools/cabana/ui/threadpool.h b/iqpilot/tools/cabana/ui/threadpool.h new file mode 100644 index 000000000..acdd2cd26 --- /dev/null +++ b/iqpilot/tools/cabana/ui/threadpool.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + + + +class ThreadPool { +public: + static ThreadPool &instance() { + static ThreadPool pool(std::clamp(std::thread::hardware_concurrency(), 2u, 4u)); + return pool; + } + + std::future run(std::function fn) { + auto task = std::make_shared>(std::move(fn)); + std::future future = task->get_future(); + { + std::lock_guard lk(mutex_); + tasks_.push([task]() { (*task)(); }); + } + cv_.notify_one(); + return future; + } + + ~ThreadPool() { + { + std::lock_guard lk(mutex_); + stop_ = true; + } + cv_.notify_all(); + for (auto &t : threads_) t.join(); + } + +private: + explicit ThreadPool(unsigned n) { + for (unsigned i = 0; i < n; ++i) { + threads_.emplace_back([this]() { + for (;;) { + std::function task; + { + std::unique_lock lk(mutex_); + cv_.wait(lk, [this]() { return stop_ || !tasks_.empty(); }); + if (stop_ && tasks_.empty()) return; + task = std::move(tasks_.front()); + tasks_.pop(); + } + task(); + } + }); + } + } + + std::vector threads_; + std::queue> tasks_; + std::mutex mutex_; + std::condition_variable cv_; + bool stop_ = false; +}; + + + +inline void parallelFor(size_t n, const std::function &fn) { + const size_t chunks = std::clamp(std::thread::hardware_concurrency(), 2, 4) + 1; + const size_t chunk = (n + chunks - 1) / chunks; + if (chunk == 0) return; + std::vector> futures; + size_t begin = chunk; + for (; begin < n; begin += chunk) { + futures.push_back(ThreadPool::instance().run([&fn, begin, end = std::min(begin + chunk, n)]() { fn(begin, end); })); + } + fn(0, std::min(chunk, n)); + for (auto &f : futures) f.get(); +} diff --git a/iqpilot/tools/cabana/ui/tools/findsignal.cc b/iqpilot/tools/cabana/ui/tools/findsignal.cc new file mode 100644 index 000000000..31a6031c0 --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/findsignal.cc @@ -0,0 +1,325 @@ +#include "tools/cabana/ui/tools/findsignal.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr int MAX_ROWS = 300; +} + +void SignalSearch::search(const std::function &cmp) { + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; + filtered_signals.clear(); + filtered_signals.reserve(prev_sigs.size()); + + std::mutex lock; + parallelFor(prev_sigs.size(), [&](size_t begin, size_t end) { + for (size_t i = begin; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + char buf[64]; + snprintf(buf, sizeof(buf), "(%.3f, %g)", can->toSeconds((*it)->mono_time), get_raw_value((*it)->dat, (*it)->size, s.sig)); + values.push_back(buf); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + + histories.push_back(filtered_signals); +} + +void SignalSearch::undo() { + if (!histories.empty()) { + histories.pop_back(); + filtered_signals.clear(); + if (!histories.empty()) filtered_signals = histories.back(); + } +} + +void SignalSearch::reset() { + histories.clear(); + filtered_signals.clear(); + initial_signals.clear(); +} + +FindSignalDlg::FindSignalDlg() { + setTitle("Find Signal"); +} + +FindSignalDlg::~FindSignalDlg() { + if (search_future_.valid()) search_future_.wait(); +} + +bool FindSignalDlg::draw() { + if (search_future_.valid() && search_future_.wait_for(std::chrono::seconds(0)) == std::future_status::ready) { + search_future_.get(); + searched_ = true; + } + searching_ = search_future_.valid(); + if (begin(ImVec2(900, 650))) { + float group_w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) / 2; + ImGui::BeginChild("Messages", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + drawMessageGroup(); + ImGui::EndChild(); + ImGui::SameLine(); + ImGui::BeginChild("Signal", ImVec2(group_w, 0), ImGuiChildFlags_Borders | ImGuiChildFlags_AutoResizeY); + drawPropertiesGroup(); + ImGui::EndChild(); + float footer = searched_ ? ImGui::GetTextLineHeightWithSpacing() : 0; + ImGui::BeginChild("Find signal", ImVec2(0, -footer), ImGuiChildFlags_Borders); + drawFindGroup(); + ImGui::EndChild(); + if (searched_) { + ImGui::Text("%zu matches. right click on an item to create signal. double click to open message", + search_.filtered_signals.size()); + } + } + return end(); +} + +void FindSignalDlg::drawMessageGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + ImGui::TextUnformatted("Messages"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(-1); + inputText("##bus", &bus_, "comma-separated values. Leave blank for all"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Address"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(-1); + inputText("##address", &address_, "comma-separated hex values. Leave blank for all"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Time"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(70); + validatedText("##first_time", &first_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + validatedText("##last_time", &last_time_, validateDouble); + ImGui::SameLine(); + ImGui::TextUnformatted("seconds"); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawPropertiesGroup() { + ImGui::BeginDisabled(searching_ || !search_.histories.empty()); + ImGui::TextUnformatted("Signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Size"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(70); + if (ImGui::InputInt("##min_size", &min_size_, 1, 10)) min_size_ = std::clamp(min_size_, 1, 64); + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(70); + if (ImGui::InputInt("##max_size", &max_size_, 1, 10)) max_size_ = std::clamp(max_size_, 1, 64); + ImGui::SameLine(); + checkBox("Little endian", &little_endian_); + ImGui::SameLine(); + checkBox("Signed", &is_signed_); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Factor"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(100); + validatedText("##factor", &factor_, validateDouble); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Offset"); + ImGui::SameLine(80); + ImGui::SetNextItemWidth(100); + validatedText("##offset", &offset_, validateDouble); + ImGui::EndDisabled(); +} + +void FindSignalDlg::drawFindGroup() { + static const char *compare_items[] = {"=", ">", ">=", "!=", "<", "<=", "between"}; + const int compare_count = IM_ARRAYSIZE(compare_items); + ImGui::TextUnformatted("Find signal"); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Value"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(90); + ImGui::Combo("##compare", &compare_, compare_items, compare_count); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::IsWindowAppearing()) ImGui::SetKeyboardFocusHere(); + validatedText("##value1", &value1_, validateDouble); + if (compare_ == compare_count - 1) { + ImGui::SameLine(); + ImGui::TextUnformatted("-"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + validatedText("##value2", &value2_, validateDouble); + } + ImGui::SameLine(); + const bool first = !searching_ && search_.histories.empty(); + ImGui::BeginDisabled(searching_ || search_.histories.size() <= 1); + if (ImGui::Button("Undo prev find")) { + search_.undo(); + searched_ = true; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || (search_.filtered_signals.empty() && !first)); + if (ImGui::Button(searching_ ? "Finding ...." : (first ? "Find" : "Find Next"))) search(); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(searching_ || first); + if (ImGui::Button("Reset")) { + search_.reset(); + searched_ = true; + } + ImGui::EndDisabled(); + + if (searching_) { + ImGui::BeginChild("view", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + } else { + drawTable(); + } +} + +void FindSignalDlg::drawTable() { + static const char *titles[] = {"Id", "Start Bit, size", "(time, value)"}; + const int columns = IM_ARRAYSIZE(titles); + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("view", columns + 1, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + const int rows = std::min(search_.filtered_signals.size(), MAX_ROWS); + + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed | (rows ? 0 : ImGuiTableColumnFlags_Disabled), 40.0f); + for (int c = 0; c < columns; ++c) { + auto column_flags = c == columns - 1 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed; + ImGui::TableSetupColumn(titles[c], column_flags, c == 0 ? 80.0f : 120.0f); + } + tableHeadersRow(); + for (int row = 0; row < rows; ++row) { + const auto &s = search_.filtered_signals[row]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(row + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) openMessage(s.id); + } + drawContextMenu(row); + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(s.id.toString().c_str()); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%d, %d", s.sig.start_bit, s.sig.size); + ImGui::TableSetColumnIndex(3); + std::string values; + for (size_t i = 0; i < s.values.size(); ++i) { + if (i) values += " "; + values += s.values[i]; + } + ImGui::TextUnformatted(values.c_str()); + } + ImGui::EndTable(); +} + +void FindSignalDlg::search() { + if (search_.histories.empty()) { + setInitialSignals(); + } + auto v1 = utils::toDouble(value1_); + auto v2 = utils::toDouble(value2_); + std::function cmp = nullptr; + switch (compare_) { + case 0: cmp = [v1](double v) { return v == v1;}; break; + case 1: cmp = [v1](double v) { return v > v1;}; break; + case 2: cmp = [v1](double v) { return v >= v1;}; break; + case 3: cmp = [v1](double v) { return v != v1;}; break; + case 4: cmp = [v1](double v) { return v < v1;}; break; + case 5: cmp = [v1](double v) { return v <= v1;}; break; + case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break; + } + searched_ = false; + + search_future_ = std::async(std::launch::async, [this, cmp = std::move(cmp)]() { search_.search(cmp); }); + searching_ = true; +} + +void FindSignalDlg::setInitialSignals() { + std::set buses; + for (auto bus : utils::split(utils::trimmed(bus_), ',')) { + bus = utils::trimmed(bus); + if (!bus.empty()) buses.insert((unsigned short)utils::toULong(bus)); + } + + std::set addresses; + for (auto addr : utils::split(utils::trimmed(address_), ',')) { + addr = utils::trimmed(addr); + if (!addr.empty()) addresses.insert(utils::toULong(addr, 16)); + } + + cabana::Signal sig{}; + sig.is_little_endian = little_endian_; + sig.is_signed = is_signed_; + sig.factor = utils::toDouble(factor_); + sig.offset = utils::toDouble(offset_); + + double first_time_val = utils::toDouble(first_time_); + double last_time_val = utils::toDouble(last_time_); + auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val); + uint64_t first_time = can->toMonoTime(first_sec); + search_.last_time = std::numeric_limits::max(); + if (last_sec > 0) { + search_.last_time = can->toMonoTime(last_sec); + } + search_.initial_signals.clear(); + + for (const auto &[id, m] : can->lastMessages()) { + if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { + const auto &events = can->events(id); + auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); + if (e != events.cend()) { + const int total_size = m.dat.size() * 8; + for (int size = min_size_; size <= max_size_; ++size) { + for (int start = 0; start <= total_size - size; ++start) { + SignalSearch::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig}; + s.sig.start_bit = start; + s.sig.size = size; + updateMsbLsb(s.sig); + s.value = get_raw_value((*e)->dat, (*e)->size, s.sig); + search_.initial_signals.push_back(s); + } + } + } + } + } +} + +void FindSignalDlg::drawContextMenu(int row) { + if (ImGui::BeginPopupContextItem("menu")) { + if (ImGui::MenuItem("Create Signal")) { + auto &s = search_.filtered_signals[row]; + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); + openMessage(s.id); + } + ImGui::EndPopup(); + } +} diff --git a/iqpilot/tools/cabana/ui/tools/findsignal.h b/iqpilot/tools/cabana/ui/tools/findsignal.h new file mode 100644 index 000000000..d3211dc65 --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/findsignal.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +struct SignalSearch { + struct SearchSignal { + MessageId id = {}; + uint64_t mono_time = 0; + cabana::Signal sig = {}; + double value = 0.; + std::vector values; + }; + + void search(const std::function &cmp); + void reset(); + void undo(); + + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; + uint64_t last_time = std::numeric_limits::max(); +}; + +class FindSignalDlg : public ToolDialog { +public: + FindSignalDlg(); + ~FindSignalDlg() override; + bool draw() override; + + Observable openMessage; + +private: + void search(); + void setInitialSignals(); + void drawContextMenu(int row); + void drawMessageGroup(); + void drawPropertiesGroup(); + void drawFindGroup(); + void drawTable(); + + std::string value1_, value2_, factor_ = "1.0", offset_ = "0.0"; + std::string bus_, address_, first_time_ = "0", last_time_ = "MAX"; + int compare_ = 0; + int min_size_ = 8, max_size_ = 8; + bool little_endian_ = true, is_signed_ = false; + bool searched_ = false; + SignalSearch search_; + std::future search_future_; + bool searching_ = false; +}; diff --git a/iqpilot/tools/cabana/ui/tools/findsimilarbits.cc b/iqpilot/tools/cabana/ui/tools/findsimilarbits.cc new file mode 100644 index 000000000..ccd3b6eca --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/findsimilarbits.cc @@ -0,0 +1,178 @@ +#include "tools/cabana/ui/tools/findsimilarbits.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/ui/util.h" + +FindSimilarBitsDlg::FindSimilarBitsDlg() { + setTitle("Find similar bits"); + + for (int bus : can->sources) { + bus_items_.push_back(bus); + } + updateMessages(); +} + +void FindSimilarBitsDlg::updateMessages() { + msg_items_.clear(); + msg_names_.clear(); + for (auto &[address, msg] : dbc()->getMessages(busAt(src_bus_))) { + msg_items_.push_back({msg.name, address}); + } + std::sort(msg_items_.begin(), msg_items_.end(), [](auto &l, auto &r) { return l.first < r.first; }); + for (auto &[name, _] : msg_items_) msg_names_.push_back(name); + msg_index_ = 0; +} + +bool FindSimilarBitsDlg::draw() { + if (begin(ImVec2(700, 500))) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find From:"); + ImGui::SameLine(90); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + if (comboBox("##src_bus", &src_bus_, bus_items_.data(), (int)bus_items_.size())) updateMessages(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(200); + comboBox("##msg", &msg_index_, msg_names_); + ImGui::SameLine(); + ImGui::TextUnformatted("Byte Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##byte_idx", &byte_idx_, 1, 10)) byte_idx_ = std::clamp(byte_idx_, 0, 63); + ImGui::SameLine(); + ImGui::TextUnformatted("Bit Index"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##bit_idx", &bit_idx_, 1, 10)) bit_idx_ = std::clamp(bit_idx_, 0, 7); + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Find In:"); + ImGui::SameLine(90); + ImGui::TextUnformatted("Bus"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + comboBox("##find_bus", &find_bus_, bus_items_.data(), (int)bus_items_.size()); + ImGui::SameLine(); + ImGui::TextUnformatted("Equal"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60); + ImGui::Combo("##equal", &equal_, "Yes\0No\0"); + ImGui::SameLine(); + ImGui::TextUnformatted("Min msg count"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(80); + if (ImGui::InputInt("##min_msgs", &min_msgs_, 1, 10)) min_msgs_ = std::max(min_msgs_, 0); + ImGui::SameLine(); + if (ImGui::Button("Find")) find(); + + drawTable(); + } + return end(); +} + +void FindSimilarBitsDlg::drawTable() { + + if (!table_has_columns_) { + ImGui::BeginChild("table", ImVec2(0, 0), ImGuiChildFlags_Borders); + ImGui::EndChild(); + return; + } + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable | ImGuiTableFlags_NoSavedSettings; + if (!ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) return; + ImGui::TableSetupScrollFreeze(0, 1); + static const char *headers[] = {"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"}; + + const float padding = ImGui::GetStyle().CellPadding.x * 2; + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 40.0f - padding); + for (int c = 0; c < 6; ++c) { + ImGui::TableSetupColumn(headers[c], c == 5 ? ImGuiTableColumnFlags_WidthStretch : ImGuiTableColumnFlags_WidthFixed, + 100.0f - padding); + } + tableHeadersRow(); + ImGuiListClipper clipper; + clipper.Begin((int)table_.size()); + while (clipper.Step()) { + for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { + auto &m = table_[i]; + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(i + 1).c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + openMessage(MessageId{.source = busAt(find_bus_), .address = m.address}); + } + } + ImGui::PopID(); + ImGui::TableSetColumnIndex(1); + ImGui::Text("%x", m.address); + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", m.byte_idx); + ImGui::TableSetColumnIndex(3); + ImGui::Text("%u", m.bit_idx); + ImGui::TableSetColumnIndex(4); + ImGui::Text("%u", m.mismatches); + ImGui::TableSetColumnIndex(5); + ImGui::Text("%u", m.total); + ImGui::TableSetColumnIndex(6); + ImGui::Text("%.2f", m.perc); + } + } + ImGui::EndTable(); +} + +void FindSimilarBitsDlg::find() { + const uint32_t selected_address = msg_index_ < (int)msg_items_.size() ? msg_items_[msg_index_].second : 0; + table_ = calcBits(busAt(src_bus_), selected_address, byte_idx_, bit_idx_, busAt(find_bus_), equal_ == 0, min_msgs_); + table_has_columns_ = true; +} + +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; + const auto &events = can->allEvents(); + int bit_to_find = -1; + for (const CanEvent *e : events) { + if (e->src == bus) { + if (e->address == selected_address && e->size > byte_idx) { + bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0; + } + } + if (e->src == find_bus) { + ++msg_count[e->address]; + if (bit_to_find == -1) continue; + + auto &mismatched = mismatches[e->address]; + if (mismatched.size() < e->size * 8) { + mismatched.resize(e->size * 8); + } + for (int i = 0; i < e->size; ++i) { + for (int j = 0; j < 8; ++j) { + int bit = ((e->dat[i] >> (7 - j)) & 1) != 0; + mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find); + } + } + } + } + + std::vector result; + result.reserve(mismatches.size()); + for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { + if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + } + } + } + } + std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; }); + return result; +} diff --git a/iqpilot/tools/cabana/ui/tools/findsimilarbits.h b/iqpilot/tools/cabana/ui/tools/findsimilarbits.h new file mode 100644 index 000000000..17dec8399 --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/findsimilarbits.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/ui/tools/tooldialog.h" + +class FindSimilarBitsDlg : public ToolDialog { +public: + FindSimilarBitsDlg(); + bool draw() override; + + Observable openMessage; + +private: + struct Mismatch { + uint32_t address, byte_idx, bit_idx, mismatches, total; + float perc; + }; + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + bool equal, int min_msgs_cnt); + uint8_t busAt(int index) const { return index < (int)bus_items_.size() ? bus_items_[index] : 0; } + void updateMessages(); + void find(); + void drawTable(); + + std::vector table_; + bool table_has_columns_ = false; + std::vector bus_items_; + int src_bus_ = 0, find_bus_ = 0; + std::vector> msg_items_; + std::vector msg_names_; + int msg_index_ = 0; + int equal_ = 0; + int byte_idx_ = 0, bit_idx_ = 0; + int min_msgs_ = 100; +}; diff --git a/iqpilot/tools/cabana/ui/tools/routeinfo.cc b/iqpilot/tools/cabana/ui/tools/routeinfo.cc new file mode 100644 index 000000000..119de6350 --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/routeinfo.cc @@ -0,0 +1,50 @@ +#include "tools/cabana/ui/tools/routeinfo.h" + +#include +#include + +#include "imgui.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/util.h" + +RouteInfoDlg::RouteInfoDlg() { + replay_ = dynamic_cast(can)->getReplay(); + setTitle("Route: " + replay_->route().name()); +} + +bool RouteInfoDlg::draw() { + static const char *headers[] = {"", "rlog", "road", "wide road", "driver", "qlog", "qcam"}; + auto yn = [](const std::string &s) { return s.empty() ? "--" : "Yes"; }; + const auto &segments = replay_->route().segments(); + + float row_h = ImGui::GetTextLineHeightWithSpacing(); + float min_h = row_h * (std::min((int)segments.size(), 13) + 1) + ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().WindowPadding.y * 2; + if (begin(ImVec2(520, min_h))) { + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingFixedFit; + if (ImGui::BeginTable("table", 7, flags, ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int c = 0; c < 7; ++c) ImGui::TableSetupColumn(headers[c]); + tableHeadersRow(); + int row = 0; + for (const auto &[seg_num, seg] : segments) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::PushID(row); + if (ImGui::Selectable(std::to_string(seg_num).c_str(), false, ImGuiSelectableFlags_SpanAllColumns)) { + can->seekTo(row * 60.0); + } + ImGui::SetItemTooltip("Click on a row to seek to the corresponding segment."); + ImGui::PopID(); + const char *cells[] = {yn(seg.rlog), yn(seg.road_cam), yn(seg.wide_road_cam), + yn(seg.driver_cam), yn(seg.qlog), yn(seg.qcamera)}; + for (int c = 1; c < 7; ++c) { + ImGui::TableSetColumnIndex(c); + ImGui::TextUnformatted(cells[c - 1]); + } + ++row; + } + ImGui::EndTable(); + } + } + return end(); +} diff --git a/iqpilot/tools/cabana/ui/tools/routeinfo.h b/iqpilot/tools/cabana/ui/tools/routeinfo.h new file mode 100644 index 000000000..c97bff3ce --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/routeinfo.h @@ -0,0 +1,14 @@ +#pragma once + +#include "tools/cabana/ui/tools/tooldialog.h" + +class Replay; + +class RouteInfoDlg : public ToolDialog { +public: + RouteInfoDlg(); + bool draw() override; + +private: + Replay *replay_ = nullptr; +}; diff --git a/iqpilot/tools/cabana/ui/tools/tooldialog.h b/iqpilot/tools/cabana/ui/tools/tooldialog.h new file mode 100644 index 000000000..9bc9fe2e0 --- /dev/null +++ b/iqpilot/tools/cabana/ui/tools/tooldialog.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/util.h" + + +class ToolDialog { +public: + virtual ~ToolDialog() = default; + virtual bool draw() = 0; + + Connections connections_; + +protected: + void setTitle(const std::string &name) { + char buf[32]; + snprintf(buf, sizeof(buf), "###tooldialog%p", (void *)this); + title_ = name + buf; + } + + + bool begin(const ImVec2 &size) { + if (!open_) return false; + ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + setNextWindowFloatsOut(); + began_ = true; + return visible_ = ImGui::Begin(title_.c_str(), &open_, ImGuiWindowFlags_NoSavedSettings); + } + + bool end() { + if (!began_) return false; + + if (visible_ && ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) && + ImGui::IsKeyPressed(ImGuiKey_Escape, false) && + !ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel)) { + open_ = false; + } + ImGui::End(); + began_ = false; + return open_; + } + + std::string title_; + bool open_ = true; + +private: + bool began_ = false, visible_ = false; +}; diff --git a/iqpilot/tools/cabana/ui/util.cc b/iqpilot/tools/cabana/ui/util.cc new file mode 100644 index 000000000..22409f7d5 --- /dev/null +++ b/iqpilot/tools/cabana/ui/util.cc @@ -0,0 +1,466 @@ +#include "tools/cabana/ui/util.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#ifdef __APPLE__ +#include +#include +extern "C" { +struct objc_object; +struct objc_selector; +objc_object *glfwGetCocoaWindow(GLFWwindow *window); +objc_selector *sel_registerName(const char *name); +void objc_msgSend(void); +} +#endif + +#include "tools/cabana/ui/icons.h" + +int inputCallback(ImGuiInputTextCallbackData *data) { + auto *ctx = static_cast(data->UserData); + if (data->EventFlag == ImGuiInputTextFlags_CallbackCharFilter) { + return ctx->validator ? ctx->validator(data) : 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackEdit) { + if (ctx->validate(std::string(data->Buf, data->BufTextLen)) == ValidState::Invalid) { + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, ctx->last_valid->c_str()); + } + return 0; + } + if (data->EventFlag == ImGuiInputTextFlags_CallbackResize) { + ctx->str->resize(data->BufTextLen); + data->Buf = ctx->str->data(); + } + return 0; +} + +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint, + ImGuiInputTextFlags flags) { + InputContext ctx{s, validator}; + flags |= ImGuiInputTextFlags_CallbackResize; + if (validator) flags |= ImGuiInputTextFlags_CallbackCharFilter; + return ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags) { + InputContext ctx{s, nullptr}; + return ImGui::InputTextMultiline(label, s->data(), s->capacity() + 1, size, flags | ImGuiInputTextFlags_CallbackResize, + inputCallback, &ctx); +} + +bool clearableInput(const char *label, std::string *s, const char *hint, ImGuiInputTextCallback validator) { + bool changed = validatedInput(label, s, validator, hint); + if (!s->empty()) { + ImGui::SameLine(0.0f, 0.0f); + ImGui::PushID(label); + if (toolButton("clear", icon::X)) { + s->clear(); + changed = true; + } + ImGui::PopID(); + } + return changed; +} + +bool comboBox(const char *label, int *index, const std::vector &items) { + bool changed = false; + const int count = (int)items.size(); + if (ImGui::BeginCombo(label, *index >= 0 && *index < count ? items[*index].c_str() : "")) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(items[i].c_str(), i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + return changed; +} + +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint, ImGuiInputTextCallback filter) { + const std::string last_valid = *s; + InputContext ctx{s, filter, validate, &last_valid}; + ImGuiInputTextFlags flags = ImGuiInputTextFlags_CallbackResize | ImGuiInputTextFlags_CallbackEdit; + if (filter) flags |= ImGuiInputTextFlags_CallbackCharFilter; + ImGui::InputTextWithHint(label, hint, s->data(), s->capacity() + 1, flags, inputCallback, &ctx); + return *s != last_valid; +} + +int nameValidator(ImGuiInputTextCallbackData *data) { + + if (data->EventChar == ' ') { + data->EventChar = '_'; + return 0; + } + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_')) ? 0 : 1; +} + +int nodeValidator(ImGuiInputTextCallbackData *data) { + + return (data->EventChar < 128 && (std::isalnum((int)data->EventChar) || data->EventChar == '_' || data->EventChar == ',')) ? 0 : 1; +} + +int doubleValidator(ImGuiInputTextCallbackData *data) { + + const ImWchar c = data->EventChar; + return (c < 128 && (std::isdigit((int)c) || c == '+' || c == '-' || c == '.' || c == 'e' || c == 'E')) ? 0 : 1; +} + +int ipValidator(ImGuiInputTextCallbackData *data) { + + const ImWchar c = data->EventChar; + return ((c >= '0' && c <= '9') || c == '.') ? 0 : 1; +} + +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data) { + + return (data->EventChar < 128 && std::isspace((int)data->EventChar)) ? 1 : 0; +} + +bool toolButton(const char *id, const char *icon, const char *tooltip, const char *text) { + std::string label = text && *text ? std::string(icon) + " " + text + "###" + id : std::string(icon) + "###" + id; + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + bool clicked = ImGui::Button(label.c_str()); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + if (tooltip && *tooltip) ImGui::SetItemTooltip("%s", tooltip); + return clicked; +} + +void disabledItemTooltip(const char *text) { + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip | ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", text); +} + +bool radioMenuItem(const char *label, bool checked, float width) { + const float indent = ImGui::GetFontSize(); + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const bool clicked = ImGui::Selectable((std::string("##") + label).c_str(), false, ImGuiSelectableFlags_None, + ImVec2(ImMax(width, ImGui::GetContentRegionAvail().x), 0.0f)); + const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text); + ImDrawList *painter = ImGui::GetWindowDrawList(); + if (checked) ImGui::RenderBullet(painter, ImVec2(pos.x + indent / 2, pos.y + ImGui::GetTextLineHeight() / 2), color); + painter->AddText(ImVec2(pos.x + indent, pos.y), color, label); + return clicked; +} + +bool PopupOwner::begin(const char *id) { + ImGuiWindow *window = ImGui::GetCurrentWindowRead(); + if (popup_id == 0) { + + + ImGuiWindow *modal = ImGui::GetTopMostPopupModal(); + if (modal != nullptr && modal != window) return false; + ImGui::OpenPopup(id); + popup_id = window->GetID(id); + owner_id = window->ID; + } else if (owner_id != window->ID) { + return false; + } else if (!ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_AnyPopupLevel)) { + + ImGui::OpenPopup(id); + } + return true; +} + +ImGuiWindow *topPopupWindow() { + ImGuiContext &g = *GImGui; + return g.OpenPopupStack.Size > 0 ? g.OpenPopupStack.back().Window : nullptr; +} + +bool dialogEscapePressed() { + return ImGui::IsKeyPressed(ImGuiKey_Escape, false) && topPopupWindow() == ImGui::GetCurrentWindow(); +} + +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled, + const char *reject_label) { + const float button_width = 80.0f; + const int count = reject_label ? 2 : 1; + const float total = button_width * count + ImGui::GetStyle().ItemSpacing.x * (count - 1); + const float avail = ImGui::GetContentRegionAvail().x; + if (avail > total) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - total); + bool pressed = false; + if (reject_label) { + if (ImGui::Button(reject_label, ImVec2(button_width, 0.0f))) { + if (rejected) *rejected = true; + pressed = true; + } + ImGui::SameLine(); + } + ImGui::BeginDisabled(!accept_enabled); + if (ImGui::Button(accept_label, ImVec2(button_width, 0.0f))) { + if (accepted) *accepted = true; + pressed = true; + } + ImGui::EndDisabled(); + if (rejected && dialogEscapePressed()) { + *rejected = true; + pressed = true; + } + return pressed; +} + +int tableHeadersRow() { + int clicked = -1; + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + for (int c = 0, count = ImGui::TableGetColumnCount(); c < count; ++c) { + if (!ImGui::TableSetColumnIndex(c)) continue; + const char *name = ImGui::TableGetColumnName(c); + if (!name) name = ""; + const float offset = (ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(name).x) * 0.5f; + if (offset > 0) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset); + ImGui::PushID(c); + ImGui::TableHeader(name); + + + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) clicked = c; + ImGui::PopID(); + } + return clicked; +} + +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size) { + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, selected ? ImGui::GetColorU32(ImGuiCol_Header) : IM_COL32(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImGui::GetColorU32(ImGuiCol_Header)); + const bool clicked = ImGui::Selectable(label, selected, flags, size); + ImGui::PopStyleColor(2); + return clicked; +} + +bool checkBox(const char *label, bool *v) { + const float box = 16.0f; + ImGuiWindow *window = ImGui::GetCurrentWindow(); + if (window->SkipItems) return false; + const ImGuiStyle &style = ImGui::GetStyle(); + const ImGuiID id = window->GetID(label); + const ImVec2 label_size = ImGui::CalcTextSize(label, nullptr, true); + const float frame_h = ImGui::GetFrameHeight(); + const ImVec2 pos = window->DC.CursorPos; + const ImRect total_bb(pos, ImVec2(pos.x + box + (label_size.x > 0.0f ? style.ItemInnerSpacing.x + label_size.x : 0.0f), pos.y + frame_h)); + ImGui::ItemSize(total_bb, style.FramePadding.y); + if (!ImGui::ItemAdd(total_bb, id)) return false; + bool hovered, held; + const bool pressed = ImGui::ButtonBehavior(total_bb, id, &hovered, &held); + if (pressed) { + *v = !*v; + ImGui::MarkItemEdited(id); + } + const float y = pos.y + IM_TRUNC((frame_h - box) * 0.5f); + const ImRect check_bb(ImVec2(pos.x, y), ImVec2(pos.x + box, y + box)); + ImGui::RenderNavCursor(total_bb, id); + const ImU32 bg = ImGui::GetColorU32((held && hovered) ? ImGuiCol_FrameBgActive : hovered ? ImGuiCol_FrameBgHovered : ImGuiCol_FrameBg); + ImGui::RenderFrame(check_bb.Min, check_bb.Max, bg, true, style.FrameRounding); + if (*v) { + const float pad = ImMax(1.0f, IM_TRUNC(box / 6.0f)); + ImGui::RenderCheckMark(window->DrawList, ImVec2(check_bb.Min.x + pad, check_bb.Min.y + pad), ImGui::GetColorU32(ImGuiCol_CheckMark), box - pad * 2.0f); + } + if (label_size.x > 0.0f) ImGui::RenderText(ImVec2(check_bb.Max.x + style.ItemInnerSpacing.x, pos.y + style.FramePadding.y), label); + return pressed; +} + +void alignRight(float width) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - width)); +} + +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font, float font_size, const ImVec2 &align) { + if (font == nullptr) font = ImGui::GetFont(); + if (font_size <= 0.0f) font_size = ImGui::GetFontSize(); + const ImVec2 size = font->CalcTextSizeA(font_size, FLT_MAX, 0.0f, text); + const ImVec2 pos(rect.Min.x + (rect.GetWidth() - size.x) * align.x, rect.Min.y + (rect.GetHeight() - size.y) * align.y); + dl->AddText(font, font_size, pos, col, text); +} + +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right) { + const ImVec2 size = ImGui::CalcTextSize(text.c_str()); + const float y = rect.Min.y + std::max(0.0f, (rect.GetHeight() - size.y) * 0.5f); + if (size.x <= rect.GetWidth()) { + dl->AddText(ImVec2(align_right ? rect.Max.x - size.x : rect.Min.x, y), col, text.c_str()); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::RenderTextEllipsis(dl, ImVec2(rect.Min.x, y), ImVec2(rect.Max.x, y + size.y), rect.Max.x, text.c_str(), nullptr, &size); + ImGui::PopStyleColor(); + } +} + +float markerSize() { return ImGui::GetTextLineHeight() - 4; } + +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col) { + const float size = markerSize(); + dl->AddRectFilled(ImVec2(pos.x, pos.y + 2), ImVec2(pos.x + size, pos.y + 2 + size), col); +} + +#ifdef __APPLE__ +void setMacAppName(const char *name) { + auto info = (CFMutableDictionaryRef)CFBundleGetInfoDictionary(CFBundleGetMainBundle()); + if (info == nullptr) return; + CFStringRef value = CFStringCreateWithCString(kCFAllocatorDefault, name, kCFStringEncodingUTF8); + CFDictionarySetValue(info, CFSTR("CFBundleName"), value); + CFRelease(value); +} + +bool isNativeFullScreen(GLFWwindow *window) { + constexpr unsigned long NS_WINDOW_STYLE_MASK_FULL_SCREEN = 1ul << 14; + objc_object *ns_window = glfwGetCocoaWindow(window); + if (ns_window == nullptr) return false; + auto styleMask = (unsigned long (*)(objc_object *, objc_selector *))objc_msgSend; + return (styleMask(ns_window, sel_registerName("styleMask")) & NS_WINDOW_STYLE_MASK_FULL_SCREEN) != 0; +} + +void toggleNativeFullScreen(GLFWwindow *window) { + auto toggle = (void (*)(objc_object *, objc_selector *, objc_object *))objc_msgSend; + toggle(glfwGetCocoaWindow(window), sel_registerName("toggleFullScreen:"), nullptr); +} +#endif + +void setNextWindowFloatsOut() { + ImGuiWindowClass window_class; + window_class.ViewportFlagsOverrideSet = ImGuiViewportFlags_NoAutoMerge; + ImGui::SetNextWindowClass(&window_class); +} + +void setNextDialogWindow(const ImVec2 &size) { + if (size.x > 0.0f || size.y > 0.0f) ImGui::SetNextWindowSize(size, ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + setNextWindowFloatsOut(); +} + +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags) { + if (!owner->begin(id)) return false; + setNextDialogWindow(size); + return ImGui::BeginPopupModal(id, nullptr, flags | ImGuiWindowFlags_NoSavedSettings); +} + + + +void beginToolbar() { + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(TOOLBAR_ITEM_SPACING, ImGui::GetStyle().ItemSpacing.y)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(TOOLBAR_BUTTON_PADDING, ImGui::GetStyle().FramePadding.y)); +} + +void endToolbar() { ImGui::PopStyleVar(2); } + +float toolbarButtonWidth(const std::string &label) { + return ImGui::CalcTextSize(label.c_str(), nullptr, true).x + ImGui::GetStyle().FramePadding.x * 2; +} + +static float toolbarGroupWidth(const std::vector &items, size_t begin, size_t end) { + float w = 0; + for (size_t i = begin; i < end; ++i) w += items[i].width + (i > begin ? ImGui::GetStyle().ItemSpacing.x : 0); + return w; +} + +float toolbarWidth(const std::vector &items, size_t spacer_index) { + spacer_index = std::min(spacer_index, items.size()); + float w = toolbarGroupWidth(items, 0, spacer_index) + toolbarGroupWidth(items, spacer_index, items.size()); + if (spacer_index > 0 && spacer_index < items.size()) w += ImGui::GetStyle().ItemSpacing.x; + return w; +} + +void drawToolbar(const std::vector &items, size_t spacer_index) { + const ImGuiStyle &style = ImGui::GetStyle(); + spacer_index = std::min(spacer_index, items.size()); + const float right_width = toolbarGroupWidth(items, spacer_index, items.size()); + const float start_x = ImGui::GetCursorPosX(); + const float avail = ImGui::GetContentRegionAvail().x; + const float right_edge = start_x + avail; + const float extension_width = toolbarButtonWidth(icon::RAQUO); + + + + const bool fits = toolbarWidth(items, spacer_index) <= avail; + size_t visible = items.size(); + if (!fits) { + const float usable = avail - (extension_width + style.ItemSpacing.x); + float used = 0; + for (visible = 0; visible < items.size(); ++visible) { + const float w = items[visible].width + (visible ? style.ItemSpacing.x : 0); + if (used + w > usable) break; + used += w; + } + } + + for (size_t i = 0; i < visible; ++i) { + if (i == 0) ImGui::SetCursorPosX(start_x); + else if (fits && i == spacer_index) ImGui::SameLine(right_edge - right_width); + else ImGui::SameLine(); + items[i].draw(); + } + + if (visible < items.size()) { + + const float extension_x = std::max(start_x, right_edge - extension_width); + visible == 0 ? ImGui::SetCursorPosX(extension_x) : ImGui::SameLine(extension_x); + if (ImGui::Button((std::string(icon::RAQUO) + "###toolbar_extension").c_str(), ImVec2(extension_width, 0))) + ImGui::OpenPopup("toolbar_extension_menu"); + ImGui::SetItemTooltip("More"); + + ImGui::SetNextWindowPos(ImVec2(ImGui::GetItemRectMax().x, ImGui::GetItemRectMax().y), ImGuiCond_Always, ImVec2(1, 0)); + if (ImGui::BeginPopup("toolbar_extension_menu")) { + for (size_t i = visible; i < items.size(); ++i) { + if (!items[i].in_menu) continue; + if (items[i].menu_label.empty()) { + items[i].draw(); + } else if (ImGui::MenuItem(items[i].menu_label.c_str(), nullptr, false, items[i].enabled)) { + items[i].trigger(); + } + } + ImGui::EndPopup(); + } + } +} + +const float MENU_ARROW_SIZE = 6.0f; +const float MENU_ARROW_SPACING = 5.0f; + +float menuButtonWidth(const std::string &text, bool bold) { + if (bold) pushBoldFont(); + const float w = ImGui::CalcTextSize(text.c_str(), nullptr, true).x + MENU_ARROW_SPACING + MENU_ARROW_SIZE + + ImGui::GetStyle().FramePadding.x * 2; + if (bold) popBoldFont(); + return w; +} + +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold, float width) { + const ImGuiStyle &style = ImGui::GetStyle(); + const bool popup_open = ImGui::IsPopupOpen(popup_id); + if (width <= 0.0f) width = menuButtonWidth(text, bold); + + + + if (bold) pushBoldFont(); + ImGui::PushStyleColor(ImGuiCol_Button, popup_open ? style.Colors[ImGuiCol_ButtonActive] : ImVec4(0, 0, 0, 0)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.0f, 0.5f)); + const bool clicked = ImGui::ButtonEx((text + "###" + id).c_str(), ImVec2(width, 0.0f), ImGuiButtonFlags_PressedOnClick); + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); + const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x; + const float ascent = ImGui::GetFontBaked()->Ascent; + if (bold) popBoldFont(); + + const ImVec2 min = ImGui::GetItemRectMin(); + const float x = min.x + style.FramePadding.x + text_width + MENU_ARROW_SPACING; + const float baseline = min.y + style.FramePadding.y + ascent; + ImGui::GetWindowDrawList()->AddTriangleFilled(ImVec2(x, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE, baseline - MENU_ARROW_SIZE * 0.5f), + ImVec2(x + MENU_ARROW_SIZE * 0.5f, baseline), + ImGui::GetColorU32(ImGuiCol_TextDisabled)); + if (clicked && !popup_open) ImGui::OpenPopup(popup_id); + + ImGui::SetNextWindowPos(ImVec2(min.x, ImGui::GetItemRectMax().y), ImGuiCond_Always); + return clicked; +} diff --git a/iqpilot/tools/cabana/ui/util.h b/iqpilot/tools/cabana/ui/util.h new file mode 100644 index 000000000..62237df4a --- /dev/null +++ b/iqpilot/tools/cabana/ui/util.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/color.h" +#include "tools/cabana/utils/util.h" + +struct GLFWwindow; + +inline ImVec4 colorRgb(int r, int g, int b, float alpha = 1.0f) { + return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, alpha); +} + +inline ImU32 toImU32(const CabanaColor &c) { return IM_COL32(c.r, c.g, c.b, c.a); } +inline ImVec4 toImVec4(const CabanaColor &c) { return ImVec4(c.r / 255.0f, c.g / 255.0f, c.b / 255.0f, c.a / 255.0f); } +inline ImU32 withAlpha(ImU32 c, int alpha) { return (c & ~IM_COL32_A_MASK) | ((ImU32)alpha << IM_COL32_A_SHIFT); } + + +constexpr const char *MESSAGES_PANEL_ID = "###MessagesPanel"; + +struct InputContext { + std::string *str; + ImGuiInputTextCallback validator; + ValidState (*validate)(const std::string &) = nullptr; + const std::string *last_valid = nullptr; +}; + +int inputCallback(ImGuiInputTextCallbackData *data); + + +bool validatedInput(const char *label, std::string *s, ImGuiInputTextCallback validator, const char *hint = "", + ImGuiInputTextFlags flags = 0); + +inline bool inputText(const char *label, std::string *s, const char *hint = "", ImGuiInputTextFlags flags = 0) { + return validatedInput(label, s, nullptr, hint, flags); +} + +bool inputTextMultiline(const char *label, std::string *s, const ImVec2 &size, ImGuiInputTextFlags flags = 0); + + +bool clearableInput(const char *label, std::string *s, const char *hint = "", ImGuiInputTextCallback validator = nullptr); + +bool comboBox(const char *label, int *index, const std::vector &items); + + +template +inline bool comboBox(const char *label, int *index, const T *values, int count) { + bool changed = false; + const std::string preview = *index >= 0 && *index < count ? std::to_string(values[*index]) : ""; + if (ImGui::BeginCombo(label, preview.c_str())) { + for (int i = 0; i < count; ++i) { + ImGui::PushID(i); + if (ImGui::Selectable(std::to_string(values[i]).c_str(), i == *index) && *index != i) { + *index = i; + changed = true; + } + if (i == *index) ImGui::SetItemDefaultFocus(); + ImGui::PopID(); + } + ImGui::EndCombo(); + } + return changed; +} + + +bool validatedText(const char *label, std::string *s, ValidState (*validate)(const std::string &), + const char *hint = "", ImGuiInputTextCallback filter = nullptr); + + +int nameValidator(ImGuiInputTextCallbackData *data); +int nodeValidator(ImGuiInputTextCallbackData *data); +int doubleValidator(ImGuiInputTextCallbackData *data); +int ipValidator(ImGuiInputTextCallbackData *data); +int nonWhitespaceValidator(ImGuiInputTextCallbackData *data); + + +bool toolButton(const char *id, const char *icon, const char *tooltip = nullptr, const char *text = nullptr); + + +void disabledItemTooltip(const char *text); + + + +bool radioMenuItem(const char *label, bool checked, float width = 0.0f); + + + + +struct PopupOwner { + ImGuiID popup_id = 0, owner_id = 0; + + + bool begin(const char *id); + + void reset() { popup_id = owner_id = 0; } +}; + + +bool dialogEscapePressed(); + + +ImGuiWindow *topPopupWindow(); + + +bool dialogButtons(const char *accept_label, bool *accepted, bool *rejected, bool accept_enabled = true, + const char *reject_label = "Cancel"); + + +int tableHeadersRow(); + + + + +bool viewSelectable(const char *label, bool selected, ImGuiSelectableFlags flags, const ImVec2 &size); + + + +bool checkBox(const char *label, bool *v); + + +void alignRight(float width); + + +void drawText(ImDrawList *dl, const ImRect &rect, const char *text, ImU32 col, ImFont *font = nullptr, + float font_size = 0.0f, const ImVec2 &align = ImVec2(0.5f, 0.5f)); + +void drawElidedText(ImDrawList *dl, const ImRect &rect, const std::string &text, ImU32 col, bool align_right = false); + +float markerSize(); +void drawColorMarker(ImDrawList *dl, const ImVec2 &pos, ImU32 col); + +void loadFonts(); +void applyTheme(int theme); +bool isDarkTheme(); + +ImU32 highlightedTextColor(); +ImU32 paletteBrightText(); + + +void setNextWindowFloatsOut(); +#ifdef __APPLE__ + + +void setMacAppName(const char *name); + +bool isNativeFullScreen(GLFWwindow *window); +void toggleNativeFullScreen(GLFWwindow *window); +#endif + + +void setNextDialogWindow(const ImVec2 &size); + +bool beginDialog(const char *id, PopupOwner *owner, const ImVec2 &size, ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize); + +const float TOOLBAR_ITEM_SPACING = 1.0f; +const float TOOLBAR_BUTTON_PADDING = 4.0f; +const float SLIDER_LENGTH = 13.0f; +const float SLIDER_THICKNESS = 13.0f; + + + +struct ToolbarItem { + float width; + std::function draw; + std::string menu_label; + std::function trigger; + bool enabled = true; + bool in_menu = true; +}; +void beginToolbar(); +void endToolbar(); +float toolbarButtonWidth(const std::string &label); + +float toolbarWidth(const std::vector &items, size_t spacer_index); + +void drawToolbar(const std::vector &items, size_t spacer_index); + + + +float menuButtonWidth(const std::string &text, bool bold = false); +bool menuButton(const char *id, const std::string &text, const char *popup_id, bool bold = false, float width = 0.0f); + + +void drawSliderHandle(ImDrawList *p, const ImRect &r); + + +bool fusionSliderInt(const char *label, int *v, int min, int max, float width); + +ImFont *boldFont(); +ImFont *monoFont(); +void pushMonoFont(float size = 0.0f); +void popMonoFont(); +void pushBoldFont(); +void popBoldFont(); +void pushLargeFont(); +void popLargeFont(); diff --git a/iqpilot/tools/cabana/ui/widgets/binaryview.cc b/iqpilot/tools/cabana/ui/widgets/binaryview.cc new file mode 100644 index 000000000..4c139d26b --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/binaryview.cc @@ -0,0 +1,548 @@ +#include "tools/cabana/ui/widgets/binaryview.h" + +#include +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +const int CELL_HEIGHT = 36; +const float SMALL_FONT_SIZE = 10.0f; +const int VERTICAL_HEADER_WIDTH = 30; +inline int get_bit_pos(const BinaryIndex &index) { return flipBitPos(index.row * 8 + index.column); } + +inline ImU32 paletteHighlight() { return ImGui::GetColorU32(ImGuiCol_Header); } +inline ImU32 paletteBase() { return ImGui::GetColorU32(ImGuiCol_ChildBg); } +inline ImU32 paletteText(bool active) { return ImGui::GetColorU32(active ? ImGuiCol_Text : ImGuiCol_TextDisabled); } +const ImU32 DARK_GRAY = IM_COL32(128, 128, 128, 255); + + + +void drawBoldText(ImDrawList *p, const ImRect &r, const char *text, ImU32 col, ImFont *font, float font_size) { + drawText(p, r, text, col, font, font_size); + drawText(p, ImRect(ImVec2(r.Min.x + 0.6f, r.Min.y), ImVec2(r.Max.x + 0.6f, r.Max.y)), text, col, font, font_size); +} + + +void fillDense7Pattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + for (float y = r.Min.y; y < r.Max.y; y += 4.0f) { + for (float x = r.Min.x + (static_cast((y - r.Min.y) / 4.0f) % 2) * 2.0f; x < r.Max.x; x += 4.0f) { + p->AddRectFilled(ImVec2(x, y), ImVec2(x + 1.0f, y + 1.0f), col); + } + } + p->PopClipRect(); +} + + +void fillBDiagPattern(ImDrawList *p, const ImRect &r, ImU32 col) { + p->PushClipRect(r.Min, r.Max, true); + const float h = r.GetHeight(); + for (float x = r.Min.x - h; x < r.Max.x; x += 8.0f) { + p->AddLine(ImVec2(x, r.Max.y), ImVec2(x + h, r.Min.y), col, 1.0f); + } + p->PopClipRect(); +} + +} + +BinaryView::BinaryView() { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); +} + +std::string BinaryView::whatsThis() const { + return R"( + Binary View
+ Shortcuts
+ Delete Signal: +  x , +  Backspace , +  Delete 
+ Change endianness:  e 
+ Change signedness:  s 
+ Open chart: +  c , +  p , +  g  + )"; +} + +void BinaryView::addShortcuts() { + const ImGuiIO &io = ImGui::GetIO(); + if (io.WantTextInput || io.KeyCtrl || io.KeySuper) return; + if (ImGui::GetTopMostPopupModal() != nullptr) return; + + if (ImGui::IsKeyPressed(ImGuiKey_X, false) || ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { + if (hovered_sig_ != nullptr) { + UndoStack::instance()->push(new RemoveSigCommand(msg_id_, hovered_sig_)); + hovered_sig_ = nullptr; + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_E, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_little_endian = !s.is_little_endian; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_S, false)) { + if (hovered_sig_ != nullptr) { + cabana::Signal s = *hovered_sig_; + s.is_signed = !s.is_signed; + editSignal(hovered_sig_, s); + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_P, false) || ImGui::IsKeyPressed(ImGuiKey_G, false) || ImGui::IsKeyPressed(ImGuiKey_C, false)) { + if (hovered_sig_ != nullptr) { + showChart(msg_id_, hovered_sig_, true, false); + } + } +} + +ImVec2 BinaryView::minimumSizeHint() const { + + pushMonoFont(); + const float min_section_size = ImGui::CalcTextSize("W").x + 8.0f; + popMonoFont(); + return {(min_section_size + 1) * 9 + VERTICAL_HEADER_WIDTH + 2, + static_cast(CELL_HEIGHT * std::min(row_count_, 10) + 2)}; +} + +void BinaryView::highlight(const cabana::Signal *sig) { + if (sig != hovered_sig_) { + hovered_sig_ = sig; + signalHovered(hovered_sig_); + } +} + +void BinaryView::setSelection() { + auto index = indexAt(last_mouse_pos_); + if (!anchor_index_.isValid() || !index.isValid()) + return; + + std::set selection; + auto [start, size, is_lb] = getSelection(index); + for (int i = 0; i < size; ++i) { + int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i; + selection.insert({pos / 8, pos % 8}); + } + selection_ = std::move(selection); +} + +void BinaryView::handleMousePress(const ImVec2 &pos) { + resize_sig_ = nullptr; + if (auto index = indexAt(last_mouse_pos_ = pos); index.isValid() && index.column != HEX_COLUMN) { + anchor_index_ = index; + auto item = &cellAt(anchor_index_); + int bit_pos = get_bit_pos(anchor_index_); + for (auto s : item->sigs) { + if (bit_pos == s->lsb || bit_pos == s->msb) { + int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb); + anchor_index_ = {idx / 8, idx % 8}; + resize_sig_ = s; + break; + } + } + } +} + +void BinaryView::highlightPosition(const ImVec2 &pos) { + if (auto index = indexAt(pos); index.isValid()) { + auto item = &cellAt(index); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); + highlight(sig); + } +} + +void BinaryView::handleMouseMove(const ImVec2 &pos) { + highlightPosition(last_mouse_pos_ = pos); + + if (ImGui::IsMouseDown(ImGuiMouseButton_Left) && indexAt(pos).column != HEX_COLUMN) setSelection(); +} + +void BinaryView::handleMouseRelease(const ImVec2 &pos) { + auto release_index = indexAt(pos); + if (release_index.isValid() && anchor_index_.isValid()) { + if (hasSelection()) { + auto sig = resize_sig_ ? *resize_sig_ : cabana::Signal{}; + std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); + resize_sig_ ? editSignal(resize_sig_, sig) + : UndoStack::instance()->push(new AddSigCommand(msg_id_, sig)); + } else { + auto item = &cellAt(anchor_index_); + if (item->sigs.size() > 0) + signalClicked(item->sigs.back()); + } + } + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; +} + +void BinaryView::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + scroll_to_top_ = true; + refresh(); +} + +void BinaryView::refresh() { + selection_.clear(); + anchor_index_ = BinaryIndex(); + resize_sig_ = nullptr; + hovered_sig_ = nullptr; + bit_flip_tracker_ = {}; + cells_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) { + row_count_ = dbc_msg->size; + cells_.resize(row_count_ * COLUMN_COUNT); + for (auto sig : dbc_msg->getSignals()) { + for (int j = 0; j < sig->size; ++j) { + int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; + int idx = COLUMN_COUNT * (pos / 8) + pos % 8; + if (idx >= cells_.size()) { + fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", sig->name.c_str(), sig->start_bit, sig->size); + break; + } + if (j == 0) sig->is_little_endian ? cells_[idx].is_lsb = true : cells_[idx].is_msb = true; + if (j == sig->size - 1) sig->is_little_endian ? cells_[idx].is_msb = true : cells_[idx].is_lsb = true; + + auto &sigs = cells_[idx].sigs; + sigs.push_back(sig); + if (sigs.size() > 1) { + std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; }); + } + } + } + } else { + row_count_ = can->lastMessage(msg_id_).dat.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + updateState(); + if (under_mouse_) highlightPosition(last_mouse_pos_); +} + + +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; + for (const auto &item : cells_) { + if (item.sigs.size() > 1) { + for (auto s : item.sigs) { + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); + } + } + } + return overlapping; +} + +std::tuple BinaryView::getSelection(BinaryIndex index) { + if (index.column == HEX_COLUMN) { + index = {index.row, 7}; + } + bool is_lb = true; + if (resize_sig_) { + is_lb = resize_sig_->is_little_endian; + } else if (settings.drag_direction == Settings::DragDirection::MsbFirst) { + is_lb = index < anchor_index_; + } else if (settings.drag_direction == Settings::DragDirection::LsbFirst) { + is_lb = !(index < anchor_index_); + } else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) { + is_lb = true; + } else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) { + is_lb = false; + } + + int cur_bit_pos = get_bit_pos(index); + int anchor_bit_pos = get_bit_pos(anchor_index_); + int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index_)); + int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1; + return {start_bit, size, is_lb}; +} + +BinaryIndex BinaryView::indexAt(const ImVec2 &pos) const { + if (column_width_ <= 0 || pos.x < grid_pos_.x + VERTICAL_HEADER_WIDTH || pos.y < grid_pos_.y) return {}; + int column = static_cast((pos.x - grid_pos_.x - VERTICAL_HEADER_WIDTH) / column_width_); + int row = static_cast((pos.y - grid_pos_.y) / CELL_HEIGHT); + if (column >= COLUMN_COUNT || row >= row_count_) return {}; + return {row, column}; +} + +ImRect BinaryView::visualRect(const BinaryIndex &index) const { + + + const float x0 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND(index.column * column_width_); + const float x1 = grid_pos_.x + VERTICAL_HEADER_WIDTH + IM_ROUND((index.column + 1) * column_width_); + const float y = grid_pos_.y + index.row * CELL_HEIGHT; + return ImRect(x0, y, x1, y + CELL_HEIGHT); +} + +void BinaryView::draw() { + is_message_active_ = can->isMessageActive(msg_id_); + if (scroll_to_top_) { + ImGui::SetScrollY(0.0f); + scroll_to_top_ = false; + } + + const int rows = row_count_; + const float width = ImGui::GetContentRegionAvail().x; + column_width_ = std::max(1.0f, (width - VERTICAL_HEADER_WIDTH) / COLUMN_COUNT); + grid_pos_ = ImGui::GetCursorScreenPos(); + ImGui::InvisibleButton("##binary_view", ImVec2(std::max(width, 1.0f), std::max(static_cast(rows * CELL_HEIGHT), 1.0f))); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + for (int row = 0; row < rows; ++row) { + const ImRect r(grid_pos_.x, grid_pos_.y + row * CELL_HEIGHT, grid_pos_.x + VERTICAL_HEADER_WIDTH, grid_pos_.y + (row + 1) * CELL_HEIGHT); + painter->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); + drawText(painter, r, std::to_string(row).c_str(), ImGui::GetColorU32(ImGuiCol_Text)); + } + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMN_COUNT; ++column) { + const BinaryIndex index = {row, column}; + paintCell(painter, visualRect(index), index); + } + } + + const ImVec2 mouse = ImGui::GetMousePos(); + const bool hovered = ImGui::IsItemHovered(); + const bool active = ImGui::IsItemActive(); + const bool under_mouse = (hovered || active) && ImGui::IsMouseHoveringRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax(), false); + if (hovered || active) { + if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) handleMousePress(mouse); + const ImVec2 delta = ImGui::GetIO().MouseDelta; + if (delta.x != 0.0f || delta.y != 0.0f) { + handleMouseMove(mouse); + } else { + + + highlightPosition(last_mouse_pos_ = mouse); + } + } + + if (std::exchange(under_mouse_, under_mouse) && !under_mouse) highlight(nullptr); + if (ImGui::IsItemDeactivated()) handleMouseRelease(mouse); + + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip)) { + if (auto index = indexAt(mouse); index.isValid() && !cellAt(index).sigs.empty()) { + ImGui::SetTooltip("%s", utils::stripHtml(utils::signalToolTip(cellAt(index).sigs.back())).c_str()); + } + } + + addShortcuts(); +} + +void BinaryView::setCell(int row, int col, uint8_t val, const CabanaColor &color) { + auto &item = cells_[row * COLUMN_COUNT + col]; + item.valid = true; + item.val = val; + item.bg_color = color; +} + +void BinaryView::updateState() { + const auto &last_msg = can->lastMessage(msg_id_); + const auto &binary = last_msg.dat; + if (binary.size() > row_count_) { + row_count_ = binary.size(); + cells_.resize(row_count_ * COLUMN_COUNT); + } + + auto &bit_flips = heatmap_live_mode_ ? last_msg.bit_flip_counts : bitFlipChanges(binary.size()); + uint32_t max_bit_flip_count = 1; + for (const auto &row : bit_flips) { + for (uint32_t count : row) { + max_bit_flip_count = std::max(max_bit_flip_count, count); + } + } + + const bool dark = isDarkTheme(); + const double max_alpha = 255.0; + const double min_alpha_with_signal = dark ? 70.0 : 25.0; + const double min_alpha_no_signal = dark ? 28.0 : 10.0; + const double alpha_gamma = dark ? 0.6 : 1.0; + const double log_factor = 1.0 + 0.2; + const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count); + + for (size_t i = 0; i < binary.size(); ++i) { + for (int j = 0; j < 8; ++j) { + auto &item = cells_[i * COLUMN_COUNT + j]; + int bit_val = (binary[i] >> (7 - j)) & 1; + + double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal; + uint32_t flip_count = bit_flips[i][j]; + if (flip_count > 0) { + double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler; + normalized_alpha = max_alpha * std::pow(std::clamp(normalized_alpha / max_alpha, 0.0, 1.0), alpha_gamma); + double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal; + alpha = std::clamp(normalized_alpha, min_alpha, max_alpha); + } + + auto color = item.bg_color; + color.a = static_cast(alpha); + setCell(i, j, bit_val, color); + } + setCell(i, HEX_COLUMN, binary[i], last_msg.colors[i]); + } +} + +const std::vector> &BinaryView::bitFlipChanges(size_t msg_size) { + auto time_range = can->timeRange(); + if (bit_flip_tracker_.time_range == time_range && !bit_flip_tracker_.flip_counts.empty()) + return bit_flip_tracker_.flip_counts; + + bit_flip_tracker_.time_range = time_range; + bit_flip_tracker_.flip_counts.assign(msg_size, std::array{}); + + auto [first, last] = can->eventsInRange(msg_id_, time_range); + if (std::distance(first, last) <= 1) return bit_flip_tracker_.flip_counts; + + std::vector prev_values((*first)->dat, (*first)->dat + (*first)->size); + for (auto it = std::next(first); it != last; ++it) { + const CanEvent *event = *it; + int size = std::min(msg_size, event->size); + for (int i = 0; i < size; ++i) { + const uint8_t diff = event->dat[i] ^ prev_values[i]; + if (!diff) continue; + + auto &bit_flips = bit_flip_tracker_.flip_counts[i]; + for (int bit = 0; bit < 8; ++bit) { + if (diff & (1u << bit)) ++bit_flips[7 - bit]; + } + prev_values[i] = event->dat[i]; + } + } + + return bit_flip_tracker_.flip_counts; +} + +bool BinaryView::hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const { + if (!index.isValid()) return false; + int idx = (index.row + dy) * COLUMN_COUNT + index.column + dx; + if (idx < 0 || idx >= (int)cells_.size()) return false; + auto &s = cells_[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); +} + +void BinaryView::paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const { + auto item = &cellAt(index); + ImFont *font = ImGui::GetFont(); + float font_size = ImGui::GetFontSize(); + ImU32 pen = paletteText(is_message_active_); + + if (index.column == HEX_COLUMN) { + if (item->valid) { + pushMonoFont(); + font = ImGui::GetFont(); + font_size = ImGui::GetFontSize(); + popMonoFont(); + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + } else if (isSelected(index)) { + auto color = resize_sig_ ? toImU32(resize_sig_->color) : paletteHighlight(); + painter->AddRectFilled(rect.Min, rect.Max, color); + pen = paletteBrightText(); + } else if (!hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), resize_sig_) == item->sigs.end()) { + if (item->sigs.size() > 0) { + for (auto &s : item->sigs) { + if (s == hovered_sig_) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(s->color.darker(125))); + } else { + drawSignalCell(painter, rect, index, s); + } + } + } else if (item->valid && item->bg_color.alpha() > 0) { + painter->AddRectFilled(rect.Min, rect.Max, toImU32(item->bg_color)); + } + bool bright = std::find(item->sigs.begin(), item->sigs.end(), hovered_sig_) != item->sigs.end(); + pen = bright ? paletteBrightText() : paletteText(is_message_active_); + } + + if (item->sigs.size() > 1) { + fillDense7Pattern(painter, rect, DARK_GRAY); + } else if (!item->valid) { + fillBDiagPattern(painter, rect, DARK_GRAY); + } + if (item->valid) { + if (index.column == HEX_COLUMN) { + drawBoldText(painter, rect, utils::hexByte(item->val), pen, font, font_size); + } else { + drawText(painter, rect, item->val ? "1" : "0", pen, font, font_size); + } + } + if (item->is_msb || item->is_lsb) { + const ImRect marker_rect(rect.Min, ImVec2(rect.Max.x - 8, rect.Max.y - 3)); + drawText(painter, marker_rect, item->is_msb ? "M" : "L", pen, nullptr, SMALL_FONT_SIZE, ImVec2(1.0f, 1.0f)); + } +} + + +void BinaryView::drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const { + bool draw_left = !hasSignal(index, -1, 0, sig); + bool draw_top = !hasSignal(index, 0, -1, sig); + bool draw_right = !hasSignal(index, 1, 0, sig); + bool draw_bottom = !hasSignal(index, 0, 1, sig); + + const int spacing = 2; + ImRect rc(rect.Min.x + draw_left * 3, rect.Min.y + draw_top * spacing, rect.Max.x - draw_right * 3, rect.Max.y - draw_bottom * spacing); + std::vector subtract; + if (!draw_top) { + if (!draw_left && !hasSignal(index, -1, -1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Min.y, rc.Min.x + 3, rc.Min.y + spacing); + } else if (!draw_right && !hasSignal(index, 1, -1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Min.y, rc.Max.x, rc.Min.y + spacing); + } + } + if (!draw_bottom) { + if (!draw_left && !hasSignal(index, -1, 1, sig)) { + subtract.emplace_back(rc.Min.x, rc.Max.y - spacing, rc.Min.x + 3, rc.Max.y); + } else if (!draw_right && !hasSignal(index, 1, 1, sig)) { + subtract.emplace_back(rc.Max.x - 3, rc.Max.y - spacing, rc.Max.x, rc.Max.y); + } + } + + + const ImRect *top_notch = !subtract.empty() && subtract.front().Min.y == rc.Min.y ? &subtract.front() : nullptr; + const ImRect *bottom_notch = !subtract.empty() && subtract.back().Min.y != rc.Min.y ? &subtract.back() : nullptr; + std::vector region; + auto band = [&](const ImRect *notch, float y0, float y1) { + const float x0 = notch && notch->Min.x == rc.Min.x ? notch->Max.x : rc.Min.x; + const float x1 = notch && notch->Min.x != rc.Min.x ? notch->Min.x : rc.Max.x; + if (x1 > x0 && y1 > y0) region.emplace_back(x0, y0, x1, y1); + }; + if (top_notch) band(top_notch, rc.Min.y, rc.Min.y + spacing); + band(nullptr, rc.Min.y + (top_notch ? spacing : 0), rc.Max.y - (bottom_notch ? spacing : 0)); + if (bottom_notch) band(bottom_notch, rc.Max.y - spacing, rc.Max.y); + + auto item = &cellAt(index); + CabanaColor color = sig->color; + color.a = item->bg_color.alpha(); + const ImU32 edge = toImU32(sig->color.darker(125)); + + for (const ImRect &clip : region) { + painter->PushClipRect(clip.Min, clip.Max, true); + + painter->AddRectFilled(rc.Min, rc.Max, paletteBase()); + painter->AddRectFilled(rc.Min, rc.Max, toImU32(color)); + + if (draw_left) painter->AddLine(ImVec2(rc.Min.x + 0.5f, rc.Min.y), ImVec2(rc.Min.x + 0.5f, rc.Max.y), edge, 1.0f); + if (draw_right) painter->AddLine(ImVec2(rc.Max.x - 0.5f, rc.Min.y), ImVec2(rc.Max.x - 0.5f, rc.Max.y), edge, 1.0f); + if (draw_bottom) painter->AddLine(ImVec2(rc.Min.x, rc.Max.y - 0.5f), ImVec2(rc.Max.x, rc.Max.y - 0.5f), edge, 1.0f); + if (draw_top) painter->AddLine(ImVec2(rc.Min.x, rc.Min.y + 0.5f), ImVec2(rc.Max.x, rc.Min.y + 0.5f), edge, 1.0f); + + + for (auto &r : subtract) { + painter->AddRect(r.Min, r.Max, edge, 0.0f, 0, 2.0f); + } + painter->PopClipRect(); + } +} diff --git a/iqpilot/tools/cabana/ui/widgets/binaryview.h b/iqpilot/tools/cabana/ui/widgets/binaryview.h new file mode 100644 index 000000000..05ae05bfd --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/binaryview.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + + +struct BinaryIndex { + int row = -1; + int column = -1; + bool isValid() const { return row >= 0 && column >= 0; } + bool operator==(const BinaryIndex &o) const { return row == o.row && column == o.column; } + bool operator<(const BinaryIndex &o) const { return std::tie(row, column) < std::tie(o.row, o.column); } +}; + +class BinaryView { +public: + static constexpr int COLUMN_COUNT = 9; + static constexpr int HEX_COLUMN = 8; + + BinaryView(); + void setMessage(const MessageId &message_id); + void highlight(const cabana::Signal *sig); + std::set getOverlappingSignals() const; + void updateState(); + + void draw(); + ImVec2 minimumSizeHint() const; + void setHeatmapLiveMode(bool live) { heatmap_live_mode_ = live; updateState(); } + std::string whatsThis() const; + + Observable signalClicked; + Observable signalHovered; + Observable editSignal; + Observable showChart; + +private: + struct Cell { + CabanaColor bg_color = CabanaColor(102, 86, 169, 255); + bool is_msb = false; + bool is_lsb = false; + uint8_t val; + std::vector sigs; + bool valid = false; + }; + + void refresh(); + void setCell(int row, int col, uint8_t val, const CabanaColor &color); + const std::vector> &bitFlipChanges(size_t msg_size); + Cell &cellAt(const BinaryIndex &index) { return cells_[index.row * COLUMN_COUNT + index.column]; } + const Cell &cellAt(const BinaryIndex &index) const { return cells_[index.row * COLUMN_COUNT + index.column]; } + + void addShortcuts(); + std::tuple getSelection(BinaryIndex index); + void setSelection(); + void handleMousePress(const ImVec2 &pos); + void handleMouseMove(const ImVec2 &pos); + void handleMouseRelease(const ImVec2 &pos); + void highlightPosition(const ImVec2 &pt); + BinaryIndex indexAt(const ImVec2 &pos) const; + ImRect visualRect(const BinaryIndex &index) const; + bool hasSelection() const { return !selection_.empty(); } + bool isSelected(const BinaryIndex &index) const { return selection_.count(index) > 0; } + + void paintCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index) const; + bool hasSignal(const BinaryIndex &index, int dx, int dy, const cabana::Signal *sig) const; + void drawSignalCell(ImDrawList *painter, const ImRect &rect, const BinaryIndex &index, const cabana::Signal *sig) const; + + MessageId msg_id_; + std::vector cells_; + int row_count_ = 0; + bool heatmap_live_mode_ = true; + struct BitFlipTracker { + std::optional> time_range; + std::vector> flip_counts; + } bit_flip_tracker_; + + BinaryIndex anchor_index_; + ImVec2 last_mouse_pos_{-1, -1}; + bool is_message_active_ = false; + const cabana::Signal *resize_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::set selection_; + ImVec2 grid_pos_; + float column_width_ = 0; + bool under_mouse_ = false; + bool scroll_to_top_ = false; + Connections connections_; +}; diff --git a/iqpilot/tools/cabana/ui/widgets/cameraview.cc b/iqpilot/tools/cabana/ui/widgets/cameraview.cc new file mode 100644 index 000000000..685873a3c --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/cameraview.cc @@ -0,0 +1,176 @@ +#include "tools/cabana/ui/widgets/cameraview.h" + +#include +#include +#include + +#include +#include "imgui_impl_opengl3_loader.h" + +#include "common/yuv.h" +#include "tools/cabana/utils/util.h" + +namespace { +constexpr GLenum GL_LINEAR_MIPMAP_LINEAR_ = 0x2703; + +void generateMipmap() { + static auto fn = (void (*)(GLenum))glfwGetProcAddress("glGenerateMipmap"); + if (fn) fn(GL_TEXTURE_2D); +} +} + +void GlTexture::upload(const RgbImage &image) { + if (id == 0) { + glGenTextures(1, &id); + glBindTexture(GL_TEXTURE_2D, id); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mipmap ? GL_LINEAR_MIPMAP_LINEAR_ : GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + } else { + glBindTexture(GL_TEXTURE_2D, id); + } + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + if (width != image.width || height != image.height) { + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + width = image.width; + height = image.height; + } else { + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, image.data.data()); + } + if (mipmap) generateMipmap(); + glBindTexture(GL_TEXTURE_2D, 0); +} + +void GlTexture::destroy() { + if (id != 0) { + glDeleteTextures(1, &id); + } + id = 0; + width = height = 0; + key = 0; +} + +CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type) + : stream_name_(stream_name), active_stream_type_(type), requested_stream_type_(type) {} + +CameraWidget::~CameraWidget() { + stopVipcThread(); +} + +void CameraWidget::startVipcThread() { + if (!vipc_thread_.joinable()) { + clearFrames(); + vipc_exit_ = false; + vipc_thread_ = std::thread(&CameraWidget::vipcThread, this); + } +} + +void CameraWidget::stopVipcThread() { + vipc_exit_ = true; + if (vipc_thread_.joinable()) { + vipc_thread_.join(); + } +} + +void CameraWidget::setVisible(bool visible) { + if (visible == visible_) return; + visible_ = visible; + visible ? startVipcThread() : stopVipcThread(); +} + +void CameraWidget::draw(const ImVec2 &size) { + setVisible(true); + ImGui::InvisibleButton("##camera", ImVec2(std::max(1.0f, size.x), std::max(1.0f, size.y)), + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + paint(); + if (ImGui::IsItemDeactivated()) clicked(); +} + +float CameraWidget::frameAspectRatio() const { + if (frame_texture_.width > 0 && frame_texture_.height > 0) { + return (float)frame_texture_.width / frame_texture_.height; + } + return 1928.0f / 1208.0f; +} + +void CameraWidget::paint() { + ImDrawList *p = ImGui::GetWindowDrawList(); + p->AddRectFilled(rect_.Min, rect_.Max, bg_); + + std::lock_guard lk(frame_lock_); + if (rgb_frame_.isNull()) return; + if (frame_updated_) { + frame_texture_.upload(rgb_frame_); + frame_updated_ = false; + } + + + float widget_ratio = (float)width() / height(); + float frame_ratio = (float)rgb_frame_.width / rgb_frame_.height; + int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); + int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); + ImVec2 video_min(rect_.Min.x + (int)(width() - w) / 2, rect_.Min.y + (int)(height() - h) / 2); + ImVec2 video_max(video_min.x + w, video_min.y + h); + + ImVec2 uv0(0, 0), uv1(1, 1); + if (active_stream_type_ == VISION_STREAM_DRIVER) { + + uv0.x = 1; + uv1.x = 0; + } + p->AddImage(frame_texture_.ref(), video_min, video_max, uv0, uv1); +} + +void CameraWidget::vipcThread() { + VisionStreamType cur_stream = requested_stream_type_; + std::unique_ptr vipc_client; + VisionIpcBufExtra frame_meta = {}; + + while (!vipc_exit_) { + if (!vipc_client || cur_stream != requested_stream_type_) { + clearFrames(); + cur_stream = requested_stream_type_; + vipc_client.reset(new VisionIpcClient(stream_name_, cur_stream, false)); + } + active_stream_type_ = cur_stream; + + if (!vipc_client->connected) { + clearFrames(); + auto streams = VisionIpcClient::getAvailableStreams(stream_name_, false); + if (streams.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + utils::runOnMainThread(utils::guarded(alive_, [this, streams]() { availableStreamsUpdated(streams); })); + + if (!vipc_client->connect(false)) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + continue; + } + } + + if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { + + if (rgb_back_.width != (int)buf->width || rgb_back_.height != (int)buf->height) { + rgb_back_.resize(buf->width, buf->height); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back_.data.data(), rgb_back_.bytesPerLine(), buf->width, buf->height); + { + std::lock_guard lk(frame_lock_); + rgb_frame_.swap(rgb_back_); + frame_updated_ = true; + } + } + } +} + +void CameraWidget::clearFrames() { + std::lock_guard lk(frame_lock_); + rgb_frame_.reset(); + rgb_back_.reset(); + frame_updated_ = false; +} diff --git a/iqpilot/tools/cabana/ui/widgets/cameraview.h b/iqpilot/tools/cabana/ui/widgets/cameraview.h new file mode 100644 index 000000000..46b4812ca --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/cameraview.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "cereal/visionstream.h" +#include "tools/cabana/core/observable.h" +#include "msgq/visionipc/visionipc_client.h" + + +struct RgbImage { + int width = 0; + int height = 0; + std::vector data; + bool isNull() const { return data.empty(); } + void reset() { width = height = 0; data.clear(); } + void resize(int w, int h) { width = w; height = h; data.resize(size_t(w) * h * 4); } + int bytesPerLine() const { return width * 4; } + void swap(RgbImage &other) { std::swap(width, other.width); std::swap(height, other.height); data.swap(other.data); } +}; + + +struct GlTexture { + GlTexture() = default; + GlTexture(const GlTexture &) = delete; + GlTexture &operator=(const GlTexture &) = delete; + ~GlTexture() { destroy(); } + void upload(const RgbImage &image); + void destroy(); + ImTextureRef ref() const { return ImTextureRef((ImTextureID)(uintptr_t)id); } + + unsigned int id = 0; + int width = 0; + int height = 0; + uint64_t key = 0; + bool mipmap = false; +}; + +class CameraWidget { +public: + explicit CameraWidget(std::string stream_name, VisionStreamType stream_type); + ~CameraWidget(); + void setStreamType(VisionStreamType type) { requested_stream_type_ = type; } + void stopVipcThread(); + + + void setVisible(bool visible); + + void draw(const ImVec2 &size); + const ImRect &rect() const { return rect_; } + float frameAspectRatio() const; + float width() const { return rect_.GetWidth(); } + float height() const { return rect_.GetHeight(); } + + Observable<> clicked; + Observable> availableStreamsUpdated; + +private: + void paint(); + void startVipcThread(); + void vipcThread(); + void clearFrames(); + + ImU32 bg_ = IM_COL32(0, 0, 0, 255); + RgbImage rgb_frame_; + RgbImage rgb_back_; + bool frame_updated_ = false; + GlTexture frame_texture_; + ImRect rect_; + bool visible_ = false; + + std::string stream_name_; + std::atomic active_stream_type_; + std::atomic requested_stream_type_; + std::thread vipc_thread_; + std::atomic vipc_exit_ = false; + std::mutex frame_lock_; + std::shared_ptr alive_ = std::make_shared(true); +}; diff --git a/iqpilot/tools/cabana/ui/widgets/detailwidget.cc b/iqpilot/tools/cabana/ui/widgets/detailwidget.cc new file mode 100644 index 000000000..0248b5f07 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/detailwidget.cc @@ -0,0 +1,439 @@ +#include "tools/cabana/ui/widgets/detailwidget.h" + +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +namespace { + +bool iequals(const std::string &a, const std::string &b) { + return a.size() == b.size() && + std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower((unsigned char)x) == std::tolower((unsigned char)y); }); +} + +} + +ElidedLabel::ElidedLabel(const std::string &text) : text_(utils::trimmed(text)) {} + +void ElidedLabel::draw(float width) { + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const ImVec2 pos(window->DC.CursorPos.x, window->DC.CursorPos.y + window->DC.CurrLineTextBaseOffset); + const ImRect bb(pos, ImVec2(pos.x + width, pos.y + ImGui::GetTextLineHeight())); + ImGui::ItemSize(bb.GetSize(), 0.0f); + if (ImGui::ItemAdd(bb, 0)) { + ImGui::RenderTextEllipsis(window->DrawList, bb.Min, bb.Max, bb.Max.x, text_.c_str(), nullptr, nullptr); + } + if (!tooltip_.empty()) ImGui::SetItemTooltip("%s", tooltip_.c_str()); + if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + clicked(); + } +} + +DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) { + tabbar_.setUsesScrollButtons(true); + tabbar_.setAutoHide(true); + tabbar_.setTabsClosable(true); + connections_.push_back(tabbar_.currentChanged.connect([this](int index) { + if (index >= 0) setMessage(MessageId::fromString(tabbar_.tabText(index))); + })); + connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); })); + connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); })); + binary_view_ = std::make_unique(); + signal_view_ = std::make_unique(charts); + + history_log_ = std::make_unique(); + + connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); })); + connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); })); + connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); })); + connections_.push_back(binary_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); })); + connections_.push_back(signal_view_->highlight.connect([this](const cabana::Signal *sig) { binary_view_->highlight(sig); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); })); + connections_.push_back(charts->seriesChanged.connect([this]() { signal_view_->updateChartState(); })); + connections_.push_back(can->timeRangeChanged.connect([this](const std::optional> &range) { + char text[64]; + if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second); + heatmap_all_text_ = range ? text : "All"; + const bool live = !range; + if (std::exchange(heatmap_live_, live) != live) binary_view_->setHeatmapLiveMode(live); + })); +} + +void DetailWidget::drawToolBar() { + const ImGuiStyle &style = ImGui::GetStyle(); + auto radio_width = [&](const char *label) { return ImGui::GetFrameHeight() + style.ItemInnerSpacing.x + ImGui::CalcTextSize(label).x; }; + auto button_width = [&](const char *label) { return ImGui::CalcTextSize(label).x + style.FramePadding.x * 2; }; + const float right_width = ImGui::CalcTextSize("Heatmap:").x + style.ItemSpacing.x + radio_width("Live") + style.ItemSpacing.x + + radio_width(heatmap_all_text_.c_str()) + style.ItemSpacing.x * 3 + 1.0f + + button_width(icon::PENCIL) + style.ItemSpacing.x + button_width(icon::X_LG); + const float avail = ImGui::GetContentRegionAvail().x; + + ImGui::AlignTextToFramePadding(); + pushBoldFont(); + name_label_.draw(std::max(1.0f, avail - right_width - style.ItemSpacing.x)); + popBoldFont(); + + alignRight(right_width); + ImGui::TextUnformatted("Heatmap:"); + ImGui::SameLine(); + if (ImGui::RadioButton("Live##heatmap_live_", heatmap_live_) && !heatmap_live_) { + heatmap_live_ = true; + binary_view_->setHeatmapLiveMode(true); + } + ImGui::SameLine(); + if (ImGui::RadioButton((heatmap_all_text_ + "##heatmap_all").c_str(), !heatmap_live_) && heatmap_live_) { + heatmap_live_ = false; + binary_view_->setHeatmapLiveMode(false); + } + + ImGui::SameLine(); + ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); + ImGui::SameLine(); + if (ImGui::Button(icon::PENCIL)) editMsg(); + ImGui::SetItemTooltip("Edit Message"); + ImGui::SameLine(); + ImGui::BeginDisabled(!action_remove_msg_enabled_); + if (ImGui::Button(icon::X_LG)) UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); + ImGui::EndDisabled(); + disabledItemTooltip("Remove Message"); +} + +void DetailWidget::showTabBarContextMenu(int index) { + if (ImGui::BeginPopupContextItem()) { + if (ImGui::MenuItem("Close Other Tabs")) { + tabbar_.moveTab(index, 0); + tabbar_.setCurrentIndex(0); + while (tabbar_.count() > 1) tabbar_.removeTab(1); + } + ImGui::EndPopup(); + } +} + +int DetailWidget::findOrAddTab(const MessageId &message_id) { + const std::string text = message_id.toString(); + int index = tabbar_.count() - 1; + for ( ; index >= 0; --index) { + if (tabbar_.tabText(index) == text) break; + } + if (index == -1) { + index = tabbar_.addTab(text); + tabbar_.setTabToolTip(index, msgName(message_id)); + } + return index; +} + +void DetailWidget::setMessage(const MessageId &message_id) { + if (std::exchange(msg_id_, message_id) == message_id) return; + + tabbar_.setCurrentIndex(findOrAddTab(message_id)); + + signal_view_->setMessage(msg_id_); + binary_view_->setMessage(msg_id_); + history_log_->setMessage(msg_id_); + refresh(); +} + +std::pair> DetailWidget::serializeMessageIds() const { + std::vector msgs; + for (int i = 0; i < tabbar_.count(); ++i) msgs.push_back(tabbar_.tabText(i)); + return std::make_pair(msg_id_.toString(), msgs); +} + +void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vector& msg_ids) { + for (const auto& str_id : msg_ids) { + MessageId id = MessageId::fromString(str_id); + if (dbc()->msg(id) != nullptr) + findOrAddTab(id); + } + + auto active_id = MessageId::fromString(active_msg_id); + if (dbc()->msg(active_id) != nullptr) + setMessage(active_id); +} + +void DetailWidget::refresh() { + std::vector warnings; + auto msg = dbc()->msg(msg_id_); + if (msg) { + if (msg_id_.source == INVALID_SOURCE) { + warnings.push_back("No messages received."); + } else if (msg->size != can->lastMessage(msg_id_).dat.size()) { + warnings.push_back("Message size (" + std::to_string(msg->size) + ") is incorrect."); + } + for (auto s : binary_view_->getOverlappingSignals()) { + warnings.push_back(s->name + " has overlapping bits."); + } + } + std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_); + name_label_.setText(msg_name); + name_label_.setToolTip(msg_name); + action_remove_msg_enabled_ = msg != nullptr; + + if (!warnings.empty()) { + warning_label_.clear(); + for (size_t i = 0; i < warnings.size(); ++i) { + if (i) warning_label_ += '\n'; + warning_label_ += warnings[i]; + } + warning_icon_ = msg ? icon::EXCLAMATION_TRIANGLE : icon::INFO_CIRCLE; + } + warning_widget_visible_ = !warnings.empty(); +} + +void DetailWidget::updateState(const std::set *msgs) { + if ((msgs && !msgs->count(msg_id_))) + return; + + if (tab_widget_index_ == 0) + binary_view_->updateState(); + else + history_log_->updateState(); +} + +void DetailWidget::editMsg() { + auto msg = dbc()->msg(msg_id_); + int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size(); + edit_dlg_ = std::make_unique(msg_id_, msgName(msg_id_), size, ImGui::GetWindowWidth()); +} + +void DetailWidget::drawTabWidget() { + + const float tab_height = ImGui::GetFrameHeight(); + const float content_height = ImGui::GetContentRegionAvail().y - tab_height - ImGui::GetStyle().ItemSpacing.y; + ImGui::BeginChild("tab_widget", ImVec2(0, std::max(content_height, 1.0f)), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (tab_widget_index_ == 0) { + + const float min_height = binary_view_->minimumSizeHint().y; + const float avail = ImGui::GetContentRegionAvail().y; + const float max_height = std::max(avail - 6.0f - ImGui::GetStyle().ItemSpacing.y * 2 - 1.0f, 1.0f); + const float height = std::clamp(min_height, 1.0f, max_height); + ImGui::BeginChild("binary_view", ImVec2(0, height)); + binary_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + binary_view_->draw(); + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0.0f, 6.0f)); + const float spacing = ImGui::GetStyle().ItemSpacing.y; + const ImRect child_rect = ImGui::GetCurrentWindow()->Rect(); + ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(child_rect.Min.x, ImGui::GetItemRectMin().y - spacing), + ImVec2(child_rect.Max.x, ImGui::GetItemRectMax().y + spacing), + ImGui::GetColorU32(ImGuiCol_WindowBg)); + ImGui::BeginChild("signal_view", ImVec2(0, 0)); + signal_view_rect_ = ImGui::GetCurrentWindow()->Rect(); + signal_view_->draw(); + ImGui::EndChild(); + } else { + history_log_->draw(); + } + ImGui::EndChild(); + + const std::string labels[] = {std::string(icon::FILE_EARMARK_RULED) + " Messages", std::string(icon::STOPWATCH) + " Logs"}; + + + const ImGuiStyle &style = ImGui::GetStyle(); + float tabs_width = 0.0f; + for (int i = 0; i < 2; ++i) { + tabs_width += ImGui::TabItemCalcSize(labels[i].c_str(), false).x + (i ? style.ItemInnerSpacing.x : 0.0f); + } + ImGuiWindow *window = ImGui::GetCurrentWindow(); + const float separator_y = ImGui::GetCursorScreenPos().y + ImGui::GetFrameHeight() - 1.0f; + window->DrawList->AddLine(ImVec2(window->WorkRect.Min.x, separator_y), ImVec2(window->WorkRect.Max.x, separator_y), + ImGui::GetColorU32(ImGuiCol_TabSelected), style.TabBarBorderSize); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - tabs_width) * 0.5f)); + + if (ImGui::BeginTabBar("tab_widget_tabs")) { + for (int i = 0; i < 2; ++i) { + if (ImGui::BeginTabItem(labels[i].c_str())) { + if (tab_widget_index_ != i) { + tab_widget_index_ = i; + if (i == 1) history_log_->onShown(); + updateState(); + } + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); + } +} + +void DetailWidget::draw() { + tabbar_.draw(); + drawToolBar(); + + if (warning_widget_visible_) { + ImGui::TextUnformatted(warning_icon_); + ImGui::SameLine(); + ImGui::TextUnformatted(warning_label_.c_str()); + } + + drawTabWidget(); + + if (edit_dlg_ && !edit_dlg_->draw()) { + if (edit_dlg_->accepted()) { + const auto r = edit_dlg_->result(); + UndoStack::instance()->push(new EditMsgCommand(r.msg_id, r.name, r.size, r.node, r.comment)); + } + edit_dlg_.reset(); + } +} + + +std::vector> DetailWidget::helpRects() const { + std::vector> rects; + if (tab_widget_index_ == 0) { + rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_); + rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_); + } + return rects; +} + +EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width) + : msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) { + window_title_ = "Edit message: " + msg_id.toString(); + + if (auto msg = dbc()->msg(msg_id)) { + node_ = msg->transmitter; + comment_edit_ = msg->comment; + } + validateName(name_edit_); +} + +EditMessageDialog::Result EditMessageDialog::result() const { + return {msg_id_, utils::trimmed(name_edit_), utils::trimmed(node_), utils::trimmed(comment_edit_), size_spin_}; +} + +bool EditMessageDialog::draw() { + if (closed_) return false; + if (!opened_) { + ImGui::OpenPopup(window_title_.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(0.0f, 0.0f)); + ImGui::SetNextWindowSize(ImVec2(width_, 0.0f), ImGuiCond_Always); + bool open = true; + if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) { + const float label_width = ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x * 2; + auto row = [&](const char *label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(label_width); + ImGui::SetNextItemWidth(-FLT_MIN); + }; + + if (!error_label_.empty()) { + row(""); + ImGui::TextUnformatted(error_label_.c_str()); + } + row("Name"); + if (validatedInput("##name", &name_edit_, nameValidator)) { + validateName(name_edit_); + } + + row("Size"); + if (ImGui::InputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES); + + row("Node"); + validatedInput("##node", &node_, nameValidator); + row("Comment"); + inputTextMultiline("##comment", &comment_edit_, ImVec2(-FLT_MIN, 192.0f)); + const bool comment_active = ImGui::IsItemActive(); + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject, ok_enabled_)) { + accepted_ = accept; + closed_ = true; + } + + if (!closed_ && ok_enabled_ && !comment_active && ImGui::IsKeyPressed(ImGuiKey_Enter, false)) { + accepted_ = true; + closed_ = true; + } + if (!open) closed_ = true; + if (closed_) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } else { + closed_ = true; + } + return !closed_; +} + +void EditMessageDialog::validateName(const std::string &text) { + bool valid = !iequals(text, UNTITLED); + error_label_.clear(); + if (!text.empty() && valid && text != original_name_) { + valid = dbc()->msg(msg_id_.source, text) == nullptr; + if (!valid) error_label_ = "Name already exists"; + } + ok_enabled_ = valid; +} + +DetailWidget* CenterWidget::ensureDetailWidget() { + if (!detail_widget) { + detail_widget = std::make_unique(charts_); + } + return detail_widget.get(); +} + +void CenterWidget::clear() { + detail_widget.reset(); + charts_ = nullptr; +} + +void CenterWidget::draw() { + if (detail_widget) { + detail_widget->draw(); + } else { + drawWelcomeWidget(); + } +} + +void CenterWidget::drawWelcomeWidget() { + const ImVec2 win_pos = ImGui::GetWindowPos(), win_size = ImGui::GetWindowSize(); + ImGui::GetWindowDrawList()->AddRectFilled(win_pos, ImVec2(win_pos.x + win_size.x, win_pos.y + win_size.y), ImGui::GetColorU32(ImGuiCol_ChildBg)); + + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const ImVec2 origin = ImGui::GetCursorPos(); + auto centered = [&](const char *text, float y) { + const ImVec2 size = ImGui::CalcTextSize(text); + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y)); + ImGui::TextUnformatted(text); + }; + ImGui::PushStyleColor(ImGuiCol_Text, colorRgb(169, 169, 169)); + float y = origin.y + avail.y * 0.5f - 90.0f; + pushLargeFont(); + centered("CABANA", y); + y += ImGui::GetTextLineHeightWithSpacing(); + popLargeFont(); + + auto newShortcutRow = [&](const char *title, const char *key) { + const float w = ImGui::CalcTextSize(title).x + ImGui::CalcTextSize(key).x + 40.0f; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - w) * 0.5f, y)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(title); + ImGui::SameLine(); + ImGui::BeginDisabled(); + ImGui::SmallButton(key); + ImGui::EndDisabled(); + y += ImGui::GetFrameHeightWithSpacing(); + }; + + centered("<-Select a message to view details", y); + y += ImGui::GetTextLineHeightWithSpacing(); + newShortcutRow("Pause", "Space"); + newShortcutRow("Help", "F1"); + newShortcutRow("WhatsThis", "Shift+F1"); + ImGui::PopStyleColor(); +} diff --git a/iqpilot/tools/cabana/ui/widgets/detailwidget.h b/iqpilot/tools/cabana/ui/widgets/detailwidget.h new file mode 100644 index 000000000..07748043d --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/detailwidget.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "tools/cabana/ui/widgets/binaryview.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/widgets/historylog.h" +#include "tools/cabana/ui/widgets/signalview.h" +#include "tools/cabana/ui/widgets/tabbar.h" + + +class ElidedLabel { +public: + explicit ElidedLabel(const std::string &text = {}); + void setText(const std::string &text) { text_ = text; } + void setToolTip(const std::string &tip) { tooltip_ = tip; } + void draw(float width); + + Observable<> clicked; + +private: + std::string text_, tooltip_; +}; + + +class EditMessageDialog { +public: + struct Result { + MessageId msg_id; + std::string name, node, comment; + int size; + }; + + EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width); + bool draw(); + bool accepted() const { return accepted_; } + Result result() const; + +private: + void validateName(const std::string &text); + + MessageId msg_id_; + std::string original_name_; + std::string name_edit_; + std::string node_; + std::string comment_edit_; + std::string error_label_; + int size_spin_; + bool ok_enabled_ = true; + std::string window_title_; + float width_; + bool opened_ = false; + bool accepted_ = false; + bool closed_ = false; +}; + +class DetailWidget { +public: + DetailWidget(ChartsWidget *charts); + void setMessage(const MessageId &message_id); + void refresh(); + void draw(); + std::pair> serializeMessageIds() const; + void restoreTabs(const std::string &active_msg_id, const std::vector &msg_ids); + std::vector> helpRects() const; + +private: + void drawToolBar(); + void drawTabWidget(); + int findOrAddTab(const MessageId& message_id); + void showTabBarContextMenu(int index); + void editMsg(); + void updateState(const std::set *msgs = nullptr); + + MessageId msg_id_; + const char *warning_icon_ = nullptr; + std::string warning_label_; + ElidedLabel name_label_; + bool warning_widget_visible_ = false; + TabBar tabbar_; + int tab_widget_index_ = 0; + bool action_remove_msg_enabled_ = false; + bool heatmap_live_ = true; + std::string heatmap_all_text_ = "All"; + ImRect binary_view_rect_, signal_view_rect_; + std::unique_ptr history_log_; + std::unique_ptr binary_view_; + std::unique_ptr signal_view_; + ChartsWidget *charts_; + std::unique_ptr edit_dlg_; + Connections connections_; +}; + +class CenterWidget { +public: + CenterWidget() = default; + void setChartsWidget(ChartsWidget *charts) { charts_ = charts; } + void setMessage(const MessageId &message_id) { ensureDetailWidget()->setMessage(message_id); } + DetailWidget* getDetailWidget() { return detail_widget.get(); } + DetailWidget* ensureDetailWidget(); + void clear(); + void draw(); + +private: + void drawWelcomeWidget(); + std::unique_ptr detail_widget; + ChartsWidget *charts_ = nullptr; +}; diff --git a/iqpilot/tools/cabana/ui/widgets/historylog.cc b/iqpilot/tools/cabana/ui/widgets/historylog.cc new file mode 100644 index 000000000..5b3fe508e --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/historylog.cc @@ -0,0 +1,307 @@ +#include "tools/cabana/ui/widgets/historylog.h" + +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/filedialog.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/export.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +constexpr int BATCH_SIZE = 50; +constexpr float DISPLAY_TYPE_WIDTH = 90.0f; +constexpr float SIGNALS_WIDTH = 160.0f; +constexpr float COMPARE_WIDTH = 50.0f; + +std::string formatTime(uint64_t mono_time) { + char buf[32] = {}; + snprintf(buf, sizeof(buf), "%.3f", can->toSeconds(mono_time)); + return buf; +} + +} + +LogsWidget::LogsWidget() { + connections_.push_back(can->seekedTo.connect([this](double) { reset(); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { reset(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { reset(); })); +} + +void LogsWidget::setMessage(const MessageId &message_id) { + msg_id_ = message_id; + reset(); +} + +void LogsWidget::reset() { + sigs_.clear(); + if (auto dbc_msg = dbc()->msg(msg_id_)) sigs_ = dbc_msg->getSignals(); + messages_.clear(); + hex_colors_ = {}; + signals_cb_ = comp_box_ = 0; + value_edit_.clear(); + value_edit_modified_ = false; + export_btn_enabled_ = false; + selected_row_ = selected_col_ = -1; + setFilter(0, "", nullptr); +} + +void LogsWidget::setFilter(int sig_idx, const std::string &value, std::function cmp) { + filter_sig_idx_ = sig_idx; + filter_value_ = utils::toDouble(value); + filter_cmp_ = value.empty() ? nullptr : cmp; + load(true); +} + +void LogsWidget::load(bool clear) { + if (clear && !messages_.empty()) { + messages_.clear(); + selected_row_ = selected_col_ = -1; + } + const uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id_).ts) + 1; + fetch(messages_.begin(), current_time, messages_.empty() ? 0 : messages_.front().mono_time); +} + +bool LogsWidget::canFetchMore() const { + const auto &events = can->events(msg_id_); + return !events.empty() && !messages_.empty() && messages_.back().mono_time > events.front()->mono_time; +} + +void LogsWidget::fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time) { + const auto &events = can->events(msg_id_); + auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) { return ts > e->mono_time; }); + + std::vector msgs; + std::vector values(sigs_.size()); + msgs.reserve(BATCH_SIZE); + for (; first != events.rend() && (*first)->mono_time > min_time; ++first) { + const CanEvent *e = *first; + for (int i = 0; i < sigs_.size(); ++i) { + sigs_[i]->getValue(e->dat, e->size, &values[i]); + } + if (!filter_cmp_ || filter_cmp_(values[filter_sig_idx_], filter_value_)) { + msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}}); + if (msgs.size() >= BATCH_SIZE && min_time == 0) break; + } + } + if (msgs.empty()) return; + + if (hexMode() && (min_time > 0 || messages_.empty())) { + const auto freq = can->lastMessage(msg_id_).freq; + const std::vector no_mask; + for (auto &m : msgs) { + hex_colors_.compute(msg_id_, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq); + m.colors = hex_colors_.colors; + } + } + const int pos = std::distance(messages_.begin(), insert_pos); + messages_.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end())); + export_btn_enabled_ = true; + + if (selected_row_ >= pos) selected_row_ += msgs.size(); +} + +void LogsWidget::filterChanged() { + if (value_edit_.empty() && !value_edit_modified_) return; + + std::function cmp = nullptr; + switch (comp_box_) { + case 0: cmp = std::greater{}; break; + case 1: cmp = std::equal_to{}; break; + case 2: cmp = [](double l, double r) { return l != r; }; break; + case 3: cmp = std::less{}; break; + } + setFilter(signals_cb_, value_edit_, cmp); +} + +void LogsWidget::exportToCSV() { + std::string dir = settings.last_dir + "/" + can->routeName() + "_" + msgName(msg_id_) + ".csv"; + FileDialog::getSaveFileName("Export " + msgName(msg_id_) + " to CSV file", dir, ".csv", [this](const std::string &fn) { + if (!fn.empty()) { + hexMode() ? utils::exportToCSV(fn, msg_id_) : utils::exportSignalsToCSV(fn, msg_id_); + } + }); +} + +void LogsWidget::draw() { + const ImGuiStyle &style = ImGui::GetStyle(); + + + const float export_w = ImGui::CalcTextSize(icon::FILETYPE_CSV).x + style.FramePadding.x * 2; + if (!sigs_.empty()) { + const float clear_w = value_edit_.empty() ? 0.0f : ImGui::CalcTextSize(icon::X).x + style.FramePadding.x * 2; + const float fixed = DISPLAY_TYPE_WIDTH + SIGNALS_WIDTH + COMPARE_WIDTH + clear_w + style.ItemSpacing.x * 4 + export_w; + const float value_w = std::clamp(ImGui::GetContentRegionAvail().x - fixed, 30.0f, 120.0f); + + ImGui::SetNextItemWidth(DISPLAY_TYPE_WIDTH); + if (ImGui::Combo("##display_type", &display_type_cb_, "Signal\0Hex\0")) { + hex_mode_ = display_type_cb_; + reset(); + } + ImGui::SetItemTooltip("Display signal value or raw hex value"); + ImGui::SameLine(); + std::string sig_items; + for (auto s : sigs_) { + sig_items += s->name; + sig_items += '\0'; + } + sig_items += '\0'; + ImGui::SetNextItemWidth(SIGNALS_WIDTH); + if (ImGui::Combo("##signals", &signals_cb_, sig_items.c_str())) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(COMPARE_WIDTH); + if (ImGui::Combo("##comp", &comp_box_, ">\0=\0!=\0<\0")) filterChanged(); + ImGui::SameLine(); + ImGui::SetNextItemWidth(value_w); + if (clearableInput("##value", &value_edit_, "", doubleValidator)) { + value_edit_modified_ = true; + filterChanged(); + } + } + alignRight(export_w); + ImGui::BeginDisabled(!export_btn_enabled_); + if (ImGui::Button(icon::FILETYPE_CSV)) exportToCSV(); + ImGui::EndDisabled(); + disabledItemTooltip("Export to CSV file..."); + + ImGui::Separator(); + drawTable(); +} + +std::string LogsWidget::headerText(int column) const { + if (column == 0) return "Time"; + if (hexMode()) return "Data"; + std::string text = sigs_[column - 1]->name; + if (!sigs_[column - 1]->unit.empty()) text += " (" + sigs_[column - 1]->unit + ")"; + std::replace(text.begin(), text.end(), '_', ' '); + return text; +} + +ImVec2 LogsWidget::headerSize(int column, float viewport_width) const { + const ImVec2 time_text_size = ImGui::CalcTextSize("000000.000"); + const ImVec2 time_col_size(time_text_size.x + 10, time_text_size.y + 6); + if (column == 0) return time_col_size; + const int default_size = std::max(100, (int)((viewport_width - time_col_size.x) / (columnCount() - 1))); + const ImVec2 rect = ImGui::CalcTextSize(headerText(column).c_str(), nullptr, false, default_size); + return ImVec2{std::max(rect.x + 10, (float)default_size), rect.y + 6}; +} + +void LogsWidget::drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const { + if (column > 0 && !hexMode()) { + CabanaColor bg = sigs_[column - 1]->color; + bg.a = 128; + dl->AddRectFilled(rect.Min, rect.Max, toImU32(bg)); + } + const std::string text = headerText(column); + const ImU32 color = isDarkTheme() ? toImU32(DarkTheme::bright_text) : ImGui::GetColorU32(ImGuiCol_Text); + + const ImRect r(rect.Min.x + 5, rect.Min.y + 3, rect.Max.x - 5, rect.Max.y - 3); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + const float wrap_width = std::max(1.0f, r.GetWidth()); + const char *s = text.c_str(); + const char *end = s + text.size(); + float y = r.Min.y; + dl->PushClipRect(rect.Min, rect.Max, true); + while (s < end) { + const char *line_end = font->CalcWordWrapPosition(font_size, s, end, wrap_width); + if (line_end == s) line_end = s + 1; + const float w = ImGui::CalcTextSize(s, line_end).x; + dl->AddText(font, font_size, ImVec2(r.Max.x - w, y), color, s, line_end); + y += ImGui::GetTextLineHeight(); + s = line_end; + while (s < end && ImCharIsBlankA(*s)) s++; + if (s < end && *s == '\n') s++; + } + dl->PopClipRect(); +} + +void LogsWidget::drawTable() { + const ImGuiStyle &style = ImGui::GetStyle(); + const int cols = columnCount(); + + + const float header_width = ImGui::GetContentRegionAvail().x - style.CellPadding.x * 2 * cols - + (vscrollbar_visible_ ? style.ScrollbarSize : 0.0f); + + std::vector sizes(cols); + float header_height = 0; + for (int i = 0; i < cols; ++i) { + sizes[i] = headerSize(i, header_width); + header_height = std::max(header_height, sizes[i].y); + } + if (hexMode() && !messages_.empty()) { + sizes[1].x = std::max(sizes[1].x, bytesCellSize(messages_.front().data.size(), false).x); + } + const float row_height = bytesCellSize(8, false).y; + + + + ImGuiTableFlags flags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_ScrollX | ImGuiTableFlags_BordersInner | + ImGuiTableFlags_SizingFixedFit; + + if (messages_.empty()) flags &= ~ImGuiTableFlags_BordersInnerV; + + float inner_width = 0; + for (int i = 0; i < cols; ++i) inner_width += sizes[i].x + style.CellPadding.x * 2; + + bool fetch_more = false; + if (ImGui::BeginTable("logs", cols, flags, ImVec2(0, 0), inner_width)) { + ImGui::TableSetupScrollFreeze(0, 1); + for (int i = 0; i < cols; ++i) { + ImGui::TableSetupColumn(headerText(i).c_str(), ImGuiTableColumnFlags_WidthFixed, sizes[i].x); + } + ImGuiTable *table = ImGui::GetCurrentTable(); + ImDrawList *painter = ImGui::GetWindowDrawList(); + + ImGui::TableNextRow(ImGuiTableRowFlags_Headers, header_height); + for (int i = 0; i < cols; ++i) { + if (!ImGui::TableSetColumnIndex(i)) continue; + drawHeaderCell(painter, ImGui::TableGetCellBgRect(table, i), i); + ImGui::Dummy(ImVec2(0, header_height - style.CellPadding.y * 2)); + } + + ImGuiListClipper clipper; + clipper.Begin(messages_.size(), row_height); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) { + const auto &m = messages_[row]; + ImGui::TableNextRow(0, row_height); + + ImGui::PushID((void *)(uintptr_t)m.mono_time); + for (int col = 0; col < cols; ++col) { + if (!ImGui::TableSetColumnIndex(col)) continue; + + const bool cell_selected = selected_row_ == row && selected_col_ == col; + ImGui::PushID(col); + if (viewSelectable("##cell", cell_selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height - style.CellPadding.y * 2))) { + selected_row_ = row; + selected_col_ = col; + } + ImGui::PopID(); + const ImRect rect = ImGui::TableGetCellBgRect(table, col); + if (col == 0) { + drawTextCell(painter, rect, formatTime(m.mono_time), cell_selected, false); + } else if (hexMode()) { + drawBytesCell(painter, rect, m.data, &m.colors, cell_selected, false, false); + } else { + drawTextCell(painter, rect, sigs_[col - 1]->formatValue(m.sig_values[col - 1], false), cell_selected, false); + } + } + ImGui::PopID(); + } + + if (clipper.DisplayEnd >= (int)messages_.size()) fetch_more = true; + } + if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) fetch_more = true; + vscrollbar_visible_ = table->InnerWindow->ScrollbarY; + ImGui::EndTable(); + } + if (fetch_more && canFetchMore()) fetch(messages_.end(), messages_.back().mono_time, 0); +} diff --git a/iqpilot/tools/cabana/ui/widgets/historylog.h b/iqpilot/tools/cabana/ui/widgets/historylog.h new file mode 100644 index 000000000..7d1572193 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/historylog.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + + +class LogsWidget { +public: + LogsWidget(); + void setMessage(const MessageId &message_id); + void updateState() { load(false); } + void onShown() { load(true); } + void draw(); + +private: + struct Message { + uint64_t mono_time = 0; + std::vector sig_values; + std::vector data; + std::vector colors; + }; + + bool hexMode() const { return sigs_.empty() || hex_mode_; } + int columnCount() const { return hexMode() ? 2 : (int)sigs_.size() + 1; } + void reset(); + void setFilter(int sig_idx, const std::string &value, std::function cmp); + void load(bool clear); + bool canFetchMore() const; + void fetch(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); + void filterChanged(); + void exportToCSV(); + void drawTable(); + std::string headerText(int column) const; + ImVec2 headerSize(int column, float viewport_width) const; + void drawHeaderCell(ImDrawList *dl, const ImRect &rect, int column) const; + + MessageId msg_id_; + std::vector sigs_; + std::deque messages_; + CanData hex_colors_; + bool hex_mode_ = false; + int filter_sig_idx_ = -1; + double filter_value_ = 0; + std::function filter_cmp_; + int signals_cb_ = 0, comp_box_ = 0, display_type_cb_ = 0; + std::string value_edit_; + bool value_edit_modified_ = false; + bool export_btn_enabled_ = false; + int selected_row_ = -1, selected_col_ = -1; + bool vscrollbar_visible_ = false; + Connections connections_; +}; diff --git a/iqpilot/tools/cabana/ui/widgets/messagebytes.cc b/iqpilot/tools/cabana/ui/widgets/messagebytes.cc new file mode 100644 index 000000000..477200c46 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/messagebytes.cc @@ -0,0 +1,60 @@ +#include "tools/cabana/ui/widgets/messagebytes.h" + +#include +#include + +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" + +ImVec2 byteCellSize() { + pushMonoFont(); + + const ImFontBaked *baked = ImGui::GetFontBaked(); + const ImVec2 size(ImGui::CalcTextSize("00 ").x, std::ceil(baked->Ascent) - std::floor(baked->Descent) + 1 + 2); + popMonoFont(); + return size; +} + +ImVec2 bytesCellSize(int n, bool multiple_lines) { + const ImVec2 byte_size = byteCellSize(); + const int rows = multiple_lines ? std::max(1, (n + 7) / 8) : 1; + const int columns = multiple_lines ? std::min(n, 8) : n; + const ImVec2 margin = ImGui::GetStyle().CellPadding; + return {columns * byte_size.x + (margin.x + 1) * 2, rows * byte_size.y + (margin.y + 1) * 2}; +} + +ImU32 cellTextColor(bool selected, bool inactive) { + if (selected) return inactive ? withAlpha(highlightedTextColor(), 100) : highlightedTextColor(); + return ImGui::GetColorU32(inactive ? ImGuiCol_TextDisabled : ImGuiCol_Text); +} + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive) { + drawElidedText(dl, rect, text, cellTextColor(selected, inactive)); +} + +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines) { + const ImU32 text_pen = cellTextColor(selected, inactive); + const ImVec2 byte_size = byteCellSize(); + pushMonoFont(); + ImFont *font = ImGui::GetFont(); + const float font_size = ImGui::GetFontSize(); + for (int i = 0; i < (int)bytes.size(); ++i) { + const int row = multiple_lines ? i / 8 : 0; + const int column = multiple_lines ? i % 8 : i; + const ImVec2 min(rect.Min.x + column * byte_size.x, rect.Min.y + row * byte_size.y); + const ImRect r(min, ImVec2(min.x + byte_size.x, min.y + byte_size.y)); + + + ImU32 pen = text_pen; + if (colors && i < (int)colors->size() && (*colors)[i].alpha() > 0) { + if (selected) { + pen = ImGui::GetColorU32(ImGuiCol_Text); + dl->AddRectFilled(r.Min, r.Max, ImGui::GetColorU32(ImGuiCol_WindowBg)); + } + dl->AddRectFilled(r.Min, r.Max, toImU32((*colors)[i])); + } + drawText(dl, r, utils::hexByte(bytes[i]), pen, font, font_size); + } + popMonoFont(); +} diff --git a/iqpilot/tools/cabana/ui/widgets/messagebytes.h b/iqpilot/tools/cabana/ui/widgets/messagebytes.h new file mode 100644 index 000000000..dabf68781 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/messagebytes.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/color.h" + + + + +ImVec2 byteCellSize(); +ImVec2 bytesCellSize(int n, bool multiple_lines); +ImU32 cellTextColor(bool selected, bool inactive); + +void drawTextCell(ImDrawList *dl, const ImRect &rect, const std::string &text, bool selected, bool inactive); +void drawBytesCell(ImDrawList *dl, const ImRect &rect, const std::vector &bytes, const std::vector *colors, + bool selected, bool inactive, bool multiple_lines); diff --git a/iqpilot/tools/cabana/ui/widgets/messageswidget.cc b/iqpilot/tools/cabana/ui/widgets/messageswidget.cc new file mode 100644 index 000000000..e2fd093f8 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/messageswidget.cc @@ -0,0 +1,512 @@ +#include "tools/cabana/ui/widgets/messageswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui_internal.h" +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/icons.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/widgets/messagebytes.h" +#include "tools/cabana/utils/strings.h" + +namespace { + +const char *COLUMN_TITLES[MessageList::COLUMN_COUNT] = {"Name", "Bus", "ID", "Node", "Freq", "Count", "Bytes"}; +constexpr float DEFAULT_SECTION_SIZE = 100.0f; + + +unsigned int toUInt(const std::string &s, bool *ok, int base) { + const char *b = s.data(), *e = b + s.size(); + while (b < e && std::isspace((unsigned char)*b)) ++b; + while (e > b && std::isspace((unsigned char)e[-1])) --e; + unsigned int v = 0; + auto [p, ec] = std::from_chars(b, e, v, base); + *ok = b < e && p == e && ec == std::errc(); + return *ok ? v : 0; +} + +bool parseRange(const std::string &filter, uint32_t value, int base = 10) { + + unsigned int min = std::numeric_limits::min(); + unsigned int max = std::numeric_limits::max(); + auto s = utils::split(filter, '-'); + bool ok = s.size() >= 1 && s.size() <= 2; + if (ok && !s[0].empty()) min = toUInt(s[0], &ok, base); + if (ok && s.size() == 1) { + max = min; + } else if (ok && s.size() == 2 && !s[1].empty()) { + max = toUInt(s[1], &ok, base); + } + return ok && value >= min && value <= max; +} + + + +inline ImGuiSortDirection flipSortDirection(ImGuiSortDirection dir) { + return dir == ImGuiSortDirection_Ascending ? ImGuiSortDirection_Descending : ImGuiSortDirection_Ascending; +} + +std::string formatFreq(float freq) { + if (freq <= 0) return "--"; + char buf[32]; + snprintf(buf, sizeof(buf), freq >= 0.95 ? "%.0f" : "%.2f", freq >= 0.95 ? std::nearbyint(freq) : freq); + return buf; +} + + +std::string cellText(const MessageList::Item &item, int column) { + const bool seen = item.id.source != INVALID_SOURCE; + switch (column) { + case MessageList::NAME: return item.name; + case MessageList::SOURCE: return seen ? std::to_string(item.id.source) : "N/A"; + case MessageList::ADDRESS: return utils::toHexString(item.id.address); + case MessageList::NODE: return item.node; + case MessageList::FREQ: return seen ? formatFreq(can->lastMessage(item.id).freq) : "N/A"; + case MessageList::COUNT: return seen ? std::to_string(can->lastMessage(item.id).count) : "N/A"; + case MessageList::DATA: return seen ? "" : "N/A"; + } + return {}; +} + +} + + + +MessageList::MessageList() { + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool has_new_ids) { msgsReceived(msgs, has_new_ids); })); + connections_.push_back(dbc()->fileChanged.connect([this]() { dbcModified(); })); + connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { dbcModified(); })); +} + +void MessageList::setFilters(const std::map &filters) { + filters_ = filters; + filterAndSort(); +} + +void MessageList::showInactiveMessages(bool show) { + show_inactive_messages = show; + filterAndSort(); +} + +void MessageList::dbcModified() { + dbc_messages_.clear(); + for (const auto &[_, m] : dbc()->getMessages(-1)) { + dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address}); + } + filterAndSort(); +} + +void MessageList::sortItems(std::vector &list) { + auto compare = [this](const auto &l, const auto &r) { + switch (sort_column_) { + case NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id); + case SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address); + case ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source); + case NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id); + case FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id); + case COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id); + default: return false; + } + }; + + if (sort_order_ == ImGuiSortDirection_Descending) + std::stable_sort(list.rbegin(), list.rend(), compare); + else + std::stable_sort(list.begin(), list.end(), compare); +} + +bool MessageList::match(const Item &item) { + if (filters_.empty()) return true; + + bool match = true; + const auto &data = can->lastMessage(item.id); + for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { + const std::string &txt = it->second; + switch (it->first) { + case NAME: { + match = utils::containsCI(item.name, txt); + if (!match) { + const auto m = dbc()->msg(item.id); + match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), + [&txt](const auto &s) { return utils::containsCI(s->name, txt); }); + } + break; + } + case SOURCE: + match = parseRange(txt, item.id.source); + break; + case ADDRESS: + match = utils::containsCI(utils::toHexString(item.id.address), txt); + match = match || parseRange(txt, item.id.address, 16); + break; + case NODE: + match = utils::containsCI(item.node, txt); + break; + case FREQ: + match = parseRange(txt, data.freq); + break; + case COUNT: + match = parseRange(txt, data.count); + break; + case DATA: + match = utils::containsCI(utils::toHex(data.dat), txt); + break; + } + } + return match; +} + +bool MessageList::filterAndSort() { + + std::vector all_messages; + all_messages.reserve(can->lastMessages().size() + dbc_messages_.size()); + auto dbc_msgs = dbc_messages_; + for (const auto &[id, m] : can->lastMessages()) { + all_messages.push_back(id); + dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address}); + } + all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end()); + + std::vector new_items; + new_items.reserve(all_messages.size()); + for (const auto &id : all_messages) { + if (show_inactive_messages || can->isMessageActive(id)) { + auto msg = dbc()->msg(id); + Item item = {.id = id, .name = msg ? msg->name : UNTITLED, .node = msg ? msg->transmitter : std::string()}; + if (match(item)) new_items.emplace_back(item); + } + } + sortItems(new_items); + + if (items != new_items) { + items = std::move(new_items); + changed(); + return true; + } + return false; +} + +void MessageList::msgsReceived(const std::set *new_msgs, bool has_new_ids) { + if (has_new_ids || ((filters_.count(FREQ) || filters_.count(COUNT) || filters_.count(DATA)) && + ++sort_threshold_ == STREAM_UPDATE_FPS)) { + sort_threshold_ = 0; + filterAndSort(); + } +} + +void MessageList::sort(int column, ImGuiSortDirection order) { + if (column != DATA) { + sort_column_ = column; + sort_order_ = order; + filterAndSort(); + } +} + + + +MessagesWidget::MessagesWidget() { + std::iota(display_order_.begin(), display_order_.end(), 0); + list_.sort(MessageList::NAME, ImGuiSortDirection_Ascending); + + connections_.push_back(list_.changed.connect([this]() { + current_row_ = -1; + if (current_msg_id_) selectMessage(*current_msg_id_); + updateBytesSectionSize(); + updateTitle(); + })); + + suppressHighlighted(); +} + +std::string MessagesWidget::whatsThis() const { + return R"( + Message View
+ Byte color
+ constant changing
+ increasing
+ decreasing
+ Shortcuts
+ Horizontal Scrolling:  shift+wheel  + )"; +} + +void MessagesWidget::drawToolBar() { + ImGui::Dummy(ImVec2(0, std::max(0.0f, 9 - ImGui::GetStyle().ItemSpacing.y))); + if (ImGui::Button("Suppress Highlighted")) suppressHighlighted(true); + ImGui::SameLine(); + ImGui::BeginDisabled(!suppress_clear_enabled_); + const std::string clear_label = suppress_clear_text_ + "##suppress_clear"; + if (ImGui::Button(clear_label.c_str())) suppressHighlighted(false); + ImGui::EndDisabled(); + disabledItemTooltip("Clear suppressed"); + + const ImGuiStyle &style = ImGui::GetStyle(); + const float checkbox_width = ImGui::CalcTextSize("Suppress Signals").x + ImGui::GetFrameHeight() + style.ItemInnerSpacing.x; + const float view_button_width = ImGui::CalcTextSize(icon::THREE_DOTS).x + style.FramePadding.x * 2; + alignRight(checkbox_width + style.ItemSpacing.x + view_button_width); + + bool suppress_defined_signals = settings.suppress_defined_signals; + if (checkBox("Suppress Signals", &suppress_defined_signals)) can->suppressDefinedSignals(suppress_defined_signals); + ImGui::SetItemTooltip("Suppress defined signals"); + ImGui::SameLine(); + + if (toolButton("view_btn", icon::THREE_DOTS, "View...")) ImGui::OpenPopup("menu"); +} + +void MessagesWidget::updateTitle() { + auto stats = std::accumulate( + list_.items.begin(), list_.items.end(), std::pair(), + [](const auto &pair, const auto &item) { + auto m = dbc()->msg(item.id); + return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair; + }); + char buf[128]; + snprintf(buf, sizeof(buf), "%zu Messages (%zu DBC Messages, %zu Signals)", list_.items.size(), stats.first, stats.second); + title_ = buf; +} + +void MessagesWidget::selectMessage(const MessageId &msg_id) { + auto it = std::find_if(list_.items.cbegin(), list_.items.cend(), [&msg_id](auto &item) { return item.id == msg_id; }); + if (it != list_.items.cend()) setCurrentRow(std::distance(list_.items.cbegin(), it)); +} + +void MessagesWidget::setCurrentRow(int row) { + if (row < 0 || row >= (int)list_.items.size()) return; + current_row_ = row; + scroll_to_current_ = true; + const auto &id = list_.items[row].id; + if (!current_msg_id_ || id != *current_msg_id_) { + current_msg_id_ = id; + msgSelectionChanged(*current_msg_id_); + } +} + +void MessagesWidget::suppressHighlighted(bool from_suppress_add) { + int n = from_suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0); + suppress_clear_text_ = n > 0 ? "Clear (" + std::to_string(n) + ")" : "Clear"; + suppress_clear_enabled_ = n > 0; +} + +void MessagesWidget::drawContextMenu() { + if (!ImGui::BeginPopup("menu")) return; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + const int column = display_order_[i]; + + if (ImGui::MenuItem(COLUMN_TITLES[column], nullptr, !hidden_[column], column > 0)) { + pending_hidden_.emplace_back(column, !hidden_[column]); + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Multi-Line bytes", nullptr, settings.multiple_lines_hex)) { + setMultiLineBytes(!settings.multiple_lines_hex); + } + if (ImGui::MenuItem("Show inactive messages", nullptr, list_.show_inactive_messages)) { + list_.showInactiveMessages(!list_.show_inactive_messages); + } + ImGui::EndPopup(); +} + +void MessagesWidget::setMultiLineBytes(bool multi) { + settings.multiple_lines_hex = multi; + updateBytesSectionSize(); +} + +void MessagesWidget::updateBytesSectionSize() { + int max_bytes = 8; + if (!settings.multiple_lines_hex) { + for (const auto &[_, m] : can->lastMessages()) { + max_bytes = std::max(max_bytes, m.dat.size()); + } + } + bytes_section_bytes_ = max_bytes; +} + +void MessagesWidget::draw() { + drawToolBar(); + drawTable(); + if (std::exchange(header_menu_requested_, false)) ImGui::OpenPopup("menu"); + drawContextMenu(); +} + +void MessagesWidget::handleKeys() { + if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_ChildWindows) || ImGui::IsAnyItemActive()) return; + const int last = (int)list_.items.size() - 1; + if (last < 0) return; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) && current_row_ > 0) { + setCurrentRow(current_row_ - 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) && current_row_ < last) { + setCurrentRow(current_row_ + 1); + } else if (ImGui::IsKeyPressed(ImGuiKey_Home)) { + setCurrentRow(0); + } else if (ImGui::IsKeyPressed(ImGuiKey_End)) { + setCurrentRow(last); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageUp)) { + setCurrentRow(std::max(current_row_ - visible_rows_, 0)); + } else if (ImGui::IsKeyPressed(ImGuiKey_PageDown)) { + setCurrentRow(std::min(current_row_ + visible_rows_, last)); + } +} + +void MessagesWidget::drawTable() { + handleKeys(); + const bool multiple_lines = settings.multiple_lines_hex; + + const ImGuiTableFlags flags = ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_Reorderable | + ImGuiTableFlags_ScrollX | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Borders | + ImGuiTableFlags_Hideable; + + const float bytes_width = bytesCellSize(bytes_section_bytes_, multiple_lines).x; + const float avail_width = ImGui::GetContentRegionAvail().x - (has_scrollbar_y_ ? ImGui::GetStyle().ScrollbarSize : 0); + const float inner_width = std::max(avail_width, fixed_columns_width_ + bytes_width); + if (!ImGui::BeginTable("messages", MessageList::COLUMN_COUNT, flags, ImVec2(0, 0), inner_width)) return; + + + ImGui::TableSetupScrollFreeze(0, 2); + + + ImGuiTable *table = ImGui::GetCurrentTable(); + table->DisableDefaultContextMenu = true; + table->IsContextPopupOpen = false; + for (int i = 0; i < MessageList::COLUMN_COUNT; ++i) { + + ImGuiTableColumnFlags column_flags = ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_PreferSortDescending; + float width = DEFAULT_SECTION_SIZE; + if (i == MessageList::NAME) { + column_flags |= ImGuiTableColumnFlags_DefaultSort | ImGuiTableColumnFlags_NoHide; + } else if (i == MessageList::DATA) { + column_flags = ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoSort | ImGuiTableColumnFlags_NoResize; + width = 0; + } + ImGui::TableSetupColumn(COLUMN_TITLES[i], column_flags, width); + } + for (const auto &[column, hide] : pending_hidden_) ImGui::TableSetColumnEnabled(column, !hide); + pending_hidden_.clear(); + + if (ImGuiTableSortSpecs *specs = ImGui::TableGetSortSpecs(); specs && specs->SpecsDirty) { + if (specs->SpecsCount > 0) list_.sort(specs->Specs[0].ColumnIndex, flipSortDirection(specs->Specs[0].SortDirection)); + specs->SpecsDirty = false; + if (current_row_ >= 0) scroll_to_current_ = true; + } + + drawHeader(); + + const int rows = list_.items.size(); + if (!multiple_lines) { + ImGuiListClipper clipper; + clipper.Begin(rows); + if (scroll_to_current_ && current_row_ >= 0) clipper.IncludeItemByIndex(current_row_); + while (clipper.Step()) { + for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) drawRow(row); + } + } else { + + for (int row = 0; row < rows; ++row) drawRow(row); + } + + + const ImGuiTableColumn &data_column = table->Columns[MessageList::DATA]; + fixed_columns_width_ = data_column.IsEnabled ? table->ColumnsGivenWidth - data_column.WidthGiven : 0; + has_scrollbar_y_ = table->InnerWindow->ScrollbarY; + visible_rows_ = std::max(1, (int)(table->InnerWindow->InnerRect.GetHeight() / bytesCellSize(0, multiple_lines).y) - 2); + ImGui::EndTable(); +} + +void MessagesWidget::drawHeader() { + if (tableHeadersRow() >= 0) header_menu_requested_ = true; + + ImGuiTable *table = ImGui::GetCurrentTable(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + display_order_[i] = table->DisplayOrderToIndex[i]; + hidden_[i] = !(ImGui::TableGetColumnFlags(i) & ImGuiTableColumnFlags_IsEnabled); + } + + + const float clear_width = ImGui::CalcTextSize(icon::X).x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::TableNextRow(); + for (int i = 0; i < MessageList::COLUMN_COUNT; i++) { + if (!ImGui::TableSetColumnIndex(i)) continue; + ImGui::PushID(i); + ImGui::SetNextItemWidth(filters_[i].empty() ? -FLT_MIN : std::max(1.0f, ImGui::GetContentRegionAvail().x - clear_width)); + const std::string placeholder = std::string("Filter ") + COLUMN_TITLES[i]; + if (clearableInput("##filter", &filters_[i], placeholder.c_str())) { + std::map filters; + for (int c = 0; c < MessageList::COLUMN_COUNT; ++c) { + if (!filters_[c].empty()) filters[c] = filters_[c]; + } + list_.setFilters(filters); + } + ImGui::PopID(); + } +} + +void MessagesWidget::drawRow(int row) { + const auto &item = list_.items[row]; + const bool selected = row == current_row_; + const bool inactive = !can->isMessageActive(item.id); + const auto &m = can->lastMessage(item.id); + const bool seen = item.id.source != INVALID_SOURCE; + const bool multiple_lines = settings.multiple_lines_hex; + const float row_height = bytesCellSize(seen ? m.dat.size() : 0, multiple_lines).y - ImGui::GetStyle().CellPadding.y * 2; + ImGui::TableNextRow(); + ImGui::PushID(row); + + bool row_item_submitted = false; + for (int column = 0; column < MessageList::COLUMN_COUNT; ++column) { + if (!ImGui::TableSetColumnIndex(column)) continue; + const ImVec2 pos = ImGui::GetCursorScreenPos(); + const float width = ImGui::GetContentRegionAvail().x; + const ImRect rect(pos, ImVec2(pos.x + width, pos.y + row_height)); + + const bool row_item = !row_item_submitted; + if (row_item) { + + + row_item_submitted = true; + + if (viewSelectable("##row", selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_SelectOnClick, ImVec2(0, row_height))) { + setCurrentRow(row); + } + if (selected && scroll_to_current_) { + + const ImGuiWindow *inner = ImGui::GetCurrentTable()->InnerWindow; + const float view_top = inner->InnerClipRect.Min.y + inner->DecoInnerSizeY1; + const float view_bottom = inner->InnerClipRect.Max.y; + if (ImGui::GetItemRectMin().y < view_top) { + ImGui::SetScrollHereY(0.0f); + } else if (ImGui::GetItemRectMax().y > view_bottom) { + ImGui::SetScrollHereY(1.0f); + } + scroll_to_current_ = false; + } + + const ImGuiTableColumn &name_col = ImGui::GetCurrentTable()->Columns[MessageList::NAME]; + const float mouse_x = ImGui::GetIO().MousePos.x; + if (ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && mouse_x >= name_col.MinX && mouse_x < name_col.MaxX) { + auto msg = dbc()->msg(item.id); + ImGui::BeginTooltip(); + ImGui::TextUnformatted(item.name.c_str()); + if (msg && !msg->comment.empty()) ImGui::TextDisabled("%s", msg->comment.c_str()); + ImGui::EndTooltip(); + } + } + + if (column == MessageList::DATA && seen) { + drawBytesCell(ImGui::GetWindowDrawList(), rect, m.dat, &m.colors, selected, inactive, multiple_lines); + } else { + drawTextCell(ImGui::GetWindowDrawList(), rect, cellText(item, column), selected, inactive); + } + + if (!row_item) ImGui::Dummy(ImVec2(width, row_height)); + } + + ImGui::PopID(); +} diff --git a/iqpilot/tools/cabana/ui/widgets/messageswidget.h b/iqpilot/tools/cabana/ui/widgets/messageswidget.h new file mode 100644 index 000000000..ed3be8efb --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/messageswidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "tools/cabana/dbc/dbcmanager.h" +#include "tools/cabana/streams/abstractstream.h" + + + +class MessageList { +public: + enum Column { NAME = 0, SOURCE, ADDRESS, NODE, FREQ, COUNT, DATA, COLUMN_COUNT }; + + struct Item { + MessageId id; + std::string name; + std::string node; + bool operator==(const Item &other) const { return id == other.id && name == other.name && node == other.node; } + }; + + MessageList(); + void sort(int column, ImGuiSortDirection order); + void setFilters(const std::map &filters); + void showInactiveMessages(bool show); + bool filterAndSort(); + + std::vector items; + bool show_inactive_messages = true; + Observable<> changed; + +private: + void msgsReceived(const std::set *new_msgs, bool has_new_ids); + void dbcModified(); + void sortItems(std::vector &list); + bool match(const Item &item); + + std::map filters_; + std::set dbc_messages_; + int sort_column_ = NAME; + ImGuiSortDirection sort_order_ = ImGuiSortDirection_Ascending; + int sort_threshold_ = 0; + Connections connections_; +}; + +class MessagesWidget { +public: + MessagesWidget(); + void draw(); + void selectMessage(const MessageId &message_id); + void suppressHighlighted(bool from_suppress_add = false); + const std::string &title() const { return title_; } + std::string whatsThis() const; + + Observable msgSelectionChanged; + +private: + void drawToolBar(); + void drawTable(); + void drawHeader(); + void drawRow(int row); + void drawContextMenu(); + void handleKeys(); + void setCurrentRow(int row); + void updateBytesSectionSize(); + void updateTitle(); + void setMultiLineBytes(bool multi); + + MessageList list_; + std::optional current_msg_id_; + int current_row_ = -1; + bool scroll_to_current_ = false; + int bytes_section_bytes_ = 8; + float fixed_columns_width_ = 0; + bool has_scrollbar_y_ = false; + int visible_rows_ = 1; + std::array filters_; + std::array hidden_ = {}; + std::array display_order_; + std::vector> pending_hidden_; + bool header_menu_requested_ = false; + std::string suppress_clear_text_; + bool suppress_clear_enabled_ = false; + std::string title_ = "MESSAGES"; + Connections connections_; +}; diff --git a/iqpilot/tools/cabana/ui/widgets/scrollabletabbar.cc b/iqpilot/tools/cabana/ui/widgets/scrollabletabbar.cc new file mode 100644 index 000000000..e328eea53 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/scrollabletabbar.cc @@ -0,0 +1,87 @@ +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +#include +#include +#include + +#include "imgui_internal.h" + +namespace { +float scrollButtonsWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::GetFrameHeight() * 2.0f + style.ItemInnerSpacing.x + style.ItemSpacing.x * 2.0f; +} + +void drawScrollButtons(ImGuiTabBar *tab_bar) { + const ImGuiStyle &style = ImGui::GetStyle(); + const float size = ImGui::GetFrameHeight(); + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float start_x = tab_bar->BarRect.Max.x + style.ItemSpacing.x; + const ImVec2 backup_pos = ImGui::GetCursorScreenPos(); + + ImGui::PushItemFlag(ImGuiItemFlags_ButtonRepeat, true); + for (int i = 0; i < 2; ++i) { + const bool left = i == 0; + ImGui::SetCursorScreenPos(ImVec2(start_x + i * (size + style.ItemInnerSpacing.x), tab_bar->BarRect.Min.y)); + ImGui::BeginDisabled(left ? tab_bar->ScrollingTarget <= 0.0f : tab_bar->ScrollingTarget >= max_scroll); + if (ImGui::Button(left ? "###scroll_left" : "###scroll_right", ImVec2(size, size))) { + const float step = (left ? -4.0f : 4.0f) * ImGui::GetFontSize(); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget + step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + + const ImVec2 c((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, + (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f); + const float h = std::round(ImGui::GetFontSize() * 0.25f); + const float dx = left ? h * 0.5f : -h * 0.5f; + ImDrawList *painter = ImGui::GetWindowDrawList(); + painter->PathLineTo(ImVec2(c.x + dx, c.y - h)); + painter->PathLineTo(ImVec2(c.x - dx, c.y)); + painter->PathLineTo(ImVec2(c.x + dx, c.y + h)); + painter->PathStroke(ImGui::GetColorU32(ImGuiCol_Text), ImDrawFlags_None, 1.5f); + ImGui::EndDisabled(); + } + ImGui::PopItemFlag(); + ImGui::SetCursorScreenPos(backup_pos); +} + +struct ScrollableTabBar { ImGuiTabBar *tab_bar; bool overflowing; }; +std::vector scrollable_tab_bars; +} + +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags) { + + ImGuiWindow *window = ImGui::GetCurrentWindow(); + ImGuiTabBar *prev_tab_bar = ImGui::TabBarFindByID(window->GetID(str_id)); + const bool overflowing = prev_tab_bar && prev_tab_bar->WidthAllTabsIdeal > prev_tab_bar->BarRect.GetWidth() + 1.0f; + const float backup_work_max_x = window->WorkRect.Max.x; + if (overflowing) window->WorkRect.Max.x -= scrollButtonsWidth(); + const bool open = ImGui::BeginTabBar(str_id, flags | ImGuiTabBarFlags_FittingPolicyScroll | ImGuiTabBarFlags_NoTabListScrollingButtons); + window->WorkRect.Max.x = backup_work_max_x; + if (open) scrollable_tab_bars.push_back({ImGui::GetCurrentTabBar(), overflowing}); + return open; +} + +void endScrollableTabBar() { + ImGui::EndTabBar(); + const ScrollableTabBar bar = scrollable_tab_bars.back(); + scrollable_tab_bars.pop_back(); + if (!bar.overflowing) return; + drawScrollButtons(bar.tab_bar); + + + + ImGuiTabBar *tab_bar = bar.tab_bar; + if (ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(tab_bar->BarRect.Min, tab_bar->BarRect.Max)) { + ImGui::SetKeyOwner(ImGuiKey_MouseWheelX, tab_bar->ID); + ImGui::SetKeyOwner(ImGuiKey_MouseWheelY, tab_bar->ID); + const ImGuiIO &io = ImGui::GetIO(); + const float wheel = io.MouseWheelH + io.MouseWheel; + if (wheel != 0.0f) { + const float max_scroll = std::max(0.0f, tab_bar->WidthAllTabs - tab_bar->BarRect.GetWidth()); + const float step = std::floor(ImGui::GetFontSize() * 2.0f); + tab_bar->ScrollingTarget = std::clamp(tab_bar->ScrollingTarget - wheel * step, 0.0f, max_scroll); + tab_bar->ScrollingAnim = tab_bar->ScrollingTarget; + } + } +} diff --git a/iqpilot/tools/cabana/ui/widgets/scrollabletabbar.h b/iqpilot/tools/cabana/ui/widgets/scrollabletabbar.h new file mode 100644 index 000000000..95b379342 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/scrollabletabbar.h @@ -0,0 +1,8 @@ +#pragma once + +#include "imgui.h" + + + +bool beginScrollableTabBar(const char *str_id, ImGuiTabBarFlags flags = 0); +void endScrollableTabBar(); diff --git a/iqpilot/tools/cabana/ui/widgets/signalview.cc b/iqpilot/tools/cabana/ui/widgets/signalview.cc new file mode 100644 index 000000000..e2d66eeca --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/signalview.cc @@ -0,0 +1,975 @@ +#include "tools/cabana/ui/widgets/signalview.h" + +#include +#include +#include +#include +#include + +#include "tools/cabana/commands.h" +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/dialogs/messagebox.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" +#include "tools/cabana/ui/icons.h" + +namespace { +constexpr float INDENTATION = 20.0f; +constexpr float H_MARGIN = 3.0f; +constexpr float V_MARGIN = 2.0f; + +constexpr float SIGNAL_ROW_EXTRA = 5.0f; +constexpr float SIGNAL_ROW_SCALE = 1.25f; +constexpr float FILTER_WIDTH = 160.0f; +constexpr float SPARKLINE_SLIDER_WIDTH = 120.0f; +constexpr float COLLAPSE_ICON_SIZE = 12.0f; + + +constexpr int SPARKLINE_RANGE_MAX = 30; +constexpr float LABEL_FONT = 12.0f; +constexpr float MINMAX_FONT = 10.0f; +constexpr int COLOR_LABEL_WIDTH = 18; + +std::string signalTypeToString(cabana::Signal::Type type) { + if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal"; + else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal"; + else return "Normal Signal"; +} + +std::string multiplexIndicator(const cabana::Signal *sig) { + return sig->type == cabana::Signal::Type::Multiplexor ? std::string(" M ") : " m" + std::to_string(sig->multiplex_value) + " "; +} + +std::string nameText(const SignalModel::Item *item) { + return item->type == SignalModel::Item::Sig ? item->sig->name : item->title; +} + +float rowHeight() { + return ImGui::GetFrameHeight(); +} + + +float signalRowHeight() { + return std::floor((ImGui::GetFrameHeight() + SIGNAL_ROW_EXTRA) * SIGNAL_ROW_SCALE); +} + + + +bool valueDescriptionEditor(int column, std::string *text) { + ImGui::PushID(column); + ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, 0.0f); + validatedInput("##edit", text, column == 0 ? doubleValidator : nullptr); + ImGui::PopStyleVar(); + const bool clicked = ImGui::IsItemActivated() || ImGui::IsItemClicked(); + ImGui::PopID(); + return clicked; +} + +} + +SignalModel::SignalModel() : root_(new Item) { + connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); })); + connections_.push_back(dbc()->msgUpdated.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->msgRemoved.connect([this](MessageId id) { handleMsgChanged(id); })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { handleSignalRemoved(sig); })); +} + +void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = sig->name}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); + std::string titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", + "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; + for (int i = 0; i < std::size(titles); ++i) { + auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; + parent_item->children.push_back(item); + if (item->type == Item::ExtraInfo) { + parent_item = item; + } + } +} + +void SignalModel::setMessage(const MessageId &id) { + msg_id_ = id; + filter_str_ = ""; + refresh(); +} + +void SignalModel::setFilter(const std::string &txt) { + filter_str_ = txt; + refresh(); +} + +void SignalModel::refresh() { + root_.reset(new SignalModel::Item); + if (auto msg = dbc()->msg(msg_id_)) { + for (auto s : msg->getSignals()) { + if (filter_str_.empty() || utils::containsCI(s->name, filter_str_)) { + insertItem(root_.get(), root_->children.size(), s); + } + } + } + modelReset(); + rowsChanged(); +} + +bool SignalModel::isEnabled(const Item *item) { + return !(item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed); +} + +bool SignalModel::isCheckable(const Item *item) { + return item->type == Item::Endian || item->type == Item::Signed; +} + +bool SignalModel::isEditable(const Item *item) { + return item->children.empty() && !isCheckable(item); +} + +int SignalModel::signalRow(const cabana::Signal *sig) const { + for (int i = 0; i < root_->children.size(); ++i) { + if (root_->children[i]->sig == sig) return i; + } + return -1; +} + +std::string SignalModel::valueText(const Item *item) const { + switch (item->type) { + case Item::Sig: return item->sig_val; + case Item::Name: return item->sig->name; + case Item::Size: return std::to_string(item->sig->size); + case Item::Node: return item->sig->receiver_name; + case Item::SignalType: return signalTypeToString(item->sig->type); + case Item::MultiplexValue: return std::to_string(item->sig->multiplex_value); + case Item::Offset: return doubleToString(item->sig->offset); + case Item::Factor: return doubleToString(item->sig->factor); + case Item::Unit: return item->sig->unit; + case Item::Comment: return item->sig->comment; + case Item::Min: return doubleToString(item->sig->min); + case Item::Max: return doubleToString(item->sig->max); + case Item::Desc: { + std::string val_desc; + for (auto &[val, desc] : item->sig->val_desc) { + if (!val_desc.empty()) val_desc += " "; + val_desc += utils::toString(val) + " \"" + desc + "\""; + } + return val_desc; + } + default: return {}; + } +} + +bool SignalModel::setData(Item *item, const ItemValue &value) { + cabana::Signal s = *item->sig; + switch (item->type) { + case Item::Name: s.name = value.toString(); break; + case Item::Size: s.size = value.toInt(); break; + case Item::Node: s.receiver_name = utils::trimmed(value.toString()); break; + case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; + case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; + case Item::Endian: s.is_little_endian = value.toBool(); break; + case Item::Signed: s.is_signed = value.toBool(); break; + case Item::Offset: s.offset = value.toDouble(); break; + case Item::Factor: s.factor = value.toDouble(); break; + case Item::Unit: s.unit = value.toString(); break; + case Item::Comment: s.comment = value.toString(); break; + case Item::Min: s.min = value.toDouble(); break; + case Item::Max: s.max = value.toDouble(); break; + case Item::Desc: s.val_desc = value.toValueDescription(); break; + default: return false; + } + return saveSignal(item->sig, s); +} + +bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { + auto msg = dbc()->msg(msg_id_); + if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { + std::string text = "There is already a signal with the same name '" + s.name + "'"; + MessageBox::warning("Failed to save signal", text); + return false; + } + + if (s.is_little_endian != origin_s->is_little_endian) { + s.start_bit = flipBitPos(s.start_bit); + } + UndoStack::instance()->push(new EditSignalCommand(msg_id_, origin_s, s)); + return true; +} + +void SignalModel::handleMsgChanged(MessageId id) { + if (id.address == msg_id_.address) { + refresh(); + } +} + +void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id == msg_id_) { + if (filter_str_.empty()) { + int i = dbc()->msg(msg_id_)->indexOf(sig); + insertItem(root_.get(), i, sig); + rowsChanged(); + } else if (utils::containsCI(sig->name, filter_str_)) { + refresh(); + } + } +} + +void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + if (filter_str_.empty()) { + + int to = dbc()->msg(msg_id_)->indexOf(sig); + if (to != row) { + auto item = root_->children[row]; + root_->children.erase(root_->children.begin() + row); + root_->children.insert(root_->children.begin() + to, item); + } + } + } +} + +void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { + if (int row = signalRow(sig); row != -1) { + delete root_->children[row]; + root_->children.erase(root_->children.begin() + row); + rowsChanged(); + } +} + +float SignalView::textWidth(const std::string &text, float font_size) { + ImFont *font = ImGui::GetFont(); + if (!font || ImGui::GetFontSize() <= 0) return 0; + return font->CalcTextSizeA(font_size > 0 ? font_size : ImGui::GetFontSize(), FLT_MAX, 0.0f, text.c_str()).x; +} + +float SignalView::nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const { + float spacing = INDENTATION + COLOR_LABEL_WIDTH + 8; + std::string txt = text; + if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) { + txt += multiplexIndicator(item->sig); + spacing += H_MARGIN * 2; + } + return std::min(widget_width / 3.0, textWidth(txt) + spacing); +} + +void SignalView::paintCell(ImDrawList *painter, const ImRect &option_rect, const SignalModel::Item *item, int column, + bool selected, const std::string &text, float viewport_x) const { + const float h_margin = H_MARGIN; + const float v_margin = V_MARGIN; + + ImRect rect(option_rect.Min.x + h_margin, option_rect.Min.y + v_margin, option_rect.Max.x - h_margin, option_rect.Max.y - v_margin); + + const ImU32 text_color = selected ? highlightedTextColor() : ImGui::GetColorU32(ImGuiCol_Text); + + if (column == 0) { + if (item->type == SignalModel::Item::Sig) { + + ImRect icon_rect(rect.Min.x, rect.Min.y, rect.Min.x + COLOR_LABEL_WIDTH, rect.Max.y); + painter->AddRectFilled(icon_rect.Min, icon_rect.Max, toImU32(item->sig->color.darker(item->highlight ? 125 : 0)), 3.0f); + drawText(painter, icon_rect, std::to_string(item->row() + 1).c_str(), item->highlight ? IM_COL32_WHITE : IM_COL32_BLACK, + nullptr, LABEL_FONT); + + rect.Min.x = icon_rect.Max.x + h_margin * 2; + + if (item->sig->type != cabana::Signal::Type::Normal) { + const std::string indicator = multiplexIndicator(item->sig); + ImRect indicator_rect(rect.Min.x, rect.Min.y, rect.Min.x + ImGui::CalcTextSize(indicator.c_str()).x, rect.Max.y); + painter->AddRectFilled(indicator_rect.Min, indicator_rect.Max, IM_COL32(160, 160, 164, 255), 3.0f); + drawElidedText(painter, indicator_rect, indicator, IM_COL32_WHITE, false); + rect.Min.x = indicator_rect.Max.x + h_margin * 2; + } + } else { + rect.Min.x = viewport_x + INDENTATION + COLOR_LABEL_WIDTH + h_margin * 3; + } + + + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, false); + } else if (column == 1) { + if (!item->sparkline.isEmpty()) { + const ImVec2 sparkline_size = item->sparkline.size; + item->sparkline.draw(painter, rect.Min); + + rect.Min.x += sparkline_size.x + 1; + float value_adjust = 10; + if (item->highlight || selected) { + painter->AddLine(rect.Min, ImVec2(rect.Min.x, rect.Max.y), text_color); + rect.Min.x += 5; + rect.Min.y -= v_margin; + rect.Max.y += v_margin; + std::string min = utils::toString(item->sparkline.min_val); + std::string max = utils::toString(item->sparkline.max_val); + drawText(painter, rect, max.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 0.0f)); + drawText(painter, rect, min.c_str(), text_color, nullptr, MINMAX_FONT, ImVec2(0.0f, 1.0f)); + value_adjust = std::max(textWidth(min, MINMAX_FONT), textWidth(max, MINMAX_FONT)) + 5; + } else if (item->sig->type == cabana::Signal::Type::Multiplexed) { + + char freq[64]; + snprintf(freq, sizeof(freq), "%.2g hz", item->sparkline.freq()); + ImRect freq_rect(rect.Min.x + 5, rect.Min.y, rect.Max.x, rect.Max.y); + drawText(painter, freq_rect, freq, text_color, nullptr, LABEL_FONT, ImVec2(0.0f, 0.5f)); + value_adjust = textWidth(freq, LABEL_FONT) + 10; + } + + rect.Min.x += value_adjust; + rect.Max.x -= button_size_.x; + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + } else { + + rect.Max.x -= button_size_.x; + if (rect.GetWidth() > 0) drawElidedText(painter, rect, text, text_color, true); + } + } +} + +void SignalView::drawEditor(SignalModel::Item *item) { + const bool take_focus = focus_item_ == item; + if (take_focus) focus_item_ = nullptr; + if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset || + item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue || + item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) { + ImGuiInputTextCallback validator = nullptr; + if (item->type == SignalModel::Item::Name) validator = nameValidator; + else if (item->type == SignalModel::Item::Node) validator = nodeValidator; + else validator = doubleValidator; + + drawLineEditor(item, validator, take_focus); + } else if (item->type == SignalModel::Item::Size) { + int v = item->sig->size; + if (take_focus) ImGui::SetKeyboardFocusHere(); + bool changed = ImGui::InputInt("##editor", &v, 1, 100, ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemDeactivated() && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + open_item_ = nullptr; + return; + } + if (ImGui::IsItemDeactivatedAfterEdit() || (changed && !ImGui::IsItemActive())) { + queueCommit(item, std::clamp(v, 1, CAN_MAX_DATA_BYTES)); + } + + if (ImGui::IsItemDeactivated() && (!ImGui::IsItemHovered() || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false) || ImGui::IsKeyPressed(ImGuiKey_Escape, false))) { + open_item_ = nullptr; + } + } else if (item->type == SignalModel::Item::SignalType) { + + if (combo_focused_ && (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || ImGui::IsKeyPressed(ImGuiKey_Enter, false) || + ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false))) { + open_item_ = nullptr; + combo_focused_ = false; + return; + } + std::vector> items; + items.emplace_back(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal); + if (!dbc()->msg(model_.msgId())->multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor); + } else if (item->sig->type != cabana::Signal::Type::Multiplexor) { + items.emplace_back(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed); + } + std::vector names; + int current = -1; + for (int i = 0; i < items.size(); ++i) { + names.push_back(items[i].first.c_str()); + if (items[i].second == (int)item->sig->type) current = i; + } + const ImGuiID popup_id = ImHashStr("##ComboPopup", 0, ImGui::GetID("##editor")); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (ImGui::Combo("##editor", ¤t, names.data(), names.size())) { + queueCommit(item, items[current].second); + open_item_ = nullptr; + } + combo_focused_ = ImGui::IsItemFocused() || ImGui::IsPopupOpen(popup_id, ImGuiPopupFlags_None); + if (!take_focus && !combo_focused_) open_item_ = nullptr; + } else if (item->type == SignalModel::Item::Desc) { + ImGui::PushStyleColor(ImGuiCol_Header, (ImU32)0); + const bool clicked = ImGui::Selectable("##editor", false, 0, ImVec2(0, rowHeight())); + ImGui::PopStyleColor(); + drawElidedText(ImGui::GetWindowDrawList(), ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()), model_.valueText(item), + highlightedTextColor(), false); + if (clicked || take_focus) { + desc_dlg_ = std::make_unique(item->sig->val_desc); + desc_dlg_->title = item->sig->name; + desc_sig_ = item->sig; + } + } else { + + drawLineEditor(item, nullptr, take_focus); + } +} + +void SignalView::commitEditor() { + SignalModel::Item *item = editing_item_; + std::string text = edit_text_; + closeEditor(); + if (item && validateEditor(item, text) == ValidState::Acceptable) { + queueCommit(item, text); + } +} + +void SignalView::closeEditor() { + editing_item_ = open_item_ = focus_item_ = nullptr; + editor_active_ = refocus_editor_ = enter_pressed_ = combo_focused_ = false; + pending_commit_ = nullptr; +} + + +ValidState SignalView::validateEditor(const SignalModel::Item *item, std::string &text) { + if (item->type == SignalModel::Item::Name) return validateName(text); + if (item->type == SignalModel::Item::Node) return validateNodes(text); + if (item->type == SignalModel::Item::Offset || item->type == SignalModel::Item::Factor || + item->type == SignalModel::Item::MultiplexValue || item->type == SignalModel::Item::Min || + item->type == SignalModel::Item::Max) { + return validateDouble(text); + } + return ValidState::Acceptable; +} + + + + +void SignalView::drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus) { + const bool editing = editing_item_ == item; + const bool was_active = editing && editor_active_; + + std::string text = editing ? edit_text_ : model_.valueText(item); + if (take_focus) ImGui::SetKeyboardFocusHere(); + if (editing && refocus_editor_) { + ImGui::SetKeyboardFocusHere(); + refocus_editor_ = false; + } + validatedInput("##editor", &text, validator, "", ImGuiInputTextFlags_AutoSelectAll); + if (ImGui::IsItemActivated()) editing_item_ = item; + if (editing_item_ != item) return; + + edit_text_ = text; + editor_active_ = ImGui::IsItemActive(); + if (was_active) { + if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) { + + editing_item_ = open_item_ = nullptr; + enter_pressed_ = false; + return; + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { + enter_pressed_ = true; + } + } + if (ImGui::IsItemDeactivated()) { + const bool by_enter = std::exchange(enter_pressed_, false); + if (!ImGui::IsItemDeactivatedAfterEdit()) { + editing_item_ = open_item_ = nullptr; + } else if (validateEditor(item, edit_text_) == ValidState::Acceptable) { + queueCommit(item, edit_text_); + editing_item_ = open_item_ = nullptr; + } else if (by_enter) { + refocus_editor_ = true; + } else { + editing_item_ = open_item_ = nullptr; + } + } +} + +void SignalView::queueCommit(SignalModel::Item *item, const ItemValue &value) { + pending_commit_ = [this, item, value]() { model_.setData(item, value); }; +} + +void SignalView::drawValueDescriptionDlg() { + if (!desc_dlg_) return; + if (desc_dlg_->draw()) return; + + if (desc_dlg_->accepted) { + + for (auto sig_item : model_.root()->children) { + if (sig_item->sig != desc_sig_) continue; + for (auto child : sig_item->children) { + if (child->type != SignalModel::Item::ExtraInfo) continue; + for (auto extra : child->children) { + if (extra->type == SignalModel::Item::Desc) queueCommit(extra, desc_dlg_->val_desc); + } + } + } + } + desc_dlg_.reset(); + desc_sig_ = nullptr; +} + +SignalView::SignalView(ChartsWidget *charts) : charts_(charts) { + settings.sparkline_range = std::clamp(settings.sparkline_range, 1, SPARKLINE_RANGE_MAX); + + + + button_size_ = ImVec2(22 * 2 + TOOLBAR_ITEM_SPACING, 22); + updateToolBar(); + + connections_.push_back(model_.rowsChanged.connect([this]() { rowsChanged(); })); + + connections_.push_back(model_.modelReset.connect([this]() { + closeEditor(); + + + if (first_visible_row_ != -1) { + last_visible_row_ = std::min(model_.rowCount() - 1, last_visible_row_ - first_visible_row_); + first_visible_row_ = 0; + } + })); + connections_.push_back(dbc()->signalAdded.connect([this](MessageId id, const cabana::Signal *sig) { handleSignalAdded(id, sig); })); + connections_.push_back(dbc()->signalUpdated.connect([this](const cabana::Signal *sig) { handleSignalUpdated(sig); })); + + connections_.push_back(dbc()->signalRemoved.connect([this](const cabana::Signal *sig) { + if (desc_sig_ == sig) desc_sig_ = nullptr; + if ((editing_item_ && editing_item_->sig == sig) || (open_item_ && open_item_->sig == sig) || + (focus_item_ && focus_item_->sig == sig)) closeEditor(); + handleSignalRemoved(sig); + })); + connections_.push_back(dbc()->fileChanged.connect([this]() { + desc_sig_ = nullptr; + closeEditor(); + handleSignalRemoved(nullptr); + })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *msgs, bool) { updateState(msgs); })); +} + +std::string SignalView::whatsThis() const { + return R"( + Signal view
+ )"; +} + +void SignalView::setMessage(const MessageId &id) { + filter_edit_.clear(); + model_.setMessage(id); +} + +void SignalView::rowsChanged() { + updateToolBar(); + updateChartState(); + updateState(); +} + +void SignalView::rowClicked(SignalModel::Item *item) { + if (item->type == SignalModel::Item::Sig || item->type == SignalModel::Item::ExtraInfo) { + item->expanded = !item->expanded; + } +} + +void SignalView::selectSignal(const cabana::Signal *sig, bool expand) { + if (int row = model_.signalRow(sig); row != -1) { + auto item = model_.root()->children[row]; + if (expand) { + item->expanded = !item->expanded; + } + scroll_to_sig_ = sig; + current_sig_ = sig; + current_type_ = SignalModel::Item::Sig; + } +} + +void SignalView::updateChartState() { + for (auto item : model_.root()->children) { + item->chart_opened = charts_->hasSignal(model_.msgId(), item->sig); + } +} + +void SignalView::signalHovered(const cabana::Signal *sig) { + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + children[i]->highlight = children[i]->sig == sig; + } +} + +void SignalView::updateToolBar() { + signal_count_lb_ = "Signals: " + std::to_string(model_.rowCount()); + sparkline_label_ = utils::formatSeconds(settings.sparkline_range); +} + +void SignalView::setSparklineRange(int value) { + settings.sparkline_range = value; + updateToolBar(); + updateState(); +} + +void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) { + if (id.address == model_.msgId().address) { + selectSignal(sig); + } +} + +void SignalView::handleSignalUpdated(const cabana::Signal *sig) { + if (int row = model_.signalRow(sig); row != -1) + updateState(); +} + +void SignalView::handleSignalRemoved(const cabana::Signal *sig) { + if (!sig || current_sig_ == sig) { + + current_sig_ = nullptr; + auto &children = model_.root()->children; + if (sig && !children.empty() && current_row_ >= 0) { + current_sig_ = children[std::min(current_row_, children.size() - 1)]->sig; + current_type_ = SignalModel::Item::Sig; + } + } + if (!sig || scroll_to_sig_ == sig) scroll_to_sig_ = nullptr; + if (!sig || hovered_sig_ == sig) hovered_sig_ = nullptr; +} + +float SignalView::widestValueWidth(const cabana::Signal *sig) { + const double raw_max = sig->is_signed ? std::ldexp(1.0, sig->size - 1) - 1 : std::ldexp(1.0, sig->size) - 1; + const double raw_min = sig->is_signed ? -std::ldexp(1.0, sig->size - 1) : 0.0; + float width = 0; + for (double raw : {raw_min, raw_max}) { + width = std::max(width, textWidth(sig->formatValue(raw * sig->factor + sig->offset))); + } + for (const auto &[_, desc] : sig->val_desc) { + width = std::max(width, textWidth(desc)); + } + return width; +} + +void SignalView::updateState(const std::set *msgs) { + const auto &last_msg = can->lastMessage(model_.msgId()); + if (model_.rowCount() == 0 || (msgs && !msgs->count(model_.msgId())) || last_msg.dat.size() == 0) return; + + + + float max_value_width = 0; + for (auto item : model_.root()->children) { + double value = 0; + if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { + item->sig_val = item->sig->formatValue(value); + } + max_value_width = std::max(max_value_width, widestValueWidth(item->sig)); + } + + if (first_visible_row_ != -1 && last_visible_row_ != -1 && last_visible_row_ < model_.rowCount()) { + const float min_max_width = textWidth("-000.00", MINMAX_FONT) + 5; + float available_width = value_column_width_ - button_size_.x; + float value_width = std::min(max_value_width + min_max_width, available_width / 2); + ImVec2 size(std::floor(available_width - value_width), + std::floor(signalRowHeight() - V_MARGIN * 2)); + + + + + const double window_end = can->currentSec(); + + + const double lead_in = settings.sparkline_range * 0.05; + + const auto range = can->eventsInRange(model_.msgId(), std::make_pair(window_end - settings.sparkline_range - lead_in, window_end)); + const CanEventIter first = range.first, last = range.second; + std::vector> futures; + for (int i = first_visible_row_; i <= last_visible_row_; ++i) { + auto item = model_.root()->children[i]; + futures.push_back(ThreadPool::instance().run([item, first, last, size, window_end]() { + item->sparkline.update(item->sig, first, last, settings.sparkline_range, size, window_end); + })); + } + for (auto &f : futures) f.get(); + } +} + + +float SignalView::toolBarRightWidth(const std::string &range_label) { + const ImGuiStyle &style = ImGui::GetStyle(); + return ImGui::CalcTextSize(range_label.c_str()).x + style.ItemSpacing.x + SPARKLINE_SLIDER_WIDTH + style.ItemSpacing.x + + ImGui::GetFont()->CalcTextSizeA(COLLAPSE_ICON_SIZE, FLT_MAX, 0.0f, icon::DASH_SQUARE).x + style.FramePadding.x * 2; +} + + + +float SignalView::minimumWidth() { + const ImGuiStyle &style = ImGui::GetStyle(); + const float left_width = ImGui::CalcTextSize("Signals: 000").x + style.ItemSpacing.x + FILTER_WIDTH; + + return left_width + style.ItemSpacing.x + toolBarRightWidth("00:00") + (style.WindowPadding.x + style.ChildBorderSize) * 2; +} + +void SignalView::draw() { + if (!ImGui::BeginChild("SignalView", ImVec2(0, 0), ImGuiChildFlags_Borders)) { + ImGui::EndChild(); + return; + } + + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(signal_count_lb_.c_str()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(FILTER_WIDTH); + if (clearableInput("##filter_edit", &filter_edit_, "Filter Signal", nonWhitespaceValidator)) { + model_.setFilter(filter_edit_); + } + + + alignRight(toolBarRightWidth(sparkline_label_)); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(sparkline_label_.c_str()); + ImGui::SameLine(); + int range = settings.sparkline_range; + if (fusionSliderInt("##sparkline_range_slider", &range, 1, SPARKLINE_RANGE_MAX, SPARKLINE_SLIDER_WIDTH)) { + setSparklineRange(range); + } + ImGui::SetItemTooltip("Sparkline time range"); + ImGui::SameLine(); + + ImGui::PushFont(ImGui::GetFont(), COLLAPSE_ICON_SIZE); + const bool collapse = toolButton("collapse_all", icon::DASH_SQUARE, "Collapse All"); + ImGui::PopFont(); + if (collapse) collapseAll(); + + drawTree(); + drawValueDescriptionDlg(); + + if (pending_commit_) std::exchange(pending_commit_, nullptr)(); + if (pending_action_) std::exchange(pending_action_, nullptr)(); + current_row_ = model_.signalRow(current_sig_); + + ImGui::EndChild(); +} + +void SignalView::collapseAll() { + commitEditor(); + for (auto item : model_.root()->children) { + item->expanded = false; + for (auto child : item->children) child->expanded = false; + } +} + +void SignalView::drawTree() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f)); + const float min_height = std::max(ImGui::GetContentRegionAvail().y, 300.0f); + const bool visible = ImGui::BeginChild("tree", ImVec2(0, min_height), ImGuiChildFlags_None); + ImGui::PopStyleVar(); + if (visible) { + DrawContext ctx{ImGui::GetWindowDrawList(), ImGui::GetCursorScreenPos().x, ImGui::GetContentRegionAvail().x, rowHeight()}; + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) editor_open_on_press_ = open_item_ != nullptr; + + int first_visible = -1, last_visible = -1; + auto &children = model_.root()->children; + for (int i = 0; i < children.size(); ++i) { + ctx.any_visible = false; + const bool header_visible = drawItem(children[i], 0, ctx); + if (header_visible && first_visible == -1) first_visible = i; + if (ctx.any_visible) last_visible = i; + } + if (first_visible == -1 && last_visible != -1) last_visible = -1; + + bool changed = first_visible != first_visible_row_ || last_visible != last_visible_row_; + first_visible_row_ = first_visible; + last_visible_row_ = last_visible; + scroll_to_sig_ = nullptr; + + if (ctx.name_width > 0) name_column_width_ = ctx.name_width; + if (ctx.value_column_width > 0 && ctx.value_column_width != value_column_width_) { + value_column_width_ = ctx.value_column_width; + changed = true; + } + if (changed) updateState(); + + + + if (!ctx.mouse_on_row && ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + current_sig_ = nullptr; + current_type_ = SignalModel::Item::Root; + } + + if (ctx.hovered_sig != hovered_sig_) { + hovered_sig_ = ctx.hovered_sig; + highlight(hovered_sig_); + } + } + ImGui::EndChild(); + ImGui::PopStyleVar(); +} + +bool SignalView::drawItem(SignalModel::Item *item, int depth, DrawContext &ctx) { + const bool selected = item->sig == current_sig_ && item->type == current_type_; + const float row_height = item->type == SignalModel::Item::Sig ? signalRowHeight() : ctx.row_height; + const ImVec2 row_min = ImGui::GetCursorScreenPos(); + const ImVec2 row_max(row_min.x + ctx.width, row_min.y + row_height); + const bool row_visible = ImGui::IsRectVisible(row_min, row_max); + ctx.any_visible |= row_visible; + + ImGui::PushID(item); + ImGui::BeginDisabled(!SignalModel::isEnabled(item)); + const bool row_clicked = viewSelectable("##row", selected, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, row_height)); + + + const float branch_x = row_min.x + depth * INDENTATION; + const bool on_branch = !item->children.empty() && ImGui::GetMousePos().x >= branch_x && + ImGui::GetMousePos().x < branch_x + INDENTATION; + if (row_clicked && on_branch) { + item->expanded = !item->expanded; + } else if (row_clicked) { + current_sig_ = item->sig; + current_type_ = item->type; + + + closeEditor(); + if (SignalModel::isEditable(item) && ImGui::GetMousePos().x >= row_min.x + name_column_width_) { + focus_item_ = open_item_ = item; + } + rowClicked(item); + } + if (item->type == SignalModel::Item::Sig && item->sig == scroll_to_sig_) { + ImGui::SetScrollHereY(0.0f); + scroll_to_sig_ = nullptr; + } + if (ImGui::IsMouseHoveringRect(row_min, row_max)) { + ctx.mouse_on_row = true; + if (ImGui::IsWindowHovered()) ctx.hovered_sig = item->sig; + } + + if (!item->children.empty()) { + const float arrow_size = ImGui::GetFontSize() * 0.7f; + ImGui::RenderArrow(ctx.draw_list, ImVec2(row_min.x + depth * INDENTATION + 4.0f, row_min.y + (row_height - arrow_size) * 0.5f), + ImGui::GetColorU32(ImGuiCol_Text), item->expanded ? ImGuiDir_Down : ImGuiDir_Right, 0.7f); + } + + + const std::string text0 = nameText(item); + ctx.name_width = std::max(ctx.name_width, nameColumnWidth(item, ctx.width, text0)); + const ImRect rect1(ImVec2(row_min.x + name_column_width_, row_min.y), row_max); + ctx.value_column_width = rect1.GetWidth(); + + + + const bool editor_open = selected && open_item_ == item; + if (row_visible || editor_open) { + const ImRect rect0(ImVec2(row_min.x + (depth + 1) * INDENTATION, row_min.y), ImVec2(row_min.x + name_column_width_, row_max.y)); + paintCell(ctx.draw_list, rect0, item, 0, selected, text0, ctx.viewport_x); + if (item->type == SignalModel::Item::Sig && ImGui::IsMouseHoveringRect(ImVec2(row_min.x, row_min.y), rect0.Max) && + ImGui::IsItemHovered(ImGuiHoveredFlags_ForTooltip) && ImGui::BeginTooltip()) { + ImGui::TextUnformatted(utils::stripHtml(utils::signalToolTip(item->sig)).c_str()); + ImGui::EndTooltip(); + } + + if (item->type == SignalModel::Item::Sig) { + paintCell(ctx.draw_list, rect1, item, 1, selected, item->sig_val, ctx.viewport_x); + drawIndexWidget(item, rect1); + } else if (SignalModel::isCheckable(item)) { + bool checked = item->type == SignalModel::Item::Endian ? item->sig->is_little_endian : item->sig->is_signed; + ImGui::SetCursorScreenPos(ImVec2(rect1.Min.x + H_MARGIN, rect1.Min.y)); + if (checkBox("##check", &checked)) queueCommit(item, checked); + } else if (SignalModel::isEditable(item) && editor_open) { + + ImGui::SetCursorScreenPos(rect1.Min); + ImGui::SetNextItemWidth(rect1.GetWidth()); + drawEditor(item); + } else { + paintCell(ctx.draw_list, rect1, item, 1, selected, model_.valueText(item), ctx.viewport_x); + } + } + ImGui::EndDisabled(); + ImGui::PopID(); + ImGui::SetCursorScreenPos(ImVec2(row_min.x, row_max.y)); + + if (item->expanded) { + for (auto child : item->children) drawItem(child, depth + 1, ctx); + } + return row_visible; +} + +void SignalView::drawIndexWidget(SignalModel::Item *item, const ImRect &rect) { + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(3.0f, 2.0f)); + const ImVec2 btn_size(ImGui::CalcTextSize(icon::GRAPH_UP).x + 6.0f, ImGui::GetFrameHeight()); + const ImVec2 size(btn_size.x * 2 + TOOLBAR_ITEM_SPACING, btn_size.y); + ImGui::SetCursorScreenPos(ImVec2(rect.Max.x - size.x, rect.Min.y + (rect.GetHeight() - size.y) * 0.5f)); + + const auto sig = item->sig; + const bool checked = item->chart_opened; + if (checked) ImGui::PushStyleColor(ImGuiCol_Button, ImGui::GetStyleColorVec4(ImGuiCol_ButtonActive)); + if (ImGui::Button((std::string(icon::GRAPH_UP) + "##plot").c_str(), btn_size) && !editor_open_on_press_) { + item->chart_opened = !checked; + showChart(model_.msgId(), sig, item->chart_opened, ImGui::GetIO().KeyShift); + } + if (checked) ImGui::PopStyleColor(); + ImGui::SetItemTooltip("%s", checked ? "Close Plot" : "Show Plot\nSHIFT click to add to previous opened plot"); + ImGui::SameLine(0.0f, TOOLBAR_ITEM_SPACING); + if (ImGui::Button((std::string(icon::X) + "##remove").c_str(), btn_size) && !editor_open_on_press_) { + pending_action_ = [this, sig]() { UndoStack::instance()->push(new RemoveSigCommand(model_.msgId(), sig)); }; + } + ImGui::SetItemTooltip("Remove signal"); + ImGui::PopStyleVar(); + button_size_ = size; +} + +ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions) { + for (auto &[val, desc] : descriptions) { + table_.emplace_back(utils::toString(val), desc); + } +} + +bool ValueDescriptionDlg::draw() { + const std::string popup_id = title + "###ValueDescriptionDlg"; + if (!opened_) { + ImGui::OpenPopup(popup_id.c_str()); + opened_ = true; + } + setNextDialogWindow(ImVec2(500.0f, 0.0f)); + bool open = true; + + if (!ImGui::BeginPopupModal(popup_id.c_str(), &open, ImGuiWindowFlags_NoSavedSettings)) return ImGui::IsPopupOpen(popup_id.c_str()); + + bool closing = false; + if (ImGui::Button(icon::PLUS)) { + table_.emplace_back("", ""); + } + ImGui::SameLine(); + ImGui::BeginDisabled(current_row_ == -1); + if (ImGui::Button(icon::DASH) && current_row_ < table_.size()) { + table_.erase(table_.begin() + current_row_); + current_row_ = -1; + } + ImGui::EndDisabled(); + + const ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("table", 3, flags, ImVec2(0.0f, 300.0f))) { + ImGui::TableSetupScrollFreeze(1, 1); + + ImGui::TableSetupColumn("##row_number", ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_NoHeaderLabel, + ImGui::CalcTextSize("000").x + ImGui::GetStyle().CellPadding.x * 2); + ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("Description", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableHeadersRow(); + for (int row = 0; row < table_.size(); ++row) { + ImGui::PushID(row); + ImGui::TableNextRow(); + if (row == current_row_) ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, ImGui::GetColorU32(ImGuiCol_Header)); + ImGui::TableSetColumnIndex(0); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(std::to_string(row + 1).c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(0, &table_[row].first)) current_row_ = row; + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (valueDescriptionEditor(1, &table_[row].second)) current_row_ = row; + ImGui::PopID(); + } + ImGui::EndTable(); + } + + bool accept = false, reject = false; + if (dialogButtons("OK", &accept, &reject)) { + if (accept) save(); + closing = true; + } + if (!open) closing = true; + if (closing) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + return !closing; +} + +void ValueDescriptionDlg::save() { + for (int i = 0; i < table_.size(); ++i) { + std::string val = utils::trimmed(table_[i].first); + std::string desc = utils::trimmed(table_[i].second); + if (!val.empty() && !desc.empty()) { + val_desc.push_back({utils::toDouble(val), desc}); + } + } + accepted = true; +} diff --git a/iqpilot/tools/cabana/ui/widgets/signalview.h b/iqpilot/tools/cabana/ui/widgets/signalview.h new file mode 100644 index 000000000..aa064b185 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/signalview.h @@ -0,0 +1,201 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" +#include "tools/cabana/core/observable.h" +#include "tools/cabana/ui/chart/chartswidget.h" +#include "tools/cabana/ui/chart/sparkline.h" +#include "tools/cabana/utils/strings.h" + + +class ItemValue { +public: + ItemValue(const std::string &s) : str_(s) {} + ItemValue(int v) : str_(std::to_string(v)) {} + ItemValue(bool v) : str_(v ? "1" : "0") {} + ItemValue(const ValueDescription &v) : val_desc_(v) {} + ItemValue(const char *) = delete; + std::string toString() const { return str_; } + int toInt() const { return utils::toInt(str_); } + bool toBool() const { return str_ == "1"; } + double toDouble() const { return utils::toDouble(str_); } + const ValueDescription &toValueDescription() const { return val_desc_; } + +private: + std::string str_; + ValueDescription val_desc_; +}; + +class SignalModel { +public: + struct Item { + enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; + ~Item() { for (auto c : children) delete c; } + inline int row() const { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } + + Type type = Type::Root; + Item *parent = nullptr; + std::vector children; + + const cabana::Signal *sig = nullptr; + std::string title; + bool highlight = false; + std::string sig_val = "-"; + Sparkline sparkline; + bool expanded = false; + bool chart_opened = false; + }; + + SignalModel(); + Item *root() const { return root_.get(); } + const MessageId &msgId() const { return msg_id_; } + int rowCount() const { return root_->children.size(); } + static bool isEnabled(const Item *item); + static bool isEditable(const Item *item); + static bool isCheckable(const Item *item); + std::string valueText(const Item *item) const; + bool setData(Item *item, const ItemValue &value); + void setMessage(const MessageId &id); + void setFilter(const std::string &txt); + bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s); + int signalRow(const cabana::Signal *sig) const; + + Observable<> rowsChanged; + Observable<> modelReset; + +private: + void insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); + void handleMsgChanged(MessageId id); + void refresh(); + + MessageId msg_id_; + std::string filter_str_; + std::unique_ptr root_; + Connections connections_; +}; + + +class ValueDescriptionDlg { +public: + ValueDescriptionDlg(const ValueDescription &descriptions); + bool draw(); + ValueDescription val_desc; + std::string title; + bool accepted = false; + +private: + void save(); + std::vector> table_; + int current_row_ = -1; + bool opened_ = false; +}; + +class SignalView { +public: + SignalView(ChartsWidget *charts); + void setMessage(const MessageId &id); + void draw(); + static float minimumWidth(); + void signalHovered(const cabana::Signal *sig); + void updateChartState(); + void selectSignal(const cabana::Signal *sig, bool expand = false); + bool saveSignal(const cabana::Signal *origin, cabana::Signal &s) { return model_.saveSignal(origin, s); } + std::string whatsThis() const; + + Observable highlight; + Observable showChart; + +private: + void rowsChanged(); + void rowClicked(SignalModel::Item *item); + static float toolBarRightWidth(const std::string &range_label); + void updateToolBar(); + void setSparklineRange(int value); + void handleSignalAdded(MessageId id, const cabana::Signal *sig); + void handleSignalUpdated(const cabana::Signal *sig); + void handleSignalRemoved(const cabana::Signal *sig); + void updateState(const std::set *msgs = nullptr); + + struct DrawContext { + ImDrawList *draw_list; + float viewport_x; + float width; + float row_height; + float name_width = 0; + float value_column_width = 0; + bool any_visible = false; + bool mouse_on_row = false; + const cabana::Signal *hovered_sig = nullptr; + }; + void drawTree(); + bool drawItem(SignalModel::Item *item, int depth, DrawContext &ctx); + void drawIndexWidget(SignalModel::Item *item, const ImRect &rect); + void collapseAll(); + static float widestValueWidth(const cabana::Signal *sig); + + + void paintCell(ImDrawList *painter, const ImRect &rect, const SignalModel::Item *item, int column, bool selected, + const std::string &text, float viewport_x) const; + float nameColumnWidth(const SignalModel::Item *item, float widget_width, const std::string &text) const; + + void drawEditor(SignalModel::Item *item); + + + void queueCommit(SignalModel::Item *item, const ItemValue &value); + void drawValueDescriptionDlg(); + static float textWidth(const std::string &text, float font_size = 0); + void closeEditor(); + void commitEditor(); + + void drawLineEditor(SignalModel::Item *item, ImGuiInputTextCallback validator, bool take_focus); + static ValidState validateEditor(const SignalModel::Item *item, std::string &text); + + float value_column_width_ = 0; + float name_column_width_ = 150; + bool editor_open_on_press_ = false; + + + int first_visible_row_ = -1; + int last_visible_row_ = -1; + const cabana::Signal *current_sig_ = nullptr; + int current_row_ = -1; + SignalModel::Item::Type current_type_ = SignalModel::Item::Root; + const cabana::Signal *scroll_to_sig_ = nullptr; + const cabana::Signal *hovered_sig_ = nullptr; + std::function pending_action_; + std::string sparkline_label_; + std::string filter_edit_; + ChartsWidget *charts_; + std::string signal_count_lb_; + + ImVec2 button_size_ = {}; + + SignalModel::Item *focus_item_ = nullptr; + + SignalModel::Item *open_item_ = nullptr; + std::function pending_commit_; + SignalModel::Item *editing_item_ = nullptr; + std::string edit_text_; + bool editor_active_ = false; + bool refocus_editor_ = false; + bool enter_pressed_ = false; + bool combo_focused_ = false; + std::unique_ptr desc_dlg_; + const cabana::Signal *desc_sig_ = nullptr; + SignalModel model_; + Connections connections_; +}; diff --git a/iqpilot/tools/cabana/ui/widgets/tabbar.cc b/iqpilot/tools/cabana/ui/widgets/tabbar.cc new file mode 100644 index 000000000..c68e25d1c --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/tabbar.cc @@ -0,0 +1,92 @@ +#include "tools/cabana/ui/widgets/tabbar.h" + +#include +#include + +#include "tools/cabana/ui/widgets/scrollabletabbar.h" + +int TabBar::addTab(const std::string &text) { + tabs_.push_back({text, 0, next_id_++}); + int index = count() - 1; + if (current_index_ == -1) { + current_index_ = index; + select_current_ = true; + currentChanged(index); + } + return index; +} + +void TabBar::setCurrentIndex(int index) { + if (index == current_index_ || index < -1 || index >= count()) return; + current_index_ = index; + select_current_ = true; + currentChanged(index); +} + +int TabBar::tabAt(const ImVec2 &pos) const { + for (int i = 0; i < count(); ++i) { + if (tabs_[i].rect.Contains(pos)) return i; + } + return -1; +} + +void TabBar::removeTab(int index) { + tabs_.erase(tabs_.begin() + index); + if (index == current_index_) { + + current_index_ = count() ? std::min(index, count() - 1) : -1; + select_current_ = true; + currentChanged(current_index_); + } else if (index < current_index_) { + --current_index_; + } +} + +void TabBar::moveTab(int from, int to) { + if (from == to || from < 0 || from >= count() || to < 0 || to >= count()) return; + const int current_id = current_index_ >= 0 ? tabs_[current_index_].id : -1; + Tab tab = std::move(tabs_[from]); + tabs_.erase(tabs_.begin() + from); + tabs_.insert(tabs_.begin() + to, std::move(tab)); + for (int i = 0; i < count(); ++i) { + if (tabs_[i].id == current_id) current_index_ = i; + } + select_current_ = true; +} + +void TabBar::draw() { + if (auto_hide_ && count() < 2) return; + ImGui::PushID(this); + + if (!(scroll_buttons_ ? beginScrollableTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip) : ImGui::BeginTabBar("##tabbar", ImGuiTabBarFlags_NoTooltip))) { + ImGui::PopID(); + return; + } + + ImGuiStyle &style = ImGui::GetStyle(); + const float close_button_min_width = tabs_closable_ ? std::exchange(style.TabCloseButtonMinWidthUnselected, -1.0f) : 0.0f; + + const bool select_current = std::exchange(select_current_, false); + int close_index = -1; + for (int i = 0; i < count(); ++i) { + bool open = true; + const std::string label = tabs_[i].text + "###tab" + std::to_string(tabs_[i].id); + const ImGuiTabItemFlags flags = (select_current && i == current_index_) ? ImGuiTabItemFlags_SetSelected : 0; + if (ImGui::BeginTabItem(label.c_str(), tabs_closable_ ? &open : nullptr, flags)) { + + if (!select_current && i != current_index_) { + current_index_ = i; + currentChanged(i); + } + ImGui::EndTabItem(); + } + tabs_[i].rect = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + if (!tabs_[i].tooltip.empty()) ImGui::SetItemTooltip("%s", tabs_[i].tooltip.c_str()); + tabContextMenu(i); + if (!open) close_index = i; + } + if (tabs_closable_) style.TabCloseButtonMinWidthUnselected = close_button_min_width; + scroll_buttons_ ? endScrollableTabBar() : ImGui::EndTabBar(); + ImGui::PopID(); + if (close_index >= 0) tabCloseRequested(close_index); +} diff --git a/iqpilot/tools/cabana/ui/widgets/tabbar.h b/iqpilot/tools/cabana/ui/widgets/tabbar.h new file mode 100644 index 000000000..6aaa1c282 --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/tabbar.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/core/observable.h" + + +class TabBar { +public: + TabBar() = default; + int addTab(const std::string &text); + int count() const { return (int)tabs_.size(); } + void setTabText(int index, const std::string &text) { if (index >= 0 && index < count()) tabs_[index].text = text; } + const std::string &tabText(int index) const { return tabs_[index].text; } + void setTabToolTip(int index, const std::string &tip) { if (index >= 0 && index < count()) tabs_[index].tooltip = tip; } + void setTabData(int index, int data) { if (index >= 0 && index < count()) tabs_[index].data = data; } + int tabData(int index) const { return index >= 0 && index < count() ? tabs_[index].data : 0; } + int currentIndex() const { return current_index_; } + void setCurrentIndex(int index); + int tabAt(const ImVec2 &pos) const; + void removeTab(int index); + void moveTab(int from, int to); + void setAutoHide(bool hide) { auto_hide_ = hide; } + void setTabsClosable(bool closable) { tabs_closable_ = closable; } + void setUsesScrollButtons(bool use) { scroll_buttons_ = use; } + void draw(); + + Observable currentChanged; + Observable tabCloseRequested; + Observable tabContextMenu; + +private: + struct Tab { std::string text; int data = 0; int id = 0; std::string tooltip; ImRect rect; }; + std::vector tabs_; + int current_index_ = -1; + int next_id_ = 0; + bool select_current_ = false; + bool auto_hide_ = false; + bool tabs_closable_ = false; + bool scroll_buttons_ = false; +}; diff --git a/iqpilot/tools/cabana/ui/widgets/videowidget.cc b/iqpilot/tools/cabana/ui/widgets/videowidget.cc new file mode 100644 index 000000000..ef8d9b5ef --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/videowidget.cc @@ -0,0 +1,612 @@ +#include "tools/cabana/ui/widgets/videowidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +#include +} +#include + +#include "tools/cabana/settings.h" +#include "tools/cabana/ui/threadpool.h" +#include "tools/cabana/ui/util.h" +#include "tools/cabana/utils/strings.h" +#include "tools/cabana/utils/util.h" + +const int MIN_VIDEO_HEIGHT = 100; +const int THUMBNAIL_MARGIN = 3; +const float POINT_10_FONT_SIZE = 13.0f; +const float POINT_16_FONT_SIZE = 21.0f; +const float TOOLBAR_MARGIN_Y = 6.0f; +const float TOOLBAR_SEPARATOR_EXTENT = 6.0f; +const float SLIDER_HEIGHT = 15.0f; + + +static const ImU32 timeline_colors[] = { + IM_COL32(111, 143, 175, 255), + IM_COL32(0, 163, 108, 255), + IM_COL32(0, 255, 0, 255), + IM_COL32(255, 195, 0, 255), + IM_COL32(199, 0, 57, 255), + IM_COL32(255, 0, 255, 255), +}; + +static const float speeds[] = {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}; +static const int NORMAL_SPEED_INDEX = std::find(std::begin(speeds), std::end(speeds), 1.0f) - std::begin(speeds); + +static Replay *getReplay() { + auto stream = dynamic_cast(can); + return stream ? stream->getReplay() : nullptr; +} + +static std::string colorName(ImU32 c) { + char buf[16]; + snprintf(buf, sizeof(buf), "#%02x%02x%02x", (c >> IM_COL32_R_SHIFT) & 0xff, (c >> IM_COL32_G_SHIFT) & 0xff, (c >> IM_COL32_B_SHIFT) & 0xff); + return buf; +} + + +static std::pair displayedTimeRange() { + return can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); +} + + +static bool decodeJpeg(const uint8_t *data, size_t size, RgbImage *out) { + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + bool ok = false; + if (context && frame && packet && size > 0 && size <= (size_t)INT32_MAX && av_new_packet(packet, (int)size) >= 0) { + std::copy(data, data + size, packet->data); + ok = avcodec_open2(context, codec, nullptr) >= 0 && avcodec_send_packet(context, packet) >= 0 && + avcodec_receive_frame(context, frame) >= 0 && frame->width > 0 && frame->height > 0; + } + int chroma_x_shift = 0, chroma_y_shift = 0; + if (ok) { + switch ((AVPixelFormat)frame->format) { + case AV_PIX_FMT_YUV420P: case AV_PIX_FMT_YUVJ420P: chroma_x_shift = chroma_y_shift = 1; break; + case AV_PIX_FMT_YUV422P: case AV_PIX_FMT_YUVJ422P: chroma_x_shift = 1; break; + case AV_PIX_FMT_YUV444P: case AV_PIX_FMT_YUVJ444P: break; + default: ok = false; break; + } + } + if (ok) { + out->resize(frame->width, frame->height); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG || frame->format == AV_PIX_FMT_YUVJ420P || + frame->format == AV_PIX_FMT_YUVJ422P || frame->format == AV_PIX_FMT_YUVJ444P; + const float y_scale = full_range ? 1.0f : 1.164383f; + const float y_offset = full_range ? 0.0f : 16.0f; + const float kr = full_range ? 1.402f : 1.596027f; + const float kgu = full_range ? 0.344136f : 0.391762f; + const float kgv = full_range ? 0.714136f : 0.812968f; + const float kb = full_range ? 1.772f : 2.017232f; + for (int y = 0; y < frame->height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *dst = out->data.data() + (size_t)y * out->bytesPerLine(); + for (int x = 0; x < frame->width; ++x) { + const float luma = y_scale * ((float)y_row[x] - y_offset); + const float u = (float)u_row[x >> chroma_x_shift] - 128.0f; + const float v = (float)v_row[x >> chroma_x_shift] - 128.0f; + const float r = luma + kr * v; + const float g = luma - kgu * u - kgv * v; + const float b = luma + kb * u; + dst[x * 4 + 0] = (uint8_t)std::clamp(std::lround(r), 0L, 255L); + dst[x * 4 + 1] = (uint8_t)std::clamp(std::lround(g), 0L, 255L); + dst[x * 4 + 2] = (uint8_t)std::clamp(std::lround(b), 0L, 255L); + dst[x * 4 + 3] = 255; + } + } + } + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return ok; +} + +VideoWidget::VideoWidget() { + if (!can->liveStreaming()) + createCameraWidget(); + + createSpeedDropdown(); + + connections_.push_back(can->timeRangeChanged.connect([this](const auto &) { timeRangeChanged(); })); + connections_.push_back(can->msgsReceived.connect([this](const std::set *, bool) { msgs_received_ = true; })); +} + +std::string VideoWidget::whatsThis() const { + + return "Video
\n" + "Timeline color
\n" + + colorName(timeline_colors[(int)TimelineType::None]) + " Disengaged   " + + colorName(timeline_colors[(int)TimelineType::Engaged]) + " Engaged
\n" + + colorName(timeline_colors[(int)TimelineType::UserBookmark]) + " User Flag   " + + colorName(timeline_colors[(int)TimelineType::AlertInfo]) + " Info
\n" + + colorName(timeline_colors[(int)TimelineType::AlertWarning]) + " Warning   " + + colorName(timeline_colors[(int)TimelineType::AlertCritical]) + " Critical
\n" + "Shortcuts
\n" + "Pause/Resume:  space "; +} + +static float toolbarHeight() { return TOOLBAR_MARGIN_Y + ImGui::GetFrameHeight(); } + +void VideoWidget::drawPlaybackController() { + beginToolbar(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + TOOLBAR_MARGIN_Y); + const float speed_width = menuButtonWidth("0.05x ", true); + + const char *play_icon = can->isPaused() ? icon::PLAY : icon::PAUSE; + const char *play_tooltip = can->isPaused() ? "Play" : "Pause"; + const char *loop_icon = getReplay() && getReplay()->loop() ? icon::REPEAT : icon::REPEAT_1; + const std::string time_text = slider_ ? formatTime(can->currentSec(), true) + " / " + formatTime(slider_->maximum() / slider_->factor) + : formatTime(can->currentSec(), true); + const char *time_tooltip = settings.absolute_time ? "Elapsed time" : "Absolute time"; + + auto seek_backward = []() { can->seekTo(can->currentSec() - 1); }; + auto toggle_play = []() { can->pause(!can->isPaused()); }; + auto seek_forward = []() { can->seekTo(can->currentSec() + 1); }; + + std::vector items = { + {toolbarButtonWidth(icon::REWIND), [&]() { if (toolButton("rewind", icon::REWIND, "Seek backward")) seek_backward(); }, + "Seek backward", seek_backward}, + {toolbarButtonWidth(play_icon), [&]() { if (toolButton("play", play_icon, play_tooltip)) toggle_play(); }, + play_tooltip, toggle_play}, + {toolbarButtonWidth(icon::FAST_FORWARD), [&]() { if (toolButton("fast-forward", icon::FAST_FORWARD, "Seek forward")) seek_forward(); }, + "Seek forward", seek_forward}, + }; + if (can->liveStreaming()) { + items.push_back({toolbarButtonWidth(icon::SKIP_END), [&]() { + ImGui::BeginDisabled(!skip_to_end_enabled_); + if (toolButton("skip-end", icon::SKIP_END, "Skip to the end")) skipToEnd(); + ImGui::EndDisabled(); + }, "Skip to the end", [this]() { skipToEnd(); }, skip_to_end_enabled_}); + } + if (slider_ || msgs_received_) { + + pushMonoFont(ImGui::GetFontSize()); + const float time_width = toolbarButtonWidth(time_text); + popMonoFont(); + items.push_back({time_width, + [&]() { + pushMonoFont(ImGui::GetFontSize()); + if (toolButton("time_display", time_text.c_str(), time_tooltip)) toggleTimeDisplay(); + popMonoFont(); + }, + time_text, [this]() { toggleTimeDisplay(); }}); + } + + const size_t spacer_index = items.size(); + if (!can->liveStreaming()) { + items.push_back({toolbarButtonWidth(loop_icon), [&]() { if (toolButton("loop", loop_icon, "Loop playback")) loopPlaybackClicked(); }, + "Loop playback", [this]() { loopPlaybackClicked(); }}); + } + items.push_back({speed_width, [&]() { drawSpeedDropdown(speed_width); }}); + if (!can->liveStreaming()) { + ToolbarItem separator{TOOLBAR_SEPARATOR_EXTENT, []() { + + const ImVec2 min = ImGui::GetCursorScreenPos(); + ImGui::Dummy(ImVec2(TOOLBAR_SEPARATOR_EXTENT, ImGui::GetFrameHeight())); + const float x = std::floor(min.x + TOOLBAR_SEPARATOR_EXTENT * 0.5f); + ImGui::GetWindowDrawList()->AddLine(ImVec2(x, min.y + 4.0f), ImVec2(x, min.y + ImGui::GetFrameHeight() - 4.0f), ImGui::GetColorU32(ImGuiCol_Separator)); + }}; + separator.in_menu = false; + items.push_back(std::move(separator)); + items.push_back({toolbarButtonWidth(icon::INFO_CIRCLE), + [&]() { if (toolButton("route_info", icon::INFO_CIRCLE, "View route details")) showRouteInfo(); }, + "View route details", [this]() { showRouteInfo(); }}); + } + + drawToolbar(items, spacer_index); + endToolbar(); +} + +void VideoWidget::skipToEnd() { + + speed_index_ = NORMAL_SPEED_INDEX; + can->pause(false); + can->seekTo(can->maxSeconds() + 1); +} + +void VideoWidget::toggleTimeDisplay() { + settings.absolute_time = !settings.absolute_time; +} + +static std::string speedText(float speed, const char *suffix) { + char buf[32]; + snprintf(buf, sizeof(buf), "%gx%s", speed, suffix); + return buf; +} + +void VideoWidget::createSpeedDropdown() { + speed_index_ = NORMAL_SPEED_INDEX; + can->setSpeed(speeds[speed_index_]); + speed_text_ = speedText(speeds[speed_index_], " "); +} + +void VideoWidget::drawSpeedDropdown(float width) { + menuButton("speed_btn", speed_text_, "speed_menu", true, width); + if (ImGui::BeginPopup("speed_menu")) { + drawSpeedMenuItems(); + ImGui::EndPopup(); + } +} + +void VideoWidget::drawSpeedMenuItems() { + + + const float indent = ImGui::GetFontSize(); + float label_width = 0; + for (int i = 0; i < (int)std::size(speeds); ++i) { + label_width = std::max(label_width, ImGui::CalcTextSize(speedText(speeds[i], "").c_str()).x); + } + for (int i = 0; i < (int)std::size(speeds); ++i) { + const float speed = speeds[i]; + if (radioMenuItem(speedText(speed, "").c_str(), speed_index_ == i, indent + label_width + indent)) { + speed_index_ = i; + can->setSpeed(speed); + speed_text_ = speedText(speed, " "); + } + } +} + +void VideoWidget::createCameraWidget() { + camera_tab_ = std::make_unique(); + camera_tab_->setAutoHide(true); + + cam_widget_ = std::make_unique("camerad", VISION_STREAM_ROAD); + + slider_ = std::make_unique(); + slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); + + connections_.push_back(slider_->sliderReleased.connect([this]() { can->seekTo(slider_->currentSecond()); })); + connections_.push_back(cam_widget_->clicked.connect([]() { can->pause(!can->isPaused()); })); + connections_.push_back(cam_widget_->availableStreamsUpdated.connect([this](std::set streams) { vipcAvailableStreamsUpdated(streams); })); + connections_.push_back(camera_tab_->currentChanged.connect([this](int index) { + if (index != -1) cam_widget_->setStreamType((VisionStreamType)camera_tab_->tabData(index)); + })); + connections_.push_back(static_cast(can)->qLogLoaded.connect([this](std::shared_ptr qlog) { cam_widget_->parseQLog(qlog); })); +} + +void VideoWidget::drawCameraWidget() { + camera_tab_->draw(); + + + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, avail.y - SLIDER_HEIGHT - toolbarHeight()); + cam_widget_->draw(ImVec2(avail.x, cam_height), thumbnail_display_time_); + + if (!slider_->isSliderDown()) slider_->setCurrentSecond(can->currentSec()); + slider_->draw(thumbnail_display_time_); + updateSliderThumbnail(); +} + +void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { + static const std::string stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; + for (int i = 0; i < streams.size(); ++i) { + if (camera_tab_->count() <= i) { + camera_tab_->addTab(std::string()); + } + int type = *std::next(streams.begin(), i); + camera_tab_->setTabText(i, stream_names[type]); + camera_tab_->setTabData(i, type); + } + while (camera_tab_->count() > streams.size()) { + camera_tab_->removeTab(camera_tab_->count() - 1); + } +} + +void VideoWidget::loopPlaybackClicked() { + getReplay()->setLoop(!getReplay()->loop()); +} + +void VideoWidget::timeRangeChanged() { + const auto time_range = can->timeRange(); + if (can->liveStreaming()) { + skip_to_end_enabled_ = !time_range.has_value(); + return; + } + time_range ? slider_->setTimeRange(time_range->first, time_range->second) + : slider_->setTimeRange(can->minSeconds(), can->maxSeconds()); +} + +std::string VideoWidget::formatTime(double sec, bool include_milliseconds) { + if (settings.absolute_time) + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); + return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); +} + +void VideoWidget::setVisible(bool visible) { + if (cam_widget_) cam_widget_->setVisible(visible); +} + +void VideoWidget::showThumbnail(double seconds) { + if (can->liveStreaming()) return; + thumbnail_display_time_ = seconds; +} + +void VideoWidget::showRouteInfo() { + + route_info_dlgs_.push_back(std::make_unique()); +} + +void VideoWidget::updateSliderThumbnail() { + if (slider_->underMouse()) { + auto [min_sec, max_sec] = displayedTimeRange(); + showThumbnail(min_sec + (ImGui::GetMousePos().x - slider_->rect().Min.x) * (max_sec - min_sec) / slider_->width()); + } else if (slider_->mouseLeft()) { + showThumbnail(-1); + } +} + +float VideoWidget::sizeHintHeight() const { + + return MIN_VIDEO_HEIGHT + SLIDER_HEIGHT + toolbarHeight(); +} + + +float VideoWidget::defaultHeight(float width) const { + if (!cam_widget_) return toolbarHeight(); + const float cam_height = std::max((float)MIN_VIDEO_HEIGHT, width / cam_widget_->frameAspectRatio()); + const float tab_height = camera_tab_->count() >= 2 ? ImGui::GetFrameHeight() : 0.0f; + return cam_height + tab_height + SLIDER_HEIGHT + toolbarHeight(); +} + +void VideoWidget::draw() { + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(ImGui::GetStyle().ItemSpacing.x, 0.0f)); + if (!can->liveStreaming()) + drawCameraWidget(); + + drawPlaybackController(); + ImGui::PopStyleVar(); + + for (auto it = route_info_dlgs_.begin(); it != route_info_dlgs_.end();) { + it = (*it)->draw() ? it + 1 : route_info_dlgs_.erase(it); + } +} + +void Slider::draw(double thumbnail_time) { + ImGui::InvisibleButton("##slider", ImVec2(std::max(1.0f, ImGui::GetContentRegionAvail().x), SLIDER_HEIGHT)); + rect_ = ImRect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); + const bool hovered = ImGui::IsItemHovered(); + left_ = hovered_ && !hovered; + hovered_ = hovered; + + if (ImGui::IsItemActivated()) handleMousePress(); + if (slider_down_) { + if (ImGui::IsItemActive()) { + + setValue(pixelPosToRangeValue(ImGui::GetMousePos().x - click_offset_)); + } else { + slider_down_ = false; + sliderReleased(); + } + } + paint(thumbnail_time); +} + +ImRect Slider::handleRect() const { + const float handle_width = SLIDER_LENGTH; + const float handle_height = std::min(SLIDER_THICKNESS, rect_.GetHeight()); + const int range = std::max(1, maximum() - minimum()); + const float x = rect_.Min.x + (float)(value() - minimum()) / range * std::max(0.0f, width() - handle_width); + const float y = rect_.GetCenter().y - handle_height / 2; + return ImRect(ImVec2(x, y), ImVec2(x + handle_width, y + handle_height)); +} + + +int Slider::pixelPosToRangeValue(float x) const { + const float handle_width = SLIDER_LENGTH; + const float span = std::max(1.0f, width() - handle_width); + return minimum() + (int)std::lround((maximum() - minimum()) * std::clamp((x - rect_.Min.x) / span, 0.0f, 1.0f)); +} + +void Slider::paint(double thumbnail_time) { + ImDrawList *p = ImGui::GetWindowDrawList(); + + ImRect handle_rect = handleRect(); + ImRect groove_rect = rect_; + + + float handle_height = handle_rect.GetHeight(); + const float groove_height = std::ceil(handle_height * 0.5f); + const float center_y = rect_.GetCenter().y; + groove_rect.Min.y = std::floor(center_y - groove_height / 2); + groove_rect.Max.y = groove_rect.Min.y + groove_height; + + p->AddRectFilled(groove_rect.Min, groove_rect.Max, timeline_colors[(int)TimelineType::None]); + + double min = minimum() / factor; + double max = maximum() / factor; + const double span = std::max(max - min, 1e-9); + + auto fillRange = [&](double begin, double end, ImU32 color) { + if (begin > max || end < min) return; + + + + ImRect r = groove_rect; + r.Min.x = rect_.Min.x + std::floor(((std::max(min, begin) - min) / span) * width()); + r.Max.x = rect_.Min.x + std::floor(((std::min(max, end) - min) / span) * width()) + 1.0f; + p->AddRectFilled(r.Min, r.Max, color); + }; + + if (auto replay = getReplay()) { + for (const auto &entry : *replay->getTimeline()) { + fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); + } + + ImU32 empty_color = ImGui::GetColorU32(ImGuiCol_WindowBg, 160 / 255.0f); + const auto event_data = replay->getEventData(); + for (const auto &[n, _] : replay->route().segments()) { + if (!event_data->isSegmentLoaded(n)) + fillRange(n * 60.0, (n + 1) * 60.0, empty_color); + } + } + + drawSliderHandle(p, handle_rect); + + if (thumbnail_time >= 0) { + float left = rect_.Min.x + (float)((thumbnail_time - min) * width() / span) - 1; + ImRect rc(ImVec2(left, rect_.Min.y + 1), ImVec2(left + 2, rect_.Max.y - 1)); + p->AddRectFilled(rc.Min, rc.Max, ImGui::GetColorU32(ImGuiCol_Header), 1.5f); + } +} + +void Slider::handleMousePress() { + + const ImRect handle_rect = handleRect(); + if (handle_rect.Contains(ImGui::GetMousePos())) { + slider_down_ = true; + click_offset_ = ImGui::GetMousePos().x - handle_rect.Min.x; + return; + } + setValue(minimum() + (int)(((maximum() - minimum()) * (ImGui::GetMousePos().x - rect_.Min.x)) / width())); + sliderReleased(); +} + +StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type) + : CameraWidget(stream_name, stream_type) { + big_thumbnail_texture_.mipmap = true; +} + +StreamCameraView::~StreamCameraView() { + for (auto &pending : pending_thumbnails_) pending.done.wait(); +} + +void StreamCameraView::parseQLog(std::shared_ptr qlog) { + auto thumbnails = std::make_shared>(); + auto done = ThreadPool::instance().run([qlog, thumbnails]() { + for (const Event &e : qlog->events) { + if (e.which != cereal::Event::Which::THUMBNAIL) continue; + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (RgbImage thumb; decodeJpeg(image_data.begin(), image_data.size(), &thumb)) { + (*thumbnails)[thumb_data.getTimestampEof()] = std::move(thumb); + } + } + }); + pending_thumbnails_.push_back({std::move(done), std::move(thumbnails)}); +} + +void StreamCameraView::collectThumbnails() { + for (auto it = pending_thumbnails_.begin(); it != pending_thumbnails_.end();) { + if (it->done.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + ++it; + continue; + } + for (auto &[ts, thumb] : *it->thumbnails) big_thumbnails_[ts] = std::move(thumb); + it = pending_thumbnails_.erase(it); + } +} + +void StreamCameraView::draw(const ImVec2 &size, double thumbnail_time) { + collectThumbnails(); + CameraWidget::draw(size); + + ImDrawList *p = ImGui::GetWindowDrawList(); + bool scrubbing = false; + if (thumbnail_time >= 0) { + scrubbing = can->isPaused(); + scrubbing ? drawScrubThumbnail(p, thumbnail_time) : drawThumbnail(p, thumbnail_time); + } + if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_time : can->currentSec())) { + drawAlert(p, rect(), *alert, ImGui::GetFontSize()); + } + + if (can->isPaused()) { + ImFont *font = boldFont(); + const char *text = "PAUSED"; + const ImVec2 text_size = font->CalcTextSizeA(POINT_16_FONT_SIZE, FLT_MAX, 0.0f, text); + const ImVec2 center = rect().GetCenter(); + p->AddText(font, POINT_16_FONT_SIZE, ImVec2(center.x - text_size.x / 2, center.y - text_size.y / 2), + IM_COL32(200, 200, 200, static_cast(255 * 0.7f)), text); + } +} + +const RgbImage *StreamCameraView::thumbnailAt(double sec, uint64_t *mono_time) { + auto it = big_thumbnails_.lower_bound(can->toMonoTime(sec)); + if (it == big_thumbnails_.end()) return nullptr; + if (big_thumbnail_texture_.id == 0 || big_thumbnail_texture_.key != it->first) { + big_thumbnail_texture_.upload(it->second); + big_thumbnail_texture_.key = it->first; + } + if (mono_time) *mono_time = it->first; + return &it->second; +} + +void StreamCameraView::drawScrubThumbnail(ImDrawList *p, double sec) { + p->AddRectFilled(rect().Min, rect().Max, IM_COL32(0, 0, 0, 255)); + if (const RgbImage *image = thumbnailAt(sec, nullptr)) { + + const float scale = std::min(width() / image->width, height() / image->height); + const ImVec2 scaled_size(std::floor(image->width * scale), std::floor(image->height * scale)); + const ImVec2 center = rect().GetCenter(); + const ImVec2 thumb_min(center.x - (int)(scaled_size.x / 2), center.y - (int)(scaled_size.y / 2)); + ImRect thumb_rect(thumb_min, ImVec2(thumb_min.x + scaled_size.x, thumb_min.y + scaled_size.y)); + p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawThumbnail(ImDrawList *p, double sec) { + uint64_t mono_time = 0; + if (const RgbImage *image = thumbnailAt(sec, &mono_time)) { + + const int h = MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2; + const int w = std::max(1, (int)std::lround((double)image->width * h / image->height)); + auto [min_sec, max_sec] = displayedTimeRange(); + int pos = (sec - min_sec) * width() / (max_sec - min_sec); + const int max_x = (int)width() - w - THUMBNAIL_MARGIN + 1; + int x = std::clamp(pos - w / 2, THUMBNAIL_MARGIN, std::max(THUMBNAIL_MARGIN, max_x)); + int y = height() - h - THUMBNAIL_MARGIN; + + ImRect thumb_rect(ImVec2(rect().Min.x + x, rect().Min.y + y), ImVec2(rect().Min.x + x + w, rect().Min.y + y + h)); + p->AddImage(big_thumbnail_texture_.ref(), thumb_rect.Min, thumb_rect.Max); + p->AddRect(thumb_rect.Min, thumb_rect.Max, paletteBrightText(), 0.0f, 0, 2.0f); + if (auto alert = getReplay()->findAlertAtTime(can->toSeconds(mono_time))) { + drawAlert(p, thumb_rect, *alert, POINT_10_FONT_SIZE); + } + drawTime(p, thumb_rect, sec); + } +} + +void StreamCameraView::drawTime(ImDrawList *p, const ImRect &rect, double seconds) { + char text[32]; + snprintf(text, sizeof(text), "%.3f", seconds); + ImFont *font = ImGui::GetFont(); + const ImVec2 text_size = font->CalcTextSizeA(POINT_10_FONT_SIZE, FLT_MAX, 0.0f, text); + + p->AddText(font, POINT_10_FONT_SIZE, ImVec2(rect.GetCenter().x - text_size.x / 2, rect.Max.y - THUMBNAIL_MARGIN - text_size.y), + paletteBrightText(), text); +} + +void StreamCameraView::drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size) { + const ImU32 pen = paletteBrightText(); + ImU32 color = withAlpha(timeline_colors[int(alert.type)], 128); + std::string text = alert.text1; + if (!alert.text2.empty()) text += "\n" + alert.text2; + + ImRect text_rect(ImVec2(rect.Min.x + 1, rect.Min.y + 1), ImVec2(rect.Max.x - 1, rect.Max.y - 1)); + ImFont *font = ImGui::GetFont(); + const float wrap_width = std::max(1.0f, text_rect.GetWidth()); + const ImVec2 r = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, text.c_str()); + p->AddRectFilled(ImVec2(text_rect.Min.x, text_rect.Min.y), ImVec2(text_rect.Max.x, text_rect.Min.y + r.y), color); + + float y = text_rect.Min.y; + for (const auto &line : utils::split(text, '\n')) { + const ImVec2 line_size = font->CalcTextSizeA(font_size, FLT_MAX, wrap_width, line.c_str()); + p->AddText(font, font_size, ImVec2(text_rect.Min.x + (text_rect.GetWidth() - line_size.x) / 2, y), pen, line.c_str(), nullptr, wrap_width); + y += line_size.y; + } +} diff --git a/iqpilot/tools/cabana/ui/widgets/videowidget.h b/iqpilot/tools/cabana/ui/widgets/videowidget.h new file mode 100644 index 000000000..3b1c918ee --- /dev/null +++ b/iqpilot/tools/cabana/ui/widgets/videowidget.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" +#include "imgui_internal.h" + +#include "tools/cabana/ui/widgets/cameraview.h" +#include "tools/cabana/ui/widgets/tabbar.h" +#include "tools/cabana/ui/tools/routeinfo.h" +#include "tools/replay/logreader.h" +#include "tools/cabana/streams/replaystream.h" +#include "tools/cabana/ui/icons.h" + +class Slider { +public: + Slider() = default; + double currentSecond() const { return value() / factor; } + void setCurrentSecond(double sec) { setValue(sec * factor); } + void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } + int value() const { return value_; } + void setValue(int v) { value_ = std::clamp(v, minimum_, maximum_); } + void setRange(int min, int max) { minimum_ = min; maximum_ = std::max(min, max); setValue(value_); } + int minimum() const { return minimum_; } + int maximum() const { return maximum_; } + bool isSliderDown() const { return slider_down_; } + float width() const { return rect_.GetWidth(); } + const ImRect &rect() const { return rect_; } + bool underMouse() const { return hovered_; } + bool mouseLeft() const { return left_; } + void draw(double thumbnail_time); + static constexpr double factor = 1000.0; + + Observable<> sliderReleased; + +private: + void handleMousePress(); + void paint(double thumbnail_time); + ImRect handleRect() const; + int pixelPosToRangeValue(float x) const; + int minimum_ = 0; + int maximum_ = 99; + int value_ = 0; + bool slider_down_ = false; + float click_offset_ = 0; + bool hovered_ = false; + bool left_ = false; + ImRect rect_; +}; + +class StreamCameraView : public CameraWidget { +public: + StreamCameraView(std::string stream_name, VisionStreamType stream_type); + ~StreamCameraView(); + void draw(const ImVec2 &size, double thumbnail_time); + void parseQLog(std::shared_ptr qlog); + +private: + struct PendingThumbnails { + std::future done; + std::shared_ptr> thumbnails; + }; + void collectThumbnails(); + + const RgbImage *thumbnailAt(double sec, uint64_t *mono_time); + void drawAlert(ImDrawList *p, const ImRect &rect, const Timeline::Entry &alert, float font_size); + void drawThumbnail(ImDrawList *p, double sec); + void drawScrubThumbnail(ImDrawList *p, double sec); + void drawTime(ImDrawList *p, const ImRect &rect, double seconds); + + std::map big_thumbnails_; + GlTexture big_thumbnail_texture_; + std::vector pending_thumbnails_; +}; + +class VideoWidget { +public: + VideoWidget(); + void draw(); + float sizeHintHeight() const; + float defaultHeight(float width) const; + + + void setVisible(bool visible); + void showThumbnail(double seconds); + std::string whatsThis() const; + +private: + void updateSliderThumbnail(); + std::string formatTime(double sec, bool include_milliseconds = false); + void timeRangeChanged(); + void createCameraWidget(); + void drawCameraWidget(); + void drawPlaybackController(); + void skipToEnd(); + void toggleTimeDisplay(); + void createSpeedDropdown(); + void drawSpeedDropdown(float width); + void drawSpeedMenuItems(); + void loopPlaybackClicked(); + void vipcAvailableStreamsUpdated(std::set streams); + void showRouteInfo(); + + std::unique_ptr cam_widget_; + std::string speed_text_; + int speed_index_ = -1; + bool skip_to_end_enabled_ = true; + bool msgs_received_ = false; + double thumbnail_display_time_ = -1; + std::unique_ptr slider_; + std::unique_ptr camera_tab_; + std::vector> route_info_dlgs_; + Connections connections_; +}; diff --git a/iqpilot/tools/cabana/utils/elidedlabel.cc b/iqpilot/tools/cabana/utils/elidedlabel.cc deleted file mode 100644 index 629a108b1..000000000 --- a/iqpilot/tools/cabana/utils/elidedlabel.cc +++ /dev/null @@ -1,29 +0,0 @@ -#include "tools/cabana/utils/elidedlabel.h" -#include -#include - -ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {} - -ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) { - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred); - setMinimumWidth(1); -} - -void ElidedLabel::resizeEvent(QResizeEvent* event) { - QLabel::resizeEvent(event); - lastText_ = elidedText_ = ""; -} - -void ElidedLabel::paintEvent(QPaintEvent *event) { - const QString curText = text(); - if (curText != lastText_) { - elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width()); - lastText_ = curText; - } - - QPainter painter(this); - drawFrame(&painter); - QStyleOption opt; - opt.initFrom(this); - style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole()); -} diff --git a/iqpilot/tools/cabana/utils/elidedlabel.h b/iqpilot/tools/cabana/utils/elidedlabel.h deleted file mode 100644 index 577eea12a..000000000 --- a/iqpilot/tools/cabana/utils/elidedlabel.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include -#include - -class ElidedLabel : public QLabel { - Q_OBJECT - -public: - explicit ElidedLabel(QWidget *parent = 0); - explicit ElidedLabel(const QString &text, QWidget *parent = 0); - -signals: - void clicked(); - -protected: - void paintEvent(QPaintEvent *event) override; - void resizeEvent(QResizeEvent* event) override; - void mouseReleaseEvent(QMouseEvent *event) override { - if (rect().contains(event->pos())) { - emit clicked(); - } - } - QString lastText_, elidedText_; -}; diff --git a/iqpilot/tools/cabana/utils/export.cc b/iqpilot/tools/cabana/utils/export.cc index d585827ef..aefbe5bad 100644 --- a/iqpilot/tools/cabana/utils/export.cc +++ b/iqpilot/tools/cabana/utils/export.cc @@ -42,4 +42,4 @@ void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id) { } } -} // namespace utils +} diff --git a/iqpilot/tools/cabana/utils/export.h b/iqpilot/tools/cabana/utils/export.h index ee110e832..4e3cf5604 100644 --- a/iqpilot/tools/cabana/utils/export.h +++ b/iqpilot/tools/cabana/utils/export.h @@ -8,4 +8,4 @@ namespace utils { void exportToCSV(const std::string &file_name, std::optional msg_id = std::nullopt); void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id); -} // namespace utils +} diff --git a/iqpilot/tools/cabana/utils/strings.cc b/iqpilot/tools/cabana/utils/strings.cc new file mode 100644 index 000000000..f3aef3b23 --- /dev/null +++ b/iqpilot/tools/cabana/utils/strings.cc @@ -0,0 +1,57 @@ +#include "tools/cabana/utils/strings.h" + +#include +#include +#include +#include + +#include "tools/cabana/dbc/dbc.h" + +namespace utils { + +std::string formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { + char out[80] = {}; + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(ms_total % 1000); + if (millis < 0) millis = -millis; + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64] = {}; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + if (!include_milliseconds) return buf; + snprintf(out, sizeof(out), "%s.%03d", buf, millis); + return out; + } + + + const bool show_hours = sec > 60 * 60; + int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); + const int hours = total_ms / (3600 * 1000); + const int minutes = (total_ms / (60 * 1000)) % 60; + const int seconds = (total_ms / 1000) % 60; + const int millis = total_ms % 1000; + if (show_hours && include_milliseconds) { + snprintf(out, sizeof(out), "%02d:%02d:%02d.%03d", hours, minutes, seconds, millis); + } else if (show_hours) { + snprintf(out, sizeof(out), "%02d:%02d:%02d", hours, minutes, seconds); + } else if (include_milliseconds) { + snprintf(out, sizeof(out), "%02d:%02d.%03d", minutes, seconds, millis); + } else { + snprintf(out, sizeof(out), "%02d:%02d", minutes, seconds); + } + return out; +} + +std::string signalToolTip(const cabana::Signal *sig) { + std::ostringstream s; + s << "\n " << sig->name << "
\n" + << " Start Bit: " << sig->start_bit << " Size: " << sig->size << "
\n" + << " MSB: " << sig->msb << " LSB: " << sig->lsb << "
\n" + << " Little Endian: " << (sig->is_little_endian ? "Y" : "N") + << " Signed: " << (sig->is_signed ? "Y" : "N") << "
\n "; + return s.str(); +} + +} diff --git a/iqpilot/tools/cabana/utils/strings.h b/iqpilot/tools/cabana/utils/strings.h new file mode 100644 index 000000000..5222fdcb8 --- /dev/null +++ b/iqpilot/tools/cabana/utils/strings.h @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cabana { class Signal; } + +namespace utils { + +std::string formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); +std::string signalToolTip(const cabana::Signal *sig); + +inline std::string trimmed(const std::string &s) { + const char *ws = " \t\n\r\f\v"; + size_t b = s.find_first_not_of(ws); + if (b == std::string::npos) return ""; + return s.substr(b, s.find_last_not_of(ws) - b + 1); +} + +inline bool containsCI(const std::string &s, const std::string &txt) { + auto it = std::search(s.begin(), s.end(), txt.begin(), txt.end(), + [](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); }); + return it != s.end(); +} + + +inline std::string stripHtml(const std::string &s) { + std::string out; + bool in_tag = false; + for (char c : s) { + if (c == '<') in_tag = true; + else if (c == '>') in_tag = false; + else if (!in_tag) out += c; + } + return trimmed(out); +} + +inline std::vector split(const std::string &s, char sep) { + std::vector parts; + size_t start = 0; + for (size_t pos; (pos = s.find(sep, start)) != std::string::npos; start = pos + 1) { + parts.push_back(s.substr(start, pos - start)); + } + parts.push_back(s.substr(start)); + return parts; +} + +inline std::string toString(double v) { + char buf[32]; + snprintf(buf, sizeof(buf), "%g", v); + return buf; +} + + +inline double toDouble(const std::string &s) { + char *end = nullptr; + double v = std::strtod(s.c_str(), &end); + return (end != s.c_str() && *end == '\0') ? v : 0.0; +} + + +inline int toInt(const std::string &s) { + char *end = nullptr; + long v = std::strtol(s.c_str(), &end, 10); + return (end != s.c_str() && *end == '\0') ? (int)v : 0; +} + + +inline unsigned long toULong(const std::string &s, int base = 10) { + char *end = nullptr; + unsigned long v = std::strtoul(s.c_str(), &end, base); + return (end != s.c_str() && *end == '\0') ? v : 0; +} + + +inline const char *hexByte(uint8_t value) { + static const auto table = [] { + std::array t; + for (int i = 0; i < 256; ++i) snprintf(t[i], sizeof(t[i]), "%02X", i); + return t; + }(); + return table[value]; +} + +inline std::string toHex(const std::vector &dat, char separator = '\0') { + static const char digits[] = "0123456789ABCDEF"; + std::string hex; + hex.reserve(dat.size() * (separator ? 3 : 2)); + for (size_t i = 0; i < dat.size(); ++i) { + if (separator && i) hex += separator; + hex += digits[dat[i] >> 4]; + hex += digits[dat[i] & 0xf]; + } + return hex; +} + +inline std::string toHexString(int value) { + char buf[16] = {}; + snprintf(buf, sizeof(buf), "0x%02X", value); + return buf; +} + +} diff --git a/iqpilot/tools/cabana/utils/util.cc b/iqpilot/tools/cabana/utils/util.cc index d989a850c..d1ed32d79 100644 --- a/iqpilot/tools/cabana/utils/util.cc +++ b/iqpilot/tools/cabana/utils/util.cc @@ -1,46 +1,69 @@ #include "tools/cabana/utils/util.h" #include +#include #include -#include #include #include #include #include -#include +#include #include #include -#include +#include #include +#include +#include #include #include #include +#ifdef __APPLE__ +#include +#endif -#include -#include -#include -#include -#include #include "common/util.h" -// SegmentTree +static const std::thread::id main_thread_id = std::this_thread::get_id(); +static std::mutex main_thread_queue_mutex; +static std::vector> main_thread_queue; -void SegmentTree::build(const std::vector &arr) { - size = arr.size(); - tree.resize(4 * size); // size of the tree is 4 times the size of the array - if (size > 0) { - build_tree(arr, 1, 0, size - 1); +bool utils::isMainThread() { return std::this_thread::get_id() == main_thread_id; } + +void utils::runOnMainThread(std::function fn) { + if (isMainThread()) { + fn(); + } else { + std::lock_guard lk(main_thread_queue_mutex); + main_thread_queue.push_back(std::move(fn)); } } -void SegmentTree::build_tree(const std::vector &arr, int n, int left, int right) { +void utils::drainMainThreadQueue() { + std::vector> fns; + { + std::lock_guard lk(main_thread_queue_mutex); + fns.swap(main_thread_queue); + } + for (auto &fn : fns) fn(); +} + + + +void SegmentTree::build(int n, const std::function &y) { + size = n; + tree.resize(4 * size); + if (size > 0) { + build_tree(y, 1, 0, size - 1); + } +} + +void SegmentTree::build_tree(const std::function &y, int n, int left, int right) { if (left == right) { - const double y = arr[left].y(); - tree[n] = {y, y}; + tree[n] = {y(left), y(left)}; } else { const int mid = (left + right) >> 1; - build_tree(arr, 2 * n, left, mid); - build_tree(arr, 2 * n + 1, mid + 1, right); + build_tree(y, 2 * n, left, mid); + build_tree(y, 2 * n + 1, mid + 1, right); tree[n] = {std::min(tree[2 * n].first, tree[2 * n + 1].first), std::max(tree[2 * n].second, tree[2 * n + 1].second)}; } } @@ -56,119 +79,22 @@ std::pair SegmentTree::get_minmax(int n, int left, int right, in return {std::min(l.first, r.first), std::max(l.second, r.second)}; } -// MessageBytesDelegate -MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) - : font_metrics(QApplication::font()), multiple_lines(multiple_lines), QStyledItemDelegate(parent) { - fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont); - byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2); - for (int i = 0; i < 256; ++i) { - hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper()); - hex_text_table[i].prepare({}, fixed_font); - } - h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1; - v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1; -} -QSize MessageBytesDelegate::sizeForBytes(int n) const { - int rows = multiple_lines ? std::max(1, n / 8) : 1; - return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2}; -} - -QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - auto data = index.data(BytesRole); - return sizeForBytes(data.isValid() ? static_cast *>(data.value())->size() : 0); -} - -void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (option.state & QStyle::State_Selected) { - painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight)); - } - - QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin); - QColor highlighted_color = option.palette.color(QPalette::HighlightedText); - auto text_color = index.data(Qt::ForegroundRole).value(); - bool inactive = text_color.isValid(); - if (!inactive) { - text_color = option.palette.color(QPalette::Text); - } - auto data = index.data(BytesRole); - if (!data.isValid()) { - painter->setFont(option.font); - painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width()); - painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text); - return; - } - - // Paint hex column - const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); - - painter->setFont(fixed_font); - const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); - const QPoint pt = item_rect.topLeft(); - for (int i = 0; i < bytes.size(); ++i) { - int row = !multiple_lines ? 0 : i / 8; - int column = !multiple_lines ? i : i % 8; - QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size); - - if (!inactive && i < colors.size() && colors[i].alpha() > 0) { - if (option.state & QStyle::State_Selected) { - painter->setPen(option.palette.color(QPalette::Text)); - painter->fillRect(r, option.palette.color(QPalette::Window)); - } - painter->fillRect(r, toQColor(colors[i])); - } else { - painter->setPen(text_pen); - } - utils::drawStaticText(painter, r, hex_text_table[bytes[i]]); - } -} - -// TabBar - -int TabBar::addTab(const QString &text) { - int index = QTabBar::addTab(text); - QToolButton *btn = new ToolButton("x", tr("Close Tab")); - int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn); - int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn); - btn->setFixedSize({width, height}); - setTabButton(index, QTabBar::RightSide, btn); - QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked); - return index; -} - -void TabBar::closeTabClicked() { - QObject *object = sender(); - for (int i = 0; i < count(); ++i) { - if (tabButton(i, QTabBar::RightSide) == object) { - emit tabCloseRequested(i); - break; - } - } -} - -// UnixSignalHandler - -UnixSignalHandler::UnixSignalHandler() { +UnixSignalHandler::UnixSignalHandler(std::function on_signal) { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { - qFatal("Couldn't create TERM socketpair"); + fprintf(stderr, "Couldn't create TERM socketpair\n"); + abort(); } - waiter = std::thread([this]() { + waiter = std::thread([this, on_signal = std::move(on_signal)]() { int tmp = 0; while (::read(sig_fd[1], &tmp, sizeof(tmp)) < 0) { if (errno != EINTR) return; } if (shutting_down.load()) return; - // Marshal exit onto the GUI thread (qApp methods are not thread-safe). - QMetaObject::invokeMethod(qApp, []() { - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); - }, Qt::QueuedConnection); + on_signal(); }); std::signal(SIGINT, signalHandler); @@ -188,118 +114,131 @@ void UnixSignalHandler::signalHandler(int s) { (void)!::write(sig_fd[0], &s, sizeof(s)); } -// NameValidator -NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} -QValidator::State NameValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - input.replace(' ', '_'); - if (input.isEmpty()) return QValidator::Intermediate; - for (const QChar &c : input) { - if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; +ValidState validateName(std::string &input) { + std::replace(input.begin(), input.end(), ' ', '_'); + if (input.empty()) return ValidState::Intermediate; + for (const unsigned char c : input) { + if (!std::isalnum(c) && c != '_') return ValidState::Invalid; } - return QValidator::Acceptable; + return ValidState::Acceptable; } -// NodeValidator +ValidState validateNodes(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; -NodeValidator::NodeValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NodeValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - // Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing). bool need_word = true; - for (const QChar &c : input) { - if (c.isLetterOrNumber() || c == '_') { + for (const unsigned char c : input) { + if (std::isalnum(c) || c == '_') { need_word = false; } else if (c == ',' && !need_word) { need_word = true; } else { - return QValidator::Invalid; + return ValidState::Invalid; } } - return need_word ? QValidator::Intermediate : QValidator::Acceptable; + return need_word ? ValidState::Intermediate : ValidState::Acceptable; } -// NonWhitespaceValidator - -NonWhitespaceValidator::NonWhitespaceValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - for (const QChar &c : input) { - if (c.isSpace()) return QValidator::Invalid; +ValidState validateNonWhitespace(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; + for (const unsigned char c : input) { + if (std::isspace(c)) return ValidState::Invalid; } - return QValidator::Acceptable; + return ValidState::Acceptable; } -// IpAddressValidator - -IpAddressValidator::IpAddressValidator(QObject *parent) : QValidator(parent) {} - -QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; +ValidState validateIpAddress(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; int dots = 0; int value = 0; bool has_digit = false; - for (const QChar &c : input) { - if (c.isDigit()) { - value = has_digit ? value * 10 + c.digitValue() : c.digitValue(); - if (value > 255) return QValidator::Invalid; + for (const unsigned char c : input) { + if (std::isdigit(c)) { + value = has_digit ? value * 10 + (c - '0') : (c - '0'); + if (value > 255) return ValidState::Invalid; has_digit = true; } else if (c == '.') { - if (!has_digit || dots >= 3) return QValidator::Invalid; + if (!has_digit || dots >= 3) return ValidState::Invalid; ++dots; has_digit = false; value = 0; } else { - return QValidator::Invalid; + return ValidState::Invalid; } } - return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate; + return (dots == 3 && has_digit) ? ValidState::Acceptable : ValidState::Intermediate; } -DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {} +ValidState validateDouble(const std::string &input) { + if (input.empty()) return ValidState::Intermediate; -QValidator::State DoubleValidator::validate(QString &input, int &pos) const { - Q_UNUSED(pos); - if (input.isEmpty()) return QValidator::Intermediate; - // Match QString::toDouble(): C locale, no hex floats / inf / nan. - const std::string bytes = input.toLatin1().toStdString(); - // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. - if (bytes.find_first_of("xXpP") != std::string::npos) { - return QValidator::Invalid; + if (input.find_first_of("xXpP") != std::string::npos) { + return ValidState::Invalid; } - const char *start = bytes.c_str(); + const char *start = input.c_str(); char *end = nullptr; const double value = std::strtod(start, &end); if (end == start) { - // Still typing a sign, decimal point, or exponent prefix. + if (input == "-" || input == "+" || input == "." || input == "-." || input == "+.") { - return QValidator::Intermediate; + return ValidState::Intermediate; } - return QValidator::Invalid; + return ValidState::Invalid; } if (*end == '\0') { - // Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not). - return std::isfinite(value) ? QValidator::Acceptable : QValidator::Invalid; + return std::isfinite(value) ? ValidState::Acceptable : ValidState::Invalid; } - // Partial exponent / trailing sign while typing (e.g. "1e", "1e-", "1."). + for (const char *p = end; *p; ++p) { const char c = *p; if (!(c == 'e' || c == 'E' || c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'))) { - return QValidator::Invalid; + return ValidState::Invalid; } } - return QValidator::Intermediate; + return ValidState::Intermediate; +} + + +extern const unsigned char bootstrap_icons_svg[]; +extern const size_t bootstrap_icons_svg_len; + +static std::unordered_map load_bootstrap_icons() { + std::unordered_map icons; + + const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); + const std::string sym_open = ""); + icons[id] = std::move(svg_str); + } + } + pos = end; + } + return icons; } namespace utils { @@ -340,9 +279,9 @@ bool getClipboardText(std::string *text) { *text = std::move(out); return true; } - has_tool |= WIFEXITED(status) && WEXITSTATUS(status) != 127; // 127: command not found + has_tool |= WIFEXITED(status) && WEXITSTATUS(status) != 127; } - return has_tool; // tool present but clipboard empty + return has_tool; } bool setClipboardText(const std::string &text) { @@ -356,190 +295,30 @@ bool setClipboardText(const std::string &text) { return false; } -bool isDarkTheme() { - QColor windowColor = QApplication::palette().color(QPalette::Window); - return windowColor.lightness() < 128; +std::string bootstrapSvg(const std::string &id) { + static auto icons = load_bootstrap_icons(); + auto it = icons.find(id); + return it != icons.end() ? it->second : std::string(); } -QPixmap icon(const QString &id) { - bool dark_theme = isDarkTheme(); - - QPixmap pm; - QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); - if (!QPixmapCache::find(key, &pm)) { - pm = bootstrapPixmap(id); - // IQ.Pilot patch: ToolButton("") (chartswidget.cc) asks for an empty id, so pm can - // be null. Painting a null QPixmap is a no-op that logs two QPainter warnings on - // every dark-theme start. Upstream candidate. - if (dark_theme && !pm.isNull()) { - QPainter p(&pm); - p.setCompositionMode(QPainter::CompositionMode_SourceIn); - p.fillRect(pm.rect(), QColor("#bbbbbb")); - } - QPixmapCache::insert(key, pm); - } - return pm; } -void setTheme(int theme) { - auto style = QApplication::style(); - if (!style) return; - - static int prev_theme = 0; - if (theme != prev_theme) { - prev_theme = theme; - QPalette new_palette; - if (theme == DARK_THEME) { - // "Darcula" like dark theme - new_palette.setColor(QPalette::Window, QColor("#353535")); - new_palette.setColor(QPalette::WindowText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Base, QColor("#3c3f41")); - new_palette.setColor(QPalette::AlternateBase, QColor("#3c3f41")); - new_palette.setColor(QPalette::ToolTipBase, QColor("#3c3f41")); - new_palette.setColor(QPalette::ToolTipText, QColor("#bbb")); - new_palette.setColor(QPalette::Text, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Button, QColor("#3c3f41")); - new_palette.setColor(QPalette::ButtonText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::Highlight, QColor("#2f65ca")); - new_palette.setColor(QPalette::HighlightedText, QColor("#bbbbbb")); - new_palette.setColor(QPalette::BrightText, QColor("#f0f0f0")); - new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#777777")); - new_palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#777777")); - new_palette.setColor(QPalette::Disabled, QPalette::Text, QColor("#777777")); - new_palette.setColor(QPalette::Light, QColor("#777777")); - new_palette.setColor(QPalette::Dark, QColor("#353535")); - } else { - new_palette = style->standardPalette(); - } - qApp->setPalette(new_palette); - style->polish(qApp); - for (auto w : QApplication::allWidgets()) { - w->setPalette(new_palette); - } - } -} - -QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - if (absolute_time) { - const auto ms_total = static_cast(std::llround(sec * 1000.0)); - const std::time_t secs = static_cast(ms_total / 1000); - int millis = static_cast(ms_total % 1000); - if (millis < 0) millis = -millis; - std::tm tm{}; - localtime_r(&secs, &tm); - char buf[64]; - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); - if (include_milliseconds) { - return QString::asprintf("%s.%03d", buf, millis); - } - return QString::fromUtf8(buf); - } - - // Relative duration (not wall-clock). - const bool show_hours = sec > 60 * 60; - int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); - const int hours = total_ms / (3600 * 1000); - const int minutes = (total_ms / (60 * 1000)) % 60; - const int seconds = (total_ms / 1000) % 60; - const int millis = total_ms % 1000; - if (show_hours) { - return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) - : QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds); - } - return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis) - : QString::asprintf("%02d:%02d", minutes, seconds); -} - -} // namespace utils - int num_decimals(double num) { - const QString string = QString::number(num); - auto dot_pos = string.indexOf('.'); - return dot_pos == -1 ? 0 : string.size() - dot_pos - 1; + char buf[32]; + snprintf(buf, sizeof(buf), "%g", num); + const char *dot = strpbrk(buf, ".,"); + return dot ? (int)strlen(dot + 1) : 0; } -QString signalToolTip(const cabana::Signal *sig) { - return QObject::tr(R"( - %1
- Start Bit: %2 Size: %3
- MSB: %4 LSB: %5
- Little Endian: %6 Signed: %7
- )").arg(QString::fromStdString(sig->name)).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) - .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); -} - -void sigTermHandler(int s) { - std::signal(s, SIG_DFL); - qApp->quit(); -} - -void initApp(int argc, char *argv[], bool disable_hidpi) { - // setup signal handlers to exit gracefully - std::signal(SIGINT, sigTermHandler); - std::signal(SIGTERM, sigTermHandler); - - std::filesystem::path app_dir; +std::filesystem::path executableDir() { #ifdef __APPLE__ - // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering - QApplication tmp(argc, argv); - app_dir = QCoreApplication::applicationDirPath().toStdString(); - if (disable_hidpi) { - qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); - } -#else - app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); -#endif - - qputenv("QT_DBL_CLICK_DIST", "150"); - // ensure the current dir matches the exectuable's directory + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) != 0) return {}; std::error_code ec; - std::filesystem::current_path(app_dir, ec); -} - -// embedded at build time from the bootstrap_icons package (see SConscript) -extern const unsigned char bootstrap_icons_svg[]; -extern const size_t bootstrap_icons_svg_len; - -static std::unordered_map load_bootstrap_icons() { - std::unordered_map icons; - - const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); - const std::string sym_open = " with - svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) - icons[id] = std::move(svg_str); - } - } - pos = end; - } - return icons; -} - -QPixmap bootstrapPixmap(const QString &id) { - static auto icons = load_bootstrap_icons(); - - QPixmap pixmap; - auto it = icons.find(id.toStdString()); - if (it != icons.end()) { - pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg"); - } - return pixmap; + auto path = std::filesystem::canonical(buf, ec); + return (ec ? std::filesystem::path(buf) : path).parent_path(); +#else + return std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); +#endif } diff --git a/iqpilot/tools/cabana/utils/util.h b/iqpilot/tools/cabana/utils/util.h index 5a3c62d11..cf0c30402 100644 --- a/iqpilot/tools/cabana/utils/util.h +++ b/iqpilot/tools/cabana/utils/util.h @@ -1,209 +1,111 @@ #pragma once -#include +#include #include #include #include +#include +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/dbc/dbc.h" -#include "tools/cabana/settings.h" - -inline QColor toQColor(const CabanaColor &color) { - return QColor(color.r, color.g, color.b, color.a); -} - -class LogSlider : public QSlider { - Q_OBJECT +#include "tools/cabana/core/color.h" +class SegmentTree { public: - LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {} + SegmentTree() = default; + void build(int n, const std::function &y); + inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } +private: + std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; + void build_tree(const std::function &y, int n, int left, int right); + std::vector> tree; + int size = 0; +}; + + +class LogScale { +public: + LogScale(double factor) : factor(factor) {} void setRange(double min, double max) { log_min = factor * std::log10(min); log_max = factor * std::log10(max); - QSlider::setRange(min, max); - setValue(QSlider::value()); } - int value() const { - double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum())); + int value(int pos, int pos_min, int pos_max) const { + double v = log_min + (log_max - log_min) * ((pos - pos_min) / double(pos_max - pos_min)); return std::lround(std::pow(10, v / factor)); } - void setValue(int v) { + int position(int v, int pos_min, int pos_max) const { double log_v = std::clamp(factor * std::log10(v), log_min, log_max); - v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min)); - QSlider::setValue(v); + return pos_min + (pos_max - pos_min) * ((log_v - log_min) / (log_max - log_min)); } private: double factor, log_min = 0, log_max = 1; }; -enum { - ColorsRole = Qt::UserRole + 1, - BytesRole = Qt::UserRole + 2 -}; +enum class ValidState { Invalid, Intermediate, Acceptable }; -class SegmentTree { -public: - SegmentTree() = default; - void build(const std::vector &arr); - inline std::pair minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); } -private: - std::pair get_minmax(int n, int left, int right, int range_left, int range_right) const; - void build_tree(const std::vector &arr, int n, int left, int right); - std::vector> tree; - int size = 0; -}; +ValidState validateName(std::string &input); -class MessageBytesDelegate : public QStyledItemDelegate { - Q_OBJECT -public: - MessageBytesDelegate(QObject *parent, bool multiple_lines = false); - void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; - QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; - bool multipleLines() const { return multiple_lines; } - void setMultipleLines(bool v) { multiple_lines = v; } - QSize sizeForBytes(int n) const; +ValidState validateNodes(const std::string &input); -private: - std::array hex_text_table; - QFontMetrics font_metrics; - QFont fixed_font; - QSize byte_size = {}; - bool multiple_lines = false; - int h_margin, v_margin; -}; +ValidState validateNonWhitespace(const std::string &input); -// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'. -class NameValidator : public QValidator { - Q_OBJECT -public: - NameValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; +ValidState validateIpAddress(const std::string &input); -// Accepts comma-separated identifiers: \w+(,\w+)* -class NodeValidator : public QValidator { - Q_OBJECT -public: - NodeValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; +ValidState validateDouble(const std::string &input); -// Accepts one or more non-whitespace characters (\S+). -class NonWhitespaceValidator : public QValidator { - Q_OBJECT -public: - NonWhitespaceValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; -// Accepts a dotted IPv4 address (0-255 per octet). -class IpAddressValidator : public QValidator { - Q_OBJECT -public: - IpAddressValidator(QObject *parent=nullptr); - QValidator::State validate(QString &input, int &pos) const override; -}; - -// C-locale floating-point validator (matches QString::toDouble). -class DoubleValidator : public QValidator { - Q_OBJECT -public: - DoubleValidator(QObject *parent = nullptr); - QValidator::State validate(QString &input, int &pos) const override; +struct DarkTheme { + static constexpr CabanaColor window{0x35, 0x35, 0x35}; + static constexpr CabanaColor window_text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor base{0x3c, 0x3f, 0x41}; + static constexpr CabanaColor tooltip_text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor text{0xbb, 0xbb, 0xbb}; + static constexpr CabanaColor button{0x3c, 0x3f, 0x41}; + static constexpr CabanaColor highlight{0x2f, 0x65, 0xca}; + static constexpr CabanaColor bright_text{0xf0, 0xf0, 0xf0}; + static constexpr CabanaColor disabled_text{0x77, 0x77, 0x77}; + static constexpr CabanaColor light{0x77, 0x77, 0x77}; + static constexpr CabanaColor dark{0x35, 0x35, 0x35}; }; namespace utils { -QPixmap icon(const QString &id); +bool isMainThread(); + +void runOnMainThread(std::function fn); +void drainMainThreadQueue(); std::string homePath(); std::filesystem::path configPath(); -bool getClipboardText(std::string *text); // false if no clipboard tool is available +bool getClipboardText(std::string *text); bool setClipboardText(const std::string &text); -bool isDarkTheme(); -void setTheme(int theme); -QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); -inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) { - auto size = (r.size() - text.size()) / 2; - p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); -} -inline QString toHex(const std::vector &dat, char separator = '\0') { - static const char digits[] = "0123456789ABCDEF"; - QString hex; - hex.reserve(dat.size() * (separator ? 3 : 2)); - for (size_t i = 0; i < dat.size(); ++i) { - if (separator && i) hex += QLatin1Char(separator); - hex += QLatin1Char(digits[dat[i] >> 4]); - hex += QLatin1Char(digits[dat[i] & 0xf]); - } - return hex; -} +std::string bootstrapSvg(const std::string &id); + -// boundary conversions for the remaining Qt byte-array based state APIs template std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } -inline auto qbytes(const std::vector &dat) { - return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); + + +template +auto guarded(const std::shared_ptr &alive, F fn) { + return [alive = std::weak_ptr(alive), fn = std::move(fn)](auto &&...args) { + if (!alive.expired()) fn(std::forward(args)...); + }; } } -class ToolButton : public QToolButton { - Q_OBJECT -public: - ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) { - setIcon(icon); - setToolTip(tooltip); - setAutoRaise(true); - const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize); - setIconSize({metric, metric}); - theme = settings.theme; - connect(&settings, &Settings::changed, this, &ToolButton::updateIcon); - } - void setIcon(const QString &icon) { - icon_str = icon; - QToolButton::setIcon(utils::icon(icon_str)); - } -private: - void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); } - QString icon_str; - int theme; -}; -class TabBar : public QTabBar { - Q_OBJECT - -public: - TabBar(QWidget *parent) : QTabBar(parent) {} - int addTab(const QString &text); - -private: - void closeTabClicked(); -}; - -// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread -// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. class UnixSignalHandler { public: - UnixSignalHandler(); + UnixSignalHandler(std::function on_signal); ~UnixSignalHandler(); static void signalHandler(int s); @@ -214,7 +116,4 @@ private: }; int num_decimals(double num); -QString signalToolTip(const cabana::Signal *sig); -inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); } -void initApp(int argc, char *argv[], bool disable_hidpi = true); -QPixmap bootstrapPixmap(const QString &id); +std::filesystem::path executableDir(); diff --git a/iqpilot/tools/cabana/videowidget.cc b/iqpilot/tools/cabana/videowidget.cc deleted file mode 100644 index 67b89cb08..000000000 --- a/iqpilot/tools/cabana/videowidget.cc +++ /dev/null @@ -1,433 +0,0 @@ -#include "tools/cabana/videowidget.h" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "tools/cabana/tools/routeinfo.h" - -const int MIN_VIDEO_HEIGHT = 100; -const int THUMBNAIL_MARGIN = 3; - -// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark -static const QColor timeline_colors[] = { - QColor(111, 143, 175), - QColor(0, 163, 108), - Qt::green, - QColor(255, 195, 0), - QColor(199, 0, 57), - Qt::magenta, -}; - -static Replay *getReplay() { - auto stream = qobject_cast(can); - return stream ? stream->getReplay() : nullptr; -} - -VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { - setFrameStyle(QFrame::StyledPanel | QFrame::Plain); - auto main_layout = new QVBoxLayout(this); - main_layout->setContentsMargins(0, 0, 0, 0); - main_layout->setSpacing(0); - if (!can->liveStreaming()) - main_layout->addWidget(createCameraWidget()); - - createPlaybackController(); - - setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum); - QObject::connect(can, &AbstractStream::paused, this, &VideoWidget::updatePlayBtnState); - QObject::connect(can, &AbstractStream::resume, this, &VideoWidget::updatePlayBtnState); - QObject::connect(can, &AbstractStream::msgsReceived, this, &VideoWidget::updateState); - QObject::connect(can, &AbstractStream::seeking, this, &VideoWidget::updateState); - QObject::connect(can, &AbstractStream::timeRangeChanged, this, &VideoWidget::timeRangeChanged); - - updatePlayBtnState(); - setWhatsThis(tr(R"( - Video
- - Timeline color -
- - - - - - -
Disengaged Engaged
User Flag Info
Warning Critical
- Shortcuts
- Pause/Resume:  space  - )").arg(timeline_colors[(int)TimelineType::None].name(), - timeline_colors[(int)TimelineType::Engaged].name(), - timeline_colors[(int)TimelineType::UserBookmark].name(), - timeline_colors[(int)TimelineType::AlertInfo].name(), - timeline_colors[(int)TimelineType::AlertWarning].name(), - timeline_colors[(int)TimelineType::AlertCritical].name())); -} - -void VideoWidget::createPlaybackController() { - QToolBar *toolbar = new QToolBar(this); - layout()->addWidget(toolbar); - - int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize); - toolbar->setIconSize({icon_size, icon_size}); - - toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); }); - play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); }); - toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); }); - - if (can->liveStreaming()) { - skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() { - // set speed to 1.0 - speed_btn->menu()->actions()[7]->setChecked(true); - can->pause(false); - can->seekTo(can->maxSeconds() + 1); - }); - } - - time_display_action = toolbar->addAction("", this, [this]() { - settings.absolute_time = !settings.absolute_time; - time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time")); - updateState(); - }); - - QWidget *spacer = new QWidget(); - spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); - toolbar->addWidget(spacer); - - if (!can->liveStreaming()) { - toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked); - createSpeedDropdown(toolbar); - toolbar->addSeparator(); - toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo); - } else { - createSpeedDropdown(toolbar); - } -} - -void VideoWidget::createSpeedDropdown(QToolBar *toolbar) { - toolbar->addWidget(speed_btn = new QToolButton(this)); - speed_btn->setMenu(new QMenu(speed_btn)); - speed_btn->setPopupMode(QToolButton::InstantPopup); - QActionGroup *speed_group = new QActionGroup(this); - speed_group->setExclusive(true); - - for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) { - auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() { - can->setSpeed(speed); - speed_btn->setText(QString("%1x ").arg(speed)); - }); - - speed_group->addAction(act); - act->setCheckable(true); - if (speed == 1.0) { - act->setChecked(true); - act->trigger(); - } - } - - QFont font = speed_btn->font(); - font.setBold(true); - speed_btn->setFont(font); - speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator)); -} - -QWidget *VideoWidget::createCameraWidget() { - QWidget *w = new QWidget(this); - QVBoxLayout *l = new QVBoxLayout(w); - l->setContentsMargins(0, 0, 0, 0); - l->setSpacing(0); - - l->addWidget(camera_tab = new TabBar(w)); - camera_tab->setAutoHide(true); - camera_tab->setExpanding(false); - - l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_ROAD)); - cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT); - cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); - - l->addWidget(slider = new Slider(w)); - slider->setSingleStep(0); - slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - - QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update)); - QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); }); - QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); }); - QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated); - QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) { - if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt()); - }); - QObject::connect(static_cast(can), &ReplayStream::qLogLoaded, cam_widget, &StreamCameraView::parseQLog, Qt::QueuedConnection); - slider->installEventFilter(this); - return w; -} - -void VideoWidget::vipcAvailableStreamsUpdated(std::set streams) { - static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road camera"}; - for (int i = 0; i < streams.size(); ++i) { - if (camera_tab->count() <= i) { - camera_tab->addTab(QString()); - } - int type = *std::next(streams.begin(), i); - camera_tab->setTabText(i, stream_names[type]); - camera_tab->setTabData(i, type); - } - while (camera_tab->count() > streams.size()) { - camera_tab->removeTab(camera_tab->count() - 1); - } -} - -void VideoWidget::loopPlaybackClicked() { - bool is_looping = getReplay()->loop(); - getReplay()->setLoop(!is_looping); - qobject_cast(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1")); -} - -void VideoWidget::timeRangeChanged() { - const auto time_range = can->timeRange(); - if (can->liveStreaming()) { - skip_to_end_action->setEnabled(!time_range.has_value()); - return; - } - time_range ? slider->setTimeRange(time_range->first, time_range->second) - : slider->setTimeRange(can->minSeconds(), can->maxSeconds()); - updateState(); -} - -QString VideoWidget::formatTime(double sec, bool include_milliseconds) { - if (settings.absolute_time) - sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); - return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); -} - -void VideoWidget::updateState() { - if (slider) { - if (!slider->isSliderDown()) { - slider->setCurrentSecond(can->currentSec()); - } - if (camera_tab->count() == 0) { // No streams available - cam_widget->update(); // Manually refresh to show alert events - } - time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true), - formatTime(slider->maximum() / slider->factor))); - } else { - time_display_action->setText(formatTime(can->currentSec(), true)); - } -} - -void VideoWidget::updatePlayBtnState() { - play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause")); - play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause")); -} - -void VideoWidget::showThumbnail(double seconds) { - if (can->liveStreaming()) return; - - cam_widget->thumbnail_dispaly_time = seconds; - slider->thumbnail_dispaly_time = seconds; - cam_widget->update(); - slider->update(); -} - -void VideoWidget::showRouteInfo() { - RouteInfoDlg *route_info = new RouteInfoDlg(this); - route_info->setAttribute(Qt::WA_DeleteOnClose); - route_info->show(); -} - -bool VideoWidget::eventFilter(QObject *obj, QEvent *event) { - if (event->type() == QEvent::MouseMove) { - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - showThumbnail(min_sec + static_cast(event)->pos().x() * (max_sec - min_sec) / slider->width()); - } else if (event->type() == QEvent::Leave) { - showThumbnail(-1); - } - return false; -} - -// Slider -Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) { - setMouseTracking(true); -} - -void Slider::paintEvent(QPaintEvent *ev) { - QPainter p(this); - - QStyleOptionSlider opt; - initStyleOption(&opt); - QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); - QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this); - - // Adjust groove height to match handle height - int handle_height = handle_rect.height(); - groove_rect.setHeight(handle_height * 0.5); - groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y())); - - p.fillRect(groove_rect, timeline_colors[(int)TimelineType::None]); - - double min = minimum() / factor; - double max = maximum() / factor; - - auto fillRange = [&](double begin, double end, const QColor &color) { - if (begin > max || end < min) return; - - QRect r = groove_rect; - r.setLeft(((std::max(min, begin) - min) / (max - min)) * width()); - r.setRight(((std::min(max, end) - min) / (max - min)) * width()); - p.fillRect(r, color); - }; - - if (auto replay = getReplay()) { - for (const auto &entry : *replay->getTimeline()) { - fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]); - } - - QColor empty_color = palette().color(QPalette::Window); - empty_color.setAlpha(160); - const auto event_data = replay->getEventData(); - for (const auto &[n, _] : replay->route().segments()) { - if (!event_data->isSegmentLoaded(n)) - fillRange(n * 60.0, (n + 1) * 60.0, empty_color); - } - } - - opt.minimum = minimum(); - opt.maximum = maximum(); - opt.subControls = QStyle::SC_SliderHandle; - opt.sliderPosition = value(); - style()->drawComplexControl(QStyle::CC_Slider, &opt, &p); - - if (thumbnail_dispaly_time >= 0) { - int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1; - QRect rc(left, rect().top() + 1, 2, rect().height() - 2); - p.setBrush(palette().highlight()); - p.setPen(Qt::NoPen); - p.drawRoundedRect(rc, 1.5, 1.5); - } -} - -void Slider::mousePressEvent(QMouseEvent *e) { - QSlider::mousePressEvent(e); - if (e->button() == Qt::LeftButton && !isSliderDown()) { - setValue(minimum() + ((maximum() - minimum()) * e->x()) / width()); - emit sliderReleased(); - } -} - -// StreamCameraView -StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) - : CameraWidget(stream_name, stream_type, parent) { -} - -void StreamCameraView::parseQLog(std::shared_ptr qlog) { - std::mutex mutex; - const auto &events = qlog->events; - unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); - size_t chunk = (events.size() + num_threads - 1) / num_threads; - std::vector threads; - for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) { - size_t start = t * chunk; - size_t end = std::min(start + chunk, events.size()); - threads.emplace_back([this, &mutex, &events, start, end]() { - for (size_t i = start; i < end; ++i) { - const Event &e = events[i]; - if (e.which == cereal::Event::Which::THUMBNAIL) { - capnp::FlatArrayMessageReader reader(e.data); - auto thumb_data = reader.getRoot().getThumbnail(); - auto image_data = thumb_data.getThumbnail(); - if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { - QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); - std::lock_guard lock(mutex); - thumbnails[thumb_data.getTimestampEof()] = generated_thumb; - big_thumbnails[thumb_data.getTimestampEof()] = thumb; - } - } - } - }); - } - for (auto &th : threads) th.join(); - update(); -} - -void StreamCameraView::paintEvent(QPaintEvent *event) { - CameraWidget::paintEvent(event); - - QPainter p(this); - bool scrubbing = false; - if (thumbnail_dispaly_time >= 0) { - scrubbing = can->isPaused(); - scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p); - } - if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) { - drawAlert(p, rect(), *alert); - } - - if (can->isPaused()) { - p.setPen(QColor(200, 200, 200, static_cast(255 * 0.7f))); - p.setFont(QFont(font().family(), 16, QFont::Bold)); - p.drawText(rect(), Qt::AlignCenter, tr("PAUSED")); - } -} - -QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { - QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation); - QPainter p(&scaled); - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - p.drawRect(scaled.rect()); - if (auto alert = getReplay()->findAlertAtTime(seconds)) { - p.setFont(QFont(font().family(), 10)); - drawAlert(p, scaled.rect(), *alert); - } - return scaled; -} - -void StreamCameraView::drawScrubThumbnail(QPainter &p) { - p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != big_thumbnails.end()) { - QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); - QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size()); - p.drawPixmap(thumb_rect.topLeft(), scaled_thumb); - drawTime(p, thumb_rect, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); - if (it != thumbnails.end()) { - const QPixmap &thumb = it->second; - auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); - int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec); - int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1); - int y = height() - thumb.height() - THUMBNAIL_MARGIN; - - p.drawPixmap(x, y, thumb); - drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time); - } -} - -void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) { - p.setPen(palette().color(QPalette::BrightText)); - p.setFont(QFont(font().family(), 10)); - p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3)); -} - -void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) { - p.setPen(QPen(palette().color(QPalette::BrightText), 2)); - QColor color = timeline_colors[int(alert.type)]; - color.setAlphaF(0.5); - QString text = QString::fromStdString(alert.text1); - if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2); - - QRect text_rect = rect.adjusted(1, 1, -1, -1); - QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); - p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color); - p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text); -} diff --git a/iqpilot/tools/cabana/videowidget.h b/iqpilot/tools/cabana/videowidget.h deleted file mode 100644 index 09f7a9931..000000000 --- a/iqpilot/tools/cabana/videowidget.h +++ /dev/null @@ -1,81 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -#include "tools/cabana/cameraview.h" -#include "tools/cabana/utils/util.h" -#include "tools/replay/logreader.h" -#include "tools/cabana/streams/replaystream.h" - -class Slider : public QSlider { - Q_OBJECT - -public: - Slider(QWidget *parent); - double currentSecond() const { return value() / factor; } - void setCurrentSecond(double sec) { setValue(sec * factor); } - void setTimeRange(double min, double max) { setRange(min * factor, max * factor); } - void mousePressEvent(QMouseEvent *e) override; - void paintEvent(QPaintEvent *ev) override; - const double factor = 1000.0; - double thumbnail_dispaly_time = -1; -}; - -class StreamCameraView : public CameraWidget { - Q_OBJECT - -public: - StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintEvent(QPaintEvent *event) override; - void parseQLog(std::shared_ptr qlog); - -private: - QPixmap generateThumbnail(QPixmap thumbnail, double seconds); - void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert); - void drawThumbnail(QPainter &p); - void drawScrubThumbnail(QPainter &p); - void drawTime(QPainter &p, const QRect &rect, double seconds); - - std::map big_thumbnails; - std::map thumbnails; - double thumbnail_dispaly_time = -1; - friend class VideoWidget; -}; - -class VideoWidget : public QFrame { - Q_OBJECT - -public: - VideoWidget(QWidget *parnet = nullptr); - void showThumbnail(double seconds); - -protected: - bool eventFilter(QObject *obj, QEvent *event) override; - QString formatTime(double sec, bool include_milliseconds = false); - void timeRangeChanged(); - void updateState(); - void updatePlayBtnState(); - QWidget *createCameraWidget(); - void createPlaybackController(); - void createSpeedDropdown(QToolBar *toolbar); - void loopPlaybackClicked(); - void vipcAvailableStreamsUpdated(std::set streams); - void showRouteInfo(); - - StreamCameraView *cam_widget; - QAction *time_display_action = nullptr; - QAction *play_toggle_action = nullptr; - QToolButton *speed_btn = nullptr; - QAction *skip_to_end_action = nullptr; - Slider *slider = nullptr; - QTabBar *camera_tab = nullptr; -}; diff --git a/iqpilot/tools/install_ubuntu_dependencies.sh b/iqpilot/tools/install_ubuntu_dependencies.sh index 5c2131d4b..f8720be93 100755 --- a/iqpilot/tools/install_ubuntu_dependencies.sh +++ b/iqpilot/tools/install_ubuntu_dependencies.sh @@ -39,6 +39,7 @@ function install_ubuntu_common_requirements() { gcc-arm-none-eabi \ capnproto \ libcapnp-dev \ + libdrm-dev \ ffmpeg \ libavformat-dev \ libavcodec-dev \ @@ -52,9 +53,10 @@ function install_ubuntu_common_requirements() { libglfw3-dev \ libglib2.0-0 \ libjpeg-dev \ - libqt5charts5-dev \ libncurses5-dev \ libusb-1.0-0-dev \ + libva-dev \ + libx264-dev \ libzmq3-dev \ libzstd-dev \ libsqlite3-dev \ @@ -62,11 +64,6 @@ function install_ubuntu_common_requirements() { ocl-icd-libopencl1 \ ocl-icd-opencl-dev \ portaudio19-dev \ - qttools5-dev-tools \ - libqt5svg5-dev \ - libqt5serialbus5-dev \ - libqt5x11extras5-dev \ - libqt5opengl5-dev \ gettext } @@ -76,8 +73,6 @@ function install_ubuntu_lts_latest_requirements() { $SUDO apt-get install -y --no-install-recommends \ g++-12 \ - qtbase5-dev \ - qtbase5-dev-tools \ python3-dev \ python3-venv } diff --git a/iqpilot/tools/iq.sh b/iqpilot/tools/iq.sh index 9426f26f0..177023898 100755 --- a/iqpilot/tools/iq.sh +++ b/iqpilot/tools/iq.sh @@ -149,6 +149,30 @@ iq_quality() { (cd "$IQ_ROOT" && iq_run scripts/lint/lint.sh "$@") } +iq_desktop_tool() { + local name="$1" launcher="$2" + shift 2 + iq_require_root + if [[ ! -x "$IQ_ROOT/$launcher" ]]; then + if [[ -f /AGNOS ]]; then + iq_note "$name is not included in IQ.OS checkouts" + return 0 + fi + iq_fail "$name launcher is missing: $IQ_ROOT/$launcher" + return 1 + fi + iq_title "opening $name" + (cd "$IQ_ROOT" && iq_run "$IQ_ROOT/$launcher" "$@") +} + +iq_cabana() { + iq_desktop_tool 'Cabana' 'iqpilot/tools/cabana/cabana' "$@" +} + +iq_juggle() { + iq_desktop_tool 'Jotpluggler' 'iqpilot/tools/jotpluggler/pluggle.py' "$@" +} + iq_pkg() { iq_require_root # the venv python has the component packages (iqdbc etc.); bare python3 does not @@ -246,6 +270,8 @@ iq_help() { printf ' %bcheck%b verify checkout, Git, Python, and venv\n' "$IQ_CYAN" "$IQ_RESET" printf ' %bbuild%b build IQ.Pilot\n' "$IQ_CYAN" "$IQ_RESET" printf ' %bquality%b run code-quality checks\n' "$IQ_CYAN" "$IQ_RESET" + printf ' %bcabana%b open the CAN analysis tool\n' "$IQ_CYAN" "$IQ_RESET" + printf ' %bjuggle%b open the log plotting tool\n' "$IQ_CYAN" "$IQ_RESET" printf ' %bpkg%b authenticate and synchronize private packages\n' "$IQ_CYAN" "$IQ_RESET" printf ' %bupdate%b pull IQ.Pilot, synchronize packages, optionally fast restart\n' "$IQ_CYAN" "$IQ_RESET" printf ' %bstatus%b show checkout, branch, commit, and tree state\n' "$IQ_CYAN" "$IQ_RESET" @@ -283,6 +309,8 @@ case "$command" in check) iq_check "$@" ;; build) iq_build "$@" ;; quality) iq_quality "$@" ;; + cabana) iq_cabana "$@" ;; + juggle) iq_juggle "$@" ;; pkg) iq_pkg "$@" ;; update) iq_update "$@" ;; status) iq_status "$@" ;; diff --git a/iqpilot/tools/jotpluggler/SConscript b/iqpilot/tools/jotpluggler/SConscript index 820d5ac04..252634eb6 100644 --- a/iqpilot/tools/jotpluggler/SConscript +++ b/iqpilot/tools/jotpluggler/SConscript @@ -73,7 +73,7 @@ def write_car_fingerprint_to_dbc_header(target, source, env): def generate_event_extractors(target, source, env): subprocess.check_call([ - "python3", + sys.executable, "iqpilot/tools/jotpluggler/generate_event_extractors.py", os.path.realpath(BASEDIR), os.path.dirname(iqdbc.__file__), diff --git a/iqpilot/tools/mac_setup.sh b/iqpilot/tools/mac_setup.sh index 8bbfe3fff..5a2dc761f 100755 --- a/iqpilot/tools/mac_setup.sh +++ b/iqpilot/tools/mac_setup.sh @@ -54,27 +54,6 @@ export PYCURL_SSL_LIBRARY=openssl $DIR/install_python_dependencies.sh echo "[ ] installed python dependencies t=$SECONDS" -# brew does not link qt5 by default -# check if qt5 can be linked, if not, prompt the user to link it -QT_BIN_LOCATION="$(command -v lupdate || :)" -if [ -n "$QT_BIN_LOCATION" ]; then - # if qt6 is linked, prompt the user to unlink it and link the right version - QT_BIN_VERSION="$(lupdate -version | awk '{print $NF}')" - if [[ ! "$QT_BIN_VERSION" =~ 5\.[0-9]+\.[0-9]+ ]]; then - echo - echo "lupdate/lrelease available at PATH is $QT_BIN_VERSION" - if [[ "$QT_BIN_LOCATION" == "$(brew --prefix)/"* ]]; then - echo "Run the following command to link qt5:" - echo "brew unlink qt@6 && brew link qt@5" - else - echo "Remove conflicting qt entries from PATH and run the following command to link qt5:" - echo "brew link qt@5" - fi - fi -else - brew link qt@5 -fi - echo echo "---- OPENPILOT SETUP DONE ----" echo "Open a new shell or configure your active shell env by running:" diff --git a/iqpilot/tools/replay/framereader.h b/iqpilot/tools/replay/framereader.h index 98c7ef1cc..571484e24 100644 --- a/iqpilot/tools/replay/framereader.h +++ b/iqpilot/tools/replay/framereader.h @@ -13,6 +13,7 @@ extern "C" { #include +#include #include } diff --git a/iqpilot/tools/scons/site_tools/pretty.py b/iqpilot/tools/scons/site_tools/pretty.py index fc86b1d06..698f04222 100644 --- a/iqpilot/tools/scons/site_tools/pretty.py +++ b/iqpilot/tools/scons/site_tools/pretty.py @@ -116,9 +116,6 @@ def generate(env): env["RANLIBCOMSTR"] = _line("RANLIB") env["CYTHONCOMSTR"] = _line("CYTHON") env["COMPILATIONDB_COMSTR"] = _line("CDB") - env["QT3_MOCFROMHCOMSTR"] = _line("MOC") - env["QT3_MOCFROMCXXCOMSTR"] = _line("MOC") - env["QT3_UICCOMSTR"] = _line("UIC") def exists(env): diff --git a/iqpilot/tools/test_tools_local_route.py b/iqpilot/tools/test_tools_local_route.py index f75db4864..4dd324580 100644 --- a/iqpilot/tools/test_tools_local_route.py +++ b/iqpilot/tools/test_tools_local_route.py @@ -2,6 +2,8 @@ Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos """ import os +import pty +import select import shutil import subprocess import time @@ -49,11 +51,17 @@ def local_route(tmp_path_factory): def run(cmd, timeout=180): - env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"} - return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env, + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=os.environ.copy(), cwd=TOOLS_DIR.parent) +def cabana_command(*args): + command = [str(CABANA_BIN), *args] + if os.uname().sysname == "Linux" and shutil.which("xvfb-run"): + command = ["xvfb-run", "-a", *command] + return command + + def test_jotpluggler_renders_a_local_route(local_route, tmp_path): assert JOTPLUGGLER_BIN.exists(), "jotpluggler not built" out = tmp_path / "plot.png" @@ -66,27 +74,33 @@ def test_jotpluggler_renders_a_local_route(local_route, tmp_path): def test_cabana_loads_a_local_route(local_route): assert CABANA_BIN.exists(), "cabana not built" - env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"} - proc = subprocess.Popen([str(CABANA_BIN), "--data_dir", str(local_route), "--no-vipc", ROUTE], - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, - env=env, cwd=TOOLS_DIR.parent) + master, slave = pty.openpty() + proc = subprocess.Popen(cabana_command("--data_dir", str(local_route), "--no-vipc", ROUTE), + stdout=slave, stderr=slave, + env=os.environ.copy(), cwd=TOOLS_DIR.parent) + os.close(slave) loaded = f"loaded route {ROUTE} with 2 valid segments" - lines = [] + output = bytearray() deadline = time.monotonic() + 60 try: while time.monotonic() < deadline: - line = proc.stdout.readline() - if not line: + ready, _, _ = select.select([master], [], [], min(1, deadline - time.monotonic())) + if not ready: + if proc.poll() is not None: + break + continue + try: + output.extend(os.read(master, 4096)) + except OSError: break - lines.append(line) - if loaded in line: + if loaded.encode() in output: break finally: proc.kill() proc.wait() - proc.stdout.close() + os.close(master) - out = "".join(lines) + out = output.decode(errors="replace") assert "failed to load route" not in out, out assert "invalid route format" not in out, out assert loaded in out, out diff --git a/iqpilot/tools/tests/test_iq_command.py b/iqpilot/tools/tests/test_iq_command.py index 79f557d5d..323d3039d 100644 --- a/iqpilot/tools/tests/test_iq_command.py +++ b/iqpilot/tools/tests/test_iq_command.py @@ -27,6 +27,15 @@ def run_pkg(checkout: Path, path: Path) -> subprocess.CompletedProcess[str]: ) +def run_iq(checkout: Path, command: str, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(IQ_COMMAND), "--dir", str(checkout), command, *args], + check=False, + capture_output=True, + text=True, + ) + + def test_public_checkout_skips_private_package_source_setup(tmp_path: Path): checkout = make_checkout(tmp_path) @@ -64,3 +73,40 @@ def test_public_checkout_runs_bundled_package_installer(tmp_path: Path): assert result.returncode == 0 assert installer_log.read_text() == "installed" + + +def test_cabana_command_runs_launcher_from_checkout(tmp_path: Path): + checkout = make_checkout(tmp_path) + log = tmp_path / "cabana.log" + launcher = checkout / "iqpilot/tools/cabana/cabana" + launcher.parent.mkdir(parents=True) + launcher.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$PWD|$*\" > {log!s}\n") + launcher.chmod(0o755) + + result = run_iq(checkout, "cabana", "--msgq") + + assert result.returncode == 0 + assert log.read_text().strip() == f"{checkout}|--msgq" + + +def test_juggle_command_runs_launcher_from_checkout(tmp_path: Path): + checkout = make_checkout(tmp_path) + log = tmp_path / "juggle.log" + launcher = checkout / "iqpilot/tools/jotpluggler/pluggle.py" + launcher.parent.mkdir(parents=True) + launcher.write_text(f"#!/usr/bin/env bash\nprintf '%s\\n' \"$PWD|$*\" > {log!s}\n") + launcher.chmod(0o755) + + result = run_iq(checkout, "juggle", "route") + + assert result.returncode == 0 + assert log.read_text().strip() == f"{checkout}|route" + + +def test_desktop_tool_missing_from_host_checkout_fails(tmp_path: Path): + checkout = make_checkout(tmp_path) + + result = run_iq(checkout, "cabana") + + assert result.returncode == 1 + assert "launcher is missing" in result.stderr