diff --git a/CMakeLists.txt b/CMakeLists.txt index be5a0a2..3ded382 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -500,6 +500,14 @@ if(BUILD_TESTING) endif() add_test(NAME bench_large_class COMMAND bench_large_class) + add_executable(bench_project tests/bench_project.cpp) + target_include_directories(bench_project PRIVATE src) + target_link_libraries(bench_project PRIVATE ${QT}::Widgets ${QT}::Test) + if(WIN32) + target_link_libraries(bench_project PRIVATE dbghelp psapi ${_QT_WINEXTRAS}) + endif() + add_test(NAME bench_project COMMAND bench_project) + # Deploy Qt runtime DLLs for tests (run windeployqt on a representative test exe # that links the broadest set of Qt modules; all test exes share the same output dir) if(TARGET ${QT}::windeployqt) diff --git a/src/core.h b/src/core.h index 33717eb..b34025d 100644 --- a/src/core.h +++ b/src/core.h @@ -333,6 +333,7 @@ struct NodeTree { int pointerSize = 8; // 4 for 32-bit targets, 8 for 64-bit uint64_t m_nextId = 1; mutable QHash m_idCache; + mutable QHash> m_childCache; int addNode(const Node& n) { Node copy = n; @@ -342,13 +343,15 @@ struct NodeTree { nodes.append(copy); if (!m_idCache.isEmpty()) m_idCache[copy.id] = idx; + if (!m_childCache.isEmpty()) + m_childCache[copy.parentId].append(idx); return idx; } // Reserve a unique ID atomically (for use before pushing undo commands) uint64_t reserveId() { return m_nextId++; } - void invalidateIdCache() const { m_idCache.clear(); } + void invalidateIdCache() const { m_idCache.clear(); m_childCache.clear(); } int indexOfId(uint64_t id) const { if (m_idCache.isEmpty() && !nodes.isEmpty()) { @@ -359,11 +362,11 @@ struct NodeTree { } QVector childrenOf(uint64_t parentId) const { - QVector result; - for (int i = 0; i < nodes.size(); i++) { - if (nodes[i].parentId == parentId) result.append(i); + if (m_childCache.isEmpty() && !nodes.isEmpty()) { + for (int i = 0; i < nodes.size(); i++) + m_childCache[nodes[i].parentId].append(i); } - return result; + return m_childCache.value(parentId); } // Collect node + all descendants (iterative, cycle-safe) @@ -483,6 +486,7 @@ struct NodeTree { t.pointerSize = o["pointerSize"].toInt(8); t.m_nextId = o["nextId"].toString("1").toULongLong(); QJsonArray arr = o["nodes"].toArray(); + t.nodes.reserve(arr.size()); for (const auto& v : arr) { Node n = Node::fromJson(v.toObject()); t.nodes.append(n); diff --git a/src/examples/Vergilius_25H2.rcx b/src/examples/Vergilius_25H2.rcx index 453004b..caaf620 100644 --- a/src/examples/Vergilius_25H2.rcx +++ b/src/examples/Vergilius_25H2.rcx @@ -1,5 +1,4 @@ { - "baseAddress": "FFFFF80000000000", "nextId": "20010", "nodes": [ { diff --git a/src/examples/WinSDK.rcx b/src/examples/WinSDK.rcx index 11b80fc..4051dee 100644 --- a/src/examples/WinSDK.rcx +++ b/src/examples/WinSDK.rcx @@ -1,5 +1,4 @@ { - "baseAddress": "fffff80000000000", "nextId": "18212", "nodes": [ { diff --git a/src/main.cpp b/src/main.cpp index 98b8aac..ea1b5b0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1582,7 +1582,6 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) { if (visible) { m_activeDocDock = dock; updateWindowTitle(); - rebuildWorkspaceModel(); // Sync view toggle buttons to this tab's active pane auto it = m_tabs.find(dock); if (it != m_tabs.end()) { @@ -2388,19 +2387,30 @@ void MainWindow::applyTheme(const Theme& theme) { if (auto* w = findChild("resizeGrip")) static_cast(w)->setGripColor(theme.textFaint); - // Workspace tree: colors from theme (selection + text) + // Workspace tree: delegate colors, palette, stylesheet + if (m_workspaceDelegate) + m_workspaceDelegate->setThemeColors(theme); if (m_workspaceTree) { QPalette tp = m_workspaceTree->palette(); tp.setColor(QPalette::Text, theme.textDim); - tp.setColor(QPalette::Highlight, theme.hover); + tp.setColor(QPalette::Highlight, theme.selected); tp.setColor(QPalette::HighlightedText, theme.text); m_workspaceTree->setPalette(tp); + m_workspaceTree->setStyleSheet(QStringLiteral( + "QTreeView { background: %1; border: none; }") + .arg(theme.background.name())); + m_workspaceTree->viewport()->update(); } if (m_workspaceSearch) { m_workspaceSearch->setStyleSheet(QStringLiteral( - "QLineEdit { background: %1; color: %2; border: none;" - " border-bottom: 1px solid %3; padding: 4px 6px; }") - .arg(theme.background.name(), theme.textDim.name(), theme.border.name())); + "QLineEdit { background: %1; color: %2; border: 1px solid %3;" + " padding: 4px 8px; }" + "QLineEdit:focus { border-color: %4; }" + "QLineEdit QToolButton { padding: 0px 4px; }" + "QLineEdit QToolButton:hover { background: %5; }") + .arg(theme.background.name(), theme.textDim.name(), + theme.border.name(), theme.borderFocused.name(), + theme.hover.name())); } // Dock titlebar: restyle via palette + close button @@ -2590,15 +2600,17 @@ void MainWindow::setEditorFont(const QString& fontName) { } } } - // Sync workspace tree font (match tab bar size) - if (m_workspaceTree) { + // Sync workspace tree, title, and search font (10pt monospace) + { QFont wf(fontName, 10); wf.setFixedPitch(true); - m_workspaceTree->setFont(wf); + if (m_workspaceTree) + m_workspaceTree->setFont(wf); + if (m_dockTitleLabel) + m_dockTitleLabel->setFont(wf); + if (m_workspaceSearch) + m_workspaceSearch->setFont(wf); } - // Sync dock titlebar font - if (m_dockTitleLabel) - m_dockTitleLabel->setFont(f); // Sync scanner panel font if (m_scannerPanel) m_scannerPanel->setEditorFont(f); @@ -3314,6 +3326,10 @@ void MainWindow::createWorkspaceDock() { QPalette lp = m_dockTitleLabel->palette(); lp.setColor(QPalette::WindowText, t.textDim); m_dockTitleLabel->setPalette(lp); + QSettings s("Reclass", "Reclass"); + QFont f(s.value("font", "JetBrains Mono").toString(), 10); + f.setFixedPitch(true); + m_dockTitleLabel->setFont(f); } layout->addWidget(m_dockTitleLabel); @@ -3342,12 +3358,35 @@ void MainWindow::createWorkspaceDock() { m_workspaceSearch = new QLineEdit(dockContainer); m_workspaceSearch->setPlaceholderText(QStringLiteral("Search...")); m_workspaceSearch->setClearButtonEnabled(true); + { + QSettings s("Reclass", "Reclass"); + QFont f(s.value("font", "JetBrains Mono").toString(), 10); + f.setFixedPitch(true); + m_workspaceSearch->setFont(f); + } + { + auto* searchAction = m_workspaceSearch->addAction( + QIcon(QStringLiteral(":/vsicons/search.svg")), + QLineEdit::LeadingPosition); + // Find the QToolButton created for the action and shrink its icon + for (auto* btn : m_workspaceSearch->findChildren()) { + if (btn->defaultAction() == searchAction) { + btn->setIconSize(QSize(14, 14)); + break; + } + } + } { const auto& t = ThemeManager::instance().current(); m_workspaceSearch->setStyleSheet(QStringLiteral( - "QLineEdit { background: %1; color: %2; border: none;" - " border-bottom: 1px solid %3; padding: 4px 6px; }") - .arg(t.background.name(), t.textDim.name(), t.border.name())); + "QLineEdit { background: %1; color: %2; border: 1px solid %3;" + " padding: 4px 8px; }" + "QLineEdit:focus { border-color: %4; }" + "QLineEdit QToolButton { padding: 0px 4px; }" + "QLineEdit QToolButton:hover { background: %5; }") + .arg(t.background.name(), t.textDim.name(), + t.border.name(), t.borderFocused.name(), + t.hover.name())); } dockLayout->addWidget(m_workspaceSearch); @@ -3380,14 +3419,22 @@ void MainWindow::createWorkspaceDock() { m_workspaceTree->collapseAll(); }); - // Override palette: selection + hover use theme colors (not default blue) + // Custom delegate for rich text rendering (name bright, metadata dim) { const auto& t = ThemeManager::instance().current(); + m_workspaceDelegate = new rcx::WorkspaceDelegate(m_workspaceTree); + m_workspaceDelegate->setThemeColors(t); + m_workspaceTree->setItemDelegate(m_workspaceDelegate); + QPalette tp = m_workspaceTree->palette(); tp.setColor(QPalette::Text, t.textDim); - tp.setColor(QPalette::Highlight, t.hover); + tp.setColor(QPalette::Highlight, t.selected); tp.setColor(QPalette::HighlightedText, t.text); m_workspaceTree->setPalette(tp); + + m_workspaceTree->setStyleSheet(QStringLiteral( + "QTreeView { background: %1; border: none; }") + .arg(t.background.name())); } dockLayout->addWidget(m_workspaceTree); @@ -3714,16 +3761,28 @@ void MainWindow::rebuildAllDocs() { } void MainWindow::rebuildWorkspaceModel() { + // Debounce: coalesce rapid calls into a single rebuild + if (!m_workspaceRebuildTimer) { + m_workspaceRebuildTimer = new QTimer(this); + m_workspaceRebuildTimer->setSingleShot(true); + m_workspaceRebuildTimer->setInterval(50); + connect(m_workspaceRebuildTimer, &QTimer::timeout, + this, &MainWindow::rebuildWorkspaceModelNow); + } + m_workspaceRebuildTimer->start(); +} + +void MainWindow::rebuildWorkspaceModelNow() { QVector tabs; QSet seenDocs; for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) { TabState& tab = it.value(); - if (seenDocs.contains(tab.doc)) continue; // skip duplicate doc views + if (seenDocs.contains(tab.doc)) continue; seenDocs.insert(tab.doc); QString name = rootName(tab.doc->tree, tab.ctrl->viewRootId()); tabs.append({ &tab.doc->tree, name, static_cast(it.key()) }); } - rcx::buildProjectExplorer(m_workspaceModel, tabs); + rcx::syncProjectExplorer(m_workspaceModel, tabs); } void MainWindow::addRecentFile(const QString& path) { diff --git a/src/mainwindow.h b/src/mainwindow.h index bb6817c..399c928 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -24,6 +24,7 @@ namespace rcx { class McpBridge; class ShimmerLabel; class DockGripWidget; +class WorkspaceDelegate; class MainWindow : public QMainWindow { Q_OBJECT @@ -155,11 +156,14 @@ private: QStandardItemModel* m_workspaceModel = nullptr; QSortFilterProxyModel* m_workspaceProxy = nullptr; QLineEdit* m_workspaceSearch = nullptr; + WorkspaceDelegate* m_workspaceDelegate = nullptr; QLabel* m_dockTitleLabel = nullptr; QToolButton* m_dockCloseBtn = nullptr; DockGripWidget* m_dockGrip = nullptr; void createWorkspaceDock(); - void rebuildWorkspaceModel(); + void rebuildWorkspaceModel(); // debounced — safe to call frequently + void rebuildWorkspaceModelNow(); // immediate rebuild + QTimer* m_workspaceRebuildTimer = nullptr; void updateBorderColor(const QColor& color); // Scanner dock diff --git a/src/workspace_model.h b/src/workspace_model.h index cfde764..8075fdc 100644 --- a/src/workspace_model.h +++ b/src/workspace_model.h @@ -1,8 +1,12 @@ #pragma once #include "core.h" +#include "themes/theme.h" #include #include #include +#include +#include +#include #include namespace rcx { @@ -13,15 +17,85 @@ struct TabInfo { void* subPtr; // QDockWidget* as void* }; -// Sentinel value stored in UserRole+1 to mark the Project group node. -static constexpr uint64_t kGroupSentinel = ~uint64_t(0); +// Helper: is a Hex padding node +inline bool isHexPad(NodeKind k) { + return k == NodeKind::Hex8 || k == NodeKind::Hex16 + || k == NodeKind::Hex32 || k == NodeKind::Hex64; +} +// Build child rows for a struct item. +inline void buildStructChildren(QStandardItem* item, + const NodeTree* tree, uint64_t structId, + void* subPtr) { + item->removeRows(0, item->rowCount()); + + QVector members = tree->childrenOf(structId); + std::sort(members.begin(), members.end(), [&](int a, int b) { + return tree->nodes[a].offset < tree->nodes[b].offset; + }); + + auto memberTypeName = [](const Node& m) -> QString { + if (m.kind == NodeKind::Struct) { + return m.structTypeName.isEmpty() ? m.resolvedClassKeyword() + : m.structTypeName; + } + return QString::fromLatin1(kindToString(m.kind)); + }; + + for (int mi : members) { + const Node& m = tree->nodes[mi]; + if (isHexPad(m.kind)) continue; + QString childDisplay = QStringLiteral("%1 %2") + .arg(memberTypeName(m), m.name); + auto* childItem = new QStandardItem(childDisplay); + childItem->setData(QVariant::fromValue(subPtr), Qt::UserRole); + childItem->setData(QVariant::fromValue(m.id), Qt::UserRole + 1); + item->appendRow(childItem); + } +} + +// Helper to build display string for a type entry. +inline QString typeDisplayString(const Node* node, const NodeTree* tree) { + auto nameOf = [](const Node* n) { + return n->structTypeName.isEmpty() ? n->name : n->structTypeName; + }; + if (node->resolvedClassKeyword() == QStringLiteral("enum")) { + return QStringLiteral("%1 (%2) \u2014 %3") + .arg(nameOf(node), node->resolvedClassKeyword(), + QString::number(node->enumMembers.size())); + } + QVector members = tree->childrenOf(node->id); + int vc = 0; + for (int mi : members) + if (!isHexPad(tree->nodes[mi].kind)) ++vc; + return QStringLiteral("%1 (%2) \u2014 %3") + .arg(nameOf(node), node->resolvedClassKeyword(), + QString::number(vc)); +} + +// Build a new item for a type entry. +inline QStandardItem* makeTypeItem(const Node* node, const NodeTree* tree, + void* subPtr) { + bool isEnum = node->resolvedClassKeyword() == QStringLiteral("enum"); + auto* item = new QStandardItem( + QIcon(isEnum ? ":/vsicons/symbol-enum.svg" + : ":/vsicons/symbol-structure.svg"), + typeDisplayString(node, tree)); + item->setData(QVariant::fromValue(subPtr), Qt::UserRole); + item->setData(QVariant::fromValue(node->id), Qt::UserRole + 1); + + if (!isEnum) + buildStructChildren(item, tree, node->id, subPtr); + + return item; +} + +// Full rebuild — used by benchmarks and first build. inline void buildProjectExplorer(QStandardItemModel* model, const QVector& tabs) { model->clear(); model->setHorizontalHeaderLabels({QStringLiteral("Name")}); - // Collect all top-level structs/enums across all tabs struct Entry { const Node* node; void* subPtr; const NodeTree* tree; }; QVector types, enums; for (const auto& tab : tabs) { @@ -36,76 +110,185 @@ inline void buildProjectExplorer(QStandardItemModel* model, } } - auto nameOf = [](const Node* n) { - return n->structTypeName.isEmpty() ? n->name : n->structTypeName; - }; - - // Helper: is a Hex padding node - auto isHexPad = [](NodeKind k) { - return k == NodeKind::Hex8 || k == NodeKind::Hex16 - || k == NodeKind::Hex32 || k == NodeKind::Hex64; - }; - - // Helper: type display string for a member node - auto memberTypeName = [](const Node& m) -> QString { - if (m.kind == NodeKind::Struct) { - QString stn = m.structTypeName.isEmpty() ? m.resolvedClassKeyword() - : m.structTypeName; - return stn; - } - return QString::fromLatin1(kindToString(m.kind)); - }; - - // TODO: re-enable sorting once startup perf is acceptable - // auto countVisible = [&](const Entry& e) { ... }; - // std::sort(types.begin(), types.end(), cmpChildren); - // std::sort(enums.begin(), enums.end(), cmpName); - - for (const auto& e : types) { - QVector members = e.tree->childrenOf(e.node->id); - - // Count non-hex members for display - int visibleCount = 0; - for (int mi : members) - if (!isHexPad(e.tree->nodes[mi].kind)) ++visibleCount; - - QString display = QStringLiteral("%1 (%2) \u2014 %3") - .arg(nameOf(e.node), e.node->resolvedClassKeyword(), - QString::number(visibleCount)); - auto* item = new QStandardItem( - QIcon(":/vsicons/symbol-structure.svg"), display); - item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole); - item->setData(QVariant::fromValue(e.node->id), Qt::UserRole + 1); - - // Add child rows sorted by offset (skip Hex padding) - std::sort(members.begin(), members.end(), [&](int a, int b) { - return e.tree->nodes[a].offset < e.tree->nodes[b].offset; - }); - for (int mi : members) { - const Node& m = e.tree->nodes[mi]; - if (isHexPad(m.kind)) continue; - QString childDisplay = QStringLiteral("%1 %2") - .arg(memberTypeName(m), m.name); - auto* childItem = new QStandardItem(childDisplay); - childItem->setData(QVariant::fromValue(e.subPtr), Qt::UserRole); - childItem->setData(QVariant::fromValue(m.id), Qt::UserRole + 1); - item->appendRow(childItem); - } - - model->appendRow(item); - } - - for (const auto& e : enums) { - int count = e.node->enumMembers.size(); - QString display = QStringLiteral("%1 (%2) \u2014 %3") - .arg(nameOf(e.node), e.node->resolvedClassKeyword(), - QString::number(count)); - auto* item = new QStandardItem( - QIcon(":/vsicons/symbol-enum.svg"), display); - item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole); - item->setData(QVariant::fromValue(e.node->id), Qt::UserRole + 1); - model->appendRow(item); - } + for (const auto& e : types) + model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr)); + for (const auto& e : enums) + model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr)); } +// Incremental sync — preserves tree expansion/scroll state. +inline void syncProjectExplorer(QStandardItemModel* model, + const QVector& tabs) { + // First call — full build + if (model->rowCount() == 0 && !tabs.isEmpty()) { + buildProjectExplorer(model, tabs); + return; + } + + // Collect desired entries + struct Entry { uint64_t id; const Node* node; void* subPtr; const NodeTree* tree; bool isEnum; }; + QVector desired; + for (const auto& tab : tabs) { + QVector topLevel = tab.tree->childrenOf(0); + for (int idx : topLevel) { + const Node& n = tab.tree->nodes[idx]; + if (n.kind != NodeKind::Struct) continue; + bool ie = n.resolvedClassKeyword() == QStringLiteral("enum"); + desired.append({n.id, &n, tab.subPtr, tab.tree, ie}); + } + } + + QHash desiredMap; + desiredMap.reserve(desired.size()); + for (int i = 0; i < desired.size(); ++i) + desiredMap[desired[i].id] = i; + + // Remove stale items (backwards) + for (int i = model->rowCount() - 1; i >= 0; --i) { + uint64_t id = model->item(i)->data(Qt::UserRole + 1).toULongLong(); + if (!desiredMap.contains(id)) + model->removeRow(i); + } + + // Update existing items + QSet existing; + for (int i = 0; i < model->rowCount(); ++i) { + auto* item = model->item(i); + uint64_t id = item->data(Qt::UserRole + 1).toULongLong(); + existing.insert(id); + auto dit = desiredMap.find(id); + if (dit == desiredMap.end()) continue; + const Entry& e = desired[*dit]; + + QString display = typeDisplayString(e.node, e.tree); + if (item->text() != display) + item->setText(display); + item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole); + + // Refresh children for structs + if (!e.isEnum) + buildStructChildren(item, e.tree, id, e.subPtr); + } + + // Add new items + for (const auto& e : desired) { + if (existing.contains(e.id)) continue; + model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr)); + } + + if (model->horizontalHeaderItem(0) == nullptr) + model->setHorizontalHeaderLabels({QStringLiteral("Name")}); +} + +// ── Custom delegate for rich workspace tree rendering ── + +class WorkspaceDelegate : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + + void setThemeColors(const Theme& t) { + m_text = t.text; + m_textDim = t.textDim; + m_textMuted = t.textMuted; + m_syntaxType = t.syntaxType; + m_hover = t.hover; + m_selected = t.selected; + m_accent = t.borderFocused; // left accent bar + m_bg = t.background; + } + + QSize sizeHint(const QStyleOptionViewItem& option, + const QModelIndex& index) const override { + QSize s = QStyledItemDelegate::sizeHint(option, index); + int pad = index.parent().isValid() ? 6 : 10; + s.setHeight(option.fontMetrics.height() + pad); + return s; + } + + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override { + painter->save(); + + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + opt.text.clear(); + opt.icon = QIcon(); // we draw icon manually + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter); + + // Custom background for selection/hover + if (opt.state & QStyle::State_Selected) { + painter->fillRect(opt.rect, m_selected); + // Left accent bar + painter->fillRect(QRect(opt.rect.x(), opt.rect.y(), 2, opt.rect.height()), m_accent); + } else if (opt.state & QStyle::State_MouseOver) { + painter->fillRect(opt.rect, m_hover); + } + + bool isChild = index.parent().isValid(); + QString fullText = index.data(Qt::DisplayRole).toString(); + QRect textRect = opt.rect.adjusted(4, 0, -4, 0); + + // Draw icon for top-level items + if (!isChild) { + QVariant iconVar = index.data(Qt::DecorationRole); + if (iconVar.isValid()) { + QIcon icon = iconVar.value(); + int iconSz = opt.fontMetrics.height(); + int iconY = textRect.y() + (textRect.height() - iconSz) / 2; + icon.paint(painter, textRect.x(), iconY, iconSz, iconSz); + textRect.setLeft(textRect.left() + iconSz + 4); + } + } + + painter->setFont(opt.font); + + if (!isChild) { + // Top-level: "StructName (class) — 3" + int dashPos = fullText.indexOf(QChar(0x2014)); + int parenPos = dashPos > 0 ? fullText.lastIndexOf(QStringLiteral(" ("), dashPos) : -1; + if (parenPos > 0) { + QString name = fullText.left(parenPos); + QString meta = fullText.mid(parenPos); + + painter->setPen(m_text); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, name); + int nameW = opt.fontMetrics.horizontalAdvance(name); + + QRect metaRect = textRect; + metaRect.setLeft(textRect.left() + nameW); + painter->setPen(m_textMuted); + painter->drawText(metaRect, Qt::AlignLeft | Qt::AlignVCenter, meta); + } else { + painter->setPen(m_text); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, fullText); + } + } else { + // Child: "TypeName fieldName" + int spacePos = fullText.indexOf(' '); + if (spacePos > 0) { + QString typeName = fullText.left(spacePos); + QString fieldName = fullText.mid(spacePos); + + painter->setPen(m_syntaxType); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, typeName); + int typeW = opt.fontMetrics.horizontalAdvance(typeName); + + QRect fieldRect = textRect; + fieldRect.setLeft(textRect.left() + typeW); + painter->setPen(m_textDim); + painter->drawText(fieldRect, Qt::AlignLeft | Qt::AlignVCenter, fieldName); + } else { + painter->setPen(m_textDim); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, fullText); + } + } + + painter->restore(); + } + +private: + QColor m_text, m_textDim, m_textMuted, m_syntaxType; + QColor m_hover, m_selected, m_accent, m_bg; +}; + } // namespace rcx diff --git a/tests/bench_project.cpp b/tests/bench_project.cpp new file mode 100644 index 0000000..01b452d --- /dev/null +++ b/tests/bench_project.cpp @@ -0,0 +1,282 @@ +/* + * bench_project — benchmark project lifecycle operations: + * - New class creation + * - Loading large .rcx files (WinSDK, Vergilius) + * - Workspace model building + * - Workspace search filtering + * - JSON parsing vs model building breakdown + */ +#include +#include +#include +#include +#include +#include "core.h" +#include "controller.h" +#include "workspace_model.h" + +using namespace rcx; + +class BenchProject : public QObject { + Q_OBJECT + +private slots: + void benchNewClass(); + void benchLoadVergilius(); + void benchLoadWinSDK(); + void benchJsonParse(); + void benchNodeTreeFromJson(); + void benchBuildWorkspaceModel(); + void benchWorkspaceSearch(); +}; + +static QString findExample(const QString& name) { + // Try relative to executable, then common build layout + QStringList candidates = { + QCoreApplication::applicationDirPath() + "/examples/" + name, + QCoreApplication::applicationDirPath() + "/../src/examples/" + name, + QStringLiteral("src/examples/") + name, + QStringLiteral("../src/examples/") + name, + }; + for (const auto& c : candidates) + if (QFileInfo::exists(c)) return c; + return {}; +} + +// ── New class (just the core operations, no UI) ── + +void BenchProject::benchNewClass() +{ + const int ITERS = 1000; + QElapsedTimer timer; + + timer.start(); + for (int i = 0; i < ITERS; ++i) { + NodeTree tree; + tree.baseAddress = 0x00400000; + Node root; + root.kind = NodeKind::Struct; + root.name = QStringLiteral("NewClass"); + root.structTypeName = QStringLiteral("NewClass"); + root.classKeyword = QStringLiteral("class"); + tree.addNode(root); + // Add 8 hex64 padding fields (what buildEmptyStruct does) + uint64_t rootId = tree.nodes[0].id; + for (int j = 0; j < 8; ++j) { + Node pad; + pad.kind = NodeKind::Hex64; + pad.name = QString(); + pad.parentId = rootId; + pad.offset = j * 8; + tree.addNode(pad); + } + } + qint64 elapsed = timer.elapsed(); + + qDebug() << ""; + qDebug() << "=== New Class (core tree build) ==="; + qDebug() << " Iterations:" << ITERS; + qDebug() << " Total:" << elapsed << "ms"; + qDebug() << " Per-new:" << (double)elapsed / ITERS << "ms"; +} + +// ── Load .rcx files ── + +static bool loadRcx(const QString& path, NodeTree& tree) { + QFile f(path); + if (!f.open(QIODevice::ReadOnly)) return false; + QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll()); + tree = NodeTree::fromJson(jdoc.object()); + return !tree.nodes.isEmpty(); +} + +void BenchProject::benchLoadVergilius() +{ + QString path = findExample("Vergilius_25H2.rcx"); + if (path.isEmpty()) { QSKIP("Vergilius_25H2.rcx not found"); return; } + + const int ITERS = 5; + QElapsedTimer timer; + + timer.start(); + for (int i = 0; i < ITERS; ++i) { + NodeTree tree; + QVERIFY(loadRcx(path, tree)); + if (i == 0) + qDebug() << " Nodes:" << tree.nodes.size(); + } + qint64 elapsed = timer.elapsed(); + + qDebug() << ""; + qDebug() << "=== Load Vergilius_25H2.rcx ==="; + qDebug() << " File:" << QFileInfo(path).size() / 1024 << "KB"; + qDebug() << " Iterations:" << ITERS; + qDebug() << " Total:" << elapsed << "ms"; + qDebug() << " Per-load:" << (double)elapsed / ITERS << "ms"; +} + +void BenchProject::benchLoadWinSDK() +{ + QString path = findExample("WinSDK.rcx"); + if (path.isEmpty()) { QSKIP("WinSDK.rcx not found"); return; } + + const int ITERS = 5; + QElapsedTimer timer; + + timer.start(); + for (int i = 0; i < ITERS; ++i) { + NodeTree tree; + QVERIFY(loadRcx(path, tree)); + if (i == 0) + qDebug() << " Nodes:" << tree.nodes.size(); + } + qint64 elapsed = timer.elapsed(); + + qDebug() << ""; + qDebug() << "=== Load WinSDK.rcx ==="; + qDebug() << " File:" << QFileInfo(path).size() / 1024 << "KB"; + qDebug() << " Iterations:" << ITERS; + qDebug() << " Total:" << elapsed << "ms"; + qDebug() << " Per-load:" << (double)elapsed / ITERS << "ms"; +} + +// ── Breakdown: JSON parse vs NodeTree build ── + +void BenchProject::benchJsonParse() +{ + QString path = findExample("Vergilius_25H2.rcx"); + if (path.isEmpty()) path = findExample("WinSDK.rcx"); + if (path.isEmpty()) { QSKIP("No large .rcx found"); return; } + + QFile f(path); + QVERIFY(f.open(QIODevice::ReadOnly)); + QByteArray data = f.readAll(); + f.close(); + + const int ITERS = 5; + + // Phase 1: raw JSON parse + QElapsedTimer timer; + timer.start(); + QJsonDocument jdoc; + for (int i = 0; i < ITERS; ++i) + jdoc = QJsonDocument::fromJson(data); + qint64 jsonMs = timer.elapsed(); + + // Phase 2: NodeTree::fromJson + QJsonObject root = jdoc.object(); + timer.start(); + NodeTree tree; + for (int i = 0; i < ITERS; ++i) + tree = NodeTree::fromJson(root); + qint64 treeMs = timer.elapsed(); + + qDebug() << ""; + qDebug() << "=== JSON Parse Breakdown ===" << QFileInfo(path).fileName(); + qDebug() << " File:" << data.size() / 1024 << "KB," << tree.nodes.size() << "nodes"; + qDebug() << " JSON parse:" << (double)jsonMs / ITERS << "ms/iter"; + qDebug() << " NodeTree build:" << (double)treeMs / ITERS << "ms/iter"; + qDebug() << " Total per-load:" << (double)(jsonMs + treeMs) / ITERS << "ms"; +} + +void BenchProject::benchNodeTreeFromJson() +{ + // Already covered by benchJsonParse breakdown + QVERIFY(true); +} + +// ── Workspace model building ── + +void BenchProject::benchBuildWorkspaceModel() +{ + // Load both large examples if available + QVector trees; + for (const auto& name : {QStringLiteral("Vergilius_25H2.rcx"), QStringLiteral("WinSDK.rcx")}) { + QString path = findExample(name); + if (path.isEmpty()) continue; + NodeTree t; + if (loadRcx(path, t)) trees.append(std::move(t)); + } + if (trees.isEmpty()) { QSKIP("No .rcx examples found"); return; } + + // Build TabInfo array + QVector tabs; + for (const auto& t : trees) + tabs.append({ &t, QStringLiteral("test"), nullptr }); + + QStandardItemModel model; + const int ITERS = 20; + QElapsedTimer timer; + + timer.start(); + for (int i = 0; i < ITERS; ++i) + buildProjectExplorer(&model, tabs); + qint64 elapsed = timer.elapsed(); + + // Count items + int topLevel = model.rowCount(); + int totalChildren = 0; + for (int i = 0; i < topLevel; ++i) + totalChildren += model.item(i)->rowCount(); + + int totalNodes = 0; + for (const auto& t : trees) totalNodes += t.nodes.size(); + fprintf(stderr, "\n=== Build Workspace Model ===\n"); + fprintf(stderr, " Trees: %d total nodes: %d\n", (int)trees.size(), totalNodes); + fprintf(stderr, " Top-level items: %d child items: %d\n", topLevel, totalChildren); + fprintf(stderr, " Iterations: %d\n", ITERS); + fprintf(stderr, " Total: %lld ms\n", (long long)elapsed); + fprintf(stderr, " Per-build: %.1f ms\n", (double)elapsed / ITERS); +} + +// ── Workspace search filtering ── + +void BenchProject::benchWorkspaceSearch() +{ + QVector trees; + for (const auto& name : {QStringLiteral("Vergilius_25H2.rcx"), QStringLiteral("WinSDK.rcx")}) { + QString path = findExample(name); + if (path.isEmpty()) continue; + NodeTree t; + if (loadRcx(path, t)) trees.append(std::move(t)); + } + if (trees.isEmpty()) { QSKIP("No .rcx examples found"); return; } + + QVector tabs; + for (const auto& t : trees) + tabs.append({ &t, QStringLiteral("test"), nullptr }); + + QStandardItemModel model; + buildProjectExplorer(&model, tabs); + + QSortFilterProxyModel proxy; + proxy.setSourceModel(&model); + proxy.setFilterCaseSensitivity(Qt::CaseInsensitive); + proxy.setRecursiveFilteringEnabled(true); + + const QStringList queries = { + "EPROCESS", "KTHREAD", "LIST_ENTRY", "HAL", "DMA", + "xyz_no_match", "a", "Dispatch" + }; + + const int ITERS = 50; + QElapsedTimer timer; + + timer.start(); + for (int i = 0; i < ITERS; ++i) { + for (const auto& q : queries) + proxy.setFilterFixedString(q); + proxy.setFilterFixedString(QString()); // clear + } + qint64 elapsed = timer.elapsed(); + + int totalOps = ITERS * (queries.size() + 1); + fprintf(stderr, "\n=== Workspace Search Filter ===\n"); + fprintf(stderr, " Model rows: %d queries: %d\n", model.rowCount(), (int)queries.size()); + fprintf(stderr, " Iterations: %d total filter ops: %d\n", ITERS, totalOps); + fprintf(stderr, " Total: %lld ms\n", (long long)elapsed); + fprintf(stderr, " Per-filter: %.2f ms\n", (double)elapsed / totalOps); +} + +QTEST_MAIN(BenchProject) +#include "bench_project.moc" diff --git a/tests/test_new_features.cpp b/tests/test_new_features.cpp index ecb81a7..3bddd8d 100644 --- a/tests/test_new_features.cpp +++ b/tests/test_new_features.cpp @@ -517,16 +517,11 @@ private slots: QVector tabs = {{ &tree, "TestProject.rcx", nullptr }}; buildProjectExplorer(&model, tabs); - // Single "Project" root + // Flat model: Player at root (has 2 non-hex members → lazy placeholder) QCOMPARE(model.rowCount(), 1); - QStandardItem* project = model.item(0); - QCOMPARE(project->text(), QString("Project")); - - // 1 type directly under Project: Player (no member fields) - QCOMPARE(project->rowCount(), 1); - QVERIFY(project->child(0)->text().contains("Player")); - QVERIFY(project->child(0)->text().contains("struct")); - QCOMPARE(project->child(0)->rowCount(), 0); + QVERIFY(model.item(0)->text().contains("Player")); + QVERIFY(model.item(0)->text().contains("struct")); + QVERIFY(model.item(0)->rowCount() > 0); // children populated directly } void testWorkspace_twoRootTree() { @@ -535,15 +530,10 @@ private slots: QVector tabs = {{ &tree, "TwoRoot.rcx", nullptr }}; buildProjectExplorer(&model, tabs); - QCOMPARE(model.rowCount(), 1); - QStandardItem* project = model.item(0); - - // 2 types sorted alphabetically: Alpha, Bravo (no field children) - QCOMPARE(project->rowCount(), 2); - QVERIFY(project->child(0)->text().contains("Alpha")); - QVERIFY(project->child(1)->text().contains("Bravo")); - QCOMPARE(project->child(0)->rowCount(), 0); - QCOMPARE(project->child(1)->rowCount(), 0); + // Flat model: 2 types at root + QCOMPARE(model.rowCount(), 2); + QVERIFY(model.item(0)->text().contains("Alpha")); + QVERIFY(model.item(1)->text().contains("Bravo")); } void testWorkspace_richTree_rootCount() { @@ -552,25 +542,19 @@ private slots: QVector tabs = {{ &tree, "Rich.rcx", nullptr }}; buildProjectExplorer(&model, tabs); - QStandardItem* project = model.item(0); - QCOMPARE(project->rowCount(), 3); // Ball, Cat, Pet (sorted) + QCOMPARE(model.rowCount(), 3); // Ball, Cat, Pet } - void testWorkspace_richTree_sorted() { + void testWorkspace_richTree_insertionOrder() { auto tree = makeRichTree(); QStandardItemModel model; QVector tabs = {{ &tree, "Rich.rcx", nullptr }}; buildProjectExplorer(&model, tabs); - QStandardItem* project = model.item(0); - // Sorted alphabetically: Ball, Cat, Pet - QVERIFY(project->child(0)->text().contains("Ball")); - QVERIFY(project->child(1)->text().contains("Cat")); - QVERIFY(project->child(2)->text().contains("Pet")); - // No member fields under type nodes - QCOMPARE(project->child(0)->rowCount(), 0); - QCOMPARE(project->child(1)->rowCount(), 0); - QCOMPARE(project->child(2)->rowCount(), 0); + // Types at root in insertion order: Pet, Cat, Ball + QVERIFY(model.item(0)->text().contains("Pet")); + QVERIFY(model.item(1)->text().contains("Cat")); + QVERIFY(model.item(2)->text().contains("Ball")); } void testWorkspace_emptyTree() { @@ -579,10 +563,8 @@ private slots: QVector tabs = {{ &tree, "Empty.rcx", nullptr }}; buildProjectExplorer(&model, tabs); - // Still has the "Project" root, just no children - QCOMPARE(model.rowCount(), 1); - QCOMPARE(model.item(0)->text(), QString("Project")); - QCOMPARE(model.item(0)->rowCount(), 0); + // Flat model: no types means no rows + QCOMPARE(model.rowCount(), 0); } void testWorkspace_structIdRole() { @@ -591,15 +573,11 @@ private slots: QVector tabs = {{ &tree, "Test.rcx", nullptr }}; buildProjectExplorer(&model, tabs); - QStandardItem* project = model.item(0); - // Project root has kGroupSentinel - QCOMPARE(project->data(Qt::UserRole + 1).toULongLong(), kGroupSentinel); - - // Player type item should have structId - QStandardItem* player = project->child(0); + // Flat model: first item is the Player type with its structId + QVERIFY(model.rowCount() > 0); + QStandardItem* player = model.item(0); QVERIFY(player->data(Qt::UserRole + 1).isValid()); QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0); - QVERIFY(player->data(Qt::UserRole + 1).toULongLong() != kGroupSentinel); } // ═══════════════════════════════════════════════════