Files
StarPilot/openpilot/tools/cabana/ui/widgets/detailwidget.cc
T
Trey Moen 3b2a75a4e0 cabana: resize heatmap and prevent signal clipping (#38975)
* cabana: allow heatmap resizing without clipping

* cabana: keep outer grid lines inside the heatmap

* cabana: add a visible heatmap resize handle

* cabana: use the standard pane divider for resizing
2026-09-19 18:41:58 -07:00

438 lines
17 KiB
C++

#include "tools/cabana/ui/widgets/detailwidget.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdio>
#include <utility>
#include "imgui.h"
#include "imgui_internal.h"
#include "tools/cabana/commands.h"
#include "tools/cabana/ui/icons.h"
#include "tools/cabana/ui/util.h"
#include "tools/cabana/utils/strings.h"
#include "tools/cabana/utils/util.h"
namespace {
bool iequals(const std::string &a, const std::string &b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return std::tolower((unsigned char)x) == std::tolower((unsigned char)y); });
}
} // namespace
ElidedLabel::ElidedLabel(const std::string &text) : text_(utils::trimmed(text)) {}
void ElidedLabel::draw(float width) {
ImGuiWindow *window = ImGui::GetCurrentWindow();
const ImVec2 pos(window->DC.CursorPos.x, window->DC.CursorPos.y + window->DC.CurrLineTextBaseOffset);
const ImRect bb(pos, ImVec2(pos.x + width, pos.y + ImGui::GetTextLineHeight()));
ImGui::ItemSize(bb.GetSize(), 0.0f);
if (ImGui::ItemAdd(bb, 0)) {
ImGui::RenderTextEllipsis(window->DrawList, bb.Min, bb.Max, bb.Max.x, text_.c_str(), nullptr, nullptr);
}
if (!tooltip_.empty()) ImGui::SetItemTooltip("%s", tooltip_.c_str());
if (ImGui::IsItemHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
clicked();
}
}
DetailWidget::DetailWidget(ChartsWidget *charts) : charts_(charts) {
tabbar_.setUsesScrollButtons(true);
tabbar_.setAutoHide(true);
tabbar_.setTabsClosable(true);
connections_.push_back(tabbar_.currentChanged.connect([this](int index) {
if (index >= 0) setMessage(MessageId::fromString(tabbar_.tabText(index)));
}));
connections_.push_back(tabbar_.tabCloseRequested.connect([this](int index) { tabbar_.removeTab(index); }));
connections_.push_back(tabbar_.tabContextMenu.connect([this](int index) { showTabBarContextMenu(index); }));
heatmap_live_ = can->liveStreaming();
binary_view_ = std::make_unique<BinaryView>();
signal_view_ = std::make_unique<SignalView>(charts);
history_log_ = std::make_unique<LogsWidget>();
page_tabs_.addTab("Messages");
page_tabs_.addTab("Logs");
connections_.push_back(page_tabs_.currentChanged.connect([this](int index) {
tab_widget_index_ = index;
if (index == 1) history_log_->onShown();
updateState();
}));
connections_.push_back(binary_view_->signalsChanged.connect([this]() { refresh(); }));
connections_.push_back(binary_view_->signalHovered.connect([this](const cabana::Signal *s) { signal_view_->signalHovered(s); }));
connections_.push_back(binary_view_->signalClicked.connect([this](const cabana::Signal *s) { signal_view_->selectSignal(s, true); }));
connections_.push_back(binary_view_->editSignal.connect([this](const cabana::Signal *origin_s, cabana::Signal &s) { signal_view_->saveSignal(origin_s, s); }));
connections_.push_back(binary_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); }));
connections_.push_back(signal_view_->showChart.connect([this](const MessageId &id, const cabana::Signal *sig, bool show, bool merge) { charts_->showChart(id, sig, show, merge); }));
connections_.push_back(signal_view_->highlight.connect([this](const cabana::Signal *sig) { binary_view_->highlight(sig); }));
connections_.push_back(can->msgsReceived.connect([this](const std::set<MessageId> *msgs, bool) { updateState(msgs); }));
connections_.push_back(dbc()->fileChanged.connect([this]() { refresh(); }));
connections_.push_back(UndoStack::instance()->indexChanged.connect([this]() { refresh(); }));
connections_.push_back(charts->seriesChanged.connect([this]() { signal_view_->updateChartState(); }));
connections_.push_back(can->timeRangeChanged.connect([this](const std::optional<std::pair<double, double>> &range) {
char text[64];
if (range) snprintf(text, sizeof(text), "%.3f - %.3f", range->first, range->second);
heatmap_all_text_ = range ? text : "All";
heatmap_live_ = !range && can->liveStreaming();
binary_view_->setHeatmapLiveMode(heatmap_live_);
}));
}
void DetailWidget::drawToolBar() {
const ImGuiStyle &style = ImGui::GetStyle();
std::vector<ToolbarItem> items;
float name_width = 0.0f;
items.push_back({0.0f, [this, &name_width]() {
ImGui::AlignTextToFramePadding();
pushBoldFont();
name_label_.draw(name_width);
popBoldFont();
}});
items.back().in_menu = false;
const size_t spacer_index = items.size();
const std::string heatmap_text = "Heatmap: " + (!heatmap_visible_ ? std::string("Hidden") : heatmap_live_ ? std::string("Live") : heatmap_all_text_);
auto heatmap_items = [this]() {
if (dropdown::Item("Hidden", nullptr, !heatmap_visible_)) heatmap_visible_ = false;
if (dropdown::Item("Live", nullptr, heatmap_visible_ && heatmap_live_)) {
heatmap_visible_ = true;
heatmap_live_ = true;
binary_view_->setHeatmapLiveMode(true);
}
if (dropdown::Item(heatmap_all_text_.c_str(), nullptr, heatmap_visible_ && !heatmap_live_)) {
heatmap_visible_ = true;
heatmap_live_ = false;
binary_view_->setHeatmapLiveMode(false);
}
};
items.push_back(toolbarMenu("heatmap", heatmap_text, "Heatmap", heatmap_items));
items.push_back({1.0f, []() { ImGui::SeparatorEx(ImGuiSeparatorFlags_Vertical); }});
items.back().in_menu = false;
// Capture the panel width before the action can run inside the overflow popup.
const float panel_width = ImGui::GetWindowWidth();
items.push_back(toolbarAction("edit_msg", icon::PENCIL, "Edit Message", [this, panel_width]() { editMsg(panel_width); }));
items.push_back(toolbarAction("remove_msg", icon::TRASH, "Remove Message",
[this]() { UndoStack::instance()->push(new RemoveMsgCommand(msg_id_)); }, action_remove_msg_enabled_));
const float right_width = toolbarWidth(items, spacer_index) - style.ItemSpacing.x;
name_width = std::max(ImGui::CalcTextSize("MMMMMM").x, ImGui::GetContentRegionAvail().x - right_width - style.ItemSpacing.x);
items[0].width = name_width;
drawToolbar(items, spacer_index);
}
void DetailWidget::showTabBarContextMenu(int index) {
if (dropdown::BeginPopupContextItem()) {
if (dropdown::Item("Close Other Tabs")) {
tabbar_.moveTab(index, 0);
tabbar_.setCurrentIndex(0);
while (tabbar_.count() > 1) tabbar_.removeTab(1);
}
dropdown::EndPopup();
}
}
int DetailWidget::findOrAddTab(const MessageId &message_id) {
const std::string text = message_id.toString();
int index = tabbar_.count() - 1;
for (/**/; index >= 0; --index) {
if (tabbar_.tabText(index) == text) break;
}
if (index == -1) {
index = tabbar_.addTab(text);
tabbar_.setTabToolTip(index, msgName(message_id));
}
return index;
}
void DetailWidget::setMessage(const MessageId &message_id) {
if (std::exchange(msg_id_, message_id) == message_id) return;
tabbar_.setCurrentIndex(findOrAddTab(message_id));
signal_view_->setMessage(msg_id_);
binary_view_->setMessage(msg_id_);
history_log_->setMessage(msg_id_);
refresh();
}
std::pair<std::string, std::vector<std::string>> DetailWidget::serializeMessageIds() const {
std::vector<std::string> msgs;
for (int i = 0; i < tabbar_.count(); ++i) msgs.push_back(tabbar_.tabText(i));
return std::make_pair(msg_id_.toString(), msgs);
}
void DetailWidget::restoreTabs(const std::string &active_msg_id, const std::vector<std::string>& msg_ids) {
for (const auto& str_id : msg_ids) {
MessageId id = MessageId::fromString(str_id);
if (dbc()->msg(id) != nullptr)
findOrAddTab(id);
}
auto active_id = MessageId::fromString(active_msg_id);
if (dbc()->msg(active_id) != nullptr)
setMessage(active_id);
}
void DetailWidget::refresh() {
auto msg = dbc()->msg(msg_id_);
std::string msg_name = msg ? msg->name + " (" + msg->transmitter + ")" : msgName(msg_id_);
name_label_.setText(msg_name);
name_label_.setToolTip(msg_name);
action_remove_msg_enabled_ = msg != nullptr;
updateWarnings();
}
void DetailWidget::updateWarnings() {
std::vector<std::string> warnings;
auto msg = dbc()->msg(msg_id_);
if (msg) {
// A restored tab or a seek can refer to a bus with no sample at the current time.
// lastMessage() returns empty data in that case, not a received zero-byte frame.
if (msg_id_.source == INVALID_SOURCE || !can->lastMessages().count(msg_id_)) {
warnings.push_back("No messages received.");
} else if (msg->size != can->lastMessage(msg_id_).dat.size()) {
warnings.push_back("Message size mismatch: DBC defines " + std::to_string(msg->size) +
" bytes, received " + std::to_string(can->lastMessage(msg_id_).dat.size()) + " bytes.");
}
for (auto s : binary_view_->getOverlappingSignals()) {
warnings.push_back(s->name + " has overlapping bits.");
}
}
if (!warnings.empty()) {
warning_label_.clear();
for (size_t i = 0; i < warnings.size(); ++i) {
if (i) warning_label_ += '\n';
warning_label_ += warnings[i];
}
warning_icon_ = msg ? icon::EXCLAMATION_TRIANGLE : icon::INFO_CIRCLE;
}
warning_widget_visible_ = !warnings.empty();
}
void DetailWidget::updateState(const std::set<MessageId> *msgs) {
if ((msgs && !msgs->count(msg_id_)))
return;
if (tab_widget_index_ == 0)
binary_view_->updateState();
else
history_log_->updateState();
updateWarnings();
}
void DetailWidget::editMsg(float parent_width) {
auto msg = dbc()->msg(msg_id_);
int size = msg ? msg->size : can->lastMessage(msg_id_).dat.size();
edit_dlg_ = std::make_unique<EditMessageDialog>(msg_id_, msgName(msg_id_), size, parent_width);
}
void DetailWidget::drawTabWidget() {
const ImGuiStyle &style = ImGui::GetStyle();
page_tabs_.draw();
if (tab_widget_index_ == 0) {
if (heatmap_visible_) {
// Allow the heatmap to grow while reserving room for signal rows.
const float handle_height = style.DockingSeparatorSize;
const float avail = ImGui::GetContentRegionAvail().y;
const float max_height = std::max(avail - handle_height - style.ItemSpacing.y * 2 - SignalView::minimumHeight(), 1.0f);
const float min_height = std::min(ImGui::GetFrameHeight(), max_height);
float height = std::clamp(heatmap_height_ < 0 ? binary_view_->minimumSizeHint().y : heatmap_height_, min_height, max_height);
ImGui::BeginChild("binary_view", ImVec2(0, height), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar);
binary_view_rect_ = ImGui::GetCurrentWindow()->Rect();
binary_view_->draw();
ImGui::EndChild();
const ImVec2 pos = ImGui::GetCursorScreenPos();
const ImVec2 size(std::max(ImGui::GetContentRegionAvail().x, 1.0f), handle_height);
const ImRect splitter(pos, ImVec2(pos.x + size.x, pos.y + size.y));
float signal_height = std::max(avail - height - handle_height - style.ItemSpacing.y * 2, SignalView::minimumHeight());
if (ImGui::SplitterBehavior(splitter, ImGui::GetID("##heatmap_resize"), ImGuiAxis_Y,
&height, &signal_height, min_height, SignalView::minimumHeight(), 4.0f)) {
heatmap_height_ = height;
}
ImGui::Dummy(size);
}
const float signal_height = std::max(ImGui::GetContentRegionAvail().y, SignalView::minimumHeight());
ImGui::BeginChild("signal_view", ImVec2(0, signal_height), ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
signal_view_rect_ = ImGui::GetCurrentWindow()->Rect();
signal_view_->draw();
ImGui::EndChild();
} else {
history_log_->draw();
}
}
void DetailWidget::draw() {
tabbar_.draw();
ImGui::BeginChild("message_content", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding);
drawToolBar();
if (warning_widget_visible_) {
ImGui::TextUnformatted(warning_icon_);
ImGui::SameLine();
ImGui::TextUnformatted(warning_label_.c_str());
}
drawTabWidget();
ImGui::EndChild();
if (edit_dlg_ && !edit_dlg_->draw()) {
if (edit_dlg_->accepted()) {
const auto r = edit_dlg_->result();
UndoStack::instance()->push(new EditMsgCommand(r.msg_id, r.name, r.size, r.node, r.comment));
}
edit_dlg_.reset();
}
}
// HelpOverlay: the whatsThis text and last drawn rect of the binary view and the signal view
std::vector<std::pair<std::string, ImRect>> DetailWidget::helpRects() const {
std::vector<std::pair<std::string, ImRect>> rects;
if (tab_widget_index_ == 0) {
if (heatmap_visible_) rects.emplace_back(binary_view_->whatsThis(), binary_view_rect_);
rects.emplace_back(signal_view_->whatsThis(), signal_view_rect_);
}
return rects;
}
EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const std::string &title, int size, float parent_width)
: msg_id_(msg_id), original_name_(title), name_edit_(title), size_spin_(size), width_(parent_width * 0.9f) {
window_title_ = "Edit Message: " + msg_id.toString();
if (auto msg = dbc()->msg(msg_id)) {
node_ = msg->transmitter;
comment_edit_ = msg->comment;
}
validateName(name_edit_);
}
EditMessageDialog::Result EditMessageDialog::result() const {
return {msg_id_, utils::trimmed(name_edit_), utils::trimmed(node_), utils::trimmed(comment_edit_), size_spin_};
}
bool EditMessageDialog::draw() {
if (closed_) return false;
if (!opened_) {
ImGui::OpenPopup(window_title_.c_str());
opened_ = true;
}
// The form needs room for message names and comments even when its panel is narrow.
const float max_width = std::max(1.0f, ImGui::GetMainViewport()->WorkSize.x - ImGui::GetStyle().WindowPadding.x * 2);
const float min_width = std::min(600.0f, max_width);
ImGui::SetNextWindowSizeConstraints(ImVec2(min_width, 0.0f), ImVec2(max_width, FLT_MAX));
setNextDialogWindow(ImVec2(std::clamp(width_, min_width, max_width), 0.0f));
bool open = true;
if (ImGui::BeginPopupModal(window_title_.c_str(), &open)) {
const float label_width = ImGui::GetCursorPosX() + ImGui::CalcTextSize("Comment").x + ImGui::GetStyle().ItemSpacing.x;
auto row = [&](const char *label) {
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(label);
ImGui::SameLine(label_width);
ImGui::SetNextItemWidth(-FLT_MIN);
};
if (!error_label_.empty()) {
row("");
ImGui::TextUnformatted(error_label_.c_str());
}
row("Name");
if (validatedInput("##name", &name_edit_, nameValidator)) {
validateName(name_edit_);
}
row("Size");
if (inputInt("##size", &size_spin_)) size_spin_ = std::clamp(size_spin_, 1, CAN_MAX_DATA_BYTES);
row("Node");
validatedInput("##node", &node_, nameValidator);
row("Comment");
inputTextMultiline("##comment", &comment_edit_, ImVec2(-FLT_MIN, 192.0f));
const bool comment_active = ImGui::IsItemActive();
bool accept = false, reject = false;
if (dialogButtons("OK", &accept, &reject, ok_enabled_)) {
accepted_ = accept;
closed_ = true;
}
// Enter triggers the default (OK) button
if (!closed_ && ok_enabled_ && !comment_active && ImGui::IsKeyPressed(ImGuiKey_Enter, false)) {
accepted_ = true;
closed_ = true;
}
if (!open) closed_ = true;
if (closed_) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
} else {
closed_ = true; // closed from outside
}
return !closed_;
}
void EditMessageDialog::validateName(const std::string &text) {
bool valid = !iequals(text, UNTITLED);
error_label_.clear();
if (!text.empty() && valid && text != original_name_) {
valid = dbc()->msg(msg_id_.source, text) == nullptr;
if (!valid) error_label_ = "Name already exists";
}
ok_enabled_ = valid;
}
DetailWidget* CenterWidget::ensureDetailWidget() {
if (!detail_widget) {
detail_widget = std::make_unique<DetailWidget>(charts_);
}
return detail_widget.get();
}
void CenterWidget::clear() {
detail_widget.reset();
charts_ = nullptr; // MainWindow recreates the ChartsWidget after startStream
}
void CenterWidget::draw() {
if (detail_widget) {
detail_widget->draw();
} else {
drawWelcomeWidget();
}
}
void CenterWidget::drawWelcomeWidget() {
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos();
auto centered = [&](const char *text, float y) {
const ImVec2 size = ImGui::CalcTextSize(text);
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - size.x) * 0.5f, y));
ImGui::TextUnformatted(text);
};
ImGui::PushStyleColor(ImGuiCol_Text, palette().text_disabled);
float y = origin.y + avail.y * 0.5f - 90.0f;
pushLargeFont();
centered("CABANA", y);
y += ImGui::GetTextLineHeightWithSpacing();
popLargeFont();
auto newShortcutRow = [&](const char *title, const char *key) {
const float w = ImGui::CalcTextSize(title).x + ImGui::CalcTextSize(key).x + 40.0f;
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - w) * 0.5f, y));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(title);
ImGui::SameLine();
ImGui::BeginDisabled();
ImGui::SmallButton(key);
ImGui::EndDisabled();
y += ImGui::GetFrameHeightWithSpacing();
};
centered("<- Select a message to view details", y);
y += ImGui::GetTextLineHeightWithSpacing();
newShortcutRow("Pause", "Space");
newShortcutRow("Help", "F1");
newShortcutRow("What's This?", "Shift+F1");
ImGui::PopStyleColor();
}