feat: value history timestamps, Ctrl+F search, base address fixes

- Add timestamps to ValueHistory ring buffer, expose via new MCP tool
  node.history, show relative time in popup ("26s ago", "2m ago")
- Add "Clear Value History" right-click menu for single and multi-select
- Add Ctrl+F find bar to RcxEditor with live search, Enter-to-next, wrap
- Fix Ctrl+F in workspace dock to auto-focus search field
- Add "Change to float" quick-convert for Hex32 right-click menu
- Sort workspace explorer by children count descending (most fields first)
- Fix provider->base() overwriting saved base address from .rcx files
- Add formula support to MCP change_base operation
- Re-evaluate baseAddressFormula on provider attach in selectSource()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
IChooseYou
2026-03-02 10:00:17 -07:00
parent ba1c2f8e5a
commit efae193520
9 changed files with 11563 additions and 6 deletions

View File

@@ -1581,6 +1581,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
} else if (commonKind == NodeKind::Hex32) { } else if (commonKind == NodeKind::Hex32) {
menu.addAction("Change to uint32_t", [this, collectIndices]() { menu.addAction("Change to uint32_t", [this, collectIndices]() {
batchChangeKind(collectIndices(), NodeKind::UInt32); }); batchChangeKind(collectIndices(), NodeKind::UInt32); });
menu.addAction("Change to float", [this, collectIndices]() {
batchChangeKind(collectIndices(), NodeKind::Float); });
addedQuickConvert = true; addedQuickConvert = true;
} else if (commonKind == NodeKind::Hex16) { } else if (commonKind == NodeKind::Hex16) {
menu.addAction("Change to int16_t", [this, collectIndices]() { menu.addAction("Change to int16_t", [this, collectIndices]() {
@@ -1629,6 +1631,18 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
act->setChecked(m_trackValues); act->setChecked(m_trackValues);
connect(act, &QAction::toggled, this, &RcxController::setTrackValues); connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
} }
{
auto* act = menu.addAction("Clear Value History");
act->setToolTip(QStringLiteral("Reset change tracking for selected nodes"));
connect(act, &QAction::triggered, this, [this, ids]() {
for (uint64_t id : ids) {
m_valueHistory[id].clear();
for (auto& lm : m_lastResult.meta)
if (lm.nodeId == id) lm.heatLevel = 0;
}
refresh();
});
}
menu.addSeparator(); menu.addSeparator();
// Check if all selected nodes share the same parent (required for grouping) // Check if all selected nodes share the same parent (required for grouping)
@@ -1723,6 +1737,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
int ni = m_doc->tree.indexOfId(nodeId); int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32); if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
}); });
menu.addAction("Change to float", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::Float);
});
addedQuickConvert = true; addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex16) { } else if (node.kind == NodeKind::Hex16) {
menu.addAction("Change to int16_t", [this, nodeId]() { menu.addAction("Change to int16_t", [this, nodeId]() {
@@ -1812,6 +1830,17 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
act->setChecked(m_trackValues); act->setChecked(m_trackValues);
connect(act, &QAction::toggled, this, &RcxController::setTrackValues); connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
} }
{
auto* act = menu.addAction("Clear Value History");
act->setToolTip(QStringLiteral("Reset change tracking for this node"));
act->setEnabled(m_valueHistory.contains(nodeId) && m_valueHistory[nodeId].uniqueCount() > 0);
connect(act, &QAction::triggered, this, [this, nodeId]() {
m_valueHistory[nodeId].clear();
for (auto& lm : m_lastResult.meta)
if (lm.nodeId == nodeId) lm.heatLevel = 0;
refresh();
});
}
menu.addSeparator(); menu.addSeparator();
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8) // Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
@@ -2934,7 +2963,32 @@ void RcxController::selectSource(const QString& text) {
m_doc->undoStack.clear(); m_doc->undoStack.clear();
m_doc->provider = std::move(provider); m_doc->provider = std::move(provider);
m_doc->dataPath.clear(); m_doc->dataPath.clear();
m_doc->tree.baseAddress = (newBase != 0) ? newBase : m_doc->tree.baseAddress; m_doc->tree.pointerSize = m_doc->provider->pointerSize();
// Re-evaluate formula if present (mirrors attachViaPlugin)
if (!m_doc->tree.baseAddressFormula.isEmpty()) {
AddressParserCallbacks cbs;
auto* prov = m_doc->provider.get();
cbs.resolveModule = [prov](const QString& name, bool* ok) -> uint64_t {
uint64_t base = prov->symbolToAddress(name);
*ok = (base != 0);
return base;
};
int ptrSz = m_doc->tree.pointerSize;
cbs.readPointer = [prov, ptrSz](uint64_t addr, bool* ok) -> uint64_t {
uint64_t val = 0;
*ok = prov->read(addr, &val, ptrSz);
return val;
};
auto result = AddressParser::evaluate(
m_doc->tree.baseAddressFormula, ptrSz, &cbs);
if (result.ok)
m_doc->tree.baseAddress = result.value;
} else if (newBase != 0 && m_doc->tree.baseAddress == 0x00400000) {
// Only apply provider base for fresh/default projects.
// If user loaded an .rcx with a custom base, preserve it.
m_doc->tree.baseAddress = newBase;
}
resetSnapshot(); resetSnapshot();
emit m_doc->documentChanged(); emit m_doc->documentChanged();

View File

@@ -11,6 +11,7 @@
#include <array> #include <array>
#include <memory> #include <memory>
#include <variant> #include <variant>
#include <QDateTime>
#include "providers/provider.h" #include "providers/provider.h"
#include "providers/buffer_provider.h" #include "providers/buffer_provider.h"
@@ -500,6 +501,7 @@ struct NodeTree {
struct ValueHistory { struct ValueHistory {
static constexpr int kCapacity = 10; static constexpr int kCapacity = 10;
std::array<QString, kCapacity> values; std::array<QString, kCapacity> values;
std::array<qint64, kCapacity> timestamps{}; // msec since epoch
int count = 0; // total unique values recorded int count = 0; // total unique values recorded
int head = 0; // next write position in ring int head = 0; // next write position in ring
@@ -509,10 +511,16 @@ struct ValueHistory {
if (values[last] == v) return; // no change if (values[last] == v) return; // no change
} }
values[head] = v; values[head] = v;
timestamps[head] = QDateTime::currentMSecsSinceEpoch();
head = (head + 1) % kCapacity; head = (head + 1) % kCapacity;
if (count < INT_MAX) count++; if (count < INT_MAX) count++;
} }
void clear() {
count = 0;
head = 0;
}
int uniqueCount() const { return qMin(count, kCapacity); } int uniqueCount() const { return qMin(count, kCapacity); }
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+) // 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
@@ -536,6 +544,17 @@ struct ValueHistory {
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
fn(values[(start + i) % kCapacity]); fn(values[(start + i) % kCapacity]);
} }
// Iterate with timestamps from oldest to newest
template<typename Fn>
void forEachWithTime(Fn&& fn) const {
int n = uniqueCount();
int start = (head + kCapacity - n) % kCapacity;
for (int i = 0; i < n; i++) {
int idx = (start + i) % kCapacity;
fn(values[idx], timestamps[idx]);
}
}
}; };
// ── LineMeta ── // ── LineMeta ──

