Compare commits

..

22 Commits

Author SHA1 Message Date
computron
71bc51cbab fix: guard Windows-only setDarkTitleBar and fix deprecated addAction arg order 2026-02-15 11:28:39 -07:00
computron
60a97ab81b fix: add missing Unix headers for Linux build 2026-02-15 11:20:28 -07:00
IChooseYou
bb00e75019 CI: win64 + linux64 builds, guard Windows-only targets for cross-platform 2026-02-15 11:16:09 -07:00
IChooseYou
c038c59e34 CI: add write permission for releases 2026-02-15 11:01:31 -07:00
IChooseYou
862f76b984 CI: auto-upload build zip to latest release 2026-02-15 10:24:46 -07:00
sysadmin
818285a76e CI: skip editor/windbg/com tests that need display or debug tools 2026-02-15 09:49:34 -07:00
sysadmin
ef5e2ebdb9 CI: fix Qt6 install - remove invalid module, pin aqtversion, use 6.8.1 LTS 2026-02-15 09:39:52 -07:00
sysadmin
75fedd2222 CI: switch to Qt6 2026-02-15 09:34:48 -07:00
sysadmin
389745e501 Add Windows CI build 2026-02-15 09:30:49 -07:00
sysadmin
1473a58742 IChooseYou 2026-02-15 09:23:17 -07:00
untitled
4192a4dad3 Hide project tree by default, remove 1px menu border, darken hover/selected theme colors 2026-02-15 08:29:59 -07:00
IChooseYou
4c6bb9564f Fix 7 verified bugs: ref invalidation, bounds check, double refresh, dangling pointer, undo bypass, overflow, hash collision
- BUG-1 (HIGH): Replace dangling QVector reference with local copies in applyTypePopupResult
- BUG-2 (MEDIUM): Add missing upper-bound check in EditTarget::Name handler
- BUG-5 (LOW): Remove redundant unconditional refresh() at end of applyTypePopupResult
- BUG-6 (LOW): Use QPointer for m_cachedPopup to auto-null on parent destruction
- BUG-7 (LOW): Rewrite materializeRefChildren to use undo macro (cmd::Insert + cmd::Collapse)
- BUG-8 (LOW): Guard against integer overflow in byteSize() and clamp arrayLen/strLen in fromJson
- BUG-9 (LOW): Use QPair<uint64_t,uint64_t> key in collectPointerRanges visited set
2026-02-15 08:16:52 -07:00
Sen66
0ef9841f90 Added options dialog 2026-02-15 03:24:12 +01:00
IChooseYOu
0a8244dad4 Single-click type chooser, popup warmup fix, rename ProcessMemory plugin
- Type chooser popup now opens on single click (no need to pre-select node)
- Fix ~170ms first-open delay by pre-initializing Qt popup subsystem at startup
- Rename ProcessMemoryWindows -> ProcessMemory (already supports Linux)
2026-02-14 16:08:44 -07:00
IChooseYou
c856ba2697 WinDbg plugin, ProcessMemoryWindows, dialog cleanup, and misc fixes
- Add WinDbgMemory plugin with debug server connection support
- Replace ProcessMemory plugin with Windows-specific ProcessMemoryWindows
- Simplify WinDbg dialog: single panel, no tabs, palette-based theming
- Fix example text visibility on dark themes (QPalette::Dark -> Disabled WindowText)
- Fix "file" -> "File" capitalization in source menu
- Add windbg_provider and com_security tests
2026-02-14 13:40:58 -07:00
IChooseYou
b44dc9e96b Update screenshot 2026-02-13 17:58:27 -07:00
IChooseYou
0f2ded471f Update screenshot 2026-02-13 17:56:07 -07:00
IChooseYou
c9377c3afd Show Icon uses 24x24 icon instead of 16x16 2026-02-13 17:47:14 -07:00
IChooseYou
a86912add1 Theme system overhaul, UI polish, and VS2022 Dark theme
- Replaced hardcoded theme factories with JSON files + CMake build step
- Shared ThemeFieldMeta table for DRY serialization and editor UI
- Fixed live preview (auto-triggers on color change, no toggle button)
- Fixed duplicate theme entries when editing built-in themes
- Moved title bar from icon to bold "Reclass" text with View > Show Icon toggle
- MDI tabs: 24px height, unicode close button styled like TypeSelectorPopup
- Added VS2022 Dark theme with purple accent colors
- Status bar padding, removed monospace font overrides on tabs/statusbar
- Default startup opens Ball demo + Unnamed hex64 tabs
2026-02-13 16:23:12 -07:00
Sen66
5a9a6b754f Expand recursive structs instead of silently skipping cycle fields
Self-referential struct children (e.g. Ball containing Ball) now render
as collapsed struct headers with a cycle marker. Clicking the fold margin
materializes the referenced children and auto-expands the recursive child,
allowing unlimited depth exploration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:11:10 +01:00
Sen66
0df52e82b8 Added custom title bar & border color when focused 2026-02-13 19:09:11 +01:00
Sen66
9a342286ee codicon.ttf isn't actually used 2026-02-13 19:01:13 +01:00
46 changed files with 3606 additions and 946 deletions

153
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
arch: 'win64_msvc2022_64'
cache: true
aqtversion: '==3.1.21'
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_windbg_provider|test_com_security"
- name: Upload artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: Reclass-Windows-x64
path: |
build/Reclass.exe
build/ReclassMcpBridge.exe
build/Plugins/
build/*.dll
build/platforms/
build/styles/
build/imageformats/
build/iconengines/
build/themes/
build/screenshot.png
- name: Package release zip
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
shell: bash
run: |
mkdir -p release
cp build/Reclass.exe release/
cp build/ReclassMcpBridge.exe release/
cp build/*.dll release/ 2>/dev/null || true
cp -r build/platforms release/ 2>/dev/null || true
cp -r build/styles release/ 2>/dev/null || true
cp -r build/imageformats release/ 2>/dev/null || true
cp -r build/iconengines release/ 2>/dev/null || true
cp -r build/Plugins release/ 2>/dev/null || true
cp -r build/themes release/ 2>/dev/null || true
cp build/screenshot.png release/ 2>/dev/null || true
cd release && 7z a ../win64-reclass-latest.zip *
- name: Update win64 release
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: latest-win64
name: win64
body: |
Windows x64 build from main branch.
Commit: ${{ github.sha }}
prerelease: true
files: win64-reclass-latest.zip
make_latest: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
cache: true
aqtversion: '==3.1.21'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgl1-mesa-dev
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor"
env:
QT_QPA_PLATFORM: offscreen
- name: Upload artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: Reclass-Linux-x64
path: |
build/Reclass
build/ReclassMcpBridge
- name: Package release tarball
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p release
cp build/Reclass release/
cp build/ReclassMcpBridge release/
cp -r build/themes release/ 2>/dev/null || true
tar czf linux64-reclass-latest.tar.gz -C release .
- name: Update linux64 release
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: latest-linux64
name: linux64
body: |
Linux x64 build from main branch.
Commit: ${{ github.sha }}
prerelease: true
files: linux64-reclass-latest.tar.gz
make_latest: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -60,6 +60,10 @@ add_executable(Reclass
src/themes/themeeditor.h
src/themes/themeeditor.cpp
src/mainwindow.h
src/optionsdialog.h
src/optionsdialog.cpp
src/titlebar.h
src/titlebar.cpp
src/mcp/mcp_bridge.h
src/mcp/mcp_bridge.cpp
$<$<PLATFORM_ID:Windows>:src/app.rc>
@@ -83,14 +87,24 @@ endif()
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
# Copy built-in theme JSON files to build directory
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/themes")
foreach(_tf ${_theme_files})
get_filename_component(_name ${_tf} NAME)
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
endforeach()
include(deploy)
add_custom_target(screenshot ALL
COMMAND Reclass --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
DEPENDS Reclass deploy
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Capturing UI screenshot with class open..."
)
if(TARGET deploy)
add_custom_target(screenshot ALL
COMMAND Reclass --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
DEPENDS Reclass deploy
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Capturing UI screenshot with class open..."
)
endif()
set(_combine_script "${CMAKE_BINARY_DIR}/combine_sources.cmake")
file(WRITE ${_combine_script} "
@@ -247,6 +261,23 @@ if(BUILD_TESTING)
target_link_libraries(test_theme PRIVATE ${QT}::Widgets ${QT}::Test)
add_test(NAME test_theme COMMAND test_theme)
if(WIN32)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
# Standalone test: proves whether CoInitializeSecurity is needed for DebugConnect
# Requires a running WinDbg debug server on port 5055
if(WIN32)
add_executable(test_com_security tests/test_com_security.cpp)
target_link_libraries(test_com_security PRIVATE dbgeng ole32 version)
add_test(NAME test_com_security COMMAND test_com_security)
endif()
# 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)
@@ -260,4 +291,7 @@ if(BUILD_TESTING)
)
endif()
endif()
add_subdirectory(plugins/ProcessMemory)
if(WIN32)
add_subdirectory(plugins/ProcessMemory)
add_subdirectory(plugins/WinDbgMemory)
endif()

View File

@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.20)
project(WinDbgMemoryPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Qt is found by the parent project; QT variable (Qt5 or Qt6) is inherited
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Plugin sources
set(PLUGIN_SOURCES
WinDbgMemoryPlugin.h
WinDbgMemoryPlugin.cpp
)
# Create shared library (DLL)
add_library(WinDbgMemoryPlugin SHARED ${PLUGIN_SOURCES})
# Link Qt + DbgEng
target_link_libraries(WinDbgMemoryPlugin PRIVATE ${QT}::Widgets dbgeng ole32)
# Include directories
target_include_directories(WinDbgMemoryPlugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../src
)
# Output to Plugins folder
set_target_properties(WinDbgMemoryPlugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
)

View File

@@ -0,0 +1,510 @@
#include "WinDbgMemoryPlugin.h"
#include <QStyle>
#include <QApplication>
#include <QMessageBox>
#include <QDialog>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QDebug>
#include <QClipboard>
#include <QGuiApplication>
#ifdef _WIN32
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
#pragma comment(lib, "dbgeng.lib")
#endif
// ──────────────────────────────────────────────────────────────────────────
// Thread dispatch helper
// ──────────────────────────────────────────────────────────────────────────
template<typename Fn>
void WinDbgMemoryProvider::dispatchToOwner(Fn&& fn) const
{
if (!m_dispatcher) { fn(); return; }
if (QThread::currentThread() == m_dispatcher->thread()) {
// Already on the owning thread — call directly
fn();
} else {
// Marshal to the owning thread and block until done
QMetaObject::invokeMethod(m_dispatcher, std::forward<Fn>(fn),
Qt::BlockingQueuedConnection);
}
}
// ──────────────────────────────────────────────────────────────────────────
// WinDbgMemoryProvider implementation
// ──────────────────────────────────────────────────────────────────────────
WinDbgMemoryProvider::WinDbgMemoryProvider(const QString& target)
{
// Create a dedicated thread for all DbgEng COM operations.
// DbgEng's remote transport (TCP/named-pipe) is thread-affine — all
// calls must happen on the thread that called DebugConnect/DebugCreate.
// A private thread with its own event loop guarantees:
// 1. dispatchToOwner() works from any calling thread (main, thread-pool, etc.)
// 2. No deadlock — the DbgEng thread is never blocked by the caller
m_dbgThread = new QThread();
m_dbgThread->setObjectName(QStringLiteral("DbgEngThread"));
m_dbgThread->start();
m_dispatcher = new DbgEngDispatcher();
m_dispatcher->moveToThread(m_dbgThread);
#ifdef _WIN32
// Run all DbgEng initialization on the dedicated thread.
// BlockingQueuedConnection blocks us until the lambda finishes,
// so member variables written inside are visible after the call.
dispatchToOwner([this, &target]() {
HRESULT hr;
qDebug() << "[WinDbg] Opening target:" << target
<< "on DbgEng thread" << QThread::currentThread();
if (target.startsWith("tcp:", Qt::CaseInsensitive)
|| target.startsWith("npipe:", Qt::CaseInsensitive))
{
// ── Remote: connect to existing WinDbg debug server ──
QByteArray connUtf8 = target.toUtf8();
qDebug() << "[WinDbg] DebugConnect:" << target;
hr = DebugConnect(connUtf8.constData(), IID_IDebugClient, (void**)&m_client);
qDebug() << "[WinDbg] DebugConnect hr=" << Qt::hex << (unsigned long)hr
<< "client=" << (void*)m_client;
if (FAILED(hr) || !m_client) {
qWarning() << "[WinDbg] DebugConnect FAILED hr=0x" << Qt::hex << (unsigned long)hr;
return;
}
m_isRemote = true;
}
else
{
// ── Local: create debug client for pid/dump ──
hr = DebugCreate(IID_IDebugClient, (void**)&m_client);
qDebug() << "[WinDbg] DebugCreate hr=" << Qt::hex << (unsigned long)hr
<< "client=" << (void*)m_client;
if (FAILED(hr) || !m_client) {
qWarning() << "[WinDbg] DebugCreate FAILED hr=0x" << Qt::hex << (unsigned long)hr;
return;
}
if (target.startsWith("pid:", Qt::CaseInsensitive))
{
bool ok = false;
ULONG pid = target.mid(4).trimmed().toULong(&ok);
if (!ok || pid == 0) {
qWarning() << "[WinDbg] Invalid PID in target:" << target;
cleanup();
return;
}
qDebug() << "[WinDbg] Attaching to PID" << pid << "(non-invasive)";
hr = m_client->AttachProcess(
0, pid,
DEBUG_ATTACH_NONINVASIVE | DEBUG_ATTACH_NONINVASIVE_NO_SUSPEND);
qDebug() << "[WinDbg] AttachProcess hr=" << Qt::hex << (unsigned long)hr;
if (FAILED(hr)) {
qWarning() << "[WinDbg] AttachProcess FAILED";
cleanup();
return;
}
}
else if (target.startsWith("dump:", Qt::CaseInsensitive))
{
QString path = target.mid(5).trimmed();
QByteArray pathUtf8 = path.toUtf8();
qDebug() << "[WinDbg] Opening dump file:" << path;
hr = m_client->OpenDumpFile(pathUtf8.constData());
qDebug() << "[WinDbg] OpenDumpFile hr=" << Qt::hex << (unsigned long)hr;
if (FAILED(hr)) {
qWarning() << "[WinDbg] OpenDumpFile FAILED";
cleanup();
return;
}
}
else
{
qWarning() << "[WinDbg] Unknown target format:" << target;
cleanup();
return;
}
}
initInterfaces();
// WaitForEvent to finalize the attach/dump load.
// For remote connections the server session is already active — skip.
if (m_control && !m_isRemote) {
qDebug() << "[WinDbg] WaitForEvent...";
hr = m_control->WaitForEvent(0, 10000);
qDebug() << "[WinDbg] WaitForEvent hr=" << Qt::hex << (unsigned long)hr;
}
querySessionInfo();
});
#else
Q_UNUSED(target);
#endif
}
void WinDbgMemoryProvider::initInterfaces()
{
#ifdef _WIN32
if (!m_client) return;
HRESULT hr;
hr = m_client->QueryInterface(IID_IDebugDataSpaces, (void**)&m_dataSpaces);
qDebug() << "[WinDbg] IDebugDataSpaces hr=" << Qt::hex << (unsigned long)hr
<< "ptr=" << (void*)m_dataSpaces;
hr = m_client->QueryInterface(IID_IDebugControl, (void**)&m_control);
qDebug() << "[WinDbg] IDebugControl hr=" << Qt::hex << (unsigned long)hr
<< "ptr=" << (void*)m_control;
hr = m_client->QueryInterface(IID_IDebugSymbols, (void**)&m_symbols);
qDebug() << "[WinDbg] IDebugSymbols hr=" << Qt::hex << (unsigned long)hr
<< "ptr=" << (void*)m_symbols;
if (!m_dataSpaces) {
qWarning() << "[WinDbg] No IDebugDataSpaces — cleaning up";
cleanup();
}
#endif
}
void WinDbgMemoryProvider::querySessionInfo()
{
#ifdef _WIN32
if (!m_client) return;
HRESULT hr;
if (m_control) {
ULONG debugClass = 0, debugQualifier = 0;
hr = m_control->GetDebuggeeType(&debugClass, &debugQualifier);
qDebug() << "[WinDbg] GetDebuggeeType hr=" << Qt::hex << (unsigned long)hr
<< "class=" << debugClass << "qualifier=" << debugQualifier;
if (SUCCEEDED(hr)) {
m_isLive = (debugQualifier < DEBUG_DUMP_SMALL);
m_writable = m_isLive;
}
}
if (m_symbols) {
ULONG numModules = 0, numUnloaded = 0;
hr = m_symbols->GetNumberModules(&numModules, &numUnloaded);
qDebug() << "[WinDbg] GetNumberModules hr=" << Qt::hex << (unsigned long)hr
<< "loaded=" << numModules << "unloaded=" << numUnloaded;
if (SUCCEEDED(hr) && numModules > 0) {
char modName[256] = {};
ULONG modSize = 0;
hr = m_symbols->GetModuleNames(0, 0, nullptr, 0, nullptr,
modName, sizeof(modName), &modSize,
nullptr, 0, nullptr);
if (SUCCEEDED(hr) && modSize > 0)
m_name = QString::fromUtf8(modName);
}
}
if (m_name.isEmpty())
m_name = m_isLive ? QStringLiteral("DbgEng (Live)") : QStringLiteral("DbgEng (Dump)");
if (m_symbols) {
ULONG numModules = 0, numUnloaded = 0;
hr = m_symbols->GetNumberModules(&numModules, &numUnloaded);
if (SUCCEEDED(hr) && numModules > 0) {
ULONG64 moduleBase = 0;
hr = m_symbols->GetModuleByIndex(0, &moduleBase);
qDebug() << "[WinDbg] Module 0 base=" << Qt::hex << moduleBase;
if (SUCCEEDED(hr))
m_base = moduleBase;
}
}
if (m_base && m_dataSpaces) {
uint8_t probe[2] = {};
ULONG got = 0;
hr = m_dataSpaces->ReadVirtual(m_base, probe, 2, &got);
qDebug() << "[WinDbg] Probe read at" << Qt::hex << m_base
<< "hr=" << (unsigned long)hr << "got=" << got
<< "bytes:" << (int)probe[0] << (int)probe[1];
if (FAILED(hr) || got == 0) {
qWarning() << "[WinDbg] Probe read FAILED — cleaning up";
cleanup();
return;
}
}
qDebug() << "[WinDbg] Ready. name=" << m_name
<< "base=" << Qt::hex << m_base << "isLive=" << m_isLive;
#endif
}
WinDbgMemoryProvider::~WinDbgMemoryProvider()
{
#ifdef _WIN32
// Dispatch COM cleanup to the DbgEng thread (thread-affine release)
if (m_dbgThread && m_dbgThread->isRunning() && m_dispatcher) {
dispatchToOwner([this]() {
if (m_client) {
if (m_isRemote)
m_client->EndSession(DEBUG_END_DISCONNECT);
else
m_client->DetachProcesses();
}
cleanup();
});
} else {
// Thread not running — clean up directly (best-effort)
if (m_client) {
if (m_isRemote)
m_client->EndSession(DEBUG_END_DISCONNECT);
else
m_client->DetachProcesses();
}
cleanup();
}
#else
cleanup();
#endif
// Stop the dedicated thread
if (m_dbgThread) {
m_dbgThread->quit();
m_dbgThread->wait(3000);
delete m_dbgThread;
m_dbgThread = nullptr;
}
delete m_dispatcher;
m_dispatcher = nullptr;
}
void WinDbgMemoryProvider::cleanup()
{
#ifdef _WIN32
if (m_symbols) { m_symbols->Release(); m_symbols = nullptr; }
if (m_control) { m_control->Release(); m_control = nullptr; }
if (m_dataSpaces) { m_dataSpaces->Release(); m_dataSpaces = nullptr; }
if (m_client) { m_client->Release(); m_client = nullptr; }
#endif
}
bool WinDbgMemoryProvider::read(uint64_t addr, void* buf, int len) const
{
#ifdef _WIN32
if (!m_dataSpaces || len <= 0) return false;
bool result = false;
dispatchToOwner([&]() {
ULONG bytesRead = 0;
HRESULT hr = m_dataSpaces->ReadVirtual(m_base + addr, buf, (ULONG)len, &bytesRead);
if (FAILED(hr) || (int)bytesRead < len)
memset((char*)buf + bytesRead, 0, len - bytesRead);
result = bytesRead > 0;
});
return result;
#else
Q_UNUSED(addr); Q_UNUSED(buf); Q_UNUSED(len);
return false;
#endif
}
bool WinDbgMemoryProvider::write(uint64_t addr, const void* buf, int len)
{
#ifdef _WIN32
if (!m_dataSpaces || !m_writable || len <= 0) return false;
bool result = false;
dispatchToOwner([&]() {
ULONG bytesWritten = 0;
HRESULT hr = m_dataSpaces->WriteVirtual(m_base + addr, const_cast<void*>(buf),
(ULONG)len, &bytesWritten);
result = SUCCEEDED(hr) && bytesWritten == (ULONG)len;
});
return result;
#else
Q_UNUSED(addr); Q_UNUSED(buf); Q_UNUSED(len);
return false;
#endif
}
int WinDbgMemoryProvider::size() const
{
#ifdef _WIN32
return m_dataSpaces ? 0x10000 : 0;
#else
return 0;
#endif
}
bool WinDbgMemoryProvider::isReadable(uint64_t /*addr*/, int len) const
{
#ifdef _WIN32
// DbgEng's ReadVirtual can read any mapped virtual address.
return m_dataSpaces != nullptr && len >= 0;
#else
return false;
#endif
}
QString WinDbgMemoryProvider::getSymbol(uint64_t addr) const
{
#ifdef _WIN32
if (!m_symbols) return {};
QString result;
dispatchToOwner([&]() {
char nameBuf[512] = {};
ULONG nameSize = 0;
ULONG64 displacement = 0;
HRESULT hr = m_symbols->GetNameByOffset(m_base + addr, nameBuf, sizeof(nameBuf),
&nameSize, &displacement);
if (SUCCEEDED(hr) && nameSize > 0) {
result = QString::fromUtf8(nameBuf);
if (displacement > 0)
result += QStringLiteral("+0x%1").arg(displacement, 0, 16);
}
});
return result;
#else
Q_UNUSED(addr);
return {};
#endif
}
// ──────────────────────────────────────────────────────────────────────────
// WinDbgMemoryPlugin implementation
// ──────────────────────────────────────────────────────────────────────────
QIcon WinDbgMemoryPlugin::Icon() const
{
return qApp->style()->standardIcon(QStyle::SP_DriveNetIcon);
}
bool WinDbgMemoryPlugin::canHandle(const QString& target) const
{
return target.startsWith("tcp:", Qt::CaseInsensitive)
|| target.startsWith("npipe:", Qt::CaseInsensitive)
|| target.startsWith("pid:", Qt::CaseInsensitive)
|| target.startsWith("dump:", Qt::CaseInsensitive);
}
std::unique_ptr<rcx::Provider> WinDbgMemoryPlugin::createProvider(const QString& target, QString* errorMsg)
{
auto provider = std::make_unique<WinDbgMemoryProvider>(target);
if (!provider->isValid())
{
if (errorMsg) {
if (target.startsWith("tcp:", Qt::CaseInsensitive)
|| target.startsWith("npipe:", Qt::CaseInsensitive))
*errorMsg = QString("Failed to connect to debug server.\n\n"
"Target: %1\n\n"
"Make sure WinDbg is running with a matching .server command\n"
"(e.g. .server tcp:port=5055) and the port/pipe is reachable.")
.arg(target);
else if (target.startsWith("pid:", Qt::CaseInsensitive))
*errorMsg = QString("Failed to attach to process.\n\n"
"Target: %1\n\n"
"Make sure the process is running and you have "
"sufficient privileges (try Run as Administrator).")
.arg(target);
else
*errorMsg = QString("Failed to open dump file.\n\n"
"Target: %1\n\n"
"Make sure the file exists and is a valid dump.")
.arg(target);
}
return nullptr;
}
return provider;
}
uint64_t WinDbgMemoryPlugin::getInitialBaseAddress(const QString& target) const
{
Q_UNUSED(target);
return 0;
}
bool WinDbgMemoryPlugin::selectTarget(QWidget* parent, QString* target)
{
QDialog dlg(parent);
dlg.setWindowTitle("WinDbg Settings");
dlg.resize(460, 260);
QPalette dlgPal = qApp->palette();
dlg.setPalette(dlgPal);
dlg.setAutoFillBackground(true);
auto* layout = new QVBoxLayout(&dlg);
layout->addWidget(new QLabel(
"Connect to a running WinDbg debug server.\n"
"In WinDbg, run: .server tcp:port=5055"));
layout->addSpacing(8);
layout->addWidget(new QLabel("Connection string:"));
auto* connEdit = new QLineEdit;
connEdit->setPlaceholderText("tcp:Port=5055,Server=localhost");
connEdit->setText("tcp:Port=5055,Server=localhost");
layout->addWidget(connEdit);
layout->addSpacing(4);
layout->addWidget(new QLabel("Run one of these in WinDbg first:"));
auto addExample = [&](const QString& text) {
auto* row = new QHBoxLayout;
auto* label = new QLabel(text);
QPalette lp = dlgPal;
lp.setColor(QPalette::WindowText, dlgPal.color(QPalette::Disabled, QPalette::WindowText));
label->setPalette(lp);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
row->addWidget(label, 1);
auto* copyBtn = new QPushButton("Copy");
copyBtn->setFixedWidth(50);
copyBtn->setToolTip("Copy to clipboard");
QObject::connect(copyBtn, &QPushButton::clicked, [text]() {
QGuiApplication::clipboard()->setText(text);
});
row->addWidget(copyBtn);
layout->addLayout(row);
};
addExample(".server tcp:port=5055");
addExample(".server npipe:pipe=reclass");
layout->addStretch();
auto* btnLayout = new QHBoxLayout;
btnLayout->addStretch();
auto* okBtn = new QPushButton("OK");
auto* cancelBtn = new QPushButton("Cancel");
btnLayout->addWidget(okBtn);
btnLayout->addWidget(cancelBtn);
layout->addLayout(btnLayout);
QObject::connect(okBtn, &QPushButton::clicked, &dlg, &QDialog::accept);
QObject::connect(cancelBtn, &QPushButton::clicked, &dlg, &QDialog::reject);
if (dlg.exec() != QDialog::Accepted)
return false;
QString conn = connEdit->text().trimmed();
if (conn.isEmpty()) return false;
*target = conn;
return true;
}
// ──────────────────────────────────────────────────────────────────────────
// Plugin factory
// ──────────────────────────────────────────────────────────────────────────
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin()
{
return new WinDbgMemoryPlugin();
}

