diff --git a/README.md b/README.md
index a1f494c33cf1e0..a77a80935d59ae 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
·
Community
·
- Try it on a comma four
+ Try it on a comma 3X
Quick start: `bash <(curl -fsSL openpilot.comma.ai)`
@@ -42,10 +42,10 @@ Using openpilot in a car
------
To use openpilot in a car, you need four things:
-1. **Supported Device:** a comma four, available at [comma.ai/shop/comma-four](https://www.comma.ai/shop/comma-four).
-2. **Software:** The setup procedure for the comma four allows users to enter a URL for custom software. Use the URL `openpilot.comma.ai` to install the release version.
+1. **Supported Device:** a comma 3X, available at [comma.ai/shop](https://comma.ai/shop/comma-3x).
+2. **Software:** The setup procedure for the comma 3X allows users to enter a URL for custom software. Use the URL `openpilot.comma.ai` to install the release version.
3. **Supported Car:** Ensure that you have one of [the 275+ supported cars](docs/CARS.md).
-4. **Car Harness:** You will also need a [car harness](https://comma.ai/shop/car-harness) to connect your comma four to your car.
+4. **Car Harness:** You will also need a [car harness](https://comma.ai/shop/car-harness) to connect your comma 3X to your car.
We have detailed instructions for [how to install the harness and device in a car](https://comma.ai/setup). Note that it's possible to run openpilot on [other hardware](https://blog.comma.ai/self-driving-car-for-free/), although it's not plug-and-play.
diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript
index f5ef0f4393946d..6f1b3014a1ae52 100644
--- a/tools/cabana/SConscript
+++ b/tools/cabana/SConscript
@@ -66,7 +66,7 @@ base_frameworks = qt_env['FRAMEWORKS']
base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"]
if arch == "Darwin":
- base_frameworks += ['QtCharts', 'CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox']
+ base_frameworks.append('QtCharts')
else:
base_libs.append('Qt5Charts')
diff --git a/tools/cabana/cabana b/tools/cabana/cabana
index cd9bf1dd7970ad..0144e6f04013a7 100755
--- a/tools/cabana/cabana
+++ b/tools/cabana/cabana
@@ -33,6 +33,6 @@ fi
# Build _cabana
cd "$ROOT"
-scons -j4 tools/cabana/_cabana cereal/messaging/bridge
+scons -j"$(nproc)" tools/cabana/_cabana cereal/messaging/bridge
exec "$DIR/_cabana" "$@"
diff --git a/tools/jotpluggler/SConscript b/tools/jotpluggler/SConscript
index 122d502341c542..d8aed7535e0341 100644
--- a/tools/jotpluggler/SConscript
+++ b/tools/jotpluggler/SConscript
@@ -1,92 +1,65 @@
import os
+
import imgui
import libusb
-from opendbc import get_generated_dbcs
-from opendbc.car import Bus
-from opendbc.car.fingerprints import MIGRATION
-from opendbc.car.values import PLATFORMS
-from openpilot.common.basedir import BASEDIR
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib')
jot_env = env.Clone()
-jot_env["LIBPATH"] += [imgui.MESA_DIR, libusb.LIB_DIR]
-jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR]
-jot_env["CXXFLAGS"] += [
- "-DGLFW_INCLUDE_NONE",
- '-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR),
+jot_env["LIBPATH"] += [imgui.MESA_DIR]
+jot_env["CPPPATH"] += [imgui.INCLUDE_DIR]
+jot_env["CPPPATH"] += [libusb.INCLUDE_DIR]
+jot_env["LIBPATH"] += [libusb.LIB_DIR]
+jot_env["CXXFLAGS"] += ["-DGLFW_INCLUDE_NONE"]
+repo_root = jot_env.Dir("../..").abspath
+jot_env["CXXFLAGS"] += ['-DJOTP_REPO_ROOT=\'"%s"\'' % repo_root]
+generated_dbc_dir = "tools/jotpluggler/generated_dbcs"
+generated_dbc_stamp = jot_env.Command(
+ f"{generated_dbc_dir}/.stamp",
+ ["materialize_generated_dbcs.py"],
+ f"python3 tools/jotpluggler/materialize_generated_dbcs.py --out {generated_dbc_dir}",
+)
+
+jot_sources = [
+ "main.cc",
+ "app.cc",
+ "app_browser.cc",
+ "app_cabana.cc",
+ "app_cabana_widgets.cc",
+ "app_camera.cc",
+ "app_common.cc",
+ "app_custom_series.cc",
+ "app_layout_flow.cc",
+ "app_layout_io.cc",
+ "app_logs.cc",
+ "app_map.cc",
+ "app_plot.cc",
+ "app_render_flow.cc",
+ "app_runtime.cc",
+ "app_session_flow.cc",
+ "app_sidebar_flow.cc",
+ "app_socketcan.cc",
+ "app_stream_flow.cc",
+ "bootstrap_icons.cc",
+ "dbc_core.cc",
+ "../cabana/panda.cc",
+ "sketch_layout.cc",
]
-def materialize_generated_dbcs(target, source, env):
- out_dir = os.path.dirname(str(target[0]))
- os.makedirs(out_dir, exist_ok=True)
-
- for name in os.listdir(out_dir):
- if name.endswith('.dbc'):
- os.unlink(os.path.join(out_dir, name))
-
- for name, content in sorted(get_generated_dbcs().items()):
- with open(os.path.join(out_dir, f"{name}.dbc"), "w") as f:
- f.write(content)
-
- with open(str(target[0]), "w") as f:
- f.write("ok\n")
-
- return None
-
-def write_car_fingerprint_to_dbc_header(target, source, env):
- pairs = {}
-
- for name, platform in sorted(PLATFORMS.items()):
- dbc = platform.config.dbc_dict.get(Bus.pt, "")
- if not dbc and name.startswith("TESLA_"):
- dbc = platform.config.dbc_dict.get(Bus.party, "")
- if not dbc and name == "COMMA_BODY":
- dbc = "comma_body"
- if dbc and name != "MOCK":
- pairs[name] = dbc
-
- for fingerprint, car in sorted(MIGRATION.items()):
- dbc = pairs.get(str(car), "")
- if dbc:
- pairs[fingerprint] = dbc
-
- lines = [
- "#pragma once",
- "",
- "#include ",
- "#include ",
- "",
- "inline constexpr std::pair kCarFingerprintToDbc[] = {",
- ]
- lines.extend(f' {{"{fingerprint}", "{dbc}"}},' for fingerprint, dbc in sorted(pairs.items()))
- lines.extend([
- "};",
- "",
- "inline std::string_view dbc_for_car_fingerprint(std::string_view fingerprint) {",
- " for (const auto &[car_fingerprint, dbc] : kCarFingerprintToDbc) {",
- " if (car_fingerprint == fingerprint) return dbc;",
- " }",
- " return {};",
- "}",
- "",
- ])
-
- with open(str(target[0]), "w") as f:
- f.write("\n".join(lines))
-
- return None
-
-generated_dbc_stamp = jot_env.Command(f"generated_dbcs/.stamp", [], materialize_generated_dbcs)
-car_fingerprint_to_dbc = jot_env.Command("car_fingerprint_to_dbc.h", [], write_car_fingerprint_to_dbc_header)
+objects = []
+for source in jot_sources:
+ basename = os.path.splitext(os.path.basename(source))[0]
+ objects.append(jot_env.Object(target=f"jot_{basename}", source=source))
-libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"),
+imgui_static = File(f"{imgui.LIB_DIR}/libimgui.a")
+glfw_static = File(f"{imgui.LIB_DIR}/libglfw3.a")
+frameworks = []
+libs = [replay_lib, common, messaging, visionipc, cereal, imgui_static, glfw_static,
"avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "m", "pthread", "usb-1.0"]
if arch == "Darwin":
- jot_env["FRAMEWORKS"] = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"]
+ frameworks = ["OpenGL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"]
else:
libs += ["GL", "dl", "va", "va-drm", "drm"]
-program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs)
+program = jot_env.Program("jotpluggler", objects, LIBS=libs, FRAMEWORKS=frameworks)
jot_env.Depends(program, generated_dbc_stamp)
-jot_env.Depends(program, car_fingerprint_to_dbc)
diff --git a/tools/jotpluggler/app.cc b/tools/jotpluggler/app.cc
index 4b56299ead39e5..497528426891d8 100644
--- a/tools/jotpluggler/app.cc
+++ b/tools/jotpluggler/app.cc
@@ -1,8 +1,8 @@
-#include "tools/jotpluggler/app.h"
-#include "tools/jotpluggler/camera.h"
-#include "tools/jotpluggler/common.h"
-#include "tools/jotpluggler/internal.h"
-#include "tools/jotpluggler/map.h"
+#include "tools/jotpluggler/jotpluggler.h"
+#include "tools/jotpluggler/app_camera.h"
+#include "tools/jotpluggler/app_common.h"
+#include "tools/jotpluggler/app_internal.h"
+#include "tools/jotpluggler/app_map.h"
#include "system/hardware/hw.h"
#include "imgui_impl_glfw.h"
@@ -18,6 +18,9 @@
#include
#include
#include
+#include
+#include
+#include
#include
#include
#include
@@ -29,25 +32,82 @@
#include "third_party/json11/json11.hpp"
-namespace fs = std::filesystem;
-
constexpr const char *UNTITLED_PANE_TITLE = "...";
ImFont *g_ui_font = nullptr;
ImFont *g_ui_bold_font = nullptr;
ImFont *g_mono_font = nullptr;
+const fs::path &repo_root() {
+ static const fs::path root = []() -> fs::path {
+#ifdef JOTP_REPO_ROOT
+ return JOTP_REPO_ROOT;
+#else
+ std::array buf = {};
+ const ssize_t count = readlink("/proc/self/exe", buf.data(), buf.size() - 1);
+ if (count <= 0) throw std::runtime_error("Unable to resolve executable path");
+ return fs::path(std::string(buf.data(), static_cast(count))).parent_path().parent_path().parent_path();
+#endif
+ }();
+ return root;
+}
+
+std::optional jetbrains_mono_font_path() {
+ const char *home = std::getenv("HOME");
+ std::vector candidates;
+ if (home != nullptr) {
+ candidates.push_back(fs::path(home) / ".local/share/fonts/fonts/ttf/JetBrainsMono-Regular.ttf");
+ candidates.push_back(fs::path(home) / ".local/share/fonts/fonts/variable/JetBrainsMono[wght].ttf");
+ }
+ candidates.push_back(fs::path("/usr/share/fonts/truetype/jetbrains-mono/JetBrainsMono-Regular.ttf"));
+ for (const fs::path &candidate : candidates) {
+ if (fs::exists(candidate)) return candidate;
+ }
+ return std::nullopt;
+}
+
+std::optional inter_font_path() {
+ std::vector candidates = {
+ repo_root() / "selfdrive" / "assets" / "fonts" / "Inter-Regular.ttf",
+ repo_root() / "selfdrive" / "ui" / "installer" / "inter-ascii.ttf",
+ };
+ for (const fs::path &candidate : candidates) {
+ if (fs::exists(candidate)) return candidate;
+ }
+ return std::nullopt;
+}
+
+std::optional inter_semibold_font_path() {
+ const fs::path candidate = repo_root() / "selfdrive" / "assets" / "fonts" / "Inter-SemiBold.ttf";
+ if (fs::exists(candidate)) {
+ return candidate;
+ }
+ return std::nullopt;
+}
+
std::string layout_name_from_arg(const std::string &layout_arg) {
const fs::path raw(layout_arg);
if (raw.extension() == ".xml" || raw.extension() == ".json") {
return raw.stem().string();
}
- if (raw.filename() != raw) {
- return raw.filename().replace_extension("").string();
- }
+ if (raw.filename() != raw) return raw.filename().replace_extension("").string();
fs::path stem_path = raw;
return stem_path.replace_extension("").string();
}
+fs::path resolve_layout_path(const std::string &layout_arg) {
+ const fs::path direct(layout_arg);
+ if (fs::exists(direct)) {
+ if (direct.extension() == ".json") return fs::absolute(direct);
+ const fs::path sibling_json = direct.parent_path() / (direct.stem().string() + ".json");
+ if (direct.extension() == ".xml" && fs::exists(sibling_json)) {
+ return fs::absolute(sibling_json);
+ }
+ }
+ const fs::path candidate = repo_root() / "tools" / "jotpluggler" / "layouts" / (layout_name_from_arg(layout_arg) + ".json");
+ if (!fs::exists(candidate)) throw std::runtime_error("Unknown layout: " + layout_arg);
+ return candidate;
+}
+
fs::path layouts_dir() {
return repo_root() / "tools" / "jotpluggler" / "layouts";
}
@@ -79,26 +139,12 @@ fs::path autosave_dir() {
return layouts_dir() / ".jotpluggler_autosave";
}
-fs::path resolve_layout_path(const std::string &layout_arg) {
- const fs::path direct(layout_arg);
- if (fs::exists(direct)) {
- if (direct.extension() == ".json") return fs::absolute(direct);
- const fs::path sibling_json = direct.parent_path() / (direct.stem().string() + ".json");
- if (direct.extension() == ".xml" && fs::exists(sibling_json)) {
- return fs::absolute(sibling_json);
- }
- }
- const fs::path candidate = layouts_dir() / (layout_name_from_arg(layout_arg) + ".json");
- if (!fs::exists(candidate)) throw std::runtime_error("Unknown layout: " + layout_arg);
- return candidate;
-}
-
fs::path autosave_path_for_layout(const fs::path &layout_path) {
const std::string stem = layout_path.empty() ? "untitled" : layout_path.stem().string();
return autosave_dir() / (sanitize_layout_stem(stem) + ".json");
}
-std::vector available_layout_names() {
+std::vector scan_layout_names() {
std::vector names;
const fs::path root = layouts_dir();
if (!fs::exists(root) || !fs::is_directory(root)) {
@@ -114,6 +160,39 @@ std::vector available_layout_names() {
return names;
}
+std::vector available_layout_names() {
+ return scan_layout_names();
+}
+
+void run_or_throw(const std::string &command, const std::string &action) {
+ const int ret = std::system(command.c_str());
+ if (ret != 0) throw std::runtime_error(action + " failed with exit code " + std::to_string(ret));
+}
+
+const char *stream_source_kind_label(StreamSourceKind kind) {
+ switch (kind) {
+ case StreamSourceKind::CerealRemote: return "Remote (ZMQ)";
+ case StreamSourceKind::Panda: return "Panda";
+ case StreamSourceKind::SocketCan: return "SocketCAN";
+ case StreamSourceKind::CerealLocal:
+ default: return "Local (MSGQ)";
+ }
+}
+
+std::string stream_source_target_label(const StreamSourceConfig &source) {
+ switch (source.kind) {
+ case StreamSourceKind::CerealRemote:
+ return source.address.empty() ? std::string("127.0.0.1") : source.address;
+ case StreamSourceKind::Panda:
+ return source.panda.serial.empty() ? std::string("auto") : source.panda.serial;
+ case StreamSourceKind::SocketCan:
+ return source.socketcan.device.empty() ? std::string("can0") : source.socketcan.device;
+ case StreamSourceKind::CerealLocal:
+ default:
+ return "127.0.0.1";
+ }
+}
+
void refresh_replaced_layout_ui(AppSession *session, UiState *state, bool mark_docks) {
state->tabs.clear();
cancel_rename_tab(state);
@@ -136,8 +215,13 @@ void start_new_layout(AppSession *session, UiState *state, const std::string &st
}
bool is_decoded_can_series_path(std::string_view path) {
- const std::string value(path);
- return util::starts_with(value, "/can/") || util::starts_with(value, "/sendcan/");
+ return path.rfind("/can/", 0) == 0 || path.rfind("/sendcan/", 0) == 0;
+}
+
+std::optional parse_can_service_kind(std::string_view service) {
+ if (service == "can") return CanServiceKind::Can;
+ if (service == "sendcan") return CanServiceKind::Sendcan;
+ return std::nullopt;
}
bool apply_route_can_decode_update(AppSession *session, UiState *state);
@@ -145,10 +229,9 @@ bool apply_route_can_decode_update(AppSession *session, UiState *state);
void rebuild_series_lookup_preserving_formats(AppSession *session,
std::string_view updated_prefix,
bool refresh_updated_formats_only) {
- const std::string prefix(updated_prefix);
if (!updated_prefix.empty()) {
for (auto it = session->route_data.series_formats.begin(); it != session->route_data.series_formats.end();) {
- if (util::starts_with(it->first, prefix)) {
+ if (it->first.rfind(updated_prefix, 0) == 0) {
it = session->route_data.series_formats.erase(it);
} else {
++it;
@@ -160,7 +243,7 @@ void rebuild_series_lookup_preserving_formats(AppSession *session,
for (RouteSeries &series : session->route_data.series) {
session->series_by_path.emplace(series.path, &series);
if (refresh_updated_formats_only) {
- if (!updated_prefix.empty() && util::starts_with(series.path, prefix)) {
+ if (!updated_prefix.empty() && series.path.rfind(updated_prefix, 0) == 0) {
const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end();
session->route_data.series_formats[series.path] = compute_series_format(series.values, enum_like);
}
@@ -171,6 +254,84 @@ void rebuild_series_lookup_preserving_formats(AppSession *session,
}
}
+bool apply_route_can_message_decode_update(AppSession *session, UiState *state, const CabanaSignalEditorState &signal) {
+ const std::string active_dbc_name = !session->dbc_override.empty() ? session->dbc_override : session->route_data.dbc_name;
+ if (!active_dbc_name.empty() && !load_dbc_by_name(active_dbc_name).has_value()) {
+ state->error_text = "DBC not found: " + active_dbc_name;
+ state->open_error_popup = true;
+ return false;
+ }
+ const std::optional service = parse_can_service_kind(signal.service);
+ if (!service.has_value()) {
+ return apply_route_can_decode_update(session, state);
+ }
+ const auto message_it = std::find_if(session->route_data.can_messages.begin(), session->route_data.can_messages.end(),
+ [&](const CanMessageData &message) {
+ return message.id.service == *service
+ && message.id.bus == static_cast(signal.bus)
+ && message.id.address == signal.message_address;
+ });
+ if (message_it == session->route_data.can_messages.end()) {
+ return apply_route_can_decode_update(session, state);
+ }
+
+ std::unordered_map message_enum_info;
+ std::vector message_series = decode_can_messages({*message_it}, active_dbc_name, &message_enum_info);
+ const std::string prefix = "/" + signal.service + "/" + std::to_string(signal.bus) + "/" + signal.message_name + "/";
+ const bool paths_changed = signal.creating || signal.original_signal_name != signal.signal_name;
+
+ std::vector updated_series;
+ updated_series.reserve(session->route_data.series.size() + message_series.size());
+ for (RouteSeries &series : session->route_data.series) {
+ if (series.path.rfind(prefix, 0) != 0) {
+ updated_series.push_back(std::move(series));
+ }
+ }
+ for (RouteSeries &series : message_series) {
+ updated_series.push_back(std::move(series));
+ }
+ std::sort(updated_series.begin(), updated_series.end(), [](const RouteSeries &a, const RouteSeries &b) {
+ return a.path < b.path;
+ });
+
+ std::unordered_map updated_enum_info;
+ updated_enum_info.reserve(session->route_data.enum_info.size() + message_enum_info.size());
+ for (auto &[path, info] : session->route_data.enum_info) {
+ if (path.rfind(prefix, 0) != 0) {
+ updated_enum_info.emplace(path, std::move(info));
+ }
+ }
+ for (auto &[path, info] : message_enum_info) {
+ updated_enum_info[path] = std::move(info);
+ }
+
+ session->route_data.series = std::move(updated_series);
+ session->route_data.enum_info = std::move(updated_enum_info);
+ if (paths_changed) {
+ session->route_data.paths.clear();
+ session->route_data.paths.reserve(session->route_data.series.size());
+ for (const RouteSeries &series : session->route_data.series) {
+ session->route_data.paths.push_back(series.path);
+ }
+ std::sort(session->route_data.paths.begin(), session->route_data.paths.end());
+ session->route_data.paths.erase(std::unique(session->route_data.paths.begin(), session->route_data.paths.end()),
+ session->route_data.paths.end());
+ session->route_data.roots = collect_route_roots_for_paths(session->route_data.paths);
+ }
+ rebuild_series_lookup_preserving_formats(session, prefix, true);
+ if (paths_changed) {
+ if (state->view_mode == AppViewMode::Plot) {
+ rebuild_browser_nodes(session, state);
+ state->browser_nodes_dirty = false;
+ } else {
+ state->browser_nodes_dirty = true;
+ }
+ }
+ rebuild_cabana_messages(session);
+ refresh_all_custom_curves(session, state);
+ return true;
+}
+
bool apply_route_can_decode_update(AppSession *session, UiState *state) {
const std::string active_dbc_name = !session->dbc_override.empty() ? session->dbc_override : session->route_data.dbc_name;
if (!active_dbc_name.empty() && !load_dbc_by_name(active_dbc_name).has_value()) {
@@ -218,6 +379,7 @@ bool apply_route_can_decode_update(AppSession *session, UiState *state) {
rebuild_route_index(session);
rebuild_browser_nodes(session, state);
+ rebuild_cabana_messages(session);
refresh_all_custom_curves(session, state);
sync_camera_feeds(session);
return true;
@@ -228,7 +390,10 @@ void apply_dbc_override_change(AppSession *session, UiState *state, const std::s
if (session->data_mode == SessionDataMode::Stream) {
start_stream_session(session, state, session->stream_source, session->stream_buffer_seconds, false);
} else if (!session->route_name.empty()) {
- const bool ok = apply_route_can_decode_update(session, state);
+ const bool can_patch_single_message = state->cabana_signal_editor.loaded && state->cabana_signal_editor.message_address != 0;
+ const bool ok = can_patch_single_message
+ ? apply_route_can_message_decode_update(session, state, state->cabana_signal_editor)
+ : apply_route_can_decode_update(session, state);
if (ok) {
state->status_text = dbc_override.empty() ? "DBC auto-detect enabled" : "DBC set to " + dbc_override;
} else {
@@ -249,7 +414,9 @@ void configure_style() {
g_ui_font = nullptr;
g_ui_bold_font = nullptr;
g_mono_font = nullptr;
- const fs::path fonts_dir = repo_root() / "selfdrive" / "assets" / "fonts";
+ const std::optional ui_font_path = inter_font_path();
+ const std::optional ui_bold_font_path = inter_semibold_font_path();
+ const std::optional mono_font_path = jetbrains_mono_font_path();
ImFontConfig font_cfg;
font_cfg.OversampleH = 2;
font_cfg.OversampleV = 2;
@@ -258,23 +425,27 @@ void configure_style() {
const auto add_font_with_icons = [&](const fs::path &path, float size) -> ImFont * {
ImFont *font = io.Fonts->AddFontFromFileTTF(path.c_str(), size, &font_cfg);
if (font != nullptr) {
- icon_add_font(size, true, font);
+ icon_add_font(size, true);
}
return font;
};
- if (ImFont *font = add_font_with_icons(fonts_dir / "Inter-Regular.ttf", 16.0f); font != nullptr) {
- g_ui_font = font;
- io.FontDefault = font;
+ if (ui_font_path.has_value()) {
+ if (ImFont *font = add_font_with_icons(*ui_font_path, 16.0f); font != nullptr) {
+ g_ui_font = font;
+ io.FontDefault = font;
+ }
+ }
+ if (ui_bold_font_path.has_value()) {
+ g_ui_bold_font = add_font_with_icons(*ui_bold_font_path, 16.75f);
}
- g_ui_bold_font = add_font_with_icons(fonts_dir / "Inter-SemiBold.ttf", 16.75f);
- if (g_ui_font == nullptr) {
- if (ImFont *font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f); font != nullptr) {
+ if (g_ui_font == nullptr && mono_font_path.has_value()) {
+ if (ImFont *font = add_font_with_icons(*mono_font_path, 15.75f); font != nullptr) {
g_mono_font = font;
io.FontDefault = font;
}
}
- if (g_mono_font == nullptr) {
- g_mono_font = add_font_with_icons(fonts_dir / "JetBrainsMono-Medium.ttf", 15.75f);
+ if (g_mono_font == nullptr && mono_font_path.has_value()) {
+ g_mono_font = add_font_with_icons(*mono_font_path, 15.75f);
}
if (g_ui_bold_font == nullptr) {
g_ui_bold_font = g_ui_font;
@@ -375,6 +546,16 @@ UiMetrics compute_ui_metrics(const ImVec2 &size, float top_offset, float sidebar
return ui;
}
+template
+void copy_to_buffer(const std::string &value, std::array *buffer) {
+ buffer->fill('\0');
+ if constexpr (N > 0) {
+ const size_t count = std::min(value.size(), N - 1);
+ std::copy_n(value.data(), count, buffer->data());
+ (*buffer)[count] = '\0';
+ }
+}
+
void sync_ui_state(UiState *state, const SketchLayout &layout) {
const bool initializing = state->tabs.empty();
state->tabs.resize(layout.tabs.size());
@@ -408,13 +589,20 @@ void resize_tab_pane_state(TabUiState *tab_state, size_t pane_count) {
}
void sync_route_buffers(UiState *state, const AppSession &session) {
- state->route_buffer = session.route_name;
- state->data_dir_buffer = session.data_dir;
+ copy_to_buffer(session.route_name, &state->route_buffer);
+ copy_to_buffer(session.data_dir, &state->data_dir_buffer);
}
void sync_stream_buffers(UiState *state, const AppSession &session) {
- state->stream_address_buffer = session.stream_source.address;
+ copy_to_buffer(session.stream_source.address, &state->stream_address_buffer);
+ copy_to_buffer(session.stream_source.panda.serial, &state->panda_serial_buffer);
+ copy_to_buffer(session.stream_source.socketcan.device, &state->socketcan_device_buffer);
state->stream_source_kind = session.stream_source.kind;
+ for (size_t i = 0; i < session.stream_source.panda.buses.size(); ++i) {
+ state->panda_can_speed_kbps[i] = session.stream_source.panda.buses[i].can_speed_kbps;
+ state->panda_data_speed_kbps[i] = session.stream_source.panda.buses[i].data_speed_kbps;
+ state->panda_can_fd[i] = session.stream_source.panda.buses[i].can_fd;
+ }
state->stream_buffer_seconds = session.stream_buffer_seconds;
}
@@ -423,8 +611,8 @@ fs::path default_layout_save_path(const AppSession &session) {
}
void sync_layout_buffers(UiState *state, const AppSession &session) {
- state->load_layout_buffer = session.layout_path.empty() ? std::string() : session.layout_path.string();
- state->save_layout_buffer = default_layout_save_path(session).string();
+ copy_to_buffer(session.layout_path.empty() ? std::string() : session.layout_path.string(), &state->load_layout_buffer);
+ copy_to_buffer(default_layout_save_path(session).string(), &state->save_layout_buffer);
}
const WorkspaceTab *app_active_tab(const SketchLayout &layout, const UiState &state) {
@@ -447,11 +635,15 @@ TabUiState *app_active_tab_state(UiState *state) {
std::string pane_window_name(int tab_runtime_id, int pane_index, const Pane &pane) {
const char *title = pane.title.empty() ? UNTITLED_PANE_TITLE : pane.title.c_str();
- return util::string_format("%s###tab%d_pane%d", title, tab_runtime_id, pane_index);
+ char buf[256];
+ std::snprintf(buf, sizeof(buf), "%s###tab%d_pane%d", title, tab_runtime_id, pane_index);
+ return buf;
}
std::string tab_item_label(const WorkspaceTab &tab, int tab_runtime_id) {
- return util::string_format("%s##workspace_tab_%d", tab.tab_name.c_str(), tab_runtime_id);
+ char buf[256];
+ std::snprintf(buf, sizeof(buf), "%s##workspace_tab_%d", tab.tab_name.c_str(), tab_runtime_id);
+ return buf;
}
void request_tab_selection(UiState *state, int tab_index) {
@@ -463,7 +655,7 @@ void begin_rename_tab(const SketchLayout &layout, UiState *state, int tab_index)
if (tab_index < 0 || tab_index >= static_cast(layout.tabs.size())) {
return;
}
- state->rename_tab_buffer = layout.tabs[static_cast(tab_index)].tab_name;
+ copy_to_buffer(layout.tabs[static_cast(tab_index)].tab_name, &state->rename_tab_buffer);
state->rename_tab_index = tab_index;
state->focus_rename_tab_input = true;
request_tab_selection(state, tab_index);
@@ -475,7 +667,9 @@ void cancel_rename_tab(UiState *state) {
}
ImGuiID dockspace_id_for_tab(int tab_runtime_id) {
- return ImHashStr(util::string_format("jotpluggler_dockspace_%d", tab_runtime_id).c_str());
+ char buf[48];
+ std::snprintf(buf, sizeof(buf), "jotpluggler_dockspace_%d", tab_runtime_id);
+ return ImHashStr(buf);
}
bool curve_has_local_samples(const Curve &curve) {
@@ -640,7 +834,7 @@ std::string next_tab_name(const SketchLayout &layout, const std::string &base_na
if (base_name == "tab" || base_name == "tab1") {
int max_suffix = 0;
for (const WorkspaceTab &tab : layout.tabs) {
- if (tab.tab_name.size() > 3 && util::starts_with(tab.tab_name, "tab")) {
+ if (tab.tab_name.size() > 3 && tab.tab_name.rfind("tab", 0) == 0) {
const std::string suffix = tab.tab_name.substr(3);
if (!suffix.empty() && std::all_of(suffix.begin(), suffix.end(), ::isdigit)) {
max_suffix = std::max(max_suffix, std::stoi(suffix));
@@ -692,7 +886,7 @@ bool active_tab_has_map_pane(const SketchLayout &layout) {
const int tab_index = std::clamp(layout.current_tab_index, 0, static_cast(layout.tabs.size()) - 1);
const WorkspaceTab &tab = layout.tabs[static_cast(tab_index)];
return std::any_of(tab.panes.begin(), tab.panes.end(), [](const Pane &pane) {
- return pane_kind_is_special(pane.kind);
+ return pane_is_special(pane);
});
}
@@ -815,11 +1009,7 @@ void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool
}
ImGui::SeparatorText("Layout");
ImGui::SetNextItemWidth(-FLT_MIN);
- const std::string layout_combo_label = [&] {
- const std::string base = session->layout_path.empty() ? std::string("untitled") : session->layout_path.stem().string();
- return state->layout_dirty ? base + " *" : base;
- }();
- if (ImGui::BeginCombo("##layout_combo", layout_combo_label.c_str())) {
+ if (ImGui::BeginCombo("##layout_combo", layout_combo_label(*session, *state).c_str())) {
if (ImGui::Selectable("New Layout")) {
start_new_layout(session, state);
}
@@ -857,7 +1047,7 @@ void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool
ImGui::SeparatorText("Data Sources");
ImGui::SetNextItemWidth(-FLT_MIN);
- input_text_with_hint_string("##browser_filter", "Search...", &state->browser_filter);
+ ImGui::InputTextWithHint("##browser_filter", "Search...", state->browser_filter.data(), state->browser_filter.size());
const float footer_height = ImGui::GetFrameHeightWithSpacing()
+ ImGui::GetTextLineHeightWithSpacing()
+ 16.0f
@@ -866,12 +1056,12 @@ void draw_sidebar(AppSession *session, const UiMetrics &ui, UiState *state, bool
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 2.0f));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8.0f, 3.0f));
if (ImGui::BeginChild("##timeseries_browser", ImVec2(0.0f, browser_height), true)) {
- const std::string filter = lowercase_copy(state->browser_filter);
+ const std::string filter = lowercase(state->browser_filter.data());
std::vector visible_paths;
for (const BrowserNode &node : session->browser_nodes) {
collect_visible_leaf_paths(node, filter, &visible_paths);
}
- for (const SpecialItemSpec &spec : kSpecialItemSpecs) {
+ for (const SpecialItemSpec &spec : special_item_specs()) {
draw_browser_special_item(spec.id, spec.label);
}
ImGui::Dummy(ImVec2(0.0f, 2.0f));
@@ -1013,13 +1203,17 @@ bool app_add_curve_to_active_pane(AppSession *session, UiState *state, const std
return add_path_curve_to_pane(session, state, tab_state->active_pane_index, path);
}
+bool pane_is_empty_for_special_item(const Pane &pane) {
+ return pane.kind == PaneKind::Plot && pane.curves.empty();
+}
+
bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pane_index, std::string_view item_id) {
if (tab == nullptr || tab_state == nullptr) return false;
if (pane_index < 0 || pane_index >= static_cast(tab->panes.size())) return false;
const SpecialItemSpec *spec = special_item_spec(item_id);
if (spec == nullptr) return false;
Pane &pane = tab->panes[static_cast(pane_index)];
- if (!((pane.kind == PaneKind::Plot && pane.curves.empty()) || pane_kind_is_special(pane.kind))) {
+ if (!(pane_is_empty_for_special_item(pane) || pane_kind_is_special(pane.kind))) {
return false;
}
if (pane.kind == spec->kind && (spec->kind != PaneKind::Camera || pane.camera_view == spec->camera_view)) {
@@ -1148,7 +1342,7 @@ void rename_runtime_tab(SketchLayout *layout, UiState *state) {
if (state->rename_tab_index < 0 || state->rename_tab_index >= static_cast(layout->tabs.size())) {
return;
}
- layout->tabs[static_cast(state->rename_tab_index)].tab_name = state->rename_tab_buffer;
+ layout->tabs[static_cast(state->rename_tab_index)].tab_name = state->rename_tab_buffer.data();
state->status_text = "Renamed tab";
layout->current_tab_index = state->rename_tab_index;
cancel_rename_tab(state);
@@ -1169,9 +1363,10 @@ void draw_inline_tab_editor(AppSession *session, UiState *state, const ImRect &t
ImGui::SetKeyboardFocusHere();
state->focus_rename_tab_input = false;
}
- const bool submitted = input_text_string("##rename_tab_inline",
- &state->rename_tab_buffer,
- ImGuiInputTextFlags_AutoSelectAll | ImGuiInputTextFlags_EnterReturnsTrue);
+ const bool submitted = ImGui::InputText("##rename_tab_inline",
+ state->rename_tab_buffer.data(),
+ state->rename_tab_buffer.size(),
+ ImGuiInputTextFlags_AutoSelectAll | ImGuiInputTextFlags_EnterReturnsTrue);
const bool active = ImGui::IsItemActive();
const bool escape = active && ImGui::IsKeyPressed(ImGuiKey_Escape);
const bool deactivated = ImGui::IsItemDeactivated();
@@ -1250,7 +1445,7 @@ std::optional draw_pane_drop_target(int tab_index, int pane_inde
return deliver(std::move(action));
}
}
- if (zone.zone != PaneDropZone::Center || (target_pane.kind == PaneKind::Plot && target_pane.curves.empty()) || pane_kind_is_special(target_pane.kind)) {
+ if (zone.zone != PaneDropZone::Center || pane_is_empty_for_special_item(target_pane) || pane_kind_is_special(target_pane.kind)) {
if (const ImGuiPayload *p = try_accept("JOTP_SPECIAL_ITEM"); p && p->Delivery) {
PaneDropAction action;
action.special_item_id = static_cast(p->Data);
@@ -1384,9 +1579,8 @@ bool apply_pane_drop_action(AppSession *session, UiState *state, const PaneDropA
if (action.target_pane_index < 0 || action.target_pane_index >= static_cast(tab->panes.size())) {
return false;
}
- if (!((tab->panes[static_cast(action.target_pane_index)].kind == PaneKind::Plot
- && tab->panes[static_cast(action.target_pane_index)].curves.empty())
- || pane_kind_is_special(tab->panes[static_cast(action.target_pane_index)].kind))) {
+ if (!(pane_is_empty_for_special_item(tab->panes[static_cast(action.target_pane_index)])
+ || pane_kind_is_special(tab->panes[static_cast(action.target_pane_index)].kind))) {
state->status_text = std::string(special_item_label(action.special_item_id)) + " can only replace another special pane or use an empty pane";
return false;
}
@@ -1834,6 +2028,7 @@ int run(const Options &options) {
.layout = options.layout.empty() ? make_empty_layout() : load_sketch_layout(layout_path),
};
UiState ui_state;
+ ui_state.start_cabana = options.start_cabana;
if (!layout_path.empty() && !session.autosave_path.empty() && fs::exists(session.autosave_path)) {
session.layout = load_sketch_layout(session.autosave_path);
ui_state.layout_dirty = true;
diff --git a/tools/jotpluggler/app_browser.cc b/tools/jotpluggler/app_browser.cc
new file mode 100644
index 00000000000000..5773df0e2ba484
--- /dev/null
+++ b/tools/jotpluggler/app_browser.cc
@@ -0,0 +1,465 @@
+#include "tools/jotpluggler/jotpluggler.h"
+
+#include "imgui_internal.h"
+
+#include
+#include
+#include
+
+namespace {
+
+constexpr float BROWSER_VALUE_WIDTH = 88.0f;
+
+bool path_matches_filter(const std::string &path, const std::string &lower_filter) {
+ if (lower_filter.empty()) return true;
+ return lowercase(path).find(lower_filter) != std::string::npos;
+}
+
+void insert_browser_path(std::vector *nodes, const std::string &path) {
+ size_t start = 0;
+ while (start < path.size() && path[start] == '/') {
+ ++start;
+ }
+ std::vector parts;
+ while (start < path.size()) {
+ const size_t end = path.find('/', start);
+ parts.push_back(path.substr(start, end == std::string::npos ? std::string::npos : end - start));
+ if (end == std::string::npos) break;
+ start = end + 1;
+ }
+ if (parts.empty()) {
+ return;
+ }
+
+ std::vector *current_nodes = nodes;
+ std::string current_path;
+ for (size_t i = 0; i < parts.size(); ++i) {
+ if (!current_path.empty()) {
+ current_path += "/";
+ }
+ current_path += parts[i];
+ auto it = std::find_if(current_nodes->begin(), current_nodes->end(),
+ [&](const BrowserNode &node) { return node.label == parts[i]; });
+ if (it == current_nodes->end()) {
+ current_nodes->push_back(BrowserNode{.label = parts[i]});
+ it = std::prev(current_nodes->end());
+ }
+ if (i + 1 == parts.size()) {
+ it->full_path = "/" + current_path;
+ }
+ current_nodes = &it->children;
+ }
+}
+
+void sort_browser_nodes(std::vector *nodes) {
+ std::sort(nodes->begin(), nodes->end(), [](const BrowserNode &a, const BrowserNode &b) {
+ if (a.children.empty() != b.children.empty()) {
+ return !a.children.empty();
+ }
+ return a.label < b.label;
+ });
+ for (BrowserNode &node : *nodes) {
+ sort_browser_nodes(&node.children);
+ }
+}
+
+std::vector build_browser_tree(const std::vector &paths) {
+ std::vector nodes;
+ for (const std::string &path : paths) {
+ insert_browser_path(&nodes, path);
+ }
+ sort_browser_nodes(&nodes);
+ return nodes;
+}
+
+bool is_deprecated_browser_path(const std::string &path) {
+ return path.find("DEPRECATED") != std::string::npos;
+}
+
+std::vector visible_browser_paths(const RouteData &route_data, bool show_deprecated_fields) {
+ if (show_deprecated_fields) return route_data.paths;
+ std::vector filtered;
+ filtered.reserve(route_data.paths.size());
+ for (const std::string &path : route_data.paths) {
+ if (!is_deprecated_browser_path(path)) {
+ filtered.push_back(path);
+ }
+ }
+ return filtered;
+}
+
+bool browser_selection_contains(const UiState &state, std::string_view path) {
+ return std::find(state.selected_browser_paths.begin(), state.selected_browser_paths.end(), path)
+ != state.selected_browser_paths.end();
+}
+
+std::vector browser_drag_paths(const UiState &state, const std::string &dragged_path) {
+ if (browser_selection_contains(state, dragged_path) && !state.selected_browser_paths.empty()) {
+ return state.selected_browser_paths;
+ }
+ return {dragged_path};
+}
+
+std::string encode_browser_drag_payload(const std::vector &paths) {
+ std::string payload;
+ for (size_t i = 0; i < paths.size(); ++i) {
+ if (i != 0) {
+ payload.push_back('\n');
+ }
+ payload += paths[i];
+ }
+ return payload;
+}
+
+void set_browser_selection_single(UiState *state, const std::string &path) {
+ state->selected_browser_paths = {path};
+ state->selected_browser_path = path;
+ state->browser_selection_anchor = path;
+}
+
+void toggle_browser_selection(UiState *state, const std::string &path) {
+ auto it = std::find(state->selected_browser_paths.begin(), state->selected_browser_paths.end(), path);
+ if (it == state->selected_browser_paths.end()) {
+ state->selected_browser_paths.push_back(path);
+ } else {
+ state->selected_browser_paths.erase(it);
+ }
+ state->selected_browser_path = path;
+ state->browser_selection_anchor = path;
+ if (state->selected_browser_paths.empty()) {
+ state->selected_browser_path.clear();
+ }
+}
+
+void select_browser_range(UiState *state, const std::vector &visible_paths, const std::string &clicked_path) {
+ if (visible_paths.empty()) {
+ set_browser_selection_single(state, clicked_path);
+ return;
+ }
+
+ const std::string anchor = state->browser_selection_anchor.empty() ? clicked_path : state->browser_selection_anchor;
+ const auto anchor_it = std::find(visible_paths.begin(), visible_paths.end(), anchor);
+ const auto clicked_it = std::find(visible_paths.begin(), visible_paths.end(), clicked_path);
+ if (clicked_it == visible_paths.end()) {
+ return;
+ }
+ if (anchor_it == visible_paths.end()) {
+ set_browser_selection_single(state, clicked_path);
+ return;
+ }
+
+ const auto [begin_it, end_it] = std::minmax(anchor_it, clicked_it);
+ std::vector selected;
+ selected.reserve(static_cast(std::distance(begin_it, end_it)) + 1);
+ for (auto it = begin_it; it != end_it + 1; ++it) {
+ selected.push_back(*it);
+ }
+ state->selected_browser_paths = std::move(selected);
+ state->selected_browser_path = clicked_path;
+}
+
+void prune_browser_selection(UiState *state, const std::vector &visible_paths) {
+ const std::unordered_set visible_set(visible_paths.begin(), visible_paths.end());
+ auto is_visible = [&](const std::string &path) {
+ return visible_set.count(path) > 0;
+ };
+
+ state->selected_browser_paths.erase(
+ std::remove_if(state->selected_browser_paths.begin(), state->selected_browser_paths.end(),
+ [&](const std::string &path) { return !is_visible(path); }),
+ state->selected_browser_paths.end());
+
+ if (!state->selected_browser_path.empty() && !is_visible(state->selected_browser_path)) {
+ state->selected_browser_path.clear();
+ }
+ if (!state->browser_selection_anchor.empty() && !is_visible(state->browser_selection_anchor)) {
+ state->browser_selection_anchor.clear();
+ }
+ if (state->selected_browser_paths.empty()) {
+ state->selected_browser_path.clear();
+ } else if (state->selected_browser_path.empty()) {
+ state->selected_browser_path = state->selected_browser_paths.back();
+ }
+}
+
+std::optional sample_route_series_value(const RouteSeries &series, double tm, bool stairs) {
+ return app_sample_xy_value_at_time(series.times, series.values, stairs, tm);
+}
+
+std::string browser_series_value_text(const AppSession &session, const UiState &state, std::string_view path) {
+ auto it = session.series_by_path.find(std::string(path));
+ if (it == session.series_by_path.end() || it->second == nullptr) return {};
+
+ const RouteSeries &series = *it->second;
+ if (series.values.empty()) return {};
+
+ const auto enum_it = session.route_data.enum_info.find(series.path);
+ const EnumInfo *enum_info = enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second;
+ const bool stairs = enum_info != nullptr;
+
+ std::optional value;
+ if (state.has_tracker_time) {
+ value = sample_route_series_value(series, state.tracker_time, stairs);
+ } else {
+ value = series.values.back();
+ }
+ if (!value.has_value()) return {};
+
+ const auto display_it = session.route_data.series_formats.find(series.path);
+ const SeriesFormat display_info = display_it == session.route_data.series_formats.end()
+ ? compute_series_format(series.values, enum_info != nullptr)
+ : display_it->second;
+
+ return format_display_value(*value, display_info, enum_info);
+}
+
+bool browser_node_matches(const BrowserNode &node, const std::string &filter) {
+ if (filter.empty()) return true;
+ if (!node.full_path.empty() && path_matches_filter(node.full_path, filter)) {
+ return true;
+ }
+ for (const BrowserNode &child : node.children) {
+ if (browser_node_matches(child, filter)) return true;
+ }
+ return false;
+}
+
+} // namespace
+
+namespace {
+
+int decimals_needed(double value) {
+ const double abs_value = std::abs(value);
+ if (abs_value < 1.0e-12) return 0;
+ for (int decimals = 0; decimals <= 6; ++decimals) {
+ const double scale = std::pow(10.0, decimals);
+ if (std::abs(abs_value * scale - std::round(abs_value * scale)) < 1.0e-6) {
+ return decimals;
+ }
+ }
+ return 6;
+}
+
+void finalize_series_format(SeriesFormat *format) {
+ format->digits_before = std::max(format->digits_before, 1);
+ format->decimals = std::clamp(format->decimals, 0, 6);
+ format->integer_like = format->decimals == 0;
+ const int sign_width = format->has_negative ? 1 : 0;
+ const int dot_width = format->decimals > 0 ? 1 : 0;
+ format->total_width = sign_width + format->digits_before + dot_width + format->decimals;
+ std::snprintf(format->fmt, sizeof(format->fmt), "%%%d.%df", format->total_width, format->decimals);
+}
+
+} // namespace
+
+SeriesFormat compute_series_format(const std::vector &values, bool enum_like) {
+ SeriesFormat format;
+ if (values.empty()) return format;
+
+ const size_t step = std::max(1, values.size() / 256);
+ bool saw_finite = false;
+ bool all_integer = enum_like;
+ double min_value = 0.0;
+ double max_value = 0.0;
+ int max_needed_decimals = 0;
+
+ for (size_t i = 0; i < values.size(); i += step) {
+ const double value = values[i];
+ if (!std::isfinite(value)) continue;
+ if (!saw_finite) {
+ min_value = value;
+ max_value = value;
+ saw_finite = true;
+ } else {
+ min_value = std::min(min_value, value);
+ max_value = std::max(max_value, value);
+ }
+ if (std::abs(value - std::round(value)) > 1.0e-9) {
+ all_integer = false;
+ }
+ if (!all_integer) {
+ max_needed_decimals = std::max(max_needed_decimals, decimals_needed(value));
+ }
+ }
+
+ if (!saw_finite) return format;
+
+ format.has_negative = min_value < 0.0;
+ const double peak = std::max(std::abs(min_value), std::abs(max_value));
+ format.digits_before = peak < 1.0 ? 1 : static_cast(std::floor(std::log10(peak))) + 1;
+
+ if (enum_like || all_integer) {
+ format.decimals = 0;
+ } else if (peak >= 1000.0) {
+ format.decimals = std::min(max_needed_decimals, 1);
+ } else if (peak >= 100.0) {
+ format.decimals = std::min(max_needed_decimals, 2);
+ } else {
+ format.decimals = std::min(max_needed_decimals, 4);
+ }
+
+ finalize_series_format(&format);
+ return format;
+}
+
+std::string format_display_value(double display_value,
+ const SeriesFormat &display_info,
+ const EnumInfo *enum_info) {
+ if (!std::isfinite(display_value)) return "---";
+ if (enum_info != nullptr) {
+ const int idx = static_cast(std::llround(display_value));
+ if (idx >= 0 && std::abs(display_value - static_cast(idx)) < 0.01
+ && static_cast(idx) < enum_info->names.size()
+ && !enum_info->names[static_cast(idx)].empty()) {
+ return enum_info->names[static_cast(idx)];
+ }
+ }
+ char buf[64] = {};
+ std::snprintf(buf, sizeof(buf), display_info.fmt, display_value);
+ return buf;
+}
+
+std::vector decode_browser_drag_payload(std::string_view payload) {
+ std::vector out;
+ size_t begin = 0;
+ while (begin <= payload.size()) {
+ const size_t end = payload.find('\n', begin);
+ const size_t length = (end == std::string_view::npos ? payload.size() : end) - begin;
+ if (length > 0) {
+ out.emplace_back(payload.substr(begin, length));
+ }
+ if (end == std::string_view::npos) break;
+ begin = end + 1;
+ }
+ return out;
+}
+
+void collect_visible_leaf_paths(const BrowserNode &node,
+ const std::string &filter,
+ std::vector *out) {
+ if (!browser_node_matches(node, filter)) {
+ return;
+ }
+ if (node.children.empty()) {
+ if (!node.full_path.empty()) {
+ out->push_back(node.full_path);
+ }
+ return;
+ }
+ for (const BrowserNode &child : node.children) {
+ collect_visible_leaf_paths(child, filter, out);
+ }
+}
+
+void rebuild_browser_nodes(AppSession *session, UiState *state) {
+ const std::vector paths = visible_browser_paths(session->route_data, state->show_deprecated_fields);
+ session->browser_nodes = build_browser_tree(paths);
+ prune_browser_selection(state, paths);
+}
+
+void rebuild_route_index(AppSession *session) {
+ session->series_by_path.clear();
+ session->route_data.series_formats.clear();
+ for (RouteSeries &series : session->route_data.series) {
+ session->series_by_path.emplace(series.path, &series);
+ const bool enum_like = session->route_data.enum_info.find(series.path) != session->route_data.enum_info.end();
+ session->route_data.series_formats.emplace(series.path, compute_series_format(series.values, enum_like));
+ }
+}
+
+void draw_browser_node(AppSession *session,
+ const BrowserNode &node,
+ UiState *state,
+ const std::string &filter,
+ const std::vector &visible_paths) {
+ if (!browser_node_matches(node, filter)) {
+ return;
+ }
+
+ if (node.children.empty()) {
+ const bool selected = browser_selection_contains(*state, node.full_path);
+ const std::string value_text = browser_series_value_text(*session, *state, node.full_path);
+ const ImGuiStyle &style = ImGui::GetStyle();
+ const ImVec2 row_size(std::max(1.0f, ImGui::GetContentRegionAvail().x), ImGui::GetFrameHeight());
+ ImGui::PushID(node.full_path.c_str());
+ const bool clicked = ImGui::InvisibleButton("##browser_leaf", row_size);
+ const bool hovered = ImGui::IsItemHovered();
+ const bool held = ImGui::IsItemActive();
+ const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax());
+ ImDrawList *draw_list = ImGui::GetWindowDrawList();
+ if (selected || hovered) {
+ const ImU32 bg = ImGui::GetColorU32(selected
+ ? (held ? ImGuiCol_HeaderActive : ImGuiCol_Header)
+ : ImGuiCol_HeaderHovered);
+ draw_list->AddRectFilled(rect.Min, rect.Max, bg, 0.0f);
+ }
+
+ const float value_right = rect.Max.x - style.FramePadding.x;
+ const float value_left = value_right - (value_text.empty() ? 0.0f : BROWSER_VALUE_WIDTH);
+ const float label_left = rect.Min.x + style.FramePadding.x;
+ const float label_right = value_text.empty()
+ ? rect.Max.x - style.FramePadding.x
+ : std::max(label_left + 40.0f, value_left - 10.0f);
+ ImGui::RenderTextEllipsis(draw_list,
+ ImVec2(label_left, rect.Min.y + style.FramePadding.y),
+ ImVec2(label_right, rect.Max.y),
+ label_right,
+ node.label.c_str(),
+ nullptr,
+ nullptr);
+ if (!value_text.empty()) {
+ app_push_mono_font();
+ ImGui::PushStyleColor(ImGuiCol_Text, selected ? color_rgb(70, 77, 86) : color_rgb(116, 124, 133));
+ ImGui::RenderTextClipped(ImVec2(value_left, rect.Min.y + style.FramePadding.y),
+ ImVec2(value_right, rect.Max.y),
+ value_text.c_str(),
+ nullptr,
+ nullptr,
+ ImVec2(1.0f, 0.0f));
+ ImGui::PopStyleColor();
+ app_pop_mono_font();
+ }
+
+ if (clicked) {
+ const bool shift_down = ImGui::GetIO().KeyShift;
+ const bool ctrl_down = ImGui::GetIO().KeyCtrl || ImGui::GetIO().KeySuper;
+ if (shift_down) {
+ select_browser_range(state, visible_paths, node.full_path);
+ } else if (ctrl_down) {
+ toggle_browser_selection(state, node.full_path);
+ } else {
+ set_browser_selection_single(state, node.full_path);
+ }
+ }
+ if (hovered && ImGui::IsMouseDoubleClicked(0)) {
+ set_browser_selection_single(state, node.full_path);
+ app_add_curve_to_active_pane(session, state, node.full_path);
+ }
+ if (ImGui::BeginDragDropSource(ImGuiDragDropFlags_SourceAllowNullID)) {
+ const std::vector drag_paths = browser_drag_paths(*state, node.full_path);
+ const std::string payload = encode_browser_drag_payload(drag_paths);
+ ImGui::SetDragDropPayload("JOTP_BROWSER_PATHS", payload.c_str(), payload.size() + 1);
+ if (drag_paths.size() == 1) {
+ ImGui::TextUnformatted(drag_paths.front().c_str());
+ } else {
+ ImGui::Text("%zu timeseries", drag_paths.size());
+ ImGui::TextUnformatted(drag_paths.front().c_str());
+ }
+ ImGui::EndDragDropSource();
+ }
+ ImGui::PopID();
+ return;
+ }
+
+ ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_SpanAvailWidth;
+ if (!filter.empty()) {
+ flags |= ImGuiTreeNodeFlags_DefaultOpen;
+ }
+ const bool open = ImGui::TreeNodeEx(node.label.c_str(), flags);
+ if (open) {
+ for (const BrowserNode &child : node.children) {
+ draw_browser_node(session, child, state, filter, visible_paths);
+ }
+ ImGui::TreePop();
+ }
+}
diff --git a/tools/jotpluggler/app_cabana.cc b/tools/jotpluggler/app_cabana.cc
new file mode 100644
index 00000000000000..ad838b774ea293
--- /dev/null
+++ b/tools/jotpluggler/app_cabana.cc
@@ -0,0 +1,2507 @@
+#include "tools/jotpluggler/app_internal.h"
+
+#include "implot.h"
+#include "imgui_internal.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+constexpr float kSplitterThickness = 4.0f;
+constexpr float kMinMessagesWidth = 210.0f;
+constexpr float kMinCenterWidth = 240.0f;
+constexpr float kMinRightWidth = 260.0f;
+constexpr float kMinTopHeight = 140.0f;
+constexpr float kMinBottomHeight = 120.0f;
+constexpr std::array, 8> kSignalHighlightColors = {{
+ {102, 86, 169},
+ {69, 137, 255},
+ {55, 171, 112},
+ {232, 171, 44},
+ {198, 89, 71},
+ {92, 155, 181},
+ {134, 172, 79},
+ {150, 112, 63},
+}};
+
+std::optional parse_can_service_kind(std::string_view service) {
+ if (service == "can") return CanServiceKind::Can;
+ if (service == "sendcan") return CanServiceKind::Sendcan;
+ return std::nullopt;
+}
+
+const char *can_service_name(CanServiceKind service) {
+ return service == CanServiceKind::Can ? "can" : "sendcan";
+}
+
+std::string format_can_address(uint32_t address) {
+ char text[32];
+ std::snprintf(text, sizeof(text), "0x%X", address);
+ return text;
+}
+
+std::string cabana_message_id_label(const CabanaMessageSummary &message) {
+ char text[32];
+ std::snprintf(text, sizeof(text), "%d:%X", message.bus, message.address);
+ return text;
+}
+
+std::string can_message_key(CanServiceKind service, uint8_t bus, uint32_t address) {
+ return "/" + std::string(can_service_name(service)) + "/" + std::to_string(bus) + "/" + format_can_address(address);
+}
+
+int cabana_flip_bit_pos(int bit_pos) {
+ return 8 * (bit_pos / 8) + 7 - (bit_pos % 8);
+}
+
+int cabana_visual_index(size_t byte_index, int bit_index) {
+ return static_cast(byte_index) * 8 + (7 - bit_index);
+}
+
+std::string sanitize_filename_component(std::string_view text) {
+ std::string out;
+ out.reserve(text.size());
+ for (char c : text) {
+ if (std::isalnum(static_cast(c)) || c == '-' || c == '_') {
+ out.push_back(c);
+ } else {
+ out.push_back('_');
+ }
+ }
+ return out.empty() ? "untitled" : out;
+}
+
+fs::path cabana_export_dir() {
+ const char *home = std::getenv("HOME");
+ fs::path root = home != nullptr ? fs::path(home) : fs::current_path();
+ const fs::path downloads = root / "Downloads";
+ return (fs::exists(downloads) ? downloads : root) / "jotpluggler_exports";
+}
+
+std::string csv_escape(std::string_view text) {
+ std::string out;
+ out.reserve(text.size() + 2);
+ out.push_back('"');
+ for (char c : text) {
+ if (c == '"') out.push_back('"');
+ out.push_back(c);
+ }
+ out.push_back('"');
+ return out;
+}
+
+std::string payload_hex(const std::string &data) {
+ static constexpr char kHex[] = "0123456789ABCDEF";
+ std::string out;
+ out.reserve(data.size() * 2);
+ for (unsigned char byte : data) {
+ out.push_back(kHex[byte >> 4]);
+ out.push_back(kHex[byte & 0xF]);
+ }
+ return out;
+}
+
+fs::path cabana_export_path(const AppSession &session,
+ const CabanaMessageSummary &message,
+ std::string_view kind) {
+ const std::string route_part = sanitize_filename_component(session.route_name.empty() ? "stream" : session.route_name);
+ char filename[256];
+ std::snprintf(filename, sizeof(filename), "%s_%s_bus%d_0x%X_%.*s.csv",
+ sanitize_filename_component(message.name).c_str(),
+ sanitize_filename_component(message.service).c_str(),
+ message.bus,
+ message.address,
+ static_cast(kind.size()),
+ kind.data());
+ return cabana_export_dir() / route_part / filename;
+}
+
+std::optional load_active_dbc(const AppSession &session) {
+ const std::string &dbc_name = !session.dbc_override.empty() ? session.dbc_override : session.route_data.dbc_name;
+ return load_dbc_by_name(dbc_name);
+}
+
+struct BitBehaviorStats {
+ double ones_ratio = 0.0;
+ double flip_ratio = 0.0;
+ size_t samples = 0;
+};
+
+struct BinaryMatrixLayout {
+ size_t byte_count = 0;
+ std::vector> cell_signals;
+ std::vector is_msb;
+ std::vector is_lsb;
+ size_t overlapping_cells = 0;
+};
+
+bool signal_contains_bit(const CabanaSignalSummary &signal, size_t byte_index, int bit_index);
+void sync_cabana_selection(AppSession *session, UiState *state);
+void clear_similar_bit_results(UiState *state);
+const CabanaSignalSummary *find_signal_by_path(const CabanaMessageSummary &message, std::string_view path);
+bool prepare_cabana_signal_editor(const AppSession &session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ const CabanaSignalSummary &signal);
+bool prepare_cabana_new_signal_editor(const AppSession &session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ int start_bit,
+ int size,
+ bool is_little_endian);
+
+void clear_cabana_binary_drag(UiState *state) {
+ state->cabana.binary_drag_active = false;
+ state->cabana.binary_drag_resizing = false;
+ state->cabana.binary_drag_moved = false;
+ state->cabana.binary_drag_signal_is_little_endian = true;
+ state->cabana.binary_drag_press_byte = -1;
+ state->cabana.binary_drag_press_bit = -1;
+ state->cabana.binary_drag_anchor_byte = -1;
+ state->cabana.binary_drag_anchor_bit = -1;
+ state->cabana.binary_drag_current_byte = -1;
+ state->cabana.binary_drag_current_bit = -1;
+ state->cabana.binary_drag_signal_path.clear();
+}
+
+std::string_view active_signal_path(const UiState &state) {
+ if (!state.cabana.selected_signal_path.empty()) {
+ return state.cabana.selected_signal_path;
+ }
+ return {};
+}
+
+void set_cabana_selected_bit(UiState *state, int byte_index, int bit_index) {
+ const bool changed = !state->cabana.has_bit_selection
+ || state->cabana.selected_bit_byte != byte_index
+ || state->cabana.selected_bit_index != bit_index;
+ state->cabana.has_bit_selection = true;
+ state->cabana.selected_bit_byte = byte_index;
+ state->cabana.selected_bit_index = bit_index;
+ if (changed) {
+ clear_similar_bit_results(state);
+ }
+}
+
+bool cabana_drag_has_selection(const UiState &state) {
+ return state.cabana.binary_drag_active
+ && state.cabana.binary_drag_anchor_byte >= 0
+ && state.cabana.binary_drag_anchor_bit >= 0
+ && state.cabana.binary_drag_current_byte >= 0
+ && state.cabana.binary_drag_current_bit >= 0;
+}
+
+bool cabana_drag_selection(const UiState &state, int *start_bit, int *size, bool *is_little_endian) {
+ if (!cabana_drag_has_selection(state)) {
+ return false;
+ }
+ const int anchor_visual = cabana_visual_index(static_cast(state.cabana.binary_drag_anchor_byte),
+ state.cabana.binary_drag_anchor_bit);
+ const int current_visual = cabana_visual_index(static_cast(state.cabana.binary_drag_current_byte),
+ state.cabana.binary_drag_current_bit);
+ const int anchor_bit_pos = state.cabana.binary_drag_anchor_byte * 8 + state.cabana.binary_drag_anchor_bit;
+ const int current_bit_pos = state.cabana.binary_drag_current_byte * 8 + state.cabana.binary_drag_current_bit;
+
+ bool little_endian = true;
+ if (state.cabana.binary_drag_resizing) {
+ little_endian = state.cabana.binary_drag_signal_is_little_endian;
+ } else {
+ // Match old Cabana's default MsbFirst drag direction.
+ little_endian = current_visual < anchor_visual;
+ }
+
+ const int visual_min = std::min(anchor_visual, current_visual);
+ *start_bit = little_endian ? std::min(anchor_bit_pos, current_bit_pos)
+ : cabana_flip_bit_pos(visual_min);
+ *size = little_endian ? std::abs(current_bit_pos - anchor_bit_pos) + 1
+ : std::abs(cabana_flip_bit_pos(current_bit_pos) - cabana_flip_bit_pos(anchor_bit_pos)) + 1;
+ *is_little_endian = little_endian;
+ return *size > 0;
+}
+
+bool cabana_selection_contains_bit(int start_bit, int size, bool is_little_endian, size_t byte_index, int bit_index) {
+ dbc::Signal signal;
+ signal.start_bit = start_bit;
+ signal.size = size;
+ signal.is_little_endian = is_little_endian;
+ dbc::updateMsbLsb(&signal);
+ CabanaSignalSummary summary{
+ .start_bit = signal.start_bit,
+ .msb = signal.msb,
+ .lsb = signal.lsb,
+ .size = signal.size,
+ .is_little_endian = signal.is_little_endian,
+ .has_bit_range = true,
+ };
+ return signal_contains_bit(summary, byte_index, bit_index);
+}
+
+bool cabana_drag_selection_contains_bit(const UiState &state, size_t byte_index, int bit_index) {
+ int start_bit = 0;
+ int size = 0;
+ bool is_little_endian = true;
+ return cabana_drag_selection(state, &start_bit, &size, &is_little_endian)
+ && cabana_selection_contains_bit(start_bit, size, is_little_endian, byte_index, bit_index);
+}
+
+bool cabana_drag_selection_has_neighbor(const UiState &state, int byte_index, int bit_index) {
+ if (byte_index < 0 || bit_index < 0 || bit_index > 7) {
+ return false;
+ }
+ return cabana_drag_selection_contains_bit(state, static_cast(byte_index), bit_index);
+}
+
+bool queue_binary_drag_apply(AppSession *session,
+ const CabanaMessageSummary &summary,
+ UiState *state,
+ int release_byte,
+ int release_bit,
+ const CabanaSignalSummary *clicked_signal) {
+ if (!state->cabana.binary_drag_active) {
+ return false;
+ }
+ if (state->cabana.binary_drag_moved) {
+ int start_bit = 0;
+ int size = 0;
+ bool is_little_endian = true;
+ if (!cabana_drag_selection(*state, &start_bit, &size, &is_little_endian) || size <= 0) {
+ return false;
+ }
+ bool queued_apply = false;
+ if (state->cabana.binary_drag_resizing) {
+ const CabanaSignalSummary *target = find_signal_by_path(summary, state->cabana.binary_drag_signal_path);
+ if (target != nullptr && prepare_cabana_signal_editor(*session, state, summary, *target)) {
+ state->cabana_signal_editor.open = false;
+ state->cabana_signal_editor.start_bit = start_bit;
+ state->cabana_signal_editor.size = size;
+ state->cabana_signal_editor.is_little_endian = is_little_endian;
+ state->cabana.pending_apply_signal_edit = true;
+ queued_apply = true;
+ }
+ } else if (size > 1 && prepare_cabana_new_signal_editor(*session, state, summary, start_bit, size, is_little_endian)) {
+ state->cabana_signal_editor.open = false;
+ state->cabana.pending_apply_signal_edit = true;
+ queued_apply = true;
+ }
+ if (queued_apply) {
+ set_cabana_selected_bit(state, release_byte, release_bit);
+ }
+ return queued_apply;
+ }
+ if (clicked_signal != nullptr) {
+ state->cabana.selected_signal_path = clicked_signal->path;
+ }
+ set_cabana_selected_bit(state, release_byte, release_bit);
+ return true;
+}
+
+bool contains_case_insensitive(std::string_view haystack, std::string_view needle) {
+ if (needle.empty()) {
+ return true;
+ }
+ const std::string hay = lowercase(haystack);
+ const std::string ndl = lowercase(needle);
+ return hay.find(ndl) != std::string::npos;
+}
+
+bool cabana_match_numeric_filter(std::string_view filter, double value) {
+ const std::string raw = trim_copy(filter);
+ if (raw.empty()) {
+ return true;
+ }
+ const size_t dash = raw.find('-');
+ if (dash != std::string::npos) {
+ if (raw.find('-', dash + 1) != std::string::npos) {
+ return false;
+ }
+ const std::string lo_text = raw.substr(0, dash);
+ const std::string hi_text = raw.substr(dash + 1);
+ char *lo_end = nullptr;
+ char *hi_end = nullptr;
+ const double lo = lo_text.empty() ? -1.0e18 : std::strtod(lo_text.c_str(), &lo_end);
+ const double hi = hi_text.empty() ? 1.0e18 : std::strtod(hi_text.c_str(), &hi_end);
+ if ((!lo_text.empty() && lo_end == lo_text.c_str()) || (!hi_text.empty() && hi_end == hi_text.c_str())) {
+ return false;
+ }
+ return value >= lo && value <= hi;
+ }
+ char *end = nullptr;
+ const double target = std::strtod(raw.c_str(), &end);
+ return end != raw.c_str() && static_cast(value) == static_cast(target);
+}
+
+bool cabana_match_address_filter(std::string_view filter, uint32_t address) {
+ const std::string raw = trim_copy(filter);
+ if (raw.empty()) {
+ return true;
+ }
+ const size_t dash = raw.find('-');
+ if (dash != std::string::npos && dash > 0 && dash + 1 < raw.size()) {
+ const std::string lo_text = raw.substr(0, dash);
+ const std::string hi_text = raw.substr(dash + 1);
+ char *lo_end = nullptr;
+ char *hi_end = nullptr;
+ const unsigned long lo = std::strtoul(lo_text.c_str(), &lo_end, 16);
+ const unsigned long hi = std::strtoul(hi_text.c_str(), &hi_end, 16);
+ if (lo_end != lo_text.c_str() && hi_end != hi_text.c_str()) {
+ return address >= lo && address <= hi;
+ }
+ }
+ return contains_case_insensitive(format_can_address(address), raw);
+}
+
+bool cabana_message_matches_filters(const CabanaMessageSummary &message,
+ std::string_view name_filter,
+ std::string_view bus_filter,
+ std::string_view addr_filter,
+ std::string_view node_filter,
+ std::string_view freq_filter,
+ std::string_view count_filter,
+ std::string_view bytes_filter,
+ const CanMessageData *message_data) {
+ const bool name_matches = [&] {
+ if (name_filter.empty()) {
+ return true;
+ }
+ if (contains_case_insensitive(message.name, name_filter)) {
+ return true;
+ }
+ return std::any_of(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) {
+ return contains_case_insensitive(signal.name, name_filter);
+ });
+ }();
+ const bool bytes_matches = [&] {
+ if (bytes_filter.empty()) {
+ return true;
+ }
+ if (message_data == nullptr || message_data->samples.empty()) {
+ return false;
+ }
+ return contains_case_insensitive(payload_hex(message_data->samples.back().data), trim_copy(bytes_filter));
+ }();
+ return name_matches
+ && cabana_match_numeric_filter(bus_filter, message.bus)
+ && cabana_match_address_filter(addr_filter, message.address)
+ && contains_case_insensitive(message.node, node_filter)
+ && cabana_match_numeric_filter(freq_filter, message.frequency_hz)
+ && cabana_match_numeric_filter(count_filter, static_cast(message.sample_count))
+ && bytes_matches;
+}
+
+const CanMessageData *find_message_data(const AppSession &session, const CabanaMessageSummary &message) {
+ const std::optional service = parse_can_service_kind(message.service);
+ if (!service.has_value()) {
+ return nullptr;
+ }
+ const CanMessageData key{.id = CanMessageId{*service, static_cast(message.bus), message.address}};
+ auto it = std::lower_bound(session.route_data.can_messages.begin(),
+ session.route_data.can_messages.end(),
+ key,
+ [](const CanMessageData &a, const CanMessageData &b) {
+ return std::make_tuple(a.id.service, a.id.bus, a.id.address)
+ < std::make_tuple(b.id.service, b.id.bus, b.id.address);
+ });
+ if (it == session.route_data.can_messages.end()
+ || it->id.service != key.id.service
+ || it->id.bus != key.id.bus
+ || it->id.address != key.id.address) {
+ return nullptr;
+ }
+ return &*it;
+}
+
+bool prepare_cabana_signal_editor(const AppSession &session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ const CabanaSignalSummary &signal) {
+ const std::optional db = load_active_dbc(session);
+ const dbc::Message *dbc_message = db.has_value() ? db->message(message.address) : nullptr;
+ if (dbc_message == nullptr) {
+ state->error_text = "No active DBC message available for editing";
+ state->open_error_popup = true;
+ return false;
+ }
+ auto it = std::find_if(dbc_message->signals.begin(), dbc_message->signals.end(), [&](const dbc::Signal &dbc_signal) {
+ return dbc_signal.name == signal.name;
+ });
+ if (it == dbc_message->signals.end()) {
+ state->error_text = "Signal not found in active DBC";
+ state->open_error_popup = true;
+ return false;
+ }
+
+ CabanaSignalEditorState &editor = state->cabana_signal_editor;
+ editor.loaded = true;
+ editor.creating = false;
+ editor.message_root = message.root_path;
+ editor.message_name = message.name;
+ editor.service = message.service;
+ editor.signal_path = signal.path;
+ editor.bus = message.bus;
+ editor.message_address = message.address;
+ editor.original_signal_name = it->name;
+ editor.signal_name = it->name;
+ editor.start_bit = it->start_bit;
+ editor.size = it->size;
+ editor.factor = it->factor;
+ editor.offset = it->offset;
+ editor.min = it->min;
+ editor.max = it->max;
+ editor.is_signed = it->is_signed;
+ editor.is_little_endian = it->is_little_endian;
+ editor.type = static_cast(it->type);
+ editor.multiplex_value = it->multiplex_value;
+ editor.receiver_name = it->receiver_name;
+ editor.unit = it->unit;
+ return true;
+}
+
+bool prepare_cabana_new_signal_editor(const AppSession &session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ int start_bit,
+ int size,
+ bool is_little_endian) {
+ const std::optional db = load_active_dbc(session);
+ const dbc::Message *dbc_message = db.has_value() ? db->message(message.address) : nullptr;
+ if (dbc_message == nullptr) {
+ state->error_text = "No active DBC message available for creating a signal";
+ state->open_error_popup = true;
+ return false;
+ }
+
+ const int byte_index = start_bit / 8;
+ const int bit_index = start_bit & 7;
+ std::string base_name = "bit_" + std::to_string(byte_index) + "_" + std::to_string(bit_index);
+ std::string signal_name = base_name;
+ int suffix = 2;
+ auto exists = [&](std::string_view candidate) {
+ return std::any_of(dbc_message->signals.begin(), dbc_message->signals.end(), [&](const dbc::Signal &signal) {
+ return signal.name == candidate;
+ });
+ };
+ while (exists(signal_name)) {
+ signal_name = base_name + "_" + std::to_string(suffix++);
+ }
+
+ CabanaSignalEditorState &editor = state->cabana_signal_editor;
+ editor.loaded = true;
+ editor.creating = true;
+ editor.message_root = message.root_path;
+ editor.message_name = message.name;
+ editor.service = message.service;
+ editor.signal_path.clear();
+ editor.bus = message.bus;
+ editor.message_address = message.address;
+ editor.original_signal_name.clear();
+ editor.signal_name = signal_name;
+ editor.start_bit = start_bit;
+ editor.size = size;
+ editor.factor = 1.0;
+ editor.offset = 0.0;
+ editor.min = 0.0;
+ editor.max = std::min(std::pow(2.0, static_cast(std::min(size, 24))) - 1.0, 1.0e9);
+ editor.is_signed = false;
+ editor.is_little_endian = is_little_endian;
+ editor.type = static_cast(dbc::Signal::Type::Normal);
+ editor.multiplex_value = 0;
+ editor.receiver_name = "XXX";
+ editor.unit.clear();
+ return true;
+}
+
+void open_cabana_new_signal_editor(const AppSession &session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ int byte_index,
+ int bit_index) {
+ if (prepare_cabana_new_signal_editor(session, state, message, byte_index * 8 + bit_index, 1, true)) {
+ state->cabana_signal_editor.open = true;
+ }
+}
+
+const CabanaMessageSummary *find_selected_message(const AppSession &session, const UiState &state) {
+ auto it = std::find_if(session.cabana_messages.begin(), session.cabana_messages.end(), [&](const CabanaMessageSummary &message) {
+ return message.root_path == state.cabana.selected_message_root;
+ });
+ return it == session.cabana_messages.end() ? nullptr : &*it;
+}
+
+const CabanaMessageSummary *find_message_by_root(const AppSession &session, std::string_view root_path) {
+ auto it = std::find_if(session.cabana_messages.begin(), session.cabana_messages.end(), [&](const CabanaMessageSummary &message) {
+ return message.root_path == root_path;
+ });
+ return it == session.cabana_messages.end() ? nullptr : &*it;
+}
+
+void select_cabana_message(AppSession *session, UiState *state, std::string_view root_path) {
+ state->cabana.selected_message_root.assign(root_path);
+ state->cabana.sync_message_tabs = true;
+ if (std::find(state->cabana.open_message_roots.begin(), state->cabana.open_message_roots.end(), root_path)
+ == state->cabana.open_message_roots.end()) {
+ state->cabana.open_message_roots.emplace_back(root_path);
+ }
+ state->cabana.signal_filter[0] = '\0';
+ state->cabana.selected_signal_path.clear();
+ state->cabana.detail_top_auto_fit = true;
+ state->cabana.has_bit_selection = false;
+ clear_similar_bit_results(state);
+ clear_cabana_binary_drag(state);
+ sync_cabana_selection(session, state);
+}
+
+void close_cabana_message_tab(AppSession *session, UiState *state, std::string_view root_path) {
+ auto &roots = state->cabana.open_message_roots;
+ auto it = std::find(roots.begin(), roots.end(), root_path);
+ if (it == roots.end()) {
+ return;
+ }
+ const bool closing_selected = state->cabana.selected_message_root == root_path;
+ const size_t index = static_cast(it - roots.begin());
+ roots.erase(it);
+ if (!closing_selected) {
+ return;
+ }
+ if (roots.empty()) {
+ state->cabana.selected_message_root.clear();
+ state->cabana.selected_signal_path.clear();
+ state->cabana.has_bit_selection = false;
+ clear_similar_bit_results(state);
+ clear_cabana_binary_drag(state);
+ return;
+ }
+ const size_t next_index = std::min(index, roots.size() - 1);
+ select_cabana_message(session, state, roots[next_index]);
+}
+
+bool similar_bit_results_match_selection(const UiState &state) {
+ return state.cabana.has_bit_selection
+ && state.cabana.similar_bits_source_root == state.cabana.selected_message_root
+ && state.cabana.similar_bits_source_byte == state.cabana.selected_bit_byte
+ && state.cabana.similar_bits_source_bit == state.cabana.selected_bit_index;
+}
+
+void clear_similar_bit_results(UiState *state) {
+ state->cabana.similar_bits_source_root.clear();
+ state->cabana.similar_bits_source_byte = -1;
+ state->cabana.similar_bits_source_bit = -1;
+ state->cabana.similar_bit_matches.clear();
+}
+
+void poll_similar_bit_search(UiState *state) {
+ if (!state->cabana.similar_bits_loading || !state->cabana.similar_bit_future.valid()) {
+ return;
+ }
+ using namespace std::chrono_literals;
+ if (state->cabana.similar_bit_future.wait_for(0ms) != std::future_status::ready) {
+ return;
+ }
+ std::vector matches = state->cabana.similar_bit_future.get();
+ state->cabana.similar_bits_loading = false;
+ if (similar_bit_results_match_selection(*state)) {
+ state->cabana.similar_bit_matches = std::move(matches);
+ } else {
+ clear_similar_bit_results(state);
+ }
+}
+
+void sync_cabana_selection(AppSession *session, UiState *state) {
+ poll_similar_bit_search(state);
+ if (!state->cabana_mode_initialized) {
+ state->cabana.camera_view = sidebar_preview_camera_view(*session);
+ state->cabana_mode_initialized = true;
+ }
+ auto &open_roots = state->cabana.open_message_roots;
+ open_roots.erase(std::remove_if(open_roots.begin(), open_roots.end(), [&](const std::string &root_path) {
+ return find_message_by_root(*session, root_path) == nullptr;
+ }),
+ open_roots.end());
+ if (session->cabana_messages.empty()) {
+ state->cabana.selected_message_root.clear();
+ state->cabana.selected_signal_path.clear();
+ state->cabana.open_message_roots.clear();
+ state->cabana.chart_signal_paths.clear();
+ state->cabana.has_bit_selection = false;
+ clear_similar_bit_results(state);
+ clear_cabana_binary_drag(state);
+ return;
+ }
+ const CabanaMessageSummary *selected = find_selected_message(*session, *state);
+ if (selected == nullptr) {
+ state->cabana.selected_message_root.clear();
+ state->cabana.selected_signal_path.clear();
+ state->cabana.has_bit_selection = false;
+ clear_similar_bit_results(state);
+ clear_cabana_binary_drag(state);
+ return;
+ }
+
+ std::unordered_set allowed;
+ allowed.reserve(selected->signals.size());
+ for (const CabanaSignalSummary &signal : selected->signals) {
+ allowed.insert(signal.path);
+ }
+ state->cabana.chart_signal_paths.erase(
+ std::remove_if(state->cabana.chart_signal_paths.begin(), state->cabana.chart_signal_paths.end(),
+ [&](const std::string &path) { return session->series_by_path.find(path) == session->series_by_path.end(); }),
+ state->cabana.chart_signal_paths.end());
+ if (!state->cabana.selected_signal_path.empty() && !allowed.count(state->cabana.selected_signal_path)) {
+ state->cabana.selected_signal_path.clear();
+ }
+}
+
+std::string format_cabana_time(double seconds) {
+ seconds = std::max(0.0, seconds);
+ const int total = static_cast(seconds);
+ const int minutes = total / 60;
+ const int secs = total % 60;
+ char text[32];
+ std::snprintf(text, sizeof(text), "%02d:%02d", minutes, secs);
+ return text;
+}
+
+void draw_cabana_message_tabs(AppSession *session, UiState *state) {
+ auto &roots = state->cabana.open_message_roots;
+ if (roots.size() <= 1) {
+ return;
+ }
+ ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg());
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 0.0f));
+ ImGui::BeginChild("##cabana_message_tabs", ImVec2(0.0f, 28.0f), false, ImGuiWindowFlags_NoScrollbar);
+ int close_index = -1;
+ int close_others_index = -1;
+ if (ImGui::BeginTabBar("##cabana_message_tabbar",
+ ImGuiTabBarFlags_FittingPolicyResizeDown |
+ ImGuiTabBarFlags_Reorderable |
+ ImGuiTabBarFlags_NoTooltip)) {
+ for (size_t i = 0; i < roots.size(); ++i) {
+ const CabanaMessageSummary *message = find_message_by_root(*session, roots[i]);
+ if (message == nullptr) {
+ continue;
+ }
+ const std::string message_id = cabana_message_id_label(*message);
+ const std::string label = message_id + "###cabana_tab_" + roots[i];
+ bool open = true;
+ const ImGuiTabItemFlags flags = (state->cabana.sync_message_tabs && state->cabana.selected_message_root == roots[i])
+ ? ImGuiTabItemFlags_SetSelected
+ : 0;
+ if (ImGui::BeginTabItem(label.c_str(), &open, flags)) {
+ if (!state->cabana.sync_message_tabs && state->cabana.selected_message_root != roots[i]) {
+ select_cabana_message(session, state, roots[i]);
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip("%s", message->name.c_str());
+ }
+ if (ImGui::BeginPopupContextItem(("##cabana_tab_ctx_" + roots[i]).c_str())) {
+ if (ImGui::MenuItem("Close Other Tabs", nullptr, false, roots.size() > 1)) {
+ close_others_index = static_cast(i);
+ }
+ ImGui::EndPopup();
+ }
+ ImGui::EndTabItem();
+ }
+ if (!open) {
+ close_index = static_cast(i);
+ }
+ }
+ ImGui::EndTabBar();
+ }
+ if (close_others_index >= 0) {
+ const std::string keep = roots[static_cast(close_others_index)];
+ roots.assign(1, keep);
+ select_cabana_message(session, state, keep);
+ } else if (close_index >= 0) {
+ close_cabana_message_tab(session, state, roots[static_cast(close_index)]);
+ }
+ state->cabana.sync_message_tabs = false;
+ ImGui::EndChild();
+ ImGui::PopStyleVar();
+ ImGui::PopStyleColor();
+}
+
+bool export_raw_can_csv(const AppSession &session,
+ const CabanaMessageSummary &message,
+ std::string *error,
+ fs::path *output_path) {
+ const CanMessageData *message_data = find_message_data(session, message);
+ if (message_data == nullptr || message_data->samples.empty()) {
+ if (error != nullptr) *error = "No raw CAN frames available";
+ return false;
+ }
+
+ const fs::path output = cabana_export_path(session, message, "raw");
+ fs::create_directories(output.parent_path());
+ std::ofstream out(output);
+ if (!out.is_open()) {
+ if (error != nullptr) *error = "Failed to open raw CSV";
+ return false;
+ }
+
+ out << "mono_time,bus_time,dt_ms,data_hex\n";
+ for (size_t i = 0; i < message_data->samples.size(); ++i) {
+ const CanFrameSample &sample = message_data->samples[i];
+ out << sample.mono_time << ','
+ << sample.bus_time << ',';
+ if (i > 0) {
+ out << 1000.0 * (sample.mono_time - message_data->samples[i - 1].mono_time);
+ }
+ out << ',' << csv_escape(payload_hex(sample.data)) << '\n';
+ }
+
+ if (!out.good()) {
+ if (error != nullptr) *error = "Failed while writing raw CSV";
+ return false;
+ }
+ if (output_path != nullptr) *output_path = output;
+ return true;
+}
+
+bool export_decoded_can_csv(const AppSession &session,
+ const CabanaMessageSummary &message,
+ std::string *error,
+ fs::path *output_path) {
+ const CanMessageData *message_data = find_message_data(session, message);
+ if (message_data == nullptr || message_data->samples.empty()) {
+ if (error != nullptr) *error = "No raw CAN frames available";
+ return false;
+ }
+ if (message.signals.empty()) {
+ if (error != nullptr) *error = "No decoded signals for this message";
+ return false;
+ }
+
+ std::vector export_signals;
+ std::vector export_series;
+ std::vector export_formats;
+ std::vector export_enums;
+ export_signals.reserve(message.signals.size());
+ export_series.reserve(message.signals.size());
+ export_formats.reserve(message.signals.size());
+ export_enums.reserve(message.signals.size());
+
+ for (const CabanaSignalSummary &signal : message.signals) {
+ const RouteSeries *series = app_find_route_series(session, signal.path);
+ if (series == nullptr) {
+ continue;
+ }
+ export_signals.push_back(&signal);
+ export_series.push_back(series);
+ auto format_it = session.route_data.series_formats.find(signal.path);
+ export_formats.push_back(format_it == session.route_data.series_formats.end() ? nullptr : &format_it->second);
+ auto enum_it = session.route_data.enum_info.find(signal.path);
+ export_enums.push_back(enum_it == session.route_data.enum_info.end() ? nullptr : &enum_it->second);
+ }
+
+ if (export_series.empty()) {
+ if (error != nullptr) *error = "No decoded signal data available";
+ return false;
+ }
+
+ const fs::path output = cabana_export_path(session, message, "decoded");
+ fs::create_directories(output.parent_path());
+ std::ofstream out(output);
+ if (!out.is_open()) {
+ if (error != nullptr) *error = "Failed to open decoded CSV";
+ return false;
+ }
+
+ out << "mono_time,bus_time,dt_ms,data_hex";
+ for (const CabanaSignalSummary *signal : export_signals) {
+ out << ',' << csv_escape(signal->name);
+ }
+ out << '\n';
+
+ for (size_t i = 0; i < message_data->samples.size(); ++i) {
+ const CanFrameSample &sample = message_data->samples[i];
+ out << sample.mono_time << ','
+ << sample.bus_time << ',';
+ if (i > 0) {
+ out << 1000.0 * (sample.mono_time - message_data->samples[i - 1].mono_time);
+ }
+ out << ',' << csv_escape(payload_hex(sample.data));
+ for (size_t j = 0; j < export_series.size(); ++j) {
+ out << ',';
+ const std::optional value = app_sample_xy_value_at_time(
+ export_series[j]->times, export_series[j]->values, false, sample.mono_time);
+ if (value.has_value()) {
+ out << csv_escape(export_formats[j] != nullptr
+ ? format_display_value(*value, *export_formats[j], export_enums[j])
+ : std::to_string(*value));
+ }
+ }
+ out << '\n';
+ }
+
+ if (!out.good()) {
+ if (error != nullptr) *error = "Failed while writing decoded CSV";
+ return false;
+ }
+ if (output_path != nullptr) *output_path = output;
+ return true;
+}
+
+size_t closest_can_sample_index(const CanMessageData &message, double tracker_time) {
+ if (message.samples.empty()) {
+ return 0;
+ }
+ auto it = std::lower_bound(message.samples.begin(), message.samples.end(), tracker_time,
+ [](const CanFrameSample &sample, double time) {
+ return sample.mono_time < time;
+ });
+ if (it == message.samples.begin()) {
+ return 0;
+ }
+ if (it == message.samples.end()) {
+ return message.samples.size() - 1;
+ }
+ const size_t upper = static_cast(it - message.samples.begin());
+ const size_t lower = upper - 1;
+ return std::abs(message.samples[upper].mono_time - tracker_time) < std::abs(message.samples[lower].mono_time - tracker_time)
+ ? upper
+ : lower;
+}
+
+uint8_t can_bit(const std::string &data, size_t byte_index, int bit_index) {
+ if (byte_index >= data.size() || bit_index < 0 || bit_index > 7) {
+ return 0;
+ }
+ return (static_cast(data[byte_index]) >> bit_index) & 0x1;
+}
+
+BitBehaviorStats bit_behavior_stats(const CanMessageData &message, size_t byte_index, int bit_index) {
+ BitBehaviorStats stats;
+ if (message.samples.empty()) {
+ return stats;
+ }
+ size_t ones = 0;
+ size_t flips = 0;
+ uint8_t prev = can_bit(message.samples.front().data, byte_index, bit_index);
+ ones += prev;
+ for (size_t i = 1; i < message.samples.size(); ++i) {
+ const uint8_t bit = can_bit(message.samples[i].data, byte_index, bit_index);
+ ones += bit;
+ flips += bit != prev;
+ prev = bit;
+ }
+ stats.samples = message.samples.size();
+ stats.ones_ratio = static_cast(ones) / static_cast(stats.samples);
+ stats.flip_ratio = stats.samples > 1 ? static_cast(flips) / static_cast(stats.samples - 1) : 0.0;
+ return stats;
+}
+
+size_t can_message_payload_width(const CanMessageData &message) {
+ size_t width = 0;
+ for (const CanFrameSample &sample : message.samples) {
+ width = std::max(width, sample.data.size());
+ }
+ return width;
+}
+
+size_t cabana_signal_byte_count(const CabanaMessageSummary &message) {
+ size_t width = 0;
+ for (const CabanaSignalSummary &signal : message.signals) {
+ if (!signal.has_bit_range) {
+ continue;
+ }
+ width = std::max(width, static_cast(std::max(signal.msb / 8, signal.lsb / 8) + 1));
+ }
+ return width;
+}
+
+BinaryMatrixLayout build_binary_matrix_layout(const CabanaMessageSummary &message, size_t byte_count) {
+ BinaryMatrixLayout layout;
+ layout.byte_count = byte_count;
+ layout.cell_signals.resize(byte_count * 8);
+ layout.is_msb.assign(byte_count * 8, false);
+ layout.is_lsb.assign(byte_count * 8, false);
+ for (size_t i = 0; i < message.signals.size(); ++i) {
+ const CabanaSignalSummary &signal = message.signals[i];
+ if (!signal.has_bit_range) {
+ continue;
+ }
+ for (size_t byte = 0; byte < byte_count; ++byte) {
+ for (int bit = 0; bit < 8; ++bit) {
+ if (signal_contains_bit(signal, byte, bit)) {
+ layout.cell_signals[byte * 8 + static_cast(bit)].push_back(static_cast(i));
+ }
+ }
+ }
+ const size_t msb_byte = static_cast(signal.msb / 8);
+ const size_t lsb_byte = static_cast(signal.lsb / 8);
+ if (msb_byte < byte_count) {
+ layout.is_msb[msb_byte * 8 + static_cast(signal.msb & 7)] = true;
+ }
+ if (lsb_byte < byte_count) {
+ layout.is_lsb[lsb_byte * 8 + static_cast(signal.lsb & 7)] = true;
+ }
+ }
+ for (std::vector &signals : layout.cell_signals) {
+ std::stable_sort(signals.begin(), signals.end(), [&](int a, int b) {
+ return message.signals[static_cast(a)].size > message.signals[static_cast(b)].size;
+ });
+ if (signals.size() > 1) {
+ ++layout.overlapping_cells;
+ }
+ }
+ return layout;
+}
+
+bool cell_has_signal(const BinaryMatrixLayout &layout, int byte_index, int bit_index, int signal_index) {
+ if (byte_index < 0 || bit_index < 0 || bit_index > 7) {
+ return false;
+ }
+ if (static_cast(byte_index) >= layout.byte_count) {
+ return false;
+ }
+ const std::vector &signals = layout.cell_signals[static_cast(byte_index) * 8 + static_cast(bit_index)];
+ return std::find(signals.begin(), signals.end(), signal_index) != signals.end();
+}
+
+std::vector compute_bit_flip_heat(const CanMessageData &message,
+ size_t byte_count,
+ bool live_mode,
+ size_t tracker_index) {
+ std::vector heat(byte_count * 8, 0.0f);
+ if (message.samples.size() < 2 || byte_count == 0) {
+ return heat;
+ }
+
+ size_t begin = 0;
+ size_t end = message.samples.size();
+ if (live_mode) {
+ end = std::min(message.samples.size(), tracker_index + 1);
+ const size_t window = 96;
+ begin = end > window ? end - window : 0;
+ }
+ if (end <= begin + 1) {
+ return heat;
+ }
+
+ std::vector flip_counts(byte_count * 8, 0);
+ uint32_t max_count = 1;
+ std::string prev = message.samples[begin].data;
+ for (size_t i = begin + 1; i < end; ++i) {
+ const std::string ¤t = message.samples[i].data;
+ for (size_t byte = 0; byte < byte_count; ++byte) {
+ const uint8_t before = byte < prev.size() ? static_cast(prev[byte]) : 0;
+ const uint8_t after = byte < current.size() ? static_cast(current[byte]) : 0;
+ const uint8_t diff = before ^ after;
+ if (diff == 0) {
+ continue;
+ }
+ for (int bit = 0; bit < 8; ++bit) {
+ if ((diff & (1u << bit)) == 0) {
+ continue;
+ }
+ uint32_t &count = flip_counts[byte * 8 + static_cast(bit)];
+ ++count;
+ max_count = std::max(max_count, count);
+ }
+ }
+ prev = current;
+ }
+
+ for (size_t i = 0; i < flip_counts.size(); ++i) {
+ if (flip_counts[i] == 0) {
+ continue;
+ }
+ const float frac = static_cast(flip_counts[i]) / static_cast(max_count);
+ heat[i] = std::sqrt(frac);
+ }
+ return heat;
+}
+
+bool signal_charted(const UiState &state, std::string_view path) {
+ return std::find(state.cabana.chart_signal_paths.begin(), state.cabana.chart_signal_paths.end(), path)
+ != state.cabana.chart_signal_paths.end();
+}
+
+ImU32 signal_fill_color(size_t index, float alpha_scale, bool emphasized) {
+ const auto &rgb = kSignalHighlightColors[index % kSignalHighlightColors.size()];
+ const float alpha = emphasized ? std::clamp(0.34f + alpha_scale * 0.38f, 0.34f, 0.78f)
+ : std::clamp(0.14f + alpha_scale * 0.28f, 0.14f, 0.48f);
+ return ImGui::GetColorU32(color_rgb(rgb, alpha));
+}
+
+ImU32 signal_border_color(size_t index, bool emphasized) {
+ const auto &rgb = kSignalHighlightColors[index % kSignalHighlightColors.size()];
+ return ImGui::GetColorU32(color_rgb(rgb[0], rgb[1], rgb[2], emphasized ? 0.95f : 0.78f));
+}
+
+void draw_cell_hatching(ImDrawList *draw, const ImRect &rect, ImU32 color, float spacing) {
+ for (float x = rect.Min.x - rect.GetHeight(); x < rect.Max.x; x += spacing) {
+ const ImVec2 a(std::max(rect.Min.x, x), std::min(rect.Max.y, rect.Min.y + (rect.Min.x - x) + rect.GetHeight()));
+ const ImVec2 b(std::min(rect.Max.x, x + rect.GetHeight()), std::max(rect.Min.y, rect.Max.y - (rect.Max.x - x)));
+ draw->AddLine(a, b, color, 1.0f);
+ }
+}
+
+bool signal_contains_bit(const CabanaSignalSummary &signal, size_t byte_index, int bit_index) {
+ if (!signal.has_bit_range || bit_index < 0 || bit_index > 7) {
+ return false;
+ }
+ const int msb_byte = signal.msb / 8;
+ const int lsb_byte = signal.lsb / 8;
+ if (msb_byte == lsb_byte) {
+ return static_cast(byte_index) == msb_byte
+ && bit_index >= (signal.lsb & 7)
+ && bit_index <= (signal.msb & 7);
+ }
+ for (int i = msb_byte, step = signal.is_little_endian ? -1 : 1;; i += step) {
+ const int hi = i == msb_byte ? (signal.msb & 7) : 7;
+ const int lo = i == lsb_byte ? (signal.lsb & 7) : 0;
+ if (static_cast(byte_index) == i && bit_index >= lo && bit_index <= hi) {
+ return true;
+ }
+ if (i == lsb_byte) {
+ return false;
+ }
+ }
+}
+
+std::vector> highlighted_signals(const CabanaMessageSummary &message, const UiState &state) {
+ std::vector> out;
+ for (const std::string &path : state.cabana.chart_signal_paths) {
+ for (size_t i = 0; i < message.signals.size(); ++i) {
+ const CabanaSignalSummary &signal = message.signals[i];
+ if (signal.path != path || !signal.has_bit_range) {
+ continue;
+ }
+ out.push_back({&signal, signal_fill_color(i, 0.5f, true)});
+ break;
+ }
+ }
+ return out;
+}
+
+bool cabana_bit_selected(const UiState &state, size_t byte_index, int bit_index) {
+ return state.cabana.has_bit_selection
+ && state.cabana.selected_bit_byte == static_cast(byte_index)
+ && state.cabana.selected_bit_index == bit_index;
+}
+
+std::vector selected_bit_signals(const CabanaMessageSummary &message, const UiState &state) {
+ std::vector out;
+ if (!state.cabana.has_bit_selection) {
+ return out;
+ }
+ for (const CabanaSignalSummary &signal : message.signals) {
+ if (signal_contains_bit(signal,
+ static_cast(state.cabana.selected_bit_byte),
+ state.cabana.selected_bit_index)) {
+ out.push_back(&signal);
+ }
+ }
+ return out;
+}
+
+const CabanaSignalSummary *find_signal_by_path(const CabanaMessageSummary &message, std::string_view path) {
+ auto it = std::find_if(message.signals.begin(), message.signals.end(), [&](const CabanaSignalSummary &signal) {
+ return signal.path == path;
+ });
+ return it == message.signals.end() ? nullptr : &*it;
+}
+
+const CabanaSignalSummary *topmost_signal_at_cell(const CabanaMessageSummary &message,
+ const BinaryMatrixLayout &layout,
+ size_t byte_index,
+ int bit_index) {
+ if (byte_index >= layout.byte_count || bit_index < 0 || bit_index > 7) {
+ return nullptr;
+ }
+ const std::vector &signals = layout.cell_signals[byte_index * 8 + static_cast(bit_index)];
+ return signals.empty() ? nullptr : &message.signals[static_cast(signals.back())];
+}
+
+const CabanaSignalSummary *resize_signal_at_cell(const CabanaMessageSummary &message,
+ const BinaryMatrixLayout &layout,
+ size_t byte_index,
+ int bit_index) {
+ if (byte_index >= layout.byte_count || bit_index < 0 || bit_index > 7) {
+ return nullptr;
+ }
+ const int physical_bit = static_cast(byte_index) * 8 + bit_index;
+ const std::vector &signals = layout.cell_signals[byte_index * 8 + static_cast(bit_index)];
+ for (int signal_index : signals) {
+ const CabanaSignalSummary &signal = message.signals[static_cast(signal_index)];
+ if (signal.has_bit_range && (physical_bit == signal.msb || physical_bit == signal.lsb)) {
+ return &signal;
+ }
+ }
+ return nullptr;
+}
+
+std::vector find_similar_bits_from_snapshot(const std::vector &messages,
+ const std::vector &can_messages,
+ const CabanaMessageSummary &source_message,
+ const CanMessageData &source_data,
+ size_t source_byte,
+ int source_bit) {
+ const BitBehaviorStats target = bit_behavior_stats(source_data, source_byte, source_bit);
+ std::vector matches;
+ for (const CabanaMessageSummary &message : messages) {
+ const std::optional service = parse_can_service_kind(message.service);
+ if (!service.has_value()) continue;
+ const CanMessageData key{.id = CanMessageId{*service, static_cast(message.bus), message.address}};
+ auto it = std::lower_bound(can_messages.begin(), can_messages.end(), key, [](const CanMessageData &a, const CanMessageData &b) {
+ return std::make_tuple(a.id.service, a.id.bus, a.id.address)
+ < std::make_tuple(b.id.service, b.id.bus, b.id.address);
+ });
+ if (it == can_messages.end()
+ || it->id.service != key.id.service
+ || it->id.bus != key.id.bus
+ || it->id.address != key.id.address
+ || it->samples.size() < 2) {
+ continue;
+ }
+ for (size_t byte = 0; byte < can_message_payload_width(*it); ++byte) {
+ for (int bit = 0; bit < 8; ++bit) {
+ if (message.root_path == source_message.root_path
+ && static_cast(byte) == static_cast(source_byte)
+ && bit == source_bit) {
+ continue;
+ }
+ const BitBehaviorStats stats = bit_behavior_stats(*it, byte, bit);
+ if (stats.samples < 2) continue;
+ const double ones_diff = std::abs(stats.ones_ratio - target.ones_ratio);
+ const double flip_diff = std::abs(stats.flip_ratio - target.flip_ratio);
+ matches.push_back({
+ .message_root = message.root_path,
+ .label = message.name,
+ .bus = message.bus,
+ .address = message.address,
+ .byte_index = static_cast(byte),
+ .bit_index = bit,
+ .score = ones_diff * 0.65 + flip_diff * 0.35,
+ .ones_ratio = stats.ones_ratio,
+ .flip_ratio = stats.flip_ratio,
+ });
+ }
+ }
+ }
+ std::sort(matches.begin(), matches.end(), [](const CabanaSimilarBitMatch &a, const CabanaSimilarBitMatch &b) {
+ return std::tie(a.score, a.label, a.byte_index, a.bit_index)
+ < std::tie(b.score, b.label, b.byte_index, b.bit_index);
+ });
+ if (matches.size() > 12) {
+ matches.resize(12);
+ }
+ return matches;
+}
+
+void draw_bit_selection_panel(AppSession *session, const CabanaMessageSummary &message, UiState *state) {
+ poll_similar_bit_search(state);
+ if (!state->cabana.has_bit_selection) {
+ return;
+ }
+ app_push_bold_font();
+ ImGui::Text("Selected Bit: B%d.%d", state->cabana.selected_bit_byte, state->cabana.selected_bit_index);
+ app_pop_bold_font();
+ ImGui::SameLine();
+ if (ImGui::SmallButton("Clear")) {
+ state->cabana.has_bit_selection = false;
+ clear_similar_bit_results(state);
+ return;
+ }
+ ImGui::SameLine();
+ ImGui::BeginDisabled(state->cabana.similar_bits_loading);
+ if (ImGui::SmallButton("Find Similar Bits")) {
+ const CanMessageData *message_data = find_message_data(*session, message);
+ if (message_data != nullptr) {
+ state->cabana.similar_bit_matches.clear();
+ state->cabana.similar_bits_source_root = message.root_path;
+ state->cabana.similar_bits_source_byte = state->cabana.selected_bit_byte;
+ state->cabana.similar_bits_source_bit = state->cabana.selected_bit_index;
+ state->cabana.similar_bits_loading = true;
+ const std::vector messages = session->cabana_messages;
+ const std::vector can_messages = session->route_data.can_messages;
+ const CanMessageData source_data = *message_data;
+ const size_t source_byte = static_cast(state->cabana.selected_bit_byte);
+ const int source_bit = state->cabana.selected_bit_index;
+ state->cabana.similar_bit_future = std::async(std::launch::async, [messages, can_messages, message, source_data, source_byte, source_bit]() {
+ return find_similar_bits_from_snapshot(messages, can_messages, message, source_data, source_byte, source_bit);
+ });
+ }
+ }
+ ImGui::EndDisabled();
+ ImGui::SameLine();
+ if (ImGui::SmallButton("Create Signal...")) {
+ open_cabana_new_signal_editor(*session,
+ state,
+ message,
+ state->cabana.selected_bit_byte,
+ state->cabana.selected_bit_index);
+ }
+ const auto overlaps = selected_bit_signals(message, *state);
+ if (overlaps.empty()) {
+ ImGui::TextDisabled("No decoded signals cover this bit.");
+ } else {
+ ImGui::TextDisabled("Signals covering this bit:");
+ for (size_t i = 0; i < overlaps.size(); ++i) {
+ if (i > 0) ImGui::SameLine(0.0f, 8.0f);
+ if (ImGui::SmallButton(overlaps[i]->name.c_str())) {
+ state->cabana.selected_signal_path = overlaps[i]->path;
+ }
+ }
+ }
+
+ if (state->cabana.similar_bits_loading && similar_bit_results_match_selection(*state)) {
+ ImGui::Spacing();
+ ImGui::TextDisabled("Searching similar bits...");
+ } else if (similar_bit_results_match_selection(*state) && !state->cabana.similar_bit_matches.empty()) {
+ ImGui::Spacing();
+ ImGui::TextDisabled("Similar bits:");
+ if (ImGui::BeginTable("##cabana_similar_bits", 5,
+ ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_BordersInnerV)) {
+ ImGui::TableSetupColumn("Message", ImGuiTableColumnFlags_WidthStretch, 2.2f);
+ ImGui::TableSetupColumn("Bit", ImGuiTableColumnFlags_WidthFixed, 58.0f);
+ ImGui::TableSetupColumn("Score", ImGuiTableColumnFlags_WidthFixed, 58.0f);
+ ImGui::TableSetupColumn("1s", ImGuiTableColumnFlags_WidthFixed, 52.0f);
+ ImGui::TableSetupColumn("Flip", ImGuiTableColumnFlags_WidthFixed, 56.0f);
+ ImGui::TableHeadersRow();
+ for (const CabanaSimilarBitMatch &match : state->cabana.similar_bit_matches) {
+ ImGui::TableNextRow();
+ ImGui::TableNextColumn();
+ const std::string label = match.label + "##" + match.message_root + "_" + std::to_string(match.byte_index) + "_" + std::to_string(match.bit_index);
+ if (ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) {
+ select_cabana_message(session, state, match.message_root);
+ state->cabana.has_bit_selection = true;
+ state->cabana.selected_bit_byte = match.byte_index;
+ state->cabana.selected_bit_index = match.bit_index;
+ }
+ ImGui::TableNextColumn();
+ ImGui::Text("B%d.%d", match.byte_index, match.bit_index);
+ ImGui::TableNextColumn();
+ ImGui::Text("%.3f", match.score);
+ ImGui::TableNextColumn();
+ ImGui::Text("%.0f%%", 100.0 * match.ones_ratio);
+ ImGui::TableNextColumn();
+ ImGui::Text("%.0f%%", 100.0 * match.flip_ratio);
+ }
+ ImGui::EndTable();
+ }
+ }
+ ImGui::Spacing();
+}
+
+void draw_can_heatmap(const CanMessageData &message,
+ const std::vector> &highlighted,
+ double tracker_time) {
+ const size_t byte_count = can_message_payload_width(message);
+ if (message.samples.empty() || byte_count == 0) {
+ return;
+ }
+
+ app_push_bold_font();
+ ImGui::TextUnformatted("History Heatmap");
+ app_pop_bold_font();
+ ImGui::TextDisabled("aggregated over all frames");
+ ImGui::Spacing();
+
+ const size_t row_count = byte_count * 8;
+ const float avail_w = ImGui::GetContentRegionAvail().x;
+ const float label_w = 42.0f;
+ const float row_h = std::clamp(160.0f / std::max(1.0f, static_cast(row_count)), 10.0f, 16.0f);
+ const float grid_h = row_h * static_cast(row_count);
+ const float grid_w = std::max(120.0f, avail_w - label_w - 8.0f);
+ const int columns = std::max(1, std::min(std::min(220, message.samples.size()), static_cast(grid_w / 4.0f)));
+ const float cell_w = grid_w / static_cast(columns);
+ const size_t tracker_index = closest_can_sample_index(message, tracker_time);
+
+ ImGui::InvisibleButton("##cabana_heatmap", ImVec2(label_w + grid_w, grid_h + 4.0f));
+ const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax());
+ const ImVec2 grid_min(rect.Min.x + label_w, rect.Min.y);
+ ImDrawList *draw = ImGui::GetWindowDrawList();
+ const ImU32 grid_bg = ImGui::GetColorU32(color_rgb(246, 247, 249));
+ const ImU32 low = ImGui::GetColorU32(color_rgb(234, 238, 243));
+ const ImU32 high = ImGui::GetColorU32(color_rgb(69, 116, 201));
+ const ImU32 border = ImGui::GetColorU32(color_rgb(204, 209, 214));
+ draw->AddRectFilled(ImVec2(grid_min.x, rect.Min.y), ImVec2(grid_min.x + grid_w, rect.Min.y + grid_h), grid_bg, 4.0f);
+
+ for (size_t row = 0; row < row_count; ++row) {
+ const size_t byte_index = row / 8;
+ const int bit_index = 7 - static_cast(row % 8);
+ const float y0 = rect.Min.y + static_cast(row) * row_h;
+ const float y1 = y0 + row_h;
+ if (row % 8 == 0) {
+ const std::string label = "B" + std::to_string(byte_index);
+ draw->AddText(ImVec2(rect.Min.x, y0 + 1.0f), ImGui::GetColorU32(color_rgb(92, 100, 112)), label.c_str());
+ if (row > 0) {
+ draw->AddLine(ImVec2(rect.Min.x, y0), ImVec2(grid_min.x + grid_w, y0), border, 1.0f);
+ }
+ }
+ for (int col = 0; col < columns; ++col) {
+ const size_t start = (message.samples.size() * static_cast(col)) / static_cast(columns);
+ const size_t end = std::max(start + 1,
+ (message.samples.size() * static_cast(col + 1)) / static_cast(columns));
+ size_t ones = 0;
+ for (size_t i = start; i < std::min(end, message.samples.size()); ++i) {
+ ones += can_bit(message.samples[i].data, byte_index, bit_index);
+ }
+ const float frac = static_cast(ones) / static_cast(std::max(1, std::min(end, message.samples.size()) - start));
+ ImU32 color = mix_color(low, high, frac);
+ for (const auto &[signal, signal_color] : highlighted) {
+ if (signal_contains_bit(*signal, byte_index, bit_index)) {
+ color = mix_color(color, signal_color, 0.65f);
+ break;
+ }
+ }
+ const float x0 = grid_min.x + static_cast(col) * cell_w;
+ const float x1 = x0 + cell_w + 0.5f;
+ draw->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), color);
+ }
+ }
+
+ const float tracker_x = grid_min.x + cell_w * ((static_cast(tracker_index) + 0.5f) * static_cast(columns)
+ / static_cast(std::max(1, message.samples.size())));
+ draw->AddLine(ImVec2(tracker_x, rect.Min.y), ImVec2(tracker_x, rect.Min.y + grid_h),
+ ImGui::GetColorU32(color_rgb(36, 42, 50, 0.9f)), 2.0f);
+ draw->AddRect(ImVec2(grid_min.x, rect.Min.y), ImVec2(grid_min.x + grid_w, rect.Min.y + grid_h), border, 4.0f);
+}
+
+void draw_can_frame_view(const CanMessageData &message,
+ AppSession *session,
+ const CabanaMessageSummary &summary,
+ UiState *state,
+ double tracker_time) {
+ if (message.samples.empty()) {
+ draw_cabana_panel_title("Binary View");
+ ImGui::TextDisabled("No raw CAN frames available.");
+ return;
+ }
+ const size_t sample_index = closest_can_sample_index(message, tracker_time);
+ const CanFrameSample &sample = message.samples[sample_index];
+ const CanFrameSample *prev = sample_index > 0 ? &message.samples[sample_index - 1] : nullptr;
+ const size_t byte_count = std::max(can_message_payload_width(message), cabana_signal_byte_count(summary));
+ const BinaryMatrixLayout layout = build_binary_matrix_layout(summary, byte_count);
+ const std::vector heat = compute_bit_flip_heat(message, byte_count, state->cabana.heatmap_live_mode, sample_index);
+ app_push_bold_font();
+ ImGui::AlignTextToFramePadding();
+ ImGui::TextUnformatted("Binary");
+ app_pop_bold_font();
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("frame %.3fs", sample.mono_time);
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("tracker %.3fs", tracker_time);
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("%zu bytes", sample.data.size());
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("bus %d", summary.bus);
+ if (sample.bus_time != 0) {
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("bus_time %u", sample.bus_time);
+ }
+ if (prev != nullptr) {
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("dt %.1f ms", 1000.0 * (sample.mono_time - prev->mono_time));
+ }
+ ImGui::Spacing();
+ draw_payload_bytes(sample.data, prev == nullptr ? nullptr : &prev->data);
+ if (layout.overlapping_cells > 0) {
+ ImGui::SameLine(0.0f, 12.0f);
+ ImGui::TextColored(color_rgb(222, 181, 86), "%zu overlap%s",
+ layout.overlapping_cells, layout.overlapping_cells == 1 ? "" : "s");
+ }
+ ImGui::Spacing();
+
+ const float footer_reserve = state->cabana.has_bit_selection ? 152.0f : 8.0f;
+ const float matrix_height = std::max(140.0f, ImGui::GetContentRegionAvail().y - footer_reserve);
+ const float index_w = 28.0f;
+ const float hex_w = 36.0f;
+ const float bit_w = std::max(32.0f, (ImGui::GetContentRegionAvail().x - index_w - hex_w) / 8.0f);
+ const float grid_w = bit_w * 8.0f;
+ const float row_h = std::clamp((matrix_height - 4.0f) / std::max(1.0f, static_cast(byte_count)),
+ 28.0f,
+ 42.0f);
+ const float total_h = row_h * static_cast(byte_count);
+ const ImU32 base_bg = ImGui::GetColorU32(color_rgb(56, 58, 61));
+ const ImU32 heat_high = ImGui::GetColorU32(color_rgb(72, 117, 202));
+ const ImU32 cell_border = ImGui::GetColorU32(color_rgb(102, 106, 111, 0.72f));
+ const ImU32 text_color = ImGui::GetColorU32(color_rgb(219, 223, 228));
+ const ImU32 marker_color = ImGui::GetColorU32(color_rgb(175, 180, 186));
+ const ImU32 invalid_hatch = ImGui::GetColorU32(color_rgb(126, 131, 139, 0.58f));
+ const ImU32 selection_border = ImGui::GetColorU32(color_rgb(232, 236, 241, 0.95f));
+ const ImU32 hover_border = ImGui::GetColorU32(color_rgb(232, 236, 241, 0.32f));
+ const ImU32 drag_fill = ImGui::GetColorU32(color_rgb(87, 127, 219, 0.18f));
+ const ImU32 drag_border = ImGui::GetColorU32(color_rgb(54, 91, 184, 0.95f));
+ const bool released_this_frame = ImGui::IsMouseReleased(ImGuiMouseButton_Left);
+ bool drag_release_handled = false;
+ ImGui::BeginChild("##cabana_binary_grid", ImVec2(0.0f, matrix_height), false);
+ const ImVec2 origin = ImGui::GetCursorScreenPos();
+ const ImVec2 mouse = ImGui::GetIO().MousePos;
+ const float content_w = index_w + grid_w + hex_w;
+ ImGui::InvisibleButton("##cabana_binary_grid_area", ImVec2(content_w, total_h));
+ const bool area_hovered = ImGui::IsItemHovered();
+ const float scroll_y = ImGui::GetScrollY();
+
+ int hover_byte = -1;
+ int hover_bit = -1;
+ const bool tracking_hover = area_hovered || (state->cabana.binary_drag_active && ImGui::IsMouseDown(ImGuiMouseButton_Left));
+ if (tracking_hover) {
+ const float rel_x = mouse.x - (origin.x + index_w);
+ const float rel_y = mouse.y - origin.y + scroll_y;
+ if (rel_x >= 0.0f && rel_x < grid_w && rel_y >= 0.0f && rel_y < total_h) {
+ hover_byte = std::clamp(static_cast(rel_y / row_h), 0, static_cast(byte_count) - 1);
+ const int col = std::clamp(static_cast(rel_x / bit_w), 0, 7);
+ hover_bit = 7 - col;
+ }
+ }
+ if (state->cabana.binary_drag_active && hover_byte >= 0 && hover_bit >= 0) {
+ if (state->cabana.binary_drag_current_byte != hover_byte || state->cabana.binary_drag_current_bit != hover_bit) {
+ state->cabana.binary_drag_moved = true;
+ }
+ state->cabana.binary_drag_current_byte = hover_byte;
+ state->cabana.binary_drag_current_bit = hover_bit;
+ }
+
+ const int first_row = std::max(0, static_cast(scroll_y / row_h) - 1);
+ const int last_row = std::min(static_cast(byte_count),
+ static_cast((scroll_y + matrix_height) / row_h) + 2);
+ ImDrawList *draw = ImGui::GetWindowDrawList();
+ bool any_bit_hovered = false;
+
+ for (int row = first_row; row < last_row; ++row) {
+ const float y0 = origin.y + static_cast(row) * row_h - scroll_y;
+ const float y1 = y0 + row_h;
+ const bool valid = static_cast(row) < sample.data.size();
+
+ const ImRect index_cell(ImVec2(origin.x, y0), ImVec2(origin.x + index_w, y1));
+ draw->AddRectFilled(index_cell.Min, index_cell.Max, ImGui::GetColorU32(color_rgb(49, 51, 54)));
+ draw->AddRect(index_cell.Min, index_cell.Max, cell_border);
+ const std::string label = std::to_string(row);
+ const ImVec2 label_size = ImGui::CalcTextSize(label.c_str());
+ draw->AddText(ImVec2(index_cell.Min.x + (index_cell.GetWidth() - label_size.x) * 0.5f,
+ index_cell.Min.y + (index_cell.GetHeight() - label_size.y) * 0.5f),
+ ImGui::GetColorU32(color_rgb(84, 92, 103)),
+ label.c_str());
+
+ for (int col = 0; col < 8; ++col) {
+ const int bit = 7 - col;
+ const size_t cell_index = static_cast(row) * 8 + static_cast(bit);
+ const ImRect cell(ImVec2(origin.x + index_w + static_cast(col) * bit_w, y0),
+ ImVec2(origin.x + index_w + static_cast(col + 1) * bit_w, y1));
+ const float heat_alpha = cell_index < heat.size() ? heat[cell_index] : 0.0f;
+ const std::vector &cell_signals = layout.cell_signals[cell_index];
+ const bool hovered = hover_byte == row && hover_bit == bit;
+ const CabanaSignalSummary *clicked_signal = topmost_signal_at_cell(summary, layout, static_cast(row), bit);
+ const CabanaSignalSummary *resize_signal = resize_signal_at_cell(summary, layout, static_cast(row), bit);
+
+ if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
+ clear_cabana_binary_drag(state);
+ state->cabana.binary_drag_active = true;
+ state->cabana.binary_drag_press_byte = row;
+ state->cabana.binary_drag_press_bit = bit;
+ state->cabana.binary_drag_current_byte = row;
+ state->cabana.binary_drag_current_bit = bit;
+ if (resize_signal != nullptr) {
+ const int physical_bit = row * 8 + bit;
+ const int opposite = physical_bit == resize_signal->lsb ? resize_signal->msb : resize_signal->lsb;
+ state->cabana.binary_drag_resizing = true;
+ state->cabana.binary_drag_signal_is_little_endian = resize_signal->is_little_endian;
+ state->cabana.binary_drag_signal_path = resize_signal->path;
+ state->cabana.binary_drag_anchor_byte = opposite / 8;
+ state->cabana.binary_drag_anchor_bit = opposite & 7;
+ } else {
+ state->cabana.binary_drag_anchor_byte = row;
+ state->cabana.binary_drag_anchor_bit = bit;
+ }
+ }
+
+ draw->AddRectFilled(cell.Min, cell.Max, mix_color(base_bg, heat_high, heat_alpha * 0.55f));
+
+ if (valid && !cell_signals.empty()) {
+ for (int signal_index : cell_signals) {
+ const CabanaSignalSummary &signal = summary.signals[static_cast(signal_index)];
+ const bool emphasized = signal_charted(*state, signal.path);
+ const bool draw_left = !cell_has_signal(layout, row, bit + 1, signal_index);
+ const bool draw_right = !cell_has_signal(layout, row, bit - 1, signal_index);
+ const bool draw_top = !cell_has_signal(layout, row - 1, bit, signal_index);
+ const bool draw_bottom = !cell_has_signal(layout, row + 1, bit, signal_index);
+ ImRect inner = cell;
+ inner.Min.x += draw_left ? 3.0f : 1.0f;
+ inner.Max.x -= draw_right ? 3.0f : 1.0f;
+ inner.Min.y += draw_top ? 2.0f : 1.0f;
+ inner.Max.y -= draw_bottom ? 2.0f : 1.0f;
+ draw->AddRectFilled(inner.Min, inner.Max,
+ signal_fill_color(static_cast(signal_index), heat_alpha, emphasized),
+ 2.0f);
+ const ImU32 border_color = signal_border_color(static_cast(signal_index), emphasized);
+ const float thickness = emphasized ? 2.0f : 1.0f;
+ if (draw_left) draw->AddLine(ImVec2(inner.Min.x, inner.Min.y), ImVec2(inner.Min.x, inner.Max.y), border_color, thickness);
+ if (draw_right) draw->AddLine(ImVec2(inner.Max.x, inner.Min.y), ImVec2(inner.Max.x, inner.Max.y), border_color, thickness);
+ if (draw_top) draw->AddLine(ImVec2(inner.Min.x, inner.Min.y), ImVec2(inner.Max.x, inner.Min.y), border_color, thickness);
+ if (draw_bottom) draw->AddLine(ImVec2(inner.Min.x, inner.Max.y), ImVec2(inner.Max.x, inner.Max.y), border_color, thickness);
+ }
+ if (cell_signals.size() > 1) {
+ const ImVec2 a(cell.Max.x - 10.0f, cell.Min.y + 2.0f);
+ const ImVec2 b(cell.Max.x - 2.0f, cell.Min.y + 2.0f);
+ const ImVec2 c(cell.Max.x - 2.0f, cell.Min.y + 10.0f);
+ draw->AddTriangleFilled(a, b, c, ImGui::GetColorU32(color_rgb(223, 181, 87, 0.82f)));
+ }
+ } else if (!valid) {
+ draw->AddRectFilled(cell.Min, cell.Max, ImGui::GetColorU32(color_rgb(47, 49, 52)));
+ draw_cell_hatching(draw, cell, invalid_hatch, 7.0f);
+ }
+
+ if (cabana_drag_selection_contains_bit(*state, static_cast(row), bit)) {
+ draw->AddRectFilled(cell.Min, cell.Max, drag_fill);
+ const bool draw_left = !cabana_drag_selection_has_neighbor(*state, row, bit + 1);
+ const bool draw_right = !cabana_drag_selection_has_neighbor(*state, row, bit - 1);
+ const bool draw_top = !cabana_drag_selection_has_neighbor(*state, row - 1, bit);
+ const bool draw_bottom = !cabana_drag_selection_has_neighbor(*state, row + 1, bit);
+ if (draw_left) draw->AddLine(ImVec2(cell.Min.x, cell.Min.y), ImVec2(cell.Min.x, cell.Max.y), drag_border, 2.0f);
+ if (draw_right) draw->AddLine(ImVec2(cell.Max.x, cell.Min.y), ImVec2(cell.Max.x, cell.Max.y), drag_border, 2.0f);
+ if (draw_top) draw->AddLine(ImVec2(cell.Min.x, cell.Min.y), ImVec2(cell.Max.x, cell.Min.y), drag_border, 2.0f);
+ if (draw_bottom) draw->AddLine(ImVec2(cell.Min.x, cell.Max.y), ImVec2(cell.Max.x, cell.Max.y), drag_border, 2.0f);
+ }
+
+ draw->AddRect(cell.Min, cell.Max, cell_border);
+ if (valid) {
+ app_push_mono_font();
+ const char bit_text[2] = {static_cast(can_bit(sample.data, static_cast(row), bit) ? '1' : '0'), '\0'};
+ const ImVec2 text_size = ImGui::CalcTextSize(bit_text);
+ draw->AddText(ImGui::GetFont(),
+ ImGui::GetFontSize(),
+ ImVec2(cell.Min.x + (cell.GetWidth() - text_size.x) * 0.5f,
+ cell.Min.y + (cell.GetHeight() - text_size.y) * 0.5f - 1.0f),
+ text_color,
+ bit_text);
+ app_pop_mono_font();
+ }
+ if (layout.is_msb[cell_index] || layout.is_lsb[cell_index]) {
+ const char marker[2] = {layout.is_msb[cell_index] ? 'M' : 'L', '\0'};
+ draw->AddText(ImVec2(cell.Max.x - 11.0f, cell.Max.y - 14.0f), marker_color, marker);
+ }
+ if (cabana_bit_selected(*state, static_cast(row), bit)) {
+ draw->AddRect(cell.Min, cell.Max, selection_border, 0.0f, 0, 2.0f);
+ } else if (hovered) {
+ draw->AddRect(cell.Min, cell.Max, hover_border, 0.0f, 0, 1.0f);
+ }
+ if (hovered) {
+ any_bit_hovered = true;
+ if (clicked_signal != nullptr && !state->cabana.binary_drag_active) {
+ ImGui::SetTooltip("%s\nstart_bit %d size %d lsb %d msb %d",
+ clicked_signal->name.c_str(),
+ clicked_signal->start_bit,
+ clicked_signal->size,
+ clicked_signal->lsb,
+ clicked_signal->msb);
+ }
+ }
+ }
+
+ float byte_heat = 0.0f;
+ for (int bit = 0; bit < 8; ++bit) {
+ byte_heat = std::max(byte_heat, heat[static_cast(row) * 8 + static_cast(bit)]);
+ }
+ const ImRect hex_cell(ImVec2(origin.x + index_w + grid_w, y0),
+ ImVec2(origin.x + index_w + grid_w + hex_w, y1));
+ draw->AddRectFilled(hex_cell.Min, hex_cell.Max, mix_color(base_bg, heat_high, byte_heat * 0.5f));
+ draw->AddRect(hex_cell.Min, hex_cell.Max, cell_border);
+ if (valid) {
+ app_push_mono_font();
+ char hex[4];
+ std::snprintf(hex, sizeof(hex), "%02X", static_cast(sample.data[static_cast(row)]));
+ const ImVec2 text_size = ImGui::CalcTextSize(hex);
+ draw->AddText(ImGui::GetFont(),
+ ImGui::GetFontSize(),
+ ImVec2(hex_cell.Min.x + (hex_cell.GetWidth() - text_size.x) * 0.5f,
+ hex_cell.Min.y + (hex_cell.GetHeight() - text_size.y) * 0.5f - 1.0f),
+ text_color,
+ hex);
+ app_pop_mono_font();
+ } else {
+ draw_cell_hatching(draw, hex_cell, invalid_hatch, 7.0f);
+ }
+ }
+
+ ImGui::EndChild();
+ if (released_this_frame && state->cabana.binary_drag_active && !drag_release_handled) {
+ if (state->cabana.binary_drag_current_byte >= 0 && state->cabana.binary_drag_current_bit >= 0) {
+ const CabanaSignalSummary *release_signal =
+ topmost_signal_at_cell(summary,
+ layout,
+ static_cast(state->cabana.binary_drag_current_byte),
+ state->cabana.binary_drag_current_bit);
+ queue_binary_drag_apply(session,
+ summary,
+ state,
+ state->cabana.binary_drag_current_byte,
+ state->cabana.binary_drag_current_bit,
+ release_signal);
+ }
+ clear_cabana_binary_drag(state);
+ } else if (!any_bit_hovered && released_this_frame && state->cabana.binary_drag_active) {
+ clear_cabana_binary_drag(state);
+ }
+
+ ImGui::Spacing();
+ draw_bit_selection_panel(session, summary, state);
+}
+
+bool message_has_overlaps(const CabanaMessageSummary &message, const CanMessageData *message_data) {
+ const size_t byte_count = std::max(message_data == nullptr ? 0 : can_message_payload_width(*message_data),
+ cabana_signal_byte_count(message));
+ if (byte_count == 0) {
+ return false;
+ }
+ return build_binary_matrix_layout(message, byte_count).overlapping_cells > 0;
+}
+
+void draw_detail_toolbar(AppSession *session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ const CanMessageData *message_data) {
+ const std::string meta = message.service + " bus " + std::to_string(message.bus)
+ + (message.has_address ? " " + format_can_address(message.address) : std::string());
+ const std::string dbc_text = session->route_data.dbc_name.empty() ? "DBC: Auto / none"
+ : "DBC: " + session->route_data.dbc_name;
+ ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6.0f, 4.0f));
+ ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(8.0f, 6.0f));
+ ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg());
+ ImGui::PushStyleColor(ImGuiCol_Border, cabana_border_color());
+ const bool compact = ImGui::GetContentRegionAvail().x < 760.0f;
+ ImGui::BeginChild("##cabana_detail_toolbar", ImVec2(0.0f, compact ? 68.0f : 40.0f), true);
+ app_push_bold_font();
+ ImGui::AlignTextToFramePadding();
+ ImGui::TextUnformatted(message.name.c_str());
+ app_pop_bold_font();
+ ImGui::SameLine(0.0f, 8.0f);
+ ImGui::TextDisabled("%s", meta.c_str());
+ if (message.frequency_hz > 0.0) {
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("%.1f Hz", message.frequency_hz);
+ }
+ if (message_data != nullptr) {
+ ImGui::SameLine(0.0f, 10.0f);
+ ImGui::TextDisabled("%zu frames", message_data->samples.size());
+ }
+
+ if (compact) {
+ ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 4.0f);
+ } else {
+ ImGui::SameLine(std::max(340.0f, ImGui::GetWindowContentRegionMax().x * 0.46f));
+ }
+ ImGui::TextUnformatted("Heatmap:");
+ ImGui::SameLine(0.0f, 6.0f);
+ if (ImGui::RadioButton("Live", state->cabana.heatmap_live_mode)) {
+ state->cabana.heatmap_live_mode = true;
+ }
+ ImGui::SameLine(0.0f, 8.0f);
+ if (ImGui::RadioButton("All", !state->cabana.heatmap_live_mode)) {
+ state->cabana.heatmap_live_mode = false;
+ }
+ ImGui::SameLine(0.0f, 14.0f);
+ draw_cabana_toolbar_button("Edit DBC...", true, [&]() {
+ state->dbc_editor.open = true;
+ state->dbc_editor.loaded = false;
+ });
+ ImGui::SameLine(0.0f, 6.0f);
+ draw_cabana_toolbar_button("Export Raw CSV", message_data != nullptr, [&]() {
+ fs::path output_path;
+ std::string error;
+ if (export_raw_can_csv(*session, message, &error, &output_path)) {
+ state->status_text = "Exported raw CSV " + output_path.filename().string();
+ } else {
+ state->status_text = error;
+ }
+ });
+ ImGui::SameLine(0.0f, 6.0f);
+ draw_cabana_toolbar_button("Export Decoded CSV", message_data != nullptr, [&]() {
+ fs::path output_path;
+ std::string error;
+ if (export_decoded_can_csv(*session, message, &error, &output_path)) {
+ state->status_text = "Exported decoded CSV " + output_path.filename().string();
+ } else {
+ state->status_text = error;
+ }
+ });
+ if (!compact) {
+ const float right_w = ImGui::CalcTextSize(dbc_text.c_str()).x + 10.0f;
+ ImGui::SameLine(std::max(0.0f, ImGui::GetWindowContentRegionMax().x - right_w));
+ } else {
+ ImGui::SameLine(0.0f, 12.0f);
+ }
+ ImGui::TextDisabled("%s", dbc_text.c_str());
+ ImGui::EndChild();
+ ImGui::PopStyleColor(2);
+ ImGui::PopStyleVar(2);
+}
+
+void draw_messages_panel(AppSession *session, UiState *state) {
+ const std::string name_filter = trim_copy(state->cabana.message_filter.data());
+ const std::string bus_filter = trim_copy(state->cabana.message_bus_filter.data());
+ const std::string addr_filter = trim_copy(state->cabana.message_addr_filter.data());
+ const std::string node_filter = trim_copy(state->cabana.message_node_filter.data());
+ const std::string freq_filter = trim_copy(state->cabana.message_freq_filter.data());
+ const std::string count_filter = trim_copy(state->cabana.message_count_filter.data());
+ const std::string bytes_filter = trim_copy(state->cabana.message_bytes_filter.data());
+
+ std::vector filtered_indices;
+ filtered_indices.reserve(session->cabana_messages.size());
+ size_t filtered_signal_count = 0;
+ size_t filtered_dbc_count = 0;
+ for (int i = 0; i < static_cast(session->cabana_messages.size()); ++i) {
+ const CabanaMessageSummary &message = session->cabana_messages[static_cast(i)];
+ const CanMessageData *message_data = find_message_data(*session, message);
+ if (state->cabana.suppress_defined_signals && !message.signals.empty()) {
+ continue;
+ }
+ if (!cabana_message_matches_filters(message, name_filter, bus_filter, addr_filter, node_filter,
+ freq_filter, count_filter, bytes_filter, message_data)) {
+ continue;
+ }
+ filtered_indices.push_back(i);
+ filtered_signal_count += message.signals.size();
+ if (message.dbc_size > 0 || !message.node.empty() || !message.signals.empty()) {
+ ++filtered_dbc_count;
+ }
+ }
+
+ char title[160];
+ std::snprintf(title, sizeof(title), "%zu Messages (%zu DBC Messages, %zu Signals)",
+ filtered_indices.size(), filtered_dbc_count, filtered_signal_count);
+
+ ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg());
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f));
+ ImGui::BeginChild("##cabana_messages_header", ImVec2(0.0f, 34.0f), false, ImGuiWindowFlags_NoScrollbar);
+ ImGui::TextUnformatted(title);
+ const float clear_w = 46.0f;
+ const float checkbox_w = 126.0f;
+ const float target_x = std::max(ImGui::GetCursorPosX(), ImGui::GetWindowContentRegionMax().x - clear_w - checkbox_w - 16.0f);
+ ImGui::SameLine(target_x);
+ if (ImGui::SmallButton("Clear")) {
+ state->cabana.message_filter[0] = '\0';
+ state->cabana.message_bus_filter[0] = '\0';
+ state->cabana.message_addr_filter[0] = '\0';
+ state->cabana.message_node_filter[0] = '\0';
+ state->cabana.message_freq_filter[0] = '\0';
+ state->cabana.message_count_filter[0] = '\0';
+ state->cabana.message_bytes_filter[0] = '\0';
+ }
+ ImGui::SameLine(0.0f, 8.0f);
+ ImGui::Checkbox("Suppress Signals", &state->cabana.suppress_defined_signals);
+ ImGui::EndChild();
+ ImGui::PopStyleVar();
+ ImGui::PopStyleColor();
+ ImGui::Spacing();
+
+ if (session->cabana_messages.empty()) {
+ ImGui::TextDisabled("No CAN messages in this route.");
+ return;
+ }
+ if (filtered_indices.empty()) {
+ ImGui::TextDisabled("No messages match the current filters.");
+ return;
+ }
+
+ ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(4.0f, 2.0f));
+ if (ImGui::BeginTable("##cabana_messages", 7,
+ ImGuiTableFlags_RowBg |
+ ImGuiTableFlags_ScrollX |
+ ImGuiTableFlags_ScrollY |
+ ImGuiTableFlags_Borders |
+ ImGuiTableFlags_Resizable |
+ ImGuiTableFlags_SizingStretchProp,
+ ImGui::GetContentRegionAvail())) {
+ ImGui::TableSetupScrollFreeze(0, 2);
+ ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_NoHide, 1.8f);
+ ImGui::TableSetupColumn("Bus", ImGuiTableColumnFlags_WidthFixed, 46.0f);
+ ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 70.0f);
+ ImGui::TableSetupColumn("Node", ImGuiTableColumnFlags_WidthFixed, 84.0f);
+ ImGui::TableSetupColumn("Hz", ImGuiTableColumnFlags_WidthFixed, 54.0f);
+ ImGui::TableSetupColumn("Count", ImGuiTableColumnFlags_WidthFixed, 64.0f);
+ ImGui::TableSetupColumn("Bytes", ImGuiTableColumnFlags_WidthStretch, 1.2f);
+ ImGui::TableHeadersRow();
+
+ ImGui::TableNextRow();
+ ImGui::TableSetColumnIndex(0);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_name", "Filter", state->cabana.message_filter.data(),
+ state->cabana.message_filter.size());
+ ImGui::TableSetColumnIndex(1);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_bus", "Bus", state->cabana.message_bus_filter.data(),
+ state->cabana.message_bus_filter.size());
+ ImGui::TableSetColumnIndex(2);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_addr", "Addr", state->cabana.message_addr_filter.data(),
+ state->cabana.message_addr_filter.size());
+ ImGui::TableSetColumnIndex(3);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_node", "Node", state->cabana.message_node_filter.data(),
+ state->cabana.message_node_filter.size());
+ ImGui::TableSetColumnIndex(4);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_freq", "Hz", state->cabana.message_freq_filter.data(),
+ state->cabana.message_freq_filter.size());
+ ImGui::TableSetColumnIndex(5);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_count", "Count", state->cabana.message_count_filter.data(),
+ state->cabana.message_count_filter.size());
+ ImGui::TableSetColumnIndex(6);
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputTextWithHint("##cabana_filter_bytes", "Bytes", state->cabana.message_bytes_filter.data(),
+ state->cabana.message_bytes_filter.size());
+
+ ImGuiListClipper clipper;
+ clipper.Begin(static_cast(filtered_indices.size()));
+ while (clipper.Step()) {
+ for (int row = clipper.DisplayStart; row < clipper.DisplayEnd; ++row) {
+ const CabanaMessageSummary &message = session->cabana_messages[static_cast(filtered_indices[static_cast(row)])];
+ const bool selected = state->cabana.selected_message_root == message.root_path;
+ const std::string address = message.has_address ? format_can_address(message.address) : std::string("--");
+
+ ImGui::TableNextRow(0, 22.0f);
+ ImGui::TableSetColumnIndex(0);
+ if (ImGui::Selectable((message.name + "##" + message.root_path).c_str(), selected,
+ ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) {
+ select_cabana_message(session, state, message.root_path);
+ }
+ if (ImGui::IsItemHovered()) {
+ ImGui::SetTooltip("%s", message.root_path.c_str());
+ }
+
+ ImGui::TableSetColumnIndex(1);
+ const std::string bus_label = message.service == "sendcan"
+ ? "S" + std::to_string(message.bus)
+ : std::to_string(message.bus);
+ ImGui::TextUnformatted(bus_label.c_str());
+ ImGui::TableSetColumnIndex(2);
+ ImGui::TextUnformatted(address.c_str());
+ ImGui::TableSetColumnIndex(3);
+ if (message.node.empty()) ImGui::TextDisabled("-");
+ else ImGui::TextUnformatted(message.node.c_str());
+ ImGui::TableSetColumnIndex(4);
+ if (message.frequency_hz >= 0.95) ImGui::Text("%.0f", message.frequency_hz);
+ else if (message.frequency_hz > 0.0) ImGui::Text("%.2f", message.frequency_hz);
+ else ImGui::TextDisabled("-");
+ ImGui::TableSetColumnIndex(5);
+ ImGui::Text("%zu", message.sample_count);
+ ImGui::TableSetColumnIndex(6);
+ const CanMessageData *message_data = find_message_data(*session, message);
+ if (message_data != nullptr && !message_data->samples.empty()) {
+ const size_t current_index = state->has_tracker_time
+ ? closest_can_sample_index(*message_data, state->tracker_time)
+ : (message_data->samples.size() - 1);
+ const CanFrameSample &last = message_data->samples[current_index];
+ const CanFrameSample *prev = current_index > 0 ? &message_data->samples[current_index - 1] : nullptr;
+ draw_payload_preview_boxes(("##msg_bytes_" + message.root_path).c_str(),
+ last.data,
+ prev == nullptr ? nullptr : &prev->data,
+ std::max(72.0f, ImGui::GetColumnWidth() - 10.0f));
+ } else {
+ ImGui::TextDisabled("-");
+ }
+ }
+ }
+ ImGui::EndTable();
+ }
+ ImGui::PopStyleVar();
+}
+
+void draw_logs_toolbar(const AppSession &session,
+ UiState *state,
+ const CabanaMessageSummary &message,
+ bool can_show_signal_mode,
+ bool show_signal_mode) {
+ ImGui::PushStyleColor(ImGuiCol_ChildBg, cabana_panel_alt_bg());
+ ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f));
+ const bool compact = ImGui::GetContentRegionAvail().x < 560.0f;
+ ImGui::BeginChild("##cabana_logs_toolbar", ImVec2(0.0f, compact ? 56.0f : 34.0f), false, ImGuiWindowFlags_NoScrollbar);
+ ImGui::AlignTextToFramePadding();
+ ImGui::TextUnformatted("Display:");
+ ImGui::SameLine(0.0f, 6.0f);
+ ImGui::BeginDisabled(!can_show_signal_mode);
+ if (ImGui::RadioButton("Signal", show_signal_mode)) {
+ state->cabana.logs_hex_mode = false;
+ }
+ ImGui::EndDisabled();
+ ImGui::SameLine(0.0f, 8.0f);
+ if (ImGui::RadioButton("Hex", !show_signal_mode)) {
+ state->cabana.logs_hex_mode = true;
+ }
+
+ const std::string signal_path(active_signal_path(*state));
+ if (can_show_signal_mode) {
+ const size_t slash = signal_path.find_last_of('/');
+ const std::string signal_name = slash == std::string::npos ? signal_path : signal_path.substr(slash + 1);
+ ImGui::SameLine(0.0f, 12.0f);
+ ImGui::TextDisabled("%s", signal_name.c_str());
+ if (show_signal_mode) {
+ static constexpr const char *kOps[] = {">", "=", "!=", "<"};
+ if (compact) {
+ ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 4.0f);
+ } else {
+ ImGui::SameLine(0.0f, 10.0f);
+ }
+ ImGui::SetNextItemWidth(54.0f);
+ ImGui::Combo("##cabana_logs_cmp", &state->cabana.logs_filter_compare, kOps, IM_ARRAYSIZE(kOps));
+ ImGui::SameLine(0.0f, 6.0f);
+ ImGui::SetNextItemWidth(96.0f);
+ ImGui::InputTextWithHint("##cabana_logs_value", "value", state->cabana.logs_filter_value.data(),
+ state->cabana.logs_filter_value.size());
+ }
+ }
+
+ const float export_w = 76.0f;
+ ImGui::SameLine(std::max(0.0f, ImGui::GetWindowContentRegionMax().x - export_w));
+ if (ImGui::SmallButton("Export")) {
+ fs::path output_path;
+ std::string error;
+ const bool ok = show_signal_mode ? export_decoded_can_csv(session, message, &error, &output_path)
+ : export_raw_can_csv(session, message, &error, &output_path);
+ state->status_text = ok ? ("Exported " + output_path.filename().string()) : error;
+ }
+ ImGui::EndChild();
+ ImGui::PopStyleVar();
+ ImGui::PopStyleColor();
+ ImGui::Spacing();
+}
+
+void draw_message_history(const AppSession &session, UiState *state, const CabanaMessageSummary &message) {
+ const CanMessageData *message_data = find_message_data(session, message);
+ if (message_data == nullptr || message_data->samples.empty()) {
+ ImGui::TextDisabled("No frame history available.");
+ return;
+ }
+
+ const std::string signal_path(active_signal_path(*state));
+ const RouteSeries *series = signal_path.empty() ? nullptr : app_find_route_series(session, signal_path);
+ const auto format_it = signal_path.empty() ? session.route_data.series_formats.end() : session.route_data.series_formats.find(signal_path);
+ const auto enum_it = signal_path.empty() ? session.route_data.enum_info.end() : session.route_data.enum_info.find(signal_path);
+ const size_t current_index = closest_can_sample_index(*message_data, state->tracker_time);
+ const bool can_show_signal_mode = series != nullptr;
+ const bool show_signal_mode = can_show_signal_mode && !state->cabana.logs_hex_mode;
+
+ draw_logs_toolbar(session, state, message, can_show_signal_mode, show_signal_mode);
+
+ const bool have_filter = show_signal_mode && state->cabana.logs_filter_value[0] != '\0';
+ const double filter_value = have_filter ? std::strtod(state->cabana.logs_filter_value.data(), nullptr) : 0.0;
+ auto passes_filter = [&](double value) {
+ if (!have_filter) return true;
+ switch (state->cabana.logs_filter_compare) {
+ case 0: return value > filter_value;
+ case 1: return value == filter_value;
+ case 2: return value != filter_value;
+ case 3: return value < filter_value;
+ default: return true;
+ }
+ };
+
+ ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(5.0f, 3.0f));
+ const int columns = (!show_signal_mode && series != nullptr) ? 4 : 3;
+ if (ImGui::BeginTable("##cabana_history", columns,
+ ImGuiTableFlags_RowBg |
+ ImGuiTableFlags_ScrollY |
+ ImGuiTableFlags_SizingStretchProp |
+ ImGuiTableFlags_BordersInnerV |
+ ImGuiTableFlags_BordersOuterH |
+ ImGuiTableFlags_NoPadOuterX,
+ ImGui::GetContentRegionAvail())) {
+ ImGui::TableSetupScrollFreeze(0, 1);
+ ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 96.0f);
+ ImGui::TableSetupColumn("dt", ImGuiTableColumnFlags_WidthFixed, 72.0f);
+ if (show_signal_mode) {
+ ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthStretch, 1.0f);
+ } else {
+ ImGui::TableSetupColumn("Data", ImGuiTableColumnFlags_WidthStretch, 1.0f);
+ }
+ if (!show_signal_mode && series != nullptr) {
+ ImGui::TableSetupColumn("Value", ImGuiTableColumnFlags_WidthFixed, 108.0f);
+ }
+ ImGui::TableHeadersRow();
+
+ ImGuiListClipper clipper;
+ clipper.Begin(static_cast(message_data->samples.size()));
+ while (clipper.Step()) {
+ for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) {
+ const CanFrameSample &sample = message_data->samples[static_cast(i)];
+ const CanFrameSample *prev = i > 0 ? &message_data->samples[static_cast