IQ.Pilot Release Commit @ 0b96bd5

This commit is contained in:
IQ.Lvbs CI [bot]
2026-09-02 16:46:45 -05:00
parent 4fb3da761f
commit 7745f48100
162 changed files with 12835 additions and 9476 deletions

View File

@@ -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)))

View File

@@ -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

View File

@@ -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)

View File

@@ -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<const char *> &list, const char *value) {

View File

@@ -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"

View File

@@ -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()

View File

@@ -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<std::string, service> 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"))

View File

@@ -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"

View File

@@ -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/

View File

@@ -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 <panda-serial> read can messages from panda with given serial
--socketcan <socketcan> read can messages from given SocketCAN device
--zmq <ip-address> read can messages from zmq at the specified ip-address
messages
--data_dir <data_dir> local directory with routes
--no-vipc do not output video
--dbc <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 <ipaddress>
```
Replace &lt;ipaddress&gt; with your device's IP address.
If you can't run the bridge on the device, `--bridge <ipaddress>` 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="<your konn3kt jwt>"
./tools/cabana/konn3kt_canproxy.py <dongle_id>
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.

View File

@@ -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:
@@ -105,43 +27,87 @@ def build_bootstrap_icons_src(target, source, env):
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,
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"])
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)

View File

@@ -1 +0,0 @@
*.cc

View File

@@ -1,5 +0,0 @@
<!DOCTYPE RCC><RCC version="1.0">
<qresource>
<file>cabana-icon.png</file>
</qresource>
</RCC>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,510 +0,0 @@
#include "tools/cabana/binaryview.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <cstdio>
#include <QFontDatabase>
#include <QHeaderView>
#include <QMouseEvent>
#include <QPainter>
#include <QScrollBar>
#include <QShortcut>
#include <QToolTip>
#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"(
<b>Binary View</b><br/>
<!-- TODO: add description here -->
<span style="color:gray">Shortcuts</span><br />
Delete Signal:
<span style="background-color:lightGray;color:gray">&nbsp;x&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Backspace&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Delete&nbsp;</span><br />
Change endianness: <span style="background-color:lightGray;color:gray">&nbsp;e&nbsp; </span><br />
Change signedness: <span style="background-color:lightGray;color:gray">&nbsp;s&nbsp;</span><br />
Open chart:
<span style="background-color:lightGray;color:gray">&nbsp;c&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;p&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;g&nbsp;</span>
)");
}
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<const cabana::Signal *> BinaryView::getOverlappingSignals() const {
std::set<const cabana::Signal *> 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<int, int, bool> 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<std::array<uint32_t, 8>> &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<uint32_t, 8>{});
// 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<uint8_t> 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<int>(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);
}
}
}

View File

@@ -1,104 +0,0 @@
#pragma once
#include <set>
#include <tuple>
#include <vector>
#include <QStyledItemDelegate>
#include <QTableView>
#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<QStaticText, 256> hex_text_table;
std::array<QStaticText, 2> 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<std::array<uint32_t, 8>> &getBitFlipChanges(size_t msg_size);
struct BitFlipTracker {
std::optional<std::pair<double, double>> time_range;
std::vector<std::array<uint32_t, 8>> 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<const cabana::Signal *> sigs;
bool valid = false;
};
std::vector<Item> 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<const cabana::Signal*> 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<int, int, bool> 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;
};

View File

@@ -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" "$@"

View File

@@ -1,125 +0,0 @@
#include "tools/cabana/cameraview.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <QApplication>
#include <QPainter>
#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<std::set<VisionStreamType>>("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<VisionStreamType> 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<VisionIpcClient> 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();
}

View File

@@ -1,56 +0,0 @@
#pragma once
#include <atomic>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <QImage>
#include <QWidget>
#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<VisionStreamType>);
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<VisionStreamType> active_stream_type;
std::atomic<VisionStreamType> requested_stream_type;
std::set<VisionStreamType> available_streams;
std::thread vipc_thread;
std::atomic<bool> vipc_exit = false;
std::mutex frame_lock;
protected slots:
void vipcFrameReceived();
void availableStreamsUpdated(std::set<VisionStreamType> streams);
};
Q_DECLARE_METATYPE(std::set<VisionStreamType>);

View File

@@ -1,770 +0,0 @@
#include "tools/cabana/chart/chart.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <limits>
#include <random>
#include <QActionGroup>
#include <QContextMenuEvent>
#include <QMouseEvent>
#include <QPainterPath>
#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<double, double> &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<bool(const SigItem &s)> 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<const CanEvent *> &events,
std::vector<QPointF> &vals, std::vector<QPointF> &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<QPointF> 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<double>::max();
double max = std::numeric_limits<double>::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<double>::max();
s.max = std::numeric_limits<double>::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<double>::max()) min = 0;
if (max == std::numeric_limits<double>::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<double, double, int> 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<double>::max() ? "--" : QString::number(s.min);
QString max = s.max == std::numeric_limits<double>::lowest() ? "--" : QString::number(s.max);
text_list << QString("<span style=\"color:%1;\">■ </span>%2<b>%3</b> (%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 = "<p style='white-space:pre'>" % text_list.join("<br />") % "</p>";
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<int>(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<QPointF> 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<int> sat(35, 99);
std::uniform_int_distribution<int> 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();
}
}

View File

@@ -1,130 +0,0 @@
#pragma once
#include <functional>
#include <tuple>
#include <utility>
#include <vector>
#include <QMenu>
#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<double, double> &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<QPointF> vals;
std::vector<QPointF> 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<const CanEvent *> &events,
std::vector<QPointF> &vals, std::vector<QPointF> &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<double, double, int> 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<bool(const SigItem &)> 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<QRect> 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<SigItem> 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;
};

View File

@@ -1,661 +0,0 @@
#include "tools/cabana/chart/chartswidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <future>
#include <QApplication>
#include <QMenu>
#include <QMouseEvent>
#include <QScrollBar>
#include <QToolBar>
#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<QToolButton *>(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<QToolButton*>(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"(
<b>Chart View</b><br />
<b>Click</b>: Click to seek to a corresponding time.<br />
<b>Drag</b>: Zoom into the chart.<br />
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
<b>Right Mouse</b>: Open the context menu.<br />
)"));
}
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<std::future<void>> 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<std::pair<double, double>> &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<float>(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 &current_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<QMouseEvent *>(e)->globalPos());
return true;
} else if (e->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent *>(e)->button() == Qt::LeftButton) {
dragChartRelease(static_cast<QMouseEvent *>(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<TipLabel *>(o) != nullptr;
auto global_pos = static_cast<QMouseEvent *>(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<TipLabel *>(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<QMouseEvent *>(event)->button() == Qt::BackButton;
break;
case QEvent::NativeGesture:
back_button = (static_cast<QNativeGestureEvent *>(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;
}

View File

@@ -1,138 +0,0 @@
#pragma once
#include <unordered_map>
#include <utility>
#include <QGridLayout>
#include <QLabel>
#include <QScrollArea>
#include <QTimer>
#include <QToolBar>
#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<std::pair<double, double>> &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<ChartView *> &currentCharts() { 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<ChartView *> charts;
std::unordered_map<int, std::vector<ChartView *>> tab_charts;
TabBar *tabbar;
ChartsContainer *charts_container;
QScrollArea *charts_scroll;
uint32_t max_chart_range = 0;
std::pair<double, double> 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<double, double> range) : range(range) {
prev_range = can->timeRange();
}
void undo() override { can->setTimeRange(prev_range); }
void redo() override { can->setTimeRange(range); }
std::optional<std::pair<double, double>> prev_range, range;
};

View File

@@ -1,107 +0,0 @@
#include "tools/cabana/chart/signalselector.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
#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<int>(&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<MessageId>()) {
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<MessageId>();
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("<span style=\"color:%0;\">■ </span> %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name));
if (show_msg_name) text += QString(" <font color=\"gray\">%0 %1</font>").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::ListItem *> SignalSelector::seletedItems() {
std::vector<SignalSelector::ListItem *> ret;
for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i));
return ret;
}

View File

@@ -1,30 +0,0 @@
#pragma once
#include <QComboBox>
#include <QDialog>
#include <QListWidget>
#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<ListItem *> 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;
};

View File

@@ -1,100 +0,0 @@
#include "tools/cabana/chart/sparkline.h"
#include <algorithm>
#include <limits>
#include <QPainter>
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<double>::max();
max_val = std::numeric_limits<double>::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());
}
}

View File

@@ -1,26 +0,0 @@
#pragma once
#include <QPixmap>
#include <QPointF>
#include <vector>
#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<QPointF> points_;
std::vector<QPointF> render_points_;
double freq_ = 0;
};

View File

@@ -1,58 +0,0 @@
#include "tools/cabana/chart/tiplabel.h"
#include <utility>
#include <QApplication>
#include <QStylePainter>
#include <QToolTip>
#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);
}

View File

@@ -1,12 +0,0 @@
#pragma once
#include <QLabel>
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;
};

View File

@@ -1,11 +1,12 @@
#include "tools/cabana/commands.h"
#include <cassert>
#include <cmath>
// 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 &notifier;
}
// 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) {

View File

@@ -1,13 +1,11 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include <QObject>
#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<void()> index_changed;
std::function<void(bool)> 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<bool> cleanChanged;
private:
void setIndex(int index);
std::vector<std::unique_ptr<UndoCommand>> 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<std::pair<cabana::Signal, cabana::Signal>> sigs; // {old_sig, new_sig}
std::vector<std::pair<cabana::Signal, cabana::Signal>> sigs;
};

View File

@@ -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;

View File

@@ -0,0 +1,83 @@
#pragma once
#include <functional>
#include <map>
#include <memory>
#include <utility>
#include <vector>
namespace observable_detail {
struct HandlerTable {
virtual ~HandlerTable() = default;
virtual void erase(int id) = 0;
};
}
class Connection {
public:
Connection() = default;
Connection(std::weak_ptr<observable_detail::HandlerTable> 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<observable_detail::HandlerTable> table_;
int id_ = -1;
};
using Connections = std::vector<Connection>;
template <typename... Args>
class Observable {
public:
using Handler = std::function<void(Args...)>;
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<Handler>(std::move(handler)));
return Connection(table_, id);
}
void operator()(Args... args) const {
auto table = table_;
std::vector<int> 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<int, std::shared_ptr<Handler>> handlers;
int next_id = 0;
void erase(int id) override { handlers.erase(id); }
};
std::shared_ptr<Table> table_ = std::make_shared<Table>();
};

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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; }

View File

@@ -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);

View File

@@ -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<std::string> DBCManager::signalNames() {
// Used for autocompletion
std::set<std::string> names;
for (auto &f : allDBCFiles()) {
for (auto &[_, m] : f->getMessages()) {
@@ -141,12 +141,19 @@ std::vector<std::string> 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<DBCFile *> DBCManager::nonEmptyDBCFiles() {
std::vector<DBCFile *> 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;
}

View File

@@ -1,12 +1,12 @@
#pragma once
#include <functional>
#include <memory>
#include <map>
#include <set>
#include <string>
#include <vector>
#include "tools/cabana/core/observable.h"
#include "tools/cabana/dbc/dbcfile.h"
typedef std::set<int> SourceSet;
@@ -15,16 +15,6 @@ inline bool operator<(const std::shared_ptr<DBCFile> &l, const std::shared_ptr<D
class DBCManager {
public:
struct Callbacks {
std::function<void(MessageId, const cabana::Signal *)> signal_added;
std::function<void(const cabana::Signal *)> signal_removed;
std::function<void(const cabana::Signal *)> signal_updated;
std::function<void(MessageId)> msg_updated;
std::function<void(MessageId)> msg_removed;
std::function<void()> file_changed;
std::function<void()> 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<std::string> signalNames();
inline int dbcCount() { return allDBCFiles().size(); }
int nonEmptyDBCCount();
std::vector<DBCFile *> 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<DBCFile *> allDBCFiles();
void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); }
Observable<MessageId, const cabana::Signal *> signalAdded;
Observable<const cabana::Signal *> signalRemoved;
Observable<const cabana::Signal *> signalUpdated;
Observable<MessageId> msgUpdated;
Observable<MessageId> msgRemoved;
Observable<> fileChanged;
Observable<> maskUpdated;
private:
std::map<int, std::shared_ptr<DBCFile>> dbc_files;
Callbacks callbacks_;
};
DBCManager *dbc();

View File

@@ -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 &notifier;
}

View File

@@ -1,27 +0,0 @@
#pragma once
#include <QMetaType>
#include <QObject>
#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();

View File

@@ -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`

View File

@@ -1,324 +0,0 @@
#include "tools/cabana/detailwidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <QFormLayout>
#include <QMenu>
#include <QRadioButton>
#include <QPushButton>
#include <QToolBar>
#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<MessageId>());
}
});
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<std::pair<double, double>> &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<MessageId>() == 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<QString, QStringList> DetailWidget::serializeMessageIds() const {
QStringList msgs;
for (int i = 0; i < tabbar->count(); ++i) {
MessageId id = tabbar->tabData(i).value<MessageId>();
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<MessageId> *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;
}

View File

@@ -1,75 +0,0 @@
#pragma once
#include <QDialogButtonBox>
#include <QSplitter>
#include <QTabWidget>
#include <QTextEdit>
#include <set>
#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<QString, QStringList> 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<MessageId> *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;
};