View File

@@ -0,0 +1,122 @@
#pragma once
#include "../../src/iplugin.h"
#include "../../src/core.h"
#include <cstdint>
#include <QObject>
#include <QThread>
// Forward declarations for DbgEng COM interfaces
struct IDebugClient;
struct IDebugDataSpaces;
struct IDebugControl;
struct IDebugSymbols;
/**
* WinDbg memory provider
*
* Uses DbgEng to read memory from:
* - An existing WinDbg debug server via DebugConnect (tcp/npipe)
* - A live process by PID via DebugCreate (non-invasive attach)
* - A crash dump (.dmp) file via DebugCreate
*
* Target string format:
* "tcp:Port=5055,Server=localhost" - connect to WinDbg debug server (TCP)
* "npipe:Pipe=name,Server=localhost" - connect to WinDbg debug server (named pipe)
* "pid:1234" - attach to process 1234
* "dump:C:/path/to/file.dmp" - open dump file
*
* Threading: All DbgEng COM calls are dispatched to the thread that created
* the connection (DebugConnect/DebugCreate). This is required because the
* remote transport (TCP/named-pipe) binds to the creating thread. The
* controller's background refresh threads call read() which transparently
* marshals to the owning thread via BlockingQueuedConnection.
*/
// Helper QObject that lives on the DbgEng-owning thread.
// Used as a target for QMetaObject::invokeMethod to marshal calls.
class DbgEngDispatcher : public QObject {
Q_OBJECT
public:
using QObject::QObject;
};
class WinDbgMemoryProvider : public rcx::Provider
{
public:
/// Create a provider from a target string
WinDbgMemoryProvider(const QString& target);
~WinDbgMemoryProvider() override;
// Required overrides
bool read(uint64_t addr, void* buf, int len) const override;
int size() const override;
// Optional overrides
bool isReadable(uint64_t addr, int len) const override;
bool write(uint64_t addr, const void* buf, int len) override;
bool isWritable() const override { return m_writable; }
QString name() const override { return m_name; }
QString kind() const override { return QStringLiteral("WinDbg"); }
QString getSymbol(uint64_t addr) const override;
bool isLive() const override { return m_isLive; }
uint64_t base() const override { return m_base; }
void setBase(uint64_t b) override { m_base = b; }
private:
void initInterfaces(); // get IDebugDataSpaces/Control/Symbols from client
void querySessionInfo(); // determine live/dump, writable, name, base
void cleanup();
// Marshal a lambda to the DbgEng-owning thread. If already on that
// thread, calls directly. Otherwise blocks via QueuedConnection.
template<typename Fn>
void dispatchToOwner(Fn&& fn) const;
IDebugClient* m_client = nullptr;
IDebugDataSpaces* m_dataSpaces = nullptr;
IDebugControl* m_control = nullptr;
IDebugSymbols* m_symbols = nullptr;
QString m_name;
uint64_t m_base = 0;
bool m_isLive = false;
bool m_writable = false;
bool m_isRemote = false; // true when connected via DebugConnect (tcp/npipe)
// Dedicated thread for DbgEng COM operations. The remote TCP/pipe
// transport is thread-affine — all calls must happen on the thread
// that called DebugConnect. A private thread with its own event loop
// ensures dispatchToOwner() works from any calling thread (including
// QtConcurrent workers and the main/GUI thread) without deadlock.
QThread* m_dbgThread = nullptr;
DbgEngDispatcher* m_dispatcher = nullptr;
};
/**
* Plugin that provides WinDbgMemoryProvider
*
* Uses DbgEng to read memory via:
* - Remote connection to an existing WinDbg debug server (tcp/npipe)
* - Local non-invasive attach to a live process (pid)
* - Local crash dump file (dump)
*/
class WinDbgMemoryPlugin : public IProviderPlugin
{
public:
std::string Name() const override { return "WinDbg Memory"; }
std::string Version() const override { return "2.0.0"; }
std::string Author() const override { return "Reclass"; }
std::string Description() const override { return "Read memory via DbgEng (live process attach or crash dump)"; }
k_ELoadType LoadType() const override { return k_ELoadTypeAuto; }
QIcon Icon() const override;
bool canHandle(const QString& target) const override;
std::unique_ptr<rcx::Provider> createProvider(const QString& target, QString* errorMsg) override;
uint64_t getInitialBaseAddress(const QString& target) const override;
bool selectTarget(QWidget* parent, QString* target) override;
};
// Plugin export
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -14,8 +14,9 @@ constexpr uint64_t kGoldenRatio = 0x9E3779B97F4A7C15ULL;
struct ComposeState {
QString text;
QVector<LineMeta> meta;
QSet<uint64_t> visiting; // cycle detection for struct recursion
QSet<qulonglong> ptrVisiting; // cycle guard for pointer expansions
QSet<uint64_t> visiting; // cycle detection for struct recursion
QSet<qulonglong> ptrVisiting; // cycle guard for pointer expansions
QSet<uint64_t> virtualPtrRefs; // refIds currently being virtually expanded via pointer deref
int currentLine = 0;
int typeW = kColType; // global type column width (fallback)
int nameW = kColName; // global name column width (fallback)
@@ -64,7 +65,6 @@ uint32_t computeMarkers(const Node& node, const Provider& /*prov*/,
uint64_t /*addr*/, bool isCont, int /*depth*/) {
uint32_t mask = 0;
if (isCont) mask |= (1u << M_CONT);
if (node.kind == NodeKind::Padding) mask |= (1u << M_PAD);
// No ambient validation markers — errors only shown during inline editing.
return mask;
}
@@ -118,14 +118,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
int typeW = state.effectiveTypeW(scopeId);
int nameW = state.effectiveNameW(scopeId);
// Line count: padding wraps at 8 bytes per line
int numLines;
if (node.kind == NodeKind::Padding) {
int totalBytes = qMax(1, node.arrayLen);
numLines = (totalBytes + 7) / 8;
} else {
numLines = linesForKind(node.kind);
}
int numLines = linesForKind(node.kind);
// Resolve pointer target name for display
QString ptrTypeOverride;
@@ -156,12 +149,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
// Set byte count for hex preview lines (used for per-byte change highlighting)
if (isHexPreview(node.kind)) {
if (node.kind == NodeKind::Padding) {
int totalSz = qMax(1, node.arrayLen);
lm.lineByteCount = qMin(8, totalSz - sub * 8);
} else {
lm.lineByteCount = sizeForKind(node.kind);
}
lm.lineByteCount = sizeForKind(node.kind);
}
QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub,
@@ -345,12 +333,36 @@ void composeParent(ComposeState& state, const NodeTree& tree,
std::sort(refChildren.begin(), refChildren.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
// Use the referenced struct's scope widths (children come from there)
uint64_t refScopeId = node.refId;
for (int childIdx : refChildren) {
// Skip self-referential children (e.g. struct Ball has a field of type Ball)
if (state.visiting.contains(tree.nodes[childIdx].id))
const Node& child = tree.nodes[childIdx];
// Self-referential child → show as collapsed struct (non-expandable)
if (state.visiting.contains(child.id)) {
int typeW = state.effectiveTypeW(refScopeId);
int nameW = state.effectiveNameW(refScopeId);
LineMeta lm;
lm.nodeIdx = nodeIdx; // parent struct — materialize target
lm.nodeId = child.id;
lm.depth = childDepth;
lm.lineKind = LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(
tree.baseAddress + absAddr + child.offset, false,
state.offsetHexDigits);
lm.offsetAddr = tree.baseAddress + absAddr + child.offset;
lm.nodeKind = child.kind;
lm.foldHead = true;
lm.foldCollapsed = true;
lm.foldLevel = computeFoldLevel(childDepth, true);
lm.markerMask = (1u << M_STRUCT_BG) | (1u << M_CYCLE);
lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW;
state.emitLine(fmt::fmtStructHeader(child, childDepth,
/*collapsed=*/true, typeW, nameW), lm);
continue;
}
composeNode(state, tree, prov, childIdx, childDepth,
absAddr, node.refId, false, node.id);
absAddr, node.refId, false, refScopeId);
}
}
}
@@ -406,37 +418,55 @@ void composeNode(ComposeState& state, const NodeTree& tree,
QString ptrTargetName = resolvePointerTarget(tree, node.refId);
QString ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName);
// Check if this pointer has materialized children (from materializeRefChildren)
QVector<int> ptrChildren = state.childMap.value(node.id);
bool hasMaterialized = !ptrChildren.isEmpty();
// Force collapsed if this refId is already being virtually expanded
// (prevents infinite recursion in virtual expansion mode).
// Materialized children bypass this — they are real tree nodes with
// independent collapsed state, so recursion is bounded by the tree.
bool forceCollapsed = !hasMaterialized
&& state.virtualPtrRefs.contains(node.refId);
bool effectiveCollapsed = node.collapsed || forceCollapsed;
// Emit merged fold header: "Type* Name {" (expanded) or "Type* Name -> val" (collapsed)
{
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = node.collapsed ? LineKind::Field : LineKind::Header;
lm.lineKind = effectiveCollapsed ? LineKind::Field : LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false, state.offsetHexDigits);
lm.offsetAddr = tree.baseAddress + absAddr;
lm.nodeKind = node.kind;
lm.foldHead = true;
lm.foldCollapsed = node.collapsed;
lm.foldCollapsed = effectiveCollapsed;
lm.foldLevel = computeFoldLevel(depth, true);
lm.markerMask = computeMarkers(node, prov, absAddr, false, depth);
if (forceCollapsed) lm.markerMask |= (1u << M_CYCLE);
lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW;
lm.pointerTargetName = ptrTargetName;
state.emitLine(fmt::fmtPointerHeader(node, depth, node.collapsed,
state.emitLine(fmt::fmtPointerHeader(node, depth, effectiveCollapsed,
prov, absAddr, ptrTypeOverride,
typeW, nameW), lm);
}
if (!node.collapsed) {
if (!effectiveCollapsed) {
int sz = node.byteSize();
uint64_t ptrVal = 0;
if (prov.isValid() && sz > 0 && prov.isReadable(absAddr, sz)) {
ptrVal = (node.kind == NodeKind::Pointer32)
? (uint64_t)prov.readU32(absAddr) : prov.readU64(absAddr);
if (ptrVal != 0) {
uint64_t pBase = ptrToProviderAddr(tree, ptrVal);
if (pBase == UINT64_MAX) ptrVal = 0; // ptr below base: invalid
// Treat sentinel values as invalid pointers
if (ptrVal == UINT64_MAX || (node.kind == NodeKind::Pointer32 && ptrVal == 0xFFFFFFFF))
ptrVal = 0;
else {
uint64_t pBase = ptrToProviderAddr(tree, ptrVal);
if (pBase == UINT64_MAX) ptrVal = 0; // ptr below base: invalid
}
}
}
@@ -451,18 +481,42 @@ void composeNode(ComposeState& state, const NodeTree& tree,
if (!ptrReadable)
pBase = (uint64_t)0 - tree.baseAddress;
qulonglong key = pBase ^ (node.refId * kGoldenRatio);
if (!state.ptrVisiting.contains(key)) {
state.ptrVisiting.insert(key);
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
const Node& ref = tree.nodes[refIdx];
if (ref.kind == NodeKind::Struct || ref.kind == NodeKind::Array)
composeParent(state, tree, childProv, refIdx,
depth, pBase, ref.id,
/*isArrayChild=*/true);
if (hasMaterialized) {
// Render materialized children at the pointer target address.
// These are real tree nodes with independent state — use rootId
// so resolveAddr computes offsets relative to the pointer target.
std::sort(ptrChildren.begin(), ptrChildren.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
for (int childIdx : ptrChildren) {
composeNode(state, tree, childProv, childIdx, depth + 1,
pBase, node.id, false, node.id);
}
} else {
// Virtual expansion via ref struct definition.
// Temporarily remove the ref struct from visiting so composeParent
// doesn't hit the struct-level cycle guard. The ptrVisiting mechanism
// handles actual address-level pointer cycles, and virtualPtrRefs
// prevents infinite virtual recursion (inner self-referential pointers
// are force-collapsed with M_CYCLE for the user to materialize).
qulonglong key = pBase ^ (node.refId * kGoldenRatio);
if (!state.ptrVisiting.contains(key)) {
state.ptrVisiting.insert(key);
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
const Node& ref = tree.nodes[refIdx];
if (ref.kind == NodeKind::Struct || ref.kind == NodeKind::Array) {
bool wasVisiting = state.visiting.remove(node.refId);
state.virtualPtrRefs.insert(node.refId);
composeParent(state, tree, childProv, refIdx,
depth, pBase, ref.id,
/*isArrayChild=*/true);
state.virtualPtrRefs.remove(node.refId);
if (wasVisiting) state.visiting.insert(node.refId);
}
}
state.ptrVisiting.remove(key);
}
state.ptrVisiting.remove(key);
}
// Footer for pointer fold
@@ -542,7 +596,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
// Include struct/array names - they now use columnar layout too
int maxNameLen = kMinNameW;
for (const Node& node : tree.nodes) {
// Skip hex/padding (they show ASCII preview, not name column)
// Skip hex (they show ASCII preview, not name column)
if (isHexPreview(node.kind)) continue;
maxNameLen = qMax(maxNameLen, (int)node.name.size());
}
@@ -561,7 +615,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
const Node& child = tree.nodes[childIdx];
scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size());
// Name width (skip hex/padding, but include containers)
// Name width (skip hex, but include containers)
if (!isHexPreview(child.kind)) {
scopeMaxName = qMax(scopeMaxName, (int)child.name.size());
}
@@ -593,7 +647,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
const Node& child = tree.nodes[childIdx];
rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size());
// Name width (skip hex/padding, include containers)
// Name width (skip hex, include containers)
if (!isHexPreview(child.kind)) {
rootMaxName = qMax(rootMaxName, (int)child.name.size());
}

View File

@@ -178,6 +178,14 @@ RcxEditor* RcxController::addSplitEditor(QWidget* parent) {
editor->applyDocument(m_lastResult);
}
updateCommandRow();
// Eagerly pre-warm the type popup so first click isn't slow (~350ms cold start).
if (!m_cachedPopup) {
QTimer::singleShot(0, this, [this, editor]() {
if (!m_cachedPopup && !m_editors.isEmpty())
ensurePopup(editor);
});
}
return editor;
}
@@ -226,8 +234,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
switch (target) {
case EditTarget::Name: {
if (text.isEmpty()) break;
if (nodeIdx >= m_doc->tree.nodes.size()) break;
const Node& node = m_doc->tree.nodes[nodeIdx];
// ASCII edit on Hex/Padding nodes
// ASCII edit on Hex nodes
if (isHexPreview(node.kind)) {
setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true);
} else {
@@ -293,6 +302,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
break;
case EditTarget::BaseAddress: {
QString s = text.trimmed();
s.remove('`'); // WinDbg backtick separators (e.g. 7ff6`6cce0000)
s.remove('\n');
s.remove('\r');
// Support simple equations: 0x10+0x4, 0x100-0x10, etc.
uint64_t newBase = 0;
bool ok = true;
@@ -347,7 +359,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
if (text.startsWith(QStringLiteral("#saved:"))) {
int idx = text.mid(7).toInt();
switchToSavedSource(idx);
} else if (text == QStringLiteral("file")) {
} else if (text == QStringLiteral("File")) {
auto* w = qobject_cast<QWidget*>(parent());
QString path = QFileDialog::getOpenFileName(w, "Load Binary Data", {}, "All Files (*)");
if (!path.isEmpty()) {
@@ -599,7 +611,7 @@ void RcxController::refresh() {
if (isHexPreview(node.kind)) {
// Per-byte tracking for hex preview nodes
int lineOff = (node.kind == NodeKind::Padding) ? lm.subLine * 8 : 0;
int lineOff = 0;
int byteCount = lm.lineByteCount;
for (int b = 0; b < byteCount; b++) {
if (m_changedOffsets.contains(offset + lineOff + b)) {
@@ -792,6 +804,58 @@ void RcxController::toggleCollapse(int nodeIdx) {
cmd::Collapse{node.id, node.collapsed, !node.collapsed}));
}
void RcxController::materializeRefChildren(int nodeIdx) {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
auto& tree = m_doc->tree;
// Snapshot values before any mutation invalidates references
const uint64_t parentId = tree.nodes[nodeIdx].id;
const uint64_t refId = tree.nodes[nodeIdx].refId;
const NodeKind parentKind = tree.nodes[nodeIdx].kind;
const QString parentName = tree.nodes[nodeIdx].name;
if (refId == 0) return;
if (!tree.childrenOf(parentId).isEmpty()) return; // already materialized
// Collect children to clone (copy by value to avoid reference invalidation)
QVector<int> refChildren = tree.childrenOf(refId);
if (refChildren.isEmpty()) return;
QVector<Node> clones;
clones.reserve(refChildren.size());
for (int ci : refChildren) {
Node copy = tree.nodes[ci]; // copy by value before any mutation
copy.id = tree.reserveId();
copy.parentId = parentId;
copy.collapsed = true;
clones.append(copy);
}
// Wrap all mutations in an undo macro
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Materialize ref children"));
for (const Node& clone : clones) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::Insert{clone, {}}));
}
// Auto-expand the self-referential child (the one that was the cycle)
// so the user gets expand in a single click
for (const Node& clone : clones) {
if (clone.kind == parentKind && clone.name == parentName && clone.refId == refId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::Collapse{clone.id, true, false}));
break;
}
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
}
void RcxController::applyCommand(const Command& command, bool isUndo) {
auto& tree = m_doc->tree;
@@ -872,7 +936,7 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16);
// Patch snapshot so compose sees the new value immediately
if (m_snapshotProv)
m_snapshotProv->patchSnapshot(c.addr, bytes.constData(), bytes.size());
m_snapshotProv->patchPages(c.addr, bytes.constData(), bytes.size());
} else if constexpr (std::is_same_v<T, cmd::ChangeArrayMeta>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0) {
@@ -1052,23 +1116,23 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
// Quick-convert suggestions for Hex nodes
bool addedQuickConvert = false;
if (node.kind == NodeKind::Hex64) {
menu.addAction(icon("symbol-numeric.svg"), "Change to uint64_t", [this, nodeId]() {
menu.addAction("Change to uint64_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt64);
});
menu.addAction(icon("symbol-numeric.svg"), "Change to uint32_t", [this, nodeId]() {
menu.addAction("Change to uint32_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
});
addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex32) {
menu.addAction(icon("symbol-numeric.svg"), "Change to uint32_t", [this, nodeId]() {
menu.addAction("Change to uint32_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
});
addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex16) {
menu.addAction(icon("symbol-numeric.svg"), "Change to int16_t", [this, nodeId]() {
menu.addAction("Change to int16_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::Int16);
});
@@ -1078,7 +1142,6 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addSeparator();
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
&& node.kind != NodeKind::Padding
&& m_doc->provider->isWritable();
if (isEditable) {
menu.addAction(icon("edit.svg"), "Edit &Value\tEnter", [editor, line]() {
@@ -1094,6 +1157,51 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
editor->beginInlineEdit(EditTarget::Type, line);
});
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
if (!isHexNode(node.kind) && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
menu.addAction("Convert to &Hex", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
const Node& n = m_doc->tree.nodes[ni];
int totalSize = n.byteSize();
if (totalSize <= 0) return;
uint64_t parentId = n.parentId;
int baseOffset = n.offset;
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Convert to Hex"));
// Remove the original node
QVector<Node> subtree;
subtree.append(n);
m_doc->undoStack.push(new RcxCommand(this,
cmd::Remove{nodeId, subtree, {}}));
// Insert hex nodes to fill the space (largest first)
int padOffset = baseOffset;
int gap = totalSize;
while (gap > 0) {
NodeKind padKind;
int padSize;
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
else { padKind = NodeKind::Hex8; padSize = 1; }
insertNode(parentId, padOffset, padKind,
QString("pad_%1").arg(padOffset, 2, 16, QChar('0')));
padOffset += padSize;
gap -= padSize;
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
});
}
menu.addSeparator();
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
@@ -1355,17 +1463,17 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
// Separate into real nodes (non-Padding) and padding nodes
// Separate into real nodes (non-hex) and hex filler nodes
struct NodeInfo { uint64_t id; int offset; int size; };
QVector<NodeInfo> realNodes;
QVector<uint64_t> padIds;
QVector<uint64_t> hexIds;
for (int ci : kids) {
const Node& child = tree.nodes[ci];
int sz = (child.kind == NodeKind::Struct || child.kind == NodeKind::Array)
? tree.structSpan(child.id) : child.byteSize();
if (child.kind == NodeKind::Padding)
padIds.append(child.id);
if (isHexNode(child.kind))
hexIds.append(child.id);
else
realNodes.append({child.id, child.offset, sz});
}
@@ -1398,7 +1506,7 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
}
// Check if anything actually changes
if (offChanges.isEmpty() && padIds.isEmpty() && padsNeeded.isEmpty())
if (offChanges.isEmpty() && hexIds.isEmpty() && padsNeeded.isEmpty())
return;
// Apply as undoable macro
@@ -1406,14 +1514,14 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Realign to %1").arg(targetAlign));
// 1. Remove all existing Padding nodes (no offset adjustments — we recompute)
for (uint64_t pid : padIds) {
int idx = tree.indexOfId(pid);
// 1. Remove all existing hex filler nodes (no offset adjustments — we recompute)
for (uint64_t hid : hexIds) {
int idx = tree.indexOfId(hid);
if (idx < 0) continue;
QVector<Node> subtree;
subtree.append(tree.nodes[idx]);
m_doc->undoStack.push(new RcxCommand(this,
cmd::Remove{pid, subtree, {}}));
cmd::Remove{hid, subtree, {}}));
}
// 2. Reposition real nodes
@@ -1422,15 +1530,28 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
cmd::ChangeOffset{oc.id, oc.oldOff, oc.newOff}));
}
// 3. Insert new padding in gaps
// 3. Insert hex nodes to fill gaps (largest first for alignment)
for (const auto& pi : padsNeeded) {
Node pad;
pad.kind = NodeKind::Padding;
pad.parentId = structId;
pad.offset = pi.offset;
pad.arrayLen = pi.size;
pad.id = tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad}));
int padOffset = pi.offset;
int gap = pi.size;
while (gap > 0) {
NodeKind padKind;
int padSize;
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
else { padKind = NodeKind::Hex8; padSize = 1; }
Node pad;
pad.kind = padKind;
pad.parentId = structId;
pad.offset = padOffset;
pad.name = QString("pad_%1").arg(padOffset, 2, 16, QChar('0'));
pad.id = tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad}));
padOffset += padSize;
gap -= padSize;
}
}
m_doc->undoStack.endMacro();
@@ -1534,7 +1655,6 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
auto addPrimitives = [&](bool enabled, bool excludeStructArrayPad) {
for (const auto& m : kKindMeta) {
if (m.kind == NodeKind::Padding) continue;
if (excludeStructArrayPad &&
(m.kind == NodeKind::Struct || m.kind == NodeKind::Array))
continue;
@@ -1669,6 +1789,10 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
});
connect(popup, &TypeSelectorPopup::createNewTypeRequested,
this, [this, mode, nodeIdx]() {
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Create new type"));
Node n;
n.kind = NodeKind::Struct;
n.name = QString();
@@ -1676,6 +1800,16 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
n.offset = 0;
n.id = m_doc->tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
// Populate with default hex nodes (8 x Hex64 = 64 bytes)
for (int i = 0; i < 8; i++) {
insertNode(n.id, i * 8, NodeKind::Hex64,
QString("field_%1").arg(i * 8, 2, 16, QChar('0')));
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
TypeEntry newEntry;
newEntry.entryKind = TypeEntry::Composite;
newEntry.structId = n.id;
@@ -1694,14 +1828,22 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
}
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
const Node& node = m_doc->tree.nodes[nodeIdx];
// BUG-1 fix: Copy needed fields to locals before any mutation.
// changeNodeKind() can trigger insertNode() → addNode() → nodes.append(),
// which may reallocate the QVector, invalidating any reference into it.
const uint64_t nodeId = m_doc->tree.nodes[nodeIdx].id;
const NodeKind nodeKind = m_doc->tree.nodes[nodeIdx].kind;
const NodeKind elemKind = m_doc->tree.nodes[nodeIdx].elementKind;
const uint64_t nodeRefId = m_doc->tree.nodes[nodeIdx].refId;
const int arrLen = m_doc->tree.nodes[nodeIdx].arrayLen;
// Parse the full text for modifiers (e.g. "int32_t[10]", "Ball*")
TypeSpec spec = parseTypeSpec(fullText);
if (mode == TypePopupMode::FieldType) {
if (entry.entryKind == TypeEntry::Primitive) {
if (entry.primitiveKind != node.kind)
if (entry.primitiveKind != nodeKind)
changeNodeKind(nodeIdx, entry.primitiveKind);
} else if (entry.entryKind == TypeEntry::Composite) {
bool wasSuppressed = m_suppressRefresh;
@@ -1710,34 +1852,34 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
if (spec.isPointer) {
// Pointer modifier: e.g. "Material*" → Pointer64 + refId
if (node.kind != NodeKind::Pointer64)
if (nodeKind != NodeKind::Pointer64)
changeNodeKind(nodeIdx, NodeKind::Pointer64);
int idx = m_doc->tree.indexOfId(node.id);
int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0 && m_doc->tree.nodes[idx].refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, m_doc->tree.nodes[idx].refId, entry.structId}));
cmd::ChangePointerRef{nodeId, m_doc->tree.nodes[idx].refId, entry.structId}));
} else if (spec.arrayCount > 0) {
// Array modifier: e.g. "Material[10]" → Array + Struct element
if (node.kind != NodeKind::Array)
if (nodeKind != NodeKind::Array)
changeNodeKind(nodeIdx, NodeKind::Array);
int idx = m_doc->tree.indexOfId(node.id);
int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0) {
auto& n = m_doc->tree.nodes[idx];
if (n.elementKind != NodeKind::Struct || n.arrayLen != spec.arrayCount)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id, n.elementKind, NodeKind::Struct,
cmd::ChangeArrayMeta{nodeId, n.elementKind, NodeKind::Struct,
n.arrayLen, spec.arrayCount}));
if (n.refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, n.refId, entry.structId}));
cmd::ChangePointerRef{nodeId, n.refId, entry.structId}));
}
} else {
// Plain struct: e.g. "Material" → Struct + structTypeName + refId + collapsed
if (node.kind != NodeKind::Struct)
if (nodeKind != NodeKind::Struct)
changeNodeKind(nodeIdx, NodeKind::Struct);
int idx = m_doc->tree.indexOfId(node.id);
int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0) {
int refIdx = m_doc->tree.indexOfId(entry.structId);
QString targetName;
@@ -1748,11 +1890,11 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
QString oldTypeName = m_doc->tree.nodes[idx].structTypeName;
if (oldTypeName != targetName)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeStructTypeName{node.id, oldTypeName, targetName}));
cmd::ChangeStructTypeName{nodeId, oldTypeName, targetName}));
// Set refId so compose can expand the referenced struct's children
if (m_doc->tree.nodes[idx].refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, m_doc->tree.nodes[idx].refId, entry.structId}));
cmd::ChangePointerRef{nodeId, m_doc->tree.nodes[idx].refId, entry.structId}));
// ChangePointerRef auto-sets collapsed=true when refId != 0
}
}
@@ -1763,33 +1905,32 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
}
} else if (mode == TypePopupMode::ArrayElement) {
if (entry.entryKind == TypeEntry::Primitive) {
if (entry.primitiveKind != node.elementKind) {
if (entry.primitiveKind != elemKind) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id,
node.elementKind, entry.primitiveKind,
node.arrayLen, node.arrayLen}));
cmd::ChangeArrayMeta{nodeId,
elemKind, entry.primitiveKind,
arrLen, arrLen}));
}
} else if (entry.entryKind == TypeEntry::Composite) {
if (node.elementKind != NodeKind::Struct || node.refId != entry.structId) {
if (elemKind != NodeKind::Struct || nodeRefId != entry.structId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id,
node.elementKind, NodeKind::Struct,
node.arrayLen, node.arrayLen}));
if (node.refId != entry.structId) {
cmd::ChangeArrayMeta{nodeId,
elemKind, NodeKind::Struct,
arrLen, arrLen}));
if (nodeRefId != entry.structId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, entry.structId}));
cmd::ChangePointerRef{nodeId, nodeRefId, entry.structId}));
}
}
}
} else if (mode == TypePopupMode::PointerTarget) {
// "void" entry → refId 0; composite entry → real structId
uint64_t realRefId = (entry.entryKind == TypeEntry::Composite) ? entry.structId : 0;
if (realRefId != node.refId) {
if (realRefId != nodeRefId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, realRefId}));
cmd::ChangePointerRef{nodeId, nodeRefId, realRefId}));
}
}
refresh();
}
void RcxController::attachViaPlugin(const QString& providerIdentifier, const QString& target) {
@@ -1858,15 +1999,66 @@ void RcxController::pushSavedSourcesToEditors() {
void RcxController::setupAutoRefresh() {
m_refreshTimer = new QTimer(this);
m_refreshTimer->setInterval(2000);
m_refreshTimer->setInterval(660);
connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick);
m_refreshTimer->start();
m_refreshWatcher = new QFutureWatcher<QByteArray>(this);
connect(m_refreshWatcher, &QFutureWatcher<QByteArray>::finished,
m_refreshWatcher = new QFutureWatcher<PageMap>(this);
connect(m_refreshWatcher, &QFutureWatcher<PageMap>::finished,
this, &RcxController::onReadComplete);
}
// Recursively collect memory ranges for a struct and its pointer targets.
// memBase is the provider-relative address where this struct's data lives.
void RcxController::collectPointerRanges(
uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const
{
if (depth >= maxDepth) return;
QPair<uint64_t,uint64_t> key{structId, memBase};
if (visited.contains(key)) return;
visited.insert(key);
int span = m_doc->tree.structSpan(structId);
if (span <= 0) return;
ranges.append({memBase, span});
if (!m_snapshotProv) return;
// Walk children looking for non-collapsed pointers
QVector<int> children = m_doc->tree.childrenOf(structId);
for (int ci : children) {
const Node& child = m_doc->tree.nodes[ci];
if (child.kind != NodeKind::Pointer32 && child.kind != NodeKind::Pointer64)
continue;
if (child.collapsed || child.refId == 0) continue;
uint64_t ptrAddr = memBase + child.offset;
int ptrSize = child.byteSize();
if (!m_snapshotProv->isReadable(ptrAddr, ptrSize)) continue;
uint64_t ptrVal = (child.kind == NodeKind::Pointer32)
? (uint64_t)m_snapshotProv->readU32(ptrAddr)
: m_snapshotProv->readU64(ptrAddr);
if (ptrVal == 0 || ptrVal == UINT64_MAX || ptrVal < m_doc->tree.baseAddress) continue;
uint64_t pBase = ptrVal - m_doc->tree.baseAddress;
collectPointerRanges(child.refId, pBase, depth + 1, maxDepth,
visited, ranges);
}
// Embedded struct references (struct node with refId but no own children)
int idx = m_doc->tree.indexOfId(structId);
if (idx >= 0) {
const Node& sn = m_doc->tree.nodes[idx];
if (sn.kind == NodeKind::Struct && sn.refId != 0 && children.isEmpty())
collectPointerRanges(sn.refId, memBase, depth, maxDepth,
visited, ranges);
}
}
void RcxController::onRefreshTick() {
if (m_readInFlight) return;
if (!m_doc->provider || !m_doc->provider->isLive()) return;
@@ -1877,75 +2069,120 @@ void RcxController::onRefreshTick() {
int extent = computeDataExtent();
if (extent <= 0) return;
// Collect all needed ranges: main struct + pointer targets
QVector<QPair<uint64_t,int>> ranges;
ranges.append({0, extent});
if (m_snapshotProv) {
QSet<QPair<uint64_t,uint64_t>> visited;
uint64_t rootId = m_viewRootId;
if (rootId == 0 && !m_doc->tree.nodes.isEmpty())
rootId = m_doc->tree.nodes[0].id;
collectPointerRanges(rootId, 0, 0, 99, visited, ranges);
}
m_readInFlight = true;
m_readGen = m_refreshGen;
// Capture shared_ptr copy — keeps provider alive during async read
auto prov = m_doc->provider;
uint64_t base = prov->base();
qDebug() << "[Refresh] reading" << extent << "bytes from base" << Qt::hex << base;
m_refreshWatcher->setFuture(QtConcurrent::run([prov, extent]() -> QByteArray {
return prov->readBytes(0, extent);
qDebug() << "[Refresh] reading" << ranges.size() << "ranges from base"
<< Qt::hex << prov->base();
m_refreshWatcher->setFuture(QtConcurrent::run([prov, ranges]() -> PageMap {
constexpr uint64_t kPageSize = 4096;
constexpr uint64_t kPageMask = ~(kPageSize - 1);
PageMap pages;
for (const auto& r : ranges) {
uint64_t pageStart = r.first & kPageMask;
uint64_t end = r.first + r.second;
uint64_t pageEnd = (end + kPageSize - 1) & kPageMask;
for (uint64_t p = pageStart; p < pageEnd; p += kPageSize) {
if (!pages.contains(p))
pages[p] = prov->readBytes(p, static_cast<int>(kPageSize));
}
}
return pages;
}));
}
void RcxController::onReadComplete() {
m_readInFlight = false;
// Stale read (provider changed while we were reading) — discard
if (m_readGen != m_refreshGen) return;
QByteArray newData = m_refreshWatcher->result();
PageMap newPages;
try {
newPages = m_refreshWatcher->result();
} catch (const std::exception& e) {
qWarning() << "[Refresh] async read threw:" << e.what();
return;
} catch (...) {
qWarning() << "[Refresh] async read threw unknown exception";
return;
}
// Fast path: no changes at all — skip full recompose
if (!m_prevSnapshot.isEmpty() && m_prevSnapshot.size() == newData.size()
&& memcmp(m_prevSnapshot.constData(), newData.constData(), newData.size()) == 0)
// All-zero guard: if page 0 is all zeros and we already have data, discard
if (!m_prevPages.isEmpty() && newPages.contains(0)) {
const QByteArray& p0 = newPages.value(0);
bool allZero = true;
for (int i = 0; i < p0.size(); ++i) {
if (p0[i] != 0) { allZero = false; break; }
}
if (allZero) {
qDebug() << "[Refresh] discarding all-zero page-0, keeping stale snapshot";
return;
}
}
// Fast path: no changes at all
if (newPages == m_prevPages)
return;
// Compute which byte offsets changed
// Compute which byte offsets changed (for change highlighting).
// Skip on first snapshot — nothing to compare against.
m_changedOffsets.clear();
if (!m_prevSnapshot.isEmpty()) {
int compareLen = qMin(m_prevSnapshot.size(), newData.size());
const char* oldP = m_prevSnapshot.constData();
const char* newP = newData.constData();
for (int i = 0; i < compareLen; i++) {
if (oldP[i] != newP[i])
m_changedOffsets.insert(i);
if (!m_prevPages.isEmpty()) {
for (auto it = newPages.constBegin(); it != newPages.constEnd(); ++it) {
uint64_t pageAddr = it.key();
const QByteArray& newPage = it.value();
auto oldIt = m_prevPages.constFind(pageAddr);
if (oldIt == m_prevPages.constEnd())
continue; // new page, no previous data to diff against
const QByteArray& oldPage = oldIt.value();
int cmpLen = qMin(oldPage.size(), newPage.size());
for (int i = 0; i < cmpLen; ++i) {
if (oldPage[i] != newPage[i])
m_changedOffsets.insert(static_cast<int64_t>(pageAddr) + i);
}
}
// Bytes beyond old snapshot are all "new"
for (int i = compareLen; i < newData.size(); i++)
m_changedOffsets.insert(i);
}
m_prevSnapshot = newData;
// Update or create snapshot provider
int mainExtent = computeDataExtent();
m_prevPages = newPages;
if (m_snapshotProv)
m_snapshotProv->updateSnapshot(std::move(newData));
m_snapshotProv->updatePages(std::move(newPages), mainExtent);
else
m_snapshotProv = std::make_unique<SnapshotProvider>(m_doc->provider, std::move(newData));
m_snapshotProv = std::make_unique<SnapshotProvider>(
m_doc->provider, std::move(newPages), mainExtent);
refresh();
// Clear changed offsets after refresh consumed them
m_changedOffsets.clear();
}
int RcxController::computeDataExtent() const {
// Prefer tree-based extent: exact bytes needed for rendering
static constexpr int64_t kMaxMainExtent = 16 * 1024 * 1024; // 16 MB cap
int64_t treeExtent = 0;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
const Node& node = m_doc->tree.nodes[i];
int64_t off = m_doc->tree.computeOffset(i);
// byteSize() returns 0 for Array-of-Struct/Array; use structSpan() for containers
int sz = (node.kind == NodeKind::Struct || node.kind == NodeKind::Array)
? m_doc->tree.structSpan(node.id) : node.byteSize();
int64_t end = off + sz;
if (end > treeExtent) treeExtent = end;
}
// Clamp to max int (readBytes takes int length)
if (treeExtent > 0) return (int)qMin(treeExtent, (int64_t)std::numeric_limits<int>::max());
if (treeExtent > 0) return static_cast<int>(qMin(treeExtent, kMaxMainExtent));
// Fallback: provider size (empty tree)
int provSize = m_doc->provider->size();
if (provSize > 0) return provSize;
return 0;
@@ -1955,7 +2192,7 @@ void RcxController::resetSnapshot() {
m_refreshGen++;
m_readInFlight = false;
m_snapshotProv.reset();
m_prevSnapshot.clear();
m_prevPages.clear();
m_changedOffsets.clear();
}
@@ -1965,7 +2202,10 @@ void RcxController::handleMarginClick(RcxEditor* editor, int margin,
if (!lm) return;
if (lm->foldHead && (margin == 0 || margin == 1)) {
toggleCollapse(lm->nodeIdx);
if (lm->markerMask & (1u << M_CYCLE))
materializeRefChildren(lm->nodeIdx);
else
toggleCollapse(lm->nodeIdx);
} else if (margin == 0 || margin == 1) {
emit nodeSelected(lm->nodeIdx);
}

View File

@@ -7,6 +7,7 @@
#include <QUndoCommand>
#include <QTimer>
#include <QFutureWatcher>
#include <QPointer>
#include <memory>
namespace rcx {
@@ -89,6 +90,7 @@ public:
void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name);
void removeNode(int nodeIdx);
void toggleCollapse(int nodeIdx);
void materializeRefChildren(int nodeIdx);
void setNodeValue(int nodeIdx, int subLine, const QString& text, bool isAscii = false);
void duplicateNode(int nodeIdx);
void showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos);
@@ -137,13 +139,14 @@ private:
int m_activeSourceIdx = -1;
// ── Cached type selector popup (avoids ~350ms cold-start on first show) ──
TypeSelectorPopup* m_cachedPopup = nullptr;
QPointer<TypeSelectorPopup> m_cachedPopup;
// ── Auto-refresh state ──
using PageMap = QHash<uint64_t, QByteArray>;
QTimer* m_refreshTimer = nullptr;
QFutureWatcher<QByteArray>* m_refreshWatcher = nullptr;
QFutureWatcher<PageMap>* m_refreshWatcher = nullptr;
std::unique_ptr<SnapshotProvider> m_snapshotProv;
QByteArray m_prevSnapshot;
PageMap m_prevPages;
QSet<int64_t> m_changedOffsets;
uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0;
@@ -165,6 +168,10 @@ private:
void onReadComplete();
int computeDataExtent() const;
void resetSnapshot();
void collectPointerRanges(uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const;
};
} // namespace rcx

View File

@@ -27,7 +27,6 @@ enum class NodeKind : uint8_t {
Pointer32, Pointer64,
Vec2, Vec3, Vec4, Mat4x4,
UTF8, UTF16,
Padding,
Struct, Array
};
@@ -37,11 +36,11 @@ inline uint qHash(rcx::NodeKind key, uint seed = 0) { return ::qHash(static_cast
#endif
namespace rcx { // reopen
// ── Kind flags (replaces repeated Hex/Padding switches) ──
// ── Kind flags (replaces repeated Hex switches) ──
enum KindFlags : uint32_t {
KF_None = 0,
KF_HexPreview = 1 << 0, // Hex8..Hex64 + Padding (ASCII+hex layout)
KF_HexPreview = 1 << 0, // Hex8..Hex64 (ASCII+hex layout)
KF_Container = 1 << 1, // Struct/Array
KF_String = 1 << 2, // UTF8/UTF16
KF_Vector = 1 << 3, // Vec2/3/4
@@ -84,7 +83,6 @@ inline constexpr KindMeta kKindMeta[] = {
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
{NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String},
{NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String},
{NodeKind::Padding, "Padding", "pad", 1, 1, 1, KF_HexPreview},
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
};
@@ -155,7 +153,6 @@ inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
enum Marker : int {
M_CONT = 0,
M_PAD = 1,
M_PTR0 = 2,
M_CYCLE = 3,
M_ERR = 4,
@@ -187,9 +184,12 @@ struct Node {
int byteSize() const {
switch (kind) {
case NodeKind::UTF8: return strLen;
case NodeKind::UTF16: return strLen * 2;
case NodeKind::Padding: return qMax(1, arrayLen);
case NodeKind::Array: return arrayLen * sizeForKind(elementKind);
case NodeKind::UTF16: return qMin(strLen, INT_MAX / 2) * 2;
case NodeKind::Array: {
int elemSz = sizeForKind(elementKind);
if (elemSz <= 0) return 0;
return qMin(arrayLen, INT_MAX / elemSz) * elemSz;
}
default: return sizeForKind(kind);
}
}
@@ -221,8 +221,8 @@ struct Node {
n.classKeyword = o["classKeyword"].toString();
n.parentId = o["parentId"].toString("0").toULongLong();
n.offset = o["offset"].toInt(0);
n.arrayLen = o["arrayLen"].toInt(1);
n.strLen = o["strLen"].toInt(64);
n.arrayLen = qBound(1, o["arrayLen"].toInt(1), 1000000);
n.strLen = qBound(1, o["strLen"].toInt(64), 1000000);
n.collapsed = o["collapsed"].toBool(false);
n.refId = o["refId"].toString("0").toULongLong();
n.elementKind = kindFromString(o["elementKind"].toString("UInt8"));
@@ -535,7 +535,7 @@ inline ColumnSpan nameSpanFor(const LineMeta& lm, int typeW = kColType, int name
int ind = kFoldCol + lm.depth * 3;
int start = ind + typeW + kSepWidth;
// Hex/Padding: ASCII preview occupies the name column (padded to nameW)
// Hex: ASCII preview occupies the name column (padded to nameW)
if (isHexPreview(lm.nodeKind))
return {start, start + nameW, true};
@@ -547,9 +547,9 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW
lm.lineKind == LineKind::ArrayElementSeparator) return {};
int ind = kFoldCol + lm.depth * 3;
// Hex/Padding uses nameW for ASCII column (same as regular name column)
bool isHexPad = isHexPreview(lm.nodeKind);
int valWidth = isHexPad ? 23 : kColValue;
// Hex uses nameW for ASCII column (same as regular name column)
bool isHex = isHexPreview(lm.nodeKind);
int valWidth = isHex ? 23 : kColValue;
int prefixW = typeW + nameW + 2 * kSepWidth;
@@ -567,8 +567,8 @@ inline ColumnSpan commentSpanFor(const LineMeta& lm, int lineLength, int typeW =
if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer) return {};
int ind = kFoldCol + lm.depth * 3;
bool isHexPad = isHexPreview(lm.nodeKind);
int valWidth = isHexPad ? 23 : kColValue;
bool isHex = isHexPreview(lm.nodeKind);
int valWidth = isHex ? 23 : kColValue;
int prefixW = typeW + nameW + 2 * kSepWidth;
int start;

View File

@@ -14,6 +14,7 @@
#include <QCursor>
#include <QMenu>
#include <QApplication>
#include <QClipboard>
#include "themes/thememanager.h"
namespace rcx {
@@ -140,7 +141,7 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_EDITABLE, 5 /*INDIC_HIDDEN*/);
// Hex/Padding node dim indicator — overrides text color
// Hex node dim indicator — overrides text color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEX_DIM, 17 /*INDIC_TEXTFORE*/);
@@ -240,9 +241,6 @@ void RcxEditor::setupMarkers() {
// M_CONT (0): continuation line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_CONT);
// M_PAD (1): padding line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_PAD);
// M_PTR0 (2): right triangle
m_sci->markerDefine(QsciScintilla::RightTriangle, M_PTR0);
@@ -1037,9 +1035,6 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
if (lm->nodeIdx < 0) return false;
// Padding: reject value editing (hex bytes are display-only)
if (t == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((t == EditTarget::Name || t == EditTarget::Value) && isHexNode(lm->nodeKind))
return false;
@@ -1220,9 +1215,6 @@ static bool hitTestTarget(QsciScintilla* sci,
}
return false;
}
// Padding nodes: hex bytes are display-only, not editable
if (outTarget == EditTarget::Value && lm.nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((outTarget == EditTarget::Name || outTarget == EditTarget::Value) && isHexNode(lm.nodeKind))
return false;
@@ -1329,7 +1321,15 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
// Single-click on editable token of already-selected node → edit
int tLine, tCol; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, tCol, t)) {
if (alreadySelected && plain) {
// Type/ArrayElementType/PointerTarget open a dismissible popup
// (not inline text edit), so allow on first click without
// requiring the node to be pre-selected.
bool isPopupTarget = (t == EditTarget::Type
|| t == EditTarget::ArrayElementType
|| t == EditTarget::PointerTarget);
if ((alreadySelected || isPopupTarget) && plain) {
if (!alreadySelected)
emit nodeClicked(h.line, h.nodeId, me->modifiers());
m_pendingClickNodeId = 0;
return beginInlineEdit(t, tLine, tCol);
}
@@ -1603,6 +1603,22 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) {
case Qt::Key_End:
m_sci->setCursorPosition(m_editState.line, editEndCol());
return true;
case Qt::Key_V:
if (ke->modifiers() & Qt::ControlModifier) {
// Sanitized paste: strip newlines (and backticks for base addresses)
QString clip = QApplication::clipboard()->text();
clip.remove('\n');
clip.remove('\r');
if (m_editState.target == EditTarget::BaseAddress)
clip.remove('`');
if (!clip.isEmpty()) {
QByteArray utf8 = clip.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL,
(uintptr_t)0, utf8.constData());
}
return true;
}
return false;
default:
return false;
}
@@ -1656,9 +1672,6 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
(target == EditTarget::BaseAddress || target == EditTarget::Source
|| target == EditTarget::RootClassType || target == EditTarget::RootClassName)))
return false;
// Padding: reject value editing (display-only hex bytes)
if (target == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind))
return false;
@@ -1961,7 +1974,7 @@ void RcxEditor::showSourcePicker() {
int zoom = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM);
menuFont.setPointSize(menuFont.pointSize() + zoom);
menu.setFont(menuFont);
menu.addAction("file");
menu.addAction("File");
// Add all registered providers from global registry
const auto& providers = ProviderRegistry::instance().providers();

Binary file not shown.

View File

@@ -293,7 +293,6 @@ static QString readValueImpl(const Node& node, const Provider& prov,
line += QStringLiteral("]");
return line;
}
case NodeKind::Padding: return display ? hexVal(prov.readU8(addr)) : rawHex(prov.readU8(addr), 2);
case NodeKind::UTF8: {
QByteArray bytes = prov.readBytes(addr, node.strLen);
int end = bytes.indexOf('\0');
@@ -344,21 +343,8 @@ QString fmtNodeLine(const Node& node, const Provider& prov,
return ind + QString(prefixW, ' ') + val + cmtSuffix;
}
// Hex nodes and Padding: hex byte preview (ASCII padded to colName to align with value column)
// Hex nodes: hex byte preview (ASCII padded to colName to align with value column)
if (isHexPreview(node.kind)) {
if (node.kind == NodeKind::Padding) {
const int totalSz = qMax(1, node.arrayLen);
const int lineOff = subLine * 8;
const int lineBytes = qMin(8, totalSz - lineOff);
QByteArray b = prov.isReadable(addr + lineOff, lineBytes)
? prov.readBytes(addr + lineOff, lineBytes) : QByteArray(lineBytes, '\0');
QString ascii = bytesToAscii(b, lineBytes).leftJustified(colName, ' ');
QString hex = bytesToHex(b, lineBytes).leftJustified(23, ' '); // 8*3-1
if (subLine == 0)
return ind + type + SEP + ascii + SEP + hex + cmtSuffix;
return ind + QString(colType + (int)SEP.size(), ' ') + ascii + SEP + hex + cmtSuffix;
}
// Hex8..Hex64: single line, ASCII padded to colName so hex column aligns with value column
const int sz = sizeForKind(node.kind);
QByteArray b = prov.isReadable(addr, sz)
? prov.readBytes(addr, sz) : QByteArray(sz, '\0');

View File

@@ -50,7 +50,6 @@ static QString cTypeName(NodeKind kind) {
case NodeKind::Mat4x4: return QStringLiteral("float");
case NodeKind::UTF8: return QStringLiteral("char");
case NodeKind::UTF16: return QStringLiteral("wchar_t");
case NodeKind::Padding: return QStringLiteral("uint8_t");
default: return QStringLiteral("uint8_t");
}
}
@@ -123,8 +122,6 @@ static QString emitField(GenContext& ctx, const Node& node) {
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen) + oc;
case NodeKind::UTF16:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc;
case NodeKind::Padding:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen)) + oc;
case NodeKind::Pointer32: {
if (node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
@@ -169,7 +166,7 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
auto emitPadRun = [&](int offset, int size) {
if (size <= 0) return;
ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n")
.arg(ctx.cType(NodeKind::Padding))
.arg(QStringLiteral("uint8_t"))
.arg(ctx.uniquePadName())
.arg(QString::number(size, 16).toUpper())
.arg(offsetComment(offset));

View File

@@ -43,9 +43,11 @@
#include <QDesktopServices>
#include "themes/thememanager.h"
#include "themes/themeeditor.h"
#include "optionsdialog.h"
#ifdef _WIN32
#include <windows.h>
#include <windowsx.h>
#include <dwmapi.h>
#include <dbghelp.h>
#include <cstdio>
@@ -140,7 +142,9 @@ public:
if ((w->windowFlags() & Qt::Window) == Qt::Window
&& !w->property("DarkTitleBar").toBool()) {
w->setProperty("DarkTitleBar", true);
#ifdef _WIN32
setDarkTitleBar(w);
#endif
}
}
return QApplication::notify(receiver, event);
@@ -159,6 +163,13 @@ public:
s = QSize(s.width() + 24, s.height() + 4);
return s;
}
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
const QWidget* w) const override {
// Kill the 1px frame margin Fusion reserves around QMenu contents
if (metric == PM_MenuPanelWidth)
return 0;
return QProxyStyle::pixelMetric(metric, opt, w);
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
@@ -204,7 +215,7 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
QPalette pal;
pal.setColor(QPalette::Window, theme.background);
pal.setColor(QPalette::WindowText, theme.text);
pal.setColor(QPalette::Base, theme.backgroundAlt);
pal.setColor(QPalette::Base, theme.background);
pal.setColor(QPalette::AlternateBase, theme.surface);
pal.setColor(QPalette::Text, theme.text);
pal.setColor(QPalette::Button, theme.button);
@@ -230,6 +241,21 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
qApp->setStyleSheet(QString());
}
class BorderOverlay : public QWidget {
public:
QColor color;
explicit BorderOverlay(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_TransparentForMouseEvents);
setAttribute(Qt::WA_NoSystemBackground);
setFocusPolicy(Qt::NoFocus);
}
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.setPen(color);
p.drawRect(0, 0, width() - 1, height() - 1);
}
};
namespace rcx {
// MainWindow class declaration is in mainwindow.h
@@ -238,6 +264,32 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setWindowTitle("Reclass");
resize(1200, 800);
// Frameless window with system menu (Alt+Space) and min/max/close support
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint
| Qt::WindowMinMaxButtonsHint);
// Custom title bar (replaces native menu bar area in QMainWindow)
m_titleBar = new TitleBarWidget(this);
m_titleBar->applyTheme(ThemeManager::instance().current());
setMenuWidget(m_titleBar);
#ifdef _WIN32
// 1px top margin preserves DWM drop shadow on the frameless window
{
auto hwnd = reinterpret_cast<HWND>(winId());
MARGINS margins = {0, 0, 1, 0};
DwmExtendFrameIntoClientArea(hwnd, &margins);
}
#endif
// Border overlay — draws a 1px colored border on top of everything
auto* overlay = new BorderOverlay(this);
m_borderOverlay = overlay;
overlay->color = ThemeManager::instance().current().borderFocused;
overlay->setGeometry(rect());
overlay->raise();
overlay->show();
m_mdiArea = new QMdiArea(this);
m_mdiArea->setViewMode(QMdiArea::TabbedView);
m_mdiArea->setTabsClosable(true);
@@ -246,27 +298,24 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
const auto& t = ThemeManager::instance().current();
m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {"
" background: %1; color: %2; padding: 6px 16px; border: none;"
" background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;"
"}"
"QTabBar::tab:selected { color: %3; background: %4; }"
"QTabBar::tab:hover { color: %3; background: %5; }")
.arg(t.background.name(), t.textMuted.name(), t.text.name(),
t.backgroundAlt.name(), t.hover.name()));
}
{
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
if (auto* tb = m_mdiArea->findChild<QTabBar*>())
tb->setFont(f);
}
setCentralWidget(m_mdiArea);
createWorkspaceDock();
createMenus();
createStatusBar();
// Restore menu bar title case setting (after menus are created)
{
bool titleCase = QSettings("Reclass", "Reclass").value("menuBarTitleCase", true).toBool();
m_titleBar->setMenuBarTitleCase(titleCase);
}
// MenuBarStyle is set as app style in main() — covers both QMenuBar and QMenu
@@ -276,9 +325,10 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
// Load plugins
m_pluginManager.LoadPlugins();
// MCP bridge (on by default)
// Start MCP bridge
m_mcp = new McpBridge(this, this);
m_mcp->start();
if (QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool())
m_mcp->start();
connect(m_mdiArea, &QMdiArea::subWindowActivated,
this, [this](QMdiSubWindow*) {
@@ -306,29 +356,31 @@ QIcon MainWindow::makeIcon(const QString& svgPath) {
void MainWindow::createMenus() {
// File
auto* file = menuBar()->addMenu("&File");
file->addAction("&New", this, &MainWindow::newDocument, QKeySequence::New);
file->addAction("New &Tab", this, &MainWindow::newFile, QKeySequence(Qt::CTRL | Qt::Key_T));
file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", this, &MainWindow::openFile, QKeySequence::Open);
auto* file = m_titleBar->menuBar()->addMenu("&File");
file->addAction("&New", QKeySequence::New, this, &MainWindow::newDocument);
file->addAction("New &Tab", QKeySequence(Qt::CTRL | Qt::Key_T), this, &MainWindow::newFile);
file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", QKeySequence::Open, this, &MainWindow::openFile);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", this, &MainWindow::saveFile, QKeySequence::Save);
file->addAction(makeIcon(":/vsicons/save-as.svg"), "Save &As...", this, &MainWindow::saveFileAs, QKeySequence::SaveAs);
file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", QKeySequence::Save, this, &MainWindow::saveFile);
file->addAction(makeIcon(":/vsicons/save-as.svg"), "Save &As...", QKeySequence::SaveAs, this, &MainWindow::saveFileAs);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/export.svg"), "Export &C++ Header...", this, &MainWindow::exportCpp);
file->addSeparator();
m_mcpAction = file->addAction("Stop &MCP Server", this, &MainWindow::toggleMcp);
m_mcpAction = file->addAction(QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server", this, &MainWindow::toggleMcp);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", this, &QMainWindow::close, QKeySequence(Qt::Key_Close));
file->addAction(makeIcon(":/vsicons/settings-gear.svg"), "&Options...", this, &MainWindow::showOptionsDialog);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", QKeySequence(Qt::Key_Close), this, &QMainWindow::close);
// Edit
auto* edit = menuBar()->addMenu("&Edit");
edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", this, &MainWindow::undo, QKeySequence::Undo);
edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", this, &MainWindow::redo, QKeySequence::Redo);
auto* edit = m_titleBar->menuBar()->addMenu("&Edit");
edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", QKeySequence::Undo, this, &MainWindow::undo);
edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", QKeySequence::Redo, this, &MainWindow::redo);
edit->addSeparator();
edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog);
// View
auto* view = menuBar()->addMenu("&View");
auto* view = m_titleBar->menuBar()->addMenu("&View");
view->addAction(makeIcon(":/vsicons/split-horizontal.svg"), "Split &Horizontal", this, &MainWindow::splitView);
view->addAction(makeIcon(":/vsicons/chrome-close.svg"), "&Unsplit", this, &MainWindow::unsplitView);
view->addSeparator();
@@ -368,28 +420,38 @@ void MainWindow::createMenus() {
themeMenu->addAction("Edit Theme...", this, &MainWindow::editTheme);
view->addSeparator();
auto* actShowIcon = view->addAction("Show &Icon");
actShowIcon->setCheckable(true);
actShowIcon->setChecked(settings.value("showIcon", false).toBool());
if (actShowIcon->isChecked()) m_titleBar->setShowIcon(true);
connect(actShowIcon, &QAction::toggled, this, [this](bool checked) {
m_titleBar->setShowIcon(checked);
QSettings s("Reclass", "Reclass");
s.setValue("showIcon", checked);
});
view->addAction(m_workspaceDock->toggleViewAction());
// Node
auto* node = menuBar()->addMenu("&Node");
node->addAction(makeIcon(":/vsicons/add.svg"), "&Add Field", this, &MainWindow::addNode, QKeySequence(Qt::Key_Insert));
node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", this, &MainWindow::removeNode, QKeySequence::Delete);
node->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "Change &Type", this, &MainWindow::changeNodeType, QKeySequence(Qt::Key_T));
node->addAction(makeIcon(":/vsicons/edit.svg"), "Re&name", this, &MainWindow::renameNodeAction, QKeySequence(Qt::Key_F2));
auto* node = m_titleBar->menuBar()->addMenu("&Node");
node->addAction(makeIcon(":/vsicons/add.svg"), "&Add Field", QKeySequence(Qt::Key_Insert), this, &MainWindow::addNode);
node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", QKeySequence::Delete, this, &MainWindow::removeNode);
node->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "Change &Type", QKeySequence(Qt::Key_T), this, &MainWindow::changeNodeType);
node->addAction(makeIcon(":/vsicons/edit.svg"), "Re&name", QKeySequence(Qt::Key_F2), this, &MainWindow::renameNodeAction);
node->addAction(makeIcon(":/vsicons/files.svg"), "D&uplicate", this, &MainWindow::duplicateNodeAction)->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
// Plugins
auto* plugins = menuBar()->addMenu("&Plugins");
auto* plugins = m_titleBar->menuBar()->addMenu("&Plugins");
plugins->addAction("&Manage Plugins...", this, &MainWindow::showPluginsDialog);
// Help
auto* help = menuBar()->addMenu("&Help");
auto* help = m_titleBar->menuBar()->addMenu("&Help");
help->addAction(makeIcon(":/vsicons/question.svg"), "&About Reclass", this, &MainWindow::about);
}
void MainWindow::createStatusBar() {
m_statusLabel = new QLabel("Ready");
m_statusLabel->setContentsMargins(10, 0, 0, 0);
statusBar()->setContentsMargins(0, 4, 0, 4);
statusBar()->addWidget(m_statusLabel, 1);
{
const auto& t = ThemeManager::instance().current();
@@ -399,20 +461,9 @@ void MainWindow::createStatusBar() {
statusBar()->setPalette(sbPal);
statusBar()->setAutoFillBackground(true);
}
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
statusBar()->setFont(f);
}
void MainWindow::applyTabWidgetStyle(QTabWidget* tw) {
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont tabFont(fontName, 12);
tabFont.setFixedPitch(true);
tw->tabBar()->setFont(tabFont);
const auto& t = ThemeManager::instance().current();
tw->setStyleSheet(QStringLiteral(
"QTabWidget::pane { border: none; }"
@@ -426,6 +477,37 @@ void MainWindow::applyTabWidgetStyle(QTabWidget* tw) {
tw->tabBar()->setExpanding(false);
}
void MainWindow::styleTabCloseButtons() {
auto* tabBar = m_mdiArea->findChild<QTabBar*>();
if (!tabBar) return;
const auto& t = ThemeManager::instance().current();
QString style = QStringLiteral(
"QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }"
"QToolButton:hover { color: %2; }")
.arg(t.textDim.name(), t.indHoverSpan.name());
auto subs = m_mdiArea->subWindowList();
for (int i = 0; i < tabBar->count() && i < subs.size(); i++) {
auto* existing = qobject_cast<QToolButton*>(
tabBar->tabButton(i, QTabBar::RightSide));
if (existing && existing->text() == QStringLiteral("\u2715")) {
// Already our button, just restyle
existing->setStyleSheet(style);
continue;
}
// Replace with ✕ text button
auto* btn = new QToolButton(tabBar);
btn->setText(QStringLiteral("\u2715"));
btn->setAutoRaise(true);
btn->setCursor(Qt::PointingHandCursor);
btn->setStyleSheet(style);
QMdiSubWindow* sub = subs[i];
connect(btn, &QToolButton::clicked, sub, &QMdiSubWindow::close);
tabBar->setTabButton(i, QTabBar::RightSide, btn);
}
}
MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
SplitPane pane;
@@ -601,6 +683,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
sub->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
}
updateWindowTitle();
rebuildWorkspaceModel();
});
});
@@ -614,6 +697,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
ctrl->refresh();
rebuildWorkspaceModel();
styleTabCloseButtons();
return sub;
}
@@ -672,6 +756,25 @@ static void buildBallDemo(NodeTree& tree) {
// 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;
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.offset = i * 8;
tree.addNode(n);
}
}
void MainWindow::newFile() {
@@ -879,16 +982,25 @@ void MainWindow::toggleMcp() {
void MainWindow::applyTheme(const Theme& theme) {
applyGlobalTheme(theme);
// Custom title bar
m_titleBar->applyTheme(theme);
// Update border overlay color
updateBorderColor(isActiveWindow() ? theme.borderFocused : theme.border);
// MDI area tabs
m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {"
" background: %1; color: %2; padding: 6px 16px; border: none;"
" background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;"
"}"
"QTabBar::tab:selected { color: %3; background: %4; }"
"QTabBar::tab:hover { color: %3; background: %5; }")
.arg(theme.background.name(), theme.textMuted.name(), theme.text.name(),
theme.backgroundAlt.name(), theme.hover.name()));
// Re-style ✕ close buttons on MDI tabs
styleTabCloseButtons();
// Status bar
{
QPalette sbPal = statusBar()->palette();
@@ -897,6 +1009,13 @@ void MainWindow::applyTheme(const Theme& theme) {
statusBar()->setPalette(sbPal);
}
// Workspace tree: text color matches menu bar
if (m_workspaceTree) {
QPalette tp = m_workspaceTree->palette();
tp.setColor(QPalette::Text, theme.textDim);
m_workspaceTree->setPalette(tp);
}
// Split pane tab widgets
for (auto& state : m_tabs) {
for (auto& pane : state.panes) {
@@ -910,21 +1029,45 @@ void MainWindow::editTheme() {
int idx = tm.currentIndex();
ThemeEditor dlg(idx, this);
if (dlg.exec() == QDialog::Accepted) {
tm.revertPreview();
int selectedIdx = dlg.selectedIndex();
Theme edited = dlg.result();
// Switch to selected theme first (if changed)
if (selectedIdx != idx && selectedIdx >= 0 && selectedIdx < tm.themes().size())
tm.setCurrent(selectedIdx);
// Apply edits
int applyIdx = selectedIdx >= 0 ? selectedIdx : idx;
if (applyIdx >= 0 && applyIdx < tm.themes().size())
tm.updateTheme(applyIdx, edited);
tm.updateTheme(dlg.selectedIndex(), dlg.result());
} else {
tm.revertPreview();
}
}
// TODO: when adding more and more options, this func becomes very clunky. Fix
void MainWindow::showOptionsDialog() {
auto& tm = ThemeManager::instance();
OptionsResult current;
current.themeIndex = tm.currentIndex();
current.fontName = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString();
current.menuBarTitleCase = m_titleBar->menuBarTitleCase();
current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool();
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool();
OptionsDialog dlg(current, this);
if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK
auto r = dlg.result();
if (r.themeIndex != current.themeIndex)
tm.setCurrent(r.themeIndex);
if (r.fontName != current.fontName)
setEditorFont(r.fontName);
if (r.menuBarTitleCase != current.menuBarTitleCase) {
m_titleBar->setMenuBarTitleCase(r.menuBarTitleCase);
QSettings("Reclass", "Reclass").setValue("menuBarTitleCase", r.menuBarTitleCase);
}
if (r.safeMode != current.safeMode)
QSettings("Reclass", "Reclass").setValue("safeMode", r.safeMode);
if (r.autoStartMcp != current.autoStartMcp)
QSettings("Reclass", "Reclass").setValue("autoStartMcp", r.autoStartMcp);
}
void MainWindow::setEditorFont(const QString& fontName) {
QSettings settings("Reclass", "Reclass");
settings.setValue("font", fontName);
@@ -943,9 +1086,6 @@ void MainWindow::setEditorFont(const QString& fontName) {
}
pane.rendered->setMarginsFont(f);
}
// Update per-pane tab bar font
if (pane.tabWidget)
applyTabWidgetStyle(pane.tabWidget);
}
}
// Sync workspace tree font
@@ -953,11 +1093,6 @@ void MainWindow::setEditorFont(const QString& fontName) {
m_workspaceTree->setFont(f);
// Sync status bar font
statusBar()->setFont(f);
// Sync MDI tab bar font
if (auto* tb = m_mdiArea->findChild<QTabBar*>())
tb->setFont(f);
// Sync menu bar / menu font via global stylesheet
applyGlobalTheme(ThemeManager::instance().current());
}
RcxController* MainWindow::activeController() const {
@@ -984,6 +1119,7 @@ MainWindow::TabState* MainWindow::tabByIndex(int index) {
}
void MainWindow::updateWindowTitle() {
QString title;
auto* sub = m_mdiArea->activeSubWindow();
if (sub && m_tabs.contains(sub)) {
auto& tab = m_tabs[sub];
@@ -991,10 +1127,11 @@ void MainWindow::updateWindowTitle() {
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
if (tab.doc->modified) name += " *";
setWindowTitle(name + " - Reclass");
title = name + " - Reclass";
} else {
setWindowTitle("Reclass");
title = "Reclass";
}
setWindowTitle(title);
}
// ── Rendered view setup ──
@@ -1287,7 +1424,7 @@ void MainWindow::project_close(QMdiSubWindow* sub) {
// ── Workspace Dock ──
void MainWindow::createWorkspaceDock() {
m_workspaceDock = new QDockWidget("Workspace", this);
m_workspaceDock = new QDockWidget("Project Tree", this);
m_workspaceDock->setObjectName("WorkspaceDock");
m_workspaceDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
@@ -1297,81 +1434,51 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setModel(m_workspaceModel);
m_workspaceTree->setHeaderHidden(true);
m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
// Match editor font
{
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
m_workspaceTree->setFont(f);
}
m_workspaceTree->setExpandsOnDoubleClick(false);
m_workspaceTree->setMouseTracking(true);
m_workspaceDock->setWidget(m_workspaceTree);
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
m_workspaceDock->hide();
connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) {
// Data roles: UserRole=QMdiSubWindow*, UserRole+1=structId, UserRole+2=nodeId
auto structIdVar = index.data(Qt::UserRole + 1);
uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0;
if (structId == rcx::kGroupSentinel) {
// "Project" folder: toggle expand/collapse
m_workspaceTree->setExpanded(index, !m_workspaceTree->isExpanded(index));
return;
}
auto subVar = index.data(Qt::UserRole);
if (!subVar.isValid()) return;
auto* sub = static_cast<QMdiSubWindow*>(subVar.value<void*>());
if (!sub || !m_tabs.contains(sub)) return;
m_mdiArea->setActiveSubWindow(sub);
auto structIdVar = index.data(Qt::UserRole + 1);
auto nodeIdVar = index.data(Qt::UserRole + 2);
if (structIdVar.isValid()) {
// Double-clicked a struct: set as view root
uint64_t structId = structIdVar.toULongLong();
auto& tree = m_tabs[sub].doc->tree;
int ni = tree.indexOfId(structId);
if (ni >= 0) tree.nodes[ni].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(structId);
m_tabs[sub].ctrl->scrollToNodeId(structId);
} else if (nodeIdVar.isValid()) {
// Double-clicked a field: find its root struct, set as view root, scroll to field
uint64_t nodeId = nodeIdVar.toULongLong();
auto& tree = m_tabs[sub].doc->tree;
// Walk up to find root struct
uint64_t rootId = 0;
uint64_t cur = nodeId;
while (cur != 0) {
int idx = tree.indexOfId(cur);
if (idx < 0) break;
if (tree.nodes[idx].parentId == 0) { rootId = cur; break; }
cur = tree.nodes[idx].parentId;
}
if (rootId != 0) {
int ri = tree.indexOfId(rootId);
if (ri >= 0) tree.nodes[ri].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(rootId);
}
m_tabs[sub].ctrl->scrollToNodeId(nodeId);
} else if (!index.parent().isValid()) {
// Double-clicked project root: clear view root to show all
m_tabs[sub].ctrl->setViewRootId(0);
}
// Type/Enum node: navigate to it
auto& tree = m_tabs[sub].doc->tree;
int ni = tree.indexOfId(structId);
if (ni >= 0) tree.nodes[ni].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(structId);
m_tabs[sub].ctrl->scrollToNodeId(structId);
});
}
void MainWindow::rebuildWorkspaceModel() {
m_workspaceModel->clear();
auto* sub = m_mdiArea->activeSubWindow();
if (!sub || !m_tabs.contains(sub)) return;
TabState& tab = m_tabs[sub];
QString tabName = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
buildWorkspaceModel(m_workspaceModel, tab.doc->tree, tabName,
static_cast<void*>(sub));
m_workspaceTree->expandAll();
QVector<rcx::TabInfo> tabs;
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
TabState& tab = it.value();
QString name = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
tabs.append({ &tab.doc->tree, name, static_cast<void*>(it.key()) });
}
rcx::buildProjectExplorer(m_workspaceModel, tabs);
m_workspaceTree->expandToDepth(1);
}
void MainWindow::showPluginsDialog() {
@@ -1474,6 +1581,29 @@ void MainWindow::showPluginsDialog() {
dialog.exec();
}
void MainWindow::changeEvent(QEvent* event) {
QMainWindow::changeEvent(event);
if (event->type() == QEvent::ActivationChange) {
const auto& t = ThemeManager::instance().current();
updateBorderColor(isActiveWindow() ? t.borderFocused : t.border);
}
if (event->type() == QEvent::WindowStateChange)
m_titleBar->updateMaximizeIcon();
}
void MainWindow::resizeEvent(QResizeEvent* event) {
QMainWindow::resizeEvent(event);
if (m_borderOverlay) {
m_borderOverlay->setGeometry(rect());
m_borderOverlay->raise();
}
}
void MainWindow::updateBorderColor(const QColor& color) {
static_cast<BorderOverlay*>(m_borderOverlay)->color = color;
m_borderOverlay->update();
}
} // namespace rcx
// ── Entry point ──

View File

@@ -1,5 +1,6 @@
#pragma once
#include "controller.h"
#include "titlebar.h"
#include "pluginmanager.h"
#include <QMainWindow>
#include <QMdiArea>
@@ -48,6 +49,7 @@ private slots:
void exportCpp();
void showTypeAliasesDialog();
void editTheme();
void showOptionsDialog();
public:
// Project Lifecycle API
@@ -59,11 +61,13 @@ public:
private:
enum ViewMode { VM_Reclass, VM_Rendered };
QMdiArea* m_mdiArea;
QLabel* m_statusLabel;
PluginManager m_pluginManager;
McpBridge* m_mcp = nullptr;
QAction* m_mcpAction = nullptr;
QMdiArea* m_mdiArea;
QLabel* m_statusLabel;
TitleBarWidget* m_titleBar = nullptr;
QWidget* m_borderOverlay = nullptr;
PluginManager m_pluginManager;
McpBridge* m_mcp = nullptr;
QAction* m_mcpAction = nullptr;
struct SplitPane {
QTabWidget* tabWidget = nullptr;
@@ -104,6 +108,7 @@ private:
SplitPane createSplitPane(TabState& tab);
void applyTheme(const Theme& theme);
void applyTabWidgetStyle(QTabWidget* tw);
void styleTabCloseButtons();
SplitPane* findPaneByTabWidget(QTabWidget* tw);
SplitPane* findActiveSplitPane();
RcxEditor* activePaneEditor();
@@ -114,6 +119,11 @@ private:
QStandardItemModel* m_workspaceModel = nullptr;
void createWorkspaceDock();
void rebuildWorkspaceModel();
void updateBorderColor(const QColor& color);
protected:
void changeEvent(QEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
};
} // namespace rcx

View File

@@ -248,7 +248,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"collapse: {op:'collapse', nodeId:'ID', collapsed:true}. "
"Insert ops get auto-assigned IDs; use $0, $1 etc. to reference them in later ops. "
"Kinds: Hex8 Hex16 Hex32 Hex64 Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 "
"Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Padding Struct Array"},
"Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Struct Array"},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{

327
src/optionsdialog.cpp Normal file
View File

@@ -0,0 +1,327 @@
#include "optionsdialog.h"
#include "themes/thememanager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QDialogButtonBox>
#include <QPushButton>
#include <QGroupBox>
#include <QLabel>
#include <QTreeWidgetItem>
#include <QGraphicsDropShadowEffect>
#include <QEvent>
#include <functional>
namespace rcx {
OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
: QDialog(parent)
{
setWindowTitle("Options");
setFixedSize(700, 450);
const auto& t = ThemeManager::instance().current();
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setSpacing(8);
mainLayout->setContentsMargins(10, 10, 10, 10);
// -- Middle: left column (search + tree) | right column (pages) --
auto* middleLayout = new QHBoxLayout;
middleLayout->setSpacing(8);
// Left column: search bar + tree
auto* leftColumn = new QVBoxLayout;
leftColumn->setSpacing(4);
m_search = new QLineEdit;
m_search->setPlaceholderText("Search Options (Ctrl+E)");
m_search->setClearButtonEnabled(true);
connect(m_search, &QLineEdit::textChanged, this, &OptionsDialog::filterTree);
leftColumn->addWidget(m_search);
m_tree = new QTreeWidget;
m_tree->setHeaderHidden(true);
m_tree->setRootIsDecorated(true);
m_tree->setFixedWidth(200);
auto* envItem = new QTreeWidgetItem(m_tree, {"Environment"});
auto* generalItem = new QTreeWidgetItem(envItem, {"General"});
m_tree->expandAll();
m_tree->setCurrentItem(generalItem);
leftColumn->addWidget(m_tree, 1);
middleLayout->addLayout(leftColumn);
// Right column: stacked pages with group boxes
m_pages = new QStackedWidget;
// -- General page --
auto* generalPage = new QWidget;
auto* generalLayout = new QVBoxLayout(generalPage);
generalLayout->setContentsMargins(0, 0, 0, 0);
generalLayout->setSpacing(8);
// Visual Experience group box
auto* visualGroup = new QGroupBox("Visual Experience");
auto* visualLayout = new QFormLayout(visualGroup);
visualLayout->setSpacing(8);
visualLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
m_themeCombo = new QComboBox;
auto& tm = ThemeManager::instance();
for (const auto& theme : tm.themes())
m_themeCombo->addItem(theme.name);
m_themeCombo->setCurrentIndex(current.themeIndex);
m_themeCombo->setObjectName("themeCombo");
visualLayout->addRow("Color theme:", m_themeCombo);
m_fontCombo = new QComboBox;
m_fontCombo->addItem("JetBrains Mono");
m_fontCombo->addItem("Consolas");
m_fontCombo->setCurrentText(current.fontName);
m_fontCombo->setObjectName("fontCombo");
visualLayout->addRow("Editor Font:", m_fontCombo);
m_titleCaseCheck = new QCheckBox("Apply title case styling to menu bar");
m_titleCaseCheck->setChecked(current.menuBarTitleCase);
visualLayout->addRow(m_titleCaseCheck);
generalLayout->addWidget(visualGroup);
// Safe Mode group box
auto* safeModeGroup = new QGroupBox("Preview Features");
auto* safeModeLayout = new QVBoxLayout(safeModeGroup);
safeModeLayout->setSpacing(4);
m_safeModeCheck = new QCheckBox("Safe Mode");
m_safeModeCheck->setChecked(current.safeMode);
m_safeModeCheck->setStyleSheet(QStringLiteral(
"QCheckBox { font-weight: bold; }"));
safeModeLayout->addWidget(m_safeModeCheck);
auto* safeModeDesc = new QLabel(
"Enable to use the default OS icon for this application and "
"create the window with the name of the executable file.");
safeModeDesc->setWordWrap(true);
safeModeDesc->setContentsMargins(20, 0, 0, 0); // indent under checkbox
safeModeLayout->addWidget(safeModeDesc);
generalLayout->addWidget(safeModeGroup);
generalLayout->addStretch();
m_pages->addWidget(generalPage); // index 0
m_pageKeywords[generalItem] = collectPageKeywords(generalPage);
// -- AI Features page --
auto* aiItem = new QTreeWidgetItem(envItem, {"AI Features"});
auto* aiPage = new QWidget;
auto* aiLayout = new QVBoxLayout(aiPage);
aiLayout->setContentsMargins(0, 0, 0, 0);
aiLayout->setSpacing(8);
auto* mcpGroup = new QGroupBox("MCP Server");
auto* mcpLayout = new QVBoxLayout(mcpGroup);
mcpLayout->setSpacing(4);
m_autoMcpCheck = new QCheckBox("Auto-start MCP server");
m_autoMcpCheck->setChecked(current.autoStartMcp);
m_autoMcpCheck->setStyleSheet(QStringLiteral(
"QCheckBox { font-weight: bold; }"));
mcpLayout->addWidget(m_autoMcpCheck);
auto* mcpDesc = new QLabel(
"Automatically start the MCP bridge server when the application launches, "
"allowing external AI tools to connect and interact with the editor.");
mcpDesc->setWordWrap(true);
mcpDesc->setContentsMargins(20, 0, 0, 0);
mcpLayout->addWidget(mcpDesc);
aiLayout->addWidget(mcpGroup);
aiLayout->addStretch();
m_pages->addWidget(aiPage); // index 1
m_pageKeywords[aiItem] = collectPageKeywords(aiPage);
middleLayout->addWidget(m_pages, 1);
mainLayout->addLayout(middleLayout, 1);
// Tree <-> page connection
m_itemPageIndex[generalItem] = 0;
m_itemPageIndex[aiItem] = 1;
connect(m_tree, &QTreeWidget::currentItemChanged, this,
[this](QTreeWidgetItem* item, QTreeWidgetItem*) {
if (!item) return;
auto it = m_itemPageIndex.find(item);
if (it != m_itemPageIndex.end())
m_pages->setCurrentIndex(it.value());
});
// -- Button box --
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttons);
// -- Styling --
// Combo boxes: set directly so the popup (top-level widget) inherits it
QString comboStyle = QStringLiteral(
"QComboBox {"
" background: %1; color: %2; border: 1px solid %3;"
" padding: 3px 8px; font-size: 12px;"
"}"
"QComboBox::drop-down {"
" border: none; border-left: 1px solid %3;"
" width: 20px;"
"}"
"QComboBox::down-arrow {"
" image: url(:/vsicons/chevron-down.svg);"
" width: 12px; height: 12px;"
"}"
"QComboBox QAbstractItemView {"
" background: %1; color: %2; border: 1px solid %3;"
" selection-background-color: %4;"
"}")
.arg(t.backgroundAlt.name(), t.text.name(),
t.border.name(), t.hover.name());
m_themeCombo->setStyleSheet(comboStyle);
m_fontCombo->setStyleSheet(comboStyle);
// Dialog-wide stylesheet for everything else
setStyleSheet(QStringLiteral(
"QDialog { background: %1; }"
"QLineEdit {"
" background: %2; color: %3; border: 1px solid %4;"
" padding: 4px 8px; font-size: 12px;"
"}"
"QTreeWidget {"
" background: %2; color: %3; border: 1px solid %4;"
" font-size: 12px; outline: none;"
"}"
"QTreeWidget::item { padding: 3px 0; outline: none; }"
"QTreeWidget::item:selected { background: %5; color: %3; }"
"QTreeWidget::item:hover { background: %6; }"
"QGroupBox {"
" color: %3; border: 1px solid %4;"
" margin-top: 8px; padding: 12px 8px 8px 8px;"
" font-size: 12px; font-weight: bold;"
"}"
"QGroupBox::title {"
" subcontrol-origin: margin;"
" left: 8px; padding: 0 4px;"
"}"
"QLabel { color: %3; font-size: 12px; }"
"QCheckBox { color: %3; font-size: 12px; spacing: 6px; }"
"QPushButton {"
" background: %2; color: %3; border: 1px solid %4;"
" padding: 5px 16px; min-width: 70px; font-size: 12px;"
" outline: none;"
"}"
"QPushButton:hover { background: %6; }"
"QPushButton:pressed { background: %1; }"
"QPushButton:focus { outline: none; }")
.arg(t.background.name(), // %1
t.backgroundAlt.name(), // %2
t.text.name(), // %3
t.border.name(), // %4
t.selection.name(), // %5
t.hover.name())); // %6
// Install hover shadow on interactive widgets (not buttons — they use stylesheet hover)
for (auto* w : {static_cast<QWidget*>(m_search),
static_cast<QWidget*>(m_themeCombo),
static_cast<QWidget*>(m_fontCombo),
static_cast<QWidget*>(m_titleCaseCheck),
static_cast<QWidget*>(m_safeModeCheck),
static_cast<QWidget*>(m_autoMcpCheck)})
w->installEventFilter(this);
m_shadowColor = t.text;
m_shadowColor.setAlpha(80);
}
bool OptionsDialog::eventFilter(QObject* obj, QEvent* event) {
if (event->type() == QEvent::Enter) {
auto* w = qobject_cast<QWidget*>(obj);
if (w && !w->graphicsEffect()) {
auto* shadow = new QGraphicsDropShadowEffect(w);
shadow->setBlurRadius(12);
shadow->setOffset(0, 0);
shadow->setColor(m_shadowColor);
w->setGraphicsEffect(shadow);
}
} else if (event->type() == QEvent::Leave) {
auto* w = qobject_cast<QWidget*>(obj);
if (w)
w->setGraphicsEffect(nullptr);
}
return QDialog::eventFilter(obj, event);
}
OptionsResult OptionsDialog::result() const {
OptionsResult r;
r.themeIndex = m_themeCombo->currentIndex();
r.fontName = m_fontCombo->currentText();
r.menuBarTitleCase = m_titleCaseCheck->isChecked();
r.safeMode = m_safeModeCheck->isChecked();
r.autoStartMcp = m_autoMcpCheck->isChecked();
return r;
}
QStringList OptionsDialog::collectPageKeywords(QWidget* page) {
QStringList keywords;
for (auto* child : page->findChildren<QWidget*>()) {
if (auto* label = qobject_cast<QLabel*>(child))
keywords << label->text();
else if (auto* cb = qobject_cast<QCheckBox*>(child))
keywords << cb->text();
else if (auto* gb = qobject_cast<QGroupBox*>(child))
keywords << gb->title();
else if (auto* combo = qobject_cast<QComboBox*>(child)) {
for (int i = 0; i < combo->count(); ++i)
keywords << combo->itemText(i);
}
}
return keywords;
}
void OptionsDialog::filterTree(const QString& text) {
std::function<bool(QTreeWidgetItem*)> filter = [&](QTreeWidgetItem* item) -> bool {
bool anyChildVisible = false;
for (int i = 0; i < item->childCount(); ++i) {
if (filter(item->child(i)))
anyChildVisible = true;
}
bool selfMatch = item->text(0).contains(text, Qt::CaseInsensitive);
if (!selfMatch) {
for (const auto& kw : m_pageKeywords.value(item)) {
if (kw.contains(text, Qt::CaseInsensitive)) {
selfMatch = true;
break;
}
}
}
bool visible = selfMatch || anyChildVisible;
item->setHidden(!visible);
if (visible && item->childCount() > 0)
item->setExpanded(true);
return visible;
};
for (int i = 0; i < m_tree->topLevelItemCount(); ++i)
filter(m_tree->topLevelItem(i));
}
} // namespace rcx

53
src/optionsdialog.h Normal file
View File

@@ -0,0 +1,53 @@
#pragma once
#include "themes/theme.h"
#include <QDialog>
#include <QLineEdit>
#include <QTreeWidget>
#include <QStackedWidget>
#include <QComboBox>
#include <QCheckBox>
#include <QHash>
#include <QColor>
namespace rcx {
struct OptionsResult {
int themeIndex = 0;
QString fontName;
bool menuBarTitleCase = true;
bool safeMode = false;
bool autoStartMcp = false;
};
class OptionsDialog : public QDialog {
Q_OBJECT
public:
explicit OptionsDialog(const OptionsResult& current, QWidget* parent = nullptr);
OptionsResult result() const;
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void filterTree(const QString& text);
static QStringList collectPageKeywords(QWidget* page);
QLineEdit* m_search = nullptr;
QTreeWidget* m_tree = nullptr;
QStackedWidget* m_pages = nullptr;
QComboBox* m_themeCombo = nullptr;
QComboBox* m_fontCombo = nullptr;
QCheckBox* m_titleCaseCheck = nullptr;
QCheckBox* m_safeModeCheck = nullptr;
QCheckBox* m_autoMcpCheck = nullptr;
QColor m_shadowColor;
// searchable keywords per leaf tree item
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;
// tree item → stacked widget page index
QHash<QTreeWidgetItem*, int> m_itemPageIndex;
};
} // namespace rcx

View File

@@ -1,28 +1,65 @@
#pragma once
#include "provider.h"
#include <QHash>
#include <memory>
namespace rcx {
// Provider that reads from a cached QByteArray snapshot but delegates
// metadata (name, kind, getSymbol) to the underlying real provider.
// Used for async refresh: worker thread reads bulk data into a snapshot,
// UI thread composes against it without blocking.
// Page-based snapshot provider.
//
// During async refresh the controller reads pages for the main struct and
// every reachable pointer target. Compose reads entirely from this page
// table — no fallback to the real provider, no blocking I/O on the UI
// thread. Pages that were never fetched (truly invalid pointers) simply
// read as zeros.
class SnapshotProvider : public Provider {
std::shared_ptr<Provider> m_real;
QByteArray m_data;
QHash<uint64_t, QByteArray> m_pages; // page-aligned addr → 4096-byte page
int m_mainExtent = 0; // logical size of the main struct range
static constexpr uint64_t kPageSize = 4096;
static constexpr uint64_t kPageMask = ~(kPageSize - 1);
public:
SnapshotProvider(std::shared_ptr<Provider> real, QByteArray snapshot)
: m_real(std::move(real)), m_data(std::move(snapshot)) {}
using PageMap = QHash<uint64_t, QByteArray>;
SnapshotProvider(std::shared_ptr<Provider> real, PageMap pages, int mainExtent)
: m_real(std::move(real))
, m_pages(std::move(pages))
, m_mainExtent(mainExtent) {}
bool read(uint64_t addr, void* buf, int len) const override {
if (!isReadable(addr, len)) return false;
std::memcpy(buf, m_data.constData() + addr, len);
if (len <= 0) return false;
char* out = static_cast<char*>(buf);
uint64_t cur = addr;
int remaining = len;
while (remaining > 0) {
uint64_t pageAddr = cur & kPageMask;
int pageOff = static_cast<int>(cur - pageAddr);
int chunk = qMin(remaining, static_cast<int>(kPageSize - pageOff));
auto it = m_pages.constFind(pageAddr);
if (it != m_pages.constEnd()) {
std::memcpy(out, it->constData() + pageOff, chunk);
} else {
std::memset(out, 0, chunk);
}
out += chunk;
cur += chunk;
remaining -= chunk;
}
return true;
}
int size() const override { return m_data.size(); }
bool isReadable(uint64_t addr, int len) const override {
if (len <= 0) return (len == 0);
uint64_t end = addr + static_cast<uint64_t>(len);
for (uint64_t p = addr & kPageMask; p < end; p += kPageSize) {
if (!m_pages.contains(p)) return false;
}
return true;
}
int size() const override { return m_mainExtent; }
bool isWritable() const override { return m_real ? m_real->isWritable() : false; }
bool isLive() const override { return m_real ? m_real->isLive() : false; }
QString name() const override { return m_real ? m_real->name() : QString(); }
@@ -34,21 +71,36 @@ public:
bool write(uint64_t addr, const void* buf, int len) override {
if (!m_real) return false;
bool ok = m_real->write(addr, buf, len);
if (ok && isReadable(addr, len))
std::memcpy(m_data.data() + addr, buf, len);
if (ok) patchPages(addr, buf, len);
return ok;
}
// Update the entire snapshot (called after async read completes)
void updateSnapshot(QByteArray data) { m_data = std::move(data); }
// Patch specific bytes in the snapshot (called after user writes a value)
void patchSnapshot(uint64_t addr, const void* buf, int len) {
if (isReadable(addr, len))
std::memcpy(m_data.data() + addr, buf, len);
// Replace the entire page table (called after async read completes)
void updatePages(PageMap pages, int mainExtent) {
m_pages = std::move(pages);
m_mainExtent = mainExtent;
}
const QByteArray& snapshot() const { return m_data; }
// Patch specific bytes in existing pages (called after user writes a value)
void patchPages(uint64_t addr, const void* buf, int len) {
const char* src = static_cast<const char*>(buf);
uint64_t cur = addr;
int remaining = len;
while (remaining > 0) {
uint64_t pageAddr = cur & kPageMask;
int pageOff = static_cast<int>(cur - pageAddr);
int chunk = qMin(remaining, static_cast<int>(kPageSize - pageOff));
auto it = m_pages.find(pageAddr);
if (it != m_pages.end()) {
std::memcpy(it->data() + pageOff, src, chunk);
}
src += chunk;
cur += chunk;
remaining -= chunk;
}
}
const PageMap& pages() const { return m_pages; }
};
} // namespace rcx

View File

@@ -3,6 +3,7 @@
<file alias="chevron-right.png">icons/chevron-right.png</file>
<file alias="chevron-down.png">icons/chevron-down.png</file>
<file alias="class.png">icons/class.png</file>
</qresource>
<qresource prefix="/fonts">
<file alias="JetBrainsMono.ttf">fonts/JetBrainsMono.ttf</file>
@@ -20,6 +21,9 @@
<file alias="arrow-right.svg">vsicons/arrow-right.svg</file>
<file alias="split-horizontal.svg">vsicons/split-horizontal.svg</file>
<file alias="chrome-close.svg">vsicons/chrome-close.svg</file>
<file alias="chrome-minimize.svg">vsicons/chrome-minimize.svg</file>
<file alias="chrome-maximize.svg">vsicons/chrome-maximize.svg</file>
<file alias="chrome-restore.svg">vsicons/chrome-restore.svg</file>
<file alias="text-size.svg">vsicons/text-size.svg</file>
<file alias="add.svg">vsicons/add.svg</file>
<file alias="remove.svg">vsicons/remove.svg</file>
@@ -43,5 +47,9 @@
<file alias="selection.svg">vsicons/list-selection.svg</file>
<file alias="symbol-numeric.svg">vsicons/symbol-numeric.svg</file>
<file alias="symbol-ruler.svg">vsicons/symbol-ruler.svg</file>
<file alias="settings-gear.svg">vsicons/settings-gear.svg</file>
<file alias="chevron-down.svg">vsicons/chevron-down.svg</file>
<file alias="folder.svg">vsicons/folder.svg</file>
<file alias="symbol-enum.svg">vsicons/symbol-enum.svg</file>
</qresource>
</RCC>

View File

@@ -0,0 +1,29 @@
{
"name": "Reclass Dark",
"background": "#1e1e1e",
"backgroundAlt": "#252526",
"surface": "#2a2d2e",
"border": "#3c3c3c",
"borderFocused": "#888888",
"button": "#333333",
"text": "#d4d4d4",
"textDim": "#858585",
"textMuted": "#585858",
"textFaint": "#505050",
"hover": "#1e1e1e",
"selected": "#1e1e1e",
"selection": "#2b2b2b",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",
"syntaxString": "#ce9178",
"syntaxComment": "#6a9955",
"syntaxPreproc": "#c586c0",
"syntaxType": "#4EC9B0",
"indHoverSpan": "#E6B450",
"indCmdPill": "#2a2a2a",
"indDataChanged": "#8fbc7a",
"indHintGreen": "#5a8248",
"markerPtr": "#f44747",
"markerCycle": "#e5a00d",
"markerError": "#7a2e2e"
}

View File

@@ -0,0 +1,29 @@
{
"name": "VS2022 Dark",
"background": "#1e1e1e",
"backgroundAlt": "#2d2d30",
"surface": "#333337",
"border": "#3f3f46",
"borderFocused": "#b180d7",
"button": "#3f3f46",
"text": "#dcdcdc",
"textDim": "#858585",
"textMuted": "#636369",
"textFaint": "#4d4d55",
"hover": "#2c2c2f",
"selected": "#262629",
"selection": "#264f78",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",
"syntaxString": "#d69d85",
"syntaxComment": "#57a64a",
"syntaxPreproc": "#9b9b9b",
"syntaxType": "#4ec9b0",
"indHoverSpan": "#b180d7",
"indCmdPill": "#2d2d30",
"indDataChanged": "#8fbc7a",
"indHintGreen": "#5a8248",
"markerPtr": "#f44747",
"markerCycle": "#e5a00d",
"markerError": "#7a2e2e"
}

View File

@@ -0,0 +1,29 @@
{
"name": "Warm",
"background": "#212121",
"backgroundAlt": "#2a2a2a",
"surface": "#2a2a2a",
"border": "#373737",
"borderFocused": "#888888",
"button": "#373737",
"text": "#AAA99F",
"textDim": "#7a7a6e",
"textMuted": "#555550",
"textFaint": "#464646",
"hover": "#282828",
"selected": "#262626",
"selection": "#21213A",
"syntaxKeyword": "#AA9565",
"syntaxNumber": "#AAA98C",
"syntaxString": "#6B3B21",
"syntaxComment": "#464646",
"syntaxPreproc": "#AA9565",
"syntaxType": "#6B959F",
"indHoverSpan": "#AA9565",
"indCmdPill": "#2a2a2a",
"indDataChanged": "#6B959F",
"indHintGreen": "#464646",
"markerPtr": "#6B3B21",
"markerCycle": "#AA9565",
"markerError": "#3C2121"
}

View File

@@ -1,119 +1,56 @@
#include "theme.h"
#include <type_traits>
namespace rcx {
// ── Field table for DRY serialization ──
// ── Shared field metadata (serialization + editor UI) ──
struct ColorField { const char* key; QColor Theme::*ptr; };
static const ColorField kFields[] = {
{"background", &Theme::background},
{"backgroundAlt", &Theme::backgroundAlt},
{"surface", &Theme::surface},
{"border", &Theme::border},
{"button", &Theme::button},
{"text", &Theme::text},
{"textDim", &Theme::textDim},
{"textMuted", &Theme::textMuted},
{"textFaint", &Theme::textFaint},
{"hover", &Theme::hover},
{"selected", &Theme::selected},
{"selection", &Theme::selection},
{"syntaxKeyword", &Theme::syntaxKeyword},
{"syntaxNumber", &Theme::syntaxNumber},
{"syntaxString", &Theme::syntaxString},
{"syntaxComment", &Theme::syntaxComment},
{"syntaxPreproc", &Theme::syntaxPreproc},
{"syntaxType", &Theme::syntaxType},
{"indHoverSpan", &Theme::indHoverSpan},
{"indCmdPill", &Theme::indCmdPill},
{"indDataChanged",&Theme::indDataChanged},
{"indHintGreen", &Theme::indHintGreen},
{"markerPtr", &Theme::markerPtr},
{"markerCycle", &Theme::markerCycle},
{"markerError", &Theme::markerError},
const ThemeFieldMeta kThemeFields[] = {
{"background", "Background", "Chrome", &Theme::background},
{"backgroundAlt", "Background Alt", "Chrome", &Theme::backgroundAlt},
{"surface", "Surface", "Chrome", &Theme::surface},
{"border", "Border", "Chrome", &Theme::border},
{"borderFocused", "Border Focused", "Chrome", &Theme::borderFocused},
{"button", "Button", "Chrome", &Theme::button},
{"text", "Text", "Text", &Theme::text},
{"textDim", "Text Dim", "Text", &Theme::textDim},
{"textMuted", "Text Muted", "Text", &Theme::textMuted},
{"textFaint", "Text Faint", "Text", &Theme::textFaint},
{"hover", "Hover", "Interactive", &Theme::hover},
{"selected", "Selected", "Interactive", &Theme::selected},
{"selection", "Selection", "Interactive", &Theme::selection},
{"syntaxKeyword", "Keyword", "Syntax", &Theme::syntaxKeyword},
{"syntaxNumber", "Number", "Syntax", &Theme::syntaxNumber},
{"syntaxString", "String", "Syntax", &Theme::syntaxString},
{"syntaxComment", "Comment", "Syntax", &Theme::syntaxComment},
{"syntaxPreproc", "Preprocessor", "Syntax", &Theme::syntaxPreproc},
{"syntaxType", "Type", "Syntax", &Theme::syntaxType},
{"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan},
{"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill},
{"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged},
{"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen},
{"markerPtr", "Pointer", "Markers", &Theme::markerPtr},
{"markerCycle", "Cycle", "Markers", &Theme::markerCycle},
{"markerError", "Error", "Markers", &Theme::markerError},
};
const int kThemeFieldCount = static_cast<int>(std::extent_v<decltype(kThemeFields)>);
QJsonObject Theme::toJson() const {
QJsonObject o;
o["name"] = name;
for (const auto& f : kFields)
o[f.key] = (this->*f.ptr).name();
for (int i = 0; i < kThemeFieldCount; i++)
o[kThemeFields[i].key] = (this->*kThemeFields[i].ptr).name();
return o;
}
Theme Theme::fromJson(const QJsonObject& o) {
Theme t = reclassDark();
t.name = o["name"].toString(t.name);
for (const auto& f : kFields) {
if (o.contains(f.key))
t.*f.ptr = QColor(o[f.key].toString());
Theme t;
t.name = o["name"].toString("Untitled");
for (int i = 0; i < kThemeFieldCount; i++) {
if (o.contains(kThemeFields[i].key))
t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString());
}
return t;
}
// ── Built-in themes ──
Theme Theme::reclassDark() {
Theme t;
t.name = "Reclass Dark";
t.background = QColor("#1e1e1e");
t.backgroundAlt = QColor("#252526");
t.surface = QColor("#2a2d2e");
t.border = QColor("#3c3c3c");
t.button = QColor("#333333");
t.text = QColor("#d4d4d4");
t.textDim = QColor("#858585");
t.textMuted = QColor("#585858");
t.textFaint = QColor("#505050");
t.hover = QColor("#2b2b2b");
t.selected = QColor("#232323");
t.selection = QColor("#2b2b2b");
t.syntaxKeyword = QColor("#569cd6");
t.syntaxNumber = QColor("#b5cea8");
t.syntaxString = QColor("#ce9178");
t.syntaxComment = QColor("#6a9955");
t.syntaxPreproc = QColor("#c586c0");
t.syntaxType = QColor("#4EC9B0");
t.indHoverSpan = QColor("#E6B450");
t.indCmdPill = QColor("#2a2a2a");
t.indDataChanged= QColor("#8fbc7a");
t.indHintGreen = QColor("#5a8248");
t.markerPtr = QColor("#f44747");
t.markerCycle = QColor("#e5a00d");
t.markerError = QColor("#7a2e2e");
return t;
}
Theme Theme::warm() {
Theme t;
t.name = "Warm";
t.background = QColor("#212121");
t.backgroundAlt = QColor("#2a2a2a");
t.surface = QColor("#2a2a2a");
t.border = QColor("#373737");
t.button = QColor("#373737");
t.text = QColor("#AAA99F");
t.textDim = QColor("#7a7a6e");
t.textMuted = QColor("#555550");
t.textFaint = QColor("#464646");
t.hover = QColor("#373737");
t.selected = QColor("#2d2d2d");
t.selection = QColor("#21213A");
t.syntaxKeyword = QColor("#AA9565");
t.syntaxNumber = QColor("#AAA98C");
t.syntaxString = QColor("#6B3B21");
t.syntaxComment = QColor("#464646");
t.syntaxPreproc = QColor("#AA9565");
t.syntaxType = QColor("#6B959F");
t.indHoverSpan = QColor("#AA9565");
t.indCmdPill = QColor("#2a2a2a");
t.indDataChanged= QColor("#6B959F");
t.indHintGreen = QColor("#464646");
t.markerPtr = QColor("#6B3B21");
t.markerCycle = QColor("#AA9565");
t.markerError = QColor("#3C2121");
return t;
}
} // namespace rcx

View File

@@ -13,6 +13,7 @@ struct Theme {
QColor backgroundAlt; // panels, tab selected, tooltips
QColor surface; // alternateBase
QColor border; // separators, menu borders
QColor borderFocused; // window border when focused
QColor button; // button bg
// ── Text ──
@@ -47,9 +48,18 @@ struct Theme {
QJsonObject toJson() const;
static Theme fromJson(const QJsonObject& obj);
static Theme reclassDark();
static Theme warm();
};
// ── Shared field metadata (serialization + editor UI) ──
struct ThemeFieldMeta {
const char* key; // JSON key
const char* label; // display label
const char* group; // section group name
QColor Theme::*ptr;
};
extern const ThemeFieldMeta kThemeFields[];
extern const int kThemeFieldCount;
} // namespace rcx

View File

@@ -6,6 +6,7 @@
#include <QDialogButtonBox>
#include <QColorDialog>
#include <QComboBox>
#include <cstring>
namespace rcx {
@@ -70,7 +71,7 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
: QStringLiteral("File: %1").arg(path));
mainLayout->addWidget(m_fileInfoLabel);
// ── Scrollable area for swatches + contrast ──
// ── Scrollable area for swatches ──
auto* scroll = new QScrollArea;
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
@@ -79,83 +80,49 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
scrollLayout->setContentsMargins(0, 0, 6, 0); // right margin for scrollbar
scrollLayout->setSpacing(2);
// ── Color swatches ──
struct FieldDef { const char* label; QColor Theme::*ptr; };
// ── Color swatches (driven by kThemeFields) ──
const char* currentGroup = nullptr;
for (int fi = 0; fi < kThemeFieldCount; fi++) {
const auto& f = kThemeFields[fi];
auto addGroup = [&](const QString& title, std::initializer_list<FieldDef> fields) {
scrollLayout->addWidget(makeSectionLabel(title));
for (const auto& f : fields) {
int idx = m_swatches.size();
auto* row = new QHBoxLayout;
row->setSpacing(6);
row->setContentsMargins(8, 1, 0, 1);
auto* lbl = new QLabel(QString::fromLatin1(f.label));
lbl->setFixedWidth(120);
row->addWidget(lbl);
auto* swatchBtn = new QPushButton;
swatchBtn->setFixedSize(32, 18);
swatchBtn->setCursor(Qt::PointingHandCursor);
connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); });
row->addWidget(swatchBtn);
auto* hexLbl = new QLabel;
hexLbl->setFixedWidth(60);
hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;"));
row->addWidget(hexLbl);
row->addStretch();
SwatchEntry se;
se.label = f.label;
se.field = f.ptr;
se.swatchBtn = swatchBtn;
se.hexLabel = hexLbl;
m_swatches.append(se);
scrollLayout->addLayout(row);
// Section header on group change
if (!currentGroup || std::strcmp(currentGroup, f.group) != 0) {
scrollLayout->addWidget(makeSectionLabel(QString::fromLatin1(f.group)));
currentGroup = f.group;
}
};
addGroup("Chrome", {
{"Background", &Theme::background},
{"Background Alt", &Theme::backgroundAlt},
{"Surface", &Theme::surface},
{"Border", &Theme::border},
{"Button", &Theme::button},
});
addGroup("Text", {
{"Text", &Theme::text},
{"Text Dim", &Theme::textDim},
{"Text Muted", &Theme::textMuted},
{"Text Faint", &Theme::textFaint},
});
addGroup("Interactive", {
{"Hover", &Theme::hover},
{"Selected", &Theme::selected},
{"Selection", &Theme::selection},
});
addGroup("Syntax", {
{"Keyword", &Theme::syntaxKeyword},
{"Number", &Theme::syntaxNumber},
{"String", &Theme::syntaxString},
{"Comment", &Theme::syntaxComment},
{"Preprocessor", &Theme::syntaxPreproc},
{"Type", &Theme::syntaxType},
});
addGroup("Indicators", {
{"Hover Span", &Theme::indHoverSpan},
{"Cmd Pill", &Theme::indCmdPill},
{"Data Changed", &Theme::indDataChanged},
{"Hint Green", &Theme::indHintGreen},
});
addGroup("Markers", {
{"Pointer", &Theme::markerPtr},
{"Cycle", &Theme::markerCycle},
{"Error", &Theme::markerError},
});
int idx = m_swatches.size();
auto* row = new QHBoxLayout;
row->setSpacing(6);
row->setContentsMargins(8, 1, 0, 1);
auto* lbl = new QLabel(QString::fromLatin1(f.label));
lbl->setFixedWidth(120);
row->addWidget(lbl);
auto* swatchBtn = new QPushButton;
swatchBtn->setFixedSize(32, 18);
swatchBtn->setCursor(Qt::PointingHandCursor);
connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); });
row->addWidget(swatchBtn);
auto* hexLbl = new QLabel;
hexLbl->setFixedWidth(60);
hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;"));
row->addWidget(hexLbl);
row->addStretch();
SwatchEntry se;
se.label = f.label;
se.field = f.ptr;
se.swatchBtn = swatchBtn;
se.hexLabel = hexLbl;
m_swatches.append(se);
scrollLayout->addLayout(row);
}
scrollLayout->addStretch();
scroll->setWidget(scrollWidget);
@@ -163,28 +130,21 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
// ── Bottom bar ──
auto* bottomRow = new QHBoxLayout;
m_previewBtn = new QPushButton(QStringLiteral("Live Preview"));
m_previewBtn->setCheckable(true);
connect(m_previewBtn, &QPushButton::toggled, this, [this](bool) { togglePreview(); });
bottomRow->addWidget(m_previewBtn);
bottomRow->addStretch();
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, [this]() {
if (m_previewing) {
ThemeManager::instance().revertPreview();
m_previewing = false;
}
ThemeManager::instance().revertPreview();
reject();
});
bottomRow->addWidget(buttons);
mainLayout->addLayout(bottomRow);
// Initial update
// Initial swatch update + start live preview
for (int i = 0; i < m_swatches.size(); i++)
updateSwatch(i);
tm.previewTheme(m_theme);
}
// ── Load a different theme into the editor ──
@@ -206,8 +166,7 @@ void ThemeEditor::loadTheme(int index) {
for (int i = 0; i < m_swatches.size(); i++)
updateSwatch(i);
if (m_previewing)
tm.previewTheme(m_theme);
tm.previewTheme(m_theme);
}
// ── Swatch update ──
@@ -230,19 +189,8 @@ void ThemeEditor::pickColor(int idx) {
if (c.isValid()) {
m_theme.*s.field = c;
updateSwatch(idx);
if (m_previewing)
ThemeManager::instance().previewTheme(m_theme);
ThemeManager::instance().previewTheme(m_theme);
}
}
// ── Live preview toggle ──
void ThemeEditor::togglePreview() {
m_previewing = m_previewBtn->isChecked();
if (m_previewing)
ThemeManager::instance().previewTheme(m_theme);
else
ThemeManager::instance().revertPreview();
}
} // namespace rcx

View File

@@ -36,14 +36,10 @@ private:
QComboBox* m_themeCombo = nullptr;
QLineEdit* m_nameEdit = nullptr;
QLabel* m_fileInfoLabel = nullptr;
QPushButton* m_previewBtn = nullptr;
bool m_previewing = false;
void loadTheme(int index);
void rebuildSwatches(QVBoxLayout* swatchLayout);
void updateSwatch(int idx);
void pickColor(int idx);
void togglePreview();
};
} // namespace rcx

View File

@@ -4,6 +4,7 @@
#include <QFile>
#include <QJsonDocument>
#include <QStandardPaths>
#include <QCoreApplication>
namespace rcx {
@@ -13,18 +14,40 @@ ThemeManager& ThemeManager::instance() {
}
ThemeManager::ThemeManager() {
m_builtIn.append(Theme::reclassDark());
m_builtIn.append(Theme::warm());
loadBuiltInThemes();
loadUserThemes();
QSettings settings("Reclass", "Reclass");
QString saved = settings.value("theme", m_builtIn[0].name).toString();
QString fallback = m_builtIn.isEmpty() ? QString() : m_builtIn[0].name;
QString saved = settings.value("theme", fallback).toString();
auto all = themes();
for (int i = 0; i < all.size(); i++) {
if (all[i].name == saved) { m_currentIdx = i; break; }
}
}
// ── Load built-in themes from JSON files next to the executable ──
QString ThemeManager::builtInDir() const {
return QCoreApplication::applicationDirPath() + "/themes";
}
void ThemeManager::loadBuiltInThemes() {
m_builtIn.clear();
QDir dir(builtInDir());
if (!dir.exists()) return;
for (const QString& name : dir.entryList({"*.json"}, QDir::Files, QDir::Name)) {
QFile f(dir.filePath(name));
if (!f.open(QIODevice::ReadOnly)) continue;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
if (jdoc.isObject())
m_builtIn.append(Theme::fromJson(jdoc.object()));
}
m_builtInDefaults = m_builtIn;
}
// ── themes / current ──
QVector<Theme> ThemeManager::themes() const {
QVector<Theme> all = m_builtIn;
all.append(m_user);
@@ -37,7 +60,10 @@ const Theme& ThemeManager::current() const {
int userIdx = m_currentIdx - m_builtIn.size();
if (userIdx >= 0 && userIdx < m_user.size())
return m_user[userIdx];
return m_builtIn[0];
if (!m_builtIn.isEmpty())
return m_builtIn[0];
static const Theme empty;
return empty;
}
void ThemeManager::setCurrent(int index) {
@@ -55,17 +81,20 @@ void ThemeManager::addTheme(const Theme& theme) {
}
void ThemeManager::updateTheme(int index, const Theme& theme) {
m_previewing = false; // commit any active preview
if (index < builtInCount()) {
// Can't overwrite built-in; save as user theme instead
m_user.append(theme);
m_builtIn[index] = theme;
m_currentIdx = index;
} else {
int ui = index - builtInCount();
if (ui >= 0 && ui < m_user.size())
m_user[ui] = theme;
}
saveUserThemes();
if (index == m_currentIdx)
emit themeChanged(current());
QSettings settings("Reclass", "Reclass");
settings.setValue("theme", current().name);
emit themeChanged(current());
}
void ThemeManager::removeTheme(int index) {
@@ -82,7 +111,9 @@ void ThemeManager::removeTheme(int index) {
saveUserThemes();
}
QString ThemeManager::themesDir() const {
// ── User theme persistence ──
QString ThemeManager::userDir() const {
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ "/themes";
QDir().mkpath(dir);
@@ -91,37 +122,69 @@ QString ThemeManager::themesDir() const {
void ThemeManager::loadUserThemes() {
m_user.clear();
QDir dir(themesDir());
QDir dir(userDir());
for (const QString& name : dir.entryList({"*.json"}, QDir::Files)) {
QFile f(dir.filePath(name));
if (!f.open(QIODevice::ReadOnly)) continue;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
if (jdoc.isObject())
m_user.append(Theme::fromJson(jdoc.object()));
if (!jdoc.isObject()) continue;
Theme t = Theme::fromJson(jdoc.object());
// If this overrides a built-in (same name), replace it in-place
bool isOverride = false;
for (int i = 0; i < m_builtIn.size(); i++) {
if (m_builtIn[i].name == t.name) {
m_builtIn[i] = t;
isOverride = true;
break;
}
}
if (!isOverride)
m_user.append(t);
}
}
void ThemeManager::saveUserThemes() const {
QString dir = themesDir();
// Remove old files
QString dir = userDir();
QDir d(dir);
for (const QString& name : d.entryList({"*.json"}, QDir::Files))
d.remove(name);
// Write current user themes
// Save modified built-ins (compare against on-disk originals)
for (int i = 0; i < m_builtIn.size() && i < m_builtInDefaults.size(); i++) {
if (m_builtIn[i].toJson() != m_builtInDefaults[i].toJson()) {
QString filename = m_builtIn[i].name.toLower().replace(' ', '_') + ".json";
QFile f(dir + "/" + filename);
if (f.open(QIODevice::WriteOnly))
f.write(QJsonDocument(m_builtIn[i].toJson()).toJson(QJsonDocument::Indented));
}
}
// Save user themes
for (int i = 0; i < m_user.size(); i++) {
QString filename = m_user[i].name.toLower().replace(' ', '_') + ".json";
QFile f(dir + "/" + filename);
if (!f.open(QIODevice::WriteOnly)) continue;
f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented));
if (f.open(QIODevice::WriteOnly))
f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented));
}
}
QString ThemeManager::themeFilePath(int index) const {
if (index < builtInCount()) return {};
if (index < builtInCount()) {
// Built-in has a user override file only if modified
if (index < m_builtInDefaults.size()
&& m_builtIn[index].toJson() != m_builtInDefaults[index].toJson()) {
QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json";
return userDir() + "/" + filename;
}
// Show the built-in source file
QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json";
return builtInDir() + "/" + filename;
}
int ui = index - builtInCount();
if (ui < 0 || ui >= m_user.size()) return {};
QString filename = m_user[ui].name.toLower().replace(' ', '_') + ".json";
return themesDir() + "/" + filename;
return userDir() + "/" + filename;
}
void ThemeManager::previewTheme(const Theme& theme) {

View File

@@ -31,14 +31,17 @@ signals:
private:
ThemeManager();
QVector<Theme> m_builtIn;
QVector<Theme> m_builtIn; // built-in themes (possibly overridden)
QVector<Theme> m_builtInDefaults; // originals loaded from disk
QVector<Theme> m_user;
int m_currentIdx = 0;
int builtInCount() const { return m_builtIn.size(); }
QString themesDir() const;
void loadBuiltInThemes();
QString builtInDir() const;
QString userDir() const;
bool m_previewing = false;
Theme m_savedTheme; // stashed current theme during preview
Theme m_savedTheme;
};
} // namespace rcx

186
src/titlebar.cpp Normal file
View File

@@ -0,0 +1,186 @@
#include "titlebar.h"
#include "themes/thememanager.h"
#include <QMouseEvent>
#include <QPainter>
#include <QStyle>
#include <QWindow>
namespace rcx {
TitleBarWidget::TitleBarWidget(QWidget* parent)
: QWidget(parent)
, m_theme(ThemeManager::instance().current())
{
setFixedHeight(32);
auto* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
// App name
m_appLabel = new QLabel(QStringLiteral("Reclass"), this);
m_appLabel->setContentsMargins(10, 0, 4, 0);
m_appLabel->setAlignment(Qt::AlignVCenter);
m_appLabel->setAttribute(Qt::WA_TransparentForMouseEvents);
layout->addWidget(m_appLabel);
// Menu bar
m_menuBar = new QMenuBar(this);
m_menuBar->setNativeMenuBar(false);
m_menuBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding);
layout->addWidget(m_menuBar);
layout->addStretch();
// Chrome buttons
m_btnMin = makeChromeButton(":/vsicons/chrome-minimize.svg");
m_btnMax = makeChromeButton(":/vsicons/chrome-maximize.svg");
m_btnClose = makeChromeButton(":/vsicons/chrome-close.svg");
layout->addWidget(m_btnMin);
layout->addWidget(m_btnMax);
layout->addWidget(m_btnClose);
connect(m_btnMin, &QToolButton::clicked, this, [this]() {
window()->showMinimized();
});
connect(m_btnMax, &QToolButton::clicked, this, [this]() {
toggleMaximize();
});
connect(m_btnClose, &QToolButton::clicked, this, [this]() {
window()->close();
});
}
QToolButton* TitleBarWidget::makeChromeButton(const QString& iconPath) {
auto* btn = new QToolButton(this);
btn->setIcon(QIcon(iconPath));
btn->setIconSize(QSize(16, 16));
btn->setFixedSize(46, 32);
btn->setAutoRaise(true);
btn->setFocusPolicy(Qt::NoFocus);
return btn;
}
void TitleBarWidget::applyTheme(const Theme& theme) {
m_theme = theme;
// Title bar background
setAutoFillBackground(true);
QPalette pal = palette();
pal.setColor(QPalette::Window, theme.background);
setPalette(pal);
// App label
m_appLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(theme.textDim.name()));
// Menu bar styling — transparent background, themed text
m_menuBar->setStyleSheet(
QStringLiteral(
"QMenuBar { background: transparent; border: none; }"
"QMenuBar::item { background: transparent; color: %1; padding: 8px 8px 4px 8px; }"
"QMenuBar::item:selected { background: %2; }"
"QMenuBar::item:pressed { background: %2; }")
.arg(theme.textDim.name(), theme.hover.name()));
// Chrome buttons
QString btnStyle = QStringLiteral(
"QToolButton { background: transparent; border: none; }"
"QToolButton:hover { background: %1; }")
.arg(theme.hover.name());
m_btnMin->setStyleSheet(btnStyle);
m_btnMax->setStyleSheet(btnStyle);
// Close button: red hover
m_btnClose->setStyleSheet(QStringLiteral(
"QToolButton { background: transparent; border: none; }"
"QToolButton:hover { background: #c42b1c; }"));
update();
}
void TitleBarWidget::setShowIcon(bool show) {
if (show) {
m_appLabel->setText(QString());
m_appLabel->setPixmap(QIcon(":/icons/class.png").pixmap(24, 24));
} else {
m_appLabel->setPixmap(QPixmap());
m_appLabel->setText(QStringLiteral("Reclass"));
m_appLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(m_theme.textDim.name()));
}
}
void TitleBarWidget::setMenuBarTitleCase(bool titleCase) {
m_titleCase = titleCase;
for (QAction* action : m_menuBar->actions()) {
QString text = action->text();
QString clean = text;
clean.remove('&');
if (titleCase) {
QString result;
bool capitalizeNext = true;
for (int i = 0; i < clean.length(); ++i) {
QChar ch = clean[i];
if (ch.isLetter()) {
result += capitalizeNext ? ch.toUpper() : ch.toLower();
capitalizeNext = false;
} else {
result += ch;
if (ch.isSpace()) capitalizeNext = true;
}
}
action->setText("&" + result);
} else {
action->setText("&" + clean.toUpper());
}
}
}
void TitleBarWidget::updateMaximizeIcon() {
if (window()->isMaximized())
m_btnMax->setIcon(QIcon(":/vsicons/chrome-restore.svg"));
else
m_btnMax->setIcon(QIcon(":/vsicons/chrome-maximize.svg"));
}
void TitleBarWidget::toggleMaximize() {
if (window()->isMaximized())
window()->showNormal();
else
window()->showMaximized();
updateMaximizeIcon();
}
void TitleBarWidget::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
window()->windowHandle()->startSystemMove();
event->accept();
} else {
QWidget::mousePressEvent(event);
}
}
void TitleBarWidget::mouseDoubleClickEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
toggleMaximize();
event->accept();
} else {
QWidget::mouseDoubleClickEvent(event);
}
}
void TitleBarWidget::paintEvent(QPaintEvent* event) {
QWidget::paintEvent(event);
// 1px bottom border
QPainter p(this);
p.setPen(m_theme.border);
p.drawLine(0, height() - 1, width() - 1, height() - 1);
}
} // namespace rcx

