mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
Compare commits
68 Commits
plugin-sys
...
latest-lin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71bc51cbab | ||
|
|
60a97ab81b | ||
|
|
bb00e75019 | ||
|
|
c038c59e34 | ||
|
|
862f76b984 | ||
|
|
818285a76e | ||
|
|
ef5e2ebdb9 | ||
|
|
75fedd2222 | ||
|
|
389745e501 | ||
|
|
1473a58742 | ||
|
|
4192a4dad3 | ||
|
|
4c6bb9564f | ||
|
|
0ef9841f90 | ||
|
|
0a8244dad4 | ||
|
|
c856ba2697 | ||
|
|
b44dc9e96b | ||
|
|
0f2ded471f | ||
|
|
c9377c3afd | ||
|
|
a86912add1 | ||
|
|
5a9a6b754f | ||
|
|
0df52e82b8 | ||
|
|
9a342286ee | ||
|
|
1af0e17ef8 | ||
|
|
a45d66dd4e | ||
|
|
6922166e3c | ||
|
|
ffde3343dd | ||
|
|
c86a6dbc73 | ||
|
|
b153665059 | ||
|
|
a88b584ca0 | ||
|
|
3e827194b8 | ||
|
|
9f1c85913c | ||
|
|
cb151ab850 | ||
|
|
b0aa7cda67 | ||
|
|
4b1d3e9d3f | ||
|
|
e73b783cda | ||
|
|
7e0b995f4d | ||
|
|
52d65b4a23 | ||
|
|
db5d3ae311 | ||
|
|
33a093ae7d | ||
|
|
968476b65a | ||
|
|
df07b61144 | ||
|
|
3db051f4ba | ||
|
|
fc48fd6d2d | ||
|
|
0ffb7d6f58 | ||
|
|
0b0d9f23e1 | ||
|
|
7194322831 | ||
|
|
5f1fd56171 | ||
|
|
6bd61a6b78 | ||
|
|
60990362a0 | ||
|
|
55ef83a39f | ||
|
|
e034fe6f6c | ||
|
|
2e387f2dfc | ||
|
|
4295460597 | ||
|
|
df566064ba | ||
|
|
24a7e68136 | ||
|
|
8eab304538 | ||
|
|
9dd104ff34 | ||
|
|
910b607b79 | ||
|
|
c415b11825 | ||
|
|
1d6fddb51e | ||
|
|
276dcae444 | ||
|
|
659fb7bd32 | ||
|
|
85b840379d | ||
|
|
f4149faa9a | ||
|
|
b6c713eb29 | ||
|
|
4029b05298 | ||
|
|
0e65b9997e | ||
|
|
4caa7daa44 |
153
.github/workflows/build.yml
vendored
Normal file
153
.github/workflows/build.yml
vendored
Normal 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 }}
|
||||
232
CMakeLists.txt
232
CMakeLists.txt
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(ReclassX VERSION 0.1 LANGUAGES CXX)
|
||||
project(Reclass VERSION 0.1 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -7,12 +7,30 @@ set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets PrintSupport Svg Concurrent)
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
|
||||
|
||||
# Find Qt6 or Qt5 (config mode first, then FindQt5.cmake module for auto-download)
|
||||
set(_QT_COMPONENTS Core Widgets PrintSupport Svg Concurrent Network)
|
||||
find_package(QT NAMES Qt6 Qt5 COMPONENTS ${_QT_COMPONENTS} QUIET)
|
||||
if(NOT QT_FOUND)
|
||||
find_package(Qt5 REQUIRED COMPONENTS ${_QT_COMPONENTS})
|
||||
set(QT_VERSION_MAJOR 5)
|
||||
endif()
|
||||
# The NAMES variant only detects the version; load the actual component targets
|
||||
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS ${_QT_COMPONENTS})
|
||||
set(QT Qt${QT_VERSION_MAJOR})
|
||||
message(STATUS "Using ${QT}: ${${QT}_DIR}")
|
||||
|
||||
# Qt5 on Windows needs WinExtras for HICON conversion
|
||||
set(_QT_WINEXTRAS "")
|
||||
if(QT_VERSION_MAJOR EQUAL 5 AND WIN32)
|
||||
find_package(Qt5 REQUIRED COMPONENTS WinExtras)
|
||||
set(_QT_WINEXTRAS Qt5::WinExtras)
|
||||
endif()
|
||||
|
||||
find_package(QScintilla REQUIRED)
|
||||
|
||||
add_executable(ReclassX
|
||||
add_executable(Reclass
|
||||
src/main.cpp
|
||||
src/editor.h
|
||||
src/editor.cpp
|
||||
@@ -28,39 +46,65 @@ add_executable(ReclassX
|
||||
src/resources.qrc
|
||||
src/core.h
|
||||
src/workspace_model.h
|
||||
src/providers/buffer_provider.h src/providers/null_provider.h src/providers/process_provider.h src/providers/provider.h src/providers/snapshot_provider.h
|
||||
src/providers/buffer_provider.h src/providers/null_provider.h src/providers/provider.h src/providers/snapshot_provider.h
|
||||
src/providerregistry.cpp
|
||||
src/providerregistry.h
|
||||
src/pluginmanager.cpp
|
||||
src/pluginmanager.h
|
||||
src/typeselectorpopup.h
|
||||
src/typeselectorpopup.cpp
|
||||
src/themes/theme.h
|
||||
src/themes/theme.cpp
|
||||
src/themes/thememanager.h
|
||||
src/themes/thememanager.cpp
|
||||
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>
|
||||
)
|
||||
|
||||
target_include_directories(ReclassX PRIVATE src)
|
||||
target_include_directories(Reclass PRIVATE src)
|
||||
|
||||
target_link_libraries(ReclassX PRIVATE
|
||||
Qt6::Widgets
|
||||
Qt6::PrintSupport
|
||||
Qt6::Svg
|
||||
Qt6::Concurrent
|
||||
target_link_libraries(Reclass PRIVATE
|
||||
${QT}::Widgets
|
||||
${QT}::PrintSupport
|
||||
${QT}::Svg
|
||||
${QT}::Concurrent
|
||||
${QT}::Network
|
||||
QScintilla::QScintilla
|
||||
dbghelp
|
||||
psapi
|
||||
${_QT_WINEXTRAS}
|
||||
)
|
||||
if(WIN32)
|
||||
target_link_libraries(Reclass PRIVATE dbghelp dwmapi psapi)
|
||||
endif()
|
||||
|
||||
add_custom_target(screenshot ALL
|
||||
COMMAND ReclassX --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
|
||||
DEPENDS ReclassX
|
||||
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
|
||||
COMMENT "Capturing UI screenshot with class open..."
|
||||
)
|
||||
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
|
||||
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
|
||||
|
||||
add_custom_target(copy_demo ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
${CMAKE_BINARY_DIR}/demo.rcx
|
||||
${CMAKE_SOURCE_DIR}/src/examples/demo.rcx
|
||||
DEPENDS screenshot
|
||||
COMMENT "Copying demo.rcx to src/examples..."
|
||||
)
|
||||
# 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)
|
||||
|
||||
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} "
|
||||
@@ -85,95 +129,169 @@ message(STATUS \"Combined sources -> \${_out}\")
|
||||
|
||||
add_custom_target(combined ALL
|
||||
COMMAND ${CMAKE_COMMAND} -P ${_combine_script}
|
||||
DEPENDS ReclassX
|
||||
DEPENDS Reclass
|
||||
COMMENT "Combining all source files into h_cpp_combined.txt"
|
||||
)
|
||||
|
||||
include(CTest)
|
||||
if(BUILD_TESTING)
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
find_package(${QT} REQUIRED COMPONENTS Test)
|
||||
enable_testing()
|
||||
|
||||
add_executable(test_core tests/test_core.cpp src/format.cpp src/compose.cpp)
|
||||
target_include_directories(test_core PRIVATE src)
|
||||
target_link_libraries(test_core PRIVATE Qt6::Core Qt6::Test)
|
||||
target_link_libraries(test_core PRIVATE ${QT}::Core ${QT}::Test)
|
||||
add_test(NAME test_core COMMAND test_core)
|
||||
|
||||
add_executable(test_format tests/test_format.cpp src/format.cpp)
|
||||
target_include_directories(test_format PRIVATE src)
|
||||
target_link_libraries(test_format PRIVATE Qt6::Core Qt6::Test)
|
||||
target_link_libraries(test_format PRIVATE ${QT}::Core ${QT}::Test)
|
||||
add_test(NAME test_format COMMAND test_format)
|
||||
|
||||
add_executable(test_compose tests/test_compose.cpp src/compose.cpp src/format.cpp)
|
||||
target_include_directories(test_compose PRIVATE src)
|
||||
target_link_libraries(test_compose PRIVATE Qt6::Core Qt6::Test)
|
||||
target_link_libraries(test_compose PRIVATE ${QT}::Core ${QT}::Test)
|
||||
add_test(NAME test_compose COMMAND test_compose)
|
||||
|
||||
add_executable(test_editor tests/test_editor.cpp src/editor.cpp src/compose.cpp src/format.cpp src/providerregistry.cpp)
|
||||
add_executable(test_editor tests/test_editor.cpp src/editor.cpp src/compose.cpp src/format.cpp src/providerregistry.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_editor PRIVATE src)
|
||||
target_link_libraries(test_editor PRIVATE
|
||||
Qt6::Widgets Qt6::PrintSupport Qt6::Test
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
add_test(NAME test_editor COMMAND test_editor)
|
||||
|
||||
add_executable(test_provider tests/test_provider.cpp)
|
||||
target_include_directories(test_provider PRIVATE src)
|
||||
target_link_libraries(test_provider PRIVATE Qt6::Core Qt6::Test)
|
||||
target_link_libraries(test_provider PRIVATE ${QT}::Core ${QT}::Test)
|
||||
add_test(NAME test_provider COMMAND test_provider)
|
||||
|
||||
add_executable(test_command_row tests/test_command_row.cpp)
|
||||
target_include_directories(test_command_row PRIVATE src)
|
||||
target_link_libraries(test_command_row PRIVATE Qt6::Core Qt6::Test)
|
||||
target_link_libraries(test_command_row PRIVATE ${QT}::Core ${QT}::Test)
|
||||
add_test(NAME test_command_row COMMAND test_command_row)
|
||||
|
||||
add_executable(test_provider_getSymbol tests/test_provider_getSymbol.cpp)
|
||||
target_include_directories(test_provider_getSymbol PRIVATE src)
|
||||
target_link_libraries(test_provider_getSymbol PRIVATE Qt6::Core Qt6::Test)
|
||||
if(WIN32)
|
||||
target_link_libraries(test_provider_getSymbol PRIVATE psapi)
|
||||
endif()
|
||||
add_test(NAME test_provider_getSymbol COMMAND test_provider_getSymbol)
|
||||
|
||||
add_executable(test_controller tests/test_controller.cpp
|
||||
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
|
||||
src/typeselectorpopup.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_controller PRIVATE src)
|
||||
target_link_libraries(test_controller PRIVATE
|
||||
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
|
||||
QScintilla::QScintilla dbghelp psapi)
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
if(WIN32)
|
||||
target_link_libraries(test_controller PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||
endif()
|
||||
add_test(NAME test_controller COMMAND test_controller)
|
||||
|
||||
add_executable(test_validation tests/test_validation.cpp
|
||||
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
|
||||
src/typeselectorpopup.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_validation PRIVATE src)
|
||||
target_link_libraries(test_validation PRIVATE
|
||||
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
|
||||
QScintilla::QScintilla dbghelp psapi)
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
if(WIN32)
|
||||
target_link_libraries(test_validation PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||
endif()
|
||||
add_test(NAME test_validation COMMAND test_validation)
|
||||
|
||||
add_executable(test_generator tests/test_generator.cpp
|
||||
src/generator.cpp src/compose.cpp src/format.cpp)
|
||||
target_include_directories(test_generator PRIVATE src)
|
||||
target_link_libraries(test_generator PRIVATE Qt6::Core Qt6::Test)
|
||||
target_link_libraries(test_generator PRIVATE ${QT}::Core ${QT}::Test)
|
||||
add_test(NAME test_generator COMMAND test_generator)
|
||||
|
||||
add_executable(test_context_menu tests/test_context_menu.cpp
|
||||
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
|
||||
src/typeselectorpopup.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_context_menu PRIVATE src)
|
||||
target_link_libraries(test_context_menu PRIVATE
|
||||
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
|
||||
QScintilla::QScintilla dbghelp psapi)
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
if(WIN32)
|
||||
target_link_libraries(test_context_menu PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||
endif()
|
||||
add_test(NAME test_context_menu COMMAND test_context_menu)
|
||||
|
||||
add_executable(test_rendered_view tests/test_rendered_view.cpp
|
||||
src/generator.cpp src/compose.cpp src/format.cpp)
|
||||
target_include_directories(test_rendered_view PRIVATE src)
|
||||
target_link_libraries(test_rendered_view PRIVATE
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
add_test(NAME test_rendered_view COMMAND test_rendered_view)
|
||||
|
||||
add_executable(test_new_features tests/test_new_features.cpp
|
||||
src/generator.cpp src/compose.cpp src/format.cpp src/controller.cpp
|
||||
src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
|
||||
src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
|
||||
src/typeselectorpopup.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_new_features PRIVATE src)
|
||||
target_link_libraries(test_new_features PRIVATE
|
||||
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
|
||||
QScintilla::QScintilla dbghelp psapi)
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
if(WIN32)
|
||||
target_link_libraries(test_new_features PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||
endif()
|
||||
add_test(NAME test_new_features COMMAND test_new_features)
|
||||
|
||||
add_executable(test_type_selector tests/test_type_selector.cpp
|
||||
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
|
||||
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
|
||||
src/typeselectorpopup.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_type_selector PRIVATE src)
|
||||
target_link_libraries(test_type_selector PRIVATE
|
||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||
QScintilla::QScintilla)
|
||||
if(WIN32)
|
||||
target_link_libraries(test_type_selector PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||
endif()
|
||||
add_test(NAME test_type_selector COMMAND test_type_selector)
|
||||
|
||||
add_executable(test_theme tests/test_theme.cpp
|
||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||
target_include_directories(test_theme PRIVATE src)
|
||||
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)
|
||||
add_custom_target(deploy_tests ALL
|
||||
COMMAND $<TARGET_FILE:${QT}::windeployqt>
|
||||
--no-compiler-runtime --no-translations
|
||||
--no-opengl-sw --no-system-d3d-compiler
|
||||
$<TARGET_FILE:test_controller>
|
||||
DEPENDS test_controller
|
||||
COMMENT "Deploying Qt runtime DLLs for tests..."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
if(WIN32)
|
||||
add_subdirectory(plugins/ProcessMemory)
|
||||
add_subdirectory(plugins/WinDbgMemory)
|
||||
endif()
|
||||
add_subdirectory(plugins/ProcessMemory)
|
||||
|
||||
53
README.md
53
README.md
@@ -1,16 +1,49 @@
|
||||
# ReclassX
|
||||
|
||||
An improvement over other reclass like editors.
|
||||
|
||||
https://github.com/IChooseYou/ReclassX/raw/main/video.mp4
|
||||
This tool helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is essentially a debugging tool for figuring out unknown data structures either runtime or from some static source.
|
||||
|
||||

|
||||
|
||||
## State
|
||||
|
||||
- MCP (Model Context Protocol) bridge via `ReclassMcpBridge.exe`. The server starts by default and can be stopped from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code) and falls back to UI prompts when the client requests something not yet covered by tools. To connect, add this to your MCP client config (e.g. `.mcp.json`):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ReclassMcpBridge": {
|
||||
"command": "path/to/build/ReclassMcpBridge.exe",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Plugin system is partially implemented. Some UI bugs exist.
|
||||
- Vector/Matrix improvements have been made but are not entirely complete.
|
||||
- Every edit goes through a full undo/redo system.
|
||||
|
||||
## Build
|
||||
|
||||
Requires Qt 6, QScintilla, and MinGW on Windows.
|
||||
1. Prerequisites
|
||||
|
||||
```
|
||||
cmake -B build -G Ninja
|
||||
cmake --build build
|
||||
```
|
||||
- Qt 6 with MinGW - Qt Online Installer https://doc.qt.io/qt-6/qt-online-installation.html , note to select MinGW kit + CMake/Ninja from Tools section (online installers index: https://download.qt.io/official_releases/online_installers/)
|
||||
- CMake 3.20+ - https://cmake.org/download/ - bundled with Qt
|
||||
- windeployqt docs - https://doc.qt.io/qt-6/windows-deployment.html
|
||||
|
||||
2. Quick Build (relies on powershell| for manual build skip to step 3)
|
||||
|
||||
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
|
||||
cd Reclass
|
||||
.\scripts\build_qscintilla.ps1
|
||||
.\scripts\build.ps1
|
||||
^ script above tries to autodetect Qt install (as we learned not everyone installs to C:/Qt/)
|
||||
|
||||
3. Manual Build
|
||||
|
||||
Step by step for peoplewho want to run commands themselves:
|
||||
1. Clone with --recurse-submodules (+ fallback git submodule update --init --recursive)
|
||||
2. Build QScintilla: qmake + mingw32-make in third_party/qscintilla/src
|
||||
3. CMake configure + build with -DCMAKE_PREFIX_PATH
|
||||
4. optionallly windeployqt the exe
|
||||
|
||||
## Alternatives
|
||||
|
||||
- ReClass.NET (reclass.net) - https://github.com/ReClassNET/ReClass.NET
|
||||
- ReClassEx - https://github.com/ajkhoury/ReClassEx
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
set(_QSCI_ROOT "${CMAKE_SOURCE_DIR}/third_party/qscintilla")
|
||||
|
||||
# Try to find a pre-built library first
|
||||
find_path(QScintilla_INCLUDE_DIR
|
||||
NAMES Qsci/qsciscintilla.h
|
||||
PATHS "${_QSCI_ROOT}/src" "${_QSCI_ROOT}/include"
|
||||
@@ -7,7 +8,10 @@ find_path(QScintilla_INCLUDE_DIR
|
||||
)
|
||||
|
||||
find_library(QScintilla_LIBRARY
|
||||
NAMES qscintilla2_qt6 libqscintilla2_qt6
|
||||
NAMES
|
||||
qscintilla2_qt${QT_VERSION_MAJOR} libqscintilla2_qt${QT_VERSION_MAJOR}
|
||||
qscintilla2_qt6 libqscintilla2_qt6
|
||||
qscintilla2_qt5 libqscintilla2_qt5
|
||||
PATHS
|
||||
"${_QSCI_ROOT}/src/release"
|
||||
"${_QSCI_ROOT}/src"
|
||||
@@ -15,13 +19,11 @@ find_library(QScintilla_LIBRARY
|
||||
NO_DEFAULT_PATH
|
||||
)
|
||||
|
||||
include(FindPackageHandleStandardArgs)
|
||||
find_package_handle_standard_args(QScintilla DEFAULT_MSG
|
||||
QScintilla_LIBRARY QScintilla_INCLUDE_DIR)
|
||||
|
||||
if(QScintilla_FOUND)
|
||||
set(QScintilla_INCLUDE_DIRS ${QScintilla_INCLUDE_DIR})
|
||||
set(QScintilla_LIBRARIES ${QScintilla_LIBRARY})
|
||||
if(QScintilla_LIBRARY AND QScintilla_INCLUDE_DIR)
|
||||
# Use pre-built library
|
||||
include(FindPackageHandleStandardArgs)
|
||||
find_package_handle_standard_args(QScintilla DEFAULT_MSG
|
||||
QScintilla_LIBRARY QScintilla_INCLUDE_DIR)
|
||||
if(NOT TARGET QScintilla::QScintilla)
|
||||
add_library(QScintilla::QScintilla STATIC IMPORTED)
|
||||
set_target_properties(QScintilla::QScintilla PROPERTIES
|
||||
@@ -29,4 +31,118 @@ if(QScintilla_FOUND)
|
||||
INTERFACE_INCLUDE_DIRECTORIES "${QScintilla_INCLUDE_DIR}"
|
||||
)
|
||||
endif()
|
||||
elseif(EXISTS "${_QSCI_ROOT}/src/qsciscintilla.cpp")
|
||||
# Build from source
|
||||
message(STATUS "Building QScintilla from source")
|
||||
|
||||
file(GLOB _QSCI_LEXER_SOURCES "${_QSCI_ROOT}/scintilla/lexers/*.cpp")
|
||||
file(GLOB _QSCI_LEXLIB_SOURCES "${_QSCI_ROOT}/scintilla/lexlib/*.cpp")
|
||||
file(GLOB _QSCI_SCI_SOURCES "${_QSCI_ROOT}/scintilla/src/*.cpp")
|
||||
file(GLOB _QSCI_HEADERS "${_QSCI_ROOT}/src/Qsci/*.h")
|
||||
|
||||
set(_QSCI_QT_SOURCES
|
||||
"${_QSCI_ROOT}/src/qsciscintilla.cpp"
|
||||
"${_QSCI_ROOT}/src/qsciscintillabase.cpp"
|
||||
"${_QSCI_ROOT}/src/qsciabstractapis.cpp"
|
||||
"${_QSCI_ROOT}/src/qsciapis.cpp"
|
||||
"${_QSCI_ROOT}/src/qscicommand.cpp"
|
||||
"${_QSCI_ROOT}/src/qscicommandset.cpp"
|
||||
"${_QSCI_ROOT}/src/qscidocument.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexer.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerasm.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexeravs.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerbash.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerbatch.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexercmake.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexercoffeescript.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexercpp.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexercsharp.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexercss.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexercustom.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerd.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerdiff.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexeredifact.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerfortran.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerfortran77.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerhex.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerhtml.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexeridl.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerintelhex.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerjava.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerjavascript.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerjson.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerlua.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexermakefile.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexermarkdown.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexermasm.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexermatlab.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexernasm.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexeroctave.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerpascal.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerperl.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerpostscript.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerpo.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerpov.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerproperties.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerpython.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerruby.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerspice.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexersql.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexersrec.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexertcl.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexertekhex.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexertex.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerverilog.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexervhdl.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexerxml.cpp"
|
||||
"${_QSCI_ROOT}/src/qscilexeryaml.cpp"
|
||||
"${_QSCI_ROOT}/src/qscimacro.cpp"
|
||||
"${_QSCI_ROOT}/src/qsciprinter.cpp"
|
||||
"${_QSCI_ROOT}/src/qscistyle.cpp"
|
||||
"${_QSCI_ROOT}/src/qscistyledtext.cpp"
|
||||
"${_QSCI_ROOT}/src/InputMethod.cpp"
|
||||
"${_QSCI_ROOT}/src/ListBoxQt.cpp"
|
||||
"${_QSCI_ROOT}/src/PlatQt.cpp"
|
||||
"${_QSCI_ROOT}/src/SciAccessibility.cpp"
|
||||
"${_QSCI_ROOT}/src/SciClasses.cpp"
|
||||
"${_QSCI_ROOT}/src/ScintillaQt.cpp"
|
||||
)
|
||||
|
||||
add_library(qscintilla2 STATIC
|
||||
${_QSCI_QT_SOURCES}
|
||||
${_QSCI_HEADERS}
|
||||
${_QSCI_LEXER_SOURCES}
|
||||
${_QSCI_LEXLIB_SOURCES}
|
||||
${_QSCI_SCI_SOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(qscintilla2 PUBLIC
|
||||
"${_QSCI_ROOT}/src"
|
||||
)
|
||||
target_include_directories(qscintilla2 PRIVATE
|
||||
"${_QSCI_ROOT}/scintilla/include"
|
||||
"${_QSCI_ROOT}/scintilla/lexlib"
|
||||
"${_QSCI_ROOT}/scintilla/src"
|
||||
)
|
||||
|
||||
target_compile_definitions(qscintilla2 PRIVATE
|
||||
SCINTILLA_QT
|
||||
SCI_LEXER
|
||||
INCLUDE_DEPRECATED_FEATURES
|
||||
)
|
||||
|
||||
target_link_libraries(qscintilla2 PUBLIC
|
||||
${QT}::Widgets
|
||||
${QT}::PrintSupport
|
||||
)
|
||||
|
||||
set_target_properties(qscintilla2 PROPERTIES AUTOMOC ON)
|
||||
|
||||
add_library(QScintilla::QScintilla ALIAS qscintilla2)
|
||||
set(QScintilla_FOUND TRUE)
|
||||
else()
|
||||
set(QScintilla_FOUND FALSE)
|
||||
if(QScintilla_FIND_REQUIRED)
|
||||
message(FATAL_ERROR "Could NOT find QScintilla (missing source and pre-built library)")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
36
cmake/FindQt5.cmake
Normal file
36
cmake/FindQt5.cmake
Normal file
@@ -0,0 +1,36 @@
|
||||
# Documentation: https://cmake.org/cmake/help/latest/manual/cmake-developer.7.html#find-modules
|
||||
|
||||
# Always try config mode for the requested components (handles repeated calls)
|
||||
find_package(Qt5 COMPONENTS ${Qt5_FIND_COMPONENTS} QUIET CONFIG)
|
||||
|
||||
if(Qt5_FOUND)
|
||||
if(NOT Qt5_FIND_QUIETLY)
|
||||
message(STATUS "Qt5 found: ${Qt5_DIR}")
|
||||
endif()
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(Qt5_FIND_REQUIRED AND WIN32)
|
||||
message(STATUS "Downloading Qt5...")
|
||||
# Fix warnings about DOWNLOAD_EXTRACT_TIMESTAMP
|
||||
if(POLICY CMP0135)
|
||||
cmake_policy(SET CMP0135 NEW)
|
||||
endif()
|
||||
include(FetchContent)
|
||||
set(FETCHCONTENT_QUIET OFF)
|
||||
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
|
||||
FetchContent_Declare(Qt5
|
||||
URL "https://github.com/x64dbg/deps/releases/download/2025.07.02/qt5.12.12-msvc2017_64.7z"
|
||||
URL_HASH SHA256=770490bf09514982c8192ebde9a1fac8821108ba42b021f167bac54e85ada48a
|
||||
)
|
||||
else()
|
||||
FetchContent_Declare(Qt5
|
||||
URL "https://github.com/x64dbg/deps/releases/download/2025.07.02/qt5.12.12-msvc2017.7z"
|
||||
URL_HASH SHA256=3ff2a58e5ed772be475643cd7bb2df3e5499d7169d794ddf1ed5df5c5e862cb6
|
||||
)
|
||||
endif()
|
||||
FetchContent_MakeAvailable(Qt5)
|
||||
unset(FETCHCONTENT_QUIET)
|
||||
set(Qt5_ROOT ${qt5_SOURCE_DIR})
|
||||
find_package(Qt5 COMPONENTS ${Qt5_FIND_COMPONENTS} CONFIG REQUIRED)
|
||||
endif()
|
||||
82
cmake/deploy.cmake
Normal file
82
cmake/deploy.cmake
Normal file
@@ -0,0 +1,82 @@
|
||||
# cmake/deploy.cmake - Dual-mode script for deploying Qt runtime DLLs
|
||||
#
|
||||
# Script mode: cmake -P deploy.cmake <target_exe> <windeployqt>
|
||||
# Include mode: include(deploy) from CMakeLists.txt (creates "deploy" target)
|
||||
|
||||
if(CMAKE_SCRIPT_MODE_FILE)
|
||||
set(TARGET_EXE ${CMAKE_ARGV3})
|
||||
set(WINDEPLOYQT ${CMAKE_ARGV4})
|
||||
get_filename_component(TARGET_DIR ${TARGET_EXE} DIRECTORY)
|
||||
|
||||
# Skip if already deployed for this build
|
||||
if(EXISTS "${TARGET_DIR}/.qt_deployed")
|
||||
return()
|
||||
endif()
|
||||
|
||||
message(STATUS "Running windeployqt on ${TARGET_EXE}")
|
||||
|
||||
execute_process(
|
||||
COMMAND ${WINDEPLOYQT}
|
||||
--pdb
|
||||
--no-compiler-runtime
|
||||
--no-translations
|
||||
--no-opengl-sw
|
||||
--no-system-d3d-compiler
|
||||
--force
|
||||
${TARGET_EXE}
|
||||
RESULT_VARIABLE _result
|
||||
)
|
||||
|
||||
if(_result EQUAL 0)
|
||||
file(WRITE "${TARGET_DIR}/.qt_deployed" "")
|
||||
message(STATUS "windeployqt completed successfully")
|
||||
else()
|
||||
message(WARNING "windeployqt failed with exit code ${_result}")
|
||||
endif()
|
||||
|
||||
return()
|
||||
endif()
|
||||
|
||||
# ── Include mode: configure the deploy target ──
|
||||
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
# Discover windeployqt from qmake
|
||||
if(NOT TARGET ${QT}::windeployqt AND TARGET ${QT}::qmake)
|
||||
get_target_property(_qt_qmake_location ${QT}::qmake IMPORTED_LOCATION)
|
||||
|
||||
execute_process(
|
||||
COMMAND "${_qt_qmake_location}" -query QT_INSTALL_PREFIX
|
||||
RESULT_VARIABLE _return_code
|
||||
OUTPUT_VARIABLE _qt_install_prefix
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
|
||||
set(_windeployqt "${_qt_install_prefix}/bin/windeployqt.exe")
|
||||
if(EXISTS ${_windeployqt})
|
||||
add_executable(${QT}::windeployqt IMPORTED)
|
||||
set_target_properties(${QT}::windeployqt PROPERTIES
|
||||
IMPORTED_LOCATION ${_windeployqt}
|
||||
)
|
||||
message(STATUS "Found windeployqt: ${_windeployqt}")
|
||||
else()
|
||||
message(WARNING "windeployqt not found at ${_windeployqt}")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(TARGET ${QT}::windeployqt)
|
||||
add_custom_target(deploy
|
||||
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_LIST_DIR}/deploy.cmake
|
||||
$<TARGET_FILE:Reclass>
|
||||
$<TARGET_FILE:${QT}::windeployqt>
|
||||
DEPENDS Reclass
|
||||
COMMENT "Deploying Qt runtime DLLs..."
|
||||
)
|
||||
|
||||
# Force re-deploy on rebuild
|
||||
set_target_properties(deploy PROPERTIES
|
||||
ADDITIONAL_CLEAN_FILES $<TARGET_FILE_DIR:Reclass>/.qt_deployed
|
||||
)
|
||||
endif()
|
||||
@@ -4,8 +4,7 @@ project(ProcessMemoryPlugin LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# Find Qt
|
||||
find_package(Qt6 REQUIRED COMPONENTS Widgets)
|
||||
# Qt is found by the parent project; QT variable (Qt5 or Qt6) is inherited
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
@@ -24,10 +23,20 @@ set(PLUGIN_SOURCES
|
||||
add_library(ProcessMemoryPlugin SHARED ${PLUGIN_SOURCES})
|
||||
|
||||
# Link Qt
|
||||
target_link_libraries(ProcessMemoryPlugin PRIVATE Qt6::Widgets)
|
||||
target_link_libraries(ProcessMemoryPlugin PRIVATE ${QT}::Widgets ${_QT_WINEXTRAS})
|
||||
|
||||
# Platform-specific linking
|
||||
if(WIN32)
|
||||
target_link_libraries(ProcessMemoryPlugin PRIVATE psapi shell32)
|
||||
endif()
|
||||
|
||||
# On Linux, hide all symbols by default so only RCX_PLUGIN_EXPORT-marked ones are exported
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_compile_options(ProcessMemoryPlugin PRIVATE -fvisibility=hidden)
|
||||
endif()
|
||||
|
||||
# Include directories
|
||||
target_include_directories(ProcessMemoryPlugin PRIVATE
|
||||
target_include_directories(ProcessMemoryPlugin PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../../src
|
||||
)
|
||||
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
#include "ProcessMemoryPlugin.h"
|
||||
|
||||
#include "../../src/processpicker.h"
|
||||
|
||||
#include <QStyle>
|
||||
#include <QApplication>
|
||||
#include <QRegularExpression>
|
||||
#include <QMessageBox>
|
||||
#include <QPixmap>
|
||||
#include <QImage>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) && defined(_WIN32)
|
||||
#include <QtWin>
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <psapi.h>
|
||||
#include <shellapi.h>
|
||||
#elif defined(__linux__)
|
||||
#include <climits>
|
||||
#include <sys/types.h>
|
||||
#include <dirent.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/uio.h>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstring>
|
||||
#endif
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// ProcessMemoryProvider implementation
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ProcessMemoryProvider::ProcessMemoryProvider(DWORD pid, const QString& processName)
|
||||
#ifdef _WIN32
|
||||
|
||||
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName)
|
||||
: m_handle(nullptr)
|
||||
, m_pid(pid)
|
||||
, m_processName(processName)
|
||||
@@ -19,7 +45,7 @@ ProcessMemoryProvider::ProcessMemoryProvider(DWORD pid, const QString& processNa
|
||||
, m_base(0)
|
||||
{
|
||||
// Try to open with write access first
|
||||
m_handle = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
m_handle = OpenProcess(PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
|
||||
FALSE, pid);
|
||||
if (m_handle)
|
||||
m_writable = true;
|
||||
@@ -31,31 +57,24 @@ ProcessMemoryProvider::ProcessMemoryProvider(DWORD pid, const QString& processNa
|
||||
}
|
||||
|
||||
if (m_handle)
|
||||
{
|
||||
cacheModules();
|
||||
}
|
||||
}
|
||||
|
||||
ProcessMemoryProvider::~ProcessMemoryProvider()
|
||||
{
|
||||
if (m_handle)
|
||||
CloseHandle(m_handle);
|
||||
}
|
||||
|
||||
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
|
||||
{
|
||||
if (!m_handle || len <= 0) return false;
|
||||
|
||||
|
||||
SIZE_T bytesRead = 0;
|
||||
if (ReadProcessMemory(m_handle, (LPCVOID)(m_base + addr), buf, (SIZE_T)len, &bytesRead))
|
||||
return bytesRead == (SIZE_T)len;
|
||||
return false;
|
||||
ReadProcessMemory(m_handle, (LPCVOID)(m_base + addr), buf, (SIZE_T)len, &bytesRead);
|
||||
if ((int)bytesRead < len)
|
||||
memset((char*)buf + bytesRead, 0, len - bytesRead);
|
||||
return bytesRead > 0;
|
||||
}
|
||||
|
||||
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
|
||||
{
|
||||
if (!m_handle || !m_writable || len <= 0) return false;
|
||||
|
||||
|
||||
SIZE_T bytesWritten = 0;
|
||||
if (WriteProcessMemory(m_handle, (LPVOID)(m_base + addr), buf, (SIZE_T)len, &bytesWritten))
|
||||
return bytesWritten == (SIZE_T)len;
|
||||
@@ -64,9 +83,16 @@ bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
|
||||
|
||||
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
|
||||
{
|
||||
// TODO: Implement module enumeration with EnumProcessModules
|
||||
// For now, just return empty (no symbol resolution)
|
||||
Q_UNUSED(addr);
|
||||
for (const auto& mod : m_modules)
|
||||
{
|
||||
if (addr >= mod.base && addr < mod.base + mod.size)
|
||||
{
|
||||
uint64_t offset = addr - mod.base;
|
||||
return QStringLiteral("%1+0x%2")
|
||||
.arg(mod.name)
|
||||
.arg(offset, 0, 16, QChar('0'));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -98,6 +124,190 @@ void ProcessMemoryProvider::cacheModules()
|
||||
}
|
||||
}
|
||||
|
||||
#elif defined(__linux__)
|
||||
|
||||
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName)
|
||||
: m_fd(-1)
|
||||
, m_pid(pid)
|
||||
, m_processName(processName)
|
||||
, m_writable(false)
|
||||
, m_base(0)
|
||||
{
|
||||
QString memPath = QStringLiteral("/proc/%1/mem").arg(pid);
|
||||
QByteArray pathUtf8 = memPath.toUtf8();
|
||||
|
||||
// Try read-write first
|
||||
m_fd = ::open(pathUtf8.constData(), O_RDWR);
|
||||
if (m_fd >= 0)
|
||||
m_writable = true;
|
||||
else
|
||||
{
|
||||
// Fall back to read-only
|
||||
m_fd = ::open(pathUtf8.constData(), O_RDONLY);
|
||||
m_writable = false;
|
||||
}
|
||||
|
||||
if (m_fd >= 0)
|
||||
cacheModules();
|
||||
|
||||
}
|
||||
|
||||
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
|
||||
{
|
||||
if (m_fd < 0 || len <= 0) return false;
|
||||
|
||||
uint64_t absAddr = m_base + addr;
|
||||
|
||||
// Try process_vm_readv first (faster, no fd seek contention)
|
||||
struct iovec local;
|
||||
local.iov_base = buf;
|
||||
local.iov_len = static_cast<size_t>(len);
|
||||
|
||||
struct iovec remote;
|
||||
remote.iov_base = reinterpret_cast<void*>(absAddr);
|
||||
remote.iov_len = static_cast<size_t>(len);
|
||||
|
||||
ssize_t nread = process_vm_readv(m_pid, &local, 1, &remote, 1, 0);
|
||||
if (nread == static_cast<ssize_t>(len))
|
||||
return true;
|
||||
|
||||
// Fallback: pread on /proc/<pid>/mem
|
||||
nread = ::pread(m_fd, buf, static_cast<size_t>(len), static_cast<off_t>(absAddr));
|
||||
return nread == static_cast<ssize_t>(len);
|
||||
}
|
||||
|
||||
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
|
||||
{
|
||||
if (m_fd < 0 || !m_writable || len <= 0) return false;
|
||||
|
||||
uint64_t absAddr = m_base + addr;
|
||||
|
||||
// Try process_vm_writev first
|
||||
struct iovec local;
|
||||
local.iov_base = const_cast<void*>(buf);
|
||||
local.iov_len = static_cast<size_t>(len);
|
||||
|
||||
struct iovec remote;
|
||||
remote.iov_base = reinterpret_cast<void*>(absAddr);
|
||||
remote.iov_len = static_cast<size_t>(len);
|
||||
|
||||
ssize_t nwritten = process_vm_writev(m_pid, &local, 1, &remote, 1, 0);
|
||||
if (nwritten == static_cast<ssize_t>(len))
|
||||
return true;
|
||||
|
||||
// Fallback: pwrite on /proc/<pid>/mem
|
||||
nwritten = ::pwrite(m_fd, buf, static_cast<size_t>(len), static_cast<off_t>(absAddr));
|
||||
return nwritten == static_cast<ssize_t>(len);
|
||||
}
|
||||
|
||||
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
|
||||
{
|
||||
for (const auto& mod : m_modules)
|
||||
{
|
||||
if (addr >= mod.base && addr < mod.base + mod.size)
|
||||
{
|
||||
uint64_t offset = addr - mod.base;
|
||||
return QStringLiteral("%1+0x%2")
|
||||
.arg(mod.name)
|
||||
.arg(offset, 0, 16, QChar('0'));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void ProcessMemoryProvider::cacheModules()
|
||||
{
|
||||
// Parse /proc/<pid>/maps to discover loaded modules
|
||||
QString mapsPath = QStringLiteral("/proc/%1/maps").arg(m_pid);
|
||||
std::ifstream mapsFile(mapsPath.toStdString());
|
||||
if (!mapsFile.is_open()) return;
|
||||
|
||||
// Accumulate base/end per path, then convert to ModuleInfo
|
||||
struct Range { uint64_t base; uint64_t end; };
|
||||
QMap<QString, Range> moduleRanges;
|
||||
|
||||
std::string line;
|
||||
bool firstExec = true;
|
||||
while (std::getline(mapsFile, line))
|
||||
{
|
||||
// Format: addr_start-addr_end perms offset dev inode pathname
|
||||
// Example: 00400000-00452000 r-xp 00000000 08:02 173521 /usr/bin/foo
|
||||
std::istringstream iss(line);
|
||||
std::string addrRange, perms, offset, dev, inode, pathname;
|
||||
iss >> addrRange >> perms >> offset >> dev >> inode;
|
||||
std::getline(iss, pathname);
|
||||
|
||||
// Trim leading whitespace from pathname
|
||||
size_t start = pathname.find_first_not_of(" \t");
|
||||
if (start == std::string::npos) continue;
|
||||
pathname = pathname.substr(start);
|
||||
|
||||
// Skip non-file mappings
|
||||
if (pathname.empty() || pathname[0] != '/') continue;
|
||||
// Skip special mappings
|
||||
if (pathname.find("/dev/") == 0 || pathname.find("/memfd:") == 0) continue;
|
||||
|
||||
// Parse address range
|
||||
auto dash = addrRange.find('-');
|
||||
if (dash == std::string::npos) continue;
|
||||
uint64_t addrStart = std::stoull(addrRange.substr(0, dash), nullptr, 16);
|
||||
uint64_t addrEnd = std::stoull(addrRange.substr(dash + 1), nullptr, 16);
|
||||
|
||||
QString qpath = QString::fromStdString(pathname);
|
||||
|
||||
// Track first executable mapping as the base address
|
||||
if (firstExec && perms.size() >= 3 && perms[2] == 'x')
|
||||
{
|
||||
m_base = addrStart;
|
||||
firstExec = false;
|
||||
}
|
||||
|
||||
auto it = moduleRanges.find(qpath);
|
||||
if (it != moduleRanges.end())
|
||||
{
|
||||
if (addrStart < it->base) it->base = addrStart;
|
||||
if (addrEnd > it->end) it->end = addrEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
moduleRanges.insert(qpath, {addrStart, addrEnd});
|
||||
}
|
||||
}
|
||||
|
||||
m_modules.reserve(moduleRanges.size());
|
||||
for (auto it = moduleRanges.begin(); it != moduleRanges.end(); ++it)
|
||||
{
|
||||
QFileInfo fi(it.key());
|
||||
m_modules.append({
|
||||
fi.fileName(),
|
||||
it->base,
|
||||
it->end - it->base
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#endif // platform
|
||||
|
||||
ProcessMemoryProvider::~ProcessMemoryProvider()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
if (m_handle)
|
||||
CloseHandle(m_handle);
|
||||
#elif defined(__linux__)
|
||||
if (m_fd >= 0)
|
||||
::close(m_fd);
|
||||
#endif
|
||||
}
|
||||
|
||||
int ProcessMemoryProvider::size() const
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return m_handle ? 0x10000 : 0;
|
||||
#elif defined(__linux__)
|
||||
return (m_fd >= 0) ? 0x10000 : 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// ProcessMemoryPlugin implementation
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -119,27 +329,26 @@ std::unique_ptr<rcx::Provider> ProcessMemoryPlugin::createProvider(const QString
|
||||
// Parse target: "pid:name" or just "pid"
|
||||
QStringList parts = target.split(':');
|
||||
bool ok = false;
|
||||
DWORD pid = parts[0].toUInt(&ok);
|
||||
|
||||
if (!ok || pid == 0) {
|
||||
uint32_t pid = parts[0].toUInt(&ok);
|
||||
|
||||
if (!ok || pid == 0)
|
||||
{
|
||||
if (errorMsg) *errorMsg = "Invalid PID: " + target;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
QString name = parts.size() > 1 ? parts[1] : QString("PID %1").arg(pid);
|
||||
|
||||
|
||||
auto provider = std::make_unique<ProcessMemoryProvider>(pid, name);
|
||||
if (!provider->isValid())
|
||||
{
|
||||
if (errorMsg)
|
||||
{
|
||||
*errorMsg = QString("Failed to open process %1 (PID: %2)\n"
|
||||
"Ensure the process is running and you have sufficient permissions.")
|
||||
.arg(name).arg(pid);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -151,26 +360,49 @@ uint64_t ProcessMemoryPlugin::getInitialBaseAddress(const QString& target) const
|
||||
bool ok = false;
|
||||
DWORD pid = parts[0].toUInt(&ok);
|
||||
if (!ok || pid == 0) return 0;
|
||||
|
||||
|
||||
// Open process to get main module base
|
||||
HANDLE hProc = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
|
||||
if (!hProc) return 0;
|
||||
|
||||
|
||||
uint64_t base = 0;
|
||||
HMODULE hMod = nullptr;
|
||||
DWORD needed = 0;
|
||||
|
||||
|
||||
if (EnumProcessModulesEx(hProc, &hMod, sizeof(hMod), &needed, LIST_MODULES_ALL) && hMod)
|
||||
{
|
||||
MODULEINFO mi{};
|
||||
if (GetModuleInformation(hProc, hMod, &mi, sizeof(mi)))
|
||||
{
|
||||
base = (uint64_t)mi.lpBaseOfDll;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
CloseHandle(hProc);
|
||||
return base;
|
||||
#elif defined(__linux__)
|
||||
// Parse PID from target
|
||||
QStringList parts = target.split(':');
|
||||
bool ok = false;
|
||||
uint32_t pid = parts[0].toUInt(&ok);
|
||||
if (!ok || pid == 0) return 0;
|
||||
|
||||
// Find first executable mapping from /proc/<pid>/maps
|
||||
QString mapsPath = QStringLiteral("/proc/%1/maps").arg(pid);
|
||||
std::ifstream mapsFile(mapsPath.toStdString());
|
||||
if (!mapsFile.is_open()) return 0;
|
||||
|
||||
std::string line;
|
||||
while (std::getline(mapsFile, line)) {
|
||||
std::istringstream iss(line);
|
||||
std::string addrRange, perms;
|
||||
iss >> addrRange >> perms;
|
||||
if (perms.size() >= 3 && perms[2] == 'x') {
|
||||
auto dash = addrRange.find('-');
|
||||
if (dash != std::string::npos) {
|
||||
return std::stoull(addrRange.substr(0, dash), nullptr, 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
#else
|
||||
Q_UNUSED(target);
|
||||
return 0;
|
||||
@@ -181,7 +413,7 @@ bool ProcessMemoryPlugin::selectTarget(QWidget* parent, QString* target)
|
||||
{
|
||||
// Use custom process enumeration from plugin
|
||||
QVector<PluginProcessInfo> pluginProcesses = enumerateProcesses();
|
||||
|
||||
|
||||
// Convert to ProcessInfo for ProcessPicker
|
||||
QList<ProcessInfo> processes;
|
||||
for (const auto& pinfo : pluginProcesses)
|
||||
@@ -193,72 +425,115 @@ bool ProcessMemoryPlugin::selectTarget(QWidget* parent, QString* target)
|
||||
info.icon = pinfo.icon;
|
||||
processes.append(info);
|
||||
}
|
||||
|
||||
|
||||
// Show ProcessPicker with custom process list
|
||||
ProcessPicker picker(processes, parent);
|
||||
if (picker.exec() == QDialog::Accepted) {
|
||||
uint32_t pid = picker.selectedProcessId();
|
||||
QString name = picker.selectedProcessName();
|
||||
|
||||
|
||||
// Format target as "pid:name"
|
||||
*target = QString("%1:%2").arg(pid).arg(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
|
||||
{
|
||||
QVector<PluginProcessInfo> processes;
|
||||
|
||||
|
||||
#ifdef _WIN32
|
||||
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snapshot == INVALID_HANDLE_VALUE) {
|
||||
return processes;
|
||||
}
|
||||
|
||||
|
||||
PROCESSENTRY32W entry;
|
||||
entry.dwSize = sizeof(entry);
|
||||
|
||||
|
||||
if (Process32FirstW(snapshot, &entry)) {
|
||||
do {
|
||||
PluginProcessInfo info;
|
||||
info.pid = entry.th32ProcessID;
|
||||
info.name = QString::fromWCharArray(entry.szExeFile);
|
||||
|
||||
|
||||
// Try to get full path and icon
|
||||
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, entry.th32ProcessID);
|
||||
if (hProcess) {
|
||||
wchar_t path[MAX_PATH * 2];
|
||||
DWORD pathLen = sizeof(path) / sizeof(wchar_t);
|
||||
|
||||
|
||||
// Try QueryFullProcessImageNameW first
|
||||
if (QueryFullProcessImageNameW(hProcess, 0, path, &pathLen)) {
|
||||
info.path = QString::fromWCharArray(path);
|
||||
|
||||
|
||||
// Extract icon
|
||||
SHFILEINFOW sfi = {};
|
||||
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
|
||||
if (sfi.hIcon) {
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
QPixmap pixmap = QPixmap::fromImage(QImage::fromHICON(sfi.hIcon));
|
||||
#else
|
||||
QPixmap pixmap = QtWin::fromHICON(sfi.hIcon);
|
||||
#endif
|
||||
info.icon = QIcon(pixmap);
|
||||
DestroyIcon(sfi.hIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
CloseHandle(hProcess);
|
||||
}
|
||||
|
||||
|
||||
processes.append(info);
|
||||
|
||||
|
||||
} while (Process32NextW(snapshot, &entry));
|
||||
}
|
||||
|
||||
|
||||
CloseHandle(snapshot);
|
||||
#elif defined(__linux__)
|
||||
QDir procDir("/proc");
|
||||
QStringList entries = procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
QIcon defaultIcon = qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
|
||||
|
||||
for (const QString& entry : entries) {
|
||||
bool ok = false;
|
||||
uint32_t pid = entry.toUInt(&ok);
|
||||
if (!ok || pid == 0) continue;
|
||||
|
||||
// Read process name from /proc/<pid>/comm
|
||||
QString commPath = QStringLiteral("/proc/%1/comm").arg(pid);
|
||||
QFile commFile(commPath);
|
||||
QString procName;
|
||||
if (commFile.open(QIODevice::ReadOnly)) {
|
||||
procName = QString::fromUtf8(commFile.readAll()).trimmed();
|
||||
commFile.close();
|
||||
}
|
||||
if (procName.isEmpty()) continue; // Skip kernel threads with no name
|
||||
|
||||
// Read exe path from /proc/<pid>/exe symlink
|
||||
QString exePath = QStringLiteral("/proc/%1/exe").arg(pid);
|
||||
QFileInfo exeInfo(exePath);
|
||||
QString resolvedPath;
|
||||
if (exeInfo.exists())
|
||||
resolvedPath = exeInfo.symLinkTarget();
|
||||
|
||||
// Skip if we can't read the process memory (no access)
|
||||
QString memPath = QStringLiteral("/proc/%1/mem").arg(pid);
|
||||
if (::access(memPath.toUtf8().constData(), R_OK) != 0)
|
||||
continue;
|
||||
|
||||
PluginProcessInfo info;
|
||||
info.pid = pid;
|
||||
info.name = procName;
|
||||
info.path = resolvedPath;
|
||||
info.icon = defaultIcon;
|
||||
processes.append(info);
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
return processes;
|
||||
}
|
||||
|
||||
@@ -266,7 +541,7 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
|
||||
// Plugin factory
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
extern "C" __declspec(dllexport) IPlugin* CreatePlugin()
|
||||
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin()
|
||||
{
|
||||
return new ProcessMemoryPlugin();
|
||||
}
|
||||
|
||||
@@ -1,42 +1,49 @@
|
||||
#pragma once
|
||||
#include "../../src/iplugin.h"
|
||||
#include "../../src/core.h"
|
||||
#include <windows.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <psapi.h>
|
||||
#include <shellapi.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* Windows process memory provider
|
||||
* Reads/writes memory from a live process using Win32 API
|
||||
* Process memory provider
|
||||
* Reads/writes memory from a live process using platform APIs
|
||||
*/
|
||||
class ProcessMemoryProvider : public rcx::Provider {
|
||||
class ProcessMemoryProvider : public rcx::Provider
|
||||
{
|
||||
public:
|
||||
ProcessMemoryProvider(DWORD pid, const QString& processName);
|
||||
ProcessMemoryProvider(uint32_t pid, const QString& processName);
|
||||
~ProcessMemoryProvider() override;
|
||||
|
||||
|
||||
// Required overrides
|
||||
bool read(uint64_t addr, void* buf, int len) const override;
|
||||
int size() const override { return m_handle ? INT_MAX : NULL; } // Process memory has no fixed size
|
||||
|
||||
int size() const override;
|
||||
|
||||
// Optional overrides
|
||||
bool write(uint64_t addr, const void* buf, int len) override;
|
||||
bool isWritable() const override { return m_writable; }
|
||||
QString name() const override { return m_processName; }
|
||||
QString kind() const override { return QStringLiteral("LocalProcess"); }
|
||||
QString getSymbol(uint64_t addr) const override;
|
||||
|
||||
|
||||
bool isLive() const override { return true; }
|
||||
uint64_t base() const override { return m_base; }
|
||||
void setBase(uint64_t b) override { m_base = b; }
|
||||
|
||||
// Process-specific helpers
|
||||
DWORD pid() const { return m_pid; }
|
||||
uint32_t pid() const { return m_pid; }
|
||||
uint64_t baseAddress() const { return m_base; }
|
||||
void refreshModules() { m_modules.clear(); cacheModules(); }
|
||||
|
||||
private:
|
||||
void cacheModules();
|
||||
|
||||
|
||||
private:
|
||||
HANDLE m_handle;
|
||||
DWORD m_pid;
|
||||
#ifdef _WIN32
|
||||
void* m_handle;
|
||||
#elif defined(__linux__)
|
||||
int m_fd;
|
||||
#endif
|
||||
uint32_t m_pid;
|
||||
QString m_processName;
|
||||
bool m_writable;
|
||||
uint64_t m_base;
|
||||
@@ -52,24 +59,25 @@ private:
|
||||
/**
|
||||
* Plugin that provides ProcessMemoryProvider
|
||||
*/
|
||||
class ProcessMemoryPlugin : public IProviderPlugin {
|
||||
class ProcessMemoryPlugin : public IProviderPlugin
|
||||
{
|
||||
public:
|
||||
std::string Name() const override { return "Process Memory"; }
|
||||
std::string Version() const override { return "1.0.0"; }
|
||||
std::string Author() const override { return "ReclassX"; }
|
||||
std::string Description() const override { return "Read and write memory from local running Windows processes"; }
|
||||
std::string Author() const override { return "Reclass"; }
|
||||
std::string Description() const override { return "Read and write memory from local running processes"; }
|
||||
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;
|
||||
|
||||
|
||||
// Optional: provide custom process list
|
||||
bool providesProcessList() const override { return true; }
|
||||
QVector<PluginProcessInfo> enumerateProcesses() override;
|
||||
};
|
||||
|
||||
// Plugin export
|
||||
extern "C" __declspec(dllexport) IPlugin* CreatePlugin();
|
||||
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();
|
||||
|
||||
34
plugins/WinDbgMemory/CMakeLists.txt
Normal file
34
plugins/WinDbgMemory/CMakeLists.txt
Normal 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"
|
||||
)
|
||||
510
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp
Normal file
510
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp
Normal 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();
|
||||
}
|
||||
122
plugins/WinDbgMemory/WinDbgMemoryPlugin.h
Normal file
122
plugins/WinDbgMemory/WinDbgMemoryPlugin.h
Normal 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();
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 63 KiB |
@@ -1,4 +1,4 @@
|
||||
# PowerShell script to build ReclassX
|
||||
# PowerShell script to build Reclass
|
||||
# Automatically detects Qt installation and configures build environment
|
||||
|
||||
#Requires -Version 5.1
|
||||
@@ -303,7 +303,7 @@ function Find-MinGWDirectory {
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-ColorOutput "`n========================================" Cyan
|
||||
Write-ColorOutput "ReclassX Build Script" Cyan
|
||||
Write-ColorOutput "Reclass Build Script" Cyan
|
||||
Write-ColorOutput "========================================`n" Cyan
|
||||
|
||||
# Get script directory and project root
|
||||
@@ -426,7 +426,7 @@ try {
|
||||
Write-ColorOutput "`nCMake configuration completed successfully.`n" Green
|
||||
|
||||
# Build
|
||||
Write-ColorOutput "Building ReclassX..." Cyan
|
||||
Write-ColorOutput "Building Reclass..." Cyan
|
||||
|
||||
$cores = (Get-CimInstance -ClassName Win32_Processor).NumberOfLogicalProcessors
|
||||
if (-not $cores -or $cores -lt 1) {
|
||||
@@ -445,8 +445,8 @@ try {
|
||||
# Find executable
|
||||
Write-ColorOutput "Locating executable..." Cyan
|
||||
$exePaths = @(
|
||||
(Join-Path $buildDir "ReclassX.exe"),
|
||||
(Join-Path $buildDir "$BuildType\ReclassX.exe")
|
||||
(Join-Path $buildDir "Reclass.exe"),
|
||||
(Join-Path $buildDir "$BuildType\Reclass.exe")
|
||||
)
|
||||
|
||||
$exePath = $null
|
||||
@@ -477,7 +477,7 @@ try {
|
||||
|
||||
# Count deployed files
|
||||
$deployedFiles = Get-ChildItem -Path $exeDir -Recurse -File | Where-Object {
|
||||
$_.Name -ne "ReclassX.exe" -and $_.Extension -match '\.(dll|qm)$'
|
||||
$_.Name -ne "Reclass.exe" -and $_.Extension -match '\.(dll|qm)$'
|
||||
}
|
||||
if ($deployedFiles) {
|
||||
Write-ColorOutput "Deployed $($deployedFiles.Count) Qt dependency files." Gray
|
||||
@@ -491,7 +491,7 @@ try {
|
||||
Write-ColorOutput "Application may not run without Qt DLLs in PATH" Yellow
|
||||
}
|
||||
} else {
|
||||
Write-ColorOutput "WARNING: Could not locate ReclassX.exe" Yellow
|
||||
Write-ColorOutput "WARNING: Could not locate Reclass.exe" Yellow
|
||||
}
|
||||
|
||||
} catch {
|
||||
@@ -507,5 +507,5 @@ Write-ColorOutput "========================================`n" Cyan
|
||||
|
||||
if ($exePath) {
|
||||
Write-ColorOutput "Run the application with:" White
|
||||
Write-ColorOutput " .\build\ReclassX.exe`n" Cyan
|
||||
Write-ColorOutput " .\build\Reclass.exe`n" Cyan
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# PowerShell script to build QScintilla static library for ReclassX
|
||||
# PowerShell script to build QScintilla static library for Reclass
|
||||
# This script checks for Qt installation, prompts if missing, and builds QScintilla
|
||||
|
||||
#Requires -Version 5.1
|
||||
@@ -272,7 +272,7 @@ function Find-MakeCommand {
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Write-ColorOutput "`n========================================" Cyan
|
||||
Write-ColorOutput "QScintilla Build Script for ReclassX" Cyan
|
||||
Write-ColorOutput "QScintilla Build Script for Reclass" Cyan
|
||||
Write-ColorOutput "========================================`n" Cyan
|
||||
|
||||
# Get script directory and project root
|
||||
@@ -423,7 +423,7 @@ try {
|
||||
Write-Host " - $($lib.Name) ($sizeMB MB)" -ForegroundColor Green
|
||||
Write-Host " Path: $($lib.Path)" -ForegroundColor Gray
|
||||
}
|
||||
Write-ColorOutput "`nYou can now build ReclassX with CMake." Green
|
||||
Write-ColorOutput "`nYou can now build Reclass with CMake." Green
|
||||
} else {
|
||||
Write-ColorOutput "`nWARNING: Build completed but no library files found." Yellow
|
||||
Write-ColorOutput "Expected files: qscintilla2_qt6.a or qscintilla2_qt6.lib" Yellow
|
||||
|
||||
1
src/app.rc
Normal file
1
src/app.rc
Normal file
@@ -0,0 +1 @@
|
||||
IDI_ICON1 ICON "icons/class.ico"
|
||||
300
src/compose.cpp
300
src/compose.cpp
@@ -1,5 +1,6 @@
|
||||
#include "core.h"
|
||||
#include <algorithm>
|
||||
#include <numeric>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
@@ -13,11 +14,13 @@ 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)
|
||||
int offsetHexDigits = 8; // hex digit tier for offset margin
|
||||
bool baseEmitted = false; // only first root struct shows base address
|
||||
|
||||
// Precomputed for O(1) lookups
|
||||
@@ -62,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;
|
||||
}
|
||||
@@ -116,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;
|
||||
@@ -144,7 +139,8 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
|
||||
lm.isContinuation = isCont;
|
||||
lm.lineKind = isCont ? LineKind::Continuation : LineKind::Field;
|
||||
lm.nodeKind = node.kind;
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, isCont);
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, isCont, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress + absAddr;
|
||||
lm.markerMask = computeMarkers(node, prov, absAddr, isCont, depth);
|
||||
lm.foldLevel = computeFoldLevel(depth, false);
|
||||
lm.effectiveTypeW = typeW;
|
||||
@@ -153,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,
|
||||
@@ -171,16 +162,19 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
|
||||
void composeNode(ComposeState& state, const NodeTree& tree,
|
||||
const Provider& prov, int nodeIdx, int depth,
|
||||
uint64_t base = 0, uint64_t rootId = 0, bool isArrayChild = false,
|
||||
uint64_t scopeId = 0, int arrayElementIdx = -1);
|
||||
uint64_t scopeId = 0, int arrayElementIdx = -1,
|
||||
uint64_t arrayContainerAddr = 0);
|
||||
void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
const Provider& prov, int nodeIdx, int depth,
|
||||
uint64_t base = 0, uint64_t rootId = 0, bool isArrayChild = false,
|
||||
uint64_t scopeId = 0, int arrayElementIdx = -1);
|
||||
uint64_t scopeId = 0, int arrayElementIdx = -1,
|
||||
uint64_t arrayContainerAddr = 0);
|
||||
|
||||
void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
const Provider& prov, int nodeIdx, int depth,
|
||||
uint64_t base, uint64_t rootId, bool isArrayChild,
|
||||
uint64_t scopeId, int arrayElementIdx) {
|
||||
uint64_t scopeId, int arrayElementIdx,
|
||||
uint64_t arrayContainerAddr) {
|
||||
const Node& node = tree.nodes[nodeIdx];
|
||||
uint64_t absAddr = resolveAddr(state, tree, nodeIdx, base, rootId);
|
||||
|
||||
@@ -191,7 +185,8 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
lm.nodeId = node.id;
|
||||
lm.depth = depth;
|
||||
lm.lineKind = LineKind::Field;
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress + absAddr;
|
||||
lm.nodeKind = node.kind;
|
||||
lm.markerMask = (1u << M_CYCLE) | (1u << M_ERR);
|
||||
lm.foldLevel = computeFoldLevel(depth, false);
|
||||
@@ -208,12 +203,15 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
lm.nodeId = node.id;
|
||||
lm.depth = depth;
|
||||
lm.lineKind = LineKind::ArrayElementSeparator;
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress + absAddr;
|
||||
lm.nodeKind = node.kind;
|
||||
lm.foldLevel = computeFoldLevel(depth, false);
|
||||
lm.markerMask = 0;
|
||||
lm.arrayElementIdx = arrayElementIdx;
|
||||
state.emitLine(fmt::indent(depth) + QStringLiteral("[%1]").arg(arrayElementIdx), lm);
|
||||
uint64_t relOff = absAddr - arrayContainerAddr;
|
||||
QString relOffHex = QString::number(relOff, 16).toUpper();
|
||||
state.emitLine(fmt::indent(depth) + QStringLiteral("[%1] +0x%2").arg(arrayElementIdx).arg(relOffHex), lm);
|
||||
}
|
||||
|
||||
// Detect root header: first root-level struct — suppressed from display
|
||||
@@ -234,7 +232,8 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
lm.nodeId = node.id;
|
||||
lm.depth = depth;
|
||||
lm.lineKind = LineKind::Header;
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress + absAddr;
|
||||
lm.nodeKind = node.kind;
|
||||
lm.isRootHeader = false;
|
||||
lm.foldHead = true;
|
||||
@@ -251,7 +250,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
lm.elementKind = node.elementKind;
|
||||
lm.arrayViewIdx = node.viewIndex;
|
||||
lm.arrayCount = node.arrayLen;
|
||||
headerText = fmt::fmtArrayHeader(node, depth, node.viewIndex, node.collapsed, typeW, nameW);
|
||||
QString elemStructName = (node.elementKind == NodeKind::Struct)
|
||||
? resolvePointerTarget(tree, node.refId) : QString();
|
||||
headerText = fmt::fmtArrayHeader(node, depth, node.viewIndex, node.collapsed, typeW, nameW, elemStructName);
|
||||
} else {
|
||||
// All structs (root and nested) use the same header format
|
||||
headerText = fmt::fmtStructHeader(node, depth, node.collapsed, typeW, nameW);
|
||||
@@ -267,6 +268,105 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
|
||||
int childDepth = depth + 1;
|
||||
|
||||
// Primitive arrays with no child nodes: synthesize element lines dynamically
|
||||
if (node.kind == NodeKind::Array && children.isEmpty()
|
||||
&& node.elementKind != NodeKind::Struct && node.elementKind != NodeKind::Array) {
|
||||
int elemSize = sizeForKind(node.elementKind);
|
||||
int eTW = state.effectiveTypeW(node.id);
|
||||
int eNW = state.effectiveNameW(node.id);
|
||||
for (int i = 0; i < node.arrayLen; i++) {
|
||||
uint64_t elemAddr = absAddr + i * elemSize;
|
||||
|
||||
// Type override: "float[0]", "uint32_t[1]", etc.
|
||||
QString elemTypeStr = fmt::typeNameRaw(node.elementKind)
|
||||
+ QStringLiteral("[%1]").arg(i);
|
||||
|
||||
Node elem;
|
||||
elem.kind = node.elementKind;
|
||||
elem.name = QString(); // no name for array elements
|
||||
elem.offset = node.offset + i * elemSize;
|
||||
elem.parentId = node.id;
|
||||
elem.id = 0;
|
||||
|
||||
LineMeta lm;
|
||||
lm.nodeIdx = nodeIdx;
|
||||
lm.nodeId = node.id;
|
||||
lm.depth = childDepth;
|
||||
lm.lineKind = LineKind::Field;
|
||||
lm.nodeKind = node.elementKind;
|
||||
lm.isArrayElement = true;
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + elemAddr, false, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress + elemAddr;
|
||||
lm.markerMask = computeMarkers(elem, prov, elemAddr, false, childDepth);
|
||||
lm.foldLevel = computeFoldLevel(childDepth, false);
|
||||
lm.effectiveTypeW = eTW;
|
||||
lm.effectiveNameW = eNW;
|
||||
|
||||
state.emitLine(fmt::fmtNodeLine(elem, prov, elemAddr, childDepth, 0,
|
||||
{}, eTW, eNW, elemTypeStr), lm);
|
||||
}
|
||||
}
|
||||
|
||||
// Struct arrays with refId but no child nodes: synthesize by expanding the
|
||||
// referenced struct for each element (like repeated pointer deref)
|
||||
if (node.kind == NodeKind::Array && children.isEmpty()
|
||||
&& node.elementKind == NodeKind::Struct && node.refId != 0) {
|
||||
int refIdx = tree.indexOfId(node.refId);
|
||||
if (refIdx >= 0) {
|
||||
int elemSize = tree.structSpan(node.refId, &state.childMap);
|
||||
if (elemSize <= 0) elemSize = 1;
|
||||
for (int i = 0; i < node.arrayLen; i++) {
|
||||
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
|
||||
// Use base offset that maps refStruct's children to the right provider address
|
||||
composeParent(state, tree, prov, refIdx, childDepth, elemBase, node.refId,
|
||||
/*isArrayChild=*/true, node.id, i, absAddr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Embedded struct with refId but no child nodes: expand referenced struct's
|
||||
// children at this node's offset (single instance, like array with count=1)
|
||||
if (node.kind == NodeKind::Struct && children.isEmpty() && node.refId != 0) {
|
||||
int refIdx = tree.indexOfId(node.refId);
|
||||
if (refIdx >= 0) {
|
||||
QVector<int> refChildren = state.childMap.value(node.refId);
|
||||
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) {
|
||||
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, refScopeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For arrays, render children as condensed (no header/footer for struct elements)
|
||||
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
|
||||
int elementIdx = 0;
|
||||
@@ -275,7 +375,8 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
// For array elements, also pass the element index for [N] separator
|
||||
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
|
||||
childrenAreArrayElements, node.id,
|
||||
childrenAreArrayElements ? elementIdx++ : -1);
|
||||
childrenAreArrayElements ? elementIdx++ : -1,
|
||||
childrenAreArrayElements ? absAddr : 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,10 +389,11 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
lm.lineKind = LineKind::Footer;
|
||||
lm.nodeKind = node.kind;
|
||||
lm.isRootHeader = isRootHeader; // root footer: flush left (no fold prefix)
|
||||
lm.offsetText.clear();
|
||||
lm.foldLevel = computeFoldLevel(depth, false);
|
||||
lm.markerMask = 0;
|
||||
int sz = tree.structSpan(node.id, &state.childMap);
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr + sz, false, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress + absAddr + sz;
|
||||
state.emitLine(fmt::fmtStructFooter(node, depth, sz), lm);
|
||||
}
|
||||
|
||||
@@ -301,7 +403,8 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
||||
void composeNode(ComposeState& state, const NodeTree& tree,
|
||||
const Provider& prov, int nodeIdx, int depth,
|
||||
uint64_t base, uint64_t rootId, bool isArrayChild,
|
||||
uint64_t scopeId, int arrayElementIdx) {
|
||||
uint64_t scopeId, int arrayElementIdx,
|
||||
uint64_t arrayContainerAddr) {
|
||||
const Node& node = tree.nodes[nodeIdx];
|
||||
uint64_t absAddr = resolveAddr(state, tree, nodeIdx, base, rootId);
|
||||
|
||||
@@ -315,54 +418,105 @@ 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.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show referenced struct children: at dereferenced address if non-NULL,
|
||||
// otherwise at offset 0 as a struct template preview
|
||||
// Determine if pointer target is actually readable
|
||||
uint64_t pBase = (ptrVal != 0) ? ptrToProviderAddr(tree, ptrVal) : 0;
|
||||
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, prov, refIdx,
|
||||
depth, pBase, ref.id,
|
||||
/*isArrayChild=*/true);
|
||||
bool ptrReadable = (ptrVal != 0) && prov.isReadable(pBase, 1);
|
||||
|
||||
// For invalid/unreadable pointers: use NullProvider (shows zeros)
|
||||
// and reset margin offsets (unsigned wrap cancels baseAddress)
|
||||
static NullProvider s_nullProv;
|
||||
const Provider& childProv = ptrReadable ? prov : static_cast<const Provider&>(s_nullProv);
|
||||
if (!ptrReadable)
|
||||
pBase = (uint64_t)0 - tree.baseAddress;
|
||||
|
||||
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
|
||||
@@ -383,7 +537,7 @@ void composeNode(ComposeState& state, const NodeTree& tree,
|
||||
}
|
||||
|
||||
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
|
||||
composeParent(state, tree, prov, nodeIdx, depth, base, rootId, isArrayChild, scopeId, arrayElementIdx);
|
||||
composeParent(state, tree, prov, nodeIdx, depth, base, rootId, isArrayChild, scopeId, arrayElementIdx, arrayContainerAddr);
|
||||
} else {
|
||||
composeLeaf(state, tree, prov, nodeIdx, depth, absAddr, scopeId);
|
||||
}
|
||||
@@ -403,10 +557,26 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
||||
for (int i = 0; i < tree.nodes.size(); i++)
|
||||
state.absOffsets[i] = tree.computeOffset(i);
|
||||
|
||||
// Compute hex digit tier from max absolute address
|
||||
{
|
||||
uint64_t maxAddr = tree.baseAddress;
|
||||
for (int i = 0; i < tree.nodes.size(); i++) {
|
||||
uint64_t addr = tree.baseAddress + (uint64_t)state.absOffsets[i];
|
||||
if (addr > maxAddr) maxAddr = addr;
|
||||
}
|
||||
if (maxAddr <= 0xFFFFULL) state.offsetHexDigits = 4;
|
||||
else if (maxAddr <= 0xFFFFFFFFULL) state.offsetHexDigits = 8;
|
||||
else if (maxAddr <= 0xFFFFFFFFFFFFULL) state.offsetHexDigits = 12;
|
||||
else state.offsetHexDigits = 16;
|
||||
}
|
||||
|
||||
// Helper: compute the display type string for a node (for width calculation)
|
||||
auto nodeTypeName = [&](const Node& n) -> QString {
|
||||
if (n.kind == NodeKind::Array)
|
||||
return fmt::arrayTypeName(n.elementKind, n.arrayLen);
|
||||
if (n.kind == NodeKind::Array) {
|
||||
QString sn = (n.elementKind == NodeKind::Struct)
|
||||
? resolvePointerTarget(tree, n.refId) : QString();
|
||||
return fmt::arrayTypeName(n.elementKind, n.arrayLen, sn);
|
||||
}
|
||||
if (n.kind == NodeKind::Struct)
|
||||
return fmt::structTypeName(n);
|
||||
if (n.kind == NodeKind::Pointer32 || n.kind == NodeKind::Pointer64)
|
||||
@@ -426,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());
|
||||
}
|
||||
@@ -445,12 +615,25 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
// Primitive arrays with no tree children: account for synthesized element types
|
||||
// e.g. "uint32_t[0]", "uint32_t[99]" — longest index determines width
|
||||
if (container.kind == NodeKind::Array
|
||||
&& state.childMap.value(container.id).isEmpty()
|
||||
&& container.elementKind != NodeKind::Struct
|
||||
&& container.elementKind != NodeKind::Array
|
||||
&& container.arrayLen > 0) {
|
||||
int maxIdx = container.arrayLen - 1;
|
||||
QString longestElemType = fmt::typeNameRaw(container.elementKind)
|
||||
+ QStringLiteral("[%1]").arg(maxIdx);
|
||||
scopeMaxType = qMax(scopeMaxType, (int)longestElemType.size());
|
||||
}
|
||||
|
||||
state.scopeTypeW[container.id] = qBound(kMinTypeW, scopeMaxType, kMaxTypeW);
|
||||
state.scopeNameW[container.id] = qBound(kMinNameW, scopeMaxName, kMaxNameW);
|
||||
}
|
||||
@@ -464,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());
|
||||
}
|
||||
@@ -474,7 +657,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
||||
}
|
||||
|
||||
// Emit CommandRow as line 0 (combined: source + address + root class type + name)
|
||||
const QString cmdRowText = QStringLiteral("source\u25BE \u00B7 0x0 \u00B7 struct\u25BE <no class> {");
|
||||
const QString cmdRowText = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x0 \u00B7 struct\u25BE NoName {");
|
||||
{
|
||||
LineMeta lm;
|
||||
lm.nodeIdx = -1;
|
||||
@@ -483,7 +666,8 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
||||
lm.lineKind = LineKind::CommandRow;
|
||||
lm.foldLevel = SC_FOLDLEVELBASE;
|
||||
lm.foldHead = false;
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress, false);
|
||||
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress, false, state.offsetHexDigits);
|
||||
lm.offsetAddr = tree.baseAddress;
|
||||
lm.markerMask = 0;
|
||||
lm.effectiveTypeW = state.typeW;
|
||||
lm.effectiveNameW = state.nameW;
|
||||
@@ -502,7 +686,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
||||
composeNode(state, tree, prov, idx, 0);
|
||||
}
|
||||
|
||||
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW} };
|
||||
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress} };
|
||||
}
|
||||
|
||||
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,15 @@
|
||||
#include <QUndoCommand>
|
||||
#include <QTimer>
|
||||
#include <QFutureWatcher>
|
||||
#include <QPointer>
|
||||
#include <memory>
|
||||
|
||||
class QSplitter;
|
||||
|
||||
namespace rcx {
|
||||
|
||||
class RcxController;
|
||||
class TypeSelectorPopup;
|
||||
struct TypeEntry;
|
||||
enum class TypePopupMode;
|
||||
|
||||
// ── Document ──
|
||||
|
||||
@@ -63,11 +65,10 @@ private:
|
||||
// ── Saved source entry ──
|
||||
|
||||
struct SavedSourceEntry {
|
||||
QString kind; // "File" or "Process"
|
||||
QString kind; // "File" or provider identifier (e.g. "processmemory")
|
||||
QString displayName; // filename or process name
|
||||
QString filePath; // for File sources
|
||||
uint32_t pid = 0; // for Process sources
|
||||
QString processName; // for Process sources
|
||||
QString providerTarget; // for plugin providers (e.g. "pid:name")
|
||||
uint64_t baseAddress = 0;
|
||||
};
|
||||
|
||||
@@ -80,7 +81,7 @@ public:
|
||||
~RcxController() override;
|
||||
|
||||
RcxEditor* primaryEditor() const;
|
||||
RcxEditor* addSplitEditor(QSplitter* splitter);
|
||||
RcxEditor* addSplitEditor(QWidget* parent = nullptr);
|
||||
void removeSplitEditor(RcxEditor* editor);
|
||||
QList<RcxEditor*> editors() const { return m_editors; }
|
||||
|
||||
@@ -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);
|
||||
@@ -112,6 +114,13 @@ public:
|
||||
RcxDocument* document() const { return m_doc; }
|
||||
void setEditorFont(const QString& fontName);
|
||||
|
||||
// MCP bridge accessors
|
||||
void setSuppressRefresh(bool v) { m_suppressRefresh = v; }
|
||||
void attachViaPlugin(const QString& providerIdentifier, const QString& target);
|
||||
const QVector<SavedSourceEntry>& savedSources() const { return m_savedSources; }
|
||||
int activeSourceIndex() const { return m_activeSourceIdx; }
|
||||
void switchSource(int idx) { switchToSavedSource(idx); }
|
||||
|
||||
signals:
|
||||
void nodeSelected(int nodeIdx);
|
||||
void selectionChanged(int count);
|
||||
@@ -129,11 +138,15 @@ private:
|
||||
QVector<SavedSourceEntry> m_savedSources;
|
||||
int m_activeSourceIdx = -1;
|
||||
|
||||
// ── Cached type selector popup (avoids ~350ms cold-start on first show) ──
|
||||
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;
|
||||
@@ -143,9 +156,11 @@ private:
|
||||
void handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers mods);
|
||||
void updateCommandRow();
|
||||
void performRealignment(uint64_t structId, int targetAlign);
|
||||
void attachToProcess(uint32_t pid, const QString& processName);
|
||||
void switchToSavedSource(int idx);
|
||||
void pushSavedSourcesToEditors();
|
||||
void showTypePopup(RcxEditor* editor, TypePopupMode mode, int nodeIdx, QPoint globalPos);
|
||||
void applyTypePopupResult(TypePopupMode mode, int nodeIdx, const TypeEntry& entry, const QString& fullText);
|
||||
TypeSelectorPopup* ensurePopup(RcxEditor* editor);
|
||||
|
||||
// ── Auto-refresh methods ──
|
||||
void setupAutoRefresh();
|
||||
@@ -153,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
|
||||
|
||||
110
src/core.h
110
src/core.h
@@ -27,15 +27,20 @@ enum class NodeKind : uint8_t {
|
||||
Pointer32, Pointer64,
|
||||
Vec2, Vec3, Vec4, Mat4x4,
|
||||
UTF8, UTF16,
|
||||
Padding,
|
||||
Struct, Array
|
||||
};
|
||||
|
||||
// ── Kind flags (replaces repeated Hex/Padding switches) ──
|
||||
} // namespace rcx (temporarily close for qHash)
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
|
||||
inline uint qHash(rcx::NodeKind key, uint seed = 0) { return ::qHash(static_cast<uint8_t>(key), seed); }
|
||||
#endif
|
||||
namespace rcx { // reopen
|
||||
|
||||
// ── 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
|
||||
@@ -78,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},
|
||||
};
|
||||
@@ -128,6 +132,9 @@ inline constexpr bool isHexNode(NodeKind k) {
|
||||
inline constexpr bool isVectorKind(NodeKind k) {
|
||||
return k == NodeKind::Vec2 || k == NodeKind::Vec3 || k == NodeKind::Vec4;
|
||||
}
|
||||
inline constexpr bool isMatrixKind(NodeKind k) {
|
||||
return k == NodeKind::Mat4x4;
|
||||
}
|
||||
|
||||
inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
|
||||
QStringList out;
|
||||
@@ -146,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,
|
||||
@@ -154,6 +160,7 @@ enum Marker : int {
|
||||
M_HOVER = 6,
|
||||
M_SELECTED = 7,
|
||||
M_CMD_ROW = 8,
|
||||
M_ACCENT = 9,
|
||||
};
|
||||
|
||||
// ── Node ──
|
||||
@@ -177,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);
|
||||
}
|
||||
}
|
||||
@@ -211,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"));
|
||||
@@ -224,11 +234,12 @@ struct Node {
|
||||
return classKeyword.isEmpty() ? QStringLiteral("struct") : classKeyword;
|
||||
}
|
||||
|
||||
// Helper: is this a string-like array (char[] or wchar_t[])?
|
||||
bool isStringArray() const {
|
||||
return kind == NodeKind::Array &&
|
||||
(elementKind == NodeKind::UInt8 || elementKind == NodeKind::UInt16);
|
||||
}
|
||||
// NOTE: isStringArray() was checking UInt8/UInt16 instead of UTF8/UTF16.
|
||||
// Currently unused — commented out until a caller needs it.
|
||||
// bool isStringArray() const {
|
||||
// return kind == NodeKind::Array &&
|
||||
// (elementKind == NodeKind::UTF8 || elementKind == NodeKind::UTF16);
|
||||
// }
|
||||
};
|
||||
|
||||
// ── NodeTree ──
|
||||
@@ -355,6 +366,10 @@ struct NodeTree {
|
||||
if (end > maxEnd) maxEnd = end;
|
||||
}
|
||||
|
||||
// Embedded struct reference: no own children but refId points to a struct definition
|
||||
if (kids.isEmpty() && node.kind == NodeKind::Struct && node.refId != 0)
|
||||
maxEnd = qMax(maxEnd, structSpan(node.refId, childMap, visited));
|
||||
|
||||
return qMax(declaredSize, maxEnd);
|
||||
}
|
||||
|
||||
@@ -421,6 +436,7 @@ struct LineMeta {
|
||||
int arrayCount = 0; // Array: total element count
|
||||
int arrayElementIdx = -1; // Index of this element within parent array (-1 if not array element)
|
||||
QString offsetText;
|
||||
uint64_t offsetAddr = 0; // Raw absolute address (for margin toggle)
|
||||
uint32_t markerMask = 0;
|
||||
bool dataChanged = false; // true if any byte in this node changed since last refresh
|
||||
QVector<int> changedByteIndices; // Hex preview: which byte indices (0-based) changed on this line
|
||||
@@ -428,6 +444,7 @@ struct LineMeta {
|
||||
int effectiveTypeW = 14; // Per-line type column width used for rendering
|
||||
int effectiveNameW = 22; // Per-line name column width used for rendering
|
||||
QString pointerTargetName; // Resolved target type name for Pointer32/64 (empty = "void")
|
||||
bool isArrayElement = false; // true for synthesized primitive array element lines
|
||||
};
|
||||
|
||||
inline bool isSyntheticLine(const LineMeta& lm) {
|
||||
@@ -439,6 +456,8 @@ inline bool isSyntheticLine(const LineMeta& lm) {
|
||||
struct LayoutInfo {
|
||||
int typeW = 14; // Effective type column width (default = kColType)
|
||||
int nameW = 22; // Effective name column width (default = kColName)
|
||||
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
|
||||
uint64_t baseAddress = 0; // Base address for relative offset computation
|
||||
};
|
||||
|
||||
// ── ComposeResult ──
|
||||
@@ -489,13 +508,13 @@ struct ColumnSpan {
|
||||
|
||||
enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount,
|
||||
ArrayElementType, ArrayElementCount, PointerTarget,
|
||||
RootClassType, RootClassName };
|
||||
RootClassType, RootClassName, TypeSelector };
|
||||
|
||||
// Column layout constants (shared with format.cpp span computation)
|
||||
inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line
|
||||
inline constexpr int kColType = 14; // Max type column width (fits "uint64_t[999]")
|
||||
inline constexpr int kColName = 22;
|
||||
inline constexpr int kColValue = 32;
|
||||
inline constexpr int kColValue = 96;
|
||||
inline constexpr int kColComment = 28; // "// Enter=Save Esc=Cancel" fits
|
||||
inline constexpr int kColBaseAddr = 12; // "0x" + up to 10 hex digits (40-bit address)
|
||||
inline constexpr int kSepWidth = 1;
|
||||
@@ -516,9 +535,9 @@ 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 takes the name column position (8 chars)
|
||||
// Hex: ASCII preview occupies the name column (padded to nameW)
|
||||
if (isHexPreview(lm.nodeKind))
|
||||
return {start, start + 8, true};
|
||||
return {start, start + nameW, true};
|
||||
|
||||
return {start, start + nameW, true};
|
||||
}
|
||||
@@ -528,22 +547,19 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW
|
||||
lm.lineKind == LineKind::ArrayElementSeparator) return {};
|
||||
int ind = kFoldCol + lm.depth * 3;
|
||||
|
||||
// Hex/Padding layout: [Type][sep][ASCII(8)][sep][hex bytes(23)]
|
||||
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;
|
||||
|
||||
if (lm.isContinuation) {
|
||||
int prefixW = isHexPad
|
||||
? (typeW + kSepWidth + 8 + kSepWidth)
|
||||
: (typeW + nameW + 2 * kSepWidth);
|
||||
int start = ind + prefixW;
|
||||
return {start, start + valWidth, true};
|
||||
}
|
||||
if (lm.lineKind != LineKind::Field) return {};
|
||||
|
||||
int start = isHexPad
|
||||
? (ind + typeW + kSepWidth + 8 + kSepWidth)
|
||||
: (ind + typeW + kSepWidth + nameW + kSepWidth);
|
||||
int start = ind + prefixW;
|
||||
return {start, start + valWidth, true};
|
||||
}
|
||||
|
||||
@@ -551,19 +567,15 @@ 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;
|
||||
if (lm.isContinuation) {
|
||||
int prefixW = isHexPad
|
||||
? (typeW + kSepWidth + 8 + kSepWidth)
|
||||
: (typeW + nameW + 2 * kSepWidth);
|
||||
start = ind + prefixW + valWidth;
|
||||
} else {
|
||||
start = isHexPad
|
||||
? (ind + typeW + kSepWidth + 8 + kSepWidth + valWidth)
|
||||
: (ind + typeW + kSepWidth + nameW + kSepWidth + valWidth);
|
||||
start = ind + prefixW + valWidth;
|
||||
}
|
||||
return {start, lineLength, start < lineLength};
|
||||
}
|
||||
@@ -635,6 +647,16 @@ inline ColumnSpan commandRowRootNameSpan(const QString& lineText) {
|
||||
return {nameStart, nameEnd, true};
|
||||
}
|
||||
|
||||
// ── CommandRow type-selector chevron span ──
|
||||
// Detects "[▸]" at the start of the command row text
|
||||
|
||||
inline ColumnSpan commandRowChevronSpan(const QString& lineText) {
|
||||
if (lineText.size() < 3) return {};
|
||||
if (lineText[0] == '[' && lineText[1] == QChar(0x25B8) && lineText[2] == ']')
|
||||
return {0, qMin(4, (int)lineText.size()), true}; // include trailing space for easier clicking
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── Array element type/count spans (within type column of array headers) ──
|
||||
// Line format: " int32_t[10] name {"
|
||||
// arrayElemTypeSpan covers "int32_t", arrayElemCountSpan covers "10"
|
||||
@@ -657,6 +679,16 @@ inline ColumnSpan arrayElemCountSpanFor(const LineMeta& lm, const QString& lineT
|
||||
return {openBracket + 1, closeBracket, true};
|
||||
}
|
||||
|
||||
// Click-area version: includes brackets [N] for hit testing
|
||||
inline ColumnSpan arrayElemCountClickSpanFor(const LineMeta& lm, const QString& lineText) {
|
||||
if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {};
|
||||
int ind = kFoldCol + lm.depth * 3;
|
||||
int openBracket = lineText.indexOf('[', ind);
|
||||
int closeBracket = lineText.indexOf(']', openBracket);
|
||||
if (openBracket < 0 || closeBracket < 0 || closeBracket <= openBracket + 1) return {};
|
||||
return {openBracket, closeBracket + 1, true};
|
||||
}
|
||||
|
||||
// ── Pointer kind/target spans (within type column of pointer fields) ──
|
||||
// Line format: " void* name -> 0x..."
|
||||
// pointerTargetSpan covers the target name before '*'
|
||||
@@ -740,12 +772,12 @@ namespace fmt {
|
||||
uint64_t addr, int depth, int subLine = 0,
|
||||
const QString& comment = {}, int colType = kColType, int colName = kColName,
|
||||
const QString& typeOverride = {});
|
||||
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation);
|
||||
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation, int hexDigits = 8);
|
||||
QString fmtStructHeader(const Node& node, int depth, bool collapsed, int colType = kColType, int colName = kColName);
|
||||
QString fmtStructFooter(const Node& node, int depth, int totalSize = -1);
|
||||
QString fmtArrayHeader(const Node& node, int depth, int viewIdx, bool collapsed, int colType = kColType, int colName = kColName);
|
||||
QString fmtArrayHeader(const Node& node, int depth, int viewIdx, bool collapsed, int colType = kColType, int colName = kColName, const QString& elemStructName = {});
|
||||
QString structTypeName(const Node& node); // Full type string for struct headers
|
||||
QString arrayTypeName(NodeKind elemKind, int count);
|
||||
QString arrayTypeName(NodeKind elemKind, int count, const QString& structName = {});
|
||||
QString pointerTypeName(NodeKind kind, const QString& targetName);
|
||||
QString fmtPointerHeader(const Node& node, int depth, bool collapsed,
|
||||
const Provider& prov, uint64_t addr,
|
||||
|
||||
710
src/editor.cpp
710
src/editor.cpp
File diff suppressed because it is too large
Load Diff
12
src/editor.h
12
src/editor.h
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include "core.h"
|
||||
#include "themes/theme.h"
|
||||
#include <QWidget>
|
||||
#include <QSet>
|
||||
#include <QPoint>
|
||||
@@ -40,15 +41,18 @@ public:
|
||||
|
||||
// ── Inline editing ──
|
||||
bool isEditing() const { return m_editState.active; }
|
||||
bool beginInlineEdit(EditTarget target, int line = -1);
|
||||
bool beginInlineEdit(EditTarget target, int line = -1, int col = -1);
|
||||
void cancelInlineEdit();
|
||||
|
||||
void applySelectionOverlay(const QSet<uint64_t>& selIds);
|
||||
void setCommandRowText(const QString& line);
|
||||
void setEditorFont(const QString& fontName);
|
||||
static void setGlobalFontName(const QString& fontName);
|
||||
static QString globalFontName();
|
||||
void applyTheme(const Theme& theme);
|
||||
|
||||
// Custom type names (struct types from the tree) shown in type picker + lexer GlobalClass coloring
|
||||
QString textWithMargins() const;
|
||||
void setCustomTypeNames(const QStringList& names);
|
||||
|
||||
// Saved sources for quick-switch in source picker
|
||||
@@ -61,6 +65,8 @@ signals:
|
||||
void inlineEditCommitted(int nodeIdx, int subLine,
|
||||
EditTarget target, const QString& text);
|
||||
void inlineEditCancelled();
|
||||
void typeSelectorRequested();
|
||||
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* event) override;
|
||||
@@ -71,6 +77,9 @@ private:
|
||||
QVector<LineMeta> m_meta;
|
||||
LayoutInfo m_layout; // cached from ComposeResult
|
||||
|
||||
// ── Toggle: absolute vs relative offset margin
|
||||
bool m_relativeOffsets = false;
|
||||
|
||||
int m_marginStyleBase = -1;
|
||||
int m_hintLine = -1;
|
||||
|
||||
@@ -132,6 +141,7 @@ private:
|
||||
void allocateMarginStyles();
|
||||
|
||||
void applyMarginText(const QVector<LineMeta>& meta);
|
||||
void reformatMargins();
|
||||
void applyMarkers(const QVector<LineMeta>& meta);
|
||||
void applyFoldLevels(const QVector<LineMeta>& meta);
|
||||
void applyHexDimming(const QVector<LineMeta>& meta);
|
||||
|
||||
@@ -317,27 +317,27 @@
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"arrayLen": 4,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"elementKind": "Float",
|
||||
"id": "27",
|
||||
"kind": "Hex64",
|
||||
"name": "field_70",
|
||||
"kind": "Array",
|
||||
"name": "scores",
|
||||
"offset": 112,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"strLen": 64
|
||||
},
|
||||
{
|
||||
"arrayLen": 1,
|
||||
"arrayLen": 2,
|
||||
"collapsed": false,
|
||||
"elementKind": "UInt8",
|
||||
"elementKind": "Struct",
|
||||
"id": "28",
|
||||
"kind": "Hex64",
|
||||
"name": "field_78",
|
||||
"offset": 120,
|
||||
"kind": "Array",
|
||||
"name": "materials",
|
||||
"offset": 128,
|
||||
"parentId": "1",
|
||||
"refId": "0",
|
||||
"refId": "20",
|
||||
"strLen": 64
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
BIN
src/fonts/JetBrainsMono.ttf
Normal file
BIN
src/fonts/JetBrainsMono.ttf
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
#include "core.h"
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
@@ -41,13 +42,15 @@ QString typeName(NodeKind kind, int colType) {
|
||||
return fit(m ? QString::fromLatin1(m->typeName) : QStringLiteral("???"), colType);
|
||||
}
|
||||
|
||||
// Array type string: "uint32_t[16]" or "char[64]"
|
||||
QString arrayTypeName(NodeKind elemKind, int count) {
|
||||
auto* m = kindMeta(elemKind);
|
||||
QString elem = m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
|
||||
// char[] for UInt8, wchar_t[] for UInt16
|
||||
if (elemKind == NodeKind::UInt8) elem = QStringLiteral("char");
|
||||
else if (elemKind == NodeKind::UInt16) elem = QStringLiteral("wchar_t");
|
||||
// Array type string: "uint32_t[16]" or "Material[2]"
|
||||
QString arrayTypeName(NodeKind elemKind, int count, const QString& structName) {
|
||||
QString elem;
|
||||
if (elemKind == NodeKind::Struct && !structName.isEmpty())
|
||||
elem = structName;
|
||||
else {
|
||||
auto* m = kindMeta(elemKind);
|
||||
elem = m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
|
||||
}
|
||||
return elem + QStringLiteral("[") + QString::number(count) + QStringLiteral("]");
|
||||
}
|
||||
|
||||
@@ -78,8 +81,24 @@ QString fmtUInt32(uint32_t v) { return hexVal(v); }
|
||||
QString fmtUInt64(uint64_t v) { return hexVal(v); }
|
||||
|
||||
QString fmtFloat(float v) {
|
||||
QString s = QString::number(v, 'g', 4);
|
||||
if (!s.contains('.') && !s.contains('e') && !s.contains('E'))
|
||||
if (std::isnan(v)) return QStringLiteral("NaN");
|
||||
if (std::isinf(v)) return v > 0 ? QStringLiteral("inff") : QStringLiteral("-inff");
|
||||
|
||||
// 6 significant digits — covers full single-precision range
|
||||
QString s = QString::number(v, 'g', 6);
|
||||
|
||||
// If 'g' chose scientific notation, reformat as plain decimal
|
||||
if (s.contains('e') || s.contains('E')) {
|
||||
s = QString::number(v, 'f', 8);
|
||||
if (s.contains('.')) {
|
||||
int i = s.size() - 1;
|
||||
while (i > 0 && s[i] == '0') i--;
|
||||
if (s[i] == '.') i++; // keep at least one decimal digit
|
||||
s.truncate(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!s.contains('.'))
|
||||
s += QStringLiteral(".f");
|
||||
else
|
||||
s += QLatin1Char('f');
|
||||
@@ -111,9 +130,10 @@ QString indent(int depth) {
|
||||
|
||||
// ── Offset margin ──
|
||||
|
||||
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation) {
|
||||
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation, int hexDigits) {
|
||||
if (isContinuation) return QStringLiteral(" \u00B7 ");
|
||||
return QString::number(absoluteOffset, 16).toUpper() + QChar(' ');
|
||||
return QString::number(absoluteOffset, 16).toUpper()
|
||||
.rightJustified(hexDigits, '0') + QChar(' ');
|
||||
}
|
||||
|
||||
// ── Struct type name (for width calculation) ──
|
||||
@@ -142,9 +162,9 @@ QString fmtStructFooter(const Node& /*node*/, int depth, int /*totalSize*/) {
|
||||
|
||||
// ── Array header ──
|
||||
// Columnar format: <type[count]> <name> { (or no brace when collapsed)
|
||||
QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collapsed, int colType, int colName) {
|
||||
QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collapsed, int colType, int colName, const QString& elemStructName) {
|
||||
QString ind = indent(depth);
|
||||
QString type = fit(arrayTypeName(node.elementKind, node.arrayLen), colType);
|
||||
QString type = fit(arrayTypeName(node.elementKind, node.arrayLen, elemStructName), colType);
|
||||
QString suffix = collapsed ? QString() : QStringLiteral("{");
|
||||
return ind + type + SEP + node.name + SEP + suffix;
|
||||
}
|
||||
@@ -265,7 +285,7 @@ static QString readValueImpl(const Node& node, const Provider& prov,
|
||||
case NodeKind::Mat4x4: {
|
||||
if (!display) return {}; // not editable as single value
|
||||
if (subLine < 0 || subLine >= 4) return QStringLiteral("?");
|
||||
QString line = QStringLiteral("[");
|
||||
QString line = QStringLiteral("row%1 [").arg(subLine);
|
||||
for (int c = 0; c < 4; c++) {
|
||||
if (c > 0) line += QStringLiteral(", ");
|
||||
line += fmtFloat(prov.readF32(addr + (subLine * 4 + c) * 4)).trimmed();
|
||||
@@ -273,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');
|
||||
@@ -313,36 +332,23 @@ QString fmtNodeLine(const Node& node, const Provider& prov,
|
||||
// Blank prefix for continuation lines (same width as type+sep+name+sep)
|
||||
const int prefixW = colType + colName + 2 * kSepWidth;
|
||||
|
||||
// Comment suffix (padded or empty)
|
||||
QString cmtSuffix = comment.isEmpty() ? QString(COL_COMMENT, ' ')
|
||||
// Comment suffix (only present when a comment is provided; no trailing padding)
|
||||
QString cmtSuffix = comment.isEmpty() ? QString()
|
||||
: fit(comment, COL_COMMENT);
|
||||
|
||||
// Mat4x4: subLine 0..3 = rows
|
||||
// Mat4x4: subLine 0..3 = rows — no truncation so large floats always display fully
|
||||
if (node.kind == NodeKind::Mat4x4) {
|
||||
QString val = fit(readValue(node, prov, addr, subLine), COL_VALUE);
|
||||
QString val = readValue(node, prov, addr, subLine);
|
||||
if (subLine == 0) return ind + type + SEP + name + SEP + val + cmtSuffix;
|
||||
return ind + QString(prefixW, ' ') + val + cmtSuffix;
|
||||
}
|
||||
|
||||
// Hex nodes and Padding: hex byte preview
|
||||
// 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);
|
||||
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 8 chars so hex column aligns
|
||||
const int sz = sizeForKind(node.kind);
|
||||
QByteArray b = prov.isReadable(addr, sz)
|
||||
? prov.readBytes(addr, sz) : QByteArray(sz, '\0');
|
||||
QString ascii = bytesToAscii(b, sz).leftJustified(8, ' ');
|
||||
QString ascii = bytesToAscii(b, sz).leftJustified(colName, ' ');
|
||||
QString hex = bytesToHex(b, sz).leftJustified(23, ' ');
|
||||
return ind + type + SEP + ascii + SEP + hex + cmtSuffix;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -93,51 +92,59 @@ struct GenContext {
|
||||
// Forward declarations
|
||||
static void emitStruct(GenContext& ctx, uint64_t structId);
|
||||
|
||||
// ── Emit a single field declaration ──
|
||||
// ── Field line with offset comment (code + marker + comment) ──
|
||||
// We use a \x01 marker to separate the code part from the offset comment.
|
||||
// After all output is generated, alignComments() replaces markers with padding.
|
||||
|
||||
static const QChar kCommentMarker = QChar(0x01);
|
||||
|
||||
static QString offsetComment(int offset) {
|
||||
return QString(kCommentMarker) + QStringLiteral("// 0x%1").arg(QString::number(offset, 16).toUpper());
|
||||
}
|
||||
|
||||
static QString emitField(GenContext& ctx, const Node& node) {
|
||||
const NodeTree& tree = ctx.tree;
|
||||
QString name = sanitizeIdent(node.name.isEmpty()
|
||||
? QStringLiteral("field_%1").arg(node.offset, 2, 16, QChar('0'))
|
||||
: node.name);
|
||||
QString oc = offsetComment(node.offset);
|
||||
|
||||
switch (node.kind) {
|
||||
case NodeKind::Vec2:
|
||||
return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name);
|
||||
return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name) + oc;
|
||||
case NodeKind::Vec3:
|
||||
return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name);
|
||||
return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name) + oc;
|
||||
case NodeKind::Vec4:
|
||||
return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name);
|
||||
return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name) + oc;
|
||||
case NodeKind::Mat4x4:
|
||||
return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name);
|
||||
return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name) + oc;
|
||||
case NodeKind::UTF8:
|
||||
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen);
|
||||
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);
|
||||
case NodeKind::Padding:
|
||||
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen));
|
||||
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc;
|
||||
case NodeKind::Pointer32: {
|
||||
if (node.refId != 0) {
|
||||
int refIdx = tree.indexOfId(node.refId);
|
||||
if (refIdx >= 0) {
|
||||
QString target = ctx.structName(tree.nodes[refIdx]);
|
||||
return QStringLiteral(" %1 %2; // -> %3*").arg(ctx.cType(NodeKind::Pointer32), name, target);
|
||||
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) +
|
||||
offsetComment(node.offset).replace(QStringLiteral("//"), QStringLiteral("// -> %1*").arg(target));
|
||||
}
|
||||
}
|
||||
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name);
|
||||
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) + oc;
|
||||
}
|
||||
case NodeKind::Pointer64: {
|
||||
if (node.refId != 0) {
|
||||
int refIdx = tree.indexOfId(node.refId);
|
||||
if (refIdx >= 0) {
|
||||
QString target = ctx.structName(tree.nodes[refIdx]);
|
||||
return QStringLiteral(" %1* %2;").arg(target, name);
|
||||
return QStringLiteral(" %1* %2;").arg(target, name) + oc;
|
||||
}
|
||||
}
|
||||
return QStringLiteral(" void* %1;").arg(name);
|
||||
return QStringLiteral(" void* %1;").arg(name) + oc;
|
||||
}
|
||||
default:
|
||||
return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name);
|
||||
return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name) + oc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +162,21 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
|
||||
return tree.nodes[a].offset < tree.nodes[b].offset;
|
||||
});
|
||||
|
||||
int cursor = 0;
|
||||
// Helper: emit a padding/hex run as a single collapsed byte array
|
||||
auto emitPadRun = [&](int offset, int size) {
|
||||
if (size <= 0) return;
|
||||
ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n")
|
||||
.arg(QStringLiteral("uint8_t"))
|
||||
.arg(ctx.uniquePadName())
|
||||
.arg(QString::number(size, 16).toUpper())
|
||||
.arg(offsetComment(offset));
|
||||
};
|
||||
|
||||
for (int ci : children) {
|
||||
const Node& child = tree.nodes[ci];
|
||||
int cursor = 0;
|
||||
int i = 0;
|
||||
|
||||
while (i < children.size()) {
|
||||
const Node& child = tree.nodes[children[i]];
|
||||
int childSize;
|
||||
if (child.kind == NodeKind::Struct || child.kind == NodeKind::Array)
|
||||
childSize = tree.structSpan(child.id, &ctx.childMap);
|
||||
@@ -166,28 +184,40 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
|
||||
childSize = child.byteSize();
|
||||
|
||||
// Gap before this field
|
||||
if (child.offset > cursor) {
|
||||
int gap = child.offset - cursor;
|
||||
ctx.output += QStringLiteral(" %1 %2[0x%3];\n")
|
||||
.arg(ctx.cType(NodeKind::Padding))
|
||||
.arg(ctx.uniquePadName())
|
||||
.arg(QString::number(gap, 16).toUpper());
|
||||
} else if (child.offset < cursor) {
|
||||
// Overlap
|
||||
if (child.offset > cursor)
|
||||
emitPadRun(cursor, child.offset - cursor);
|
||||
else if (child.offset < cursor)
|
||||
ctx.output += QStringLiteral(" // WARNING: overlap at offset 0x%1 (previous field ends at 0x%2)\n")
|
||||
.arg(QString::number(child.offset, 16).toUpper())
|
||||
.arg(QString::number(cursor, 16).toUpper());
|
||||
|
||||
// Collapse consecutive hex nodes into a single padding array
|
||||
if (isHexNode(child.kind)) {
|
||||
int runStart = child.offset;
|
||||
int runEnd = child.offset + childSize;
|
||||
int j = i + 1;
|
||||
while (j < children.size()) {
|
||||
const Node& next = tree.nodes[children[j]];
|
||||
if (!isHexNode(next.kind)) break;
|
||||
int nextSize = next.byteSize();
|
||||
// Allow gaps within the run (they become part of the pad)
|
||||
if (next.offset < runEnd) break; // overlap — stop merging
|
||||
runEnd = next.offset + nextSize;
|
||||
j++;
|
||||
}
|
||||
emitPadRun(runStart, runEnd - runStart);
|
||||
cursor = runEnd;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Emit the field
|
||||
if (child.kind == NodeKind::Struct) {
|
||||
// Ensure the nested struct type is emitted first
|
||||
emitStruct(ctx, child.id);
|
||||
QString typeName = ctx.structName(child);
|
||||
QString fieldName = sanitizeIdent(child.name);
|
||||
ctx.output += QStringLiteral(" %1 %2;\n").arg(typeName, fieldName);
|
||||
ctx.output += QStringLiteral(" %1 %2;%3\n").arg(typeName, fieldName, offsetComment(child.offset));
|
||||
} else if (child.kind == NodeKind::Array) {
|
||||
// Check if array has struct element children
|
||||
QVector<int> arrayKids = ctx.childMap.value(child.id);
|
||||
bool hasStructChild = false;
|
||||
QString elemTypeName;
|
||||
@@ -203,11 +233,11 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
|
||||
|
||||
QString fieldName = sanitizeIdent(child.name);
|
||||
if (hasStructChild && !elemTypeName.isEmpty()) {
|
||||
ctx.output += QStringLiteral(" %1 %2[%3];\n")
|
||||
.arg(elemTypeName, fieldName).arg(child.arrayLen);
|
||||
ctx.output += QStringLiteral(" %1 %2[%3];%4\n")
|
||||
.arg(elemTypeName, fieldName).arg(child.arrayLen).arg(offsetComment(child.offset));
|
||||
} else {
|
||||
ctx.output += QStringLiteral(" %1 %2[%3];\n")
|
||||
.arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen);
|
||||
ctx.output += QStringLiteral(" %1 %2[%3];%4\n")
|
||||
.arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen).arg(offsetComment(child.offset));
|
||||
}
|
||||
} else {
|
||||
ctx.output += emitField(ctx, child) + QStringLiteral("\n");
|
||||
@@ -215,16 +245,12 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
|
||||
|
||||
int childEnd = child.offset + childSize;
|
||||
if (childEnd > cursor) cursor = childEnd;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Tail padding
|
||||
if (cursor < structSize) {
|
||||
int gap = structSize - cursor;
|
||||
ctx.output += QStringLiteral(" %1 %2[0x%3];\n")
|
||||
.arg(ctx.cType(NodeKind::Padding))
|
||||
.arg(ctx.uniquePadName())
|
||||
.arg(QString::number(gap, 16).toUpper());
|
||||
}
|
||||
if (cursor < structSize)
|
||||
emitPadRun(cursor, structSize - cursor);
|
||||
}
|
||||
|
||||
// ── Emit a complete struct definition ──
|
||||
@@ -294,7 +320,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
|
||||
ctx.emittedTypeNames.insert(typeName);
|
||||
int structSize = ctx.tree.structSpan(structId, &ctx.childMap);
|
||||
|
||||
ctx.output += QStringLiteral("#pragma pack(push, 1)\n");
|
||||
QString kw = node.resolvedClassKeyword();
|
||||
if (kw == QStringLiteral("enum")) kw = QStringLiteral("struct"); // enum is cosmetic
|
||||
ctx.output += QStringLiteral("%1 %2 {\n").arg(kw, typeName);
|
||||
@@ -302,7 +327,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
|
||||
emitStructBody(ctx, structId);
|
||||
|
||||
ctx.output += QStringLiteral("};\n");
|
||||
ctx.output += QStringLiteral("#pragma pack(pop)\n");
|
||||
ctx.output += QStringLiteral("static_assert(sizeof(%1) == 0x%2, \"Size mismatch for %1\");\n\n")
|
||||
.arg(typeName)
|
||||
.arg(QString::number(structSize, 16).toUpper());
|
||||
@@ -319,22 +343,39 @@ static QHash<uint64_t, QVector<int>> buildChildMap(const NodeTree& tree) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── Path breadcrumb for header comment ──
|
||||
// ── Align offset comments ──
|
||||
// Replaces kCommentMarker with spaces so all "// 0x..." comments align to
|
||||
// the same column (the longest code portion + 1 space).
|
||||
|
||||
static QString nodePath(const NodeTree& tree, uint64_t nodeId) {
|
||||
QStringList parts;
|
||||
QSet<uint64_t> seen;
|
||||
uint64_t cur = nodeId;
|
||||
while (cur != 0 && !seen.contains(cur)) {
|
||||
seen.insert(cur);
|
||||
int idx = tree.indexOfId(cur);
|
||||
if (idx < 0) break;
|
||||
const Node& n = tree.nodes[idx];
|
||||
parts << (n.name.isEmpty() ? QStringLiteral("<unnamed>") : n.name);
|
||||
cur = n.parentId;
|
||||
static QString alignComments(const QString& raw) {
|
||||
QStringList lines = raw.split('\n');
|
||||
|
||||
// First pass: find the maximum code width (text before the marker)
|
||||
int maxCode = 0;
|
||||
for (const QString& line : lines) {
|
||||
int pos = line.indexOf(kCommentMarker);
|
||||
if (pos >= 0)
|
||||
maxCode = qMax(maxCode, pos);
|
||||
}
|
||||
std::reverse(parts.begin(), parts.end());
|
||||
return parts.join(QStringLiteral(" > "));
|
||||
|
||||
// Second pass: replace markers with padding
|
||||
QString result;
|
||||
result.reserve(raw.size() + lines.size() * 8);
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (i > 0) result += '\n';
|
||||
const QString& line = lines[i];
|
||||
int pos = line.indexOf(kCommentMarker);
|
||||
if (pos >= 0) {
|
||||
result += line.left(pos);
|
||||
int pad = maxCode - pos + 1;
|
||||
if (pad < 1) pad = 1;
|
||||
result += QString(pad, ' ');
|
||||
result += line.mid(pos + 1); // skip the marker char
|
||||
} else {
|
||||
result += line;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // anonymous namespace
|
||||
@@ -350,30 +391,19 @@ QString renderCpp(const NodeTree& tree, uint64_t rootStructId,
|
||||
if (root.kind != NodeKind::Struct) return {};
|
||||
|
||||
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases};
|
||||
int rootSize = tree.structSpan(rootStructId, &ctx.childMap);
|
||||
QString typeName = ctx.structName(root);
|
||||
|
||||
ctx.output += QStringLiteral("// Generated by ReclassX\n");
|
||||
ctx.output += QStringLiteral("// Rendered from: %1 (id=0x%2, size=0x%3)\n\n")
|
||||
.arg(nodePath(tree, rootStructId))
|
||||
.arg(QString::number(rootStructId, 16).toUpper())
|
||||
.arg(QString::number(rootSize, 16).toUpper());
|
||||
ctx.output += QStringLiteral("#pragma once\n");
|
||||
ctx.output += QStringLiteral("#include <cstdint>\n\n");
|
||||
ctx.output += QStringLiteral("#pragma once\n\n");
|
||||
|
||||
emitStruct(ctx, rootStructId);
|
||||
|
||||
return ctx.output;
|
||||
return alignComments(ctx.output);
|
||||
}
|
||||
|
||||
QString renderCppAll(const NodeTree& tree,
|
||||
const QHash<NodeKind, QString>* typeAliases) {
|
||||
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases};
|
||||
|
||||
ctx.output += QStringLiteral("// Generated by ReclassX\n");
|
||||
ctx.output += QStringLiteral("// Full SDK export\n\n");
|
||||
ctx.output += QStringLiteral("#pragma once\n");
|
||||
ctx.output += QStringLiteral("#include <cstdint>\n\n");
|
||||
ctx.output += QStringLiteral("#pragma once\n\n");
|
||||
|
||||
QVector<int> roots = ctx.childMap.value(0);
|
||||
std::sort(roots.begin(), roots.end(), [&](int a, int b) {
|
||||
@@ -385,7 +415,7 @@ QString renderCppAll(const NodeTree& tree,
|
||||
emitStruct(ctx, tree.nodes[ri].id);
|
||||
}
|
||||
|
||||
return ctx.output;
|
||||
return alignComments(ctx.output);
|
||||
}
|
||||
|
||||
QString renderNull(const NodeTree&, uint64_t) {
|
||||
|
||||
BIN
src/icons/class.ico
Normal file
BIN
src/icons/class.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 970 B |
BIN
src/icons/class.png
Normal file
BIN
src/icons/class.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 918 B |
@@ -4,14 +4,20 @@
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#ifdef _WIN32
|
||||
#define RCX_PLUGIN_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define RCX_PLUGIN_EXPORT __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
// Forward declaration
|
||||
namespace rcx { class Provider; }
|
||||
|
||||
/**
|
||||
* Plugin interface for ReclassX
|
||||
*
|
||||
* Plugins are loaded from the "Plugins" folder as DLLs.
|
||||
* Each plugin must export a C function: extern "C" __declspec(dllexport) IPlugin* CreatePlugin();
|
||||
* Plugin interface for Reclass
|
||||
*
|
||||
* Plugins are loaded from the "Plugins" folder as shared libraries.
|
||||
* Each plugin must export a C function: extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();
|
||||
*/
|
||||
class IPlugin {
|
||||
public:
|
||||
@@ -127,4 +133,4 @@ public:
|
||||
// Plugin factory function signature
|
||||
typedef IPlugin* (*CreatePluginFunc)();
|
||||
|
||||
#define IPLUGIN_IID "com.reclassx.IPlugin/1.0"
|
||||
#define IPLUGIN_IID "com.reclass.IPlugin/1.0"
|
||||
|
||||
1268
src/main.cpp
1268
src/main.cpp
File diff suppressed because it is too large
Load Diff
129
src/mainwindow.h
Normal file
129
src/mainwindow.h
Normal file
@@ -0,0 +1,129 @@
|
||||
#pragma once
|
||||
#include "controller.h"
|
||||
#include "titlebar.h"
|
||||
#include "pluginmanager.h"
|
||||
#include <QMainWindow>
|
||||
#include <QMdiArea>
|
||||
#include <QMdiSubWindow>
|
||||
#include <QLabel>
|
||||
#include <QSplitter>
|
||||
#include <QTabWidget>
|
||||
#include <QDockWidget>
|
||||
#include <QTreeView>
|
||||
#include <QStandardItemModel>
|
||||
#include <QMap>
|
||||
#include <Qsci/qsciscintilla.h>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
class McpBridge;
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
friend class McpBridge;
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void newFile();
|
||||
void newDocument();
|
||||
void selfTest();
|
||||
void openFile();
|
||||
void saveFile();
|
||||
void saveFileAs();
|
||||
|
||||
|
||||
void addNode();
|
||||
void removeNode();
|
||||
void changeNodeType();
|
||||
void renameNodeAction();
|
||||
void duplicateNodeAction();
|
||||
void splitView();
|
||||
void unsplitView();
|
||||
|
||||
void undo();
|
||||
void redo();
|
||||
void about();
|
||||
void toggleMcp();
|
||||
void setEditorFont(const QString& fontName);
|
||||
void exportCpp();
|
||||
void showTypeAliasesDialog();
|
||||
void editTheme();
|
||||
void showOptionsDialog();
|
||||
|
||||
public:
|
||||
// Project Lifecycle API
|
||||
QMdiSubWindow* project_new();
|
||||
QMdiSubWindow* project_open(const QString& path = {});
|
||||
bool project_save(QMdiSubWindow* sub = nullptr, bool saveAs = false);
|
||||
void project_close(QMdiSubWindow* sub = nullptr);
|
||||
|
||||
private:
|
||||
enum ViewMode { VM_Reclass, VM_Rendered };
|
||||
|
||||
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;
|
||||
RcxEditor* editor = nullptr;
|
||||
QsciScintilla* rendered = nullptr;
|
||||
ViewMode viewMode = VM_Reclass;
|
||||
uint64_t lastRenderedRootId = 0;
|
||||
};
|
||||
|
||||
struct TabState {
|
||||
RcxDocument* doc;
|
||||
RcxController* ctrl;
|
||||
QSplitter* splitter;
|
||||
QVector<SplitPane> panes;
|
||||
int activePaneIdx = 0;
|
||||
};
|
||||
QMap<QMdiSubWindow*, TabState> m_tabs;
|
||||
|
||||
|
||||
void createMenus();
|
||||
void createStatusBar();
|
||||
void showPluginsDialog();
|
||||
QIcon makeIcon(const QString& svgPath);
|
||||
|
||||
RcxController* activeController() const;
|
||||
TabState* activeTab();
|
||||
TabState* tabByIndex(int index);
|
||||
int tabCount() const { return m_tabs.size(); }
|
||||
QMdiSubWindow* createTab(RcxDocument* doc);
|
||||
void updateWindowTitle();
|
||||
|
||||
void setViewMode(ViewMode mode);
|
||||
void updateRenderedView(TabState& tab, SplitPane& pane);
|
||||
void updateAllRenderedPanes(TabState& tab);
|
||||
uint64_t findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const;
|
||||
void setupRenderedSci(QsciScintilla* sci);
|
||||
|
||||
SplitPane createSplitPane(TabState& tab);
|
||||
void applyTheme(const Theme& theme);
|
||||
void applyTabWidgetStyle(QTabWidget* tw);
|
||||
void styleTabCloseButtons();
|
||||
SplitPane* findPaneByTabWidget(QTabWidget* tw);
|
||||
SplitPane* findActiveSplitPane();
|
||||
RcxEditor* activePaneEditor();
|
||||
|
||||
// Workspace dock
|
||||
QDockWidget* m_workspaceDock = nullptr;
|
||||
QTreeView* m_workspaceTree = nullptr;
|
||||
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
|
||||
1071
src/mcp/mcp_bridge.cpp
Normal file
1071
src/mcp/mcp_bridge.cpp
Normal file
File diff suppressed because it is too large
Load Diff
71
src/mcp/mcp_bridge.h
Normal file
71
src/mcp/mcp_bridge.h
Normal file
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
#include "mainwindow.h"
|
||||
#include <QObject>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QByteArray>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
class McpBridge : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit McpBridge(MainWindow* mainWindow, QObject* parent = nullptr);
|
||||
~McpBridge() override;
|
||||
|
||||
void start();
|
||||
void stop();
|
||||
bool isRunning() const { return m_server != nullptr; }
|
||||
|
||||
bool slowMode() const { return m_slowMode; }
|
||||
void setSlowMode(bool v) { m_slowMode = v; }
|
||||
|
||||
// Call from controller refresh / data change to notify MCP clients
|
||||
void notifyTreeChanged();
|
||||
void notifyDataChanged();
|
||||
|
||||
private:
|
||||
MainWindow* m_mainWindow;
|
||||
QLocalServer* m_server = nullptr;
|
||||
QLocalSocket* m_client = nullptr; // single client for v1
|
||||
QByteArray m_readBuffer;
|
||||
bool m_initialized = false;
|
||||
bool m_slowMode = false;
|
||||
|
||||
// JSON-RPC plumbing
|
||||
void onNewConnection();
|
||||
void onReadyRead();
|
||||
void onDisconnected();
|
||||
void processLine(const QByteArray& line);
|
||||
void sendJson(const QJsonObject& obj);
|
||||
QJsonObject okReply(const QJsonValue& id, const QJsonObject& result);
|
||||
QJsonObject errReply(const QJsonValue& id, int code, const QString& msg);
|
||||
void sendNotification(const QString& method, const QJsonObject& params = {});
|
||||
|
||||
// MCP method handlers
|
||||
QJsonObject handleInitialize(const QJsonValue& id, const QJsonObject& params);
|
||||
QJsonObject handleToolsList(const QJsonValue& id);
|
||||
QJsonObject handleToolsCall(const QJsonValue& id, const QJsonObject& params);
|
||||
|
||||
// Tool implementations
|
||||
QJsonObject toolProjectState(const QJsonObject& args);
|
||||
QJsonObject toolTreeApply(const QJsonObject& args);
|
||||
QJsonObject toolSourceSwitch(const QJsonObject& args);
|
||||
QJsonObject toolHexRead(const QJsonObject& args);
|
||||
QJsonObject toolHexWrite(const QJsonObject& args);
|
||||
QJsonObject toolStatusSet(const QJsonObject& args);
|
||||
QJsonObject toolUiAction(const QJsonObject& args);
|
||||
|
||||
// Helpers
|
||||
QJsonObject makeTextResult(const QString& text, bool isError = false);
|
||||
QString resolvePlaceholder(const QString& ref,
|
||||
const QHash<QString, uint64_t>& placeholderMap);
|
||||
|
||||
// Smart tab resolution: tabIndex arg → activeTab → first tab → auto-create
|
||||
MainWindow::TabState* resolveTab(const QJsonObject& args);
|
||||
};
|
||||
|
||||
} // namespace rcx
|
||||
327
src/optionsdialog.cpp
Normal file
327
src/optionsdialog.cpp
Normal 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
53
src/optionsdialog.h
Normal 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
|
||||
@@ -80,7 +80,7 @@ bool PluginManager::LoadPlugin(const QString& path)
|
||||
return false;
|
||||
}
|
||||
|
||||
qDebug() << "PluginManager: Loaded plugin:" << plugin->Name() << plugin->Version() << "by" << plugin->Author();
|
||||
qDebug() << "PluginManager: Loaded plugin:" << plugin->Name().c_str() << plugin->Version().c_str() << "by" << plugin->Author().c_str();
|
||||
|
||||
// Store plugin entry
|
||||
m_entries.append({library, plugin});
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
#include <tlhelp32.h>
|
||||
#include <psapi.h>
|
||||
#include <shellapi.h>
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
|
||||
#include <QtWin>
|
||||
#endif
|
||||
#elif defined(__linux__)
|
||||
#include <QDir>
|
||||
#include <QStyle>
|
||||
#include <QApplication>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
ProcessPicker::ProcessPicker(QWidget *parent)
|
||||
@@ -137,7 +145,11 @@ void ProcessPicker::enumerateProcesses()
|
||||
SHFILEINFOW sfi = {};
|
||||
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
|
||||
if (sfi.hIcon) {
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||||
info.icon = QIcon(QPixmap::fromImage(QImage::fromHICON(sfi.hIcon)));
|
||||
#else
|
||||
info.icon = QIcon(QtWin::fromHICON(sfi.hIcon));
|
||||
#endif
|
||||
DestroyIcon(sfi.hIcon);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +167,45 @@ void ProcessPicker::enumerateProcesses()
|
||||
}
|
||||
|
||||
CloseHandle(snapshot);
|
||||
#elif defined(__linux__)
|
||||
QDir procDir("/proc");
|
||||
QStringList entries = procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
|
||||
QIcon defaultIcon = qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
|
||||
|
||||
for (const QString& entry : entries) {
|
||||
bool ok = false;
|
||||
uint32_t pid = entry.toUInt(&ok);
|
||||
if (!ok || pid == 0) continue;
|
||||
|
||||
// Read process name from /proc/<pid>/comm
|
||||
QString commPath = QStringLiteral("/proc/%1/comm").arg(pid);
|
||||
QFile commFile(commPath);
|
||||
QString procName;
|
||||
if (commFile.open(QIODevice::ReadOnly)) {
|
||||
procName = QString::fromUtf8(commFile.readAll()).trimmed();
|
||||
commFile.close();
|
||||
}
|
||||
if (procName.isEmpty()) continue;
|
||||
|
||||
// Read exe path from /proc/<pid>/exe symlink
|
||||
QString exePath = QStringLiteral("/proc/%1/exe").arg(pid);
|
||||
QFileInfo exeInfo(exePath);
|
||||
QString resolvedPath;
|
||||
if (exeInfo.exists())
|
||||
resolvedPath = exeInfo.symLinkTarget();
|
||||
|
||||
// Skip if we can't read the process memory
|
||||
QString memPath = QStringLiteral("/proc/%1/mem").arg(pid);
|
||||
if (::access(memPath.toUtf8().constData(), R_OK) != 0)
|
||||
continue;
|
||||
|
||||
ProcessInfo info;
|
||||
info.pid = pid;
|
||||
info.name = procName;
|
||||
info.path = resolvedPath;
|
||||
info.icon = defaultIcon;
|
||||
processes.append(info);
|
||||
}
|
||||
#else
|
||||
// Platform not supported
|
||||
QMessageBox::warning(this, "Error", "Process enumeration not supported on this platform.");
|
||||
|
||||
@@ -36,7 +36,7 @@ void ProviderRegistry::unregisterProvider(const QString& identifier) {
|
||||
for (int i = 0; i < m_providers.size(); ++i) {
|
||||
if (m_providers[i].identifier == identifier) {
|
||||
qDebug() << "ProviderRegistry: Unregistered provider:" << identifier;
|
||||
m_providers.remove(i);
|
||||
m_providers.removeAt(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
#include "iplugin.h"
|
||||
#include <QVector>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <functional>
|
||||
|
||||
@@ -45,7 +45,7 @@ public:
|
||||
void unregisterProvider(const QString& identifier);
|
||||
|
||||
// Get all registered providers
|
||||
const QVector<ProviderInfo>& providers() const { return m_providers; }
|
||||
const QList<ProviderInfo>& providers() const { return m_providers; }
|
||||
|
||||
// Find provider by identifier
|
||||
const ProviderInfo* findProvider(const QString& identifier) const;
|
||||
@@ -55,5 +55,5 @@ public:
|
||||
|
||||
private:
|
||||
ProviderRegistry() = default;
|
||||
QVector<ProviderInfo> m_providers;
|
||||
QList<ProviderInfo> m_providers;
|
||||
};
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
#pragma once
|
||||
#include "provider.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <psapi.h>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
class ProcessProvider : public Provider {
|
||||
HANDLE m_handle = nullptr;
|
||||
uint64_t m_base = 0;
|
||||
int m_size = 0;
|
||||
QString m_name;
|
||||
|
||||
struct ModuleInfo {
|
||||
QString name;
|
||||
uint64_t base;
|
||||
uint64_t size;
|
||||
};
|
||||
QVector<ModuleInfo> m_modules;
|
||||
|
||||
public:
|
||||
ProcessProvider(HANDLE proc, uint64_t base, int regionSize, const QString& name)
|
||||
: m_handle(proc), m_base(base), m_size(regionSize), m_name(name)
|
||||
{
|
||||
cacheModules();
|
||||
}
|
||||
|
||||
~ProcessProvider() override {
|
||||
if (m_handle) CloseHandle(m_handle);
|
||||
}
|
||||
|
||||
ProcessProvider(const ProcessProvider&) = delete;
|
||||
ProcessProvider& operator=(const ProcessProvider&) = delete;
|
||||
|
||||
int size() const override { return m_size; }
|
||||
bool isReadable(uint64_t, int len) const override { return len >= 0; }
|
||||
|
||||
bool read(uint64_t addr, void* buf, int len) const override {
|
||||
SIZE_T got = 0;
|
||||
BOOL ok = ReadProcessMemory(m_handle,
|
||||
(LPCVOID)(m_base + addr), buf, len, &got);
|
||||
return ok && (int)got == len;
|
||||
}
|
||||
|
||||
bool isWritable() const override { return true; }
|
||||
|
||||
bool write(uint64_t addr, const void* buf, int len) override {
|
||||
SIZE_T got = 0;
|
||||
BOOL ok = WriteProcessMemory(m_handle,
|
||||
(LPVOID)(m_base + addr), buf, len, &got);
|
||||
return ok && (int)got == len;
|
||||
}
|
||||
|
||||
QString name() const override { return m_name; }
|
||||
QString kind() const override { return QStringLiteral("Process"); }
|
||||
bool isLive() const override { return true; }
|
||||
|
||||
// getSymbol takes an absolute virtual address and resolves it to
|
||||
// "module.dll+0xOFFSET" using the cached module list.
|
||||
QString getSymbol(uint64_t absAddr) const override {
|
||||
for (const auto& mod : m_modules) {
|
||||
if (absAddr >= mod.base && absAddr < mod.base + mod.size) {
|
||||
uint64_t offset = absAddr - mod.base;
|
||||
return QStringLiteral("%1+0x%2")
|
||||
.arg(mod.name)
|
||||
.arg(offset, 0, 16, QChar('0'));
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
HANDLE handle() const { return m_handle; }
|
||||
uint64_t baseAddress() const { return m_base; }
|
||||
void refreshModules() { m_modules.clear(); cacheModules(); }
|
||||
|
||||
private:
|
||||
void cacheModules() {
|
||||
HMODULE mods[1024];
|
||||
DWORD needed = 0;
|
||||
if (!EnumProcessModulesEx(m_handle, mods, sizeof(mods),
|
||||
&needed, LIST_MODULES_ALL))
|
||||
return;
|
||||
int count = qMin((int)(needed / sizeof(HMODULE)), 1024);
|
||||
m_modules.reserve(count);
|
||||
for (int i = 0; i < count; ++i) {
|
||||
MODULEINFO mi{};
|
||||
WCHAR modName[MAX_PATH];
|
||||
if (GetModuleInformation(m_handle, mods[i], &mi, sizeof(mi))
|
||||
&& GetModuleBaseNameW(m_handle, mods[i], modName, MAX_PATH))
|
||||
{
|
||||
m_modules.append({
|
||||
QString::fromWCharArray(modName),
|
||||
(uint64_t)mi.lpBaseOfDll,
|
||||
(uint64_t)mi.SizeOfImage
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace rcx
|
||||
#endif // _WIN32
|
||||
@@ -33,9 +33,14 @@ public:
|
||||
// Examples: "File", "Process", "Socket"
|
||||
virtual QString kind() const { return QStringLiteral("File"); }
|
||||
|
||||
// Base address for providers that offset reads (e.g. process memory).
|
||||
// For file/buffer providers this is always 0.
|
||||
virtual uint64_t base() const { return 0; }
|
||||
virtual void setBase(uint64_t newBase) { Q_UNUSED(newBase); }
|
||||
|
||||
// Resolve an absolute address to a symbol name.
|
||||
// Returns empty string if no symbol is known.
|
||||
// ProcessProvider: "ntdll.dll+0x1A30"
|
||||
// Example: "ntdll.dll+0x1A30"
|
||||
// BufferProvider: "" (no symbols in flat files)
|
||||
virtual QString getSymbol(uint64_t addr) const {
|
||||
Q_UNUSED(addr);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<qresource prefix="/icons">
|
||||
<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="Iosevka-Regular.ttf">fonts/Iosevka-Regular.ttf</file>
|
||||
<file alias="JetBrainsMono.ttf">fonts/JetBrainsMono.ttf</file>
|
||||
</qresource>
|
||||
<qresource prefix="/vsicons">
|
||||
<file alias="file.svg">vsicons/file.svg</file>
|
||||
@@ -19,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>
|
||||
@@ -42,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>
|
||||
|
||||
29
src/themes/defaults/reclass_dark.json
Normal file
29
src/themes/defaults/reclass_dark.json
Normal 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"
|
||||
}
|
||||
29
src/themes/defaults/vs.json
Normal file
29
src/themes/defaults/vs.json
Normal 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"
|
||||
}
|
||||
29
src/themes/defaults/warm.json
Normal file
29
src/themes/defaults/warm.json
Normal 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"
|
||||
}
|
||||
56
src/themes/theme.cpp
Normal file
56
src/themes/theme.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#include "theme.h"
|
||||
#include <type_traits>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
// ── Shared field metadata (serialization + editor UI) ──
|
||||
|
||||
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 (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;
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace rcx
|
||||
65
src/themes/theme.h
Normal file
65
src/themes/theme.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
#include <QColor>
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
struct Theme {
|
||||
QString name;
|
||||
|
||||
// ── Chrome ──
|
||||
QColor background; // editor bg, margin bg, window
|
||||
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 ──
|
||||
QColor text; // primary text, caret, identifiers
|
||||
QColor textDim; // margin fg, status bar
|
||||
QColor textMuted; // inactive tab, disabled menu
|
||||
QColor textFaint; // margin dim, hex dim
|
||||
|
||||
// ── Interactive ──
|
||||
QColor hover; // row hover, tab hover, menu hover
|
||||
QColor selected; // row selection highlight
|
||||
QColor selection; // text selection background
|
||||
|
||||
// ── Syntax ──
|
||||
QColor syntaxKeyword;
|
||||
QColor syntaxNumber;
|
||||
QColor syntaxString;
|
||||
QColor syntaxComment;
|
||||
QColor syntaxPreproc;
|
||||
QColor syntaxType; // custom types / GlobalClass
|
||||
|
||||
// ── Indicators ──
|
||||
QColor indHoverSpan; // hover link text
|
||||
QColor indCmdPill; // command row pill bg
|
||||
QColor indDataChanged; // changed data values
|
||||
QColor indHintGreen; // comment/hint text
|
||||
|
||||
// ── Markers ──
|
||||
QColor markerPtr; // null pointer
|
||||
QColor markerCycle; // cycle detection
|
||||
QColor markerError; // error row bg
|
||||
|
||||
QJsonObject toJson() const;
|
||||
static Theme fromJson(const QJsonObject& obj);
|
||||
};
|
||||
|
||||
// ── 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
|
||||
196
src/themes/themeeditor.cpp
Normal file
196
src/themes/themeeditor.cpp
Normal file
@@ -0,0 +1,196 @@
|
||||
#include "themeeditor.h"
|
||||
#include "thememanager.h"
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QScrollArea>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QColorDialog>
|
||||
#include <QComboBox>
|
||||
#include <cstring>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
// ── Section header label ──
|
||||
|
||||
static QLabel* makeSectionLabel(const QString& text) {
|
||||
auto* lbl = new QLabel(text);
|
||||
lbl->setStyleSheet(QStringLiteral(
|
||||
"font-weight: bold; font-size: 11px; color: #888;"
|
||||
"padding: 6px 0 2px 0; border-bottom: 1px solid #444;"));
|
||||
return lbl;
|
||||
}
|
||||
|
||||
// ── Constructor ──
|
||||
|
||||
ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
|
||||
: QDialog(parent), m_themeIndex(themeIndex)
|
||||
{
|
||||
auto& tm = ThemeManager::instance();
|
||||
auto all = tm.themes();
|
||||
m_theme = (themeIndex >= 0 && themeIndex < all.size()) ? all[themeIndex] : tm.current();
|
||||
|
||||
setWindowTitle(QStringLiteral("Theme Editor"));
|
||||
setMinimumSize(420, 480);
|
||||
resize(440, 640);
|
||||
|
||||
auto* mainLayout = new QVBoxLayout(this);
|
||||
mainLayout->setSpacing(6);
|
||||
|
||||
// ── Theme selector combo ──
|
||||
{
|
||||
auto* row = new QHBoxLayout;
|
||||
row->addWidget(new QLabel(QStringLiteral("Theme:")));
|
||||
m_themeCombo = new QComboBox;
|
||||
for (const auto& t : all)
|
||||
m_themeCombo->addItem(t.name);
|
||||
m_themeCombo->setCurrentIndex(themeIndex);
|
||||
connect(m_themeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||
this, [this](int idx) { loadTheme(idx); });
|
||||
row->addWidget(m_themeCombo, 1);
|
||||
mainLayout->addLayout(row);
|
||||
}
|
||||
|
||||
// ── Name field ──
|
||||
{
|
||||
auto* row = new QHBoxLayout;
|
||||
row->addWidget(new QLabel(QStringLiteral("Name:")));
|
||||
m_nameEdit = new QLineEdit(m_theme.name);
|
||||
connect(m_nameEdit, &QLineEdit::textChanged, this, [this](const QString& t) {
|
||||
m_theme.name = t;
|
||||
});
|
||||
row->addWidget(m_nameEdit, 1);
|
||||
mainLayout->addLayout(row);
|
||||
}
|
||||
|
||||
// ── File info ──
|
||||
m_fileInfoLabel = new QLabel;
|
||||
m_fileInfoLabel->setStyleSheet(QStringLiteral("color: #666; font-size: 10px; padding: 0 0 4px 0;"));
|
||||
QString path = tm.themeFilePath(themeIndex);
|
||||
m_fileInfoLabel->setText(path.isEmpty()
|
||||
? QStringLiteral("Built-in theme (edits save as user copy)")
|
||||
: QStringLiteral("File: %1").arg(path));
|
||||
mainLayout->addWidget(m_fileInfoLabel);
|
||||
|
||||
// ── Scrollable area for swatches ──
|
||||
auto* scroll = new QScrollArea;
|
||||
scroll->setWidgetResizable(true);
|
||||
scroll->setFrameShape(QFrame::NoFrame);
|
||||
auto* scrollWidget = new QWidget;
|
||||
auto* scrollLayout = new QVBoxLayout(scrollWidget);
|
||||
scrollLayout->setContentsMargins(0, 0, 6, 0); // right margin for scrollbar
|
||||
scrollLayout->setSpacing(2);
|
||||
|
||||
// ── Color swatches (driven by kThemeFields) ──
|
||||
const char* currentGroup = nullptr;
|
||||
for (int fi = 0; fi < kThemeFieldCount; fi++) {
|
||||
const auto& f = kThemeFields[fi];
|
||||
|
||||
// Section header on group change
|
||||
if (!currentGroup || std::strcmp(currentGroup, f.group) != 0) {
|
||||
scrollLayout->addWidget(makeSectionLabel(QString::fromLatin1(f.group)));
|
||||
currentGroup = f.group;
|
||||
}
|
||||
|
||||
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);
|
||||
mainLayout->addWidget(scroll, 1);
|
||||
|
||||
// ── Bottom bar ──
|
||||
auto* bottomRow = new QHBoxLayout;
|
||||
bottomRow->addStretch();
|
||||
|
||||
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, [this]() {
|
||||
ThemeManager::instance().revertPreview();
|
||||
reject();
|
||||
});
|
||||
bottomRow->addWidget(buttons);
|
||||
mainLayout->addLayout(bottomRow);
|
||||
|
||||
// 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 ──
|
||||
|
||||
void ThemeEditor::loadTheme(int index) {
|
||||
auto& tm = ThemeManager::instance();
|
||||
auto all = tm.themes();
|
||||
if (index < 0 || index >= all.size()) return;
|
||||
|
||||
m_themeIndex = index;
|
||||
m_theme = all[index];
|
||||
m_nameEdit->setText(m_theme.name);
|
||||
|
||||
QString path = tm.themeFilePath(index);
|
||||
m_fileInfoLabel->setText(path.isEmpty()
|
||||
? QStringLiteral("Built-in theme (edits save as user copy)")
|
||||
: QStringLiteral("File: %1").arg(path));
|
||||
|
||||
for (int i = 0; i < m_swatches.size(); i++)
|
||||
updateSwatch(i);
|
||||
|
||||
tm.previewTheme(m_theme);
|
||||
}
|
||||
|
||||
// ── Swatch update ──
|
||||
|
||||
void ThemeEditor::updateSwatch(int idx) {
|
||||
auto& s = m_swatches[idx];
|
||||
QColor c = m_theme.*s.field;
|
||||
|
||||
s.swatchBtn->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { background: %1; border: 1px solid #555; border-radius: 2px; }")
|
||||
.arg(c.name()));
|
||||
s.hexLabel->setText(c.name());
|
||||
}
|
||||
|
||||
// ── Color picker ──
|
||||
|
||||
void ThemeEditor::pickColor(int idx) {
|
||||
auto& s = m_swatches[idx];
|
||||
QColor c = QColorDialog::getColor(m_theme.*s.field, this, QString::fromLatin1(s.label));
|
||||
if (c.isValid()) {
|
||||
m_theme.*s.field = c;
|
||||
updateSwatch(idx);
|
||||
ThemeManager::instance().previewTheme(m_theme);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace rcx
|
||||
45
src/themes/themeeditor.h
Normal file
45
src/themes/themeeditor.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
#include "theme.h"
|
||||
#include <QDialog>
|
||||
#include <QVector>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
|
||||
class QScrollArea;
|
||||
class QVBoxLayout;
|
||||
class QComboBox;
|
||||
|
||||
namespace rcx {
|
||||
|
||||
class ThemeEditor : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ThemeEditor(int themeIndex, QWidget* parent = nullptr);
|
||||
Theme result() const { return m_theme; }
|
||||
int selectedIndex() const { return m_themeIndex; }
|
||||
|
||||
private:
|
||||
Theme m_theme;
|
||||
int m_themeIndex;
|
||||
|
||||
// ── Swatch row (compact: label + swatch + hex) ──
|
||||
struct SwatchEntry {
|
||||
const char* label;
|
||||
QColor Theme::*field;
|
||||
QPushButton* swatchBtn = nullptr;
|
||||
QLabel* hexLabel = nullptr;
|
||||
};
|
||||
QVector<SwatchEntry> m_swatches;
|
||||
|
||||
// ── UI ──
|
||||
QComboBox* m_themeCombo = nullptr;
|
||||
QLineEdit* m_nameEdit = nullptr;
|
||||
QLabel* m_fileInfoLabel = nullptr;
|
||||
|
||||
void loadTheme(int index);
|
||||
void updateSwatch(int idx);
|
||||
void pickColor(int idx);
|
||||
};
|
||||
|
||||
} // namespace rcx
|
||||
205
src/themes/thememanager.cpp
Normal file
205
src/themes/thememanager.cpp
Normal file
@@ -0,0 +1,205 @@
|
||||
#include "thememanager.h"
|
||||
#include <QSettings>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QStandardPaths>
|
||||
#include <QCoreApplication>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
ThemeManager& ThemeManager::instance() {
|
||||
static ThemeManager s;
|
||||
return s;
|
||||
}
|
||||
|
||||
ThemeManager::ThemeManager() {
|
||||
loadBuiltInThemes();
|
||||
loadUserThemes();
|
||||
|
||||
QSettings settings("Reclass", "Reclass");
|
||||
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);
|
||||
return all;
|
||||
}
|
||||
|
||||
const Theme& ThemeManager::current() const {
|
||||
if (m_currentIdx < m_builtIn.size())
|
||||
return m_builtIn[m_currentIdx];
|
||||
int userIdx = m_currentIdx - m_builtIn.size();
|
||||
if (userIdx >= 0 && userIdx < m_user.size())
|
||||
return m_user[userIdx];
|
||||
if (!m_builtIn.isEmpty())
|
||||
return m_builtIn[0];
|
||||
static const Theme empty;
|
||||
return empty;
|
||||
}
|
||||
|
||||
void ThemeManager::setCurrent(int index) {
|
||||
auto all = themes();
|
||||
if (index < 0 || index >= all.size()) return;
|
||||
m_currentIdx = index;
|
||||
QSettings settings("Reclass", "Reclass");
|
||||
settings.setValue("theme", all[index].name);
|
||||
emit themeChanged(current());
|
||||
}
|
||||
|
||||
void ThemeManager::addTheme(const Theme& theme) {
|
||||
m_user.append(theme);
|
||||
saveUserThemes();
|
||||
}
|
||||
|
||||
void ThemeManager::updateTheme(int index, const Theme& theme) {
|
||||
m_previewing = false; // commit any active preview
|
||||
|
||||
if (index < builtInCount()) {
|
||||
m_builtIn[index] = theme;
|
||||
m_currentIdx = index;
|
||||
} else {
|
||||
int ui = index - builtInCount();
|
||||
if (ui >= 0 && ui < m_user.size())
|
||||
m_user[ui] = theme;
|
||||
}
|
||||
saveUserThemes();
|
||||
QSettings settings("Reclass", "Reclass");
|
||||
settings.setValue("theme", current().name);
|
||||
emit themeChanged(current());
|
||||
}
|
||||
|
||||
void ThemeManager::removeTheme(int index) {
|
||||
if (index < builtInCount()) return;
|
||||
int ui = index - builtInCount();
|
||||
if (ui < 0 || ui >= m_user.size()) return;
|
||||
m_user.remove(ui);
|
||||
if (m_currentIdx == index) {
|
||||
m_currentIdx = 0;
|
||||
emit themeChanged(current());
|
||||
} else if (m_currentIdx > index) {
|
||||
m_currentIdx--;
|
||||
}
|
||||
saveUserThemes();
|
||||
}
|
||||
|
||||
// ── User theme persistence ──
|
||||
|
||||
QString ThemeManager::userDir() const {
|
||||
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
|
||||
+ "/themes";
|
||||
QDir().mkpath(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
void ThemeManager::loadUserThemes() {
|
||||
m_user.clear();
|
||||
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()) 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 = userDir();
|
||||
QDir d(dir);
|
||||
for (const QString& name : d.entryList({"*.json"}, QDir::Files))
|
||||
d.remove(name);
|
||||
|
||||
// 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))
|
||||
f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented));
|
||||
}
|
||||
}
|
||||
|
||||
QString ThemeManager::themeFilePath(int index) const {
|
||||
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 userDir() + "/" + filename;
|
||||
}
|
||||
|
||||
void ThemeManager::previewTheme(const Theme& theme) {
|
||||
if (!m_previewing) {
|
||||
m_savedTheme = current();
|
||||
m_previewing = true;
|
||||
}
|
||||
emit themeChanged(theme);
|
||||
}
|
||||
|
||||
void ThemeManager::revertPreview() {
|
||||
if (m_previewing) {
|
||||
m_previewing = false;
|
||||
emit themeChanged(m_savedTheme);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace rcx
|
||||
47
src/themes/thememanager.h
Normal file
47
src/themes/thememanager.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
#include "theme.h"
|
||||
#include <QObject>
|
||||
#include <QVector>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
class ThemeManager : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
static ThemeManager& instance();
|
||||
|
||||
QVector<Theme> themes() const;
|
||||
int currentIndex() const { return m_currentIdx; }
|
||||
const Theme& current() const;
|
||||
|
||||
void setCurrent(int index);
|
||||
void addTheme(const Theme& theme);
|
||||
void updateTheme(int index, const Theme& theme);
|
||||
void removeTheme(int index);
|
||||
|
||||
void loadUserThemes();
|
||||
void saveUserThemes() const;
|
||||
|
||||
QString themeFilePath(int index) const;
|
||||
void previewTheme(const Theme& theme);
|
||||
void revertPreview();
|
||||
|
||||
signals:
|
||||
void themeChanged(const rcx::Theme& theme);
|
||||
|
||||
private:
|
||||
ThemeManager();
|
||||
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(); }
|
||||
void loadBuiltInThemes();
|
||||
QString builtInDir() const;
|
||||
QString userDir() const;
|
||||
bool m_previewing = false;
|
||||
Theme m_savedTheme;
|
||||
};
|
||||
|
||||
} // namespace rcx
|
||||
186
src/titlebar.cpp
Normal file
186
src/titlebar.cpp
Normal 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
43
src/titlebar.h
Normal 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
|
||||
754
src/typeselectorpopup.cpp
Normal file
754
src/typeselectorpopup.cpp
Normal file
@@ -0,0 +1,754 @@
|
||||
#include "typeselectorpopup.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QToolButton>
|
||||
#include <QButtonGroup>
|
||||
#include <QStringListModel>
|
||||
#include <QStyledItemDelegate>
|
||||
#include <QPainter>
|
||||
#include <QKeyEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QIcon>
|
||||
#include <QApplication>
|
||||
#include <QScreen>
|
||||
#include <QIntValidator>
|
||||
#include <QElapsedTimer>
|
||||
#include "themes/thememanager.h"
|
||||
|
||||
namespace rcx {
|
||||
|
||||
// ── parseTypeSpec ──
|
||||
|
||||
TypeSpec parseTypeSpec(const QString& text) {
|
||||
TypeSpec spec;
|
||||
QString s = text.trimmed();
|
||||
if (s.isEmpty()) return spec;
|
||||
|
||||
// Check for pointer suffix: "Ball*" or "Ball**"
|
||||
if (s.endsWith('*')) {
|
||||
spec.isPointer = true;
|
||||
s.chop(1);
|
||||
if (s.endsWith('*')) s.chop(1); // double pointer
|
||||
spec.baseName = s.trimmed();
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Check for array suffix: "int32_t[10]"
|
||||
int bracket = s.indexOf('[');
|
||||
if (bracket > 0 && s.endsWith(']')) {
|
||||
spec.baseName = s.left(bracket).trimmed();
|
||||
QString countStr = s.mid(bracket + 1, s.size() - bracket - 2);
|
||||
bool ok;
|
||||
int count = countStr.toInt(&ok);
|
||||
if (ok && count > 0)
|
||||
spec.arrayCount = count;
|
||||
return spec;
|
||||
}
|
||||
|
||||
spec.baseName = s;
|
||||
return spec;
|
||||
}
|
||||
|
||||
// ── Custom delegate: gutter checkmark + icon + text + sections ──
|
||||
|
||||
class TypeSelectorDelegate : public QStyledItemDelegate {
|
||||
public:
|
||||
explicit TypeSelectorDelegate(TypeSelectorPopup* popup, QObject* parent = nullptr)
|
||||
: QStyledItemDelegate(parent), m_popup(popup) {}
|
||||
|
||||
void setFont(const QFont& f) { m_font = f; }
|
||||
void setFilteredTypes(const QVector<TypeEntry>* filtered, const TypeEntry* current, bool hasCurrent) {
|
||||
m_filtered = filtered;
|
||||
m_current = current;
|
||||
m_hasCurrent = hasCurrent;
|
||||
}
|
||||
|
||||
void paint(QPainter* painter, const QStyleOptionViewItem& option,
|
||||
const QModelIndex& index) const override {
|
||||
painter->save();
|
||||
|
||||
const auto& t = ThemeManager::instance().current();
|
||||
int row = index.row();
|
||||
bool isSection = (m_filtered && row >= 0 && row < m_filtered->size()
|
||||
&& (*m_filtered)[row].entryKind == TypeEntry::Section);
|
||||
bool isDisabled = (m_filtered && row >= 0 && row < m_filtered->size()
|
||||
&& !(*m_filtered)[row].enabled);
|
||||
|
||||
// Background
|
||||
if (isSection) {
|
||||
// No background highlight for sections
|
||||
} else if (isDisabled) {
|
||||
// Subtle background on hover only
|
||||
if (option.state & QStyle::State_MouseOver)
|
||||
painter->fillRect(option.rect, t.surface);
|
||||
} else {
|
||||
if (option.state & QStyle::State_Selected)
|
||||
painter->fillRect(option.rect, t.selected);
|
||||
else if (option.state & QStyle::State_MouseOver)
|
||||
painter->fillRect(option.rect, t.hover);
|
||||
}
|
||||
|
||||
int x = option.rect.x();
|
||||
int y = option.rect.y();
|
||||
int h = option.rect.height();
|
||||
int w = option.rect.width();
|
||||
|
||||
// Section: centered dim text with horizontal rules
|
||||
if (isSection) {
|
||||
painter->setPen(t.textDim);
|
||||
QFont dimFont = m_font;
|
||||
dimFont.setPointSize(qMax(7, m_font.pointSize() - 1));
|
||||
painter->setFont(dimFont);
|
||||
QFontMetrics fm(dimFont);
|
||||
QString text = index.data().toString();
|
||||
int textW = fm.horizontalAdvance(text);
|
||||
int textX = x + (w - textW) / 2;
|
||||
int lineY = y + h / 2;
|
||||
|
||||
// Left rule
|
||||
if (textX > x + 8)
|
||||
painter->drawLine(x + 8, lineY, textX - 6, lineY);
|
||||
// Text
|
||||
painter->drawText(QRect(textX, y, textW, h), Qt::AlignVCenter, text);
|
||||
// Right rule
|
||||
if (textX + textW + 6 < x + w - 8)
|
||||
painter->drawLine(textX + textW + 6, lineY, x + w - 8, lineY);
|
||||
|
||||
painter->restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (m_current->entryKind == TypeEntry::Primitive && entry.entryKind == TypeEntry::Primitive)
|
||||
isCurrent = (entry.primitiveKind == m_current->primitiveKind);
|
||||
else if (m_current->entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite)
|
||||
isCurrent = (entry.structId == m_current->structId);
|
||||
if (isCurrent) {
|
||||
painter->setPen(t.text);
|
||||
painter->setFont(m_font);
|
||||
painter->drawText(QRect(x, y, 10, h), Qt::AlignCenter,
|
||||
QString(QChar(0x25B8)));
|
||||
}
|
||||
}
|
||||
x += 10;
|
||||
|
||||
// Icon 16x16 — only for composite entries
|
||||
bool hasIcon = (m_filtered && row >= 0 && row < m_filtered->size()
|
||||
&& (*m_filtered)[row].entryKind == TypeEntry::Composite);
|
||||
if (hasIcon) {
|
||||
static QIcon structIcon(QStringLiteral(":/vsicons/symbol-structure.svg"));
|
||||
QPixmap pm = structIcon.pixmap(16, 16);
|
||||
if (isDisabled) {
|
||||
// Paint dimmed
|
||||
QPixmap dimmed(pm.size());
|
||||
dimmed.fill(Qt::transparent);
|
||||
QPainter p(&dimmed);
|
||||
p.setOpacity(0.35);
|
||||
p.drawPixmap(0, 0, pm);
|
||||
p.end();
|
||||
painter->drawPixmap(x, y + (h - 16) / 2, dimmed);
|
||||
} else {
|
||||
structIcon.paint(painter, x, y + (h - 16) / 2, 16, 16);
|
||||
}
|
||||
}
|
||||
x += 20;
|
||||
|
||||
// Text
|
||||
QColor textColor;
|
||||
if (isDisabled)
|
||||
textColor = t.textDim;
|
||||
else if (option.state & QStyle::State_Selected)
|
||||
textColor = option.palette.color(QPalette::HighlightedText);
|
||||
else
|
||||
textColor = option.palette.color(QPalette::Text);
|
||||
|
||||
painter->setPen(textColor);
|
||||
painter->setFont(m_font);
|
||||
painter->drawText(QRect(x, y, option.rect.right() - x, h),
|
||||
Qt::AlignVCenter | Qt::AlignLeft,
|
||||
index.data().toString());
|
||||
|
||||
painter->restore();
|
||||
}
|
||||
|
||||
QSize sizeHint(const QStyleOptionViewItem& /*option*/,
|
||||
const QModelIndex& index) const override {
|
||||
QFontMetrics fm(m_font);
|
||||
int row = index.row();
|
||||
bool isSection = (m_filtered && row >= 0 && row < m_filtered->size()
|
||||
&& (*m_filtered)[row].entryKind == TypeEntry::Section);
|
||||
int h = isSection ? fm.height() + 2 : fm.height() + 8;
|
||||
return QSize(200, h);
|
||||
}
|
||||
|
||||
private:
|
||||
TypeSelectorPopup* m_popup = nullptr;
|
||||
QFont m_font;
|
||||
const QVector<TypeEntry>* m_filtered = nullptr;
|
||||
const TypeEntry* m_current = nullptr;
|
||||
bool m_hasCurrent = false;
|
||||
};
|
||||
|
||||
// ── TypeSelectorPopup ──
|
||||
|
||||
TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
|
||||
: QFrame(parent, Qt::Popup | Qt::FramelessWindowHint)
|
||||
{
|
||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||
|
||||
const auto& theme = ThemeManager::instance().current();
|
||||
QPalette pal;
|
||||
pal.setColor(QPalette::Window, theme.backgroundAlt);
|
||||
pal.setColor(QPalette::WindowText, theme.text);
|
||||
pal.setColor(QPalette::Base, theme.background);
|
||||
pal.setColor(QPalette::AlternateBase, theme.surface);
|
||||
pal.setColor(QPalette::Text, theme.text);
|
||||
pal.setColor(QPalette::Button, theme.button);
|
||||
pal.setColor(QPalette::ButtonText, theme.text);
|
||||
pal.setColor(QPalette::Highlight, theme.hover);
|
||||
pal.setColor(QPalette::HighlightedText, theme.text);
|
||||
setPalette(pal);
|
||||
setAutoFillBackground(true);
|
||||
|
||||
setFrameShape(QFrame::NoFrame);
|
||||
setLineWidth(0);
|
||||
|
||||
auto* layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(6, 6, 6, 6);
|
||||
layout->setSpacing(4);
|
||||
|
||||
// Row 1: title + Esc hint
|
||||
{
|
||||
auto* row = new QHBoxLayout;
|
||||
row->setContentsMargins(0, 0, 0, 0);
|
||||
m_titleLabel = new QLabel(QStringLiteral("Change type"));
|
||||
m_titleLabel->setPalette(pal);
|
||||
QFont bold = m_titleLabel->font();
|
||||
bold.setBold(true);
|
||||
m_titleLabel->setFont(bold);
|
||||
row->addWidget(m_titleLabel);
|
||||
|
||||
row->addStretch();
|
||||
|
||||
m_escLabel = new QToolButton;
|
||||
m_escLabel->setText(QStringLiteral("\u2715 Esc"));
|
||||
m_escLabel->setAutoRaise(true);
|
||||
m_escLabel->setCursor(Qt::PointingHandCursor);
|
||||
m_escLabel->setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: %1; border: none; padding: 2px 6px; }"
|
||||
"QToolButton:hover { color: %2; }")
|
||||
.arg(theme.textDim.name(), theme.indHoverSpan.name()));
|
||||
connect(m_escLabel, &QToolButton::clicked, this, [this]() {
|
||||
hide();
|
||||
});
|
||||
row->addWidget(m_escLabel);
|
||||
|
||||
layout->addLayout(row);
|
||||
}
|
||||
|
||||
// Row 2: + Create new type button (flat, no gradient)
|
||||
{
|
||||
m_createBtn = new QToolButton;
|
||||
m_createBtn->setText(QStringLiteral("+ Create new type\u2026"));
|
||||
m_createBtn->setToolButtonStyle(Qt::ToolButtonTextOnly);
|
||||
m_createBtn->setAutoRaise(true);
|
||||
m_createBtn->setCursor(Qt::PointingHandCursor);
|
||||
m_createBtn->setStyleSheet(QStringLiteral(
|
||||
"QToolButton { color: %1; border: none; padding: 3px 6px; }"
|
||||
"QToolButton:hover { color: %2; background: %3; }")
|
||||
.arg(theme.textMuted.name(), theme.text.name(), theme.hover.name()));
|
||||
connect(m_createBtn, &QToolButton::clicked, this, [this]() {
|
||||
emit createNewTypeRequested();
|
||||
hide();
|
||||
});
|
||||
layout->addWidget(m_createBtn);
|
||||
}
|
||||
|
||||
// Separator
|
||||
{
|
||||
auto* sep = new QFrame;
|
||||
sep->setFrameShape(QFrame::HLine);
|
||||
sep->setFrameShadow(QFrame::Plain);
|
||||
QPalette sepPal = pal;
|
||||
sepPal.setColor(QPalette::WindowText, theme.border);
|
||||
sep->setPalette(sepPal);
|
||||
sep->setFixedHeight(1);
|
||||
layout->addWidget(sep);
|
||||
}
|
||||
|
||||
// Row 3: Modifier toggles [ plain ] [ * ] [ ** ] [ [n] ]
|
||||
{
|
||||
m_modRow = new QWidget;
|
||||
auto* modLayout = new QHBoxLayout(m_modRow);
|
||||
modLayout->setContentsMargins(0, 0, 0, 0);
|
||||
modLayout->setSpacing(3);
|
||||
|
||||
m_modGroup = new QButtonGroup(this);
|
||||
m_modGroup->setExclusive(true);
|
||||
|
||||
QString btnStyle = QStringLiteral(
|
||||
"QToolButton { color: %1; background: %2; border: 1px solid %3;"
|
||||
" padding: 2px 8px; border-radius: 3px; }"
|
||||
"QToolButton:checked { color: %4; background: %5; border-color: %5; }"
|
||||
"QToolButton:hover:!checked { background: %6; }")
|
||||
.arg(theme.textDim.name(), theme.background.name(), theme.border.name(),
|
||||
theme.text.name(), theme.selected.name(), theme.hover.name());
|
||||
|
||||
auto makeToggle = [&](const QString& label, int id) -> QToolButton* {
|
||||
auto* btn = new QToolButton;
|
||||
btn->setText(label);
|
||||
btn->setCheckable(true);
|
||||
btn->setCursor(Qt::PointingHandCursor);
|
||||
btn->setStyleSheet(btnStyle);
|
||||
m_modGroup->addButton(btn, id);
|
||||
modLayout->addWidget(btn);
|
||||
return btn;
|
||||
};
|
||||
|
||||
m_btnPlain = makeToggle(QStringLiteral("plain"), 0);
|
||||
m_btnPtr = makeToggle(QStringLiteral("*"), 1);
|
||||
m_btnDblPtr = makeToggle(QStringLiteral("**"), 2);
|
||||
m_btnArray = makeToggle(QStringLiteral("[n]"), 3);
|
||||
m_btnPlain->setChecked(true);
|
||||
|
||||
// Array count input (shown only when [n] is active)
|
||||
m_arrayCountEdit = new QLineEdit;
|
||||
m_arrayCountEdit->setPlaceholderText(QStringLiteral("n"));
|
||||
m_arrayCountEdit->setValidator(new QIntValidator(1, 99999, m_arrayCountEdit));
|
||||
m_arrayCountEdit->setFixedWidth(50);
|
||||
m_arrayCountEdit->setPalette(pal);
|
||||
m_arrayCountEdit->hide();
|
||||
modLayout->addWidget(m_arrayCountEdit);
|
||||
|
||||
modLayout->addStretch();
|
||||
layout->addWidget(m_modRow);
|
||||
|
||||
connect(m_modGroup, &QButtonGroup::idToggled,
|
||||
this, [this](int id, bool checked) {
|
||||
if (!checked) return;
|
||||
m_arrayCountEdit->setVisible(id == 3);
|
||||
if (id == 3) m_arrayCountEdit->setFocus();
|
||||
updateModifierPreview();
|
||||
});
|
||||
connect(m_arrayCountEdit, &QLineEdit::textChanged,
|
||||
this, [this]() { updateModifierPreview(); });
|
||||
}
|
||||
|
||||
// Row 4: Filter + preview
|
||||
{
|
||||
m_filterEdit = new QLineEdit;
|
||||
m_filterEdit->setPlaceholderText(QStringLiteral("Filter types\u2026"));
|
||||
m_filterEdit->setClearButtonEnabled(true);
|
||||
m_filterEdit->setPalette(pal);
|
||||
m_filterEdit->installEventFilter(this);
|
||||
connect(m_filterEdit, &QLineEdit::textChanged,
|
||||
this, &TypeSelectorPopup::applyFilter);
|
||||
layout->addWidget(m_filterEdit);
|
||||
|
||||
m_previewLabel = new QLabel;
|
||||
m_previewLabel->setPalette(pal);
|
||||
m_previewLabel->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: %1; padding: 1px 6px; }").arg(theme.syntaxType.name()));
|
||||
m_previewLabel->hide();
|
||||
layout->addWidget(m_previewLabel);
|
||||
}
|
||||
|
||||
// Row 4: List
|
||||
{
|
||||
m_model = new QStringListModel(this);
|
||||
m_listView = new QListView;
|
||||
m_listView->setModel(m_model);
|
||||
m_listView->setPalette(pal);
|
||||
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);
|
||||
|
||||
auto* delegate = new TypeSelectorDelegate(this, m_listView);
|
||||
m_listView->setItemDelegate(delegate);
|
||||
|
||||
layout->addWidget(m_listView, 1);
|
||||
|
||||
connect(m_listView, &QListView::clicked,
|
||||
this, [this](const QModelIndex& index) {
|
||||
acceptIndex(index.row());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 = QStringLiteral("warmup");
|
||||
setTypes({dummy});
|
||||
popup(QPoint(-9999, -9999));
|
||||
hide();
|
||||
QApplication::processEvents();
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::setFont(const QFont& font) {
|
||||
m_font = font;
|
||||
|
||||
m_titleLabel->setFont([&]() {
|
||||
QFont f = font; f.setBold(true); return f;
|
||||
}());
|
||||
m_escLabel->setFont(font);
|
||||
m_createBtn->setFont(font);
|
||||
m_filterEdit->setFont(font);
|
||||
m_listView->setFont(font);
|
||||
m_previewLabel->setFont(font);
|
||||
|
||||
QFont smallFont = font;
|
||||
smallFont.setPointSize(qMax(7, font.pointSize() - 1));
|
||||
m_btnPlain->setFont(smallFont);
|
||||
m_btnPtr->setFont(smallFont);
|
||||
m_btnDblPtr->setFont(smallFont);
|
||||
m_btnArray->setFont(smallFont);
|
||||
m_arrayCountEdit->setFont(smallFont);
|
||||
|
||||
auto* delegate = static_cast<TypeSelectorDelegate*>(m_listView->itemDelegate());
|
||||
if (delegate)
|
||||
delegate->setFont(font);
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::setTitle(const QString& title) {
|
||||
m_titleLabel->setText(title);
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::setMode(TypePopupMode mode) {
|
||||
m_mode = mode;
|
||||
// Show modifier toggles for modes where type modifiers make sense
|
||||
bool showMods = (mode == TypePopupMode::FieldType
|
||||
|| mode == TypePopupMode::ArrayElement);
|
||||
m_modRow->setVisible(showMods);
|
||||
// Reset to plain when showing
|
||||
if (showMods) {
|
||||
m_btnPlain->setChecked(true);
|
||||
m_arrayCountEdit->clear();
|
||||
m_arrayCountEdit->hide();
|
||||
}
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::setCurrentNodeSize(int bytes) {
|
||||
m_currentNodeSize = bytes;
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::setTypes(const QVector<TypeEntry>& types, const TypeEntry* current) {
|
||||
m_allTypes = types;
|
||||
if (current) {
|
||||
m_currentEntry = *current;
|
||||
m_hasCurrent = true;
|
||||
} else {
|
||||
m_currentEntry = TypeEntry{};
|
||||
m_hasCurrent = false;
|
||||
}
|
||||
// Reset modifier toggles
|
||||
m_btnPlain->setChecked(true);
|
||||
m_arrayCountEdit->clear();
|
||||
m_arrayCountEdit->hide();
|
||||
m_previewLabel->hide();
|
||||
|
||||
m_filterEdit->clear();
|
||||
applyFilter(QString());
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::popup(const QPoint& globalPos) {
|
||||
QFontMetrics fm(m_font);
|
||||
int maxTextW = fm.horizontalAdvance(QStringLiteral("Choose element type Esc"));
|
||||
for (const auto& t : m_allTypes) {
|
||||
QString text = t.classKeyword.isEmpty()
|
||||
? t.displayName
|
||||
: (t.classKeyword + QStringLiteral(" ") + t.displayName);
|
||||
int w = 10 + 20 + fm.horizontalAdvance(text) + 16;
|
||||
if (w > maxTextW) maxTextW = w;
|
||||
}
|
||||
int popupW = qBound(280, maxTextW + 24, 500);
|
||||
int rowH = fm.height() + 8;
|
||||
int headerH = rowH * 3 + 30;
|
||||
if (m_modRow->isVisible())
|
||||
headerH += rowH + 4; // extra row for modifier toggles
|
||||
int listH = qBound(rowH * 3, rowH * (int)m_filteredTypes.size(), rowH * 14);
|
||||
int popupH = headerH + listH;
|
||||
|
||||
QScreen* screen = QApplication::screenAt(globalPos);
|
||||
if (screen) {
|
||||
QRect avail = screen->availableGeometry();
|
||||
if (globalPos.y() + popupH > avail.bottom())
|
||||
popupH = avail.bottom() - globalPos.y();
|
||||
if (globalPos.x() + popupW > avail.right())
|
||||
popupW = avail.right() - globalPos.x();
|
||||
}
|
||||
|
||||
setFixedSize(popupW, popupH);
|
||||
move(globalPos);
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
m_filterEdit->setFocus();
|
||||
|
||||
// Pre-select current type in list
|
||||
if (m_hasCurrent) {
|
||||
for (int i = 0; i < m_filteredTypes.size(); i++) {
|
||||
const auto& entry = m_filteredTypes[i];
|
||||
if (entry.entryKind == TypeEntry::Section) continue;
|
||||
bool match = false;
|
||||
if (m_currentEntry.entryKind == TypeEntry::Primitive && entry.entryKind == TypeEntry::Primitive)
|
||||
match = (entry.primitiveKind == m_currentEntry.primitiveKind);
|
||||
else if (m_currentEntry.entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite)
|
||||
match = (entry.structId == m_currentEntry.structId);
|
||||
if (match) {
|
||||
m_listView->setCurrentIndex(m_model->index(i));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::updateModifierPreview() {
|
||||
int modId = m_modGroup->checkedId();
|
||||
if (modId <= 0) {
|
||||
m_previewLabel->hide();
|
||||
return;
|
||||
}
|
||||
QString suffix;
|
||||
if (modId == 1) suffix = QStringLiteral("*");
|
||||
else if (modId == 2) suffix = QStringLiteral("**");
|
||||
else if (modId == 3) {
|
||||
QString countText = m_arrayCountEdit->text().trimmed();
|
||||
suffix = countText.isEmpty()
|
||||
? QStringLiteral("[n]")
|
||||
: QStringLiteral("[%1]").arg(countText);
|
||||
}
|
||||
m_previewLabel->setText(QStringLiteral("\u2192 <type>%1").arg(suffix));
|
||||
m_previewLabel->show();
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::applyFilter(const QString& text) {
|
||||
m_filteredTypes.clear();
|
||||
QStringList displayStrings;
|
||||
|
||||
QString filterBase = text.trimmed();
|
||||
|
||||
// Separate primitives and composites
|
||||
QVector<TypeEntry> primitives, composites;
|
||||
for (const auto& t : m_allTypes) {
|
||||
if (t.entryKind == TypeEntry::Section) continue; // skip stale sections
|
||||
bool matchesFilter = filterBase.isEmpty()
|
||||
|| t.displayName.contains(filterBase, Qt::CaseInsensitive)
|
||||
|| t.classKeyword.contains(filterBase, Qt::CaseInsensitive);
|
||||
if (!matchesFilter) continue;
|
||||
|
||||
if (t.entryKind == TypeEntry::Primitive)
|
||||
primitives.append(t);
|
||||
else if (t.entryKind == TypeEntry::Composite)
|
||||
composites.append(t);
|
||||
}
|
||||
|
||||
// For non-Root modes, sort primitives: same-size first, then rest
|
||||
if (m_mode != TypePopupMode::Root && m_currentNodeSize > 0 && !primitives.isEmpty()) {
|
||||
QVector<TypeEntry> sameSize, other;
|
||||
for (const auto& p : primitives) {
|
||||
if (sizeForKind(p.primitiveKind) == m_currentNodeSize)
|
||||
sameSize.append(p);
|
||||
else
|
||||
other.append(p);
|
||||
}
|
||||
primitives = sameSize + other;
|
||||
}
|
||||
|
||||
// Helper lambdas for appending sections
|
||||
auto appendPrimitives = [&]() {
|
||||
if (primitives.isEmpty()) return;
|
||||
TypeEntry sec;
|
||||
sec.entryKind = TypeEntry::Section;
|
||||
sec.displayName = QStringLiteral("primitives");
|
||||
sec.enabled = false;
|
||||
m_filteredTypes.append(sec);
|
||||
displayStrings << sec.displayName;
|
||||
for (const auto& p : primitives) {
|
||||
m_filteredTypes.append(p);
|
||||
displayStrings << p.displayName;
|
||||
}
|
||||
};
|
||||
auto appendComposites = [&]() {
|
||||
if (composites.isEmpty()) return;
|
||||
TypeEntry sec;
|
||||
sec.entryKind = TypeEntry::Section;
|
||||
sec.displayName = QStringLiteral("project types");
|
||||
sec.enabled = false;
|
||||
m_filteredTypes.append(sec);
|
||||
displayStrings << sec.displayName;
|
||||
for (const auto& c : composites) {
|
||||
m_filteredTypes.append(c);
|
||||
QString label = c.classKeyword.isEmpty()
|
||||
? c.displayName
|
||||
: (c.classKeyword + QStringLiteral(" ") + c.displayName);
|
||||
displayStrings << label;
|
||||
}
|
||||
};
|
||||
|
||||
// Root mode: project types first (composites are the primary selection)
|
||||
if (m_mode == TypePopupMode::Root) {
|
||||
appendComposites();
|
||||
appendPrimitives();
|
||||
} else {
|
||||
appendPrimitives();
|
||||
appendComposites();
|
||||
}
|
||||
|
||||
m_model->setStringList(displayStrings);
|
||||
|
||||
auto* delegate = static_cast<TypeSelectorDelegate*>(m_listView->itemDelegate());
|
||||
if (delegate)
|
||||
delegate->setFilteredTypes(&m_filteredTypes, &m_currentEntry, m_hasCurrent);
|
||||
|
||||
// Select first selectable item
|
||||
int first = nextSelectableRow(0, 1);
|
||||
if (first >= 0)
|
||||
m_listView->setCurrentIndex(m_model->index(first));
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::acceptCurrent() {
|
||||
QModelIndex idx = m_listView->currentIndex();
|
||||
if (idx.isValid())
|
||||
acceptIndex(idx.row());
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::acceptIndex(int row) {
|
||||
if (row < 0 || row >= m_filteredTypes.size()) return;
|
||||
const TypeEntry& entry = m_filteredTypes[row];
|
||||
if (entry.entryKind == TypeEntry::Section) return;
|
||||
if (!entry.enabled) return;
|
||||
|
||||
// Build full text with modifier from toggle buttons
|
||||
int modId = m_modGroup->checkedId();
|
||||
QString fullText = entry.displayName;
|
||||
if (modId == 1)
|
||||
fullText += QStringLiteral("*");
|
||||
else if (modId == 2)
|
||||
fullText += QStringLiteral("**");
|
||||
else if (modId == 3) {
|
||||
QString countText = m_arrayCountEdit->text().trimmed();
|
||||
if (!countText.isEmpty())
|
||||
fullText += QStringLiteral("[%1]").arg(countText);
|
||||
}
|
||||
|
||||
emit typeSelected(entry, fullText);
|
||||
hide();
|
||||
}
|
||||
|
||||
int TypeSelectorPopup::nextSelectableRow(int from, int direction) const {
|
||||
int i = from;
|
||||
while (i >= 0 && i < m_filteredTypes.size()) {
|
||||
const auto& e = m_filteredTypes[i];
|
||||
if (e.entryKind != TypeEntry::Section && e.enabled)
|
||||
return i;
|
||||
i += direction;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool TypeSelectorPopup::eventFilter(QObject* obj, QEvent* event) {
|
||||
if (event->type() == QEvent::KeyPress) {
|
||||
auto* ke = static_cast<QKeyEvent*>(event);
|
||||
|
||||
if (ke->key() == Qt::Key_Escape) {
|
||||
hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (obj == m_filterEdit) {
|
||||
if (ke->key() == Qt::Key_Down) {
|
||||
m_listView->setFocus();
|
||||
QModelIndex cur = m_listView->currentIndex();
|
||||
int startRow = cur.isValid() ? cur.row() : 0;
|
||||
int next = nextSelectableRow(startRow, 1);
|
||||
if (next >= 0)
|
||||
m_listView->setCurrentIndex(m_model->index(next));
|
||||
return true;
|
||||
}
|
||||
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
|
||||
acceptCurrent();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (obj == m_listView) {
|
||||
if (ke->key() == Qt::Key_Up) {
|
||||
QModelIndex cur = m_listView->currentIndex();
|
||||
if (!cur.isValid() || cur.row() == 0) {
|
||||
m_filterEdit->setFocus();
|
||||
return true;
|
||||
}
|
||||
// Skip sections and disabled entries
|
||||
int prev = nextSelectableRow(cur.row() - 1, -1);
|
||||
if (prev < 0) {
|
||||
m_filterEdit->setFocus();
|
||||
return true;
|
||||
}
|
||||
m_listView->setCurrentIndex(m_model->index(prev));
|
||||
return true;
|
||||
}
|
||||
if (ke->key() == Qt::Key_Down) {
|
||||
QModelIndex cur = m_listView->currentIndex();
|
||||
int startRow = cur.isValid() ? cur.row() + 1 : 0;
|
||||
int next = nextSelectableRow(startRow, 1);
|
||||
if (next >= 0)
|
||||
m_listView->setCurrentIndex(m_model->index(next));
|
||||
return true;
|
||||
}
|
||||
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
|
||||
acceptCurrent();
|
||||
return true;
|
||||
}
|
||||
// Forward printable keys to filter edit for type-to-filter
|
||||
if (!ke->text().isEmpty() && ke->text()[0].isPrint()) {
|
||||
m_filterEdit->setFocus();
|
||||
m_filterEdit->setText(m_filterEdit->text() + ke->text());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return QFrame::eventFilter(obj, event);
|
||||
}
|
||||
|
||||
void TypeSelectorPopup::hideEvent(QHideEvent* event) {
|
||||
QFrame::hideEvent(event);
|
||||
emit dismissed();
|
||||
}
|
||||
|
||||
} // namespace rcx
|
||||
105
src/typeselectorpopup.h
Normal file
105
src/typeselectorpopup.h
Normal file
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
#include <QFrame>
|
||||
#include <QFont>
|
||||
#include <QVector>
|
||||
#include <QString>
|
||||
#include <cstdint>
|
||||
#include "core.h"
|
||||
|
||||
class QLineEdit;
|
||||
class QListView;
|
||||
class QStringListModel;
|
||||
class QLabel;
|
||||
class QToolButton;
|
||||
class QButtonGroup;
|
||||
class QWidget;
|
||||
|
||||
namespace rcx {
|
||||
|
||||
// ── Popup mode ──
|
||||
|
||||
enum class TypePopupMode { Root, FieldType, ArrayElement, PointerTarget };
|
||||
|
||||
// ── Type entry (explicit discriminant — no sentinel IDs) ──
|
||||
|
||||
struct TypeEntry {
|
||||
enum Kind { Primitive, Composite, Section };
|
||||
|
||||
Kind entryKind = Primitive;
|
||||
NodeKind primitiveKind = NodeKind::Hex8; // valid when entryKind==Primitive
|
||||
uint64_t structId = 0; // valid when entryKind==Composite
|
||||
QString displayName;
|
||||
QString classKeyword; // "struct", "class", "enum" (Composite only)
|
||||
bool enabled = true; // false = grayed out (visible but not selectable)
|
||||
};
|
||||
|
||||
// ── Parsed type spec (shared between popup filter and inline edit) ──
|
||||
|
||||
struct TypeSpec {
|
||||
QString baseName;
|
||||
bool isPointer = false;
|
||||
int arrayCount = 0; // 0 = not array
|
||||
};
|
||||
|
||||
TypeSpec parseTypeSpec(const QString& text);
|
||||
|
||||
// ── Popup widget ──
|
||||
|
||||
class TypeSelectorPopup : public QFrame {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit TypeSelectorPopup(QWidget* parent = nullptr);
|
||||
|
||||
void setFont(const QFont& font);
|
||||
void setTitle(const QString& title);
|
||||
void setMode(TypePopupMode mode);
|
||||
void setCurrentNodeSize(int bytes);
|
||||
void setTypes(const QVector<TypeEntry>& types, const TypeEntry* current = nullptr);
|
||||
void popup(const QPoint& globalPos);
|
||||
|
||||
/// Force native window creation to avoid cold-start delay.
|
||||
void warmUp();
|
||||
|
||||
signals:
|
||||
void typeSelected(const TypeEntry& entry, const QString& fullText);
|
||||
void createNewTypeRequested();
|
||||
void dismissed();
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* obj, QEvent* event) override;
|
||||
void hideEvent(QHideEvent* event) override;
|
||||
|
||||
private:
|
||||
QLabel* m_titleLabel = nullptr;
|
||||
QToolButton* m_escLabel = nullptr;
|
||||
QToolButton* m_createBtn = nullptr;
|
||||
QLineEdit* m_filterEdit = nullptr;
|
||||
QLabel* m_previewLabel = nullptr;
|
||||
QListView* m_listView = nullptr;
|
||||
QStringListModel* m_model = nullptr;
|
||||
|
||||
// Modifier toggles
|
||||
QWidget* m_modRow = nullptr;
|
||||
QToolButton* m_btnPlain = nullptr;
|
||||
QToolButton* m_btnPtr = nullptr;
|
||||
QToolButton* m_btnDblPtr = nullptr;
|
||||
QToolButton* m_btnArray = nullptr;
|
||||
QLineEdit* m_arrayCountEdit = nullptr;
|
||||
QButtonGroup* m_modGroup = nullptr;
|
||||
|
||||
QVector<TypeEntry> m_allTypes;
|
||||
QVector<TypeEntry> m_filteredTypes;
|
||||
TypeEntry m_currentEntry;
|
||||
bool m_hasCurrent = false;
|
||||
TypePopupMode m_mode = TypePopupMode::FieldType;
|
||||
int m_currentNodeSize = 0;
|
||||
QFont m_font;
|
||||
|
||||
void applyFilter(const QString& text);
|
||||
void updateModifierPreview();
|
||||
void acceptCurrent();
|
||||
void acceptIndex(int row);
|
||||
int nextSelectableRow(int from, int direction) const;
|
||||
};
|
||||
|
||||
} // namespace rcx
|
||||
@@ -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
185
tests/test_com_security.cpp
Normal 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
|
||||
}
|
||||
@@ -48,8 +48,8 @@ private slots:
|
||||
QCOMPARE(result.meta[2].depth, 1);
|
||||
|
||||
// Offset text
|
||||
QCOMPARE(result.meta[1].offsetText, QString("0"));
|
||||
QCOMPARE(result.meta[2].offsetText, QString("4"));
|
||||
QCOMPARE(result.meta[1].offsetText, QString("0000 "));
|
||||
QCOMPARE(result.meta[2].offsetText, QString("0004 "));
|
||||
|
||||
// Line 3 is root footer
|
||||
QCOMPARE(result.meta[3].lineKind, LineKind::Footer);
|
||||
@@ -81,7 +81,7 @@ private slots:
|
||||
|
||||
// Line 1: single Vec3 line, not continuation, depth 1
|
||||
QVERIFY(!result.meta[1].isContinuation);
|
||||
QCOMPARE(result.meta[1].offsetText, QString("0"));
|
||||
QCOMPARE(result.meta[1].offsetText, QString("0000 "));
|
||||
QCOMPARE(result.meta[1].depth, 1);
|
||||
QCOMPARE(result.meta[1].nodeKind, NodeKind::Vec3);
|
||||
|
||||
@@ -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
|
||||
@@ -732,7 +731,7 @@ private slots:
|
||||
}
|
||||
|
||||
void testArrayHeaderCharTypes() {
|
||||
// UInt8 array → "char[N]", UInt16 → "wchar_t[N]"
|
||||
// UInt8 array → "uint8_t[N]", UInt16 → "uint16_t[N]"
|
||||
NodeTree tree;
|
||||
tree.baseAddress = 0;
|
||||
|
||||
@@ -769,11 +768,11 @@ private slots:
|
||||
for (int i = 0; i < result.meta.size(); i++) {
|
||||
if (!result.meta[i].isArrayHeader) continue;
|
||||
QString text = lines[i];
|
||||
if (text.contains("char[64]")) foundChar = true;
|
||||
if (text.contains("wchar_t[32]")) foundWchar = true;
|
||||
if (text.contains("uint8_t[64]")) foundChar = true;
|
||||
if (text.contains("uint16_t[32]")) foundWchar = true;
|
||||
}
|
||||
QVERIFY2(foundChar, "Should have 'char[64]' header");
|
||||
QVERIFY2(foundWchar, "Should have 'wchar_t[32]' header");
|
||||
QVERIFY2(foundChar, "Should have 'uint8_t[64]' header");
|
||||
QVERIFY2(foundWchar, "Should have 'uint16_t[32]' header");
|
||||
}
|
||||
|
||||
void testArraySpansClickable() {
|
||||
@@ -995,13 +994,13 @@ private slots:
|
||||
ComposeResult r2 = compose(tree, prov);
|
||||
QStringList lines2 = r2.text.split('\n');
|
||||
bool found42 = false;
|
||||
bool still10 = false;
|
||||
for (const QString& l : lines2) {
|
||||
if (l.contains("[42]")) found42 = true;
|
||||
if (l.contains("[10]")) still10 = true;
|
||||
bool still10Header = false;
|
||||
for (int i = 0; i < r2.meta.size(); i++) {
|
||||
if (r2.meta[i].isArrayHeader && lines2[i].contains("uint8_t[42]")) found42 = true;
|
||||
if (r2.meta[i].isArrayHeader && lines2[i].contains("uint8_t[10]")) still10Header = true;
|
||||
}
|
||||
QVERIFY2(found42, "Recomposed text should show [42]");
|
||||
QVERIFY2(!still10, "Recomposed text should NOT still show [10]");
|
||||
QVERIFY2(found42, "Recomposed header should show uint8_t[42]");
|
||||
QVERIFY2(!still10Header, "Recomposed header should NOT still show uint8_t[10]");
|
||||
|
||||
// Spans must still work after recompose
|
||||
int headerLine = -1;
|
||||
@@ -1015,6 +1014,161 @@ private slots:
|
||||
QCOMPARE(countText, QString("42"));
|
||||
}
|
||||
|
||||
void testPrimitiveArrayElements() {
|
||||
// Expanded primitive array should synthesize element lines dynamically
|
||||
NodeTree tree;
|
||||
tree.baseAddress = 0x1000;
|
||||
|
||||
Node root;
|
||||
root.kind = NodeKind::Struct;
|
||||
root.name = "Root";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
Node arr;
|
||||
arr.kind = NodeKind::Array;
|
||||
arr.name = "values";
|
||||
arr.parentId = rootId;
|
||||
arr.offset = 0;
|
||||
arr.elementKind = NodeKind::UInt32;
|
||||
arr.arrayLen = 4;
|
||||
tree.addNode(arr);
|
||||
|
||||
// Buffer with known values: 0x11, 0x22, 0x33, 0x44
|
||||
QByteArray data(64, '\0');
|
||||
uint32_t v0 = 0x11, v1 = 0x22, v2 = 0x33, v3 = 0x44;
|
||||
memcpy(data.data() + 0, &v0, 4);
|
||||
memcpy(data.data() + 4, &v1, 4);
|
||||
memcpy(data.data() + 8, &v2, 4);
|
||||
memcpy(data.data() + 12, &v3, 4);
|
||||
BufferProvider prov(data);
|
||||
|
||||
ComposeResult result = compose(tree, prov);
|
||||
QStringList lines = result.text.split('\n');
|
||||
|
||||
// Find array header
|
||||
int headerLine = -1;
|
||||
for (int i = 0; i < result.meta.size(); i++) {
|
||||
if (result.meta[i].isArrayHeader) { headerLine = i; break; }
|
||||
}
|
||||
QVERIFY2(headerLine >= 0, "Array header must exist");
|
||||
QVERIFY2(lines[headerLine].contains("uint32_t[4]"),
|
||||
qPrintable("Header should contain 'uint32_t[4]': " + lines[headerLine]));
|
||||
|
||||
// Count element field lines (depth >= 2, lineKind == Field)
|
||||
int elemCount = 0;
|
||||
bool found0 = false, found3 = false;
|
||||
for (int i = 0; i < result.meta.size(); i++) {
|
||||
if (result.meta[i].lineKind == LineKind::Field && result.meta[i].depth >= 2) {
|
||||
elemCount++;
|
||||
// Type column should have combined type+index: "uint32_t[0]"
|
||||
if (lines[i].contains("uint32_t[0]")) found0 = true;
|
||||
if (lines[i].contains("uint32_t[3]")) found3 = true;
|
||||
// isArrayElement flag must be set
|
||||
QVERIFY2(result.meta[i].isArrayElement,
|
||||
qPrintable("Element line must have isArrayElement=true: " + lines[i]));
|
||||
}
|
||||
}
|
||||
QCOMPARE(elemCount, 4);
|
||||
QVERIFY2(found0, "Should have uint32_t[0] element");
|
||||
QVERIFY2(found3, "Should have uint32_t[3] element");
|
||||
|
||||
// Check footer exists
|
||||
bool hasFooter = false;
|
||||
for (int i = headerLine + 1; i < result.meta.size(); i++) {
|
||||
if (result.meta[i].lineKind == LineKind::Footer && result.meta[i].nodeKind == NodeKind::Array) {
|
||||
hasFooter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
QVERIFY2(hasFooter, "Array should have footer line");
|
||||
}
|
||||
|
||||
void testPrimitiveArrayCollapsed() {
|
||||
// Collapsed primitive array should show NO element lines
|
||||
NodeTree tree;
|
||||
tree.baseAddress = 0;
|
||||
|
||||
Node root;
|
||||
root.kind = NodeKind::Struct;
|
||||
root.name = "Root";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
Node arr;
|
||||
arr.kind = NodeKind::Array;
|
||||
arr.name = "data";
|
||||
arr.parentId = rootId;
|
||||
arr.offset = 0;
|
||||
arr.elementKind = NodeKind::UInt16;
|
||||
arr.arrayLen = 8;
|
||||
arr.collapsed = true;
|
||||
tree.addNode(arr);
|
||||
|
||||
NullProvider prov;
|
||||
ComposeResult result = compose(tree, prov);
|
||||
|
||||
// No field lines at depth >= 2 (no synthesized elements)
|
||||
int elemFields = 0;
|
||||
for (int i = 0; i < result.meta.size(); i++) {
|
||||
if (result.meta[i].lineKind == LineKind::Field && result.meta[i].depth >= 2)
|
||||
elemFields++;
|
||||
}
|
||||
QCOMPARE(elemFields, 0);
|
||||
}
|
||||
|
||||
void testStructArrayStillUsesChildren() {
|
||||
// Struct array with manual children should still render child nodes, not synthesize
|
||||
NodeTree tree;
|
||||
tree.baseAddress = 0;
|
||||
|
||||
Node root;
|
||||
root.kind = NodeKind::Struct;
|
||||
root.name = "Root";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
Node arr;
|
||||
arr.kind = NodeKind::Array;
|
||||
arr.name = "items";
|
||||
arr.parentId = rootId;
|
||||
arr.offset = 0;
|
||||
arr.elementKind = NodeKind::Struct;
|
||||
arr.arrayLen = 1;
|
||||
int ai = tree.addNode(arr);
|
||||
uint64_t arrId = tree.nodes[ai].id;
|
||||
|
||||
// One struct child
|
||||
Node elem;
|
||||
elem.kind = NodeKind::Struct;
|
||||
elem.name = "Item";
|
||||
elem.parentId = arrId;
|
||||
elem.offset = 0;
|
||||
int ei = tree.addNode(elem);
|
||||
uint64_t elemId = tree.nodes[ei].id;
|
||||
|
||||
Node field;
|
||||
field.kind = NodeKind::UInt32;
|
||||
field.name = "val";
|
||||
field.parentId = elemId;
|
||||
field.offset = 0;
|
||||
tree.addNode(field);
|
||||
|
||||
NullProvider prov;
|
||||
ComposeResult result = compose(tree, prov);
|
||||
|
||||
// Should have the child struct's field rendered
|
||||
bool hasVal = false;
|
||||
QStringList lines = result.text.split('\n');
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (lines[i].contains("val")) { hasVal = true; break; }
|
||||
}
|
||||
QVERIFY2(hasVal, "Struct array child field 'val' should be rendered");
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// Pointer tests
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -336,7 +336,7 @@ private slots:
|
||||
auto vs = rcx::valueSpanFor(lm, 100);
|
||||
QVERIFY(vs.valid);
|
||||
QCOMPARE(vs.start, 44); // 21 + 22 + 1 (kSepWidth)
|
||||
QCOMPARE(vs.end, 76); // 44 + 32 (kColValue)
|
||||
QCOMPARE(vs.end, 44 + rcx::kColValue);
|
||||
}
|
||||
|
||||
void testColumnSpan_continuation() {
|
||||
@@ -352,7 +352,7 @@ private slots:
|
||||
auto vs = rcx::valueSpanFor(lm, 100);
|
||||
QVERIFY(vs.valid);
|
||||
QCOMPARE(vs.start, 6 + 14 + 22 + 2); // kFoldCol+indent + kColType(14) + kColName(22) + 2*kSepWidth
|
||||
QCOMPARE(vs.end, 44 + 32); // start + kColValue
|
||||
QCOMPARE(vs.end, 44 + rcx::kColValue);
|
||||
}
|
||||
|
||||
void testColumnSpan_headerFooter() {
|
||||
@@ -392,7 +392,7 @@ private slots:
|
||||
auto vs = rcx::valueSpanFor(lm, 100);
|
||||
QVERIFY(vs.valid);
|
||||
QCOMPARE(vs.start, 41); // 18 + 22 + 1 (kSepWidth)
|
||||
QCOMPARE(vs.end, 73); // 41 + 32 (kColValue)
|
||||
QCOMPARE(vs.end, 41 + rcx::kColValue); // start + kColValue
|
||||
}
|
||||
|
||||
void testNodeIdJsonRoundTrip() {
|
||||
|
||||
65
tests/test_dbgconnect.cpp
Normal file
65
tests/test_dbgconnect.cpp
Normal 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;
|
||||
}
|
||||
@@ -5,6 +5,13 @@
|
||||
#include <QFocusEvent>
|
||||
#include <QMouseEvent>
|
||||
#include <QFile>
|
||||
#include <QMenu>
|
||||
#include <QProxyStyle>
|
||||
#include <QStyleOption>
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QCursor>
|
||||
#include <QScreen>
|
||||
#include <Qsci/qsciscintilla.h>
|
||||
#include <Qsci/qsciscintillabase.h>
|
||||
#include "editor.h"
|
||||
@@ -163,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) {
|
||||
@@ -271,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);
|
||||
}
|
||||
@@ -473,27 +481,17 @@ private slots:
|
||||
QCOMPARE(cancelSpy.count(), 0);
|
||||
}
|
||||
|
||||
// ── Test: type edit begins and can be cancelled ──
|
||||
// ── Test: type edit emits typePickerRequested (popup-based, not inline edit) ──
|
||||
void testTypeEditCancel() {
|
||||
m_editor->applyDocument(m_result);
|
||||
|
||||
// Begin type edit on a field line
|
||||
QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested);
|
||||
|
||||
// Begin type edit on a field line — now handled by TypeSelectorPopup
|
||||
bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine);
|
||||
QVERIFY(ok);
|
||||
QVERIFY(m_editor->isEditing());
|
||||
|
||||
// Process deferred events (showTypeAutocomplete is deferred via QTimer)
|
||||
QApplication::processEvents();
|
||||
|
||||
// First Escape closes autocomplete popup (if active) or cancels edit
|
||||
QKeyEvent esc1(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier);
|
||||
QApplication::sendEvent(m_editor->scintilla(), &esc1);
|
||||
|
||||
// If autocomplete was open, first Esc only closed popup; need second Esc
|
||||
if (m_editor->isEditing()) {
|
||||
QKeyEvent esc2(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier);
|
||||
QApplication::sendEvent(m_editor->scintilla(), &esc2);
|
||||
}
|
||||
QCOMPARE(spy.count(), 1);
|
||||
// Type editing uses popup, not inline edit state
|
||||
QVERIFY(!m_editor->isEditing());
|
||||
}
|
||||
|
||||
@@ -523,11 +521,11 @@ private slots:
|
||||
QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Type edit on header should succeed
|
||||
// Type edit on header should succeed (emits popup signal, not inline edit)
|
||||
QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested);
|
||||
bool ok = m_editor->beginInlineEdit(EditTarget::Type, headerLine);
|
||||
QVERIFY(ok);
|
||||
QVERIFY(m_editor->isEditing());
|
||||
m_editor->cancelInlineEdit();
|
||||
QCOMPARE(typeSpy.count(), 1);
|
||||
|
||||
// Name edit on header should succeed
|
||||
ok = m_editor->beginInlineEdit(EditTarget::Name, headerLine);
|
||||
@@ -598,35 +596,19 @@ private slots:
|
||||
void testTypeAutocompleteTypingAndCommit() {
|
||||
m_editor->applyDocument(m_result);
|
||||
|
||||
QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested);
|
||||
|
||||
// Type edit now emits typePickerRequested for TypeSelectorPopup
|
||||
bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine);
|
||||
QVERIFY(ok);
|
||||
|
||||
// Autocomplete is deferred via QTimer::singleShot(0) — poll until active
|
||||
QTRY_VERIFY2(m_editor->scintilla()->SendScintilla(
|
||||
QsciScintillaBase::SCI_AUTOCACTIVE) != 0,
|
||||
"Autocomplete should be active");
|
||||
|
||||
// Simulate typing 'i' — filters to typeName entries starting with 'i'
|
||||
QKeyEvent keyI(QEvent::KeyPress, Qt::Key_I, Qt::NoModifier, "i");
|
||||
QApplication::sendEvent(m_editor->scintilla(), &keyI);
|
||||
|
||||
// Still editing
|
||||
QVERIFY(m_editor->isEditing());
|
||||
|
||||
// Simulate Enter to select from autocomplete (handled synchronously)
|
||||
QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted);
|
||||
QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier);
|
||||
QApplication::sendEvent(m_editor->scintilla(), &enter);
|
||||
|
||||
// Should have committed immediately (no deferred timer for type edits)
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QVERIFY(!m_editor->isEditing());
|
||||
|
||||
// The committed text should be a valid typeName starting with 'i'
|
||||
// Verify signal carries valid nodeIdx (second arg)
|
||||
QList<QVariant> args = spy.first();
|
||||
QString committedText = args.at(3).toString();
|
||||
QVERIFY2(committedText.startsWith('i'),
|
||||
qPrintable("Expected typeName starting with 'i', got: " + committedText));
|
||||
QVERIFY(args.at(1).toInt() >= 0);
|
||||
|
||||
// No inline edit state — popup handles everything
|
||||
QVERIFY(!m_editor->isEditing());
|
||||
|
||||
m_editor->applyDocument(m_result);
|
||||
}
|
||||
@@ -635,28 +617,15 @@ private slots:
|
||||
void testTypeEditClickAwayNoChange() {
|
||||
m_editor->applyDocument(m_result);
|
||||
|
||||
QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested);
|
||||
|
||||
// Type edit emits typePickerRequested (popup handles click-away)
|
||||
bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine);
|
||||
QVERIFY(ok);
|
||||
QCOMPARE(spy.count(), 1);
|
||||
|
||||
// Process deferred autocomplete
|
||||
QApplication::processEvents();
|
||||
|
||||
// Click away on viewport — should commit (not cancel)
|
||||
QSignalSpy commitSpy(m_editor, &RcxEditor::inlineEditCommitted);
|
||||
QMouseEvent click(QEvent::MouseButtonPress, QPointF(10, 10),
|
||||
QPointF(10, 10), Qt::LeftButton,
|
||||
Qt::LeftButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(m_editor->scintilla()->viewport(), &click);
|
||||
|
||||
// No inline edit state — popup handles click-away behavior
|
||||
QVERIFY(!m_editor->isEditing());
|
||||
QCOMPARE(commitSpy.count(), 1);
|
||||
|
||||
// The committed text should be the original typeName (no change)
|
||||
// First field at kFirstDataLine is InheritedAddressSpace (UInt8 → "uint8_t")
|
||||
QList<QVariant> args = commitSpy.first();
|
||||
QString committedText = args.at(3).toString();
|
||||
QVERIFY2(committedText == "uint8_t",
|
||||
qPrintable("Expected 'uint8_t', got: " + committedText));
|
||||
|
||||
m_editor->applyDocument(m_result);
|
||||
}
|
||||
@@ -783,71 +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
|
||||
ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine);
|
||||
QVERIFY2(ok, "Type edit should be allowed on Padding lines");
|
||||
QVERIFY(m_editor->isEditing());
|
||||
m_editor->cancelInlineEdit();
|
||||
QApplication::processEvents(); // flush deferred autocomplete timer
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
@@ -856,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);
|
||||
@@ -1096,6 +998,247 @@ private slots:
|
||||
QVERIFY2(!foundRootHeader,
|
||||
"Root header should be suppressed from compose output");
|
||||
}
|
||||
|
||||
// ── Test: MenuBarStyle gives QMenu items generous click targets ──
|
||||
// ── Test: M_ACCENT marker appears on selected rows ──
|
||||
void testAccentMarkerOnSelectedRows() {
|
||||
m_editor->applyDocument(m_result);
|
||||
|
||||
// Find a data line with a valid nodeId
|
||||
uint64_t targetId = 0;
|
||||
int targetLine = -1;
|
||||
for (int i = kFirstDataLine; i < m_result.meta.size(); i++) {
|
||||
const auto& lm = m_result.meta[i];
|
||||
if (lm.nodeId != 0 && lm.nodeId != kCommandRowId
|
||||
&& lm.lineKind == LineKind::Field) {
|
||||
targetId = lm.nodeId;
|
||||
targetLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
QVERIFY2(targetLine >= 0, "No data line found for accent test");
|
||||
|
||||
// Apply selection overlay with that node
|
||||
QSet<uint64_t> selIds;
|
||||
selIds.insert(targetId);
|
||||
m_editor->applySelectionOverlay(selIds);
|
||||
|
||||
auto* sci = m_editor->scintilla();
|
||||
|
||||
// Direct test: add M_ACCENT manually and read it back
|
||||
int directHandle = sci->markerAdd(targetLine, M_ACCENT);
|
||||
int directMarkers = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine);
|
||||
QVERIFY2(directMarkers & (1 << M_ACCENT),
|
||||
qPrintable(QString("Direct markerAdd(M_ACCENT=%1) failed on line %2 (handle=%3, mask=0x%4)")
|
||||
.arg(M_ACCENT).arg(targetLine).arg(directHandle).arg(directMarkers, 0, 16)));
|
||||
sci->markerDelete(targetLine, M_ACCENT);
|
||||
|
||||
// Now test via applySelectionOverlay
|
||||
m_editor->applySelectionOverlay(selIds);
|
||||
|
||||
// Verify M_SELECTED is set on the target line
|
||||
int markers = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine);
|
||||
QVERIFY2(markers & (1 << M_SELECTED),
|
||||
qPrintable(QString("M_SELECTED not set on line %1 (mask=0x%2)")
|
||||
.arg(targetLine).arg(markers, 0, 16)));
|
||||
|
||||
// Verify M_ACCENT is set on the target line
|
||||
QVERIFY2(markers & (1 << M_ACCENT),
|
||||
qPrintable(QString("M_ACCENT not set on line %1 (mask=0x%2)")
|
||||
.arg(targetLine).arg(markers, 0, 16)));
|
||||
|
||||
// Verify a non-selected line does NOT have M_ACCENT
|
||||
int otherLine = -1;
|
||||
for (int i = kFirstDataLine; i < m_result.meta.size(); i++) {
|
||||
const auto& lm = m_result.meta[i];
|
||||
if (lm.nodeId != targetId && lm.nodeId != 0
|
||||
&& lm.nodeId != kCommandRowId && lm.lineKind == LineKind::Field) {
|
||||
otherLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (otherLine >= 0) {
|
||||
int otherMarkers = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_MARKERGET, (unsigned long)otherLine);
|
||||
QVERIFY2(!(otherMarkers & (1 << M_ACCENT)),
|
||||
qPrintable(QString("M_ACCENT should NOT be set on non-selected line %1 (mask=0x%2)")
|
||||
.arg(otherLine).arg(otherMarkers, 0, 16)));
|
||||
}
|
||||
|
||||
// Clear selection and verify accent is removed
|
||||
m_editor->applySelectionOverlay(QSet<uint64_t>());
|
||||
markers = (int)sci->SendScintilla(
|
||||
QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine);
|
||||
QVERIFY2(!(markers & (1 << M_ACCENT)),
|
||||
qPrintable(QString("M_ACCENT should be cleared after deselection on line %1 (mask=0x%2)")
|
||||
.arg(targetLine).arg(markers, 0, 16)));
|
||||
}
|
||||
|
||||
void testMenuItemSizeIsAccessible() {
|
||||
// Instantiate the same QProxyStyle used by the app (MenuBarStyle is
|
||||
// defined in main.cpp — we replicate the logic here to test it)
|
||||
class TestMenuStyle : public QProxyStyle {
|
||||
public:
|
||||
using QProxyStyle::QProxyStyle;
|
||||
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
|
||||
const QSize& sz, const QWidget* w) const override {
|
||||
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
||||
if (type == CT_MenuBarItem)
|
||||
s.setHeight(s.height() + qRound(s.height() * 0.5));
|
||||
if (type == CT_MenuItem)
|
||||
s = QSize(s.width() + 24, s.height() + 4);
|
||||
return s;
|
||||
}
|
||||
};
|
||||
|
||||
TestMenuStyle style;
|
||||
QMenu menu;
|
||||
auto* action = menu.addAction("Delete Node");
|
||||
|
||||
QStyleOptionMenuItem opt;
|
||||
opt.initFrom(&menu);
|
||||
opt.text = action->text();
|
||||
|
||||
QSize base = style.QProxyStyle::sizeFromContents(
|
||||
QStyle::CT_MenuItem, &opt, QSize(80, 20), &menu);
|
||||
QSize styled = style.sizeFromContents(
|
||||
QStyle::CT_MenuItem, &opt, QSize(80, 20), &menu);
|
||||
|
||||
// Width must grow by at least 24px
|
||||
QVERIFY2(styled.width() >= base.width() + 24,
|
||||
qPrintable(QString("Menu item width %1 too narrow (base %2, need +24)")
|
||||
.arg(styled.width()).arg(base.width())));
|
||||
|
||||
// Height must grow by at least 4px
|
||||
QVERIFY2(styled.height() >= base.height() + 4,
|
||||
qPrintable(QString("Menu item height %1 too short (base %2, need +4)")
|
||||
.arg(styled.height()).arg(base.height())));
|
||||
}
|
||||
|
||||
void testMenuHoverRendersAmberText() {
|
||||
// Replicate MenuBarStyle with drawControl hover override
|
||||
class TestMenuStyle : public QProxyStyle {
|
||||
public:
|
||||
using QProxyStyle::QProxyStyle;
|
||||
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
|
||||
const QSize& sz, const QWidget* w) const override {
|
||||
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
||||
if (type == CT_MenuBarItem)
|
||||
s.setHeight(s.height() + qRound(s.height() * 0.5));
|
||||
if (type == CT_MenuItem)
|
||||
s = QSize(s.width() + 24, s.height() + 4);
|
||||
return s;
|
||||
}
|
||||
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
|
||||
QPainter* p, const QWidget* w) const override {
|
||||
if (elem == PE_FrameMenu) return;
|
||||
QProxyStyle::drawPrimitive(elem, opt, p, w);
|
||||
}
|
||||
void drawControl(ControlElement element, const QStyleOption* opt,
|
||||
QPainter* p, const QWidget* w) const override {
|
||||
if (element == CE_MenuItem || element == CE_MenuBarItem) {
|
||||
if (auto* mi = qstyleoption_cast<const QStyleOptionMenuItem*>(opt)) {
|
||||
if ((mi->state & State_Selected)
|
||||
&& mi->menuItemType != QStyleOptionMenuItem::Separator) {
|
||||
QStyleOptionMenuItem patched = *mi;
|
||||
patched.palette.setColor(QPalette::Highlight,
|
||||
mi->palette.color(QPalette::Mid));
|
||||
patched.palette.setColor(QPalette::HighlightedText,
|
||||
mi->palette.color(QPalette::Link));
|
||||
QProxyStyle::drawControl(element, &patched, p, w);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
QProxyStyle::drawControl(element, opt, p, w);
|
||||
}
|
||||
};
|
||||
|
||||
// Install our style as the app style (same as main.cpp does)
|
||||
qApp->setStyle(new TestMenuStyle("Fusion"));
|
||||
|
||||
// Set app palette matching applyGlobalTheme for Reclass Dark
|
||||
QPalette pal;
|
||||
pal.setColor(QPalette::Window, QColor("#1e1e1e"));
|
||||
pal.setColor(QPalette::WindowText, QColor("#d4d4d4"));
|
||||
pal.setColor(QPalette::Base, QColor("#252526"));
|
||||
pal.setColor(QPalette::AlternateBase, QColor("#2a2d2e"));
|
||||
pal.setColor(QPalette::Text, QColor("#d4d4d4"));
|
||||
pal.setColor(QPalette::Button, QColor("#333333"));
|
||||
pal.setColor(QPalette::ButtonText, QColor("#d4d4d4"));
|
||||
pal.setColor(QPalette::Highlight, QColor("#2b2b2b"));
|
||||
pal.setColor(QPalette::HighlightedText, QColor("#E6B450"));
|
||||
pal.setColor(QPalette::Mid, QColor("#3c3c3c"));
|
||||
pal.setColor(QPalette::Dark, QColor("#1e1e1e"));
|
||||
pal.setColor(QPalette::Light, QColor("#505050"));
|
||||
pal.setColor(QPalette::Link, QColor("#E6B450"));
|
||||
qApp->setPalette(pal);
|
||||
|
||||
// Build and show a real QMenu
|
||||
QMenu menu;
|
||||
menu.addAction("First Item");
|
||||
menu.addAction("Second Item");
|
||||
menu.addAction("Third Item");
|
||||
menu.popup(QPoint(100, 100));
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&menu));
|
||||
QApplication::processEvents();
|
||||
|
||||
// ── Deliver real mouse events to trigger hover on second item ──
|
||||
QList<QAction*> actions = menu.actions();
|
||||
QRect itemRect = menu.actionGeometry(actions[1]);
|
||||
QPoint localCenter = itemRect.center();
|
||||
|
||||
// Enter event — tells QMenu the mouse is inside
|
||||
QEvent enter(QEvent::Enter);
|
||||
QApplication::sendEvent(&menu, &enter);
|
||||
QApplication::processEvents();
|
||||
|
||||
// MouseMove to the second item — triggers hover/select
|
||||
QMouseEvent move(QEvent::MouseMove, QPointF(localCenter),
|
||||
menu.mapToGlobal(localCenter),
|
||||
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
|
||||
QApplication::sendEvent(&menu, &move);
|
||||
QApplication::processEvents();
|
||||
QTest::qWait(50); // let repaint settle
|
||||
|
||||
// Verify QMenu internally considers the action hovered
|
||||
QVERIFY2(menu.activeAction() == actions[1],
|
||||
"QMenu did not set activeAction after mouse move — "
|
||||
"hover event delivery failed");
|
||||
|
||||
// ── Capture what's actually on screen ──
|
||||
QScreen* screen = QGuiApplication::primaryScreen();
|
||||
QVERIFY(screen);
|
||||
QPixmap grab = screen->grabWindow(menu.winId());
|
||||
QImage img = grab.toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
|
||||
// Crop to just the hovered item rect
|
||||
QImage itemImg = img.copy(itemRect);
|
||||
|
||||
// Scan hovered item for amber pixels (E6B450 = R:230 G:180 B:80)
|
||||
int amberPixels = 0;
|
||||
int totalPixels = itemImg.width() * itemImg.height();
|
||||
for (int y = 0; y < itemImg.height(); ++y) {
|
||||
for (int x = 0; x < itemImg.width(); ++x) {
|
||||
QColor c = itemImg.pixelColor(x, y);
|
||||
if (c.red() > 180 && c.green() > 140 && c.blue() < 100)
|
||||
++amberPixels;
|
||||
}
|
||||
}
|
||||
|
||||
// Always save screenshots so we can visually inspect
|
||||
img.save("menu_hover_full.png");
|
||||
itemImg.save("menu_hover_item.png");
|
||||
|
||||
menu.close();
|
||||
|
||||
QVERIFY2(amberPixels > 10,
|
||||
qPrintable(QString("Expected amber text pixels in hovered item, "
|
||||
"found %1 / %2 total (see menu_hover_full.png, menu_hover_item.png)")
|
||||
.arg(amberPixels).arg(totalPixels)));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestEditor)
|
||||
|
||||
@@ -39,12 +39,21 @@ private slots:
|
||||
}
|
||||
|
||||
void testFmtOffsetMargin_primary() {
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0x10, false), QString("10"));
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0, false), QString("0"));
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0x10, false), QString("00000010 "));
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0, false), QString("00000000 "));
|
||||
}
|
||||
|
||||
void testFmtOffsetMargin_continuation() {
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0x10, true), QString(" \u00B7"));
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0x10, true), QString(" \u00B7 "));
|
||||
}
|
||||
|
||||
void testFmtOffsetMargin_kernelAddr() {
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0xFFFFF80012345678ULL, false, 16),
|
||||
QString("FFFFF80012345678 "));
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0x10, false, 16),
|
||||
QString("0000000000000010 "));
|
||||
QCOMPARE(fmt::fmtOffsetMargin(0x10, false, 4),
|
||||
QString("0010 "));
|
||||
}
|
||||
|
||||
void testFmtStructHeader() {
|
||||
|
||||
@@ -54,18 +54,16 @@ private slots:
|
||||
QString result = rcx::renderCpp(tree, rootId);
|
||||
|
||||
// Header
|
||||
QVERIFY(result.contains("Generated by ReclassX"));
|
||||
QVERIFY(result.contains("#pragma once"));
|
||||
QVERIFY(result.contains("#include <cstdint>"));
|
||||
QVERIFY(!result.contains("#include <cstdint>"));
|
||||
QVERIFY(!result.contains("#pragma pack"));
|
||||
|
||||
// Struct definition
|
||||
QVERIFY(result.contains("#pragma pack(push, 1)"));
|
||||
QVERIFY(result.contains("struct Player {"));
|
||||
QVERIFY(result.contains("int32_t health;"));
|
||||
QVERIFY(result.contains("float speed;"));
|
||||
QVERIFY(result.contains("uint64_t id;"));
|
||||
QVERIFY(result.contains("};"));
|
||||
QVERIFY(result.contains("#pragma pack(pop)"));
|
||||
|
||||
// static_assert - struct is 16 bytes (0+4 + 4+4 + 8+8 = 16)
|
||||
QVERIFY(result.contains("static_assert(sizeof(Player) == 0x10"));
|
||||
@@ -420,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() {
|
||||
@@ -485,7 +459,6 @@ private slots:
|
||||
|
||||
QString result = rcx::renderCppAll(tree);
|
||||
|
||||
QVERIFY(result.contains("Full SDK export"));
|
||||
QVERIFY(result.contains("struct StructA {"));
|
||||
QVERIFY(result.contains("struct StructB {"));
|
||||
QVERIFY(result.contains("uint32_t valueA;"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
#include <QTest>
|
||||
#ifdef _WIN32
|
||||
#include "providers/process_provider.h"
|
||||
|
||||
using namespace rcx;
|
||||
#endif
|
||||
|
||||
class TestProcessProviderSymbol : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
|
||||
#ifdef _WIN32
|
||||
void getSymbol_selfProcess() {
|
||||
// Attach to our own process for testing
|
||||
HANDLE self = GetCurrentProcess();
|
||||
@@ -87,19 +88,10 @@ private slots:
|
||||
QString sym = prov.getSymbol(ntdllBase);
|
||||
QVERIFY(sym.toLower().startsWith("ntdll.dll+0x"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestProcessProviderSymbol)
|
||||
#include "test_provider_getSymbol.moc"
|
||||
|
||||
#else
|
||||
// Non-Windows: empty test that passes
|
||||
#include <QTest>
|
||||
class TestProcessProviderSymbol : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void skip() { QSKIP("ProcessProvider tests are Windows-only"); }
|
||||
#endif
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestProcessProviderSymbol)
|
||||
#include "test_provider_getSymbol.moc"
|
||||
#endif
|
||||
|
||||
361
tests/test_rendered_view.cpp
Normal file
361
tests/test_rendered_view.cpp
Normal file
@@ -0,0 +1,361 @@
|
||||
#include <QtTest/QTest>
|
||||
#include <QApplication>
|
||||
#include <Qsci/qsciscintilla.h>
|
||||
#include <Qsci/qsciscintillabase.h>
|
||||
#include <Qsci/qscilexercpp.h>
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
|
||||
#include "core.h"
|
||||
#include "generator.h"
|
||||
|
||||
// Raw Scintilla message IDs not exposed by QsciScintillaBase wrapper
|
||||
static constexpr int SCI_GETSELBACK = 2477;
|
||||
static constexpr int SCI_GETSELFORE = 2476;
|
||||
|
||||
// ── Helper: extract BGR long from QColor (Scintilla stores colors as 0x00BBGGRR) ──
|
||||
|
||||
static long toBGR(const QColor& c) {
|
||||
return (long)c.red() | ((long)c.green() << 8) | ((long)c.blue() << 16);
|
||||
}
|
||||
|
||||
// ── Replicates MainWindow::setupRenderedSci so the test stays in sync ──
|
||||
|
||||
static void setupRenderedSci(QsciScintilla* sci) {
|
||||
QFont f("Consolas", 12);
|
||||
f.setFixedPitch(true);
|
||||
|
||||
sci->setFont(f);
|
||||
sci->setReadOnly(false);
|
||||
sci->setWrapMode(QsciScintilla::WrapNone);
|
||||
sci->setTabWidth(4);
|
||||
sci->setIndentationsUseTabs(false);
|
||||
sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2);
|
||||
sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2);
|
||||
|
||||
// Line number margin
|
||||
sci->setMarginType(0, QsciScintilla::NumberMargin);
|
||||
sci->setMarginWidth(0, "00000");
|
||||
sci->setMarginsBackgroundColor(QColor("#252526"));
|
||||
sci->setMarginsForegroundColor(QColor("#858585"));
|
||||
sci->setMarginsFont(f);
|
||||
|
||||
sci->setMarginWidth(1, 0);
|
||||
sci->setMarginWidth(2, 0);
|
||||
|
||||
// Lexer FIRST — setLexer() resets caret/selection/paper colors
|
||||
auto* lexer = new QsciLexerCPP(sci);
|
||||
lexer->setFont(f);
|
||||
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword);
|
||||
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::KeywordSet2);
|
||||
lexer->setColor(QColor("#b5cea8"), QsciLexerCPP::Number);
|
||||
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::DoubleQuotedString);
|
||||
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::SingleQuotedString);
|
||||
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::Comment);
|
||||
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentLine);
|
||||
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentDoc);
|
||||
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Default);
|
||||
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Identifier);
|
||||
lexer->setColor(QColor("#c586c0"), QsciLexerCPP::PreProcessor);
|
||||
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Operator);
|
||||
for (int i = 0; i <= 127; i++) {
|
||||
lexer->setPaper(QColor("#1e1e1e"), i);
|
||||
lexer->setFont(f, i);
|
||||
}
|
||||
sci->setLexer(lexer);
|
||||
sci->setBraceMatching(QsciScintilla::NoBraceMatch);
|
||||
|
||||
// Colors AFTER setLexer() — the lexer resets these on attach
|
||||
sci->setPaper(QColor("#1e1e1e"));
|
||||
sci->setColor(QColor("#d4d4d4"));
|
||||
sci->setCaretForegroundColor(QColor("#d4d4d4"));
|
||||
sci->setCaretLineVisible(true);
|
||||
sci->setCaretLineBackgroundColor(QColor(43, 43, 43));
|
||||
sci->setSelectionBackgroundColor(QColor("#264f78"));
|
||||
sci->setSelectionForegroundColor(QColor("#d4d4d4"));
|
||||
}
|
||||
|
||||
// ── Test tree helper ──
|
||||
|
||||
static rcx::NodeTree makeTestTree() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "TestStruct";
|
||||
root.structTypeName = "TestStruct";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node f1;
|
||||
f1.kind = rcx::NodeKind::Int32;
|
||||
f1.name = "health";
|
||||
f1.parentId = rootId;
|
||||
f1.offset = 0;
|
||||
tree.addNode(f1);
|
||||
|
||||
rcx::Node f2;
|
||||
f2.kind = rcx::NodeKind::Float;
|
||||
f2.name = "speed";
|
||||
f2.parentId = rootId;
|
||||
f2.offset = 4;
|
||||
tree.addNode(f2);
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
// ── Test class ──
|
||||
|
||||
class TestRenderedView : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
|
||||
// ── Verify caret line background is NOT yellow after setup ──
|
||||
|
||||
void testCaretLineBackgroundNotYellow() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
sci.show();
|
||||
sci.setText("struct Foo {\n int x;\n};\n");
|
||||
QTest::qWait(50);
|
||||
|
||||
long bgr = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK);
|
||||
long expected = toBGR(QColor(43, 43, 43));
|
||||
|
||||
// Yellow would be 0x00FFFF or similar high-value — ours should be dark
|
||||
long yellow = toBGR(QColor(255, 255, 0));
|
||||
QVERIFY2(bgr != yellow,
|
||||
qPrintable(QString("Caret line is yellow (0x%1), expected dark (0x%2)")
|
||||
.arg(bgr, 6, 16, QChar('0'))
|
||||
.arg(expected, 6, 16, QChar('0'))));
|
||||
QCOMPARE(bgr, expected);
|
||||
}
|
||||
|
||||
// ── Verify caret line is enabled ──
|
||||
|
||||
void testCaretLineEnabled() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
long visible = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEVISIBLE);
|
||||
QCOMPARE(visible, (long)1);
|
||||
}
|
||||
|
||||
// ── Verify editor background (paper) is dark ──
|
||||
|
||||
void testPaperColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
// Query default style background via Scintilla
|
||||
long bgr = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK,
|
||||
(unsigned long)0 /*STYLE_DEFAULT*/);
|
||||
long expected = toBGR(QColor("#1e1e1e"));
|
||||
QCOMPARE(bgr, expected);
|
||||
}
|
||||
|
||||
// ── Verify caret (cursor) foreground color ──
|
||||
|
||||
void testCaretForegroundColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
long bgr = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETFORE);
|
||||
long expected = toBGR(QColor("#d4d4d4"));
|
||||
QCOMPARE(bgr, expected);
|
||||
}
|
||||
|
||||
// ── Verify selection colors are set (no direct Scintilla getter, but we can
|
||||
// verify they survive a round-trip through the SCI_SETSEL* messages by
|
||||
// checking the element colour API introduced in Scintilla 5.x) ──
|
||||
|
||||
void testSelectionColorsApplied() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
sci.show();
|
||||
sci.setText("int x = 42;\n");
|
||||
QTest::qWait(50);
|
||||
|
||||
// Select text and verify rendering doesn't crash
|
||||
sci.SendScintilla(QsciScintillaBase::SCI_SETSEL, (unsigned long)0, (long)3);
|
||||
QTest::qWait(50);
|
||||
|
||||
// SCI_GETELEMENTCOLOUR (element 10 = SC_ELEMENT_SELECTION_BACK) returns
|
||||
// the selection back colour on Scintilla >= 5.2. If not available, fall
|
||||
// back to verifying the calls didn't throw and caret line is still correct.
|
||||
constexpr int SCI_GETELEMENTCOLOUR = 2753;
|
||||
constexpr int SC_ELEMENT_SELECTION_BACK = 10;
|
||||
|
||||
long selBack = sci.SendScintilla(SCI_GETELEMENTCOLOUR,
|
||||
(unsigned long)SC_ELEMENT_SELECTION_BACK);
|
||||
if (selBack != 0) {
|
||||
// Scintilla 5.x: colour stored as 0xAABBGGRR (with alpha in high byte)
|
||||
long bgrMask = selBack & 0x00FFFFFF;
|
||||
long expected = toBGR(QColor("#264f78"));
|
||||
QCOMPARE(bgrMask, expected);
|
||||
} else {
|
||||
// Older Scintilla: just verify caret line is still correct as a proxy
|
||||
long caretBg = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK);
|
||||
long expected = toBGR(QColor(43, 43, 43));
|
||||
QCOMPARE(caretBg, expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verify lexer keyword color is VS Code blue, not default ──
|
||||
|
||||
void testKeywordColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QColor kw = lexer->color(QsciLexerCPP::Keyword);
|
||||
QCOMPARE(kw, QColor("#569cd6"));
|
||||
}
|
||||
|
||||
// ── Verify comment color is VS Code green ──
|
||||
|
||||
void testCommentColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::Comment), QColor("#6a9955"));
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::CommentLine), QColor("#6a9955"));
|
||||
}
|
||||
|
||||
// ── Verify number color is VS Code light green ──
|
||||
|
||||
void testNumberColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::Number), QColor("#b5cea8"));
|
||||
}
|
||||
|
||||
// ── Verify string color is VS Code orange ──
|
||||
|
||||
void testStringColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::DoubleQuotedString), QColor("#ce9178"));
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::SingleQuotedString), QColor("#ce9178"));
|
||||
}
|
||||
|
||||
// ── Verify preprocessor color is VS Code purple ──
|
||||
|
||||
void testPreprocessorColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::PreProcessor), QColor("#c586c0"));
|
||||
}
|
||||
|
||||
// ── Verify default/identifier text color ──
|
||||
|
||||
void testDefaultTextColor() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::Default), QColor("#d4d4d4"));
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::Identifier), QColor("#d4d4d4"));
|
||||
QCOMPARE(lexer->color(QsciLexerCPP::Operator), QColor("#d4d4d4"));
|
||||
}
|
||||
|
||||
// ── Verify all 128 lexer styles have dark paper ──
|
||||
|
||||
void testAllStylesHaveDarkPaper() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
|
||||
QVERIFY(lexer != nullptr);
|
||||
|
||||
QColor expected("#1e1e1e");
|
||||
for (int i = 0; i <= 127; i++) {
|
||||
QColor paper = lexer->paper(i);
|
||||
QVERIFY2(paper == expected,
|
||||
qPrintable(QString("Style %1 paper is %2, expected %3")
|
||||
.arg(i).arg(paper.name()).arg(expected.name())));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verify margin colors match dark theme ──
|
||||
|
||||
void testMarginColors() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
// Query margin background via Scintilla (style 33 = STYLE_LINENUMBER)
|
||||
long marginBg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK,
|
||||
(unsigned long)33);
|
||||
long expectedBg = toBGR(QColor("#252526"));
|
||||
QCOMPARE(marginBg, expectedBg);
|
||||
|
||||
long marginFg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETFORE,
|
||||
(unsigned long)33);
|
||||
long expectedFg = toBGR(QColor("#858585"));
|
||||
QCOMPARE(marginFg, expectedFg);
|
||||
}
|
||||
|
||||
// ── End-to-end: generate C++ and load into rendered view ──
|
||||
|
||||
void testGeneratedCodeInRenderedView() {
|
||||
auto tree = makeTestTree();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString code = rcx::renderCpp(tree, rootId);
|
||||
|
||||
// Verify generated code has no pragma pack / cstdint
|
||||
QVERIFY(!code.contains("#pragma pack"));
|
||||
QVERIFY(!code.contains("#include <cstdint>"));
|
||||
QVERIFY(code.contains("#pragma once"));
|
||||
QVERIFY(code.contains("struct TestStruct {"));
|
||||
|
||||
// Load into rendered sci and verify colors survive
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
sci.show();
|
||||
sci.setText(code);
|
||||
QTest::qWait(100);
|
||||
|
||||
// Caret line must still be dark after text load
|
||||
long caretBg = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK);
|
||||
long expected = toBGR(QColor(43, 43, 43));
|
||||
QCOMPARE(caretBg, expected);
|
||||
|
||||
// Paper must still be dark
|
||||
long paperBg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK,
|
||||
(unsigned long)0);
|
||||
QCOMPARE(paperBg, toBGR(QColor("#1e1e1e")));
|
||||
}
|
||||
|
||||
// ── Verify brace matching is disabled ──
|
||||
|
||||
void testBraceMatchDisabled() {
|
||||
QsciScintilla sci;
|
||||
setupRenderedSci(&sci);
|
||||
|
||||
QCOMPARE(sci.braceMatching(), QsciScintilla::NoBraceMatch);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestRenderedView)
|
||||
#include "test_rendered_view.moc"
|
||||
149
tests/test_theme.cpp
Normal file
149
tests/test_theme.cpp
Normal file
@@ -0,0 +1,149 @@
|
||||
#include <QtTest/QTest>
|
||||
#include <QtTest/QSignalSpy>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include "themes/theme.h"
|
||||
#include "themes/thememanager.h"
|
||||
|
||||
using namespace rcx;
|
||||
|
||||
class TestTheme : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void builtInThemes() {
|
||||
auto& tm = ThemeManager::instance();
|
||||
auto all = tm.themes();
|
||||
QVERIFY(all.size() >= 2);
|
||||
|
||||
// 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());
|
||||
|
||||
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() {
|
||||
auto& tm = ThemeManager::instance();
|
||||
Theme orig = tm.themes()[0];
|
||||
QJsonObject json = orig.toJson();
|
||||
Theme loaded = Theme::fromJson(json);
|
||||
|
||||
QCOMPARE(loaded.name, orig.name);
|
||||
QCOMPARE(loaded.background, orig.background);
|
||||
QCOMPARE(loaded.text, orig.text);
|
||||
QCOMPARE(loaded.selection, orig.selection);
|
||||
QCOMPARE(loaded.syntaxKeyword, orig.syntaxKeyword);
|
||||
QCOMPARE(loaded.syntaxNumber, orig.syntaxNumber);
|
||||
QCOMPARE(loaded.syntaxString, orig.syntaxString);
|
||||
QCOMPARE(loaded.syntaxComment, orig.syntaxComment);
|
||||
QCOMPARE(loaded.syntaxType, orig.syntaxType);
|
||||
QCOMPARE(loaded.markerPtr, orig.markerPtr);
|
||||
QCOMPARE(loaded.markerError, orig.markerError);
|
||||
QCOMPARE(loaded.indHoverSpan, orig.indHoverSpan);
|
||||
}
|
||||
|
||||
void jsonRoundTripWarm() {
|
||||
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);
|
||||
|
||||
QCOMPARE(loaded.name, orig.name);
|
||||
QCOMPARE(loaded.background, orig.background);
|
||||
QCOMPARE(loaded.selection, orig.selection);
|
||||
QCOMPARE(loaded.syntaxKeyword, orig.syntaxKeyword);
|
||||
}
|
||||
|
||||
void fromJsonMissingFields() {
|
||||
QJsonObject sparse;
|
||||
sparse["name"] = "Sparse";
|
||||
sparse["background"] = "#ff0000";
|
||||
Theme t = Theme::fromJson(sparse);
|
||||
|
||||
QCOMPARE(t.name, QString("Sparse"));
|
||||
QCOMPARE(t.background, QColor("#ff0000"));
|
||||
// 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() >= 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() {
|
||||
auto& tm = ThemeManager::instance();
|
||||
QSignalSpy spy(&tm, &ThemeManager::themeChanged);
|
||||
|
||||
int startIdx = tm.currentIndex();
|
||||
int target = (startIdx == 0) ? 1 : 0;
|
||||
tm.setCurrent(target);
|
||||
|
||||
QCOMPARE(spy.count(), 1);
|
||||
QCOMPARE(tm.currentIndex(), target);
|
||||
QCOMPARE(tm.current().name, tm.themes()[target].name);
|
||||
|
||||
// Restore
|
||||
tm.setCurrent(startIdx);
|
||||
}
|
||||
|
||||
void themeManagerCRUD() {
|
||||
auto& tm = ThemeManager::instance();
|
||||
int initialCount = tm.themes().size();
|
||||
|
||||
// Add
|
||||
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, QString("Test Custom"));
|
||||
|
||||
// Update
|
||||
int idx = tm.themes().size() - 1;
|
||||
Theme updated = custom;
|
||||
updated.background = QColor("#00ff00");
|
||||
tm.updateTheme(idx, updated);
|
||||
QCOMPARE(tm.themes()[idx].background, QColor("#00ff00"));
|
||||
|
||||
// Remove
|
||||
tm.removeTheme(idx);
|
||||
QCOMPARE(tm.themes().size(), initialCount);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestTheme)
|
||||
#include "test_theme.moc"
|
||||
742
tests/test_type_selector.cpp
Normal file
742
tests/test_type_selector.cpp
Normal file
@@ -0,0 +1,742 @@
|
||||
#include <QtTest/QTest>
|
||||
#include <QtTest/QSignalSpy>
|
||||
#include <QApplication>
|
||||
#include <QSplitter>
|
||||
#include <QElapsedTimer>
|
||||
#include <QVBoxLayout>
|
||||
#include <QToolButton>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QStringListModel>
|
||||
#include <QLabel>
|
||||
#include <QFrame>
|
||||
#include <Qsci/qsciscintilla.h>
|
||||
#include "controller.h"
|
||||
#include "typeselectorpopup.h"
|
||||
#include "themes/thememanager.h"
|
||||
#include "core.h"
|
||||
|
||||
Q_DECLARE_METATYPE(rcx::TypeEntry)
|
||||
|
||||
using namespace rcx;
|
||||
|
||||
static void buildTwoRootTree(NodeTree& tree) {
|
||||
tree.baseAddress = 0x1000;
|
||||
|
||||
Node a;
|
||||
a.kind = NodeKind::Struct;
|
||||
a.name = "Alpha";
|
||||
a.structTypeName = "Alpha";
|
||||
a.parentId = 0;
|
||||
a.offset = 0;
|
||||
int ai = tree.addNode(a);
|
||||
uint64_t aId = tree.nodes[ai].id;
|
||||
|
||||
{ Node n; n.kind = NodeKind::Int32; n.name = "x"; n.parentId = aId; n.offset = 0; tree.addNode(n); }
|
||||
{ Node n; n.kind = NodeKind::Int32; n.name = "y"; n.parentId = aId; n.offset = 4; tree.addNode(n); }
|
||||
|
||||
Node b;
|
||||
b.kind = NodeKind::Struct;
|
||||
b.name = "Bravo";
|
||||
b.structTypeName = "Bravo";
|
||||
b.parentId = 0;
|
||||
b.offset = 0x100;
|
||||
int bi = tree.addNode(b);
|
||||
uint64_t bId = tree.nodes[bi].id;
|
||||
|
||||
{ Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = bId; n.offset = 0; tree.addNode(n); }
|
||||
}
|
||||
|
||||
static QByteArray makeBuffer() {
|
||||
return QByteArray(0x200, '\0');
|
||||
}
|
||||
|
||||
class TestTypeSelector : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void initTestCase() {
|
||||
qRegisterMetaType<TypeEntry>("TypeEntry");
|
||||
}
|
||||
|
||||
// ── Chevron span detection ──
|
||||
|
||||
void testChevronSpanDetected() {
|
||||
QString text = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x1000 \u00B7 struct\u25BE Alpha {");
|
||||
ColumnSpan span = commandRowChevronSpan(text);
|
||||
QVERIFY(span.valid);
|
||||
QCOMPARE(span.start, 0);
|
||||
QCOMPARE(span.end, 4); // includes trailing space for easier clicking
|
||||
}
|
||||
|
||||
void testChevronSpanRejects() {
|
||||
QVERIFY(!commandRowChevronSpan(QStringLiteral("Hi")).valid);
|
||||
QVERIFY(!commandRowChevronSpan(QStringLiteral("\u25B8 source")).valid);
|
||||
// Old down-triangle glyph must not match
|
||||
QVERIFY(!commandRowChevronSpan(QStringLiteral("[\u25BE] source")).valid);
|
||||
}
|
||||
|
||||
// ── Existing spans unbroken by chevron prefix ──
|
||||
|
||||
void testSpansWithPrefix() {
|
||||
QString text = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x1000 \u00B7 struct\u25BE Alpha {");
|
||||
|
||||
ColumnSpan src = commandRowSrcSpan(text);
|
||||
QVERIFY(src.valid);
|
||||
QVERIFY(text.mid(src.start, src.end - src.start).contains("source"));
|
||||
|
||||
ColumnSpan addr = commandRowAddrSpan(text);
|
||||
QVERIFY(addr.valid);
|
||||
QVERIFY(text.mid(addr.start, addr.end - addr.start).contains("0x1000"));
|
||||
|
||||
ColumnSpan rootName = commandRowRootNameSpan(text);
|
||||
QVERIFY(rootName.valid);
|
||||
QCOMPARE(text.mid(rootName.start, rootName.end - rootName.start).trimmed(), QString("Alpha"));
|
||||
}
|
||||
|
||||
// ── Benchmark: warmUp() + cached reuse vs cold new/delete ──
|
||||
|
||||
void benchmarkPopupOpen() {
|
||||
auto makeComposite = [](uint64_t id, const QString& name, const QString& kw) {
|
||||
TypeEntry e;
|
||||
e.entryKind = TypeEntry::Composite;
|
||||
e.structId = id;
|
||||
e.displayName = name;
|
||||
e.classKeyword = kw;
|
||||
return e;
|
||||
};
|
||||
QVector<TypeEntry> types;
|
||||
types.append(makeComposite(1, "Alpha", "struct"));
|
||||
types.append(makeComposite(2, "Bravo", "struct"));
|
||||
types.append(makeComposite(3, "Charlie", "struct"));
|
||||
types.append(makeComposite(4, "Delta", "class"));
|
||||
|
||||
TypeEntry cur1 = makeComposite(1, "Alpha", "struct");
|
||||
TypeEntry cur2 = makeComposite(2, "Bravo", "struct");
|
||||
|
||||
QFont font("Consolas", 12);
|
||||
font.setFixedPitch(true);
|
||||
|
||||
auto ms = [](qint64 ns) { return QString::number(ns / 1000000.0, 'f', 2); };
|
||||
|
||||
// --- Measure cold path: new popup, first show ever ---
|
||||
{
|
||||
QElapsedTimer total;
|
||||
total.start();
|
||||
auto* popup = new TypeSelectorPopup();
|
||||
popup->setFont(font);
|
||||
popup->setTypes(types, &cur1);
|
||||
popup->popup(QPoint(100, 100));
|
||||
QApplication::processEvents();
|
||||
qint64 tCold = total.nsecsElapsed();
|
||||
popup->hide();
|
||||
QApplication::processEvents();
|
||||
|
||||
qDebug() << "";
|
||||
qDebug().noquote() << QString("=== COLD (new popup, no warmUp) ===");
|
||||
qDebug().noquote() << QString(" Total: %1 ms").arg(ms(tCold));
|
||||
|
||||
// --- Measure cached reuse of same instance ---
|
||||
{
|
||||
QElapsedTimer t2;
|
||||
t2.start();
|
||||
popup->setTypes(types, &cur2);
|
||||
popup->popup(QPoint(100, 100));
|
||||
QApplication::processEvents();
|
||||
qint64 tReuse = t2.nsecsElapsed();
|
||||
popup->hide();
|
||||
QApplication::processEvents();
|
||||
|
||||
qDebug() << "";
|
||||
qDebug().noquote() << QString("=== WARM (reuse same popup) ===");
|
||||
qDebug().noquote() << QString(" Total: %1 ms").arg(ms(tReuse));
|
||||
}
|
||||
|
||||
delete popup;
|
||||
}
|
||||
|
||||
// --- Measure warmUp() approach ---
|
||||
{
|
||||
QElapsedTimer tWarmup;
|
||||
tWarmup.start();
|
||||
auto* popup2 = new TypeSelectorPopup();
|
||||
popup2->warmUp();
|
||||
qint64 tWarmMs = tWarmup.nsecsElapsed();
|
||||
|
||||
qDebug() << "";
|
||||
qDebug().noquote() << QString("=== warmUp() cost (constructor + hidden show/hide) ===");
|
||||
qDebug().noquote() << QString(" Total: %1 ms").arg(ms(tWarmMs));
|
||||
|
||||
// First user-visible show after warmUp
|
||||
QElapsedTimer t3;
|
||||
t3.start();
|
||||
popup2->setFont(font);
|
||||
popup2->setTypes(types, &cur1);
|
||||
popup2->popup(QPoint(100, 100));
|
||||
QApplication::processEvents();
|
||||
qint64 tFirst = t3.nsecsElapsed();
|
||||
popup2->hide();
|
||||
QApplication::processEvents();
|
||||
|
||||
qDebug() << "";
|
||||
qDebug().noquote() << QString("=== FIRST visible show after warmUp() ===");
|
||||
qDebug().noquote() << QString(" Total: %1 ms").arg(ms(tFirst));
|
||||
|
||||
// Second show (fully warm)
|
||||
QElapsedTimer t4;
|
||||
t4.start();
|
||||
popup2->setTypes(types, &cur2);
|
||||
popup2->popup(QPoint(100, 100));
|
||||
QApplication::processEvents();
|
||||
qint64 tSecond = t4.nsecsElapsed();
|
||||
popup2->hide();
|
||||
QApplication::processEvents();
|
||||
|
||||
qDebug() << "";
|
||||
qDebug().noquote() << QString("=== SECOND visible show after warmUp() ===");
|
||||
qDebug().noquote() << QString(" Total: %1 ms").arg(ms(tSecond));
|
||||
|
||||
delete popup2;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
NodeTree tree;
|
||||
buildTwoRootTree(tree);
|
||||
|
||||
QVector<TypeEntry> types;
|
||||
for (const auto& n : tree.nodes) {
|
||||
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
||||
TypeEntry e;
|
||||
e.entryKind = TypeEntry::Composite;
|
||||
e.structId = n.id;
|
||||
e.displayName = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
|
||||
e.classKeyword = n.resolvedClassKeyword();
|
||||
types.append(e);
|
||||
}
|
||||
}
|
||||
|
||||
QCOMPARE(types.size(), 2);
|
||||
QCOMPARE(types[0].displayName, QString("Alpha"));
|
||||
QCOMPARE(types[1].displayName, QString("Bravo"));
|
||||
}
|
||||
|
||||
// ── Popup signals ──
|
||||
|
||||
void testPopupSignals() {
|
||||
TypeSelectorPopup popup;
|
||||
|
||||
TypeEntry eA;
|
||||
eA.entryKind = TypeEntry::Composite;
|
||||
eA.structId = 1;
|
||||
eA.displayName = "A";
|
||||
eA.classKeyword = "struct";
|
||||
TypeEntry eB;
|
||||
eB.entryKind = TypeEntry::Composite;
|
||||
eB.structId = 2;
|
||||
eB.displayName = "B";
|
||||
eB.classKeyword = "struct";
|
||||
QVector<TypeEntry> types;
|
||||
types.append(eA);
|
||||
types.append(eB);
|
||||
popup.setTypes(types, &eA);
|
||||
|
||||
QSignalSpy typeSpy(&popup, &TypeSelectorPopup::typeSelected);
|
||||
QSignalSpy createSpy(&popup, &TypeSelectorPopup::createNewTypeRequested);
|
||||
|
||||
emit popup.typeSelected(eB, QStringLiteral("B"));
|
||||
QCOMPARE(typeSpy.count(), 1);
|
||||
// Verify the entry came through — check the fullText (second arg)
|
||||
QCOMPARE(typeSpy.at(0).at(1).toString(), QStringLiteral("B"));
|
||||
|
||||
emit popup.createNewTypeRequested();
|
||||
QCOMPARE(createSpy.count(), 1);
|
||||
}
|
||||
|
||||
// ── Full GUI integration ──
|
||||
// Single test method to avoid QScintilla reinit issues.
|
||||
|
||||
void testViewSwitchingAndCreateType() {
|
||||
auto* doc = new RcxDocument();
|
||||
buildTwoRootTree(doc->tree);
|
||||
doc->provider = std::make_unique<BufferProvider>(makeBuffer());
|
||||
|
||||
auto* splitter = new QSplitter();
|
||||
auto* ctrl = new RcxController(doc, nullptr);
|
||||
auto* editor = ctrl->addSplitEditor(splitter);
|
||||
|
||||
splitter->resize(800, 600);
|
||||
splitter->show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(splitter));
|
||||
|
||||
// Initial refresh so compose populates meta + editor text
|
||||
ctrl->refresh();
|
||||
QApplication::processEvents();
|
||||
|
||||
auto* sci = editor->scintilla();
|
||||
|
||||
// -- Command row starts with [U+25B8] --
|
||||
{
|
||||
const LineMeta* meta = editor->metaForLine(0);
|
||||
QVERIFY(meta);
|
||||
QCOMPARE(meta->lineKind, LineKind::CommandRow);
|
||||
|
||||
QString line0 = sci->text(0);
|
||||
if (line0.endsWith('\n')) line0.chop(1);
|
||||
QVERIFY2(line0.startsWith(QStringLiteral("[\u25B8]")),
|
||||
qPrintable("Expected chevron prefix, got: " + line0.left(10)));
|
||||
}
|
||||
|
||||
// -- Find root IDs --
|
||||
uint64_t alphaId = 0, bravoId = 0;
|
||||
for (const auto& n : doc->tree.nodes) {
|
||||
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
||||
if (n.name == "Alpha") alphaId = n.id;
|
||||
if (n.name == "Bravo") bravoId = n.id;
|
||||
}
|
||||
}
|
||||
QVERIFY(alphaId != 0);
|
||||
QVERIFY(bravoId != 0);
|
||||
QCOMPARE(ctrl->viewRootId(), (uint64_t)0);
|
||||
|
||||
// -- Switch to Bravo: command row + fields update --
|
||||
ctrl->setViewRootId(bravoId);
|
||||
QApplication::processEvents();
|
||||
|
||||
QCOMPARE(ctrl->viewRootId(), bravoId);
|
||||
QVERIFY2(sci->text(0).contains("Bravo"),
|
||||
qPrintable("Expected 'Bravo' in command row, got: " + sci->text(0)));
|
||||
QVERIFY2(sci->text().contains("speed"),
|
||||
"View should show Bravo's 'speed' field");
|
||||
|
||||
// -- Switch to Alpha --
|
||||
ctrl->setViewRootId(alphaId);
|
||||
QApplication::processEvents();
|
||||
|
||||
QCOMPARE(ctrl->viewRootId(), alphaId);
|
||||
QVERIFY2(sci->text(0).contains("Alpha"),
|
||||
qPrintable("Expected 'Alpha' in command row, got: " + sci->text(0)));
|
||||
|
||||
// -- Create new type (no name) --
|
||||
int nodesBefore = doc->tree.nodes.size();
|
||||
|
||||
Node newNode;
|
||||
newNode.kind = NodeKind::Struct;
|
||||
newNode.name = QString();
|
||||
newNode.parentId = 0;
|
||||
newNode.offset = 0;
|
||||
newNode.id = doc->tree.reserveId();
|
||||
uint64_t newId = newNode.id;
|
||||
|
||||
doc->undoStack.push(new RcxCommand(ctrl, cmd::Insert{newNode}));
|
||||
ctrl->setViewRootId(newId);
|
||||
QApplication::processEvents();
|
||||
|
||||
// Verify new struct
|
||||
int idx = doc->tree.indexOfId(newId);
|
||||
QVERIFY(idx >= 0);
|
||||
QVERIFY(doc->tree.nodes[idx].name.isEmpty());
|
||||
QCOMPARE(doc->tree.nodes[idx].kind, NodeKind::Struct);
|
||||
QCOMPARE(doc->tree.nodes[idx].parentId, (uint64_t)0);
|
||||
QCOMPARE(ctrl->viewRootId(), newId);
|
||||
|
||||
// Command row shows "NoName" for empty-named struct
|
||||
QVERIFY2(sci->text(0).contains("NoName"),
|
||||
qPrintable("Expected 'NoName' in command row, got: " + sci->text(0)));
|
||||
|
||||
// -- Undo removes the new struct --
|
||||
doc->undoStack.undo();
|
||||
QApplication::processEvents();
|
||||
QCOMPARE(doc->tree.nodes.size(), nodesBefore);
|
||||
|
||||
// Cleanup
|
||||
delete ctrl;
|
||||
delete splitter;
|
||||
delete doc;
|
||||
}
|
||||
|
||||
// ── parseTypeSpec tests ──
|
||||
|
||||
void testParseTypeSpecPlain() {
|
||||
TypeSpec spec = parseTypeSpec("int32_t");
|
||||
QCOMPARE(spec.baseName, QString("int32_t"));
|
||||
QVERIFY(!spec.isPointer);
|
||||
QCOMPARE(spec.arrayCount, 0);
|
||||
}
|
||||
|
||||
void testParseTypeSpecArray() {
|
||||
TypeSpec spec = parseTypeSpec("int32_t[10]");
|
||||
QCOMPARE(spec.baseName, QString("int32_t"));
|
||||
QVERIFY(!spec.isPointer);
|
||||
QCOMPARE(spec.arrayCount, 10);
|
||||
}
|
||||
|
||||
void testParseTypeSpecPointer() {
|
||||
TypeSpec spec = parseTypeSpec("Ball*");
|
||||
QCOMPARE(spec.baseName, QString("Ball"));
|
||||
QVERIFY(spec.isPointer);
|
||||
QCOMPARE(spec.arrayCount, 0);
|
||||
}
|
||||
|
||||
void testParseTypeSpecDoublePointer() {
|
||||
TypeSpec spec = parseTypeSpec("Ball**");
|
||||
QCOMPARE(spec.baseName, QString("Ball"));
|
||||
QVERIFY(spec.isPointer);
|
||||
}
|
||||
|
||||
void testParseTypeSpecEmpty() {
|
||||
TypeSpec spec = parseTypeSpec("");
|
||||
QVERIFY(spec.baseName.isEmpty());
|
||||
QVERIFY(!spec.isPointer);
|
||||
QCOMPARE(spec.arrayCount, 0);
|
||||
}
|
||||
|
||||
void testParseTypeSpecWhitespace() {
|
||||
TypeSpec spec = parseTypeSpec(" Ball * ");
|
||||
// trimmed → "Ball *", ends with '*'
|
||||
QCOMPARE(spec.baseName, QString("Ball"));
|
||||
QVERIFY(spec.isPointer);
|
||||
}
|
||||
|
||||
void testParseTypeSpecArrayZero() {
|
||||
// [0] parses baseName but arrayCount stays 0 (invalid count)
|
||||
TypeSpec spec = parseTypeSpec("int32_t[0]");
|
||||
QCOMPARE(spec.baseName, QString("int32_t"));
|
||||
QCOMPARE(spec.arrayCount, 0);
|
||||
}
|
||||
|
||||
// ── FieldType popup: selecting a composite (struct) type changes node kind + structTypeName + collapsed ──
|
||||
|
||||
void testFieldTypeCompositeChangesNodeToStruct() {
|
||||
auto* doc = new RcxDocument();
|
||||
buildTwoRootTree(doc->tree);
|
||||
doc->provider = std::make_unique<BufferProvider>(makeBuffer());
|
||||
|
||||
auto* splitter = new QSplitter();
|
||||
auto* ctrl = new RcxController(doc, nullptr);
|
||||
ctrl->addSplitEditor(splitter);
|
||||
|
||||
splitter->resize(800, 600);
|
||||
splitter->show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(splitter));
|
||||
ctrl->refresh();
|
||||
QApplication::processEvents();
|
||||
|
||||
// Find the "x" field (Int32) inside Alpha struct, and Bravo struct id
|
||||
int xIdx = -1;
|
||||
uint64_t bravoId = 0;
|
||||
for (int i = 0; i < doc->tree.nodes.size(); i++) {
|
||||
const auto& n = doc->tree.nodes[i];
|
||||
if (n.name == "x" && n.kind == NodeKind::Int32) xIdx = i;
|
||||
if (n.name == "Bravo" && n.kind == NodeKind::Struct) bravoId = n.id;
|
||||
}
|
||||
QVERIFY(xIdx >= 0);
|
||||
QVERIFY(bravoId != 0);
|
||||
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Int32);
|
||||
QVERIFY(!doc->tree.nodes[xIdx].collapsed);
|
||||
uint64_t xNodeId = doc->tree.nodes[xIdx].id;
|
||||
|
||||
// Simulate the plain-struct path of applyTypePopupResult:
|
||||
// beginMacro → changeNodeKind(Struct) → ChangeStructTypeName → ChangePointerRef → endMacro
|
||||
doc->undoStack.beginMacro(QStringLiteral("Change to composite type"));
|
||||
ctrl->changeNodeKind(xIdx, NodeKind::Struct);
|
||||
|
||||
xIdx = doc->tree.indexOfId(xNodeId);
|
||||
QVERIFY(xIdx >= 0);
|
||||
|
||||
int bravoIdx = doc->tree.indexOfId(bravoId);
|
||||
QVERIFY(bravoIdx >= 0);
|
||||
QString targetName = doc->tree.nodes[bravoIdx].structTypeName;
|
||||
|
||||
doc->undoStack.push(new RcxCommand(ctrl,
|
||||
cmd::ChangeStructTypeName{xNodeId, doc->tree.nodes[xIdx].structTypeName, targetName}));
|
||||
|
||||
// Set refId so compose can expand referenced struct children (auto-collapses)
|
||||
doc->undoStack.push(new RcxCommand(ctrl,
|
||||
cmd::ChangePointerRef{xNodeId, 0, bravoId}));
|
||||
|
||||
doc->undoStack.endMacro();
|
||||
QApplication::processEvents();
|
||||
|
||||
// Verify: Struct with correct name, refId, AND collapsed
|
||||
xIdx = doc->tree.indexOfId(xNodeId);
|
||||
QVERIFY(xIdx >= 0);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Struct);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].structTypeName, QString("Bravo"));
|
||||
QCOMPARE(doc->tree.nodes[xIdx].refId, bravoId);
|
||||
QVERIFY(doc->tree.nodes[xIdx].collapsed);
|
||||
|
||||
// Single undo reverses the entire macro
|
||||
doc->undoStack.undo();
|
||||
QApplication::processEvents();
|
||||
xIdx = doc->tree.indexOfId(xNodeId);
|
||||
QVERIFY(xIdx >= 0);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Int32);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].refId, uint64_t(0));
|
||||
QVERIFY(doc->tree.nodes[xIdx].structTypeName.isEmpty());
|
||||
|
||||
delete ctrl;
|
||||
delete splitter;
|
||||
delete doc;
|
||||
}
|
||||
|
||||
// ── FieldType popup: selecting a composite with * modifier creates Pointer64 + refId ──
|
||||
|
||||
void testFieldTypeCompositeWithPointerModifier() {
|
||||
auto* doc = new RcxDocument();
|
||||
buildTwoRootTree(doc->tree);
|
||||
doc->provider = std::make_unique<BufferProvider>(makeBuffer());
|
||||
|
||||
auto* splitter = new QSplitter();
|
||||
auto* ctrl = new RcxController(doc, nullptr);
|
||||
ctrl->addSplitEditor(splitter);
|
||||
|
||||
splitter->resize(800, 600);
|
||||
splitter->show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(splitter));
|
||||
ctrl->refresh();
|
||||
QApplication::processEvents();
|
||||
|
||||
// Find the "x" field (Int32) and Bravo struct
|
||||
int xIdx = -1;
|
||||
uint64_t bravoId = 0;
|
||||
for (int i = 0; i < doc->tree.nodes.size(); i++) {
|
||||
const auto& n = doc->tree.nodes[i];
|
||||
if (n.name == "x" && n.kind == NodeKind::Int32) xIdx = i;
|
||||
if (n.name == "Bravo" && n.kind == NodeKind::Struct) bravoId = n.id;
|
||||
}
|
||||
QVERIFY(xIdx >= 0);
|
||||
QVERIFY(bravoId != 0);
|
||||
|
||||
uint64_t xNodeId = doc->tree.nodes[xIdx].id;
|
||||
|
||||
// Simulate the pointer path of applyTypePopupResult:
|
||||
// beginMacro → changeNodeKind(Pointer64) → ChangePointerRef → endMacro
|
||||
doc->undoStack.beginMacro(QStringLiteral("Change to composite type"));
|
||||
ctrl->changeNodeKind(xIdx, NodeKind::Pointer64);
|
||||
|
||||
xIdx = doc->tree.indexOfId(xNodeId);
|
||||
QVERIFY(xIdx >= 0);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Pointer64);
|
||||
|
||||
doc->undoStack.push(new RcxCommand(ctrl,
|
||||
cmd::ChangePointerRef{xNodeId, 0, bravoId}));
|
||||
doc->undoStack.endMacro();
|
||||
QApplication::processEvents();
|
||||
|
||||
// Verify: Pointer64 with refId pointing to Bravo, auto-collapsed
|
||||
xIdx = doc->tree.indexOfId(xNodeId);
|
||||
QVERIFY(xIdx >= 0);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Pointer64);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].refId, bravoId);
|
||||
QVERIFY(doc->tree.nodes[xIdx].collapsed);
|
||||
|
||||
// Single undo reverses the entire macro
|
||||
doc->undoStack.undo();
|
||||
QApplication::processEvents();
|
||||
xIdx = doc->tree.indexOfId(xNodeId);
|
||||
QVERIFY(xIdx >= 0);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Int32);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].refId, uint64_t(0));
|
||||
|
||||
delete ctrl;
|
||||
delete splitter;
|
||||
delete doc;
|
||||
}
|
||||
|
||||
// ── FieldType popup: selecting a primitive type still works ──
|
||||
|
||||
void testFieldTypePrimitiveStillWorks() {
|
||||
auto* doc = new RcxDocument();
|
||||
buildTwoRootTree(doc->tree);
|
||||
doc->provider = std::make_unique<BufferProvider>(makeBuffer());
|
||||
|
||||
auto* splitter = new QSplitter();
|
||||
auto* ctrl = new RcxController(doc, nullptr);
|
||||
ctrl->addSplitEditor(splitter);
|
||||
|
||||
splitter->resize(800, 600);
|
||||
splitter->show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(splitter));
|
||||
ctrl->refresh();
|
||||
QApplication::processEvents();
|
||||
|
||||
// Find the "x" field (Int32)
|
||||
int xIdx = -1;
|
||||
for (int i = 0; i < doc->tree.nodes.size(); i++) {
|
||||
if (doc->tree.nodes[i].name == "x") { xIdx = i; break; }
|
||||
}
|
||||
QVERIFY(xIdx >= 0);
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Int32);
|
||||
|
||||
// Change to Float via changeNodeKind (same path as primitive TypeEntry)
|
||||
ctrl->changeNodeKind(xIdx, NodeKind::Float);
|
||||
QApplication::processEvents();
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Float);
|
||||
|
||||
// Undo
|
||||
doc->undoStack.undo();
|
||||
QApplication::processEvents();
|
||||
QCOMPARE(doc->tree.nodes[xIdx].kind, NodeKind::Int32);
|
||||
|
||||
delete ctrl;
|
||||
delete splitter;
|
||||
delete doc;
|
||||
}
|
||||
|
||||
// ── Section headers in filtered list ──
|
||||
|
||||
void testSectionHeadersPresent() {
|
||||
TypeSelectorPopup popup;
|
||||
|
||||
// Build entries with both primitives and composites
|
||||
QVector<TypeEntry> types;
|
||||
TypeEntry prim;
|
||||
prim.entryKind = TypeEntry::Primitive;
|
||||
prim.primitiveKind = NodeKind::Int32;
|
||||
prim.displayName = "int32_t";
|
||||
types.append(prim);
|
||||
|
||||
TypeEntry comp;
|
||||
comp.entryKind = TypeEntry::Composite;
|
||||
comp.structId = 42;
|
||||
comp.displayName = "MyStruct";
|
||||
comp.classKeyword = "struct";
|
||||
types.append(comp);
|
||||
|
||||
popup.setTypes(types);
|
||||
// After setTypes, the internal filtered list should have section headers
|
||||
// We can verify this indirectly by checking the model row count
|
||||
// (should be > 2 due to section headers)
|
||||
auto* listView = popup.findChild<QListView*>();
|
||||
QVERIFY(listView);
|
||||
QVERIFY(listView->model()->rowCount() > 2);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestTypeSelector)
|
||||
#include "test_type_selector.moc"
|
||||
@@ -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() {
|
||||
|
||||
463
tests/test_windbg_provider.cpp
Normal file
463
tests/test_windbg_provider.cpp
Normal 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"
|
||||
124
tools/rcx-mcp-stdio.cpp
Normal file
124
tools/rcx-mcp-stdio.cpp
Normal file
@@ -0,0 +1,124 @@
|
||||
// ReclassMcpBridge: Bridges stdin/stdout to QLocalSocket for MCP transport.
|
||||
// Claude Desktop spawns this process; it connects to the ReclassMcpBridge named pipe
|
||||
// inside the running Reclass application.
|
||||
//
|
||||
// stdin (from Claude) → QLocalSocket → McpBridge (in Reclass)
|
||||
// stdout (to Claude) ← QLocalSocket ← McpBridge (in Reclass)
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QLocalSocket>
|
||||
#include <QTimer>
|
||||
#include <QTextStream>
|
||||
#include <cstdio>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#include <io.h>
|
||||
#include <fcntl.h>
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#include <sys/select.h>
|
||||
#endif
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
QCoreApplication app(argc, argv);
|
||||
|
||||
#ifdef _WIN32
|
||||
// Ensure stdin/stdout are in binary mode on Windows
|
||||
_setmode(_fileno(stdin), _O_BINARY);
|
||||
_setmode(_fileno(stdout), _O_BINARY);
|
||||
#endif
|
||||
|
||||
auto* socket = new QLocalSocket(&app);
|
||||
QByteArray readBuf;
|
||||
|
||||
// Socket → stdout: forward lines from Reclass to Claude Desktop
|
||||
QObject::connect(socket, &QLocalSocket::readyRead, [&]() {
|
||||
readBuf.append(socket->readAll());
|
||||
while (true) {
|
||||
int idx = readBuf.indexOf('\n');
|
||||
if (idx < 0) break;
|
||||
QByteArray line = readBuf.left(idx + 1); // include newline
|
||||
readBuf.remove(0, idx + 1);
|
||||
fwrite(line.constData(), 1, line.size(), stdout);
|
||||
fflush(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
QObject::connect(socket, &QLocalSocket::disconnected, [&]() {
|
||||
fprintf(stderr, "[ReclassMcpBridge] Disconnected from server\n");
|
||||
app.quit();
|
||||
});
|
||||
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)
|
||||
QObject::connect(socket, &QLocalSocket::errorOccurred, [&](QLocalSocket::LocalSocketError err) {
|
||||
#else
|
||||
QObject::connect(socket, QOverload<QLocalSocket::LocalSocketError>::of(&QLocalSocket::error), [&](QLocalSocket::LocalSocketError err) {
|
||||
#endif
|
||||
fprintf(stderr, "[ReclassMcpBridge] Socket error %d: %s\n",
|
||||
(int)err, socket->errorString().toUtf8().constData());
|
||||
app.quit();
|
||||
});
|
||||
|
||||
// Connect to the named pipe
|
||||
socket->connectToServer("ReclassMcpBridge");
|
||||
if (!socket->waitForConnected(5000)) {
|
||||
fprintf(stderr, "[ReclassMcpBridge] Failed to connect to ReclassMcpBridge pipe: %s\n",
|
||||
socket->errorString().toUtf8().constData());
|
||||
return 1;
|
||||
}
|
||||
fprintf(stderr, "[ReclassMcpBridge] Connected to ReclassMcpBridge\n");
|
||||
|
||||
// Stdin → socket: poll stdin with a timer (stdin isn't a socket on Windows)
|
||||
QByteArray stdinBuf;
|
||||
auto* stdinTimer = new QTimer(&app);
|
||||
stdinTimer->setInterval(10);
|
||||
|
||||
QObject::connect(stdinTimer, &QTimer::timeout, [&]() {
|
||||
#ifdef _WIN32
|
||||
HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE);
|
||||
DWORD avail = 0;
|
||||
if (!PeekNamedPipe(hStdin, nullptr, 0, nullptr, &avail, nullptr)) {
|
||||
// stdin closed (pipe broken)
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
if (avail == 0) return;
|
||||
|
||||
char buf[4096];
|
||||
DWORD bytesRead = 0;
|
||||
DWORD toRead = qMin(avail, (DWORD)sizeof(buf));
|
||||
if (!ReadFile(hStdin, buf, toRead, &bytesRead, nullptr) || bytesRead == 0) {
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
stdinBuf.append(buf, (int)bytesRead);
|
||||
#else
|
||||
// On Unix, we could use QSocketNotifier, but timer works fine too
|
||||
char buf[4096];
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(STDIN_FILENO, &fds);
|
||||
struct timeval tv = {0, 0};
|
||||
if (select(STDIN_FILENO + 1, &fds, nullptr, nullptr, &tv) <= 0) return;
|
||||
ssize_t n = ::read(STDIN_FILENO, buf, sizeof(buf));
|
||||
if (n <= 0) {
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
stdinBuf.append(buf, (int)n);
|
||||
#endif
|
||||
// Forward complete lines to socket
|
||||
while (true) {
|
||||
int idx = stdinBuf.indexOf('\n');
|
||||
if (idx < 0) break;
|
||||
QByteArray line = stdinBuf.left(idx + 1);
|
||||
stdinBuf.remove(0, idx + 1);
|
||||
socket->write(line);
|
||||
socket->flush();
|
||||
}
|
||||
});
|
||||
|
||||
stdinTimer->start();
|
||||
return app.exec();
|
||||
}
|
||||
Reference in New Issue
Block a user