View File

@@ -19,8 +19,10 @@
#include <QClipboard> #include <QClipboard>
#include <QLabel> #include <QLabel>
#include <QToolButton> #include <QToolButton>
#include <QLineEdit>
#include <QScreen> #include <QScreen>
#include <QScrollBar> #include <QScrollBar>
#include <QDateTime>
#include <functional> #include <functional>
#include "themes/thememanager.h" #include "themes/thememanager.h"
@@ -102,7 +104,8 @@ public:
sep->setPalette(sp); sep->setPalette(sp);
vbox->addWidget(sep); vbox->addWidget(sep);
for (const QString& v : vals) { qint64 now = QDateTime::currentMSecsSinceEpoch();
hist.forEachWithTime([&](const QString& v, qint64 msec) {
auto* row = new QHBoxLayout; auto* row = new QHBoxLayout;
row->setContentsMargins(0, 1, 0, 1); row->setContentsMargins(0, 1, 0, 1);
row->setSpacing(8); row->setSpacing(8);
@@ -113,6 +116,24 @@ public:
row->addWidget(label, 1); row->addWidget(label, 1);
m_labels.append(label); m_labels.append(label);
if (msec > 0) {
qint64 elapsed = now - msec;
QString timeStr;
if (elapsed < 1000)
timeStr = QStringLiteral("now");
else if (elapsed < 60000)
timeStr = QStringLiteral("%1s ago").arg(elapsed / 1000);
else if (elapsed < 3600000)
timeStr = QStringLiteral("%1m ago").arg(elapsed / 60000);
else
timeStr = QStringLiteral("%1h ago").arg(elapsed / 3600000);
auto* timeLabel = new QLabel(timeStr);
timeLabel->setFont(font);
timeLabel->setStyleSheet(QStringLiteral("color: %1;").arg(theme.textDim.name()));
row->addWidget(timeLabel);
}
if (showButtons) { if (showButtons) {
auto* setBtn = new QToolButton; auto* setBtn = new QToolButton;
setBtn->setText(QStringLiteral("Set")); setBtn->setText(QStringLiteral("Set"));
@@ -130,7 +151,7 @@ public:
row->addWidget(setBtn); row->addWidget(setBtn);
} }
vbox->addLayout(row); vbox->addLayout(row);
} });
adjustSize(); adjustSize();
} }
@@ -380,6 +401,12 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
m_sci = new QsciScintilla(this); m_sci = new QsciScintilla(this);
layout->addWidget(m_sci); layout->addWidget(m_sci);
// Find bar (hidden by default, shown with Ctrl+F)
m_findBar = new QLineEdit(this);
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
m_findBar->setVisible(false);
layout->addWidget(m_findBar);
setupScintilla(); setupScintilla();
setupLexer(); setupLexer();
setupMargins(); setupMargins();
@@ -395,6 +422,27 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
m_sci->viewport()->installEventFilter(this); m_sci->viewport()->installEventFilter(this);
m_sci->viewport()->setMouseTracking(true); m_sci->viewport()->setMouseTracking(true);
// Find bar: live search on text change
connect(m_findBar, &QLineEdit::textChanged, this, [this](const QString& text) {
if (text.isEmpty()) return;
m_sci->findFirst(text, false, false, false, true, true, 0, 0);
});
// Find bar: Enter jumps to next match (wraps at end)
connect(m_findBar, &QLineEdit::returnPressed, this, [this]() {
QString text = m_findBar->text();
if (text.isEmpty()) return;
if (!m_sci->findNext())
m_sci->findFirst(text, false, false, false, true, true, 0, 0);
});
// Escape hides find bar
{
auto* escAction = new QAction(m_findBar);
escAction->setShortcut(QKeySequence(Qt::Key_Escape));
escAction->setShortcutContext(Qt::WidgetShortcut);
m_findBar->addAction(escAction);
connect(escAction, &QAction::triggered, this, [this]() { hideFindBar(); });
}
// Recalculate hover when the viewport scrolls (scrollbar drag, wheel // Recalculate hover when the viewport scrolls (scrollbar drag, wheel
// deceleration, etc.) so the highlight tracks whatever is under the cursor. // deceleration, etc.) so the highlight tracks whatever is under the cursor.
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged, connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
@@ -782,6 +830,14 @@ void RcxEditor::applyTheme(const Theme& theme) {
abs, theme.background); abs, theme.background);
} }
} }
// Find bar
if (m_findBar) {
m_findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 4px 8px; font-size: 13px; }")
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
}
} }
void RcxEditor::applyDocument(const ComposeResult& result) { void RcxEditor::applyDocument(const ComposeResult& result) {
@@ -1243,6 +1299,17 @@ int RcxEditor::currentNodeIndex() const {
return lm ? lm->nodeIdx : -1; return lm ? lm->nodeIdx : -1;
} }
void RcxEditor::showFindBar() {
m_findBar->setVisible(true);
m_findBar->setFocus();
m_findBar->selectAll();
}
void RcxEditor::hideFindBar() {
m_findBar->setVisible(false);
m_sci->setFocus();
}
void RcxEditor::scrollToNodeId(uint64_t nodeId) { void RcxEditor::scrollToNodeId(uint64_t nodeId) {
for (int i = 0; i < m_meta.size(); i++) { for (int i = 0; i < m_meta.size(); i++) {
if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) { if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) {
@@ -1810,6 +1877,10 @@ static bool hitTestTarget(QsciScintilla* sci,
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
if (obj == m_sci && event->type() == QEvent::KeyPress) { if (obj == m_sci && event->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(event); auto* ke = static_cast<QKeyEvent*>(event);
if (ke->matches(QKeySequence::Find)) {
showFindBar();
return true;
}
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke); bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
if (!handled && !m_editState.active) { if (!handled && !m_editState.active) {
// Clear hover on keyboard navigation (stale after scroll) // Clear hover on keyboard navigation (stale after scroll)

View File

@@ -6,6 +6,7 @@
#include <QPoint> #include <QPoint>
#include <QHash> #include <QHash>
class QLineEdit;
class QsciScintilla; class QsciScintilla;
class QsciLexerCPP; class QsciLexerCPP;
@@ -154,6 +155,11 @@ private:
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr; const NodeTree* m_disasmTree = nullptr;
// ── Find bar ──
QLineEdit* m_findBar = nullptr;
void showFindBar();
void hideFindBar();
// ── Reentrancy guards ── // ── Reentrancy guards ──
bool m_applyingDocument = false; bool m_applyingDocument = false;
bool m_clampingSelection = false; bool m_clampingSelection = false;

11329
src/examples/t6zm.rcx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2737,6 +2737,18 @@ void MainWindow::createWorkspaceDock() {
} }
}); });
// Ctrl+F focuses the workspace search field
{
auto* findAction = new QAction(dockContainer);
findAction->setShortcut(QKeySequence::Find);
findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
dockContainer->addAction(findAction);
connect(findAction, &QAction::triggered, this, [this]() {
m_workspaceSearch->setFocus();
m_workspaceSearch->selectAll();
});
}
m_workspaceDock->setWidget(dockContainer); m_workspaceDock->setWidget(dockContainer);
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock); addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
m_workspaceDock->hide(); m_workspaceDock->hide();

View File

@@ -256,7 +256,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. " "insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. " "change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
"change_offset: {op:'change_offset', nodeId:'ID', offset:16}. " "change_offset: {op:'change_offset', nodeId:'ID', offset:16}. "
"change_base: {op:'change_base', baseAddress:'0x400000'}. " "change_base: {op:'change_base', baseAddress:'0x400000', formula:'[0x233CA80]'} — formula is optional, enables auto-resolve on provider attach. "
"change_struct_type: {op:'change_struct_type', nodeId:'ID', structTypeName:'Name'}. " "change_struct_type: {op:'change_struct_type', nodeId:'ID', structTypeName:'Name'}. "
"change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. " "change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. "
"change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. " "change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. "
@@ -396,6 +396,24 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
}} }}
}); });
// 9. node.history
tools.append(QJsonObject{
{"name", "node.history"},
{"description", "Returns timestamped value change history (up to 10 entries) "
"for specified nodes. Requires live provider with value tracking enabled."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"nodeIds", QJsonObject{{"type", "array"},
{"items", QJsonObject{{"type", "string"}}},
{"description", "Array of node IDs to get history for."}}},
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index. Omit for active tab."}}}
}},
{"required", QJsonArray{"nodeIds"}}
}}
});
return okReply(id, QJsonObject{{"tools", tools}}); return okReply(id, QJsonObject{{"tools", tools}});
} }
@@ -420,6 +438,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
else if (toolName == "status.set") result = toolStatusSet(args); else if (toolName == "status.set") result = toolStatusSet(args);
else if (toolName == "ui.action") result = toolUiAction(args); else if (toolName == "ui.action") result = toolUiAction(args);
else if (toolName == "tree.search") result = toolTreeSearch(args); else if (toolName == "tree.search") result = toolTreeSearch(args);
else if (toolName == "node.history") result = toolNodeHistory(args);
else return errReply(id, -32601, "Unknown tool: " + toolName); else return errReply(id, -32601, "Unknown tool: " + toolName);
m_mainWindow->clearMcpStatus(); m_mainWindow->clearMcpStatus();
@@ -751,8 +770,10 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
} }
else if (opType == "change_base") { else if (opType == "change_base") {
uint64_t newBase = op.value("baseAddress").toString().toULongLong(nullptr, 16); uint64_t newBase = op.value("baseAddress").toString().toULongLong(nullptr, 16);
QString oldFormula = tree.baseAddressFormula;
QString newFormula = op.value("formula").toString();
doc->undoStack.push(new RcxCommand(ctrl, doc->undoStack.push(new RcxCommand(ctrl,
cmd::ChangeBase{tree.baseAddress, newBase})); cmd::ChangeBase{tree.baseAddress, newBase, oldFormula, newFormula}));
applied++; applied++;
} }
else if (opType == "change_struct_type") { else if (opType == "change_struct_type") {
@@ -1226,6 +1247,43 @@ QJsonObject McpBridge::toolTreeSearch(const QJsonObject& args) {
QJsonDocument(out).toJson(QJsonDocument::Indented))); QJsonDocument(out).toJson(QJsonDocument::Indented)));
} }
// ════════════════════════════════════════════════════════════════════
// Tool: node.history — return timestamped value history for nodes
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolNodeHistory(const QJsonObject& args) {
auto* tab = resolveTab(args);
if (!tab) return makeTextResult("No active tab.", true);
const auto& histMap = tab->ctrl->valueHistory();
QJsonArray requestedIds = args.value("nodeIds").toArray();
if (requestedIds.isEmpty())
return makeTextResult("nodeIds array is required.", true);
QJsonObject result;
for (const auto& idVal : requestedIds) {
QString idStr = idVal.toString();
uint64_t nodeId = idStr.toULongLong();
auto it = histMap.find(nodeId);
QJsonArray entries;
if (it != histMap.end()) {
it->forEachWithTime([&](const QString& val, qint64 msec) {
QJsonObject entry;
entry.insert(QStringLiteral("value"), val);
entry.insert(QStringLiteral("timestamp"), msec);
entries.append(entry);
});
}
QJsonObject nodeResult;
nodeResult.insert(QStringLiteral("entries"), entries);
nodeResult.insert(QStringLiteral("heatLevel"), it != histMap.end() ? it->heatLevel() : 0);
nodeResult.insert(QStringLiteral("uniqueCount"), it != histMap.end() ? it->uniqueCount() : 0);
result.insert(idStr, nodeResult);
}
return makeTextResult(QString::fromUtf8(
QJsonDocument(result).toJson(QJsonDocument::Compact)));
}
// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════
// Notifications (call from MainWindow/Controller hooks) // Notifications (call from MainWindow/Controller hooks)
// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════