43
src/titlebar.h Normal file
View File

@@ -0,0 +1,43 @@
#pragma once
#include "themes/theme.h"
#include <QWidget>
#include <QMenuBar>
#include <QToolButton>
#include <QLabel>
#include <QHBoxLayout>
namespace rcx {
class TitleBarWidget : public QWidget {
Q_OBJECT
public:
explicit TitleBarWidget(QWidget* parent = nullptr);
QMenuBar* menuBar() const { return m_menuBar; }
void applyTheme(const Theme& theme);
void setShowIcon(bool show);
void setMenuBarTitleCase(bool titleCase);
bool menuBarTitleCase() const { return m_titleCase; }
void updateMaximizeIcon();
protected:
void mousePressEvent(QMouseEvent* event) override;
void mouseDoubleClickEvent(QMouseEvent* event) override;
void paintEvent(QPaintEvent* event) override;
private:
QLabel* m_appLabel = nullptr;
QMenuBar* m_menuBar = nullptr;
QToolButton* m_btnMin = nullptr;
QToolButton* m_btnMax = nullptr;
QToolButton* m_btnClose = nullptr;
Theme m_theme;
bool m_titleCase = true;
QToolButton* makeChromeButton(const QString& iconPath);
void toggleMaximize();
};
} // namespace rcx