View File

@@ -1,249 +0,0 @@
#include "tools/cabana/historylog.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <functional>
#include <QFileDialog>
#include <QPainter>
#include <QVBoxLayout>
#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<bool(double, double)> 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<Message>::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<HistoryLogModel::Message> msgs;
std::vector<double> 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<uint8_t> 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<QBrush>());
}
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<int>(&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<bool(double, double)> cmp = nullptr;
switch (comp_box->currentIndex()) {
case 0: cmp = std::greater<double>{}; break;
case 1: cmp = std::equal_to<double>{}; break;
case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal
case 3: cmp = std::less<double>{}; 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);
}
}

View File

@@ -1,81 +0,0 @@
#pragma once
#include <deque>
#include <vector>
#include <QComboBox>
#include <QHeaderView>
#include <QLineEdit>
#include <QTableView>
#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<bool(double, double)> 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<double> sig_values;
std::vector<uint8_t> data;
std::vector<CabanaColor> colors;
};
void fetchData(std::deque<Message>::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<bool(double, double)> filter_cmp = nullptr;
std::deque<Message> messages;
std::vector<cabana::Signal *> 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;
};

View File

@@ -1,741 +0,0 @@
#include "tools/cabana/mainwin.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>
#include <QFileDialog>
#include <QMenuBar>
#include <QMessageBox>
#include <QProgressDialog>
#include <QResizeEvent>
#include <QShortcut>
#include <QTextDocument>
#include <QVBoxLayout>
#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>("uint64_t");
qRegisterMetaType<SourceSet>("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<char>(json_file), std::istreambuf_iterator<char>()};
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<std::string> 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<DummyStream *>(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<DummyStream *>(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<int>(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<HelpOverlay*>()) {
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<MessagesWidget *>());
drawHelpForWidget(painter, parent->findChild<BinaryView *>());
drawHelpForWidget(painter, parent->findChild<SignalView *>());
drawHelpForWidget(painter, parent->findChild<ChartsWidget *>());
drawHelpForWidget(painter, parent->findChild<VideoWidget *>());
}
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();
}

View File

@@ -1,119 +0,0 @@
#pragma once
#include <QDockWidget>
#include <QMainWindow>
#include <QMenu>
#include <QProgressBar>
#include <QSplitter>
#include <QStatusBar>
#include <cstdint>
#include <set>
#include <string>
#include <unordered_map>
#include <vector>
#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<std::string, std::string> 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<uint8_t> 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;
};

