mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
feat: fix heatmap false-heat on offset shift, hover flicker, type chooser cleanup
- Clear value history when node offsets change (insert/delete/resize/ manual offset edit) so stale values from old addresses don't show false heat coloring - Invalidate in-flight async reads (bump refreshGen) when tree layout changes, preventing stale snapshot data from re-introducing heat - Fix command bar hover cursor flicker: remove premature applyHoverCursor() from applyDocument() — runs correctly via applySelectionOverlays() after text is finalized - Fix hover indicator survival: reorder refresh() so text-modifying passes (updateCommandRow) run before overlay passes - Guard synthetic Leave events during setText() to preserve hover state - Remove primitives from type chooser when pointer modifier (* / **) is active; remove primitives entirely in Root command bar mode - Add test_editor and test_controller test coverage for heat clearing, hover survival, and mixed hex/non-hex type scenarios
This commit is contained in:
7
.github/workflows/build.yml
vendored
7
.github/workflows/build.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: cmake --build build
|
||||
|
||||
- name: Test
|
||||
run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_windbg_provider|test_com_security"
|
||||
run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_controller|test_windbg_provider|test_com_security"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
build/imageformats/
|
||||
build/iconengines/
|
||||
build/themes/
|
||||
build/examples/
|
||||
build/screenshot.png
|
||||
|
||||
- name: Get date tag
|
||||
@@ -77,6 +78,7 @@ jobs:
|
||||
mkdir -p release/Plugins
|
||||
cp build/Plugins/*.dll release/Plugins/ 2>/dev/null || true
|
||||
cp -r build/themes release/ 2>/dev/null || true
|
||||
cp -r build/examples release/ 2>/dev/null || true
|
||||
cp build/screenshot.png release/ 2>/dev/null || true
|
||||
cd release && 7z a ../Reclass-win64-qt6.zip *
|
||||
|
||||
@@ -122,7 +124,7 @@ jobs:
|
||||
run: cmake --build build
|
||||
|
||||
- name: Test
|
||||
run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor"
|
||||
run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_controller"
|
||||
env:
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
|
||||
@@ -138,6 +140,7 @@ jobs:
|
||||
cp build/Reclass AppDir/usr/bin/
|
||||
cp build/ReclassMcpBridge AppDir/usr/bin/
|
||||
cp -r build/themes AppDir/usr/bin/ 2>/dev/null || true
|
||||
cp -r build/examples AppDir/usr/bin/ 2>/dev/null || true
|
||||
mkdir -p AppDir/usr/bin/Plugins
|
||||
cp build/Plugins/*.so AppDir/usr/bin/Plugins/ 2>/dev/null || true
|
||||
cp src/icons/class.png AppDir/usr/share/icons/hicolor/256x256/apps/reclass.png
|
||||
|
||||
@@ -101,6 +101,14 @@ foreach(_tf ${_theme_files})
|
||||
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
|
||||
endforeach()
|
||||
|
||||
# Copy example .rcx files to build directory
|
||||
file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx")
|
||||
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples")
|
||||
foreach(_ef ${_example_files})
|
||||
get_filename_component(_name ${_ef} NAME)
|
||||
configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY)
|
||||
endforeach()
|
||||
|
||||
include(deploy)
|
||||
|
||||
if(TARGET deploy)
|
||||
@@ -218,6 +226,16 @@ if(BUILD_TESTING)
|
||||
endif()
|
||||
add_test(NAME test_context_menu COMMAND test_context_menu)
|
||||
|
||||
add_executable(test_editor tests/test_editor.cpp
|
||||
src/editor.cpp src/compose.cpp src/format.cpp
|
||||
src/providerregistry.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_editor PRIVATE src)
|
||||
target_link_libraries(test_editor PRIVATE
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
add_test(NAME test_editor COMMAND test_editor)
|
||||
|
||||
add_executable(test_rendered_view tests/test_rendered_view.cpp
|
||||
src/generator.cpp src/compose.cpp src/format.cpp)
|
||||
target_include_directories(test_rendered_view PRIVATE src)
|
||||
|
||||
@@ -436,7 +436,10 @@ void RcxController::connectEditor(RcxEditor* editor) {
|
||||
m_doc->undoStack.clear();
|
||||
m_doc->provider = std::move(provider);
|
||||
m_doc->dataPath.clear();
|
||||
m_doc->tree.baseAddress = newBase;
|
||||
if (m_doc->tree.baseAddress == 0)
|
||||
m_doc->tree.baseAddress = newBase;
|
||||
else
|
||||
m_doc->provider->setBase(m_doc->tree.baseAddress);
|
||||
resetSnapshot();
|
||||
emit m_doc->documentChanged();
|
||||
|
||||
@@ -634,12 +637,12 @@ void RcxController::refresh() {
|
||||
}
|
||||
|
||||
// Update value history and compute heat levels
|
||||
// Use the snapshot provider if active; skip entirely if no valid provider
|
||||
// Only run when a live provider is attached (not for static file/buffer sources)
|
||||
{
|
||||
const Provider* prov = nullptr;
|
||||
if (m_snapshotProv)
|
||||
if (m_snapshotProv && m_snapshotProv->isLive())
|
||||
prov = m_snapshotProv.get();
|
||||
else if (m_doc->provider && m_doc->provider->isValid())
|
||||
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
|
||||
prov = m_doc->provider.get();
|
||||
|
||||
if (prov) {
|
||||
@@ -651,11 +654,9 @@ void RcxController::refresh() {
|
||||
const Node& node = m_doc->tree.nodes[lm.nodeIdx];
|
||||
// Skip containers — they don't have scalar values
|
||||
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) continue;
|
||||
// Skip hex preview nodes — they show raw bytes, not a single value
|
||||
if (isHexPreview(node.kind)) continue;
|
||||
|
||||
int64_t nodeOff = m_doc->tree.computeOffset(lm.nodeIdx);
|
||||
uint64_t addr = m_doc->tree.baseAddress + static_cast<uint64_t>(nodeOff);
|
||||
uint64_t addr = static_cast<uint64_t>(nodeOff); // provider-relative
|
||||
int sz = node.byteSize();
|
||||
if (sz <= 0 || !prov->isReadable(addr, sz)) continue;
|
||||
|
||||
@@ -666,17 +667,6 @@ void RcxController::refresh() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply persisted heat levels even when provider is unavailable
|
||||
if (!prov) {
|
||||
for (auto& lm : m_lastResult.meta) {
|
||||
if (lm.nodeId != 0) {
|
||||
auto it = m_valueHistory.find(lm.nodeId);
|
||||
if (it != m_valueHistory.end())
|
||||
lm.heatLevel = it->heatLevel();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prune stale selections (nodes removed by undo/redo/delete)
|
||||
@@ -707,9 +697,11 @@ void RcxController::refresh() {
|
||||
editor->applyDocument(m_lastResult);
|
||||
editor->restoreViewState(vs);
|
||||
}
|
||||
applySelectionOverlays();
|
||||
// Text-modifying passes first (command row replaces line 0 text),
|
||||
// then overlays last so hover indicators survive the refresh.
|
||||
pushSavedSourcesToEditors();
|
||||
updateCommandRow();
|
||||
applySelectionOverlays();
|
||||
}
|
||||
|
||||
void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
|
||||
@@ -906,6 +898,23 @@ void RcxController::materializeRefChildren(int nodeIdx) {
|
||||
void RcxController::applyCommand(const Command& command, bool isUndo) {
|
||||
auto& tree = m_doc->tree;
|
||||
|
||||
// Clear value history for nodes whose effective offset changed.
|
||||
// When offsets shift (insert/delete/resize), old recorded values came from
|
||||
// a different memory address, so keeping them would show false heat.
|
||||
// Also invalidates any in-flight async read so that stale snapshot data
|
||||
// from before the offset change doesn't re-introduce false heat.
|
||||
auto clearHistoryForAdjs = [&](const QVector<cmd::OffsetAdj>& adjs) {
|
||||
if (adjs.isEmpty()) return;
|
||||
m_refreshGen++; // discard in-flight async read (stale layout)
|
||||
for (const auto& adj : adjs) {
|
||||
// Clear the adjusted node itself
|
||||
m_valueHistory.remove(adj.nodeId);
|
||||
// Clear all descendants (their effective address also shifted)
|
||||
for (int ci : tree.subtreeIndices(adj.nodeId))
|
||||
m_valueHistory.remove(tree.nodes[ci].id);
|
||||
}
|
||||
};
|
||||
|
||||
std::visit([&](auto&& c) {
|
||||
using T = std::decay_t<decltype(c)>;
|
||||
if constexpr (std::is_same_v<T, cmd::ChangeKind>) {
|
||||
@@ -917,6 +926,12 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
|
||||
if (ai >= 0)
|
||||
tree.nodes[ai].offset = isUndo ? adj.oldOffset : adj.newOffset;
|
||||
}
|
||||
// The changed node's value format changed; clear its history.
|
||||
// If offAdjs is empty (same-size change), still bump gen to
|
||||
// discard in-flight reads that would record the old format.
|
||||
if (c.offAdjs.isEmpty()) m_refreshGen++;
|
||||
m_valueHistory.remove(c.nodeId);
|
||||
clearHistoryForAdjs(c.offAdjs);
|
||||
} else if constexpr (std::is_same_v<T, cmd::Rename>) {
|
||||
int idx = tree.indexOfId(c.nodeId);
|
||||
if (idx >= 0)
|
||||
@@ -945,6 +960,7 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
|
||||
if (ai >= 0) tree.nodes[ai].offset = adj.newOffset;
|
||||
}
|
||||
}
|
||||
clearHistoryForAdjs(c.offAdjs);
|
||||
} else if constexpr (std::is_same_v<T, cmd::Remove>) {
|
||||
if (isUndo) {
|
||||
// Restore nodes first
|
||||
@@ -970,6 +986,8 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
|
||||
}
|
||||
tree.invalidateIdCache();
|
||||
}
|
||||
// Siblings shifted — their old values are from wrong addresses
|
||||
clearHistoryForAdjs(c.offAdjs);
|
||||
} else if constexpr (std::is_same_v<T, cmd::ChangeBase>) {
|
||||
tree.baseAddress = isUndo ? c.oldBase : c.newBase;
|
||||
qDebug() << "[ChangeBase] tree.baseAddress =" << Qt::hex << tree.baseAddress
|
||||
@@ -1016,6 +1034,11 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
|
||||
int idx = tree.indexOfId(c.nodeId);
|
||||
if (idx >= 0)
|
||||
tree.nodes[idx].offset = isUndo ? c.oldOffset : c.newOffset;
|
||||
// Node and its descendants read from a different address now
|
||||
m_refreshGen++; // discard in-flight async read (stale layout)
|
||||
m_valueHistory.remove(c.nodeId);
|
||||
for (int ci : tree.subtreeIndices(c.nodeId))
|
||||
m_valueHistory.remove(tree.nodes[ci].id);
|
||||
}
|
||||
}, command);
|
||||
|
||||
@@ -1494,8 +1517,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line,
|
||||
}
|
||||
}
|
||||
|
||||
applySelectionOverlays();
|
||||
updateCommandRow();
|
||||
applySelectionOverlays();
|
||||
|
||||
if (m_selIds.size() == 1) {
|
||||
uint64_t sid = *m_selIds.begin();
|
||||
@@ -1508,8 +1531,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line,
|
||||
void RcxController::clearSelection() {
|
||||
m_selIds.clear();
|
||||
m_anchorLine = -1;
|
||||
applySelectionOverlays();
|
||||
updateCommandRow();
|
||||
applySelectionOverlays();
|
||||
}
|
||||
|
||||
void RcxController::applySelectionOverlays() {
|
||||
@@ -1750,7 +1773,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
|
||||
|
||||
switch (mode) {
|
||||
case TypePopupMode::Root:
|
||||
addPrimitives(/*enabled=*/false, /*excludeStructArrayPad=*/false);
|
||||
// No primitives in Root mode – only project types are valid roots
|
||||
addComposites([&](const Node&, const TypeEntry& e) {
|
||||
return e.structId == m_viewRootId;
|
||||
});
|
||||
@@ -2019,7 +2042,10 @@ void RcxController::attachViaPlugin(const QString& providerIdentifier, const QSt
|
||||
m_doc->undoStack.clear();
|
||||
m_doc->provider = std::move(provider);
|
||||
m_doc->dataPath.clear();
|
||||
m_doc->tree.baseAddress = newBase;
|
||||
if (m_doc->tree.baseAddress == 0)
|
||||
m_doc->tree.baseAddress = newBase;
|
||||
else
|
||||
m_doc->provider->setBase(m_doc->tree.baseAddress);
|
||||
resetSnapshot();
|
||||
emit m_doc->documentChanged();
|
||||
refresh();
|
||||
@@ -2062,9 +2088,15 @@ void RcxController::pushSavedSourcesToEditors() {
|
||||
|
||||
// ── Auto-refresh ──
|
||||
|
||||
void RcxController::setRefreshInterval(int ms) {
|
||||
if (m_refreshTimer)
|
||||
m_refreshTimer->setInterval(qMax(1, ms));
|
||||
}
|
||||
|
||||
void RcxController::setupAutoRefresh() {
|
||||
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
|
||||
m_refreshTimer = new QTimer(this);
|
||||
m_refreshTimer->setInterval(660);
|
||||
m_refreshTimer->setInterval(qMax(1, ms));
|
||||
connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick);
|
||||
m_refreshTimer->start();
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ public:
|
||||
|
||||
RcxDocument* document() const { return m_doc; }
|
||||
void setEditorFont(const QString& fontName);
|
||||
void setRefreshInterval(int ms);
|
||||
|
||||
// MCP bridge accessors
|
||||
void setSuppressRefresh(bool v) { m_suppressRefresh = v; }
|
||||
@@ -121,6 +122,9 @@ public:
|
||||
int activeSourceIndex() const { return m_activeSourceIdx; }
|
||||
void switchSource(int idx) { switchToSavedSource(idx); }
|
||||
|
||||
// Test accessor
|
||||
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
|
||||
|
||||
signals:
|
||||
void nodeSelected(int nodeIdx);
|
||||
void selectionChanged(int count);
|
||||
|
||||
299
src/editor.cpp
299
src/editor.cpp
@@ -5,6 +5,7 @@
|
||||
#include <Qsci/qsciscintillabase.h>
|
||||
#include <Qsci/qscilexercpp.h>
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QFont>
|
||||
#include <QColor>
|
||||
#include <QKeyEvent>
|
||||
@@ -15,10 +16,142 @@
|
||||
#include <QMenu>
|
||||
#include <QApplication>
|
||||
#include <QClipboard>
|
||||
#include <QLabel>
|
||||
#include <QToolButton>
|
||||
#include <QScreen>
|
||||
#include <functional>
|
||||
#include "themes/thememanager.h"
|
||||
|
||||
namespace rcx {
|
||||
|
||||
// ── Value history popup (styled like TypeSelectorPopup) ──
|
||||
|
||||
class ValueHistoryPopup : public QFrame {
|
||||
uint64_t m_nodeId = 0;
|
||||
bool m_hasButtons = false;
|
||||
QStringList m_values;
|
||||
QVector<QLabel*> m_labels;
|
||||
std::function<void(const QString&)> m_onSet;
|
||||
public:
|
||||
explicit ValueHistoryPopup(QWidget* parent)
|
||||
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
||||
{
|
||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setAutoFillBackground(true);
|
||||
}
|
||||
|
||||
uint64_t nodeId() const { return m_nodeId; }
|
||||
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
|
||||
|
||||
void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font,
|
||||
bool showButtons = false) {
|
||||
QStringList vals;
|
||||
hist.forEach([&](const QString& v) { vals.append(v); });
|
||||
|
||||
if (nodeId == m_nodeId && vals == m_values
|
||||
&& showButtons == m_hasButtons && isVisible())
|
||||
return;
|
||||
|
||||
// In-place label update when structure unchanged (avoids flicker)
|
||||
if (nodeId == m_nodeId && vals.size() == m_values.size()
|
||||
&& vals.size() == m_labels.size()
|
||||
&& showButtons == m_hasButtons && isVisible()) {
|
||||
for (int i = 0; i < vals.size(); i++)
|
||||
m_labels[i]->setText(vals[i]);
|
||||
m_values = vals;
|
||||
return;
|
||||
}
|
||||
|
||||
m_nodeId = nodeId;
|
||||
m_values = vals;
|
||||
m_hasButtons = showButtons;
|
||||
m_labels.clear();
|
||||
|
||||
delete layout();
|
||||
qDeleteAll(findChildren<QWidget*>(QString(), Qt::FindDirectChildrenOnly));
|
||||
|
||||
const auto& theme = ThemeManager::instance().current();
|
||||
QPalette pal;
|
||||
pal.setColor(QPalette::Window, theme.backgroundAlt);
|
||||
pal.setColor(QPalette::WindowText, theme.text);
|
||||
setPalette(pal);
|
||||
|
||||
auto* vbox = new QVBoxLayout(this);
|
||||
vbox->setContentsMargins(8, 6, 8, 6);
|
||||
vbox->setSpacing(2);
|
||||
|
||||
auto* title = new QLabel(QStringLiteral("Previous Values"));
|
||||
QFont bold = font;
|
||||
bold.setBold(true);
|
||||
title->setFont(bold);
|
||||
title->setStyleSheet(QStringLiteral("color: %1;").arg(theme.text.name()));
|
||||
vbox->addWidget(title);
|
||||
|
||||
auto* sep = new QFrame;
|
||||
sep->setFrameShape(QFrame::HLine);
|
||||
sep->setFrameShadow(QFrame::Plain);
|
||||
sep->setFixedHeight(1);
|
||||
QPalette sp; sp.setColor(QPalette::WindowText, theme.border);
|
||||
sep->setPalette(sp);
|
||||
vbox->addWidget(sep);
|
||||
|
||||
for (const QString& v : vals) {
|
||||
auto* row = new QHBoxLayout;
|
||||
row->setContentsMargins(0, 1, 0, 1);
|
||||
row->setSpacing(8);
|
||||
|
||||
auto* label = new QLabel(v);
|
||||
label->setFont(font);
|
||||
label->setStyleSheet(QStringLiteral("color: %1;").arg(theme.syntaxNumber.name()));
|
||||
row->addWidget(label, 1);
|
||||
m_labels.append(label);
|
||||
|
||||
if (showButtons) {
|
||||
auto* setBtn = new QToolButton;
|
||||
setBtn->setText(QStringLiteral("Set"));
|
||||
setBtn->setAutoRaise(true);
|
||||
setBtn->setCursor(Qt::PointingHandCursor);
|
||||
setBtn->setFont(font);
|
||||
setBtn->setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: %1; border: none; padding: 1px 4px; }"
|
||||
"QToolButton:hover { color: %2; background: %3; }")
|
||||
.arg(theme.textDim.name(), theme.text.name(), theme.hover.name()));
|
||||
QString val = v;
|
||||
QObject::connect(setBtn, &QToolButton::clicked, [this, val]() {
|
||||
if (m_onSet) m_onSet(val);
|
||||
});
|
||||
row->addWidget(setBtn);
|
||||
}
|
||||
vbox->addLayout(row);
|
||||
}
|
||||
|
||||
adjustSize();
|
||||
}
|
||||
|
||||
void showAt(const QPoint& globalPos) {
|
||||
if (isVisible()) return;
|
||||
QSize sz = sizeHint();
|
||||
QRect screen = QApplication::screenAt(globalPos)
|
||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||
: QRect(0, 0, 1920, 1080);
|
||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||
int y = globalPos.y();
|
||||
if (y + sz.height() > screen.bottom())
|
||||
y = globalPos.y() - sz.height() - 4;
|
||||
move(x, y);
|
||||
show();
|
||||
}
|
||||
|
||||
void dismiss() {
|
||||
if (isVisible()) hide();
|
||||
m_nodeId = 0;
|
||||
m_values.clear();
|
||||
m_labels.clear();
|
||||
}
|
||||
};
|
||||
|
||||
static constexpr int IND_EDITABLE = 8;
|
||||
static constexpr int IND_HEX_DIM = 9;
|
||||
static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address
|
||||
@@ -71,6 +204,27 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
||||
m_sci->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(m_sci, &QWidget::customContextMenuRequested,
|
||||
this, [this](const QPoint& pos) {
|
||||
// Right-click on offset margin → show margin mode menu
|
||||
int margin0Width = (int)m_sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_GETMARGINWIDTHN, 0UL, 0L);
|
||||
if (pos.x() < margin0Width) {
|
||||
QMenu menu;
|
||||
auto* actRel = menu.addAction("Relative Offsets (+0x)");
|
||||
auto* actAbs = menu.addAction("Absolute Addresses");
|
||||
actRel->setCheckable(true);
|
||||
actAbs->setCheckable(true);
|
||||
actRel->setChecked(m_relativeOffsets);
|
||||
actAbs->setChecked(!m_relativeOffsets);
|
||||
QAction* chosen = menu.exec(m_sci->mapToGlobal(pos));
|
||||
if (chosen == actRel && !m_relativeOffsets) {
|
||||
m_relativeOffsets = true;
|
||||
reformatMargins();
|
||||
} else if (chosen == actAbs && m_relativeOffsets) {
|
||||
m_relativeOffsets = false;
|
||||
reformatMargins();
|
||||
}
|
||||
return;
|
||||
}
|
||||
int line = m_sci->lineAt(pos);
|
||||
int nodeIdx = -1;
|
||||
int subLine = 0;
|
||||
@@ -374,6 +528,9 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
|
||||
if (m_editState.active)
|
||||
endInlineEdit();
|
||||
|
||||
// Guard: suppress popup dismiss during setText() which fires synthetic Leave events
|
||||
m_applyingDocument = true;
|
||||
|
||||
// Save hover state — setText() triggers viewport Leave events that would clear it
|
||||
uint64_t savedHoverId = m_hoveredNodeId;
|
||||
int savedHoverLine = m_hoveredLine;
|
||||
@@ -422,6 +579,13 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
|
||||
m_hoveredNodeId = savedHoverId;
|
||||
m_hoveredLine = savedHoverLine;
|
||||
m_hoverInside = savedHoverInside;
|
||||
m_applyingDocument = false;
|
||||
|
||||
// Re-apply hover markers (setText() clears all Scintilla markers).
|
||||
// applyHoverCursor() is NOT called here — it evaluates hitTest() against
|
||||
// composed text that updateCommandRow() will overwrite. The correct call
|
||||
// happens via applySelectionOverlays() after all text is finalized.
|
||||
applyHoverHighlight();
|
||||
}
|
||||
|
||||
void RcxEditor::applyMarginText(const QVector<LineMeta>& meta) {
|
||||
@@ -787,31 +951,35 @@ void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
|
||||
int typeW = lm.effectiveTypeW;
|
||||
int nameW = lm.effectiveNameW;
|
||||
|
||||
// For hex preview nodes: use dataChanged + changedByteIndices (per-byte heat)
|
||||
if (heat <= 0) continue;
|
||||
|
||||
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
|
||||
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
|
||||
|
||||
// For hex preview nodes: per-byte heat coloring on changed bytes
|
||||
if (isHexPreview(lm.nodeKind) && lm.dataChanged && !lm.changedByteIndices.isEmpty()) {
|
||||
// Hex nodes don't track heatLevel (they're skipped in controller).
|
||||
// Use IND_HEAT_COLD for any changed byte (simple visual feedback).
|
||||
int ind = kFoldCol + lm.depth * 3;
|
||||
int asciiStart = ind + typeW + kSepWidth;
|
||||
int hexStart = asciiStart + nameW + kSepWidth;
|
||||
|
||||
for (int byteIdx : lm.changedByteIndices) {
|
||||
fillIndicatorCols(IND_HEAT_COLD, i, asciiStart + byteIdx, asciiStart + byteIdx + 1);
|
||||
fillIndicatorCols(activeInd, i, asciiStart + byteIdx, asciiStart + byteIdx + 1);
|
||||
int hexCol = hexStart + byteIdx * 3;
|
||||
fillIndicatorCols(IND_HEAT_COLD, i, hexCol, hexCol + 2);
|
||||
fillIndicatorCols(activeInd, i, hexCol, hexCol + 2);
|
||||
}
|
||||
// Clear the other two heat indicators on this line
|
||||
for (int hi : heatIndicators) {
|
||||
if (hi != activeInd)
|
||||
clearIndicatorLine(hi, i);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-hex nodes: apply heat-level indicator to value span
|
||||
if (heat <= 0) continue;
|
||||
|
||||
QString lineText = getLineText(m_sci, i);
|
||||
ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW);
|
||||
if (!vs.valid) continue;
|
||||
|
||||
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
|
||||
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
|
||||
fillIndicatorCols(activeInd, i, vs.start, vs.end);
|
||||
|
||||
// Clear the other two heat indicators on this span to avoid overlap
|
||||
@@ -1478,6 +1646,10 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
|
||||
}
|
||||
// Track mouse position for cursor updates (both edit and non-edit mode)
|
||||
if (obj == m_sci->viewport()) {
|
||||
// Ignore synthetic Leave from setText() during document refresh
|
||||
if (m_applyingDocument && event->type() == QEvent::Leave)
|
||||
return true;
|
||||
|
||||
if (event->type() == QEvent::MouseMove) {
|
||||
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
|
||||
m_hoverInside = true;
|
||||
@@ -1683,6 +1855,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
m_hoveredNodeId = 0;
|
||||
m_hoveredLine = -1;
|
||||
applyHoverHighlight();
|
||||
// Dismiss hover popup so it gets recreated with Set buttons once edit starts
|
||||
if (m_historyPopup)
|
||||
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||
// Clear editable-token color hints (de-emphasize non-active tokens)
|
||||
clearIndicatorLine(IND_EDITABLE, m_hintLine);
|
||||
m_hintLine = -1;
|
||||
@@ -1864,6 +2039,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
m_sci->viewport()->setCursor(Qt::ArrowCursor);
|
||||
});
|
||||
}
|
||||
// Refresh hover cursor so value history popup appears with Set buttons immediately
|
||||
if (target == EditTarget::Value)
|
||||
QTimer::singleShot(0, this, &RcxEditor::applyHoverCursor);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2216,25 +2394,60 @@ void RcxEditor::applyHoverCursor() {
|
||||
if (m_editState.active) {
|
||||
if (m_sci->isListActive()) {
|
||||
m_sci->viewport()->setCursor(Qt::ArrowCursor);
|
||||
return;
|
||||
}
|
||||
auto h = hitTest(m_lastHoverPos);
|
||||
if (h.line == m_editState.line &&
|
||||
h.col >= m_editState.spanStart && h.col <= editEndCol()) {
|
||||
m_sci->viewport()->setCursor(Qt::IBeamCursor);
|
||||
} else {
|
||||
m_sci->viewport()->setCursor(Qt::ArrowCursor);
|
||||
auto h = hitTest(m_lastHoverPos);
|
||||
if (h.line == m_editState.line &&
|
||||
h.col >= m_editState.spanStart && h.col <= editEndCol()) {
|
||||
m_sci->viewport()->setCursor(Qt::IBeamCursor);
|
||||
} else {
|
||||
m_sci->viewport()->setCursor(Qt::ArrowCursor);
|
||||
}
|
||||
}
|
||||
// Value history popup — only during inline value editing on a heated node
|
||||
{
|
||||
bool showPopup = false;
|
||||
if (m_valueHistory && m_editState.target == EditTarget::Value
|
||||
&& m_editState.line >= 0 && m_editState.line < m_meta.size()) {
|
||||
const LineMeta& lm = m_meta[m_editState.line];
|
||||
if (lm.heatLevel > 0 && lm.nodeId != 0) {
|
||||
auto it = m_valueHistory->find(lm.nodeId);
|
||||
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
|
||||
if (!m_historyPopup)
|
||||
m_historyPopup = new ValueHistoryPopup(this);
|
||||
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
|
||||
popup->setOnSet([this](const QString& val) {
|
||||
if (!m_editState.active) return;
|
||||
long endPos = posFromCol(m_sci, m_editState.line, editEndCol());
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL,
|
||||
m_editState.posStart, endPos);
|
||||
QByteArray utf8 = val.toUtf8();
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL,
|
||||
(uintptr_t)0, utf8.constData());
|
||||
});
|
||||
popup->populate(lm.nodeId, *it, editorFont(), true);
|
||||
int px = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
|
||||
(unsigned long)0, m_editState.posStart);
|
||||
int py = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
|
||||
(unsigned long)0, m_editState.posStart);
|
||||
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
||||
(unsigned long)m_editState.line);
|
||||
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
||||
popup->showAt(anchor);
|
||||
showPopup = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
|
||||
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mouse left viewport - set Arrow, cancel calltip
|
||||
// Mouse left viewport - set Arrow, dismiss history popup
|
||||
// (but not during applyDocument — the Leave is synthetic from setText)
|
||||
if (!m_hoverInside) {
|
||||
if (m_calltipVisible) {
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL);
|
||||
m_calltipVisible = false;
|
||||
m_calltipLine = -1;
|
||||
}
|
||||
if (m_historyPopup && !m_applyingDocument)
|
||||
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||
m_sci->viewport()->setCursor(Qt::ArrowCursor);
|
||||
return;
|
||||
}
|
||||
@@ -2323,41 +2536,39 @@ void RcxEditor::applyHoverCursor() {
|
||||
m_hoverSpanLines.append(h.line);
|
||||
}
|
||||
|
||||
// Value history calltip on hover
|
||||
// Value history popup on hover (read-only, no buttons)
|
||||
{
|
||||
bool showCalltip = false;
|
||||
if (m_valueHistory && h.line >= 0 && h.line < m_meta.size() && !m_editState.active) {
|
||||
bool showPopup = false;
|
||||
if (m_valueHistory && h.line >= 0 && h.line < m_meta.size()) {
|
||||
const LineMeta& lm = m_meta[h.line];
|
||||
if (lm.heatLevel > 0 && lm.nodeId != 0) {
|
||||
auto it = m_valueHistory->find(lm.nodeId);
|
||||
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
|
||||
// Check cursor is over the value span
|
||||
QString lineText = getLineText(m_sci, h.line);
|
||||
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
|
||||
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
|
||||
QString tip = QStringLiteral("Previous Values:");
|
||||
it->forEach([&](const QString& v) {
|
||||
tip += QStringLiteral("\n ") + v;
|
||||
});
|
||||
if (m_calltipLine != h.line) {
|
||||
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
|
||||
if (!m_historyPopup)
|
||||
m_historyPopup = new ValueHistoryPopup(this);
|
||||
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
|
||||
popup->populate(lm.nodeId, *it, editorFont(), false);
|
||||
long linePos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
|
||||
(unsigned long)h.line);
|
||||
QByteArray tipUtf8 = tip.toUtf8();
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPSHOW,
|
||||
pos, tipUtf8.constData());
|
||||
m_calltipLine = h.line;
|
||||
m_calltipVisible = true;
|
||||
}
|
||||
showCalltip = true;
|
||||
long byteOff = lineText.left(vs.start).toUtf8().size();
|
||||
int px = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
|
||||
(unsigned long)0, linePos + byteOff);
|
||||
int py = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
|
||||
(unsigned long)0, linePos);
|
||||
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
||||
(unsigned long)h.line);
|
||||
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
||||
popup->showAt(anchor);
|
||||
showPopup = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!showCalltip && m_calltipVisible) {
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL);
|
||||
m_calltipVisible = false;
|
||||
m_calltipLine = -1;
|
||||
}
|
||||
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
|
||||
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||
}
|
||||
|
||||
// Determine cursor shape based on interaction type
|
||||
|
||||
@@ -132,10 +132,10 @@ private:
|
||||
|
||||
// ── Value history ref (owned by controller) ──
|
||||
const QHash<uint64_t, ValueHistory>* m_valueHistory = nullptr;
|
||||
bool m_calltipVisible = false;
|
||||
int m_calltipLine = -1;
|
||||
QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp)
|
||||
|
||||
// ── Reentrancy guards ──
|
||||
bool m_applyingDocument = false;
|
||||
bool m_clampingSelection = false;
|
||||
bool m_updatingComment = false;
|
||||
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
{
|
||||
"baseAddress": "400000",
|
||||
"nextId": "29",
|
||||
"nodes": [
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "1",
|
||||
"kind": "Struct",
|
||||
"name": "aBall",
|
||||
"offset": 0,
|
||||
"parentId": "0",
|
||||
"refId": "0",
|
||||
"strLen": 64,
|
||||
"structTypeName": "ball"
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "2",
|
||||
"kind": "Hex64",
|
||||
"name": "field_00",
|
||||
"offset": 0,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "3",
|
||||
"kind": "Hex64",
|
||||
"name": "field_08",
|
||||
"offset": 8,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "4",
|
||||
"kind": "Vec4",
|
||||
"name": "position",
|
||||
"offset": 16,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "5",
|
||||
"kind": "Vec3",
|
||||
"name": "velocity",
|
||||
"offset": 32,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "6",
|
||||
"kind": "Hex32",
|
||||
"name": "field_2C",
|
||||
"offset": 44,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "7",
|
||||
"kind": "Float",
|
||||
"name": "speed",
|
||||
"offset": 48,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "8",
|
||||
"kind": "UInt32",
|
||||
"name": "color",
|
||||
"offset": 52,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "9",
|
||||
"kind": "Float",
|
||||
"name": "radius",
|
||||
"offset": 56,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "10",
|
||||
"kind": "Hex32",
|
||||
"name": "field_3C",
|
||||
"offset": 60,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "11",
|
||||
"kind": "Float",
|
||||
"name": "mass",
|
||||
"offset": 64,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "12",
|
||||
"kind": "Hex64",
|
||||
"name": "field_44",
|
||||
"offset": 68,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "13",
|
||||
"kind": "Bool",
|
||||
"name": "bouncy",
|
||||
"offset": 76,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "14",
|
||||
"kind": "Hex8",
|
||||
"name": "field_4D",
|
||||
"offset": 77,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "15",
|
||||
"kind": "Hex16",
|
||||
"name": "field_4E",
|
||||
"offset": 78,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "16",
|
||||
"kind": "UInt32",
|
||||
"name": "color",
|
||||
"offset": 80,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "17",
|
||||
"kind": "Hex32",
|
||||
"name": "field_54",
|
||||
"offset": 84,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "18",
|
||||
"kind": "Hex64",
|
||||
"name": "field_58",
|
||||
"offset": 88,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "19",
|
||||
"kind": "Hex64",
|
||||
"name": "field_60",
|
||||
"offset": 96,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "20",
|
||||
"kind": "Struct",
|
||||
"name": "aPhysics",
|
||||
"offset": 0,
|
||||
"parentId": "0",
|
||||
"refId": "0",
|
||||
"strLen": 64,
|
||||
"structTypeName": "Physics"
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "21",
|
||||
"kind": "Hex64",
|
||||
"name": "field_00",
|
||||
"offset": 0,
|
||||
"parentId": "20",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "22",
|
||||
"kind": "Hex64",
|
||||
"name": "field_08",
|
||||
"offset": 8,
|
||||
"parentId": "20",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "23",
|
||||
"kind": "Hex64",
|
||||
"name": "field_10",
|
||||
"offset": 16,
|
||||
"parentId": "20",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "24",
|
||||
"kind": "Hex64",
|
||||
"name": "field_18",
|
||||
"offset": 24,
|
||||
"parentId": "20",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"id": "25",
|
||||
"kind": "Hex64",
|
||||
"name": "field_20",
|
||||
"offset": 32,
|
||||
"parentId": "20",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"collapsed": true,
|
||||
"elementKind": "UInt8",
|
||||
"id": "26",
|
||||
"kind": "Pointer64",
|
||||
"name": "physics",
|
||||
"offset": 104,
|
||||
"parentId": "1",
|
||||
"refId": "20",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 4,
|
||||
"collapsed": false,
|
||||
"elementKind": "Float",
|
||||
"id": "27",
|
||||
"kind": "Array",
|
||||
"name": "scores",
|
||||
"offset": 112,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 2,
|
||||
"collapsed": false,
|
||||
"elementKind": "Struct",
|
||||
"id": "28",
|
||||
"kind": "Array",
|
||||
"name": "materials",
|
||||
"offset": 128,
|
||||
"parentId": "1",
|
||||
"refId": "20",
|
||||
"strLen": 64
|
||||
}
|
||||
]
|
||||
}
|
||||
123
src/main.cpp
123
src/main.cpp
@@ -412,6 +412,18 @@ void MainWindow::createMenus() {
|
||||
Qt5Qt6AddAction(file, "Export ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction);
|
||||
Qt5Qt6AddAction(file, "Import from &Source...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importFromSource);
|
||||
Qt5Qt6AddAction(file, "&Import ReClass XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importReclassXml);
|
||||
// Examples submenu — scan once at init
|
||||
{
|
||||
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
|
||||
QStringList rcxFiles = exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name);
|
||||
if (!rcxFiles.isEmpty()) {
|
||||
auto* examples = file->addMenu("&Examples");
|
||||
for (const QString& fn : rcxFiles) {
|
||||
QString fullPath = exDir.absoluteFilePath(fn);
|
||||
examples->addAction(fn, this, [this, fullPath]() { project_open(fullPath); });
|
||||
}
|
||||
}
|
||||
}
|
||||
file->addSeparator();
|
||||
const auto itemName = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
|
||||
m_mcpAction = Qt5Qt6AddAction(file, itemName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp);
|
||||
@@ -732,77 +744,22 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
|
||||
return sub;
|
||||
}
|
||||
|
||||
// Build Ball + Material demo structs into a tree
|
||||
static void buildBallDemo(NodeTree& tree) {
|
||||
// Ball struct (128 bytes = 0x80)
|
||||
Node ball;
|
||||
ball.kind = NodeKind::Struct;
|
||||
ball.name = "aBall";
|
||||
ball.structTypeName = "Ball";
|
||||
ball.parentId = 0;
|
||||
ball.offset = 0;
|
||||
int bi = tree.addNode(ball);
|
||||
uint64_t ballId = tree.nodes[bi].id;
|
||||
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 32; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_2C"; n.parentId = ballId; n.offset = 44; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 48; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 52; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 56; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Float; n.name = "mass"; n.parentId = ballId; n.offset = 64; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_44"; n.parentId = ballId; n.offset = 68; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 76; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex8; n.name = "field_4D"; n.parentId = ballId; n.offset = 77; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex16; n.name = "field_4E"; n.parentId = ballId; n.offset = 78; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 80; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_54"; n.parentId = ballId; n.offset = 84; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_58"; n.parentId = ballId; n.offset = 88; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_60"; n.parentId = ballId; n.offset = 96; tree.addNode(n); }
|
||||
|
||||
// Material struct (renamed from Physics, 40 bytes = 0x28)
|
||||
Node mat;
|
||||
mat.kind = NodeKind::Struct;
|
||||
mat.name = "aMaterial";
|
||||
mat.structTypeName = "Material";
|
||||
mat.parentId = 0;
|
||||
mat.offset = 0;
|
||||
int mi = tree.addNode(mat);
|
||||
uint64_t matId = tree.nodes[mi].id;
|
||||
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = matId; n.offset = 0; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = matId; n.offset = 8; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_10"; n.parentId = matId; n.offset = 16; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = matId; n.offset = 24; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_20"; n.parentId = matId; n.offset = 32; tree.addNode(n); }
|
||||
|
||||
// Pointer to Material in Ball struct
|
||||
{ Node n; n.kind = NodeKind::Pointer64; n.name = "material"; n.parentId = ballId; n.offset = 104; n.refId = matId; n.collapsed = true; tree.addNode(n); }
|
||||
|
||||
// float[4] scores at offset 112
|
||||
{ Node n; n.kind = NodeKind::Array; n.name = "scores"; n.parentId = ballId; n.offset = 112; n.elementKind = NodeKind::Float; n.arrayLen = 4; tree.addNode(n); }
|
||||
|
||||
// Material[2] materials at offset 128 (112 + 16 for float[4])
|
||||
{ Node n; n.kind = NodeKind::Array; n.name = "materials"; n.parentId = ballId; n.offset = 128; n.elementKind = NodeKind::Struct; n.arrayLen = 2; n.refId = matId; tree.addNode(n); }
|
||||
|
||||
// Unnamed struct (128 bytes of hex64 fields)
|
||||
Node unnamed;
|
||||
unnamed.kind = NodeKind::Struct;
|
||||
unnamed.name = "instance";
|
||||
unnamed.structTypeName = "Unnamed";
|
||||
unnamed.parentId = 0;
|
||||
unnamed.offset = 0;
|
||||
int ui = tree.addNode(unnamed);
|
||||
uint64_t unnamedId = tree.nodes[ui].id;
|
||||
// Build a minimal empty struct for new documents
|
||||
static void buildEmptyStruct(NodeTree& tree) {
|
||||
Node root;
|
||||
root.kind = NodeKind::Struct;
|
||||
root.name = "instance";
|
||||
root.structTypeName = "Unnamed";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
for (int i = 0; i < 16; i++) {
|
||||
Node n;
|
||||
n.kind = NodeKind::Hex64;
|
||||
n.name = QStringLiteral("field_%1").arg(i * 8, 2, 16, QChar('0'));
|
||||
n.parentId = unnamedId;
|
||||
n.parentId = rootId;
|
||||
n.offset = i * 8;
|
||||
tree.addNode(n);
|
||||
}
|
||||
@@ -829,14 +786,12 @@ void MainWindow::newDocument() {
|
||||
doc->typeAliases.clear();
|
||||
doc->modified = false;
|
||||
|
||||
// Build Ball + Material structs
|
||||
buildBallDemo(doc->tree);
|
||||
buildEmptyStruct(doc->tree);
|
||||
|
||||
// Cross-platform writable buffer, zeroed (256 bytes covers Ball + spare)
|
||||
QByteArray data(256, '\0');
|
||||
doc->provider = std::make_shared<BufferProvider>(data);
|
||||
|
||||
// Focus on Ball struct
|
||||
// Focus on first struct
|
||||
ctrl->setViewRootId(0);
|
||||
for (const auto& n : doc->tree.nodes) {
|
||||
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
||||
@@ -854,7 +809,22 @@ void MainWindow::newDocument() {
|
||||
}
|
||||
|
||||
void MainWindow::selfTest() {
|
||||
project_new();
|
||||
// Auto-open KUSER_SHARED_DATA example if available
|
||||
QString exPath = QCoreApplication::applicationDirPath()
|
||||
+ "/examples/KUSER_SHARED_DATA.rcx";
|
||||
if (QFile::exists(exPath)) {
|
||||
project_open(exPath);
|
||||
} else {
|
||||
project_new();
|
||||
}
|
||||
|
||||
// Auto-attach process memory plugin to self
|
||||
auto* ctrl = activeController();
|
||||
if (ctrl) {
|
||||
DWORD pid = GetCurrentProcessId();
|
||||
QString target = QString("%1:Reclass.exe").arg(pid);
|
||||
ctrl->attachViaPlugin(QStringLiteral("processmemory"), target);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::openFile() {
|
||||
@@ -1080,6 +1050,7 @@ void MainWindow::showOptionsDialog() {
|
||||
current.showIcon = QSettings("Reclass", "Reclass").value("showIcon", false).toBool();
|
||||
current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool();
|
||||
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool();
|
||||
current.refreshMs = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
|
||||
|
||||
OptionsDialog dlg(current, this);
|
||||
if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK
|
||||
@@ -1107,6 +1078,12 @@ void MainWindow::showOptionsDialog() {
|
||||
|
||||
if (r.autoStartMcp != current.autoStartMcp)
|
||||
QSettings("Reclass", "Reclass").setValue("autoStartMcp", r.autoStartMcp);
|
||||
|
||||
if (r.refreshMs != current.refreshMs) {
|
||||
QSettings("Reclass", "Reclass").setValue("refreshMs", r.refreshMs);
|
||||
for (auto& tab : m_tabs)
|
||||
tab.ctrl->setRefreshInterval(r.refreshMs);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setEditorFont(const QString& fontName) {
|
||||
@@ -1510,13 +1487,11 @@ void MainWindow::showTypeAliasesDialog() {
|
||||
QMdiSubWindow* MainWindow::project_new() {
|
||||
auto* doc = new RcxDocument(this);
|
||||
|
||||
// Cross-platform writable buffer, zeroed (256 bytes covers Ball struct + spare)
|
||||
QByteArray data(256, '\0');
|
||||
doc->loadData(data);
|
||||
doc->tree.baseAddress = 0x00400000;
|
||||
|
||||
// Build Ball + Material demo structs
|
||||
buildBallDemo(doc->tree);
|
||||
buildEmptyStruct(doc->tree);
|
||||
|
||||
auto* sub = createTab(doc);
|
||||
rebuildWorkspaceModel();
|
||||
|
||||
@@ -58,6 +58,29 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
||||
generalLayout->setContentsMargins(0, 0, 0, 0);
|
||||
generalLayout->setSpacing(8);
|
||||
|
||||
// Refresh Rate group box
|
||||
auto* refreshGroup = new QGroupBox("Refresh Rate");
|
||||
auto* refreshLayout = new QFormLayout(refreshGroup);
|
||||
refreshLayout->setSpacing(8);
|
||||
refreshLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
|
||||
|
||||
m_refreshSpin = new QSpinBox;
|
||||
m_refreshSpin->setRange(1, 60000);
|
||||
m_refreshSpin->setSingleStep(50);
|
||||
m_refreshSpin->setValue(current.refreshMs);
|
||||
m_refreshSpin->setSuffix(" ms");
|
||||
m_refreshSpin->setObjectName("refreshSpin");
|
||||
refreshLayout->addRow("Interval:", m_refreshSpin);
|
||||
|
||||
auto* refreshDesc = new QLabel(
|
||||
"How often live memory is re-read and the view is updated, in milliseconds. "
|
||||
"Lower values give faster updates but use more CPU. Default: 660 ms.");
|
||||
refreshDesc->setWordWrap(true);
|
||||
refreshDesc->setContentsMargins(0, 0, 0, 0);
|
||||
refreshLayout->addRow(refreshDesc);
|
||||
|
||||
generalLayout->addWidget(refreshGroup);
|
||||
|
||||
// Visual Experience group box
|
||||
auto* visualGroup = new QGroupBox("Visual Experience");
|
||||
auto* visualLayout = new QFormLayout(visualGroup);
|
||||
@@ -184,6 +207,7 @@ OptionsResult OptionsDialog::result() const {
|
||||
r.showIcon = m_showIconCheck->isChecked();
|
||||
r.safeMode = m_safeModeCheck->isChecked();
|
||||
r.autoStartMcp = m_autoMcpCheck->isChecked();
|
||||
r.refreshMs = m_refreshSpin->value();
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <QComboBox>
|
||||
#include <QCheckBox>
|
||||
#include <QHash>
|
||||
#include <QSpinBox>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
@@ -16,6 +17,7 @@ struct OptionsResult {
|
||||
bool showIcon = false;
|
||||
bool safeMode = false;
|
||||
bool autoStartMcp = false;
|
||||
int refreshMs = 660;
|
||||
};
|
||||
|
||||
class OptionsDialog : public QDialog {
|
||||
@@ -38,6 +40,7 @@ private:
|
||||
QCheckBox* m_showIconCheck = nullptr;
|
||||
QCheckBox* m_safeModeCheck = nullptr;
|
||||
QCheckBox* m_autoMcpCheck = nullptr;
|
||||
QSpinBox* m_refreshSpin = nullptr;
|
||||
|
||||
// searchable keywords per leaf tree item
|
||||
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"indHoverSpan": "#E6B450",
|
||||
"indCmdPill": "#2a2a2a",
|
||||
"indDataChanged": "#8fbc7a",
|
||||
"indHeatCold": "#569cd6",
|
||||
"indHeatCold": "#D4A945",
|
||||
"indHeatWarm": "#E6B450",
|
||||
"indHeatHot": "#f44747",
|
||||
"indHintGreen": "#5a8248",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"indHoverSpan": "#b180d7",
|
||||
"indCmdPill": "#2d2d30",
|
||||
"indDataChanged": "#8fbc7a",
|
||||
"indHeatCold": "#569cd6",
|
||||
"indHeatCold": "#D4A945",
|
||||
"indHeatWarm": "#d69d85",
|
||||
"indHeatHot": "#f44747",
|
||||
"indHintGreen": "#5a8248",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"indHoverSpan": "#AA9565",
|
||||
"indCmdPill": "#2a2a2a",
|
||||
"indDataChanged": "#6B959F",
|
||||
"indHeatCold": "#6B959F",
|
||||
"indHeatCold": "#C4A44A",
|
||||
"indHeatWarm": "#AA9565",
|
||||
"indHeatHot": "#A05040",
|
||||
"indHintGreen": "#464646",
|
||||
|
||||
@@ -54,9 +54,9 @@ Theme Theme::fromJson(const QJsonObject& o) {
|
||||
t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString());
|
||||
}
|
||||
// Derive heat colors from the theme's own palette when keys are absent
|
||||
// cold = keyword blue, warm = hover/string amber, hot = marker red
|
||||
// cold = muted yellow, warm = hover/string amber, hot = marker red
|
||||
if (!t.indHeatCold.isValid())
|
||||
t.indHeatCold = t.syntaxKeyword;
|
||||
t.indHeatCold = QColor("#D4A945");
|
||||
if (!t.indHeatWarm.isValid())
|
||||
t.indHeatWarm = t.indHoverSpan.isValid() ? t.indHoverSpan : t.syntaxString;
|
||||
if (!t.indHeatHot.isValid())
|
||||
|
||||
@@ -336,6 +336,7 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
|
||||
m_arrayCountEdit->setVisible(id == 3);
|
||||
if (id == 3) m_arrayCountEdit->setFocus();
|
||||
updateModifierPreview();
|
||||
applyFilter(m_filterEdit->text());
|
||||
});
|
||||
connect(m_arrayCountEdit, &QLineEdit::textChanged,
|
||||
this, [this]() { updateModifierPreview(); });
|
||||
@@ -562,6 +563,10 @@ void TypeSelectorPopup::applyFilter(const QString& text) {
|
||||
|
||||
QString filterBase = text.trimmed();
|
||||
|
||||
// Hide primitives when a pointer modifier (* or **) is active
|
||||
int modId = m_modGroup->checkedId();
|
||||
bool hideprimitives = (modId == 1 || modId == 2);
|
||||
|
||||
// Separate primitives and composites
|
||||
QVector<TypeEntry> primitives, composites;
|
||||
for (const auto& t : m_allTypes) {
|
||||
@@ -571,9 +576,10 @@ void TypeSelectorPopup::applyFilter(const QString& text) {
|
||||
|| t.classKeyword.contains(filterBase, Qt::CaseInsensitive);
|
||||
if (!matchesFilter) continue;
|
||||
|
||||
if (t.entryKind == TypeEntry::Primitive)
|
||||
primitives.append(t);
|
||||
else if (t.entryKind == TypeEntry::Composite)
|
||||
if (t.entryKind == TypeEntry::Primitive) {
|
||||
if (!hideprimitives)
|
||||
primitives.append(t);
|
||||
} else if (t.entryKind == TypeEntry::Composite)
|
||||
composites.append(t);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,26 @@
|
||||
|
||||
using namespace rcx;
|
||||
|
||||
// Provider with a configurable base address (for testing source-switch logic)
|
||||
class BaseAwareProvider : public Provider {
|
||||
QByteArray m_data;
|
||||
uint64_t m_base;
|
||||
public:
|
||||
BaseAwareProvider(QByteArray data, uint64_t base)
|
||||
: m_data(std::move(data)), m_base(base) {}
|
||||
bool read(uint64_t addr, void* buf, int len) const override {
|
||||
if (addr + len > (uint64_t)m_data.size()) return false;
|
||||
std::memcpy(buf, m_data.constData() + addr, len);
|
||||
return true;
|
||||
}
|
||||
int size() const override { return m_data.size(); }
|
||||
uint64_t base() const override { return m_base; }
|
||||
void setBase(uint64_t b) override { m_base = b; }
|
||||
bool isLive() const override { return true; }
|
||||
QString name() const override { return QStringLiteral("test"); }
|
||||
QString kind() const override { return QStringLiteral("Process"); }
|
||||
};
|
||||
|
||||
// Small tree: one root struct with a few typed fields at known offsets.
|
||||
// Keeps tests fast and deterministic (no giant PEB tree).
|
||||
static void buildSmallTree(NodeTree& tree) {
|
||||
@@ -383,6 +403,48 @@ private slots:
|
||||
QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF);
|
||||
}
|
||||
|
||||
// ── Test: source switch preserves existing base address ──
|
||||
void testSourceSwitchPreservesBase() {
|
||||
// Document already has baseAddress = 0x1000 from buildSmallTree()
|
||||
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
|
||||
|
||||
// Simulate attaching a new provider whose base differs (e.g. 0x400000)
|
||||
auto prov = std::make_shared<BaseAwareProvider>(makeSmallBuffer(), 0x400000);
|
||||
uint64_t newBase = prov->base();
|
||||
QCOMPARE(newBase, (uint64_t)0x400000);
|
||||
|
||||
m_doc->provider = prov;
|
||||
// This is the controller logic under test:
|
||||
if (m_doc->tree.baseAddress == 0)
|
||||
m_doc->tree.baseAddress = newBase;
|
||||
else
|
||||
m_doc->provider->setBase(m_doc->tree.baseAddress);
|
||||
|
||||
// baseAddress must stay at the original value
|
||||
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
|
||||
// provider base must be synced to match
|
||||
QCOMPARE(m_doc->provider->base(), (uint64_t)0x1000);
|
||||
}
|
||||
|
||||
// ── Test: source switch on fresh doc uses provider default ──
|
||||
void testSourceSwitchFreshDocUsesProviderBase() {
|
||||
// Simulate a fresh document (no loaded .rcx → baseAddress == 0)
|
||||
m_doc->tree.baseAddress = 0;
|
||||
|
||||
auto prov = std::make_shared<BaseAwareProvider>(makeSmallBuffer(), 0x7FFE0000);
|
||||
uint64_t newBase = prov->base();
|
||||
|
||||
m_doc->provider = prov;
|
||||
if (m_doc->tree.baseAddress == 0)
|
||||
m_doc->tree.baseAddress = newBase;
|
||||
else
|
||||
m_doc->provider->setBase(m_doc->tree.baseAddress);
|
||||
|
||||
// Fresh doc should adopt the provider's default base
|
||||
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x7FFE0000);
|
||||
QCOMPARE(m_doc->provider->base(), (uint64_t)0x7FFE0000);
|
||||
}
|
||||
|
||||
// ── Test: toggleCollapse + undo ──
|
||||
void testToggleCollapse() {
|
||||
// Root is index 0, a Struct node
|
||||
@@ -406,6 +468,181 @@ private slots:
|
||||
QApplication::processEvents();
|
||||
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
|
||||
}
|
||||
// ── Test: value history popup only appears during inline editing ──
|
||||
void testValueHistoryPopupOnlyDuringEdit() {
|
||||
// Record value history for field_u32 so it has heat
|
||||
auto& tree = m_doc->tree;
|
||||
int idx = -1;
|
||||
for (int i = 0; i < tree.nodes.size(); i++) {
|
||||
if (tree.nodes[i].name == "field_u32") { idx = i; break; }
|
||||
}
|
||||
QVERIFY(idx >= 0);
|
||||
uint64_t nodeId = tree.nodes[idx].id;
|
||||
|
||||
QHash<uint64_t, ValueHistory> history;
|
||||
history[nodeId].record("100");
|
||||
history[nodeId].record("200");
|
||||
history[nodeId].record("300");
|
||||
QVERIFY(history[nodeId].uniqueCount() > 1);
|
||||
|
||||
m_editor->setValueHistoryRef(&history);
|
||||
|
||||
// Refresh and compose so editor has meta with heatLevel
|
||||
m_ctrl->refresh();
|
||||
QApplication::processEvents();
|
||||
ComposeResult result = m_doc->compose();
|
||||
// Manually set heat on the node's line meta
|
||||
for (auto& lm : result.meta) {
|
||||
if (lm.nodeId == nodeId) lm.heatLevel = 2;
|
||||
}
|
||||
m_editor->applyDocument(result);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Popup should not exist or not be visible (no editing active)
|
||||
auto* popup = m_editor->findChild<QWidget*>(QString(), Qt::FindDirectChildrenOnly);
|
||||
// Even if popup widget exists, it should not be visible
|
||||
bool popupVisible = false;
|
||||
for (auto* child : m_editor->findChildren<QFrame*>(QString(), Qt::FindDirectChildrenOnly)) {
|
||||
if (child->isVisible() && child->windowFlags() & Qt::ToolTip)
|
||||
popupVisible = true;
|
||||
}
|
||||
QVERIFY2(!popupVisible, "Popup should not be visible when not editing");
|
||||
|
||||
// Start inline edit on value column of field_u32
|
||||
int fieldLine = -1;
|
||||
for (int i = 0; i < result.meta.size(); i++) {
|
||||
if (result.meta[i].nodeId == nodeId && result.meta[i].lineKind == LineKind::Field) {
|
||||
fieldLine = i; break;
|
||||
}
|
||||
}
|
||||
QVERIFY(fieldLine >= 0);
|
||||
|
||||
bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine);
|
||||
QVERIFY(ok);
|
||||
QVERIFY(m_editor->isEditing());
|
||||
|
||||
// Trigger hover cursor update (simulates mouse move during editing)
|
||||
QApplication::processEvents();
|
||||
|
||||
// Cancel edit to clean up
|
||||
m_editor->cancelInlineEdit();
|
||||
QApplication::processEvents();
|
||||
|
||||
m_editor->setValueHistoryRef(nullptr);
|
||||
}
|
||||
|
||||
// ── Test: delete node clears value history for shifted siblings ──
|
||||
void testDeleteClearsHeatForShiftedNodes() {
|
||||
// Replace with a live provider so refresh() actually records values
|
||||
m_doc->provider = std::make_unique<BaseAwareProvider>(makeSmallBuffer(), 0x1000);
|
||||
m_ctrl->refresh();
|
||||
QApplication::processEvents();
|
||||
|
||||
auto& tree = m_doc->tree;
|
||||
|
||||
// Locate field_u32 (the node we'll delete) and the siblings after it.
|
||||
// The small tree has: field_u32(0), field_float(4), field_u8(8),
|
||||
// pad0/Hex16(9), pad1/Hex8(11), field_hex/Hex32(12)
|
||||
// field_float and field_u8 are regular (non-hex) types.
|
||||
int delIdx = -1;
|
||||
for (int i = 0; i < tree.nodes.size(); i++) {
|
||||
if (tree.nodes[i].name == "field_u32") { delIdx = i; break; }
|
||||
}
|
||||
QVERIFY(delIdx >= 0);
|
||||
uint64_t delId = tree.nodes[delIdx].id;
|
||||
|
||||
// Collect sibling node IDs that come after field_u32 (will be shifted)
|
||||
uint64_t parentId = tree.nodes[delIdx].parentId;
|
||||
int deletedSize = tree.nodes[delIdx].byteSize(); // 4 bytes
|
||||
int deletedEnd = tree.nodes[delIdx].offset + deletedSize;
|
||||
QVector<uint64_t> shiftedIds;
|
||||
QHash<uint64_t, QString> nameMap; // for debug messages
|
||||
for (int i = 0; i < tree.nodes.size(); i++) {
|
||||
if (tree.nodes[i].parentId == parentId && i != delIdx
|
||||
&& tree.nodes[i].offset >= deletedEnd) {
|
||||
shiftedIds.append(tree.nodes[i].id);
|
||||
nameMap[tree.nodes[i].id] = tree.nodes[i].name;
|
||||
}
|
||||
}
|
||||
QVERIFY2(!shiftedIds.isEmpty(), "Should have siblings after field_u32");
|
||||
|
||||
// Seed value history for shifted siblings (simulate accumulated heat)
|
||||
auto& history = const_cast<QHash<uint64_t, ValueHistory>&>(m_ctrl->valueHistory());
|
||||
for (uint64_t id : shiftedIds) {
|
||||
history[id].record("old_val_1");
|
||||
history[id].record("old_val_2");
|
||||
history[id].record("old_val_3");
|
||||
QVERIFY2(history[id].heatLevel() >= 2,
|
||||
qPrintable(QString("Pre-delete: %1 should have heat>=2")
|
||||
.arg(nameMap[id])));
|
||||
}
|
||||
|
||||
// Also seed the to-be-deleted node
|
||||
history[delId].record("del_1");
|
||||
history[delId].record("del_2");
|
||||
QVERIFY(history.contains(delId));
|
||||
|
||||
// Delete field_u32 — this shifts all subsequent siblings
|
||||
m_ctrl->removeNode(delIdx);
|
||||
QApplication::processEvents();
|
||||
|
||||
// The deleted node's history should be gone
|
||||
QVERIFY2(!m_ctrl->valueHistory().contains(delId),
|
||||
"Deleted node's value history should be cleared");
|
||||
|
||||
// All shifted siblings should have heat=0 after the delete.
|
||||
// With a live provider, refresh() inside removeNode re-records one new
|
||||
// value at the new offset → count=1 → heatLevel=0.
|
||||
for (uint64_t id : shiftedIds) {
|
||||
int heat = m_ctrl->valueHistory().contains(id)
|
||||
? m_ctrl->valueHistory()[id].heatLevel() : 0;
|
||||
QVERIFY2(heat == 0,
|
||||
qPrintable(QString("Shifted node '%1' (id=%2) should have heat=0, got %3")
|
||||
.arg(nameMap[id]).arg(id).arg(heat)));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: value history records and cycles correctly ──
|
||||
void testValueHistoryRingBuffer() {
|
||||
ValueHistory vh;
|
||||
QCOMPARE(vh.count, 0);
|
||||
QCOMPARE(vh.heatLevel(), 0);
|
||||
|
||||
vh.record("10");
|
||||
QCOMPARE(vh.count, 1);
|
||||
QCOMPARE(vh.heatLevel(), 0); // 1 unique = static
|
||||
|
||||
// Duplicate should not increase count
|
||||
vh.record("10");
|
||||
QCOMPARE(vh.count, 1);
|
||||
|
||||
vh.record("20");
|
||||
QCOMPARE(vh.count, 2);
|
||||
QCOMPARE(vh.heatLevel(), 1); // cold
|
||||
|
||||
vh.record("30");
|
||||
QCOMPARE(vh.count, 3);
|
||||
QCOMPARE(vh.heatLevel(), 2); // warm
|
||||
|
||||
vh.record("40");
|
||||
vh.record("50");
|
||||
QCOMPARE(vh.count, 5);
|
||||
QCOMPARE(vh.heatLevel(), 3); // hot
|
||||
|
||||
QCOMPARE(vh.last(), QString("50"));
|
||||
|
||||
// Ring buffer: uniqueCount() caps at kCapacity
|
||||
for (int i = 0; i < 20; i++)
|
||||
vh.record(QString::number(100 + i));
|
||||
QCOMPARE(vh.uniqueCount(), ValueHistory::kCapacity);
|
||||
QVERIFY(vh.count > ValueHistory::kCapacity);
|
||||
|
||||
// forEach iterates oldest→newest within ring
|
||||
QStringList vals;
|
||||
vh.forEach([&](const QString& v) { vals.append(v); });
|
||||
QCOMPARE(vals.size(), ValueHistory::kCapacity);
|
||||
QCOMPARE(vals.last(), vh.last());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestController)
|
||||
|
||||
@@ -999,6 +999,144 @@ private slots:
|
||||
"Root header should be suppressed from compose output");
|
||||
}
|
||||
|
||||
// ── Test: command row hover indicator survives refresh cycle ──
|
||||
void testCommandRowHoverSurvivesRefresh() {
|
||||
// IND_HOVER_SPAN = 11 (defined in editor.cpp, replicate for test)
|
||||
constexpr int IND_HOVER_SPAN = 11;
|
||||
|
||||
m_editor->applyDocument(m_result);
|
||||
|
||||
// Set command row text (simulates controller.updateCommandRow)
|
||||
QString cmdText = QStringLiteral(
|
||||
"source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct\u25BE _PEB64 {");
|
||||
m_editor->setCommandRowText(cmdText);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Parse the source span on line 0
|
||||
auto* sci = m_editor->scintilla();
|
||||
int len = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
|
||||
QVERIFY(len > 0);
|
||||
QByteArray buf(len + 1, '\0');
|
||||
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0,
|
||||
(void*)buf.data());
|
||||
QString lineText = QString::fromUtf8(buf.constData(), len);
|
||||
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
|
||||
lineText.chop(1);
|
||||
|
||||
ColumnSpan srcSpan = commandRowSrcSpan(lineText);
|
||||
QVERIFY2(srcSpan.valid, "Source span should be valid on command row");
|
||||
|
||||
// Programmatically move mouse to the source span
|
||||
int hoverCol = srcSpan.start + 1;
|
||||
QPoint hoverPos = colToViewport(sci, 0, hoverCol);
|
||||
sendMouseMove(sci->viewport(), hoverPos);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Verify IND_HOVER_SPAN is set at the hover position
|
||||
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
|
||||
(unsigned long)0, (long)hoverCol);
|
||||
sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT,
|
||||
(unsigned long)IND_HOVER_SPAN);
|
||||
int valBefore = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_INDICATORVALUEAT,
|
||||
(unsigned long)IND_HOVER_SPAN, pos);
|
||||
QVERIFY2(valBefore != 0,
|
||||
"IND_HOVER_SPAN should be set on source span after hover");
|
||||
|
||||
// Verify cursor is PointingHand (Source target = clickable)
|
||||
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
|
||||
|
||||
// ── Simulate a full refresh cycle (same order as controller.refresh) ──
|
||||
ViewState vs = m_editor->saveViewState();
|
||||
m_editor->applyDocument(m_result);
|
||||
m_editor->restoreViewState(vs);
|
||||
|
||||
// Cursor must NOT have flipped to Arrow during applyDocument
|
||||
// (applyHoverCursor is not called prematurely on composed text)
|
||||
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
|
||||
|
||||
// updateCommandRow() — replaces line 0 text
|
||||
m_editor->setCommandRowText(cmdText);
|
||||
|
||||
// applySelectionOverlays() — must run AFTER updateCommandRow
|
||||
m_editor->applySelectionOverlay(QSet<uint64_t>());
|
||||
QApplication::processEvents();
|
||||
|
||||
// Re-query the position (text was replaced, byte offset may have shifted)
|
||||
long posAfter = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
|
||||
(unsigned long)0, (long)hoverCol);
|
||||
int valAfter = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_INDICATORVALUEAT,
|
||||
(unsigned long)IND_HOVER_SPAN, posAfter);
|
||||
QVERIFY2(valAfter != 0,
|
||||
"IND_HOVER_SPAN must survive refresh on command row "
|
||||
"(hover should not flicker)");
|
||||
|
||||
// Cursor must still be PointingHand after full refresh cycle
|
||||
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
|
||||
|
||||
m_editor->applyDocument(m_result);
|
||||
}
|
||||
|
||||
// ── Test: command row hover survives multiple rapid refresh cycles ──
|
||||
void testCommandRowHoverSurvivesRepeatedRefresh() {
|
||||
constexpr int IND_HOVER_SPAN = 11;
|
||||
|
||||
m_editor->applyDocument(m_result);
|
||||
|
||||
QString cmdText = QStringLiteral(
|
||||
"source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct\u25BE _PEB64 {");
|
||||
m_editor->setCommandRowText(cmdText);
|
||||
QApplication::processEvents();
|
||||
|
||||
auto* sci = m_editor->scintilla();
|
||||
int lineLen = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
|
||||
QByteArray buf(lineLen + 1, '\0');
|
||||
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0,
|
||||
(void*)buf.data());
|
||||
QString lineText = QString::fromUtf8(buf.constData(), lineLen);
|
||||
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
|
||||
lineText.chop(1);
|
||||
|
||||
ColumnSpan srcSpan = commandRowSrcSpan(lineText);
|
||||
QVERIFY(srcSpan.valid);
|
||||
int hoverCol = srcSpan.start + 1;
|
||||
|
||||
// Move mouse into position
|
||||
QPoint hoverPos = colToViewport(sci, 0, hoverCol);
|
||||
sendMouseMove(sci->viewport(), hoverPos);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Simulate 5 rapid refresh cycles (like ~660ms timer x5)
|
||||
for (int cycle = 0; cycle < 5; cycle++) {
|
||||
ViewState vs = m_editor->saveViewState();
|
||||
m_editor->applyDocument(m_result);
|
||||
m_editor->restoreViewState(vs);
|
||||
m_editor->setCommandRowText(cmdText);
|
||||
m_editor->applySelectionOverlay(QSet<uint64_t>());
|
||||
|
||||
// Re-send mouse move each cycle (mouse is still there physically)
|
||||
sendMouseMove(sci->viewport(), hoverPos);
|
||||
QApplication::processEvents();
|
||||
|
||||
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
|
||||
(unsigned long)0, (long)hoverCol);
|
||||
int val = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_INDICATORVALUEAT,
|
||||
(unsigned long)IND_HOVER_SPAN, pos);
|
||||
QVERIFY2(val != 0,
|
||||
qPrintable(QString(
|
||||
"IND_HOVER_SPAN lost on refresh cycle %1").arg(cycle)));
|
||||
QVERIFY2(viewportCursor(m_editor) == Qt::PointingHandCursor,
|
||||
qPrintable(QString(
|
||||
"Cursor flipped away from PointingHand on cycle %1").arg(cycle)));
|
||||
}
|
||||
|
||||
m_editor->applyDocument(m_result);
|
||||
}
|
||||
|
||||
// ── Test: MenuBarStyle gives QMenu items generous click targets ──
|
||||
// ── Test: M_ACCENT marker appears on selected rows ──
|
||||
void testAccentMarkerOnSelectedRows() {
|
||||
@@ -1117,6 +1255,157 @@ private slots:
|
||||
.arg(styled.height()).arg(base.height())));
|
||||
}
|
||||
|
||||
// ── Test: non-hex nodes don't show false heat coloring after offset shift ──
|
||||
void testDeleteClearsHeatOnShiftedNodes() {
|
||||
// Heat indicator constants (replicated from editor.cpp)
|
||||
constexpr int IND_HEAT_COLD = 13;
|
||||
constexpr int IND_HEAT_WARM = 17;
|
||||
constexpr int IND_HEAT_HOT = 18;
|
||||
|
||||
// Build a small tree: root struct with mixed regular (non-hex) + hex fields
|
||||
NodeTree tree;
|
||||
tree.baseAddress = 0x1000;
|
||||
|
||||
Node root;
|
||||
root.kind = NodeKind::Struct;
|
||||
root.structTypeName = "SmallStruct";
|
||||
root.name = "s";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
// field0: UInt32 at offset 0 (4 bytes) — will be deleted
|
||||
// field1: UInt32 at offset 4 (4 bytes) — regular type, will shift
|
||||
// field2: Float at offset 8 (4 bytes) — regular type, will shift
|
||||
// field3: Hex32 at offset 12 (4 bytes) — hex type, will shift
|
||||
struct FieldDef { int off; NodeKind kind; const char* name; };
|
||||
FieldDef defs[] = {
|
||||
{ 0, NodeKind::UInt32, "count"},
|
||||
{ 4, NodeKind::UInt32, "flags"},
|
||||
{ 8, NodeKind::Float, "speed"},
|
||||
{12, NodeKind::Hex32, "raw"},
|
||||
};
|
||||
QVector<uint64_t> fieldIds;
|
||||
for (auto& d : defs) {
|
||||
Node n;
|
||||
n.kind = d.kind;
|
||||
n.name = d.name;
|
||||
n.parentId = rootId;
|
||||
n.offset = d.off;
|
||||
int idx = tree.addNode(n);
|
||||
fieldIds.append(tree.nodes[idx].id);
|
||||
}
|
||||
|
||||
// Create a provider with 16 bytes of recognizable data
|
||||
QByteArray data(16, '\0');
|
||||
uint32_t v0 = 42; memcpy(data.data() + 0, &v0, 4); // count=42
|
||||
uint32_t v1 = 0xFF; memcpy(data.data() + 4, &v1, 4); // flags=255
|
||||
float v2 = 3.14f; memcpy(data.data() + 8, &v2, 4); // speed=3.14
|
||||
uint32_t v3 = 0xCAFE; memcpy(data.data() + 12, &v3, 4); // raw=0xCAFE
|
||||
BufferProvider prov(data);
|
||||
|
||||
// Compose the initial document
|
||||
ComposeResult result = compose(tree, prov);
|
||||
|
||||
// Inject heatLevel=2 (warm) on field1, field2, field3 — simulates
|
||||
// heat accumulated before the delete
|
||||
for (auto& lm : result.meta) {
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
if (lm.nodeId == fieldIds[i])
|
||||
lm.heatLevel = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to editor — heat indicators should appear
|
||||
m_editor->applyDocument(result);
|
||||
QApplication::processEvents();
|
||||
|
||||
auto* sci = m_editor->scintilla();
|
||||
|
||||
// Helper: check if any heat indicator is set anywhere on a line
|
||||
auto hasHeatOnLine = [&](int line) -> bool {
|
||||
int lineLen = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line);
|
||||
long lineStart = sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)line);
|
||||
for (long pos = lineStart; pos < lineStart + lineLen; pos++) {
|
||||
for (int ind : { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT }) {
|
||||
int val = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_INDICATORVALUEAT,
|
||||
(unsigned long)ind, pos);
|
||||
if (val != 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find lines for each shifted field
|
||||
auto findFieldLine = [&](const ComposeResult& cr, uint64_t nodeId) -> int {
|
||||
for (int i = 0; i < cr.meta.size(); i++) {
|
||||
if (cr.meta[i].nodeId == nodeId && cr.meta[i].lineKind == LineKind::Field)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
int line1 = findFieldLine(result, fieldIds[1]);
|
||||
int line2 = findFieldLine(result, fieldIds[2]);
|
||||
int line3 = findFieldLine(result, fieldIds[3]);
|
||||
QVERIFY(line1 >= 0);
|
||||
QVERIFY(line2 >= 0);
|
||||
QVERIFY(line3 >= 0);
|
||||
|
||||
// Verify heat indicators ARE present (UInt32, Float, and Hex32)
|
||||
QVERIFY2(hasHeatOnLine(line1),
|
||||
"Heat should be present on UInt32 'flags' before delete");
|
||||
QVERIFY2(hasHeatOnLine(line2),
|
||||
"Heat should be present on Float 'speed' before delete");
|
||||
QVERIFY2(hasHeatOnLine(line3),
|
||||
"Heat should be present on Hex32 'raw' before delete");
|
||||
|
||||
// ── Simulate delete of field0 (UInt32 'count' at offset 0) ──
|
||||
int field0Idx = tree.indexOfId(fieldIds[0]);
|
||||
QVERIFY(field0Idx >= 0);
|
||||
tree.nodes.remove(field0Idx);
|
||||
tree.invalidateIdCache();
|
||||
|
||||
// Shift remaining fields' offsets down by 4
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
int fi = tree.indexOfId(fieldIds[i]);
|
||||
if (fi >= 0) tree.nodes[fi].offset -= 4;
|
||||
}
|
||||
|
||||
// Recompose — heatLevel defaults to 0 (simulates cleared history)
|
||||
ComposeResult afterResult = compose(tree, prov);
|
||||
|
||||
// Apply the post-delete document to the editor
|
||||
m_editor->applyDocument(afterResult);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Find new line positions
|
||||
int newLine1 = findFieldLine(afterResult, fieldIds[1]);
|
||||
int newLine2 = findFieldLine(afterResult, fieldIds[2]);
|
||||
int newLine3 = findFieldLine(afterResult, fieldIds[3]);
|
||||
QVERIFY(newLine1 >= 0);
|
||||
QVERIFY(newLine2 >= 0);
|
||||
QVERIFY(newLine3 >= 0);
|
||||
|
||||
// After applying heatLevel=0, NO heat indicators should appear
|
||||
QVERIFY2(!hasHeatOnLine(newLine1),
|
||||
"UInt32 'flags' should NOT show heat after offset shift "
|
||||
"(old values are from wrong address)");
|
||||
QVERIFY2(!hasHeatOnLine(newLine2),
|
||||
"Float 'speed' should NOT show heat after offset shift "
|
||||
"(old values are from wrong address)");
|
||||
QVERIFY2(!hasHeatOnLine(newLine3),
|
||||
"Hex32 'raw' should NOT show heat after offset shift "
|
||||
"(old values are from wrong address)");
|
||||
|
||||
// Restore original document
|
||||
m_editor->applyDocument(m_result);
|
||||
}
|
||||
|
||||
void testMenuHoverRendersAmberText() {
|
||||
// Replicate MenuBarStyle with drawControl hover override
|
||||
class TestMenuStyle : public QProxyStyle {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <QPushButton>
|
||||
#include <QGroupBox>
|
||||
#include <QLineEdit>
|
||||
#include <QSpinBox>
|
||||
#include <QLabel>
|
||||
#include "optionsdialog.h"
|
||||
#include "themes/thememanager.h"
|
||||
@@ -222,6 +223,45 @@ private slots:
|
||||
QVERIFY(!aiItem->isHidden());
|
||||
}
|
||||
|
||||
void refreshRateSpinBoxExists() {
|
||||
OptionsResult defaults;
|
||||
defaults.refreshMs = 660;
|
||||
OptionsDialog dlg(defaults);
|
||||
|
||||
auto* spin = dlg.findChild<QSpinBox*>("refreshSpin");
|
||||
QVERIFY(spin);
|
||||
QCOMPARE(spin->value(), 660);
|
||||
QCOMPARE(spin->minimum(), 1);
|
||||
QCOMPARE(spin->maximum(), 60000);
|
||||
}
|
||||
|
||||
void refreshRateResultReflectsInput() {
|
||||
OptionsResult input;
|
||||
input.refreshMs = 200;
|
||||
OptionsDialog dlg(input);
|
||||
|
||||
auto r = dlg.result();
|
||||
QCOMPARE(r.refreshMs, 200);
|
||||
|
||||
// Change via spin box
|
||||
auto* spin = dlg.findChild<QSpinBox*>("refreshSpin");
|
||||
QVERIFY(spin);
|
||||
spin->setValue(100);
|
||||
r = dlg.result();
|
||||
QCOMPARE(r.refreshMs, 100);
|
||||
}
|
||||
|
||||
void refreshRateClampsMin() {
|
||||
OptionsResult input;
|
||||
input.refreshMs = 0; // below minimum
|
||||
OptionsDialog dlg(input);
|
||||
|
||||
auto* spin = dlg.findChild<QSpinBox*>("refreshSpin");
|
||||
QVERIFY(spin);
|
||||
// QSpinBox clamps to minimum
|
||||
QCOMPARE(spin->value(), 1);
|
||||
}
|
||||
|
||||
void dialogInheritsPalette() {
|
||||
auto& tm = ThemeManager::instance();
|
||||
const auto& theme = tm.current();
|
||||
|
||||
Reference in New Issue
Block a user