View File

@@ -16,6 +16,7 @@
#include <QApplication>
#include <QScreen>
#include <QIntValidator>
#include <QElapsedTimer>
#include "themes/thememanager.h"
namespace rcx {
@@ -121,7 +122,7 @@ public:
return;
}
// 18px gutter: side triangle if current
// Gutter: side triangle if current
if (m_hasCurrent && m_filtered && row >= 0 && row < m_filtered->size()) {
const TypeEntry& entry = (*m_filtered)[row];
bool isCurrent = false;
@@ -130,13 +131,13 @@ public:
else if (m_current->entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite)
isCurrent = (entry.structId == m_current->structId);
if (isCurrent) {
painter->setPen(t.syntaxType);
painter->setPen(t.text);
painter->setFont(m_font);
painter->drawText(QRect(x, y, 18, h), Qt::AlignCenter,
painter->drawText(QRect(x, y, 10, h), Qt::AlignCenter,
QString(QChar(0x25B8)));
}
}
x += 18;
x += 10;
// Icon 16x16 — only for composite entries
bool hasIcon = (m_filtered && row >= 0 && row < m_filtered->size()
@@ -368,6 +369,7 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
m_listView->setFrameShape(QFrame::NoFrame);
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setMouseTracking(true);
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_listView->viewport()->setAttribute(Qt::WA_Hover, true);
m_listView->installEventFilter(this);
@@ -384,10 +386,33 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
}
void TypeSelectorPopup::warmUp() {
// One-time per-process cost (~170ms): Qt lazily initializes the style/font/DLL
// subsystem the first time a popup with complex children is shown. Pre-pay it
// by briefly showing a throwaway dummy popup with a QListView, then show+hide
// ourselves.
{
auto* primer = new QFrame(nullptr, Qt::Popup | Qt::FramelessWindowHint);
primer->resize(300, 400);
auto* lay = new QVBoxLayout(primer);
lay->addWidget(new QLabel(QStringLiteral("x")));
lay->addWidget(new QLineEdit);
auto* model = new QStringListModel(primer);
QStringList items; for (int i = 0; i < 10; i++) items << QStringLiteral("x");
model->setStringList(items);
auto* lv = new QListView;
lv->setModel(model);
lay->addWidget(lv);
primer->show();
QApplication::processEvents();
primer->hide();
QApplication::processEvents();
delete primer;
}
TypeEntry dummy;
dummy.entryKind = TypeEntry::Primitive;
dummy.primitiveKind = NodeKind::Hex8;
dummy.displayName = "warmup";
dummy.displayName = QStringLiteral("warmup");
setTypes({dummy});
popup(QPoint(-9999, -9999));
hide();
@@ -467,7 +492,7 @@ void TypeSelectorPopup::popup(const QPoint& globalPos) {
QString text = t.classKeyword.isEmpty()
? t.displayName
: (t.classKeyword + QStringLiteral(" ") + t.displayName);
int w = 18 + 20 + fm.horizontalAdvance(text) + 16;
int w = 10 + 20 + fm.horizontalAdvance(text) + 16;
if (w > maxTextW) maxTextW = w;
}
int popupW = qBound(280, maxTextW + 24, 500);

View File

@@ -1,62 +1,76 @@
#pragma once
#include "core.h"
#include <QIcon>
#include <QStandardItemModel>
#include <QStandardItem>
#include <algorithm>
namespace rcx {
// Recursively add children of parentId as tree items under parentItem.
inline void addWorkspaceChildren(QStandardItem* parentItem,
const NodeTree& tree,
uint64_t parentId,
void* subPtr) {
QVector<int> children = tree.childrenOf(parentId);
std::sort(children.begin(), children.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
struct TabInfo {
const NodeTree* tree;
QString name;
void* subPtr; // QMdiSubWindow* as void*
};
for (int idx : children) {
const Node& node = tree.nodes[idx];
// Sentinel value stored in UserRole+1 to mark the Project group node.
static constexpr uint64_t kGroupSentinel = ~uint64_t(0);
// Skip hex preview nodes — they are padding/filler, not meaningful fields
if (isHexNode(node.kind)) continue;
QString display;
if (node.kind == NodeKind::Struct) {
QString typeName = node.structTypeName.isEmpty()
? node.name : node.structTypeName;
display = QStringLiteral("%1 (%2)")
.arg(typeName, node.resolvedClassKeyword());
} else {
display = QStringLiteral("%1 (%2)")
.arg(node.name, QString::fromLatin1(kindToString(node.kind)));
}
auto* item = new QStandardItem(display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
if (node.kind == NodeKind::Struct)
item->setData(QVariant::fromValue(node.id), Qt::UserRole + 1);
item->setData(QVariant::fromValue(node.id), Qt::UserRole + 2); // nodeId for scroll
if (node.kind == NodeKind::Struct)
addWorkspaceChildren(item, tree, node.id, subPtr);
parentItem->appendRow(item);
}
}
inline void buildWorkspaceModel(QStandardItemModel* model,
const NodeTree& tree,
const QString& projectName,
void* subPtr = nullptr) {
inline void buildProjectExplorer(QStandardItemModel* model,
const QVector<TabInfo>& tabs) {
model->clear();
model->setHorizontalHeaderLabels({QStringLiteral("Name")});
auto* projectItem = new QStandardItem(projectName);
projectItem->setData(QVariant::fromValue(subPtr), Qt::UserRole);
// Single "Project" root with folder icon
void* firstSub = tabs.isEmpty() ? nullptr : tabs[0].subPtr;
auto* projectItem = new QStandardItem(QIcon(":/vsicons/folder.svg"),
QStringLiteral("Project"));
projectItem->setData(QVariant::fromValue(firstSub), Qt::UserRole);
projectItem->setData(QVariant::fromValue(kGroupSentinel), Qt::UserRole + 1);
addWorkspaceChildren(projectItem, tree, 0, subPtr);
// Collect all top-level structs/enums across all tabs
QVector<std::pair<const Node*, void*>> types, enums;
for (const auto& tab : tabs) {
QVector<int> topLevel = tab.tree->childrenOf(0);
for (int idx : topLevel) {
const Node& n = tab.tree->nodes[idx];
if (n.kind != NodeKind::Struct) continue;
if (n.resolvedClassKeyword() == QStringLiteral("enum"))
enums.append({&n, tab.subPtr});
else
types.append({&n, tab.subPtr});
}
}
auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
};
auto cmpName = [&](const std::pair<const Node*, void*>& a,
const std::pair<const Node*, void*>& b) {
return nameOf(a.first).compare(nameOf(b.first), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpName);
std::sort(enums.begin(), enums.end(), cmpName);
for (const auto& [n, subPtr] : types) {
QString display = QStringLiteral("%1 (%2)")
.arg(nameOf(n), n->resolvedClassKeyword());
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-structure.svg"), display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(n->id), Qt::UserRole + 1);
projectItem->appendRow(item);
}
for (const auto& [n, subPtr] : enums) {
QString display = QStringLiteral("%1 (%2)")
.arg(nameOf(n), n->resolvedClassKeyword());
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-enum.svg"), display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(n->id), Qt::UserRole + 1);
projectItem->appendRow(item);
}
model->appendRow(projectItem);
}

185
tests/test_com_security.cpp Normal file
View File

@@ -0,0 +1,185 @@
/**
* test_com_security.cpp — DebugConnect transport diagnostic
*
* Tests EVERY transport to find what works from MinGW:
* 1. TCP to WinDbg .server (port 5055)
* 2. Named pipe to WinDbg .server
* 3. TCP with various COM security configs
* 4. DebugCreate local (baseline)
*
* SETUP: In WinDbg, run BOTH of these:
* .server tcp:port=5055
* .server npipe:pipe=reclass
*
* Then run this test.
*/
#include <cstdio>
#include <cstdlib>
#include <cstring>
#ifdef _WIN32
#include <windows.h>
#include <objbase.h>
#include <initguid.h>
#include <dbgeng.h>
#endif
#ifdef _WIN32
static void try_connect(const char* label, const char* connStr)
{
printf(" %-40s → ", label);
fflush(stdout);
IDebugClient* client = nullptr;
HRESULT hr = DebugConnect(connStr, IID_IDebugClient, (void**)&client);
if (SUCCEEDED(hr) && client) {
printf("SUCCESS (hr=0x%08lX)\n", (unsigned long)hr);
// Try to get data spaces and read something
IDebugDataSpaces* ds = nullptr;
IDebugSymbols* sym = nullptr;
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
HRESULT hrWait = ctrl->WaitForEvent(0, 5000);
printf(" WaitForEvent: hr=0x%08lX\n", (unsigned long)hrWait);
}
if (sym) {
ULONG numMods = 0, numUnloaded = 0;
sym->GetNumberModules(&numMods, &numUnloaded);
printf(" Modules: %lu loaded\n", numMods);
if (numMods > 0 && ds) {
ULONG64 base = 0;
sym->GetModuleByIndex(0, &base);
unsigned char buf[2] = {};
ULONG got = 0;
ds->ReadVirtual(base, buf, 2, &got);
printf(" Read at 0x%llX: got=%lu bytes=[%02X %02X]\n",
(unsigned long long)base, got, buf[0], buf[1]);
}
}
if (sym) sym->Release();
if (ds) ds->Release();
if (ctrl) ctrl->Release();
client->Release();
} else {
char buf[256] = {};
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr, (DWORD)hr, 0, buf, sizeof(buf), nullptr);
for (char* p = buf + strlen(buf) - 1; p >= buf && (*p == '\r' || *p == '\n'); --p)
*p = '\0';
printf("FAIL hr=0x%08lX (%s)\n", (unsigned long)hr, buf);
}
}
#endif
int main()
{
#ifdef _WIN32
char hostname[256] = {};
DWORD hsize = sizeof(hostname);
GetComputerNameA(hostname, &hsize);
printf("=== DebugConnect Transport Diagnostic ===\n");
printf("Machine: %s\n\n", hostname);
// ── Baseline: DebugCreate (local) ──
printf("[1] DebugCreate (local, no network)\n");
{
IDebugClient* client = nullptr;
HRESULT hr = DebugCreate(IID_IDebugClient, (void**)&client);
printf(" DebugCreate: %s (hr=0x%08lX)\n\n",
SUCCEEDED(hr) ? "OK" : "FAIL", (unsigned long)hr);
if (client) client->Release();
}
// ── TCP variants ──
printf("[2] TCP connections (need: .server tcp:port=5055)\n");
try_connect("tcp:Port=5055,Server=localhost",
"tcp:Port=5055,Server=localhost");
try_connect("tcp:Port=5055,Server=127.0.0.1",
"tcp:Port=5055,Server=127.0.0.1");
{
char conn[512];
snprintf(conn, sizeof(conn), "tcp:Port=5055,Server=%s", hostname);
try_connect(conn, conn);
}
printf("\n");
// ── Named pipe variants ──
printf("[3] Named pipe connections (need: .server npipe:pipe=reclass)\n");
try_connect("npipe:Pipe=reclass,Server=localhost",
"npipe:Pipe=reclass,Server=localhost");
{
char conn[512];
snprintf(conn, sizeof(conn), "npipe:Pipe=reclass,Server=%s", hostname);
try_connect(conn, conn);
}
try_connect("npipe:Pipe=reclass",
"npipe:Pipe=reclass");
printf("\n");
// ── TCP with COM security ──
printf("[4] TCP with explicit COM init (MTA + IMPERSONATE)\n");
{
// This runs in-process so CoInitialize affects subsequent calls
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
CoInitializeSecurity(
nullptr, -1, nullptr, nullptr,
RPC_C_AUTHN_LEVEL_DEFAULT,
RPC_C_IMP_LEVEL_IMPERSONATE,
nullptr, EOAC_NONE, nullptr);
try_connect("tcp:Port=5055,Server=localhost (MTA+SEC)",
"tcp:Port=5055,Server=localhost");
try_connect("npipe:Pipe=reclass (MTA+SEC)",
"npipe:Pipe=reclass,Server=localhost");
CoUninitialize();
}
printf("\n");
// ── Check if dbgeng.dll is the system one ──
printf("[5] DbgEng DLL info\n");
{
HMODULE hmod = GetModuleHandleA("dbgeng.dll");
if (hmod) {
char path[MAX_PATH] = {};
GetModuleFileNameA(hmod, path, MAX_PATH);
printf(" dbgeng.dll loaded from: %s\n", path);
// Get version
DWORD verSize = GetFileVersionInfoSizeA(path, nullptr);
if (verSize > 0) {
auto* verData = (char*)malloc(verSize);
if (GetFileVersionInfoA(path, 0, verSize, verData)) {
VS_FIXEDFILEINFO* fileInfo = nullptr;
UINT len = 0;
if (VerQueryValueA(verData, "\\", (void**)&fileInfo, &len)) {
printf(" Version: %d.%d.%d.%d\n",
HIWORD(fileInfo->dwFileVersionMS),
LOWORD(fileInfo->dwFileVersionMS),
HIWORD(fileInfo->dwFileVersionLS),
LOWORD(fileInfo->dwFileVersionLS));
}
}
free(verData);
}
} else {
printf(" dbgeng.dll not loaded yet\n");
}
}
printf("\n=== Done ===\n");
return 0;
#else
printf("Windows only.\n");
return 0;
#endif
}

View File

@@ -89,7 +89,7 @@ private slots:
QCOMPARE(result.meta[2].lineKind, LineKind::Footer);
}
void testPaddingMarker() {
void testHexNodeCompose() {
NodeTree tree;
tree.baseAddress = 0;
@@ -100,19 +100,18 @@ private slots:
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node pad;
pad.kind = NodeKind::Padding;
pad.name = "pad";
pad.parentId = rootId;
pad.offset = 0;
tree.addNode(pad);
Node hex;
hex.kind = NodeKind::Hex8;
hex.name = "pad";
hex.parentId = rootId;
hex.offset = 0;
tree.addNode(hex);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// CommandRow + padding + root footer = 3
// CommandRow + hex node + root footer = 3
QCOMPARE(result.meta.size(), 3);
QVERIFY(result.meta[1].markerMask & (1u << M_PAD));
QCOMPARE(result.meta[1].depth, 1);
// Line 2 is root footer

View File

@@ -34,9 +34,8 @@ static void buildSmallTree(NodeTree& tree) {
field(0, NodeKind::UInt32, "field_u32"); // 4 bytes
field(4, NodeKind::Float, "field_float"); // 4 bytes
field(8, NodeKind::UInt8, "field_u8"); // 1 byte
field(9, NodeKind::Padding, "pad0"); // 3 bytes padding
// Set padding arrayLen = 3 for 3-byte padding
tree.nodes.last().arrayLen = 3;
field(9, NodeKind::Hex16, "pad0"); // 2 bytes
field(11, NodeKind::Hex8, "pad1"); // 1 byte
field(12, NodeKind::Hex32, "field_hex"); // 4 bytes
}
@@ -282,47 +281,6 @@ private slots:
QVERIFY(newIdx >= 0);
}
// ── Test: Padding value edit is effectively blocked at controller level ──
void testPaddingValueEditIsBlocked() {
// Find the padding node
int padIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].kind == NodeKind::Padding) { padIdx = i; break; }
}
QVERIFY(padIdx >= 0);
uint64_t addr = m_doc->tree.computeOffset(padIdx);
// Read original data at padding offset
int padSize = m_doc->tree.nodes[padIdx].byteSize();
QByteArray origData = m_doc->provider->readBytes(addr, padSize);
// The context menu blocks Padding editing, so the controller's setNodeValue
// would only be called if the editing UI somehow allows it. But let's verify
// the editor correctly blocks it.
// Find padding line in composed output
ComposeResult result = m_doc->compose();
int paddingLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::Padding &&
result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
m_editor->applyDocument(result);
QApplication::processEvents();
// beginInlineEdit(Value) on Padding line must be rejected
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
// Data must be unchanged
QByteArray afterData = m_doc->provider->readBytes(addr, padSize);
QCOMPARE(afterData, origData);
}
// ── Test: setNodeValue with Hex32 (space-separated hex bytes) ──
void testSetNodeValueHex() {
int idx = -1;

65
tests/test_dbgconnect.cpp Normal file
View File

@@ -0,0 +1,65 @@
#include <cstdio>
#include <cstdint>
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
int main()
{
const char* connStr = "tcp:Port=5057,Server=localhost";
printf("Attempting DebugConnect(\"%s\")...\n", connStr);
IDebugClient* client = nullptr;
HRESULT hr = DebugConnect(connStr, IID_IDebugClient, (void**)&client);
printf("DebugConnect returned: 0x%08lX\n", hr);
if (SUCCEEDED(hr) && client) {
printf("Connected! Getting IDebugDataSpaces...\n");
IDebugDataSpaces* ds = nullptr;
hr = client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
printf("QueryInterface(IDebugDataSpaces) = 0x%08lX\n", hr);
if (ds) {
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
printf("Waiting for event...\n");
hr = ctrl->WaitForEvent(0, 5000);
printf("WaitForEvent = 0x%08lX\n", hr);
ctrl->Release();
}
// Try to read 2 bytes
IDebugSymbols* sym = nullptr;
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
if (sym) {
ULONG numMods = 0, numUnloaded = 0;
hr = sym->GetNumberModules(&numMods, &numUnloaded);
printf("GetNumberModules = 0x%08lX, numMods=%lu\n", hr, numMods);
if (numMods > 0) {
ULONG64 base = 0;
hr = sym->GetModuleByIndex(0, &base);
printf("Module[0] base = 0x%llX (hr=0x%08lX)\n", base, hr);
if (SUCCEEDED(hr) && base) {
uint8_t buf[4] = {};
ULONG got = 0;
hr = ds->ReadVirtual(base, buf, 4, &got);
printf("ReadVirtual(%llX, 4) = 0x%08lX, got=%lu, data=[%02X %02X %02X %02X]\n",
base, hr, got, buf[0], buf[1], buf[2], buf[3]);
}
}
sym->Release();
}
ds->Release();
}
client->Release();
} else {
printf("DebugConnect FAILED. hr=0x%08lX\n", hr);
}
return 0;
}

View File

@@ -170,9 +170,10 @@ static NodeTree makeTestTree() {
n.parentId = rootId; n.offset = off;
tree.addNode(n);
};
auto pad = [&](int off, int len, const char* name) {
Node n; n.kind = NodeKind::Padding; n.name = name;
n.parentId = rootId; n.offset = off; n.arrayLen = len;
auto pad = [&](int off, int /*len*/, const char* name) {
// 4-byte padding → Hex32 (all usages in this test pass len=4)
Node n; n.kind = NodeKind::Hex32; n.name = name;
n.parentId = rootId; n.offset = off;
tree.addNode(n);
};
auto arr = [&](int off, NodeKind ek, int len, const char* name) {
@@ -278,8 +279,8 @@ static NodeTree makeTestTree() {
n.kind = NodeKind::UInt16; n.name = "Length"; n.offset = 0; tree.addNode(n);
n.kind = NodeKind::UInt16; n.name = "MaximumLength"; n.offset = 2; tree.addNode(n);
n.kind = NodeKind::Padding; n.name = "Pad";
n.offset = 4; n.arrayLen = 4; tree.addNode(n);
n.kind = NodeKind::Hex32; n.name = "Pad";
n.offset = 4; n.arrayLen = 1; tree.addNode(n);
n.kind = NodeKind::Pointer64; n.name = "Buffer"; n.offset = 8; n.arrayLen = 1;
tree.addNode(n);
}
@@ -751,70 +752,6 @@ private slots:
m_editor->applyDocument(m_result);
}
// ── Test: Padding line rejects value editing ──
void testPaddingLineRejectsValueEdit() {
m_editor->applyDocument(m_result);
// Find a Padding line in the composed output
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY2(paddingLine >= 0, "Should have at least one Padding line in test tree");
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
QCOMPARE(lm->nodeKind, NodeKind::Padding);
// Value edit on Padding MUST be rejected (the bug fix)
QVERIFY2(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine),
"Value edit should be rejected on Padding lines");
QVERIFY(!m_editor->isEditing());
// Name edit on Padding SHOULD succeed (ASCII preview column is editable)
bool ok = m_editor->beginInlineEdit(EditTarget::Name, paddingLine);
QVERIFY2(ok, "Name edit should be allowed on Padding lines (ASCII preview)");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
// Type edit on Padding SHOULD succeed (emits popup signal)
QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested);
ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine);
QVERIFY2(ok, "Type edit should be allowed on Padding lines");
QCOMPARE(typeSpy.count(), 1);
}
// ── Test: resolvedSpanFor rejects Value on Padding (defense-in-depth) ──
void testPaddingLineRejectsValueSpan() {
m_editor->applyDocument(m_result);
// Find a Padding line
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
// valueSpanFor returns valid (shared with Hex via KF_HexPreview)
ColumnSpan vs = RcxEditor::valueSpan(*lm, 200);
QVERIFY2(vs.valid, "valueSpanFor should return valid for Padding (shared HexPreview flag)");
// But beginInlineEdit should still reject it
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
}
// ── Test: value edit commit fires signal with typed text ──
void testValueEditCommitUpdatesSignal() {
m_editor->applyDocument(m_result);
@@ -823,8 +760,6 @@ private slots:
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field);
QVERIFY(lm->nodeKind != NodeKind::Padding);
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine);
QVERIFY(ok);