View File

@@ -59,6 +59,7 @@ private:
QJsonObject toolStatusSet(const QJsonObject& args); QJsonObject toolStatusSet(const QJsonObject& args);
QJsonObject toolUiAction(const QJsonObject& args); QJsonObject toolUiAction(const QJsonObject& args);
QJsonObject toolTreeSearch(const QJsonObject& args); QJsonObject toolTreeSearch(const QJsonObject& args);
QJsonObject toolNodeHistory(const QJsonObject& args);
// Helpers // Helpers
QJsonObject makeTextResult(const QString& text, bool isError = false); QJsonObject makeTextResult(const QString& text, bool isError = false);

View File

@@ -46,10 +46,17 @@ inline void buildProjectExplorer(QStandardItemModel* model,
auto nameOf = [](const Node* n) { auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName; return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
}; };
// Sort structs by children count descending (most fields first)
auto cmpChildren = [&](const Entry& a, const Entry& b) {
int ca = a.tree->childrenOf(a.node->id).size();
int cb = b.tree->childrenOf(b.node->id).size();
if (ca != cb) return ca > cb;
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpChildren);
auto cmpName = [&](const Entry& a, const Entry& b) { auto cmpName = [&](const Entry& a, const Entry& b) {
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0; return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
}; };
std::sort(types.begin(), types.end(), cmpName);
std::sort(enums.begin(), enums.end(), cmpName); std::sort(enums.begin(), enums.end(), cmpName);
// Helper: type display string for a member node // Helper: type display string for a member node