View File

@@ -1,465 +0,0 @@
#include "tools/cabana/messageswidget.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <limits>
#include <utility>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QPainter>
#include <QPalette>
#include <QPushButton>
#include <QScrollBar>
#include <QVBoxLayout>
#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 &current, 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"(
<b>Message View</b><br/>
<!-- TODO: add description here -->
<span style="color:gray">Byte color</span><br />
<span style="color:gray;"> </span> constant changing<br />
<span style="color:blue;"> </span> increasing<br />
<span style="color:red;"> </span> decreasing<br />
<span style="color:gray">Shortcuts</span><br />
Horizontal Scrolling: <span style="background-color:lightGray;color:gray">&nbsp;shift+wheel&nbsp;</span>
)"));
}
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<size_t, size_t>(),
[](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 += "<br /><span style=\"color:gray;\">" + QString::fromStdString(msg->comment) + "</span>";
return tooltip;
}
return {};
}
void MessageListModel::setFilterStrings(const std::map<int, QString> &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<MessageListModel::Item> &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<unsigned int>::min();
unsigned int max = std::numeric_limits<unsigned int>::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<MessageId> 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<Item> 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<MessageId> *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<QRgb>(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<int>(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<int, QString> filters;
for (int i = 0; i < (int)editors.size(); i++) {
if (!editors[i]->text().isEmpty()) {
filters[i] = editors[i]->text();
}
}
qobject_cast<MessageListModel*>(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;
}

View File

@@ -1,128 +0,0 @@
#pragma once
#include <algorithm>
#include <cstdint>
#include <map>
#include <optional>
#include <set>
#include <vector>
#include <QAbstractTableModel>
#include <QHeaderView>
#include <QLineEdit>
#include <QMenu>
#include <QTreeView>
#include <QWheelEvent>
#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<int, QString> &filters);
void showInactiveMessages(bool show);
void msgsReceived(const std::set<MessageId> *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<Item> items_;
bool show_inactive_messages = true;
private:
void sortItems(std::vector<MessageListModel::Item> &items);
bool match(const MessageListModel::Item &id);
std::map<int, QString> filters_;
std::set<MessageId> 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<QLineEdit *> editors;
};
class MessagesWidget : public QWidget {
Q_OBJECT
public:
MessagesWidget(QWidget *parent);
void selectMessage(const MessageId &message_id);
std::vector<uint8_t> saveHeaderState() const {
const auto state = view->header()->saveState();
return {state.begin(), state.end()};
}
bool restoreHeaderState(const std::vector<uint8_t> &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<MessageId> current_msg_id;
MessageListModel *model;
QPushButton *suppress_add;
QPushButton *suppress_clear;
QMenu *menu;
};

View File

@@ -122,7 +122,7 @@ void Panda::set_data_speed_kbps(uint16_t bus, uint16_t speed) {
bool Panda::can_receive(std::vector<can_frame>& 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<can_fra
const uint8_t data_len = dlc_to_len[header.data_len_code];
if (pos + sizeof(can_header) + data_len > 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<can_fra
pos += sizeof(can_header) + data_len;
}
// move the overflowing data to the beginning of the buffer for the next round
memmove(data, &data[pos], size - pos);
size -= pos;
@@ -192,7 +192,7 @@ uint8_t Panda::calculate_checksum(uint8_t *data, uint32_t len) {
return checksum;
}
// USB implementation methods
bool Panda::init_usb_connection(const std::string& serial) {
ssize_t num_devices;
libusb_device **dev_list = NULL;
@@ -201,7 +201,7 @@ bool Panda::init_usb_connection(const std::string& serial) {
ctx = init_usb_ctx();
if (!ctx) { goto fail; }
// connect by serial
num_devices = libusb_get_device_list(ctx, &dev_list);
if (num_devices < 0) { goto fail; }
@@ -333,7 +333,7 @@ int Panda::bulk_read(unsigned char endpoint, unsigned char* data, int length, un
do {
err = libusb_bulk_transfer(dev_handle, endpoint, data, length, &transferred, timeout);
if (err == LIBUSB_ERROR_TIMEOUT) {
break; // timeout is okay to exit, recv still happened
break;
} else if (err == LIBUSB_ERROR_OVERFLOW) {
comms_healthy_flag = false;
LOGE_100("overflow got 0x%x", transferred);

View File

@@ -58,10 +58,10 @@ public:
bool comms_healthy();
std::string hw_serial();
// Static functions
static std::vector<std::string> 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<bool> connected_flag = true;
std::atomic<bool> 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[]);

View File

@@ -0,0 +1,115 @@
#include "tools/cabana/routes.h"
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <thread>
#include "json11/json11.hpp"
#include "tools/replay/api.h"
namespace routes {
std::pair<bool, int> 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::milliseconds>(
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<time_t>(-1)) return 0;
return static_cast<int64_t>(secs) * 1000 + millis;
}
std::string formatUnixMs(int64_t ms) {
time_t secs = static_cast<time_t>(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<DeviceInfo> parseDevices(const std::string &json) {
std::vector<DeviceInfo> 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<RouteInfo> parseRoutes(const std::string &json, bool preserved) {
std::vector<RouteInfo> 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<int64_t>(route["start_time_utc_millis"].number_value());
info.end_ms = static_cast<int64_t>(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<DeviceInfo>{}, 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<int64_t>(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<RouteInfo>{}, success, error_code);
}).detach();
}
}

View File

@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
#include <vector>
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<void(std::vector<DeviceInfo> devices, bool success, int error_code)>;
using RoutesCallback = std::function<void(std::vector<RouteInfo> routes, bool success, int error_code)>;
std::pair<bool, int> checkApiResponse(const std::string &result);
int64_t nowUnixMs();
int64_t parseIsoToUnixMs(const std::string &s);
std::string formatUnixMs(int64_t ms);
std::vector<DeviceInfo> parseDevices(const std::string &json);
std::vector<RouteInfo> parseRoutes(const std::string &json, bool preserved);
void fetchDevices(DevicesCallback callback);
void fetchRoutes(const std::string &dongle_id, int period_days, RoutesCallback callback);
}

View File

@@ -2,41 +2,23 @@
#include <algorithm>
#include <cerrno>
#include <cctype>
#include <charconv>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <map>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#ifdef __APPLE__
#include <CoreFoundation/CoreFoundation.h>
#endif
#include <QAbstractButton>
#include <QDialogButtonBox>
#include <QFileDialog>
#include <QFormLayout>
#include <QPushButton>
#include <type_traits>
#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<std::string> strings;
std::string bytes;
bool is_byte_array = false;
};
using LegacySettings = std::map<std::string, LegacyValue>;
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<std::vector<uint32_t>> decoded(1);
std::vector<bool> 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<char, char> 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<unsigned char>(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<unsigned char>(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<UniChar> 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<const void *> keys(count);
std::vector<const void *> 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<CFStringRef>(keys[i]));
CFTypeRef object = objects[i];
LegacyValue value;
if (CFGetTypeID(object) == CFBooleanGetTypeID()) {
value.strings.push_back(CFBooleanGetValue(static_cast<CFBooleanRef>(object)) ? "true" : "false");
} else if (CFGetTypeID(object) == CFNumberGetTypeID()) {
int number = 0;
if (CFNumberGetValue(static_cast<CFNumberRef>(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number));
} else if (CFGetTypeID(object) == CFStringGetTypeID()) {
value = cfStringValue(static_cast<CFStringRef>(object));
} else if (CFGetTypeID(object) == CFDataGetTypeID()) {
auto data = static_cast<CFDataRef>(object);
value.is_byte_array = true;
value.bytes.assign(reinterpret_cast<const char *>(CFDataGetBytePtr(data)), CFDataGetLength(data));
} else if (CFGetTypeID(object) == CFArrayGetTypeID()) {
auto array = static_cast<CFArrayRef>(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<CFStringRef>(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 <typename T>
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<T, bool>) {
if (stored == "true") value = true;
if (stored == "false") value = false;
} else if constexpr (std::is_integral_v<T> || std::is_enum_v<T>) {
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<T>(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<std::string> &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<uint8_t> &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 <typename T>
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<uint8_t> &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 <typename T>
void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) {
if constexpr (std::is_same_v<T, bool>) {
@@ -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<uint8_t> &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 <class Store, class SettingOperation>
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();
}

View File

@@ -1,45 +1,18 @@
#pragma once
#include <cstdint>
#include <vector>
#include <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QLineEdit>
#include <QSpinBox>
#include <string>
#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<uint8_t> geometry;
std::vector<uint8_t> video_splitter_state;
std::vector<uint8_t> window_state;
std::vector<uint8_t> 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;

View File

@@ -1,719 +0,0 @@
#include "tools/cabana/signalview.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <algorithm>
#include <future>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMessageBox>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QScrollBar>
#include <QVBoxLayout>
#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<ValueDescription>(); 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<int>(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<SignalModel::Item*>(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"(
<b>Signal view</b><br />
<!-- TODO: add description here -->
)"));
}
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<QToolButton *>();
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<QModelIndex, QModelIndex> 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<MessageId> *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<int>(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<std::future<void>> 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;
}

View File

@@ -1,153 +0,0 @@
#pragma once
#include <memory>
#include <set>
#include <utility>
#include <QAbstractItemModel>
#include <QLabel>
#include <QLineEdit>
#include <QSlider>
#include <QStyledItemDelegate>
#include <QTableWidget>
#include <QTreeView>
#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<Item *> 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<Item> 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<MessageId> *msgs = nullptr);
std::pair<QModelIndex, QModelIndex> 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<SignalView *>(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;
};

View File

@@ -1,26 +1,51 @@
#include "tools/cabana/streams/abstractstream.h"
#include "tools/cabana/dbc/dbcqt.h"
#include <cassert>
#include <limits>
#include <utility>
#include <QApplication>
#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<MonotonicBuffer>(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<void()> fn) {
utils::runOnMainThread([alive = std::weak_ptr<bool>(alive_), fn = std::move(fn)]() {
if (!alive.expired()) fn();
});
}
void AbstractStream::postToMainThreadAndWait(std::function<void()> fn) {
assert(!utils::isMainThread());
std::unique_lock lock(mutex_);
if (exiting_) return;
auto done = std::make_shared<bool>(false);
postToMainThread([this, alive = std::weak_ptr<bool>(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<std::pair<double, double>> &range) {
@@ -107,7 +131,7 @@ void AbstractStream::setTimeRange(const std::optional<std::pair<double, double>>
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<const CanEvent *> &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<const CanEvent *> &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<const CanEvent *> &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<CanEventIter, CanEventIter> AbstractStream::eventsInRange(const MessageId &id, std::optional<std::pair<double, double>> 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<double>::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<uint8_t> &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));
}
}

View File

@@ -4,6 +4,7 @@
#include <array>
#include <condition_variable>
#include <chrono>
#include <functional>
#include <memory>
#include <mutex>
#include <optional>
@@ -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<std::pair<double, double>> &range);
void eventsMerged(const MessageEventsMap &events_map);
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
void sourcesUpdated(const SourceSet &s);
void privateUpdateLastMsgsSignal();
public:
Observable<> paused;
Observable<> resume;
Observable<double> seeking;
Observable<double> seekedTo;
Observable<const std::optional<std::pair<double, double>> &> timeRangeChanged;
Observable<const MessageEventsMap &> eventsMerged;
Observable<const std::set<MessageId> *, bool> msgsReceived;
Observable<const std::string &> error;
SourceSet sources;
protected:
void postToMainThread(std::function<void()> fn);
void postToMainThreadAndWait(std::function<void()> fn);
void cancelWaits();
void requestUpdateLastMessages() { postToMainThread([this]() { updateLastMessages(); }); }
void mergeEvents(const std::vector<const CanEvent *> &events);
void insertEvents(const std::vector<const CanEvent *> &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<MessageId, CanData> last_msgs;
std::unique_ptr<MonotonicBuffer> event_buffer_;
std::shared_ptr<bool> alive_ = std::make_shared<bool>(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<MessageId> new_msgs_;
std::unordered_map<MessageId, CanData> messages_;
std::unordered_map<MessageId, std::vector<uint8_t>> 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;

View File

@@ -1,5 +1,6 @@
#include "tools/cabana/streams/devicestream.h"
#include <cassert>
#include <cerrno>
#include <chrono>
#include <csignal>
@@ -9,22 +10,17 @@
#include <memory>
#include <string>
#include <thread>
#include <utility>
#include <unistd.h>
#include <sys/wait.h>
#include "cereal/services.h"
#include <QButtonGroup>
#include <QFormLayout>
#include <QMessageBox>
#include <QRadioButton>
#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<char *>(nullptr));
execl(path.c_str(), path.c_str(), address_.c_str(), can_filter, static_cast<char *>(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<ssize_t>(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(Context::create());
std::unique_ptr<SubSocket> sock(SubSocket::create(context.get(), "can", address, false, true, services.at("can").queue_size));
std::unique_ptr<SubSocket> 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<Message> msg(sock->receive(true));
if (!msg) {
@@ -123,36 +111,3 @@ void DeviceStream::streamThread() {
handleEvent(kj::ArrayPtr<capnp::word>((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<int>(DeviceStream::Mode::Msgq));
group->addButton(zmq, static_cast<int>(DeviceStream::Mode::Zmq));
group->addButton(bridge, static_cast<int>(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<QAbstractButton *, bool>(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) {
if (checked) ip_address->setEnabled(button != msgq);
});
zmq->setChecked(true);
}
AbstractStream *OpenDeviceWidget::open() {
auto mode = static_cast<DeviceStream::Mode>(group->checkedId());
return new DeviceStream(qApp, mode, mode == DeviceStream::Mode::Msgq ? "" : ip_address->text());
}

View File

@@ -2,28 +2,17 @@
#include "tools/cabana/streams/livestream.h"
#include <string>
#include <sys/types.h>
// 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 <address> (konn3kt_canproxy, or `bridge` on the device)
// Bridge - fork cereal/messaging/bridge <address>, (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_;
};

View File

@@ -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<Logger>();
}
@@ -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<capnp::word> data) {
if (logger) {
logger->write(data);
@@ -89,12 +86,11 @@ void LiveStream::handleEvent(kj::ArrayPtr<capnp::word> 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<uint64_t>(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();
}

View File

@@ -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<bool> update_pending_ = false;
std::atomic<int> fps_ = 10;
std::vector<const CanEvent *> received_events_;
std::chrono::system_clock::time_point begin_date_time;

View File

@@ -4,13 +4,7 @@
#include <cstdio>
#include <thread>
#include <QCheckBox>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QTimer>
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<PandaStream *>(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<int>(&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<int>(&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;
}
}

View File

@@ -3,15 +3,9 @@
#include <memory>
#include <vector>
#include <QComboBox>
#include <QFormLayout>
#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> 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 = {};
};

View File

@@ -1,28 +1,28 @@
#include "tools/cabana/streams/replaystream.h"
#include <filesystem>
#include <QLabel>
#include <QFileDialog>
#include <QGridLayout>
#include <QMessageBox>
#include <QPushButton>
#include <string>
#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<OpenpilotPrefix>();
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<const CanEvent *> 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<cereal::Event>();
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<LogReader> qlog) { emit qLogLoaded(qlog); };
replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); };
replay->onQLogLoaded = [this](std::shared_ptr<LogReader> 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<ReplayStream>(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();
}

View File

@@ -1,22 +1,17 @@
#pragma once
#include <QCheckBox>
#include <algorithm>
#include <memory>
#include <set>
#include <vector>
#include "common/prefix.h"
#include "tools/cabana/streams/abstractstream.h"
#include "tools/replay/replay.h"
Q_DECLARE_METATYPE(std::shared_ptr<LogReader>);
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<LogReader> qlog);
Observable<std::shared_ptr<LogReader>> qLogLoaded;
private:
void mergeSegments();
std::unique_ptr<Replay> replay = nullptr;
Connection settings_connection_;
std::set<int> processed_segments;
std::unique_ptr<OpenpilotPrefix> op_prefix;
};
class OpenReplayWidget : public AbstractOpenStreamWidget {
Q_OBJECT
public:
OpenReplayWidget(QWidget *parent = nullptr);
AbstractStream *open() override;
private:
QLineEdit *route_edit;
std::vector<QCheckBox *> cameras;
};

View File

@@ -1,200 +0,0 @@
#include "tools/cabana/streams/routes.h"
#include <chrono>
#include <ctime>
#include <string>
#include <thread>
#include <utility>
#include <QApplication>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QListWidget>
#include <QMessageBox>
#include <QPainter>
#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<bool, int> 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::milliseconds>(
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<time_t>(-1)) return 0;
return static_cast<int64_t>(secs) * 1000 + millis;
}
QString formatUnixMs(int64_t ms) {
time_t secs = static_cast<time_t>(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<int>::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes);
connect(period_selector_, QOverload<int>::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<bool>(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<int64_t>(period) * 24LL * 60LL * 60LL * 1000LL;
}
int request_id = ++fetch_id_;
std::thread([this, alive = std::weak_ptr<bool>(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<int64_t>(route["start_time_utc_millis"].number_value());
to_ms = static_cast<int64_t>(route["end_time_utc_millis"].number_value());
}
const int mins = static_cast<int>((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() : "";
}

View File

@@ -1,28 +0,0 @@
#pragma once
#include <atomic>
#include <memory>
#include <QComboBox>
#include <QDialog>
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<int> fetch_id_{0};
// expires on destruction; guards main-thread callbacks from detached worker threads
std::shared_ptr<bool> alive_ = std::make_shared<bool>(true);
};

View File

@@ -8,15 +8,8 @@
#include <unistd.h>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QPushButton>
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;
}
}

View File

@@ -1,17 +1,14 @@
#pragma once
#include <QComboBox>
#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 = {};
};

View File

@@ -1,69 +0,0 @@
#include "tools/cabana/streamselector.h"
#include <filesystem>
#include <QFileDialog>
#include <QLabel>
#include <QPushButton>
#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);
}

View File

@@ -1,24 +0,0 @@
#pragma once
#include <QDialogButtonBox>
#include <QDialog>
#include <QLineEdit>
#include <QTabWidget>
#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;
};

View File

@@ -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()

View File

@@ -1,10 +1,15 @@
#include <cmath>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <sstream>
#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
@@ -127,7 +132,7 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\"";
auto &sig_2 = msg->sigs[1];
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<std::string> 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<br /><span font-size:small">
Start Bit: 3 Size: 12<br />
MSB: 14 LSB: 3<br />
Little Endian: Y Signed: N</span>
)");
}
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() {

View File

@@ -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

View File

@@ -1,284 +0,0 @@
#include "tools/cabana/tools/findsignal.h"
#include <set>
#include <thread>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#include <QTimer>
#include <QVBoxLayout>
// 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<bool(double)> 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<std::thread> 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<uint64_t>::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<int>(&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<bool(double)> 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<ushort> buses;
for (auto bus : bus_edit->text().trimmed().split(",")) {
bus = bus.trimmed();
if (!bus.isEmpty()) buses.insert(bus.toUShort());
}
std::set<uint32_t> 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<uint64_t>::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);
}
}
}

View File

@@ -1,66 +0,0 @@
#pragma once
#include <algorithm>
#include <limits>
#include <string>
#include <vector>
#include <QAbstractTableModel>
#include <QCheckBox>
#include <QLabel>
#include <QPushButton>
#include <QTableView>
#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<bool(double)> cmp);
void reset();
void undo();
std::vector<SearchSignal> filtered_signals;
std::vector<SearchSignal> initial_signals;
std::vector<std::vector<SearchSignal>> histories;
uint64_t last_time = std::numeric_limits<uint64_t>::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;
};

View File

@@ -1,161 +0,0 @@
#include "tools/cabana/tools/findsimilarbits.h"
#include <algorithm>
#include <unordered_map>
#include <QGridLayout>
#include <QHeaderView>
#include <QHBoxLayout>
#include <QIntValidator>
#include <QLabel>
#include <QPushButton>
#include <QRadioButton>
#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::mismatched_struct> 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<uint32_t, std::vector<uint32_t>> mismatches;
std::unordered_map<uint32_t, uint32_t> 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<mismatched_struct> 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;
}

View File

@@ -1,36 +0,0 @@
#pragma once
#include <vector>
#include <QComboBox>
#include <QDialog>
#include <QLineEdit>
#include <QSpinBox>
#include <QTableWidget>
#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<mismatched_struct> 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;
};

View File

@@ -1,40 +0,0 @@
#include "tools/cabana/tools/routeinfo.h"
#include <QHeaderView>
#include <QScrollBar>
#include <QTableWidget>
#include <QVBoxLayout>
#include "tools/cabana/streams/replaystream.h"
RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) {
auto *replay = qobject_cast<ReplayStream *>(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);
}

View File

@@ -1,8 +0,0 @@
#pragma once
#include <QDialog>
class RouteInfoDlg : public QDialog {
Q_OBJECT
public:
RouteInfoDlg(QWidget *parent = nullptr);
};

View File

@@ -0,0 +1,212 @@
#include "tools/cabana/ui/app.h"
#include <atomic>
#include <cstdio>
#include <stdexcept>
#include <utility>
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
#include "imgui_impl_opengl3_loader.h"
#include "implot.h"
#include <GLFW/glfw3.h>
#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<bool> g_signal_exit{false};
std::vector<KeyEvent> 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<KeyEvent> takeKeyEvents() {
return std::exchange(g_key_events, {});
}
int run(std::unique_ptr<AbstractStream> 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;
}
}

View File

@@ -0,0 +1,23 @@
#pragma once
#include <functional>
#include <memory>
#include <string>
#include <vector>
#include "tools/cabana/streams/abstractstream.h"
using StreamLoader = std::function<std::unique_ptr<AbstractStream>()>;
int run(std::unique_ptr<AbstractStream> stream, StreamLoader stream_loader, const std::string &dbc_file);
struct KeyEvent {
int key;
int mods;
};
std::vector<KeyEvent> takeKeyEvents();

View File

@@ -0,0 +1,790 @@
#define IMGUI_DEFINE_MATH_OPERATORS
#include "tools/cabana/ui/chart/chart.h"
#include <algorithm>
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <limits>
#include <random>
#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<double, double> &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<bool(const SigItem &s)> 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<SignalSelector>("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<int>(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<int>(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<int>(marker_size, fm_height)));
x += w + 12;
}
int adjust_top = (y + row_height) - top_left.y;
adjust_top = std::max<int>(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<const CanEvent *> &events,
std::vector<ImPlotPoint> &vals, std::vector<ImPlotPoint> &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<ImPlotPoint> 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::PointIter, ChartView::PointIter> ChartView::visibleRange(const std::vector<ImPlotPoint> &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<double>::max();
double max = std::numeric_limits<double>::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<double>::max();
s.max = std::numeric_limits<double>::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<double>::max()) min = 0;
if (max == std::numeric_limits<double>::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<double, double, int> 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::SigItem> ChartView::takeExtraSignals() {
std::vector<SigItem> 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<TipLine> 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<double>::max() ? "--" : utils::toString(s.min);
std::string max = s.max == std::numeric_limits<double>::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<int>(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<int> sat(35, 99);
std::uniform_int_distribution<int> 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;
}

View File

@@ -0,0 +1,142 @@
#pragma once
#include <functional>
#include <string>
#include <tuple>
#include <utility>
#include <vector>
#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<ImPlotPoint> vals;
std::vector<ImPlotPoint> step_vals;
ImPlotPoint track_pt{};
SegmentTree segment_tree;
double min = 0;
double max = 0;
};
ChartView(const std::pair<double, double> &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<bool(const SigItem &)> predicate);
void takeSignalsFrom(ChartView *source);
std::vector<SigItem> takeExtraSignals();
void adoptSignal(SigItem s);
void setDropHighlight(bool highlight) { can_drop_ = highlight; }
const std::vector<SigItem> &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<ImPlotPoint>::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<const CanEvent *> &events,
std::vector<ImPlotPoint> &vals, std::vector<ImPlotPoint> &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<double, double, int> 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<PointIter, PointIter> visibleRange(const std::vector<ImPlotPoint> &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<ImRect> 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<SigItem> sigs_;
double cur_sec_ = 0;
SeriesType series_type_ = SeriesType::Line;
bool can_drop_ = false;
double tooltip_x_ = -1;
ChartsWidget *charts_widget_;
Connections connections_;
};

View File

@@ -0,0 +1,674 @@
#define IMGUI_DEFINE_MATH_OPERATORS
#include "tools/cabana/ui/chart/chartswidget.h"
#include "tools/cabana/ui/threadpool.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <future>
#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<MessageId> *, 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"(
<b>Chart View</b><br />
<b>Click</b>: Click to seek to a corresponding time.<br />
<b>Drag</b>: Zoom into the chart.<br />
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
<b>Right Mouse</b>: Open the context menu.<br />
)";
}
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<ChartView *>(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<std::future<void>> 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<float>(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<ToolbarItem> 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<ChartView>(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<std::string> ChartsWidget::serializeChartIds() const {
std::vector<std::string> 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<std::string> &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<int>(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<SignalSelector>("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<SignalSelector> dlg, ChartView *owner, std::function<void(SignalSelector &)> 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<ChartView *> 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;
}

View File

@@ -0,0 +1,168 @@
#pragma once
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
#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<std::string> serializeChartIds() const;
void restoreChartsFromIds(const std::vector<std::string> &chart_ids);
std::string whatsThis() const;
void setColumnCount(int n);
void removeAll();
void setIsDocked(bool dock);
Observable<> toggleChartsDocking;
Observable<> seriesChanged;
Observable<double> 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<ChartView *> &currentCharts() { return tab_charts_[tabbar_.tabData(tabbar_.currentIndex())]; }
ChartView *findChart(const MessageId &id, const cabana::Signal *sig);
void execSignalSelector(std::unique_ptr<SignalSelector> dlg, ChartView *owner, std::function<void(SignalSelector &)> accepted);
void drawDragPreview();
LogSlider range_slider_{1000};
bool is_docked_ = true;
bool float_window_init_ = false;
UndoStack zoom_undo_stack_;
std::vector<std::unique_ptr<ChartView>> charts_;
std::unordered_map<int, std::vector<ChartView *>> tab_charts_;
TabBar tabbar_;
ChartsContainer charts_container_{this};
ImGuiWindow *charts_scroll_ = nullptr;
ImRect charts_scroll_viewport_;
int max_chart_range_ = 0;
std::pair<double, double> 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<std::unique_ptr<ChartView>> deleted_charts_;
std::unique_ptr<SignalSelector> signal_selector_;
ChartView *signal_selector_owner_ = nullptr;
std::function<void(SignalSelector &)> signal_selector_accepted_;
Connections connections_;
friend class ChartView;
friend class ChartsContainer;
};
class ZoomCommand : public UndoCommand {
public:
ZoomCommand(std::pair<double, double> range) : range(range) {
prev_range = can->timeRange();
}
void undo() override { can->setTimeRange(prev_range); }
void redo() override { can->setTimeRange(range); }
std::optional<std::pair<double, double>> prev_range, range;
};

View File

@@ -0,0 +1,155 @@
#include "tools/cabana/ui/chart/signalselector.h"
#include <algorithm>
#include <cfloat>
#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<ListItem> &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);
}
}
}

View File

@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <vector>
#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<ListItem> &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<ListItem> &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<ComboItem> msgs_combo_;
int msgs_combo_index_ = -1;
std::string msgs_combo_filter_;
std::vector<ListItem> available_list_;
std::vector<ListItem> selected_list_;
int available_row_ = -1;
int selected_row_ = -1;
bool accepted_ = false;
bool open_ = false;
bool show_ = false;
};

View File

@@ -0,0 +1,177 @@
#include "tools/cabana/ui/chart/sparkline.h"
#include <algorithm>
#include <cmath>
#include <limits>
#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<double>::max();
max_val = std::numeric_limits<double>::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<ImVec2> 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();
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include <vector>
#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<Point> points_;
std::vector<ImVec2> render_points_;
ImU32 color_ = 0;
double window_end_ = 0;
double xscale_ = 0;
bool draw_individual_points_ = false;
double freq_ = 0;
};

View File

@@ -0,0 +1,69 @@
#define IMGUI_DEFINE_MATH_OPERATORS
#include "tools/cabana/ui/chart/tiplabel.h"
#include <algorithm>
#include <cfloat>
#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<TipLine> &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);
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <vector>
#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<TipLine> &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<TipLine> text_;
ImVec2 pos_;
ImVec2 size_;
bool visible_ = false;
};

View File

@@ -0,0 +1,222 @@
#include "tools/cabana/ui/dialogs/filedialog.h"
#include <algorithm>
#include <cctype>
#include <system_error>
#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<unsigned char>(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<unsigned char>(a[i])) && isdigit(static_cast<unsigned char>(b[j]))) {
size_t ie = i, je = j;
while (ie < a.size() && isdigit(static_cast<unsigned char>(a[ie]))) ++ie;
while (je < b.size() && isdigit(static_cast<unsigned char>(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<unsigned char>(a[i])), cb = tolower(static_cast<unsigned char>(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<fs::directory_entry> 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<int>(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);
}
}
}

View File

@@ -0,0 +1,19 @@
#pragma once
#include <filesystem>
#include <functional>
#include <string>
#include <vector>
namespace FileDialog {
using Callback = std::function<void(const std::string &path)>;
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();
}

View File

@@ -0,0 +1,87 @@
#include "tools/cabana/ui/dialogs/messagebox.h"
#include <deque>
#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<void(bool)> on_result;
};
std::deque<Box> g_queue;
bool g_show_details = false;
PopupOwner g_owner;
void push(Box box) { g_queue.push_back(std::move(box)); }
std::function<void(bool)> wrap(std::function<void()> 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<void()> 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<void()> 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<void(bool)> 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);
}
}
}

View File

@@ -0,0 +1,19 @@
#pragma once
#include <functional>
#include <string>
namespace MessageBox {
void information(const std::string &title, const std::string &text, std::function<void()> on_close = nullptr);
void warning(const std::string &title, const std::string &text, const std::string &detailed_text = "",
std::function<void()> on_close = nullptr);
void question(const std::string &title, const std::string &text, std::function<void(bool ok)> on_result);
void draw();
}

View File

@@ -0,0 +1,124 @@
#include "tools/cabana/ui/dialogs/routesdialog.h"
#include <utility>
#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<void(bool, const std::string &)> on_done) {
on_done_ = std::move(on_done);
open_ = true;
popup_.reset();
s_ = State{};
alive_ = std::make_shared<bool>(true);
routes::fetchDevices([this, alive = std::weak_ptr<bool>(alive_)](std::vector<routes::DeviceInfo> 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<routes::DeviceInfo> &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<bool>(alive_), request_id](std::vector<routes::RouteInfo> 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<routes::RouteInfo> &list, bool success) {
if (success) {
for (const auto &route : list) {
const int mins = static_cast<int>((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<int>(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);
}

Some files were not shown because too many files have changed in this diff Show More