View File

@@ -418,30 +418,6 @@ private slots:
QVERIFY(result.contains("wchar_t wname[32];"));
}
// ── Padding node ──
void testPaddingNode() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "PadTest";
root.structTypeName = "PadTest";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node pad;
pad.kind = rcx::NodeKind::Padding;
pad.name = "reserved";
pad.parentId = rootId;
pad.offset = 0;
pad.arrayLen = 16;
tree.addNode(pad);
QString result = rcx::renderCpp(tree, rootId);
QVERIFY(result.contains("uint8_t reserved[16];"));
}
// ── Full SDK export (multiple root structs) ──
void testFullSdkExport() {

View File

@@ -304,39 +304,6 @@ private slots:
QVERIFY(result.contains("float speed;"));
}
void testGenerator_typeAliases_padding() {
// Padding gap and tail padding should use aliased uint8_t
NodeTree tree;
Node root;
root.kind = NodeKind::Struct;
root.name = "PadTest";
root.structTypeName = "PadTest";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f1;
f1.kind = NodeKind::UInt32;
f1.name = "a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
Node f2;
f2.kind = NodeKind::UInt32;
f2.name = "b";
f2.parentId = rootId;
f2.offset = 8; // gap of 4 bytes at offset 4
tree.addNode(f2);
QHash<NodeKind, QString> aliases;
aliases[NodeKind::Padding] = "BYTE";
QString result = renderCpp(tree, rootId, &aliases);
// Padding gap should use the alias
QVERIFY(result.contains("BYTE _pad"));
}
void testGenerator_typeAliases_array() {
// Array element type should use alias
NodeTree tree;
@@ -547,134 +514,92 @@ private slots:
void testWorkspace_simpleTree() {
auto tree = makeSimpleTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "TestProject.rcx");
QVector<TabInfo> tabs = {{ &tree, "TestProject.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// 1 top-level item (the project)
// Single "Project" root
QCOMPARE(model.rowCount(), 1);
QStandardItem* project = model.item(0);
QCOMPARE(project->text(), QString("TestProject.rcx"));
QCOMPARE(project->text(), QString("Project"));
// Project has 1 child: the Player struct
// 1 type directly under Project: Player (no member fields)
QCOMPARE(project->rowCount(), 1);
QStandardItem* player = project->child(0);
QVERIFY(player->text().contains("Player"));
QVERIFY(player->text().contains("struct"));
// Player struct has 2 children: health, speed
QCOMPARE(player->rowCount(), 2);
QVERIFY(player->child(0)->text().contains("health"));
QVERIFY(player->child(1)->text().contains("speed"));
QVERIFY(project->child(0)->text().contains("Player"));
QVERIFY(project->child(0)->text().contains("struct"));
QCOMPARE(project->child(0)->rowCount(), 0);
}
void testWorkspace_twoRootTree() {
auto tree = makeTwoRootTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "TwoRoot.rcx");
QVector<TabInfo> tabs = {{ &tree, "TwoRoot.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QCOMPARE(model.rowCount(), 1);
QStandardItem* project = model.item(0);
// 2 root struct children: Alpha and Bravo
// 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"));
// Each has 1 field child
QCOMPARE(project->child(0)->rowCount(), 1);
QVERIFY(project->child(0)->child(0)->text().contains("flagsA"));
QCOMPARE(project->child(1)->rowCount(), 1);
QVERIFY(project->child(1)->child(0)->text().contains("flagsB"));
QCOMPARE(project->child(0)->rowCount(), 0);
QCOMPARE(project->child(1)->rowCount(), 0);
}
void testWorkspace_richTree_rootCount() {
auto tree = makeRichTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx");
QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0);
QCOMPARE(project->rowCount(), 3); // Pet, Cat, Ball
QCOMPARE(project->rowCount(), 3); // Ball, Cat, Pet (sorted)
}
void testWorkspace_richTree_petChildren() {
void testWorkspace_richTree_sorted() {
auto tree = makeRichTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx");
QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* pet = model.item(0)->child(0);
QVERIFY(pet->text().contains("Pet"));
// Pet has 2 non-hex children: name (UTF8), owner (Pointer64)
QCOMPARE(pet->rowCount(), 2);
QVERIFY(pet->child(0)->text().contains("name"));
QVERIFY(pet->child(1)->text().contains("owner"));
}
void testWorkspace_richTree_catNesting() {
auto tree = makeRichTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx");
QStandardItem* cat = model.item(0)->child(1);
QVERIFY(cat->text().contains("Cat"));
// Find the nested "Pet" struct child (base)
QStandardItem* base = nullptr;
for (int i = 0; i < cat->rowCount(); i++) {
if (cat->child(i)->text().contains("Pet") &&
cat->child(i)->text().contains("struct")) {
base = cat->child(i);
break;
}
}
QVERIFY2(base != nullptr, "Cat should have a nested Pet struct child");
// base has structId set
QVERIFY(base->data(Qt::UserRole + 1).isValid());
// base should have its own children (name + owner)
QCOMPARE(base->rowCount(), 2);
}
void testWorkspace_richTree_ballChildren() {
auto tree = makeRichTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx");
QStandardItem* ball = model.item(0)->child(2);
QVERIFY(ball->text().contains("Ball"));
// Ball has 3 non-hex children: speed, position, color
QCOMPARE(ball->rowCount(), 3);
QVERIFY(ball->child(0)->text().contains("speed"));
QVERIFY(ball->child(1)->text().contains("position"));
QVERIFY(ball->child(2)->text().contains("color"));
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);
}
void testWorkspace_emptyTree() {
NodeTree tree;
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Empty.rcx");
QVector<TabInfo> 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);
}
void testWorkspace_structIdRole() {
auto tree = makeSimpleTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Test.rcx");
QVector<TabInfo> tabs = {{ &tree, "Test.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0);
// Project item should NOT have structId
QVERIFY(!project->data(Qt::UserRole + 1).isValid());
// Project root has kGroupSentinel
QCOMPARE(project->data(Qt::UserRole + 1).toULongLong(), kGroupSentinel);
// Player struct should have structId
// Player type item should have structId
QStandardItem* player = project->child(0);
QVERIFY(player->data(Qt::UserRole + 1).isValid());
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0);
// health field should NOT have structId
QStandardItem* health = player->child(0);
QVERIFY(!health->data(Qt::UserRole + 1).isValid());
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() != kGroupSentinel);
}
// ═══════════════════════════════════════════════════

View File

@@ -11,31 +11,37 @@ class TestTheme : public QObject {
Q_OBJECT
private slots:
void builtInThemes() {
Theme dark = Theme::reclassDark();
QCOMPARE(dark.name, "Reclass Dark");
QVERIFY(dark.background.isValid());
QVERIFY(dark.text.isValid());
QVERIFY(dark.syntaxKeyword.isValid());
QVERIFY(dark.markerError.isValid());
auto& tm = ThemeManager::instance();
auto all = tm.themes();
QVERIFY(all.size() >= 2);
Theme warm = Theme::warm();
QCOMPARE(warm.name, "Warm");
QVERIFY(warm.background.isValid());
QVERIFY(warm.text.isValid());
QCOMPARE(warm.background, QColor("#212121"));
QCOMPARE(warm.selection, QColor("#21213A"));
QCOMPARE(warm.syntaxKeyword, QColor("#AA9565"));
QCOMPARE(warm.syntaxType, QColor("#6B959F"));
}
// Find themes by name
const Theme* dark = nullptr;
const Theme* warm = nullptr;
for (const auto& t : all) {
if (t.name == "Reclass Dark") dark = &t;
if (t.name == "Warm") warm = &t;
}
QVERIFY(dark);
QCOMPARE(dark->name, QString("Reclass Dark"));
QVERIFY(dark->background.isValid());
QVERIFY(dark->text.isValid());
QVERIFY(dark->syntaxKeyword.isValid());
QVERIFY(dark->markerError.isValid());
void selectionColorFixed() {
Theme dark = Theme::reclassDark();
QCOMPARE(dark.selection, QColor("#2b2b2b"));
QVERIFY(dark.selection != QColor("#264f78"));
QVERIFY(warm);
QCOMPARE(warm->name, QString("Warm"));
QVERIFY(warm->background.isValid());
QVERIFY(warm->text.isValid());
QCOMPARE(warm->background, QColor("#212121"));
QCOMPARE(warm->selection, QColor("#21213A"));
QCOMPARE(warm->syntaxKeyword, QColor("#AA9565"));
QCOMPARE(warm->syntaxType, QColor("#6B959F"));
}
void jsonRoundTrip() {
Theme orig = Theme::reclassDark();
auto& tm = ThemeManager::instance();
Theme orig = tm.themes()[0];
QJsonObject json = orig.toJson();
Theme loaded = Theme::fromJson(json);
@@ -54,7 +60,12 @@ private slots:
}
void jsonRoundTripWarm() {
Theme orig = Theme::warm();
auto& tm = ThemeManager::instance();
auto all = tm.themes();
Theme orig;
for (const auto& t : all)
if (t.name == "Warm") { orig = t; break; }
QJsonObject json = orig.toJson();
Theme loaded = Theme::fromJson(json);
@@ -70,21 +81,27 @@ private slots:
sparse["background"] = "#ff0000";
Theme t = Theme::fromJson(sparse);
QCOMPARE(t.name, "Sparse");
QCOMPARE(t.name, QString("Sparse"));
QCOMPARE(t.background, QColor("#ff0000"));
// Missing fields fall back to reclassDark defaults
Theme defaults = Theme::reclassDark();
QCOMPARE(t.text, defaults.text);
QCOMPARE(t.syntaxKeyword, defaults.syntaxKeyword);
QCOMPARE(t.markerError, defaults.markerError);
// Missing fields are default (invalid) QColor
QVERIFY(!t.text.isValid());
QVERIFY(!t.syntaxKeyword.isValid());
QVERIFY(!t.markerError.isValid());
}
void themeManagerHasBuiltIns() {
auto& tm = ThemeManager::instance();
auto all = tm.themes();
QVERIFY(all.size() >= 2);
QCOMPARE(all[0].name, "Reclass Dark");
QCOMPARE(all[1].name, "Warm");
QVERIFY(all.size() >= 3);
QCOMPARE(all[0].name, QString("Reclass Dark"));
// VS2022 Dark and Warm are also loaded (order depends on filename sort)
bool hasVs = false, hasWarm = false;
for (const auto& t : all) {
if (t.name == "VS2022 Dark") hasVs = true;
if (t.name == "Warm") hasWarm = true;
}
QVERIFY(hasVs);
QVERIFY(hasWarm);
}
void themeManagerSwitch() {
@@ -108,12 +125,12 @@ private slots:
int initialCount = tm.themes().size();
// Add
Theme custom = Theme::reclassDark();
Theme custom = tm.themes()[0];
custom.name = "Test Custom";
custom.background = QColor("#ff0000");
tm.addTheme(custom);
QCOMPARE(tm.themes().size(), initialCount + 1);
QCOMPARE(tm.themes().last().name, "Test Custom");
QCOMPARE(tm.themes().last().name, QString("Test Custom"));
// Update
int idx = tm.themes().size() - 1;

View File

@@ -8,6 +8,8 @@
#include <QLineEdit>
#include <QListView>
#include <QStringListModel>
#include <QLabel>
#include <QFrame>
#include <Qsci/qsciscintilla.h>
#include "controller.h"
#include "typeselectorpopup.h"
@@ -198,6 +200,127 @@ private slots:
}
}
// ── Isolate first-show cost with different window flags ──
void benchmarkFirstShow() {
auto ms = [](qint64 ns) { return QString::number(ns / 1000000.0, 'f', 2); };
struct FlagTest {
const char* name;
Qt::WindowFlags flags;
};
FlagTest tests[] = {
{"Qt::Popup|Frameless", Qt::Popup | Qt::FramelessWindowHint},
{"Qt::Tool|Frameless", Qt::Tool | Qt::FramelessWindowHint},
{"Qt::ToolTip", Qt::ToolTip},
{"Qt::Window|Frameless", Qt::Window | Qt::FramelessWindowHint},
{"Qt::Popup|Frameless (2nd)", Qt::Popup | Qt::FramelessWindowHint},
};
for (const auto& test : tests) {
auto* f = new QFrame(nullptr, test.flags);
f->resize(300, 400);
QElapsedTimer t; t.start();
f->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
f->hide();
QApplication::processEvents();
t.restart();
f->show();
qint64 t3 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t4 = t.nsecsElapsed();
f->hide();
QApplication::processEvents();
qDebug() << "";
qDebug().noquote() << QString("=== %1 ===").arg(test.name);
qDebug().noquote() << QString(" 1st: show=%1ms events=%2ms | 2nd: show=%3ms events=%4ms")
.arg(ms(t1)).arg(ms(t2)).arg(ms(t3)).arg(ms(t4));
delete f;
}
// TypeSelectorPopup: cold vs after warmUp
{
auto* popup = new TypeSelectorPopup();
TypeEntry dummy;
dummy.entryKind = TypeEntry::Primitive;
dummy.primitiveKind = NodeKind::Hex8;
dummy.displayName = "test";
popup->setTypes({dummy});
QElapsedTimer t; t.start();
popup->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
t.restart();
popup->show();
qint64 t3 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t4 = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
qDebug() << "";
qDebug().noquote() << QString("=== TypeSelectorPopup (cold, Qt::Popup) ===");
qDebug().noquote() << QString(" 1st: show=%1ms events=%2ms | 2nd: show=%3ms events=%4ms")
.arg(ms(t1)).arg(ms(t2)).arg(ms(t3)).arg(ms(t4));
delete popup;
}
// Clean order test: dummy popup with children FIRST, then TypeSelectorPopup
qDebug() << "";
qDebug() << "=== CLEAN: dummy popup first, then TypeSelectorPopup ===";
{
auto* dummy = new QFrame(nullptr, Qt::Popup | Qt::FramelessWindowHint);
dummy->resize(300, 400);
auto* dLay = new QVBoxLayout(dummy);
dLay->addWidget(new QLabel("dummy"));
dLay->addWidget(new QLineEdit);
auto* dModel = new QStringListModel(dummy);
QStringList dItems; for (int i = 0; i < 10; i++) dItems << "x";
dModel->setStringList(dItems);
auto* dLv = new QListView; dLv->setModel(dModel);
dLay->addWidget(dLv);
QElapsedTimer t; t.start();
dummy->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
dummy->hide();
QApplication::processEvents();
qDebug().noquote() << QString(" Dummy popup: show=%1ms events=%2ms").arg(ms(t1)).arg(ms(t2));
delete dummy;
}
{
auto* popup = new TypeSelectorPopup();
TypeEntry e;
e.entryKind = TypeEntry::Primitive;
e.primitiveKind = NodeKind::Hex8;
e.displayName = "test";
popup->setTypes({e});
popup->resize(300, 400);
QElapsedTimer t; t.start();
popup->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
qDebug().noquote() << QString(" TypeSelectorPopup (after dummy): show=%1ms events=%2ms").arg(ms(t1)).arg(ms(t2));
delete popup;
}
}
// ── Popup data model ──
void testPopupListsRootStructs() {

View File

@@ -57,8 +57,8 @@ static void buildValidationTree(NodeTree& tree) {
field(46, NodeKind::Hex32, "field_h32");
field(50, NodeKind::Hex64, "field_h64");
field(58, NodeKind::Pointer64, "field_ptr");
field(66, NodeKind::Padding, "pad0");
tree.nodes.last().arrayLen = 6;
field(66, NodeKind::Hex32, "pad0");
field(70, NodeKind::Hex16, "pad1");
fieldArr(72, NodeKind::UInt32, 4, "field_arr");
}
@@ -725,9 +725,9 @@ private slots:
QCOMPARE(m_doc->undoStack.count(), 0);
}
// ── changeNodeKind size transitions: shrink inserts padding ──
// ── changeNodeKind size transitions: shrink inserts hex nodes ──
void testChangeKindShrinkInsertsPadding() {
void testChangeKindShrinkInsertsHexNodes() {
int idx = findNode(m_doc->tree, "field_u32");
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32); // 4 bytes
@@ -737,7 +737,7 @@ private slots:
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt8);
// Should have inserted padding nodes (Hex16 + Hex8 = 3 bytes, or similar)
// Should have inserted hex nodes (Hex16 + Hex8 = 3 bytes, or similar)
QVERIFY(m_doc->tree.nodes.size() > origCount);
// Undo restores everything
@@ -985,37 +985,6 @@ private slots:
QVERIFY(!m_editor->isEditing());
}
// ── Editor: padding value edit blocked, name/type still work ──
void testPaddingEditRestrictions() {
m_ctrl->refresh();
QApplication::processEvents();
ComposeResult result = m_doc->compose();
m_editor->applyDocument(result);
QApplication::processEvents();
// Find padding line
int padLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::Padding &&
result.meta[i].lineKind == LineKind::Field) {
padLine = i;
break;
}
}
QVERIFY(padLine >= 0);
// Value edit rejected
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, padLine));
// Type edit accepted
bool ok = m_editor->beginInlineEdit(EditTarget::Type, padLine);
QVERIFY(ok);
m_editor->cancelInlineEdit();
QApplication::processEvents();
}
// ── Editor: struct header rejects value edit ──
void testStructHeaderRejectsValueEdit() {

View File

@@ -0,0 +1,463 @@
#include <QTest>
#include <QByteArray>
#include <QProcess>
#include <QThread>
#include <QtConcurrent>
#include <QFuture>
#include <cstring>
#include "providers/provider.h"
#include "../plugins/WinDbgMemory/WinDbgMemoryPlugin.h"
#ifdef _WIN32
#include <windows.h>
#include <tlhelp32.h>
#include <initguid.h>
#include <dbgeng.h>
#endif
using namespace rcx;
static const char* CDB_PATH = "C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x64\\cdb.exe";
static const int DBG_PORT = 5055;
class TestWinDbgProvider : public QObject {
Q_OBJECT
private:
QProcess* m_cdbProcess = nullptr;
uint32_t m_notepadPid = 0;
bool m_weSpawnedNotepad = false;
QString m_connString;
static uint32_t findProcess(const wchar_t* name)
{
#ifdef _WIN32
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32W entry;
entry.dwSize = sizeof(entry);
uint32_t pid = 0;
if (Process32FirstW(snap, &entry)) {
do {
if (_wcsicmp(entry.szExeFile, name) == 0) {
pid = entry.th32ProcessID;
break;
}
} while (Process32NextW(snap, &entry));
}
CloseHandle(snap);
return pid;
#else
Q_UNUSED(name); return 0;
#endif
}
static uint32_t launchNotepad()
{
#ifdef _WIN32
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (CreateProcessW(L"C:\\Windows\\notepad.exe", nullptr, nullptr, nullptr,
FALSE, 0, nullptr, nullptr, &si, &pi)) {
WaitForInputIdle(pi.hProcess, 3000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return pi.dwProcessId;
}
return 0;
#else
return 0;
#endif
}
static void terminateProcess(uint32_t pid)
{
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
if (h) { TerminateProcess(h, 0); CloseHandle(h); }
#else
Q_UNUSED(pid);
#endif
}
private slots:
// ── Fixture ──
/// Try a quick DebugConnect to see if the port is already serving.
static bool canConnect(const QString& connStr)
{
#ifdef _WIN32
IDebugClient* probe = nullptr;
QByteArray utf8 = connStr.toUtf8();
HRESULT hr = DebugConnect(utf8.constData(), IID_IDebugClient, (void**)&probe);
if (SUCCEEDED(hr) && probe) {
probe->EndSession(DEBUG_END_DISCONNECT);
probe->Release();
return true;
}
return false;
#else
Q_UNUSED(connStr);
return false;
#endif
}
void initTestCase()
{
m_connString = QString("tcp:Port=%1,Server=localhost").arg(DBG_PORT);
// If a debug server is already listening (e.g. WinDbg with .server),
// skip launching our own cdb.exe.
if (canConnect(m_connString)) {
qDebug() << "Debug server already running on port" << DBG_PORT << "— using it";
return;
}
// No server running — launch cdb ourselves
m_notepadPid = findProcess(L"notepad.exe");
if (m_notepadPid == 0) {
m_notepadPid = launchNotepad();
m_weSpawnedNotepad = true;
}
QVERIFY2(m_notepadPid != 0, "Need notepad.exe running");
qDebug() << "Using notepad.exe PID:" << m_notepadPid;
m_cdbProcess = new QProcess(this);
QStringList args;
args << "-server" << QString("tcp:port=%1").arg(DBG_PORT)
<< "-pv"
<< "-p" << QString::number(m_notepadPid);
m_cdbProcess->setProgram(CDB_PATH);
m_cdbProcess->setArguments(args);
m_cdbProcess->start();
QVERIFY2(m_cdbProcess->waitForStarted(5000), "Failed to start cdb.exe");
QThread::sleep(3);
qDebug() << "cdb.exe debug server started on port" << DBG_PORT;
}
void cleanupTestCase()
{
if (m_cdbProcess) {
m_cdbProcess->write("q\n");
if (!m_cdbProcess->waitForFinished(5000))
m_cdbProcess->kill();
delete m_cdbProcess;
m_cdbProcess = nullptr;
}
if (m_weSpawnedNotepad && m_notepadPid)
terminateProcess(m_notepadPid);
}
// ── Plugin metadata ──
void plugin_name()
{
WinDbgMemoryPlugin plugin;
QCOMPARE(plugin.Name(), std::string("WinDbg Memory"));
}
void plugin_version()
{
WinDbgMemoryPlugin plugin;
QCOMPARE(plugin.Version(), std::string("2.0.0"));
}
void plugin_canHandle_tcp()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("tcp:Port=5055,Server=localhost"));
QVERIFY(plugin.canHandle("TCP:Port=1234,Server=10.0.0.1"));
}
void plugin_canHandle_npipe()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("npipe:Pipe=test,Server=localhost"));
}
void plugin_canHandle_pid()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("pid:1234"));
}
void plugin_canHandle_dump()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("dump:C:/test.dmp"));
}
void plugin_canHandle_invalid()
{
WinDbgMemoryPlugin plugin;
QVERIFY(!plugin.canHandle(""));
QVERIFY(!plugin.canHandle("1234"));
QVERIFY(!plugin.canHandle("file:///test.bin"));
}
// ── Connection failure ──
void provider_connect_badPort()
{
WinDbgMemoryProvider prov("tcp:Port=59999,Server=localhost");
QVERIFY(!prov.isValid());
QCOMPARE(prov.size(), 0);
}
void provider_connect_badPipe()
{
WinDbgMemoryProvider prov("npipe:Pipe=nonexistent_reclass_test_pipe,Server=localhost");
QVERIFY(!prov.isValid());
QCOMPARE(prov.size(), 0);
}
void plugin_createProvider_badConnection()
{
WinDbgMemoryPlugin plugin;
QString error;
auto prov = plugin.createProvider("tcp:Port=59999,Server=localhost", &error);
QVERIFY(prov == nullptr);
QVERIFY(!error.isEmpty());
}
// ── Connect and read (main thread) ──
void provider_connect_valid()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY2(prov.isValid(), "Should connect to cdb debug server");
QCOMPARE(prov.kind(), QStringLiteral("WinDbg"));
QVERIFY(prov.size() > 0);
}
void provider_name()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QVERIFY(!prov.name().isEmpty());
qDebug() << "Provider name:" << prov.name();
}
void provider_isLive()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QVERIFY(prov.isLive());
}
void provider_baseAddress()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QVERIFY2(prov.base() != 0, "Should have a non-zero base from first module");
qDebug() << "Base address:" << QString("0x%1").arg(prov.base(), 0, 16);
}
void provider_setBase()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint64_t orig = prov.base();
prov.setBase(0x1000);
QCOMPARE(prov.base(), (uint64_t)0x1000);
prov.setBase(orig);
QCOMPARE(prov.base(), orig);
}
// ── Read: MZ header on main thread ──
void provider_read_mz_mainThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint8_t buf[2] = {};
bool ok = prov.read(0, buf, 2);
QVERIFY2(ok, "Failed to read from debug session (main thread)");
QCOMPARE(buf[0], (uint8_t)'M');
QCOMPARE(buf[1], (uint8_t)'Z');
}
// ── Read: MZ header from a background thread (the actual failure case) ──
void provider_read_mz_backgroundThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
// Simulate what the controller's refresh does:
// read from a QtConcurrent worker thread.
QFuture<QByteArray> future = QtConcurrent::run([&prov]() -> QByteArray {
return prov.readBytes(0, 128);
});
future.waitForFinished();
QByteArray data = future.result();
QCOMPARE(data.size(), 128);
QCOMPARE((uint8_t)data[0], (uint8_t)'M');
QCOMPARE((uint8_t)data[1], (uint8_t)'Z');
}
// ── Read: bulk data from background thread ──
void provider_read_4k_backgroundThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QFuture<QByteArray> future = QtConcurrent::run([&prov]() -> QByteArray {
return prov.readBytes(0, 4096);
});
future.waitForFinished();
QByteArray data = future.result();
QCOMPARE(data.size(), 4096);
QCOMPARE((uint8_t)data[0], (uint8_t)'M');
QCOMPARE((uint8_t)data[1], (uint8_t)'Z');
// Verify it's not all zeros (the old failure mode)
bool allZero = true;
for (int i = 0; i < data.size(); ++i) {
if (data[i] != 0) { allZero = false; break; }
}
QVERIFY2(!allZero, "Data is all zeros — background thread read failed");
}
// ── Multiple sequential background reads (simulates refresh timer) ──
void provider_read_multipleRefreshes()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
for (int i = 0; i < 5; ++i) {
QFuture<QByteArray> future = QtConcurrent::run([&prov]() -> QByteArray {
return prov.readBytes(0, 128);
});
future.waitForFinished();
QByteArray data = future.result();
QCOMPARE(data.size(), 128);
QCOMPARE((uint8_t)data[0], (uint8_t)'M');
QCOMPARE((uint8_t)data[1], (uint8_t)'Z');
}
}
// ── Read helpers ──
void provider_readU16()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QCOMPARE(prov.readU16(0), (uint16_t)0x5A4D); // "MZ" little-endian
}
void provider_read_peSignature()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint32_t peOffset = prov.readU32(0x3C);
QVERIFY2(peOffset > 0 && peOffset < 0x1000, "PE offset should be reasonable");
uint8_t sig[4] = {};
bool ok = prov.read(peOffset, sig, 4);
QVERIFY(ok);
QCOMPARE(sig[0], (uint8_t)'P');
QCOMPARE(sig[1], (uint8_t)'E');
QCOMPARE(sig[2], (uint8_t)0);
QCOMPARE(sig[3], (uint8_t)0);
}
// ── Edge cases ──
void provider_read_zeroLength()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint8_t buf = 0xFF;
QVERIFY(!prov.read(0, &buf, 0));
}
void provider_read_negativeLength()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint8_t buf = 0xFF;
QVERIFY(!prov.read(0, &buf, -1));
}
// ── getSymbol ──
void provider_getSymbol()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QString sym = prov.getSymbol(0);
qDebug() << "Symbol at base+0:" << sym;
// Should not crash; may or may not resolve
}
void provider_getSymbol_backgroundThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QFuture<QString> future = QtConcurrent::run([&prov]() -> QString {
return prov.getSymbol(0);
});
future.waitForFinished();
// Should not crash from background thread
qDebug() << "Symbol (bg thread):" << future.result();
}
// ── createProvider full flow ──
void plugin_createProvider_valid()
{
WinDbgMemoryPlugin plugin;
QString error;
auto prov = plugin.createProvider(m_connString, &error);
QVERIFY2(prov != nullptr, qPrintable("createProvider failed: " + error));
QVERIFY(prov->isValid());
uint8_t mz[2] = {};
QVERIFY(prov->read(0, mz, 2));
QCOMPARE(mz[0], (uint8_t)'M');
QCOMPARE(mz[1], (uint8_t)'Z');
}
// ── Multiple concurrent connections ──
void provider_multipleConcurrent()
{
WinDbgMemoryProvider prov1(m_connString);
WinDbgMemoryProvider prov2(m_connString);
QVERIFY(prov1.isValid());
QVERIFY(prov2.isValid());
QCOMPARE(prov1.readU16(0), (uint16_t)0x5A4D);
QCOMPARE(prov2.readU16(0), (uint16_t)0x5A4D);
}
// ── Factory ──
void factory_createPlugin()
{
IPlugin* raw = CreatePlugin();
QVERIFY(raw != nullptr);
QCOMPARE(raw->Type(), IPlugin::ProviderPlugin);
QCOMPARE(raw->Name(), std::string("WinDbg Memory"));
delete raw;
}
};
QTEST_MAIN(TestWinDbgProvider)
#include "test_windbg_provider.moc"

View File

@@ -15,6 +15,9 @@
#include <windows.h>
#include <io.h>
#include <fcntl.h>
#else
#include <unistd.h>
#include <sys/select.h>
#endif
int main(int argc, char* argv[]) {