mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
Compare commits
51 Commits
snapshot-0
...
msvc-fix-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
955db3813a | ||
|
|
f4f203e0f0 | ||
|
|
1d3f1a672a | ||
|
|
da29206bdb | ||
|
|
4986893fca | ||
|
|
17a1fb032e | ||
|
|
8d92957837 | ||
|
|
f981fe456d | ||
|
|
877ceea4c1 | ||
|
|
4160a229c6 | ||
|
|
1e1afc1640 | ||
|
|
f0cf6c549a | ||
|
|
683eab16ee | ||
|
|
b53dea8f9f | ||
|
|
f06abbab79 | ||
|
|
2477591ed2 | ||
|
|
6c13356d6d | ||
|
|
3b273a7ab2 | ||
|
|
3509a0d9dd | ||
|
|
43c3f5a842 | ||
|
|
0697ce4853 | ||
|
|
ed1bfd04cd | ||
|
|
c275eb33c9 | ||
|
|
636176ee8c | ||
|
|
9a716444f4 | ||
|
|
a46da4ee16 | ||
|
|
cd52451210 | ||
|
|
82bf9118c9 | ||
|
|
f4c7e9327d | ||
|
|
5944dbdc81 | ||
|
|
b3425aec9e | ||
|
|
2a8cfee719 | ||
|
|
e999c664b8 | ||
|
|
0dc4af6b1d | ||
|
|
376aad2169 | ||
|
|
4937c58062 | ||
|
|
9c72265901 | ||
|
|
86499e58ee | ||
|
|
b2ae8d5a5d | ||
|
|
6768f04e9a | ||
|
|
c6e5f6508f | ||
|
|
e6529052b3 | ||
|
|
d43e989992 | ||
|
|
879e9f4047 | ||
|
|
e0d5a799b4 | ||
|
|
efae193520 | ||
|
|
ba1c2f8e5a | ||
|
|
5a0a4d1802 | ||
|
|
030eb34510 | ||
|
|
2939b25895 | ||
|
|
d38cb02fa2 |
72
.github/workflows/build.yml
vendored
72
.github/workflows/build.yml
vendored
@@ -2,7 +2,8 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- "**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@@ -21,9 +22,9 @@ jobs:
|
|||||||
- name: Install Qt6 and MinGW
|
- name: Install Qt6 and MinGW
|
||||||
uses: jurplel/install-qt-action@v4
|
uses: jurplel/install-qt-action@v4
|
||||||
with:
|
with:
|
||||||
version: '6.8.1'
|
version: "6.8.1"
|
||||||
arch: 'win64_mingw'
|
arch: "win64_mingw"
|
||||||
tools: 'tools_mingw1310,qt.tools.win64_mingw1310'
|
tools: "tools_mingw1310,qt.tools.win64_mingw1310"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Configure
|
- name: Configure
|
||||||
@@ -83,7 +84,7 @@ jobs:
|
|||||||
- name: Install Qt6
|
- name: Install Qt6
|
||||||
uses: jurplel/install-qt-action@v4
|
uses: jurplel/install-qt-action@v4
|
||||||
with:
|
with:
|
||||||
version: '6.8.1'
|
version: "6.8.1"
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -140,9 +141,66 @@ jobs:
|
|||||||
name: Reclass-linux64-qt6
|
name: Reclass-linux64-qt6
|
||||||
path: Reclass-linux64-qt6.AppImage
|
path: Reclass-linux64-qt6.AppImage
|
||||||
|
|
||||||
|
macos:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-15
|
||||||
|
qt_arch: clang_arm64
|
||||||
|
artifact_name: Reclass-macos-arm64-qt6
|
||||||
|
zip_name: Reclass-macos-arm64-qt6.zip
|
||||||
|
- os: macos-15-intel
|
||||||
|
qt_arch: clang_64
|
||||||
|
artifact_name: Reclass-macos-x86_64-qt6
|
||||||
|
zip_name: Reclass-macos-x86_64-qt6.zip
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
brew update
|
||||||
|
brew install cmake ninja qt
|
||||||
|
|
||||||
|
- name: Configure Qt paths
|
||||||
|
run: |
|
||||||
|
QT_PREFIX="$(brew --prefix qt)"
|
||||||
|
echo "QT_PREFIX=$QT_PREFIX" >> "$GITHUB_ENV"
|
||||||
|
echo "PATH=$QT_PREFIX/bin:$PATH" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Configure
|
||||||
|
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_UI_TESTS=OFF -DCMAKE_PREFIX_PATH="$QT_PREFIX"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cmake --build build
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: ctest --test-dir build --output-on-failure
|
||||||
|
|
||||||
|
- name: Package app zip
|
||||||
|
run: |
|
||||||
|
MACDEPLOYQT_BIN="$QT_PREFIX/bin/macdeployqt"
|
||||||
|
if [ ! -x "$MACDEPLOYQT_BIN" ]; then
|
||||||
|
MACDEPLOYQT_BIN=$(which macdeployqt 2>/dev/null || find "$RUNNER_WORKSPACE" -name macdeployqt -path "*/bin/*" | head -1)
|
||||||
|
fi
|
||||||
|
echo "Found macdeployqt at: $MACDEPLOYQT_BIN"
|
||||||
|
"$MACDEPLOYQT_BIN" build/Reclass.app -always-overwrite
|
||||||
|
codesign --force --deep --sign - build/Reclass.app
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent build/Reclass.app "${{ matrix.zip_name }}"
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.artifact_name }}
|
||||||
|
path: ${{ matrix.zip_name }}
|
||||||
|
|
||||||
release:
|
release:
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
needs: [windows, linux]
|
needs: [windows, linux, macos]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -167,5 +225,7 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
artifacts/Reclass-win64-qt6/Reclass-win64-qt6.zip
|
artifacts/Reclass-win64-qt6/Reclass-win64-qt6.zip
|
||||||
artifacts/Reclass-linux64-qt6/Reclass-linux64-qt6.AppImage
|
artifacts/Reclass-linux64-qt6/Reclass-linux64-qt6.AppImage
|
||||||
|
artifacts/Reclass-macos-arm64-qt6/Reclass-macos-arm64-qt6.zip
|
||||||
|
artifacts/Reclass-macos-x86_64-qt6/Reclass-macos-x86_64-qt6.zip
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ CMakeUserPresets.json
|
|||||||
plugins/RcNetPluginCompatLayer/bridge/obj
|
plugins/RcNetPluginCompatLayer/bridge/obj
|
||||||
plugins/RcNetPluginCompatLayer/bridge/bin
|
plugins/RcNetPluginCompatLayer/bridge/bin
|
||||||
.cache
|
.cache
|
||||||
|
*.DS_Store
|
||||||
|
|||||||
250
CMakeLists.txt
250
CMakeLists.txt
@@ -36,10 +36,35 @@ file(GLOB RAW_PDB_SRCS third_party/raw_pdb/src/*.cpp)
|
|||||||
add_library(raw_pdb STATIC ${RAW_PDB_SRCS})
|
add_library(raw_pdb STATIC ${RAW_PDB_SRCS})
|
||||||
target_include_directories(raw_pdb PUBLIC third_party/raw_pdb/src)
|
target_include_directories(raw_pdb PUBLIC third_party/raw_pdb/src)
|
||||||
target_compile_features(raw_pdb PRIVATE cxx_std_11)
|
target_compile_features(raw_pdb PRIVATE cxx_std_11)
|
||||||
|
# PDB_CRT.h forward-declares printf/memcmp/etc with __cdecl which conflicts
|
||||||
|
# with non-MSVC compilers (GCC, Clang, MinGW). Force-include a prefix header
|
||||||
|
# that pulls in the real CRT headers and strips __cdecl.
|
||||||
|
if(NOT MSVC)
|
||||||
|
target_compile_options(raw_pdb PUBLIC
|
||||||
|
-include "${CMAKE_CURRENT_SOURCE_DIR}/cmake/raw_pdb_prefix.h")
|
||||||
|
endif()
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(raw_pdb PRIVATE rpcrt4)
|
target_link_libraries(raw_pdb PRIVATE rpcrt4)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Fadec — generate decode tables (.inc files) from instrs.txt at configure time
|
||||||
|
find_package(Python3 3.9 REQUIRED)
|
||||||
|
set(FADEC_DIR "${CMAKE_SOURCE_DIR}/third_party/fadec")
|
||||||
|
if(NOT EXISTS "${FADEC_DIR}/fadec-decode-public.inc")
|
||||||
|
message(STATUS "Generating fadec decode tables...")
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${Python3_EXECUTABLE} "${FADEC_DIR}/parseinstrs.py" decode
|
||||||
|
"${FADEC_DIR}/instrs.txt"
|
||||||
|
"${FADEC_DIR}/fadec-decode-public.inc"
|
||||||
|
"${FADEC_DIR}/fadec-decode-private.inc"
|
||||||
|
--32 --64
|
||||||
|
RESULT_VARIABLE _fadec_result
|
||||||
|
)
|
||||||
|
if(NOT _fadec_result EQUAL 0)
|
||||||
|
message(FATAL_ERROR "Failed to generate fadec decode tables")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
add_executable(Reclass
|
add_executable(Reclass
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/editor.h
|
src/editor.h
|
||||||
@@ -84,10 +109,14 @@ add_executable(Reclass
|
|||||||
src/scannerpanel.h
|
src/scannerpanel.h
|
||||||
src/scannerpanel.cpp
|
src/scannerpanel.cpp
|
||||||
src/mainwindow.h
|
src/mainwindow.h
|
||||||
|
src/startpage.h
|
||||||
|
src/dock_tab_buttons.h
|
||||||
src/optionsdialog.h
|
src/optionsdialog.h
|
||||||
src/optionsdialog.cpp
|
src/optionsdialog.cpp
|
||||||
src/titlebar.h
|
src/titlebar.h
|
||||||
src/titlebar.cpp
|
src/titlebar.cpp
|
||||||
|
src/macos_titlebar.h
|
||||||
|
$<$<PLATFORM_ID:Darwin>:src/macos_titlebar.mm>
|
||||||
src/mcp/mcp_bridge.h
|
src/mcp/mcp_bridge.h
|
||||||
src/mcp/mcp_bridge.cpp
|
src/mcp/mcp_bridge.cpp
|
||||||
src/addressparser.h
|
src/addressparser.h
|
||||||
@@ -99,6 +128,16 @@ add_executable(Reclass
|
|||||||
$<$<PLATFORM_ID:Windows>:src/app.rc>
|
$<$<PLATFORM_ID:Windows>:src/app.rc>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
set_target_properties(Reclass PROPERTIES
|
||||||
|
MACOSX_BUNDLE TRUE
|
||||||
|
MACOSX_BUNDLE_ICON_FILE "class.icns"
|
||||||
|
)
|
||||||
|
target_sources(Reclass PRIVATE src/icons/class.icns)
|
||||||
|
set_source_files_properties(src/icons/class.icns
|
||||||
|
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
|
||||||
|
endif()
|
||||||
|
|
||||||
target_include_directories(Reclass PRIVATE src third_party/fadec)
|
target_include_directories(Reclass PRIVATE src third_party/fadec)
|
||||||
|
|
||||||
target_link_libraries(Reclass PRIVATE
|
target_link_libraries(Reclass PRIVATE
|
||||||
@@ -116,6 +155,14 @@ endif()
|
|||||||
|
|
||||||
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
|
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
|
||||||
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
|
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
|
||||||
|
if(APPLE)
|
||||||
|
add_custom_command(TARGET ReclassMcpBridge POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy
|
||||||
|
$<TARGET_FILE:ReclassMcpBridge>
|
||||||
|
$<TARGET_FILE_DIR:Reclass>/ReclassMcpBridge
|
||||||
|
COMMENT "Bundling ReclassMcpBridge into Reclass.app"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Copy built-in theme JSON files to build directory
|
# Copy built-in theme JSON files to build directory
|
||||||
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
|
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
|
||||||
@@ -125,6 +172,12 @@ foreach(_tf ${_theme_files})
|
|||||||
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
|
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
target_sources(Reclass PRIVATE ${_theme_files})
|
||||||
|
set_source_files_properties(${_theme_files}
|
||||||
|
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/themes")
|
||||||
|
endif()
|
||||||
|
|
||||||
# Copy example .rcx files to build directory
|
# Copy example .rcx files to build directory
|
||||||
file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx")
|
file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx")
|
||||||
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples")
|
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples")
|
||||||
@@ -133,6 +186,12 @@ foreach(_ef ${_example_files})
|
|||||||
configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY)
|
configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY)
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
|
if(APPLE)
|
||||||
|
target_sources(Reclass PRIVATE ${_example_files})
|
||||||
|
set_source_files_properties(${_example_files}
|
||||||
|
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/examples")
|
||||||
|
endif()
|
||||||
|
|
||||||
include(deploy)
|
include(deploy)
|
||||||
|
|
||||||
|
|
||||||
@@ -273,178 +332,183 @@ if(BUILD_TESTING)
|
|||||||
option(BUILD_UI_TESTS "Build tests that require a display (Qt Widgets)" ON)
|
option(BUILD_UI_TESTS "Build tests that require a display (Qt Widgets)" ON)
|
||||||
if(BUILD_UI_TESTS)
|
if(BUILD_UI_TESTS)
|
||||||
|
|
||||||
add_executable(test_controller tests/test_controller.cpp
|
add_executable(test_controller tests/test_controller.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_controller PRIVATE src third_party/fadec)
|
target_include_directories(test_controller PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_controller PRIVATE
|
target_link_libraries(test_controller PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_controller PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_controller PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_controller COMMAND test_controller)
|
add_test(NAME test_controller COMMAND test_controller)
|
||||||
|
|
||||||
add_executable(test_validation tests/test_validation.cpp
|
add_executable(grab_tabs tests/grab_tabs.cpp
|
||||||
|
src/themes/theme.cpp src/themes/thememanager.cpp src/resources.qrc)
|
||||||
|
target_include_directories(grab_tabs PRIVATE src)
|
||||||
|
target_link_libraries(grab_tabs PRIVATE ${QT}::Widgets ${QT}::Svg ${QT}::Test)
|
||||||
|
|
||||||
|
add_executable(test_validation tests/test_validation.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_validation PRIVATE src third_party/fadec)
|
target_include_directories(test_validation PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_validation PRIVATE
|
target_link_libraries(test_validation PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_validation PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_validation PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_validation COMMAND test_validation)
|
add_test(NAME test_validation COMMAND test_validation)
|
||||||
|
|
||||||
add_executable(test_context_menu tests/test_context_menu.cpp
|
add_executable(test_context_menu tests/test_context_menu.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_context_menu PRIVATE src third_party/fadec)
|
target_include_directories(test_context_menu PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_context_menu PRIVATE
|
target_link_libraries(test_context_menu PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_context_menu PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_context_menu PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_context_menu COMMAND test_context_menu)
|
add_test(NAME test_context_menu COMMAND test_context_menu)
|
||||||
|
|
||||||
add_executable(test_source_management tests/test_source_management.cpp
|
add_executable(test_source_management tests/test_source_management.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_source_management PRIVATE src third_party/fadec)
|
target_include_directories(test_source_management PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_source_management PRIVATE
|
target_link_libraries(test_source_management PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_source_management PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_source_management PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_source_management COMMAND test_source_management)
|
add_test(NAME test_source_management COMMAND test_source_management)
|
||||||
|
|
||||||
add_executable(test_editor tests/test_editor.cpp
|
add_executable(test_editor tests/test_editor.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp
|
||||||
src/providerregistry.cpp
|
src/providerregistry.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_editor PRIVATE src third_party/fadec)
|
target_include_directories(test_editor PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_editor PRIVATE
|
target_link_libraries(test_editor PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
add_test(NAME test_editor COMMAND test_editor)
|
add_test(NAME test_editor COMMAND test_editor)
|
||||||
|
|
||||||
add_executable(test_rendered_view tests/test_rendered_view.cpp
|
add_executable(test_rendered_view tests/test_rendered_view.cpp
|
||||||
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.cpp)
|
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.cpp)
|
||||||
target_include_directories(test_rendered_view PRIVATE src)
|
target_include_directories(test_rendered_view PRIVATE src)
|
||||||
target_link_libraries(test_rendered_view PRIVATE
|
target_link_libraries(test_rendered_view PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
add_test(NAME test_rendered_view COMMAND test_rendered_view)
|
add_test(NAME test_rendered_view COMMAND test_rendered_view)
|
||||||
|
|
||||||
add_executable(test_new_features tests/test_new_features.cpp
|
add_executable(test_new_features tests/test_new_features.cpp
|
||||||
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_new_features PRIVATE src third_party/fadec)
|
target_include_directories(test_new_features PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_new_features PRIVATE
|
target_link_libraries(test_new_features PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_new_features PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_new_features PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_new_features COMMAND test_new_features)
|
add_test(NAME test_new_features COMMAND test_new_features)
|
||||||
|
|
||||||
add_executable(test_type_selector tests/test_type_selector.cpp
|
add_executable(test_type_selector tests/test_type_selector.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_type_selector PRIVATE src third_party/fadec)
|
target_include_directories(test_type_selector PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_type_selector PRIVATE
|
target_link_libraries(test_type_selector PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_type_selector PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_type_selector PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_type_selector COMMAND test_type_selector)
|
add_test(NAME test_type_selector COMMAND test_type_selector)
|
||||||
|
|
||||||
add_executable(test_type_visibility tests/test_type_visibility.cpp
|
add_executable(test_type_visibility tests/test_type_visibility.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(test_type_visibility PRIVATE src third_party/fadec)
|
target_include_directories(test_type_visibility PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_type_visibility PRIVATE
|
target_link_libraries(test_type_visibility PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_type_visibility PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_type_visibility PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_type_visibility COMMAND test_type_visibility)
|
add_test(NAME test_type_visibility COMMAND test_type_visibility)
|
||||||
|
|
||||||
add_executable(test_options_dialog tests/test_options_dialog.cpp
|
add_executable(test_options_dialog tests/test_options_dialog.cpp
|
||||||
src/optionsdialog.cpp src/themes/theme.cpp src/themes/thememanager.cpp)
|
src/optionsdialog.cpp src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||||
target_include_directories(test_options_dialog PRIVATE src)
|
target_include_directories(test_options_dialog PRIVATE src)
|
||||||
target_link_libraries(test_options_dialog PRIVATE ${QT}::Widgets ${QT}::Test)
|
target_link_libraries(test_options_dialog PRIVATE ${QT}::Widgets ${QT}::Test)
|
||||||
add_test(NAME test_options_dialog COMMAND test_options_dialog)
|
add_test(NAME test_options_dialog COMMAND test_options_dialog)
|
||||||
|
|
||||||
add_executable(test_source_provider tests/test_source_provider.cpp
|
add_executable(test_source_provider tests/test_source_provider.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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/typeselectorpopup.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS}
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS}
|
||||||
src/resources.qrc)
|
src/resources.qrc)
|
||||||
target_include_directories(test_source_provider PRIVATE src third_party/fadec)
|
target_include_directories(test_source_provider PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(test_source_provider PRIVATE
|
target_link_libraries(test_source_provider PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test ${QT}::Svg
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test ${QT}::Svg
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(test_source_provider PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(test_source_provider PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME test_source_provider COMMAND test_source_provider)
|
add_test(NAME test_source_provider COMMAND test_source_provider)
|
||||||
|
|
||||||
add_executable(test_scanner_ui tests/test_scanner_ui.cpp
|
add_executable(test_scanner_ui tests/test_scanner_ui.cpp
|
||||||
src/scanner.cpp src/scannerpanel.cpp src/addressparser.cpp
|
src/scanner.cpp src/scannerpanel.cpp src/addressparser.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp)
|
src/themes/theme.cpp src/themes/thememanager.cpp)
|
||||||
target_include_directories(test_scanner_ui PRIVATE src)
|
target_include_directories(test_scanner_ui PRIVATE src)
|
||||||
target_link_libraries(test_scanner_ui PRIVATE
|
target_link_libraries(test_scanner_ui PRIVATE
|
||||||
${QT}::Widgets ${QT}::Concurrent ${QT}::Test)
|
${QT}::Widgets ${QT}::Concurrent ${QT}::Test)
|
||||||
add_test(NAME test_scanner_ui COMMAND test_scanner_ui)
|
add_test(NAME test_scanner_ui COMMAND test_scanner_ui)
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
|
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
|
||||||
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp
|
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp
|
||||||
src/scanner.cpp)
|
src/scanner.cpp)
|
||||||
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
|
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
|
||||||
target_link_libraries(test_windbg_provider PRIVATE
|
target_link_libraries(test_windbg_provider PRIVATE
|
||||||
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
|
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
|
||||||
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
|
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
add_executable(bench_large_class tests/bench_large_class.cpp
|
add_executable(bench_large_class tests/bench_large_class.cpp
|
||||||
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp
|
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp
|
||||||
src/providerregistry.cpp
|
src/providerregistry.cpp
|
||||||
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
|
||||||
target_include_directories(bench_large_class PRIVATE src third_party/fadec)
|
target_include_directories(bench_large_class PRIVATE src third_party/fadec)
|
||||||
target_link_libraries(bench_large_class PRIVATE
|
target_link_libraries(bench_large_class PRIVATE
|
||||||
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
|
||||||
QScintilla::QScintilla)
|
QScintilla::QScintilla)
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
target_link_libraries(bench_large_class PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
target_link_libraries(bench_large_class PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
|
||||||
endif()
|
endif()
|
||||||
add_test(NAME bench_large_class COMMAND bench_large_class)
|
add_test(NAME bench_large_class COMMAND bench_large_class)
|
||||||
|
|
||||||
# Deploy Qt runtime DLLs for tests (run windeployqt on a representative test exe
|
# 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)
|
# that links the broadest set of Qt modules; all test exes share the same output dir)
|
||||||
if(TARGET ${QT}::windeployqt)
|
if(TARGET ${QT}::windeployqt)
|
||||||
add_custom_target(deploy_tests ALL
|
add_custom_target(deploy_tests ALL
|
||||||
COMMAND $<TARGET_FILE:${QT}::windeployqt>
|
COMMAND $<TARGET_FILE:${QT}::windeployqt>
|
||||||
--no-compiler-runtime --no-translations
|
--no-compiler-runtime --no-translations
|
||||||
--no-opengl-sw --no-system-d3d-compiler
|
--no-opengl-sw --no-system-d3d-compiler
|
||||||
@@ -452,12 +516,14 @@ if(BUILD_TESTING)
|
|||||||
DEPENDS test_controller
|
DEPENDS test_controller
|
||||||
COMMENT "Deploying Qt runtime DLLs for tests..."
|
COMMENT "Deploying Qt runtime DLLs for tests..."
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
endif() # BUILD_UI_TESTS
|
endif() # BUILD_UI_TESTS
|
||||||
endif()
|
endif()
|
||||||
add_subdirectory(plugins/ProcessMemory)
|
if(NOT APPLE)
|
||||||
add_subdirectory(plugins/RemoteProcessMemory)
|
add_subdirectory(plugins/ProcessMemory)
|
||||||
|
add_subdirectory(plugins/RemoteProcessMemory)
|
||||||
|
endif()
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
add_subdirectory(plugins/WinDbgMemory)
|
add_subdirectory(plugins/WinDbgMemory)
|
||||||
add_subdirectory(plugins/RcNetPluginCompatLayer)
|
add_subdirectory(plugins/RcNetPluginCompatLayer)
|
||||||
|
|||||||
129
README.md
129
README.md
@@ -12,64 +12,109 @@
|
|||||||
[](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml)
|
[](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/IChooseYou/Reclass/releases)
|
[](https://github.com/IChooseYou/Reclass/releases)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Reclass 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 at runtime from a live process, or from a static source like a binary file or crash dump.
|
Reclass helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is a debugging tool for figuring out unknown data structures — either at runtime from a live process, or from a static source like a binary file or crash dump.
|
||||||
|
|
||||||
Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor surface is rendered as formatted plain text with inline editing, fold markers, and hex/ASCII previews.
|
Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor surface is rendered as formatted plain text with inline editing, fold markers, and hex/ASCII previews.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Structured binary view** — render raw bytes as typed fields (integers, floats, pointers, vectors, matrices, strings, booleans, padding)
|
### Editor
|
||||||
- **Struct & array nesting** — define nested structs and arrays with collapsible fold regions
|
|
||||||
- **Enums & bitfields** — define enums and bitfield types with named members, inline editing, and auto-sort
|
- **Structured binary view** — render raw bytes as typed fields with columnar alignment
|
||||||
- **Inline editing** — click to edit type names, field names, values, and base addresses directly in the editor
|
- **Inline editing** — click to edit type names, field names, values, base addresses, array metadata, pointer targets, enum members, bitfield members, static expressions, and comments — all with real-time validation
|
||||||
- **Undo/redo** — full undo history for all mutations via command stack
|
- **Tab-cycling** — tab through editable fields within a line
|
||||||
- **Multi-document tabs** — open multiple projects simultaneously in MDI sub-windows
|
- **Type autocomplete** — cached popup type picker with search/filter for struct targets
|
||||||
|
- **Multi-select** — Ctrl+click individual nodes or Shift+click for range selection
|
||||||
- **Split views** — multiple synchronized editor panes over the same document
|
- **Split views** — multiple synchronized editor panes over the same document
|
||||||
- **Type autocomplete** — popup type picker when changing field kinds
|
- **Find bar** — Ctrl+F in-editor search with indicator highlighting
|
||||||
- **Hex + ASCII margins** — raw byte previews alongside the structured view
|
- **Fold/collapse** — expand and collapse structs, arrays, and pointer expansions with embedded fold indicators
|
||||||
- **Value history & heatmap** — track value changes over time with color-coded heat indicators
|
- **Hex + ASCII columns** — raw byte previews alongside the structured view with per-byte change highlighting
|
||||||
- **Disassembly preview** — hover over code pointers to see decoded instructions
|
|
||||||
- **C/C++ code generation** — export structs as compilable C/C++ headers
|
|
||||||
- **Import / export** — PDB import (Windows), ReClass XML import/export, C/C++ source import
|
|
||||||
- **Themes** — built-in theme editor with multiple presets
|
|
||||||
- **MCP bridge** — expose all tool functionality to AI clients via Model Context Protocol
|
|
||||||
- **Plugin system** — extend with custom data source providers via DLL plugins; the following ship by default:
|
|
||||||
- **Process plugin** — access memory of live processes on Windows and Linux
|
|
||||||
- **WinDbg plugin** — access data sources live in WinDbg debugging sessions
|
|
||||||
- **ReClass.NET compatibility layer** — load existing .NET and native ReClass.NET plugins
|
|
||||||
|
|
||||||
## Roadmap
|
### Live Memory Analysis
|
||||||
|
|
||||||
- [ ] Process memory section enumeration
|
- **Auto-refresh** — configurable interval (default 660ms) with async page-based reads for non-blocking UI
|
||||||
- [ ] Address parser auto-complete
|
- **Value history & heatmap** — per-node ring buffer (10 samples with timestamps), color-coded heat indicators (static/cold/warm/hot) based on change frequency
|
||||||
- [ ] Safe mode
|
- **Changed-byte highlighting** — per-byte change indicators within hex preview lines
|
||||||
- [ ] File import for other Reclass instances
|
- **Memory write-back** — edit values inline, writes propagate through the provider to live process memory
|
||||||
- [ ] Expose UI functionality to plugins
|
- **Pointer chasing** — automatic reads of dereferenced memory regions across pointer chains
|
||||||
- [ ] iOS/macOS support
|
- **Address parser** — formula expressions like `<module.exe>+0x1A0`, pointer dereference chains, symbol resolution
|
||||||
- [ ] Display RTTI information
|
|
||||||
|
### Undo / Redo
|
||||||
|
|
||||||
|
Full command stack with 15 undoable operations: ChangeKind, Rename, Collapse, Insert, Remove, ChangeBase, WriteBytes, ChangeArrayMeta, ChangePointerRef, ChangeStructTypeName, ChangeClassKeyword, ChangeOffset, ChangeEnumMembers, ChangeOffsetExpr, ToggleStatic. Batch macro support for multi-node operations.
|
||||||
|
|
||||||
|
### Import / Export
|
||||||
|
|
||||||
|
| Format | Import | Export |
|
||||||
|
|--------|:------:|:------:|
|
||||||
|
| **Native JSON (.rcx)** | Full tree + metadata | Full tree + metadata |
|
||||||
|
| **C/C++ source** | Struct/class/union/enum parsing with offset comments | Header generation with optional static asserts |
|
||||||
|
| **ReClass XML** | Full compatibility with ReClass Classic | Full compatibility |
|
||||||
|
| **PDB symbols (Windows)** | UDT enumeration with selective recursive import via raw_pdb — no DIA SDK dependency | |
|
||||||
|
|
||||||
|
### Workspace & Navigation
|
||||||
|
|
||||||
|
- **Multi-document tabs** — MDI interface, one document per tab
|
||||||
|
- **Workspace dock** — project explorer tree with struct/enum/union icons, sorted by field count, quick navigation to members
|
||||||
|
- **Scanner dock** — integrated memory search panel
|
||||||
|
- **Dual view mode** — switch between ReClass tree view and rendered C/C++ output per tab
|
||||||
|
- **View root** — focus on a specific struct, hiding all others
|
||||||
|
- **Scroll to node** — programmatic navigation to any node by ID
|
||||||
|
|
||||||
## Data Sources
|
## Data Sources
|
||||||
|
|
||||||
- **File** — open any binary file and inspect its contents as structured data
|
- **File** — open any binary file and inspect its contents as structured data
|
||||||
- **Process** — attach to a live process and read its memory in real time
|
- **Process** — attach to a live process and read its memory in real time (Windows/Linux)
|
||||||
- **Remote Process** — read another process's memory via shared memory
|
- **Remote Process** — read another process's memory over TCP with cross-architecture 32/64-bit support
|
||||||
- **WinDbg** — load `.dmp` crash dump files or connect to live debugging sessions
|
- **WinDbg** — connect to live WinDbg debugging sessions or load crash dumps
|
||||||
|
- **Saved sources** — quick-switch between recently used data sources per tab
|
||||||
|
|
||||||
## Screenshots
|
## Plugin System
|
||||||
|
|
||||||

|
DLL plugins loaded from a `Plugins` folder, auto or manual.
|
||||||
|
|
||||||

|
**Bundled plugins:**
|
||||||
|
|
||||||

|
| Plugin | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| **Process memory** | Attach to local processes on Windows and Linux — PID-based, with symbol resolution and module/region enumeration |
|
||||||
|
| **WinDbg** | Access data from live WinDbg debugging sessions |
|
||||||
|
| **Remote process memory** | TCP RPC-based remote process access with cross-architecture support |
|
||||||
|
| **ReClass.NET compatibility** | Load existing ReClass.NET native DLL plugins directly; optional .NET CLR hosting for managed plugins |
|
||||||
|
|
||||||
## MCP Integration
|
## MCP Integration
|
||||||
|
|
||||||
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge`. The server starts automatically on launch and can be toggled from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code). A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
|
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge` — the first reverse engineering tool with native AI/LLM integration. The server uses JSON-RPC 2.0 over named pipes and can be toggled from the Tools menu or auto-started on launch.
|
||||||
|
|
||||||
|
**Available tools:**
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `projectState` | Read current tree structure, base address, tab state |
|
||||||
|
| `treeApply` | Apply structural command deltas to the node tree |
|
||||||
|
| `sourceSwitch` | Switch the active data source |
|
||||||
|
| `hexRead` | Read bytes at an address |
|
||||||
|
| `hexWrite` | Write bytes at an address |
|
||||||
|
| `statusSet` | Update the status bar text |
|
||||||
|
| `uiAction` | Trigger menu actions programmatically |
|
||||||
|
| `treeSearch` | Search nodes by name or type |
|
||||||
|
| `nodeHistory` | Query value change history for a node |
|
||||||
|
|
||||||
|
**Notifications:** `notifyTreeChanged`, `notifyDataChanged`
|
||||||
|
|
||||||
|
A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -101,6 +146,16 @@ cd Reclass
|
|||||||
|
|
||||||
The build script auto-detects your Qt install location.
|
The build script auto-detects your Qt install location.
|
||||||
|
|
||||||
|
### macOS Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build_macos.sh --qt-dir /opt/homebrew/opt/qt --build-type Release --package
|
||||||
|
```
|
||||||
|
|
||||||
|
If you installed Qt via Homebrew, `--qt-dir /opt/homebrew/opt/qt` is typical on Apple Silicon. You can also set `QTDIR` or `Qt6_DIR` instead of passing `--qt-dir`.
|
||||||
|
|
||||||
|
Note: macOS Gatekeeper may block unsigned apps. If the app won't open, go to **System Settings > Privacy & Security** and click **Open Anyway**.
|
||||||
|
|
||||||
### Manual Build (MinGW)
|
### Manual Build (MinGW)
|
||||||
|
|
||||||
1. Clone with `--recurse-submodules` (or run `git submodule update --init --recursive` after cloning)
|
1. Clone with `--recurse-submodules` (or run `git submodule update --init --recursive` after cloning)
|
||||||
@@ -122,6 +177,8 @@ The `msvc/` folder contains a ready-made solution (`Reclass.slnx`) with projects
|
|||||||
ctest --test-dir build --output-on-failure
|
ctest --test-dir build --output-on-failure
|
||||||
```
|
```
|
||||||
|
|
||||||
|
30 tests covering composition, serialization, undo/redo, import/export, provider switching, type visibility, validation, scanning, and rendering.
|
||||||
|
|
||||||
## Alternatives
|
## Alternatives
|
||||||
|
|
||||||
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)
|
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)
|
||||||
|
|||||||
29
cmake/raw_pdb_prefix.h
Normal file
29
cmake/raw_pdb_prefix.h
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Force-included before every raw_pdb translation unit (and consumers).
|
||||||
|
// PDB_CRT.h forward-declares printf/memcmp/etc with extern "C" __cdecl,
|
||||||
|
// which conflicts with MinGW's CRT headers (C++ linkage, no __cdecl).
|
||||||
|
//
|
||||||
|
// Fix: include the real CRT headers, then include PDB_CRT.h with function
|
||||||
|
// names macro-renamed to harmless dummies. This triggers #pragma once so
|
||||||
|
// no raw_pdb source file ever processes PDB_CRT.h's conflicting declarations.
|
||||||
|
//
|
||||||
|
// Guarded with __cplusplus because PUBLIC propagation applies this to C
|
||||||
|
// sources (fadec) where PDB_CRT.h is irrelevant and <cstdio> doesn't exist.
|
||||||
|
#ifdef __cplusplus
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#undef __cdecl
|
||||||
|
#define __cdecl
|
||||||
|
|
||||||
|
#define printf _pdb_crt_unused_printf
|
||||||
|
#define memcmp _pdb_crt_unused_memcmp
|
||||||
|
#define memcpy _pdb_crt_unused_memcpy
|
||||||
|
#define strlen _pdb_crt_unused_strlen
|
||||||
|
#define strcmp _pdb_crt_unused_strcmp
|
||||||
|
#include "Foundation/PDB_CRT.h"
|
||||||
|
#undef printf
|
||||||
|
#undef memcmp
|
||||||
|
#undef memcpy
|
||||||
|
#undef strlen
|
||||||
|
#undef strcmp
|
||||||
|
#endif
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 403 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 113 KiB |
@@ -66,15 +66,26 @@
|
|||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||||
<ClCompile>
|
<ClCompile>
|
||||||
<AdditionalIncludeDirectories>..\third_party\fadec\;..\third_party\raw_pdb\src\;..\third_party\qscintilla\src\;..\src\</AdditionalIncludeDirectories>
|
<AdditionalIncludeDirectories>..\third_party\fadec\;..\third_party\raw_pdb\src\;..\third_party\qscintilla\src\;..\src\</AdditionalIncludeDirectories>
|
||||||
|
<PreprocessorDefinitions>NOMINMAX;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<Link>
|
<Link>
|
||||||
<AdditionalDependencies>dwmapi.lib;dbghelp.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>dwmapi.lib;dbghelp.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
</Link>
|
</Link>
|
||||||
|
<PostBuildEvent>
|
||||||
|
<Command>$(QtToolsPath)/windeployqt $(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe</Command>
|
||||||
|
</PostBuildEvent>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||||
<Link>
|
<Link>
|
||||||
<AdditionalDependencies>dwmapi.lib;dbghelp.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>dwmapi.lib;dbghelp.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
</Link>
|
</Link>
|
||||||
|
<ClCompile>
|
||||||
|
<AdditionalIncludeDirectories>..\third_party\fadec\;..\third_party\raw_pdb\src\;..\third_party\qscintilla\src\;..\src\</AdditionalIncludeDirectories>
|
||||||
|
<PreprocessorDefinitions>NOMINMAX;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||||
|
</ClCompile>
|
||||||
|
<PostBuildEvent>
|
||||||
|
<Command>$(QtToolsPath)/windeployqt $(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe</Command>
|
||||||
|
</PostBuildEvent>
|
||||||
</ItemDefinitionGroup>
|
</ItemDefinitionGroup>
|
||||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="Configuration">
|
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="Configuration">
|
||||||
<ClCompile>
|
<ClCompile>
|
||||||
@@ -129,10 +140,12 @@
|
|||||||
<ClInclude Include="..\src\addressparser.h" />
|
<ClInclude Include="..\src\addressparser.h" />
|
||||||
<ClInclude Include="..\src\core.h" />
|
<ClInclude Include="..\src\core.h" />
|
||||||
<ClInclude Include="..\src\disasm.h" />
|
<ClInclude Include="..\src\disasm.h" />
|
||||||
|
<QtMoc Include="..\src\dock_tab_buttons.h" />
|
||||||
<ClInclude Include="..\src\generator.h" />
|
<ClInclude Include="..\src\generator.h" />
|
||||||
<ClInclude Include="..\src\iplugin.h" />
|
<ClInclude Include="..\src\iplugin.h" />
|
||||||
<ClInclude Include="..\src\pluginmanager.h" />
|
<ClInclude Include="..\src\pluginmanager.h" />
|
||||||
<ClInclude Include="..\src\providerregistry.h" />
|
<ClInclude Include="..\src\providerregistry.h" />
|
||||||
|
<QtMoc Include="..\src\startpage.h" />
|
||||||
<ClInclude Include="..\src\workspace_model.h" />
|
<ClInclude Include="..\src\workspace_model.h" />
|
||||||
<ClInclude Include="..\src\imports\export_reclass_xml.h" />
|
<ClInclude Include="..\src\imports\export_reclass_xml.h" />
|
||||||
<ClInclude Include="..\src\imports\import_pdb.h" />
|
<ClInclude Include="..\src\imports\import_pdb.h" />
|
||||||
@@ -152,7 +165,12 @@
|
|||||||
<ClCompile Include="..\src\editor.cpp" />
|
<ClCompile Include="..\src\editor.cpp" />
|
||||||
<ClCompile Include="..\src\format.cpp" />
|
<ClCompile Include="..\src\format.cpp" />
|
||||||
<ClCompile Include="..\src\generator.cpp" />
|
<ClCompile Include="..\src\generator.cpp" />
|
||||||
<ClCompile Include="..\src\main.cpp" />
|
<ClCompile Include="..\src\main.cpp">
|
||||||
|
<DynamicSource Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">input</DynamicSource>
|
||||||
|
<QtMocFileName Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">%(Filename).moc</QtMocFileName>
|
||||||
|
<DynamicSource Condition="'$(Configuration)|$(Platform)'=='Release|x64'">input</DynamicSource>
|
||||||
|
<QtMocFileName Condition="'$(Configuration)|$(Platform)'=='Release|x64'">%(Filename).moc</QtMocFileName>
|
||||||
|
</ClCompile>
|
||||||
<ClCompile Include="..\src\optionsdialog.cpp" />
|
<ClCompile Include="..\src\optionsdialog.cpp" />
|
||||||
<ClCompile Include="..\src\pluginmanager.cpp" />
|
<ClCompile Include="..\src\pluginmanager.cpp" />
|
||||||
<ClCompile Include="..\src\processpicker.cpp" />
|
<ClCompile Include="..\src\processpicker.cpp" />
|
||||||
|
|||||||
@@ -89,6 +89,12 @@
|
|||||||
<QtMoc Include="..\src\themes\thememanager.h">
|
<QtMoc Include="..\src\themes\thememanager.h">
|
||||||
<Filter>Header Files\themes</Filter>
|
<Filter>Header Files\themes</Filter>
|
||||||
</QtMoc>
|
</QtMoc>
|
||||||
|
<QtMoc Include="..\src\dock_tab_buttons.h">
|
||||||
|
<Filter>Header Files</Filter>
|
||||||
|
</QtMoc>
|
||||||
|
<QtMoc Include="..\src\startpage.h">
|
||||||
|
<Filter>Header Files</Filter>
|
||||||
|
</QtMoc>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClInclude Include="..\src\addressparser.h">
|
<ClInclude Include="..\src\addressparser.h">
|
||||||
@@ -165,9 +171,6 @@
|
|||||||
<ClCompile Include="..\src\generator.cpp">
|
<ClCompile Include="..\src\generator.cpp">
|
||||||
<Filter>Source Files</Filter>
|
<Filter>Source Files</Filter>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<ClCompile Include="..\src\main.cpp">
|
|
||||||
<Filter>Source Files</Filter>
|
|
||||||
</ClCompile>
|
|
||||||
<ClCompile Include="..\src\optionsdialog.cpp">
|
<ClCompile Include="..\src\optionsdialog.cpp">
|
||||||
<Filter>Source Files</Filter>
|
<Filter>Source Files</Filter>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
@@ -219,5 +222,8 @@
|
|||||||
<ClCompile Include="..\src\themes\thememanager.cpp">
|
<ClCompile Include="..\src\themes\thememanager.cpp">
|
||||||
<Filter>Source Files\themes</Filter>
|
<Filter>Source Files\themes</Filter>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
|
<ClCompile Include="..\src\main.cpp">
|
||||||
|
<Filter>Source Files</Filter>
|
||||||
|
</ClCompile>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
168
scripts/build_macos.sh
Executable file
168
scripts/build_macos.sh
Executable file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
print_help() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Reclass macOS Build Script
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/build_macos.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--qt-dir <path> Qt installation prefix (e.g. /opt/homebrew/opt/qt)
|
||||||
|
--build-type <type> Release | Debug | RelWithDebInfo | MinSizeRel (default: Release)
|
||||||
|
--build-dir <path> Build directory (default: <repo>/build)
|
||||||
|
--generator <name> CMake generator (default: Ninja if available)
|
||||||
|
--clean Remove build directory before configuring
|
||||||
|
--rebuild Clean then build
|
||||||
|
--package Run macdeployqt and create a zip
|
||||||
|
--tests Run ctest after build
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- You can set QTDIR or Qt6_DIR in your environment instead of --qt-dir.
|
||||||
|
- If Qt is installed via Homebrew, the script will try to detect it.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
project_root="$(cd "${script_dir}/.." && pwd)"
|
||||||
|
|
||||||
|
qt_dir=""
|
||||||
|
build_type="Release"
|
||||||
|
build_dir="${project_root}/build"
|
||||||
|
generator=""
|
||||||
|
do_clean="false"
|
||||||
|
do_package="false"
|
||||||
|
do_tests="false"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--qt-dir)
|
||||||
|
qt_dir="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--build-type)
|
||||||
|
build_type="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--build-dir)
|
||||||
|
build_dir="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--generator)
|
||||||
|
generator="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--clean)
|
||||||
|
do_clean="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--rebuild)
|
||||||
|
do_clean="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--package)
|
||||||
|
do_package="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--tests)
|
||||||
|
do_tests="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
print_help
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $1" >&2
|
||||||
|
print_help
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${qt_dir}" ]]; then
|
||||||
|
if [[ -n "${QTDIR:-}" ]]; then
|
||||||
|
qt_dir="${QTDIR}"
|
||||||
|
elif [[ -n "${Qt6_DIR:-}" ]]; then
|
||||||
|
qt_dir="${Qt6_DIR}"
|
||||||
|
elif command -v brew >/dev/null 2>&1; then
|
||||||
|
if brew --prefix qt >/dev/null 2>&1; then
|
||||||
|
qt_dir="$(brew --prefix qt)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v cmake >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: cmake not found. Install CMake and try again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${generator}" ]]; then
|
||||||
|
if command -v ninja >/dev/null 2>&1; then
|
||||||
|
generator="Ninja"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${do_clean}" == "true" && -d "${build_dir}" ]]; then
|
||||||
|
echo "Cleaning build directory: ${build_dir}"
|
||||||
|
rm -rf "${build_dir}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${build_dir}"
|
||||||
|
|
||||||
|
cmake_args=(
|
||||||
|
-S "${project_root}"
|
||||||
|
-B "${build_dir}"
|
||||||
|
-DCMAKE_BUILD_TYPE="${build_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "${generator}" ]]; then
|
||||||
|
cmake_args+=(-G "${generator}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${qt_dir}" ]]; then
|
||||||
|
export PATH="${qt_dir}/bin:${PATH}"
|
||||||
|
cmake_args+=(-DCMAKE_PREFIX_PATH="${qt_dir}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Configuring..."
|
||||||
|
cmake "${cmake_args[@]}"
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
cmake --build "${build_dir}" --config "${build_type}"
|
||||||
|
|
||||||
|
if [[ "${do_tests}" == "true" ]]; then
|
||||||
|
echo "Running tests..."
|
||||||
|
ctest --test-dir "${build_dir}" --output-on-failure -C "${build_type}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${do_package}" == "true" ]]; then
|
||||||
|
app_path="${build_dir}/Reclass.app"
|
||||||
|
if [[ ! -d "${app_path}" ]]; then
|
||||||
|
echo "ERROR: ${app_path} not found. Build may have failed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
macdeployqt_bin=""
|
||||||
|
if [[ -n "${qt_dir}" && -x "${qt_dir}/bin/macdeployqt" ]]; then
|
||||||
|
macdeployqt_bin="${qt_dir}/bin/macdeployqt"
|
||||||
|
elif command -v macdeployqt >/dev/null 2>&1; then
|
||||||
|
macdeployqt_bin="$(command -v macdeployqt)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${macdeployqt_bin}" ]]; then
|
||||||
|
echo "ERROR: macdeployqt not found. Ensure Qt is installed and in PATH." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running macdeployqt..."
|
||||||
|
"${macdeployqt_bin}" "${app_path}" -always-overwrite
|
||||||
|
|
||||||
|
arch="$(uname -m)"
|
||||||
|
zip_name="Reclass-macos-${arch}-qt6.zip"
|
||||||
|
echo "Creating zip: ${zip_name}"
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "${app_path}" "${build_dir}/${zip_name}"
|
||||||
|
echo "Packaged: ${build_dir}/${zip_name}"
|
||||||
|
fi
|
||||||
145
src/compose.cpp
145
src/compose.cpp
@@ -24,6 +24,9 @@ struct ComposeState {
|
|||||||
int offsetHexDigits = 8; // hex digit tier for offset margin
|
int offsetHexDigits = 8; // hex digit tier for offset margin
|
||||||
bool baseEmitted = false; // only first root struct shows base address
|
bool baseEmitted = false; // only first root struct shows base address
|
||||||
bool compactColumns = false; // compact column mode: cap type width, overflow long types
|
bool compactColumns = false; // compact column mode: cap type width, overflow long types
|
||||||
|
bool treeLines = false; // draw Unicode tree connectors in indentation
|
||||||
|
bool braceWrap = false; // opening brace on its own line
|
||||||
|
QVector<bool> siblingStack; // per-depth: true = more siblings follow at this level
|
||||||
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
|
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
|
||||||
|
|
||||||
// Precomputed for O(1) lookups
|
// Precomputed for O(1) lookups
|
||||||
@@ -41,6 +44,15 @@ struct ComposeState {
|
|||||||
return scopeNameW.value(scopeId, nameW);
|
return scopeNameW.value(scopeId, nameW);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set sibling-continuation flag for children at the given depth.
|
||||||
|
// childDepth is the depth of the children being iterated.
|
||||||
|
void setTreeSibling(int childDepth, bool hasMoreSiblings) {
|
||||||
|
if (!treeLines) return;
|
||||||
|
int d = childDepth - 1;
|
||||||
|
while (siblingStack.size() <= d) siblingStack.append(false);
|
||||||
|
siblingStack[d] = hasMoreSiblings;
|
||||||
|
}
|
||||||
|
|
||||||
void emitLine(const QString& lineText, LineMeta lm) {
|
void emitLine(const QString& lineText, LineMeta lm) {
|
||||||
if (currentLine > 0) text += '\n';
|
if (currentLine > 0) text += '\n';
|
||||||
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
|
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
|
||||||
@@ -52,7 +64,29 @@ struct ComposeState {
|
|||||||
text += lm.foldCollapsed ? QStringLiteral(" \u25B8 ") : QStringLiteral(" \u25BE ");
|
text += lm.foldCollapsed ? QStringLiteral(" \u25B8 ") : QStringLiteral(" \u25BE ");
|
||||||
else
|
else
|
||||||
text += QStringLiteral(" ");
|
text += QStringLiteral(" ");
|
||||||
text += lineText;
|
|
||||||
|
// Replace leading indent spaces with Unicode tree connectors
|
||||||
|
if (treeLines && lm.depth > 0) {
|
||||||
|
QString treeIndent;
|
||||||
|
int D = lm.depth;
|
||||||
|
bool isFooter = (lm.lineKind == LineKind::Footer);
|
||||||
|
for (int d = 0; d < D; d++) {
|
||||||
|
bool active = (d < siblingStack.size() && siblingStack[d]);
|
||||||
|
if (isFooter || d < D - 1) {
|
||||||
|
// Ancestor continuation or footer's own level
|
||||||
|
treeIndent += active ? QStringLiteral("\u2502 ")
|
||||||
|
: QStringLiteral(" ");
|
||||||
|
} else {
|
||||||
|
// This node's own connector (non-footer only)
|
||||||
|
treeIndent += active ? QStringLiteral("\u251C\u2500 ")
|
||||||
|
: QStringLiteral("\u2514\u2500 ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text += treeIndent + lineText.mid(D * 3);
|
||||||
|
} else {
|
||||||
|
text += lineText;
|
||||||
|
}
|
||||||
|
|
||||||
meta.append(lm);
|
meta.append(lm);
|
||||||
currentLine++;
|
currentLine++;
|
||||||
}
|
}
|
||||||
@@ -286,7 +320,24 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
lm.effectiveNameW = nameW;
|
lm.effectiveNameW = nameW;
|
||||||
headerText = fmt::fmtStructHeader(node, depth, node.collapsed, typeW, nameW, state.compactColumns);
|
headerText = fmt::fmtStructHeader(node, depth, node.collapsed, typeW, nameW, state.compactColumns);
|
||||||
}
|
}
|
||||||
state.emitLine(headerText, lm);
|
// Brace wrapping: move trailing '{' to its own line
|
||||||
|
if (state.braceWrap && !node.collapsed && headerText.endsWith(QChar('{'))) {
|
||||||
|
headerText.chop(1);
|
||||||
|
// Remove trailing separator spaces
|
||||||
|
while (headerText.endsWith(' ')) headerText.chop(1);
|
||||||
|
state.emitLine(headerText, lm);
|
||||||
|
// Emit standalone brace line
|
||||||
|
LineMeta braceLm;
|
||||||
|
braceLm.nodeIdx = nodeIdx;
|
||||||
|
braceLm.nodeId = node.id;
|
||||||
|
braceLm.depth = depth;
|
||||||
|
braceLm.lineKind = LineKind::Header;
|
||||||
|
braceLm.foldLevel = computeFoldLevel(depth, true);
|
||||||
|
braceLm.markerMask = (1u << M_STRUCT_BG);
|
||||||
|
state.emitLine(fmt::indent(depth) + QStringLiteral("{"), braceLm);
|
||||||
|
} else {
|
||||||
|
state.emitLine(headerText, lm);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.collapsed || isArrayChild || isRootHeader) {
|
if (!node.collapsed || isArrayChild || isRootHeader) {
|
||||||
@@ -305,6 +356,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (int oi = 0; oi < order.size(); oi++) {
|
for (int oi = 0; oi < order.size(); oi++) {
|
||||||
|
state.setTreeSibling(childDepth, oi < order.size() - 1);
|
||||||
int mi = order[oi];
|
int mi = order[oi];
|
||||||
const auto& m = node.enumMembers[mi];
|
const auto& m = node.enumMembers[mi];
|
||||||
LineMeta lm;
|
LineMeta lm;
|
||||||
@@ -353,6 +405,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
maxNameLen = qMax(maxNameLen, (int)m.name.size());
|
maxNameLen = qMax(maxNameLen, (int)m.name.size());
|
||||||
|
|
||||||
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
|
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
|
||||||
|
state.setTreeSibling(childDepth, mi < node.bitfieldMembers.size() - 1);
|
||||||
const auto& m = node.bitfieldMembers[mi];
|
const auto& m = node.bitfieldMembers[mi];
|
||||||
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
|
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
|
||||||
m.bitOffset, m.bitWidth);
|
m.bitOffset, m.bitWidth);
|
||||||
@@ -415,6 +468,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
int eTW = state.effectiveTypeW(node.id);
|
int eTW = state.effectiveTypeW(node.id);
|
||||||
int eNW = state.effectiveNameW(node.id);
|
int eNW = state.effectiveNameW(node.id);
|
||||||
for (int i = 0; i < node.arrayLen; i++) {
|
for (int i = 0; i < node.arrayLen; i++) {
|
||||||
|
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
|
||||||
uint64_t elemAddr = absAddr + i * elemSize;
|
uint64_t elemAddr = absAddr + i * elemSize;
|
||||||
|
|
||||||
// Type override: "float[0]", "uint32_t[1]", etc.
|
// Type override: "float[0]", "uint32_t[1]", etc.
|
||||||
@@ -460,6 +514,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
int elemSize = tree.structSpan(node.refId, &state.childMap);
|
int elemSize = tree.structSpan(node.refId, &state.childMap);
|
||||||
if (elemSize <= 0) elemSize = 1;
|
if (elemSize <= 0) elemSize = 1;
|
||||||
for (int i = 0; i < node.arrayLen; i++) {
|
for (int i = 0; i < node.arrayLen; i++) {
|
||||||
|
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
|
||||||
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
|
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
|
||||||
// Use base offset that maps refStruct's children to the right provider address
|
// Use base offset that maps refStruct's children to the right provider address
|
||||||
composeParent(state, tree, prov, refIdx, childDepth, elemBase, node.refId,
|
composeParent(state, tree, prov, refIdx, childDepth, elemBase, node.refId,
|
||||||
@@ -476,7 +531,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
const QVector<int>& refChildren = childIndices(state, node.refId);
|
const QVector<int>& refChildren = childIndices(state, node.refId);
|
||||||
// Use the referenced struct's scope widths (children come from there)
|
// Use the referenced struct's scope widths (children come from there)
|
||||||
uint64_t refScopeId = node.refId;
|
uint64_t refScopeId = node.refId;
|
||||||
for (int childIdx : refChildren) {
|
for (int rci = 0; rci < refChildren.size(); rci++) {
|
||||||
|
int childIdx = refChildren[rci];
|
||||||
|
state.setTreeSibling(childDepth, rci < refChildren.size() - 1);
|
||||||
const Node& child = tree.nodes[childIdx];
|
const Node& child = tree.nodes[childIdx];
|
||||||
// Self-referential child → show as collapsed struct (non-expandable)
|
// Self-referential child → show as collapsed struct (non-expandable)
|
||||||
if (state.visiting.contains(child.id)) {
|
if (state.visiting.contains(child.id)) {
|
||||||
@@ -514,7 +571,13 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
// For arrays, render children as condensed (no header/footer for struct elements)
|
// For arrays, render children as condensed (no header/footer for struct elements)
|
||||||
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
|
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
|
||||||
int elementIdx = 0;
|
int elementIdx = 0;
|
||||||
for (int childIdx : regular) {
|
for (int ri = 0; ri < regular.size(); ri++) {
|
||||||
|
int childIdx = regular[ri];
|
||||||
|
// A regular child has more siblings if there are more regular children
|
||||||
|
// or if static fields follow after all regular children
|
||||||
|
bool hasMore = (ri < regular.size() - 1)
|
||||||
|
|| (!staticIdxs.isEmpty() && !node.collapsed);
|
||||||
|
state.setTreeSibling(childDepth, hasMore);
|
||||||
// Pass this container's id as the scope for children (for per-scope widths)
|
// Pass this container's id as the scope for children (for per-scope widths)
|
||||||
// For array elements, also pass the element index for [N] separator
|
// For array elements, also pass the element index for [N] separator
|
||||||
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
|
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
|
||||||
@@ -569,7 +632,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
|
|
||||||
auto cbs = makeResolver(absAddr);
|
auto cbs = makeResolver(absAddr);
|
||||||
|
|
||||||
for (int si : staticIdxs) {
|
for (int sii = 0; sii < staticIdxs.size(); sii++) {
|
||||||
|
int si = staticIdxs[sii];
|
||||||
|
state.setTreeSibling(childDepth, sii < staticIdxs.size() - 1);
|
||||||
const Node& sf = tree.nodes[si];
|
const Node& sf = tree.nodes[si];
|
||||||
|
|
||||||
// Evaluate expression → absolute address
|
// Evaluate expression → absolute address
|
||||||
@@ -639,8 +704,18 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
|
|
||||||
// ── Body + children (only when expanded) ──
|
// ── Body + children (only when expanded) ──
|
||||||
if (!isCollapsed) {
|
if (!isCollapsed) {
|
||||||
|
// Determine if struct children follow the body line
|
||||||
|
bool hasStructKids = exprOk
|
||||||
|
&& (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array);
|
||||||
|
const QVector<int> staticKids = hasStructKids
|
||||||
|
? childIndices(state, sf.id) : QVector<int>();
|
||||||
|
hasStructKids = hasStructKids && !staticKids.isEmpty();
|
||||||
|
|
||||||
// Body line: " return <expr> → 0xADDR"
|
// Body line: " return <expr> → 0xADDR"
|
||||||
{
|
{
|
||||||
|
// Body has more siblings if struct children follow
|
||||||
|
state.setTreeSibling(childDepth + 1, hasStructKids);
|
||||||
|
|
||||||
QString bodyLine;
|
QString bodyLine;
|
||||||
if (!sf.offsetExpr.isEmpty()) {
|
if (!sf.offsetExpr.isEmpty()) {
|
||||||
if (exprOk)
|
if (exprOk)
|
||||||
@@ -676,10 +751,10 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If struct/array, compose children at evaluated address
|
// If struct/array, compose children at evaluated address
|
||||||
if (exprOk && (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array)) {
|
if (hasStructKids) {
|
||||||
const QVector<int>& staticKids = childIndices(state, sf.id);
|
for (int ski = 0; ski < staticKids.size(); ski++) {
|
||||||
for (int sci : staticKids) {
|
state.setTreeSibling(childDepth + 1, ski < staticKids.size() - 1);
|
||||||
composeNode(state, tree, prov, sci, childDepth + 1,
|
composeNode(state, tree, prov, staticKids[ski], childDepth + 1,
|
||||||
staticAddr, sf.id, false, sf.id);
|
staticAddr, sf.id, false, sf.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,9 +858,26 @@ void composeNode(ComposeState& state, const NodeTree& tree,
|
|||||||
lm.effectiveTypeW = ptrOverflow ? ptrTypeOverride.size() : typeW;
|
lm.effectiveTypeW = ptrOverflow ? ptrTypeOverride.size() : typeW;
|
||||||
lm.effectiveNameW = nameW;
|
lm.effectiveNameW = nameW;
|
||||||
lm.pointerTargetName = ptrTargetName;
|
lm.pointerTargetName = ptrTargetName;
|
||||||
state.emitLine(fmt::fmtPointerHeader(node, depth, effectiveCollapsed,
|
{
|
||||||
prov, absAddr, ptrTypeOverride,
|
QString ptrText = fmt::fmtPointerHeader(node, depth, effectiveCollapsed,
|
||||||
typeW, nameW, state.compactColumns), lm);
|
prov, absAddr, ptrTypeOverride,
|
||||||
|
typeW, nameW, state.compactColumns);
|
||||||
|
if (state.braceWrap && !effectiveCollapsed && ptrText.endsWith(QChar('{'))) {
|
||||||
|
ptrText.chop(1);
|
||||||
|
while (ptrText.endsWith(' ')) ptrText.chop(1);
|
||||||
|
state.emitLine(ptrText, lm);
|
||||||
|
LineMeta braceLm;
|
||||||
|
braceLm.nodeIdx = nodeIdx;
|
||||||
|
braceLm.nodeId = node.id;
|
||||||
|
braceLm.depth = depth;
|
||||||
|
braceLm.lineKind = LineKind::Header;
|
||||||
|
braceLm.foldLevel = computeFoldLevel(depth, true);
|
||||||
|
braceLm.markerMask = lm.markerMask;
|
||||||
|
state.emitLine(fmt::indent(depth) + QStringLiteral("{"), braceLm);
|
||||||
|
} else {
|
||||||
|
state.emitLine(ptrText, lm);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!effectiveCollapsed) {
|
if (!effectiveCollapsed) {
|
||||||
@@ -818,8 +910,9 @@ void composeNode(ComposeState& state, const NodeTree& tree,
|
|||||||
// Render materialized children at the pointer target address.
|
// Render materialized children at the pointer target address.
|
||||||
// These are real tree nodes with independent state — use rootId
|
// These are real tree nodes with independent state — use rootId
|
||||||
// so resolveAddr computes offsets relative to the pointer target.
|
// so resolveAddr computes offsets relative to the pointer target.
|
||||||
for (int childIdx : ptrChildren) {
|
for (int pci = 0; pci < ptrChildren.size(); pci++) {
|
||||||
composeNode(state, tree, childProv, childIdx, depth + 1,
|
state.setTreeSibling(depth + 1, pci < ptrChildren.size() - 1);
|
||||||
|
composeNode(state, tree, childProv, ptrChildren[pci], depth + 1,
|
||||||
pBase, node.id, false, node.id);
|
pBase, node.id, false, node.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -878,9 +971,11 @@ void composeNode(ComposeState& state, const NodeTree& tree,
|
|||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
|
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
|
||||||
bool compactColumns) {
|
bool compactColumns, bool treeLines, bool braceWrap) {
|
||||||
ComposeState state;
|
ComposeState state;
|
||||||
state.compactColumns = compactColumns;
|
state.compactColumns = compactColumns;
|
||||||
|
state.treeLines = treeLines;
|
||||||
|
state.braceWrap = braceWrap;
|
||||||
|
|
||||||
// Precompute parent→children map
|
// Precompute parent→children map
|
||||||
for (int i = 0; i < tree.nodes.size(); i++)
|
for (int i = 0; i < tree.nodes.size(); i++)
|
||||||
@@ -955,6 +1050,9 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
|||||||
|
|
||||||
for (int childIdx : state.childMap.value(container.id)) {
|
for (int childIdx : state.childMap.value(container.id)) {
|
||||||
const Node& child = tree.nodes[childIdx];
|
const Node& child = tree.nodes[childIdx];
|
||||||
|
// Skip struct children — pointer headers shouldn't inflate sibling widths
|
||||||
|
if (child.kind == NodeKind::Struct)
|
||||||
|
continue;
|
||||||
scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size());
|
scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size());
|
||||||
|
|
||||||
// Name width (skip hex, but include containers)
|
// Name width (skip hex, but include containers)
|
||||||
@@ -987,6 +1085,9 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
|||||||
int rootMaxName = kMinNameW;
|
int rootMaxName = kMinNameW;
|
||||||
for (int childIdx : state.childMap.value(0)) {
|
for (int childIdx : state.childMap.value(0)) {
|
||||||
const Node& child = tree.nodes[childIdx];
|
const Node& child = tree.nodes[childIdx];
|
||||||
|
// Skip struct children — pointer headers shouldn't inflate sibling widths
|
||||||
|
if (child.kind == NodeKind::Struct)
|
||||||
|
continue;
|
||||||
rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size());
|
rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size());
|
||||||
|
|
||||||
// Name width (skip hex, include containers)
|
// Name width (skip hex, include containers)
|
||||||
@@ -1017,6 +1118,18 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
|||||||
state.emitLine(cmdRowText, lm);
|
state.emitLine(cmdRowText, lm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brace wrapping: emit standalone "{" after CommandRow
|
||||||
|
if (state.braceWrap) {
|
||||||
|
LineMeta braceLm;
|
||||||
|
braceLm.nodeIdx = -1;
|
||||||
|
braceLm.nodeId = 0; // not associated with any node (no hover)
|
||||||
|
braceLm.depth = 0;
|
||||||
|
braceLm.lineKind = LineKind::Header;
|
||||||
|
braceLm.foldLevel = SC_FOLDLEVELBASE;
|
||||||
|
braceLm.markerMask = 0;
|
||||||
|
state.emitLine(QStringLiteral("{"), braceLm);
|
||||||
|
}
|
||||||
|
|
||||||
const QVector<int>& roots = childIndices(state, 0);
|
const QVector<int>& roots = childIndices(state, 0);
|
||||||
|
|
||||||
for (int idx : roots) {
|
for (int idx : roots) {
|
||||||
@@ -1026,7 +1139,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
|
|||||||
composeNode(state, tree, prov, idx, 0);
|
composeNode(state, tree, prov, idx, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress} };
|
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress, treeLines} };
|
||||||
}
|
}
|
||||||
|
|
||||||
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {
|
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {
|
||||||
|
|||||||
@@ -72,8 +72,9 @@ RcxDocument::RcxDocument(QObject* parent)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns) const {
|
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns,
|
||||||
return rcx::compose(tree, *provider, viewRootId, compactColumns);
|
bool treeLines, bool braceWrap) const {
|
||||||
|
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RcxDocument::save(const QString& path) {
|
bool RcxDocument::save(const QString& path) {
|
||||||
@@ -244,6 +245,17 @@ void RcxController::connectEditor(RcxEditor* editor) {
|
|||||||
showTypePopup(editor, mode, nodeIdx, globalPos);
|
showTypePopup(editor, mode, nodeIdx, globalPos);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Insert key shortcut
|
||||||
|
connect(editor, &RcxEditor::insertAboveRequested,
|
||||||
|
this, [this](int nodeIdx, NodeKind kind) {
|
||||||
|
if (nodeIdx >= 0)
|
||||||
|
insertNodeAbove(nodeIdx, kind, QStringLiteral("field"));
|
||||||
|
else {
|
||||||
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
|
insertNode(target, -1, kind, QStringLiteral("field"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Inline editing signals
|
// Inline editing signals
|
||||||
connect(editor, &RcxEditor::inlineEditCommitted,
|
connect(editor, &RcxEditor::inlineEditCommitted,
|
||||||
this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text,
|
this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text,
|
||||||
@@ -308,7 +320,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
|
|||||||
// Regular type change
|
// Regular type change
|
||||||
bool ok;
|
bool ok;
|
||||||
NodeKind k = kindFromTypeName(text, &ok);
|
NodeKind k = kindFromTypeName(text, &ok);
|
||||||
if (ok) {
|
if (ok && k != NodeKind::Struct && k != NodeKind::Array) {
|
||||||
changeNodeKind(nodeIdx, k);
|
changeNodeKind(nodeIdx, k);
|
||||||
} else if (nodeIdx < m_doc->tree.nodes.size()) {
|
} else if (nodeIdx < m_doc->tree.nodes.size()) {
|
||||||
// Check if it's a defined struct type name
|
// Check if it's a defined struct type name
|
||||||
@@ -535,6 +547,7 @@ void RcxController::resetChangeTracking() {
|
|||||||
m_changedOffsets.clear();
|
m_changedOffsets.clear();
|
||||||
m_valueHistory.clear();
|
m_valueHistory.clear();
|
||||||
m_prevPages.clear();
|
m_prevPages.clear();
|
||||||
|
m_valueTrackCooldown = 5; // suppress tracking for ~1s
|
||||||
for (auto& lm : m_lastResult.meta)
|
for (auto& lm : m_lastResult.meta)
|
||||||
lm.heatLevel = 0;
|
lm.heatLevel = 0;
|
||||||
}
|
}
|
||||||
@@ -545,9 +558,9 @@ void RcxController::refresh() {
|
|||||||
|
|
||||||
// Compose against snapshot provider if active, otherwise real provider
|
// Compose against snapshot provider if active, otherwise real provider
|
||||||
if (m_snapshotProv)
|
if (m_snapshotProv)
|
||||||
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns);
|
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap);
|
||||||
else
|
else
|
||||||
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns);
|
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap);
|
||||||
|
|
||||||
s_composeDoc = nullptr;
|
s_composeDoc = nullptr;
|
||||||
|
|
||||||
@@ -591,7 +604,8 @@ void RcxController::refresh() {
|
|||||||
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
|
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
|
||||||
prov = m_doc->provider.get();
|
prov = m_doc->provider.get();
|
||||||
|
|
||||||
if (m_trackValues && prov) {
|
if (m_valueTrackCooldown > 0) --m_valueTrackCooldown;
|
||||||
|
if (m_trackValues && prov && m_valueTrackCooldown <= 0) {
|
||||||
for (auto& lm : m_lastResult.meta) {
|
for (auto& lm : m_lastResult.meta) {
|
||||||
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
|
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
|
||||||
if (isSyntheticLine(lm) || lm.isContinuation) continue;
|
if (isSyntheticLine(lm) || lm.isContinuation) continue;
|
||||||
@@ -708,6 +722,15 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
|
|||||||
m_doc->undoStack.push(new RcxCommand(this,
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
cmd::ChangeKind{node.id, node.kind, newKind, {}}));
|
cmd::ChangeKind{node.id, node.kind, newKind, {}}));
|
||||||
|
|
||||||
|
// Hex nodes don't display names (ASCII preview instead), so the stored
|
||||||
|
// name may be empty or stale. Give it a sensible default.
|
||||||
|
if (isHexNode(node.kind) && !isHexNode(newKind)) {
|
||||||
|
QString autoName = QStringLiteral("field_%1")
|
||||||
|
.arg(node.offset, 4, 16, QChar('0'));
|
||||||
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
|
cmd::Rename{node.id, node.name, autoName}));
|
||||||
|
}
|
||||||
|
|
||||||
// Insert hex nodes to fill the gap (largest first for alignment)
|
// Insert hex nodes to fill the gap (largest first for alignment)
|
||||||
int padOffset = baseOffset;
|
int padOffset = baseOffset;
|
||||||
while (gap > 0) {
|
while (gap > 0) {
|
||||||
@@ -741,8 +764,19 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
|
|||||||
adjs.append({sib.id, sib.offset, sib.offset + delta});
|
adjs.append({sib.id, sib.offset, sib.offset + delta});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bool needsRename = isHexNode(node.kind) && !isHexNode(newKind);
|
||||||
|
if (needsRename) {
|
||||||
|
m_doc->undoStack.beginMacro(QStringLiteral("Change type"));
|
||||||
|
}
|
||||||
m_doc->undoStack.push(new RcxCommand(this,
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
cmd::ChangeKind{node.id, node.kind, newKind, adjs}));
|
cmd::ChangeKind{node.id, node.kind, newKind, adjs}));
|
||||||
|
if (needsRename) {
|
||||||
|
QString autoName = QStringLiteral("field_%1")
|
||||||
|
.arg(node.offset, 4, 16, QChar('0'));
|
||||||
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
|
cmd::Rename{node.id, node.name, autoName}));
|
||||||
|
m_doc->undoStack.endMacro();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -782,6 +816,31 @@ void RcxController::insertNode(uint64_t parentId, int offset, NodeKind kind, con
|
|||||||
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
|
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RcxController::insertNodeAbove(int beforeIdx, NodeKind kind, const QString& name) {
|
||||||
|
if (beforeIdx < 0 || beforeIdx >= m_doc->tree.nodes.size()) return;
|
||||||
|
const Node& before = m_doc->tree.nodes[beforeIdx];
|
||||||
|
|
||||||
|
Node n;
|
||||||
|
n.kind = kind;
|
||||||
|
n.name = name;
|
||||||
|
n.parentId = before.parentId;
|
||||||
|
n.offset = before.offset;
|
||||||
|
n.id = m_doc->tree.reserveId();
|
||||||
|
|
||||||
|
int insertSize = sizeForKind(kind);
|
||||||
|
|
||||||
|
// Shift siblings at or after the insertion offset down
|
||||||
|
QVector<cmd::OffsetAdj> adjs;
|
||||||
|
auto siblings = m_doc->tree.childrenOf(before.parentId);
|
||||||
|
for (int si : siblings) {
|
||||||
|
auto& sib = m_doc->tree.nodes[si];
|
||||||
|
if (sib.offset >= before.offset)
|
||||||
|
adjs.append({sib.id, sib.offset, sib.offset + insertSize});
|
||||||
|
}
|
||||||
|
|
||||||
|
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n, adjs}));
|
||||||
|
}
|
||||||
|
|
||||||
void RcxController::removeNode(int nodeIdx) {
|
void RcxController::removeNode(int nodeIdx) {
|
||||||
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
|
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
|
||||||
const Node& node = m_doc->tree.nodes[nodeIdx];
|
const Node& node = m_doc->tree.nodes[nodeIdx];
|
||||||
@@ -1581,6 +1640,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
} else if (commonKind == NodeKind::Hex32) {
|
} else if (commonKind == NodeKind::Hex32) {
|
||||||
menu.addAction("Change to uint32_t", [this, collectIndices]() {
|
menu.addAction("Change to uint32_t", [this, collectIndices]() {
|
||||||
batchChangeKind(collectIndices(), NodeKind::UInt32); });
|
batchChangeKind(collectIndices(), NodeKind::UInt32); });
|
||||||
|
menu.addAction("Change to float", [this, collectIndices]() {
|
||||||
|
batchChangeKind(collectIndices(), NodeKind::Float); });
|
||||||
addedQuickConvert = true;
|
addedQuickConvert = true;
|
||||||
} else if (commonKind == NodeKind::Hex16) {
|
} else if (commonKind == NodeKind::Hex16) {
|
||||||
menu.addAction("Change to int16_t", [this, collectIndices]() {
|
menu.addAction("Change to int16_t", [this, collectIndices]() {
|
||||||
@@ -1623,13 +1684,19 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
});
|
});
|
||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
||||||
|
// ── Insert ► submenu ──
|
||||||
{
|
{
|
||||||
auto* act = menu.addAction("Track Value Changes");
|
auto* insertMenu = menu.addMenu(icon("diff-added.svg"), "Insert");
|
||||||
act->setCheckable(true);
|
insertMenu->addAction("Insert 4", [this]() {
|
||||||
act->setChecked(m_trackValues);
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field"));
|
||||||
|
});
|
||||||
|
insertMenu->addAction("Insert 8", [this]() {
|
||||||
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
|
insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field"));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
menu.addSeparator();
|
|
||||||
|
|
||||||
// Check if all selected nodes share the same parent (required for grouping)
|
// Check if all selected nodes share the same parent (required for grouping)
|
||||||
{
|
{
|
||||||
@@ -1646,6 +1713,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
menu.addAction("Group into Union", [this, ids]() { groupIntoUnion(ids); });
|
menu.addAction("Group into Union", [this, ids]() { groupIntoUnion(ids); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
menu.addAction(icon("files.svg"), QString("Duplicate %1 nodes").arg(count), [this, ids]() {
|
menu.addAction(icon("files.svg"), QString("Duplicate %1 nodes").arg(count), [this, ids]() {
|
||||||
for (uint64_t id : ids) {
|
for (uint64_t id : ids) {
|
||||||
int idx = m_doc->tree.indexOfId(id);
|
int idx = m_doc->tree.indexOfId(id);
|
||||||
@@ -1658,7 +1727,35 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
||||||
menu.addAction(icon("link.svg"), "Copy &Address", [this, ids]() {
|
{
|
||||||
|
auto* act = menu.addAction("Track Value Changes");
|
||||||
|
act->setCheckable(true);
|
||||||
|
act->setChecked(m_trackValues);
|
||||||
|
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
auto* act = menu.addAction("Clear Value History");
|
||||||
|
act->setToolTip(QStringLiteral("Reset change tracking for selected nodes"));
|
||||||
|
connect(act, &QAction::triggered, this, [this, ids]() {
|
||||||
|
for (uint64_t id : ids) {
|
||||||
|
m_valueHistory.remove(id);
|
||||||
|
for (int ci : m_doc->tree.subtreeIndices(id))
|
||||||
|
m_valueHistory.remove(m_doc->tree.nodes[ci].id);
|
||||||
|
}
|
||||||
|
m_refreshGen++;
|
||||||
|
m_prevPages.clear();
|
||||||
|
m_changedOffsets.clear();
|
||||||
|
m_valueTrackCooldown = 5;
|
||||||
|
refresh();
|
||||||
|
for (auto* editor : m_editors)
|
||||||
|
editor->dismissHistoryPopup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
|
QMenu* copyMenu = menu.addMenu(icon("clippy.svg"), "Copy");
|
||||||
|
copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, ids]() {
|
||||||
QStringList addrs;
|
QStringList addrs;
|
||||||
for (uint64_t id : ids) {
|
for (uint64_t id : ids) {
|
||||||
int ni = m_doc->tree.indexOfId(id);
|
int ni = m_doc->tree.indexOfId(id);
|
||||||
@@ -1669,6 +1766,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
QApplication::clipboard()->setText(addrs.join('\n'));
|
QApplication::clipboard()->setText(addrs.join('\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
emit contextMenuAboutToShow(&menu, line);
|
||||||
menu.exec(globalPos);
|
menu.exec(globalPos);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1706,7 +1804,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
// Fall through to always-available actions
|
// Fall through to always-available actions
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// Quick-convert suggestions for Hex nodes
|
// ── Quick-convert suggestions (top-level for fast access) ──
|
||||||
bool addedQuickConvert = false;
|
bool addedQuickConvert = false;
|
||||||
if (node.kind == NodeKind::Hex64) {
|
if (node.kind == NodeKind::Hex64) {
|
||||||
menu.addAction("Change to uint64_t", [this, nodeId]() {
|
menu.addAction("Change to uint64_t", [this, nodeId]() {
|
||||||
@@ -1723,6 +1821,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
|
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
|
||||||
});
|
});
|
||||||
|
menu.addAction("Change to float", [this, nodeId]() {
|
||||||
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
|
if (ni >= 0) changeNodeKind(ni, NodeKind::Float);
|
||||||
|
});
|
||||||
addedQuickConvert = true;
|
addedQuickConvert = true;
|
||||||
} else if (node.kind == NodeKind::Hex16) {
|
} else if (node.kind == NodeKind::Hex16) {
|
||||||
menu.addAction("Change to int16_t", [this, nodeId]() {
|
menu.addAction("Change to int16_t", [this, nodeId]() {
|
||||||
@@ -1759,35 +1861,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
});
|
});
|
||||||
addedQuickConvert = true;
|
addedQuickConvert = true;
|
||||||
}
|
}
|
||||||
// "Change to ptr*" — convert hex/void-ptr to typed pointer with auto-created class
|
|
||||||
if (node.kind == NodeKind::Hex64 || node.kind == NodeKind::Hex32
|
|
||||||
|| ((node.kind == NodeKind::Pointer64 || node.kind == NodeKind::Pointer32)
|
|
||||||
&& node.refId == 0)) {
|
|
||||||
menu.addAction("Change to ptr*", [this, nodeId]() {
|
|
||||||
convertToTypedPointer(nodeId);
|
|
||||||
});
|
|
||||||
addedQuickConvert = true;
|
|
||||||
}
|
|
||||||
// Split hex node into two half-sized hex nodes
|
|
||||||
if (node.kind == NodeKind::Hex64) {
|
|
||||||
menu.addAction("Change to hex32+hex32", [this, nodeId]() {
|
|
||||||
splitHexNode(nodeId);
|
|
||||||
});
|
|
||||||
addedQuickConvert = true;
|
|
||||||
} else if (node.kind == NodeKind::Hex32) {
|
|
||||||
menu.addAction("Change to hex16+hex16", [this, nodeId]() {
|
|
||||||
splitHexNode(nodeId);
|
|
||||||
});
|
|
||||||
addedQuickConvert = true;
|
|
||||||
} else if (node.kind == NodeKind::Hex16) {
|
|
||||||
menu.addAction("Change to hex8+hex8", [this, nodeId]() {
|
|
||||||
splitHexNode(nodeId);
|
|
||||||
});
|
|
||||||
addedQuickConvert = true;
|
|
||||||
}
|
|
||||||
if (addedQuickConvert)
|
if (addedQuickConvert)
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
||||||
|
// ── Edit Value / Rename / Change Type ──
|
||||||
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
|
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
|
||||||
&& !isHexNode(node.kind)
|
&& !isHexNode(node.kind)
|
||||||
&& m_doc->provider->isWritable();
|
&& m_doc->provider->isWritable();
|
||||||
@@ -1806,152 +1883,239 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
});
|
});
|
||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
||||||
|
// ── Insert ► submenu ──
|
||||||
{
|
{
|
||||||
auto* act = menu.addAction("Track Value Changes");
|
auto* insertMenu = menu.addMenu(icon("diff-added.svg"), "Insert");
|
||||||
act->setCheckable(true);
|
insertMenu->addAction("Insert 4 Above\tShift+Ins",
|
||||||
act->setChecked(m_trackValues);
|
[this, nodeIdx]() {
|
||||||
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
insertNodeAbove(nodeIdx, NodeKind::Hex32, QStringLiteral("field"));
|
||||||
}
|
});
|
||||||
menu.addSeparator();
|
insertMenu->addAction("Insert 8 Above\tIns",
|
||||||
|
[this, nodeIdx]() {
|
||||||
|
insertNodeAbove(nodeIdx, NodeKind::Hex64, QStringLiteral("field"));
|
||||||
|
});
|
||||||
|
insertMenu->addSeparator();
|
||||||
|
insertMenu->addAction("Append bytes...", [this, &menu]() {
|
||||||
|
bool ok;
|
||||||
|
QString input = QInputDialog::getText(menu.parentWidget(),
|
||||||
|
QStringLiteral("Append bytes"),
|
||||||
|
QStringLiteral("Byte count (decimal or 0x hex):"),
|
||||||
|
QLineEdit::Normal, QStringLiteral("128"), &ok);
|
||||||
|
if (!ok || input.trimmed().isEmpty()) return;
|
||||||
|
|
||||||
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
|
QString trimmed = input.trimmed();
|
||||||
if (!isHexNode(node.kind) && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
|
int byteCount = 0;
|
||||||
menu.addAction("Convert to &Hex", [this, nodeId]() {
|
if (trimmed.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
byteCount = trimmed.mid(2).toInt(&ok, 16);
|
||||||
if (ni < 0) return;
|
else
|
||||||
const Node& n = m_doc->tree.nodes[ni];
|
byteCount = trimmed.toInt(&ok, 10);
|
||||||
int totalSize = n.byteSize();
|
if (!ok || byteCount <= 0) return;
|
||||||
if (totalSize <= 0) return;
|
|
||||||
|
|
||||||
uint64_t parentId = n.parentId;
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
int baseOffset = n.offset;
|
int hex64Count = byteCount / 8;
|
||||||
|
int remainBytes = byteCount % 8;
|
||||||
|
|
||||||
bool wasSuppressed = m_suppressRefresh;
|
|
||||||
m_suppressRefresh = true;
|
m_suppressRefresh = true;
|
||||||
m_doc->undoStack.beginMacro(QStringLiteral("Convert to Hex"));
|
m_doc->undoStack.beginMacro(QStringLiteral("Append %1 bytes").arg(byteCount));
|
||||||
|
int idx = 0;
|
||||||
// Remove the original node
|
for (int i = 0; i < hex64Count; i++, idx++)
|
||||||
QVector<Node> subtree;
|
insertNode(target, -1, NodeKind::Hex64,
|
||||||
subtree.append(n);
|
QStringLiteral("field_%1").arg(idx));
|
||||||
m_doc->undoStack.push(new RcxCommand(this,
|
for (int i = 0; i < remainBytes; i++, idx++)
|
||||||
cmd::Remove{nodeId, subtree, {}}));
|
insertNode(target, -1, NodeKind::Hex8,
|
||||||
|
QStringLiteral("field_%1").arg(idx));
|
||||||
// Insert hex nodes to fill the space (largest first)
|
|
||||||
int padOffset = baseOffset;
|
|
||||||
int gap = totalSize;
|
|
||||||
while (gap > 0) {
|
|
||||||
NodeKind padKind;
|
|
||||||
int padSize;
|
|
||||||
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
|
|
||||||
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
|
|
||||||
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
|
|
||||||
else { padKind = NodeKind::Hex8; padSize = 1; }
|
|
||||||
|
|
||||||
insertNode(parentId, padOffset, padKind,
|
|
||||||
QString("pad_%1").arg(padOffset, 2, 16, QChar('0')));
|
|
||||||
padOffset += padSize;
|
|
||||||
gap -= padSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
m_doc->undoStack.endMacro();
|
m_doc->undoStack.endMacro();
|
||||||
m_suppressRefresh = wasSuppressed;
|
m_suppressRefresh = false;
|
||||||
if (!m_suppressRefresh) refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addSeparator();
|
// ── Convert ► submenu ──
|
||||||
|
{
|
||||||
|
auto* convertMenu = menu.addMenu(icon("symbol-structure.svg"), "Convert");
|
||||||
|
bool hasConvert = false;
|
||||||
|
|
||||||
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
|
// "Change to ptr*" — convert hex/void-ptr to typed pointer
|
||||||
menu.addAction(icon("diff-added.svg"), "Add &Child", [this, nodeId]() {
|
if (node.kind == NodeKind::Hex64 || node.kind == NodeKind::Hex32
|
||||||
insertNode(nodeId, 0, NodeKind::Hex64, "newField");
|
|| ((node.kind == NodeKind::Pointer64 || node.kind == NodeKind::Pointer32)
|
||||||
});
|
&& node.refId == 0)) {
|
||||||
// Add Static Field — inserts a static field child
|
convertMenu->addAction("Change to ptr*", [this, nodeId]() {
|
||||||
menu.addAction("Add Static Field", [this, nodeId]() {
|
convertToTypedPointer(nodeId);
|
||||||
Node sf;
|
|
||||||
sf.id = m_doc->tree.m_nextId++;
|
|
||||||
sf.kind = NodeKind::Hex64;
|
|
||||||
sf.name = QStringLiteral("static_field");
|
|
||||||
sf.parentId = nodeId;
|
|
||||||
sf.offset = 0;
|
|
||||||
sf.isStatic = true;
|
|
||||||
sf.offsetExpr = QStringLiteral("base");
|
|
||||||
m_doc->undoStack.push(new RcxCommand(this,
|
|
||||||
cmd::Insert{sf, {}}));
|
|
||||||
});
|
|
||||||
if (node.collapsed) {
|
|
||||||
menu.addAction(icon("expand-all.svg"), "&Expand", [this, nodeId]() {
|
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
|
||||||
if (ni >= 0) toggleCollapse(ni);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
menu.addAction(icon("collapse-all.svg"), "&Collapse", [this, nodeId]() {
|
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
|
||||||
if (ni >= 0) toggleCollapse(ni);
|
|
||||||
});
|
});
|
||||||
|
hasConvert = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split hex node into two half-sized hex nodes
|
||||||
|
if (node.kind == NodeKind::Hex64) {
|
||||||
|
convertMenu->addAction("Split to hex32+hex32", [this, nodeId]() {
|
||||||
|
splitHexNode(nodeId);
|
||||||
|
});
|
||||||
|
hasConvert = true;
|
||||||
|
} else if (node.kind == NodeKind::Hex32) {
|
||||||
|
convertMenu->addAction("Split to hex16+hex16", [this, nodeId]() {
|
||||||
|
splitHexNode(nodeId);
|
||||||
|
});
|
||||||
|
hasConvert = true;
|
||||||
|
} else if (node.kind == NodeKind::Hex16) {
|
||||||
|
convertMenu->addAction("Split to hex8+hex8", [this, nodeId]() {
|
||||||
|
splitHexNode(nodeId);
|
||||||
|
});
|
||||||
|
hasConvert = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Hex nodes (decompose non-hex types)
|
||||||
|
if (!isHexNode(node.kind) && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
|
||||||
|
convertMenu->addAction("Convert to &Hex", [this, nodeId]() {
|
||||||
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
|
if (ni < 0) return;
|
||||||
|
const Node& n = m_doc->tree.nodes[ni];
|
||||||
|
int totalSize = n.byteSize();
|
||||||
|
if (totalSize <= 0) return;
|
||||||
|
|
||||||
|
uint64_t parentId = n.parentId;
|
||||||
|
int baseOffset = n.offset;
|
||||||
|
|
||||||
|
bool wasSuppressed = m_suppressRefresh;
|
||||||
|
m_suppressRefresh = true;
|
||||||
|
m_doc->undoStack.beginMacro(QStringLiteral("Convert to Hex"));
|
||||||
|
|
||||||
|
QVector<Node> subtree;
|
||||||
|
subtree.append(n);
|
||||||
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
|
cmd::Remove{nodeId, subtree, {}}));
|
||||||
|
|
||||||
|
int padOffset = baseOffset;
|
||||||
|
int gap = totalSize;
|
||||||
|
while (gap > 0) {
|
||||||
|
NodeKind padKind;
|
||||||
|
int padSize;
|
||||||
|
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
|
||||||
|
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
|
||||||
|
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
|
||||||
|
else { padKind = NodeKind::Hex8; padSize = 1; }
|
||||||
|
|
||||||
|
insertNode(parentId, padOffset, padKind,
|
||||||
|
QString("pad_%1").arg(padOffset, 2, 16, QChar('0')));
|
||||||
|
padOffset += padSize;
|
||||||
|
gap -= padSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_doc->undoStack.endMacro();
|
||||||
|
m_suppressRefresh = wasSuppressed;
|
||||||
|
if (!m_suppressRefresh) refresh();
|
||||||
|
});
|
||||||
|
hasConvert = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasConvert)
|
||||||
|
convertMenu->setEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Static Field as sibling (for child nodes of a struct)
|
// ── Structure ► submenu (only when relevant) ──
|
||||||
if (node.parentId != 0 && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
|
{
|
||||||
uint64_t parentId = node.parentId;
|
auto* structMenu = menu.addMenu("Static");
|
||||||
int pi = m_doc->tree.indexOfId(parentId);
|
bool hasStructAction = false;
|
||||||
if (pi >= 0 && (m_doc->tree.nodes[pi].kind == NodeKind::Struct
|
|
||||||
|| m_doc->tree.nodes[pi].kind == NodeKind::Array)) {
|
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
|
||||||
menu.addAction("Add Static Field", [this, parentId]() {
|
structMenu->addAction(icon("diff-added.svg"), "Add &Child", [this, nodeId]() {
|
||||||
|
insertNode(nodeId, 0, NodeKind::Hex64, "newField");
|
||||||
|
});
|
||||||
|
structMenu->addAction("Add Static Method (WIP)", [this, nodeId]() {
|
||||||
Node sf;
|
Node sf;
|
||||||
sf.id = m_doc->tree.m_nextId++;
|
sf.id = m_doc->tree.m_nextId++;
|
||||||
sf.kind = NodeKind::Hex64;
|
sf.kind = NodeKind::Hex64;
|
||||||
sf.name = QStringLiteral("static_field");
|
sf.name = QStringLiteral("static_field");
|
||||||
sf.parentId = parentId;
|
sf.parentId = nodeId;
|
||||||
sf.offset = 0;
|
sf.offset = 0;
|
||||||
sf.isStatic = true;
|
sf.isStatic = true;
|
||||||
sf.offsetExpr = QStringLiteral("base");
|
sf.offsetExpr = QStringLiteral("base");
|
||||||
m_doc->undoStack.push(new RcxCommand(this,
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
cmd::Insert{sf, {}}));
|
cmd::Insert{sf, {}}));
|
||||||
});
|
});
|
||||||
|
if (node.collapsed) {
|
||||||
|
structMenu->addAction(icon("expand-all.svg"), "&Expand", [this, nodeId]() {
|
||||||
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
|
if (ni >= 0) toggleCollapse(ni);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
structMenu->addAction(icon("collapse-all.svg"), "&Collapse", [this, nodeId]() {
|
||||||
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
|
if (ni >= 0) toggleCollapse(ni);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hasStructAction = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Static field: Edit Expression inline
|
// Add Static Field as sibling (for child nodes of a struct)
|
||||||
if (node.isStatic) {
|
if (node.parentId != 0 && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
|
||||||
menu.addAction("Edit E&xpression", [this, editor, line, nodeId]() {
|
uint64_t pid = node.parentId;
|
||||||
// Build completions list: "base" + sibling field names
|
int pi = m_doc->tree.indexOfId(pid);
|
||||||
QStringList completions;
|
if (pi >= 0 && (m_doc->tree.nodes[pi].kind == NodeKind::Struct
|
||||||
completions << QStringLiteral("base");
|
|| m_doc->tree.nodes[pi].kind == NodeKind::Array)) {
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
structMenu->addAction("Add Static Method (WIP)", [this, pid]() {
|
||||||
if (ni >= 0) {
|
Node sf;
|
||||||
uint64_t parentId = m_doc->tree.nodes[ni].parentId;
|
sf.id = m_doc->tree.m_nextId++;
|
||||||
for (const Node& sib : m_doc->tree.nodes) {
|
sf.kind = NodeKind::Hex64;
|
||||||
if (sib.parentId == parentId && !sib.isStatic && !sib.name.isEmpty())
|
sf.name = QStringLiteral("static_field");
|
||||||
completions << sib.name;
|
sf.parentId = pid;
|
||||||
|
sf.offset = 0;
|
||||||
|
sf.isStatic = true;
|
||||||
|
sf.offsetExpr = QStringLiteral("base");
|
||||||
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
|
cmd::Insert{sf, {}}));
|
||||||
|
});
|
||||||
|
hasStructAction = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static field: Edit Expression
|
||||||
|
if (node.isStatic) {
|
||||||
|
structMenu->addAction("Edit E&xpression", [this, editor, line, nodeId]() {
|
||||||
|
QStringList completions;
|
||||||
|
completions << QStringLiteral("base");
|
||||||
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
|
if (ni >= 0) {
|
||||||
|
uint64_t pid = m_doc->tree.nodes[ni].parentId;
|
||||||
|
for (const Node& sib : m_doc->tree.nodes) {
|
||||||
|
if (sib.parentId == pid && !sib.isStatic && !sib.name.isEmpty())
|
||||||
|
completions << sib.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor->setStaticCompletions(completions);
|
||||||
|
editor->beginInlineEdit(EditTarget::StaticExpr, line);
|
||||||
|
});
|
||||||
|
hasStructAction = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dissolve Union
|
||||||
|
{
|
||||||
|
uint64_t targetUnionId = 0;
|
||||||
|
if (node.kind == NodeKind::Struct
|
||||||
|
&& node.resolvedClassKeyword() == QStringLiteral("union")) {
|
||||||
|
targetUnionId = nodeId;
|
||||||
|
} else if (node.parentId != 0) {
|
||||||
|
int pi = m_doc->tree.indexOfId(node.parentId);
|
||||||
|
if (pi >= 0 && m_doc->tree.nodes[pi].kind == NodeKind::Struct
|
||||||
|
&& m_doc->tree.nodes[pi].resolvedClassKeyword() == QStringLiteral("union")) {
|
||||||
|
targetUnionId = node.parentId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editor->setStaticCompletions(completions);
|
if (targetUnionId != 0) {
|
||||||
editor->beginInlineEdit(EditTarget::StaticExpr, line);
|
structMenu->addAction("Dissolve Union", [this, targetUnionId]() {
|
||||||
});
|
dissolveUnion(targetUnionId);
|
||||||
}
|
});
|
||||||
|
hasStructAction = true;
|
||||||
// Dissolve Union: available on union itself or any of its children
|
|
||||||
{
|
|
||||||
uint64_t targetUnionId = 0;
|
|
||||||
if (node.kind == NodeKind::Struct
|
|
||||||
&& node.resolvedClassKeyword() == QStringLiteral("union")) {
|
|
||||||
targetUnionId = nodeId;
|
|
||||||
} else if (node.parentId != 0) {
|
|
||||||
int pi = m_doc->tree.indexOfId(node.parentId);
|
|
||||||
if (pi >= 0 && m_doc->tree.nodes[pi].kind == NodeKind::Struct
|
|
||||||
&& m_doc->tree.nodes[pi].resolvedClassKeyword() == QStringLiteral("union")) {
|
|
||||||
targetUnionId = node.parentId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (targetUnionId != 0) {
|
|
||||||
menu.addAction("Dissolve Union", [this, targetUnionId]() {
|
if (!hasStructAction)
|
||||||
dissolveUnion(targetUnionId);
|
structMenu->setEnabled(false);
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
|
// ── Duplicate / Delete ──
|
||||||
menu.addAction(icon("files.svg"), "D&uplicate\tCtrl+D", [this, nodeId]() {
|
menu.addAction(icon("files.svg"), "D&uplicate\tCtrl+D", [this, nodeId]() {
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
if (ni >= 0) duplicateNode(ni);
|
if (ni >= 0) duplicateNode(ni);
|
||||||
@@ -1963,21 +2127,29 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
||||||
menu.addAction(icon("link.svg"), "Copy &Address", [this, nodeId]() {
|
// ── Tracking ──
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
{
|
||||||
if (ni < 0) return;
|
auto* act = menu.addAction("Track Value Changes");
|
||||||
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
|
act->setCheckable(true);
|
||||||
QApplication::clipboard()->setText(
|
act->setChecked(m_trackValues);
|
||||||
QStringLiteral("0x") + QString::number(addr, 16).toUpper());
|
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
||||||
});
|
}
|
||||||
|
{
|
||||||
menu.addAction(icon("whole-word.svg"), "Copy &Offset", [this, nodeId]() {
|
auto* act = menu.addAction("Clear Value History");
|
||||||
int ni = m_doc->tree.indexOfId(nodeId);
|
act->setToolTip(QStringLiteral("Reset change tracking for this node"));
|
||||||
if (ni < 0) return;
|
connect(act, &QAction::triggered, this, [this, nodeId]() {
|
||||||
int off = m_doc->tree.nodes[ni].offset;
|
m_valueHistory.remove(nodeId);
|
||||||
QApplication::clipboard()->setText(
|
for (int ci : m_doc->tree.subtreeIndices(nodeId))
|
||||||
QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0'));
|
m_valueHistory.remove(m_doc->tree.nodes[ci].id);
|
||||||
});
|
m_refreshGen++;
|
||||||
|
m_prevPages.clear();
|
||||||
|
m_changedOffsets.clear();
|
||||||
|
m_valueTrackCooldown = 5;
|
||||||
|
refresh();
|
||||||
|
for (auto* editor : m_editors)
|
||||||
|
editor->dismissHistoryPopup();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
} // else (non-member node actions)
|
} // else (non-member node actions)
|
||||||
@@ -1985,68 +2157,80 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
|
|
||||||
// ── Always-available actions ──
|
// ── Always-available actions ──
|
||||||
|
|
||||||
// Add Static Field to current view root (struct)
|
|
||||||
if (m_viewRootId != 0) {
|
|
||||||
int ri = m_doc->tree.indexOfId(m_viewRootId);
|
|
||||||
if (ri >= 0 && (m_doc->tree.nodes[ri].kind == NodeKind::Struct
|
|
||||||
|| m_doc->tree.nodes[ri].kind == NodeKind::Array)) {
|
|
||||||
uint64_t rootId = m_viewRootId;
|
|
||||||
menu.addAction("Add Static Field", [this, rootId]() {
|
|
||||||
Node sf;
|
|
||||||
sf.id = m_doc->tree.m_nextId++;
|
|
||||||
sf.kind = NodeKind::Hex64;
|
|
||||||
sf.name = QStringLiteral("static_field");
|
|
||||||
sf.parentId = rootId;
|
|
||||||
sf.offset = 0;
|
|
||||||
sf.isStatic = true;
|
|
||||||
sf.offsetExpr = QStringLiteral("base");
|
|
||||||
m_doc->undoStack.push(new RcxCommand(this,
|
|
||||||
cmd::Insert{sf, {}}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.addAction(icon("diff-added.svg"), "Append bytes...", [this, &menu]() {
|
|
||||||
bool ok;
|
|
||||||
QString input = QInputDialog::getText(menu.parentWidget(),
|
|
||||||
QStringLiteral("Append bytes"),
|
|
||||||
QStringLiteral("Byte count (decimal or 0x hex):"),
|
|
||||||
QLineEdit::Normal, QStringLiteral("128"), &ok);
|
|
||||||
if (!ok || input.trimmed().isEmpty()) return;
|
|
||||||
|
|
||||||
QString trimmed = input.trimmed();
|
|
||||||
int byteCount = 0;
|
|
||||||
if (trimmed.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
|
|
||||||
byteCount = trimmed.mid(2).toInt(&ok, 16);
|
|
||||||
else
|
|
||||||
byteCount = trimmed.toInt(&ok, 10);
|
|
||||||
if (!ok || byteCount <= 0) return;
|
|
||||||
|
|
||||||
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
|
||||||
int hex64Count = byteCount / 8;
|
|
||||||
int remainBytes = byteCount % 8;
|
|
||||||
|
|
||||||
m_suppressRefresh = true;
|
|
||||||
m_doc->undoStack.beginMacro(QStringLiteral("Append %1 bytes").arg(byteCount));
|
|
||||||
int idx = 0;
|
|
||||||
for (int i = 0; i < hex64Count; i++, idx++)
|
|
||||||
insertNode(target, -1, NodeKind::Hex64,
|
|
||||||
QStringLiteral("field_%1").arg(idx));
|
|
||||||
for (int i = 0; i < remainBytes; i++, idx++)
|
|
||||||
insertNode(target, -1, NodeKind::Hex8,
|
|
||||||
QStringLiteral("field_%1").arg(idx));
|
|
||||||
m_doc->undoStack.endMacro();
|
|
||||||
m_suppressRefresh = false;
|
|
||||||
refresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
menu.addSeparator();
|
|
||||||
// Only add Track Value Changes here if not already added in node-specific section
|
|
||||||
if (!hasNode) {
|
if (!hasNode) {
|
||||||
|
// Insert submenu for empty area
|
||||||
|
auto* insertMenu = menu.addMenu(icon("diff-added.svg"), "Insert");
|
||||||
|
insertMenu->addAction("Insert 4", [this]() {
|
||||||
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
|
insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field"));
|
||||||
|
});
|
||||||
|
insertMenu->addAction("Insert 8", [this]() {
|
||||||
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
|
insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field"));
|
||||||
|
});
|
||||||
|
insertMenu->addSeparator();
|
||||||
|
insertMenu->addAction("Append bytes...", [this, &menu]() {
|
||||||
|
bool ok;
|
||||||
|
QString input = QInputDialog::getText(menu.parentWidget(),
|
||||||
|
QStringLiteral("Append bytes"),
|
||||||
|
QStringLiteral("Byte count (decimal or 0x hex):"),
|
||||||
|
QLineEdit::Normal, QStringLiteral("128"), &ok);
|
||||||
|
if (!ok || input.trimmed().isEmpty()) return;
|
||||||
|
|
||||||
|
QString trimmed = input.trimmed();
|
||||||
|
int byteCount = 0;
|
||||||
|
if (trimmed.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
|
||||||
|
byteCount = trimmed.mid(2).toInt(&ok, 16);
|
||||||
|
else
|
||||||
|
byteCount = trimmed.toInt(&ok, 10);
|
||||||
|
if (!ok || byteCount <= 0) return;
|
||||||
|
|
||||||
|
uint64_t target = m_viewRootId ? m_viewRootId : 0;
|
||||||
|
int hex64Count = byteCount / 8;
|
||||||
|
int remainBytes = byteCount % 8;
|
||||||
|
|
||||||
|
m_suppressRefresh = true;
|
||||||
|
m_doc->undoStack.beginMacro(QStringLiteral("Append %1 bytes").arg(byteCount));
|
||||||
|
int idx = 0;
|
||||||
|
for (int i = 0; i < hex64Count; i++, idx++)
|
||||||
|
insertNode(target, -1, NodeKind::Hex64,
|
||||||
|
QStringLiteral("field_%1").arg(idx));
|
||||||
|
for (int i = 0; i < remainBytes; i++, idx++)
|
||||||
|
insertNode(target, -1, NodeKind::Hex8,
|
||||||
|
QStringLiteral("field_%1").arg(idx));
|
||||||
|
m_doc->undoStack.endMacro();
|
||||||
|
m_suppressRefresh = false;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Static Field to current view root
|
||||||
|
if (m_viewRootId != 0) {
|
||||||
|
int ri = m_doc->tree.indexOfId(m_viewRootId);
|
||||||
|
if (ri >= 0 && (m_doc->tree.nodes[ri].kind == NodeKind::Struct
|
||||||
|
|| m_doc->tree.nodes[ri].kind == NodeKind::Array)) {
|
||||||
|
uint64_t rootId = m_viewRootId;
|
||||||
|
menu.addAction("Add Static Method (WIP)", [this, rootId]() {
|
||||||
|
Node sf;
|
||||||
|
sf.id = m_doc->tree.m_nextId++;
|
||||||
|
sf.kind = NodeKind::Hex64;
|
||||||
|
sf.name = QStringLiteral("static_field");
|
||||||
|
sf.parentId = rootId;
|
||||||
|
sf.offset = 0;
|
||||||
|
sf.isStatic = true;
|
||||||
|
sf.offsetExpr = QStringLiteral("base");
|
||||||
|
m_doc->undoStack.push(new RcxCommand(this,
|
||||||
|
cmd::Insert{sf, {}}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
auto* act = menu.addAction("Track Value Changes");
|
auto* act = menu.addAction("Track Value Changes");
|
||||||
act->setCheckable(true);
|
act->setCheckable(true);
|
||||||
act->setChecked(m_trackValues);
|
act->setChecked(m_trackValues);
|
||||||
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2059,10 +2243,47 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
|
|
||||||
menu.addSeparator();
|
menu.addSeparator();
|
||||||
|
|
||||||
menu.addAction(icon("clippy.svg"), "Copy All as Text", [editor]() {
|
QMenu* copyMenu = menu.addMenu(icon("clippy.svg"), "Copy");
|
||||||
|
if (hasNode) {
|
||||||
|
uint64_t copyNodeId = m_doc->tree.nodes[nodeIdx].id;
|
||||||
|
copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, copyNodeId]() {
|
||||||
|
int ni = m_doc->tree.indexOfId(copyNodeId);
|
||||||
|
if (ni < 0) return;
|
||||||
|
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
|
||||||
|
QApplication::clipboard()->setText(
|
||||||
|
QStringLiteral("0x") + QString::number(addr, 16).toUpper());
|
||||||
|
});
|
||||||
|
copyMenu->addAction(icon("whole-word.svg"), "Copy &Offset", [this, copyNodeId]() {
|
||||||
|
int ni = m_doc->tree.indexOfId(copyNodeId);
|
||||||
|
if (ni < 0) return;
|
||||||
|
int off = m_doc->tree.nodes[ni].offset;
|
||||||
|
QApplication::clipboard()->setText(
|
||||||
|
QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0'));
|
||||||
|
});
|
||||||
|
copyMenu->addSeparator();
|
||||||
|
}
|
||||||
|
copyMenu->addAction("Copy Line", [editor, line]() {
|
||||||
|
auto* sci = editor->scintilla();
|
||||||
|
int len = (int)sci->SendScintilla(QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line);
|
||||||
|
if (len > 0) {
|
||||||
|
QByteArray buf(len + 1, '\0');
|
||||||
|
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)line, (void*)buf.data());
|
||||||
|
QString text = QString::fromUtf8(buf.data(), len).trimmed();
|
||||||
|
if (!text.isEmpty())
|
||||||
|
QApplication::clipboard()->setText(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
copyMenu->addAction("Copy All as Text", [editor]() {
|
||||||
QApplication::clipboard()->setText(editor->textWithMargins());
|
QApplication::clipboard()->setText(editor->textWithMargins());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
menu.addSeparator();
|
||||||
|
|
||||||
|
menu.addAction(icon("search.svg"), "Search...\tCtrl+F", [editor]() {
|
||||||
|
QTimer::singleShot(0, editor, &RcxEditor::showFindBar);
|
||||||
|
});
|
||||||
|
|
||||||
|
emit contextMenuAboutToShow(&menu, line);
|
||||||
menu.exec(globalPos);
|
menu.exec(globalPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2223,6 +2444,7 @@ void RcxController::updateCommandRow() {
|
|||||||
.arg(elide(src, 40), elide(addr, 24));
|
.arg(elide(src, 40), elide(addr, 24));
|
||||||
|
|
||||||
// Build row 2: root class type + name (uses current view root)
|
// Build row 2: root class type + name (uses current view root)
|
||||||
|
QString brace = m_braceWrap ? QString() : QStringLiteral(" {");
|
||||||
QString row2;
|
QString row2;
|
||||||
if (m_viewRootId != 0) {
|
if (m_viewRootId != 0) {
|
||||||
int vi = m_doc->tree.indexOfId(m_viewRootId);
|
int vi = m_doc->tree.indexOfId(m_viewRootId);
|
||||||
@@ -2230,8 +2452,8 @@ void RcxController::updateCommandRow() {
|
|||||||
const auto& n = m_doc->tree.nodes[vi];
|
const auto& n = m_doc->tree.nodes[vi];
|
||||||
QString keyword = n.resolvedClassKeyword();
|
QString keyword = n.resolvedClassKeyword();
|
||||||
QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
|
QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
|
||||||
row2 = QStringLiteral("%1 %2 {")
|
row2 = QStringLiteral("%1 %2%3")
|
||||||
.arg(keyword, className.isEmpty() ? QStringLiteral("NoName") : className);
|
.arg(keyword, className.isEmpty() ? QStringLiteral("NoName") : className, brace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (row2.isEmpty()) {
|
if (row2.isEmpty()) {
|
||||||
@@ -2241,14 +2463,14 @@ void RcxController::updateCommandRow() {
|
|||||||
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
|
||||||
QString keyword = n.resolvedClassKeyword();
|
QString keyword = n.resolvedClassKeyword();
|
||||||
QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
|
QString className = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
|
||||||
row2 = QStringLiteral("%1 %2 {")
|
row2 = QStringLiteral("%1 %2%3")
|
||||||
.arg(keyword, className.isEmpty() ? QStringLiteral("NoName") : className);
|
.arg(keyword, className.isEmpty() ? QStringLiteral("NoName") : className, brace);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (row2.isEmpty())
|
if (row2.isEmpty())
|
||||||
row2 = QStringLiteral("struct NoName {");
|
row2 = QStringLiteral("struct NoName") + brace;
|
||||||
|
|
||||||
QString combined = QStringLiteral("[\u25B8] ") + row + QStringLiteral(" ") + row2;
|
QString combined = QStringLiteral("[\u25B8] ") + row + QStringLiteral(" ") + row2;
|
||||||
|
|
||||||
@@ -2462,7 +2684,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TypePopupMode::FieldType: {
|
case TypePopupMode::FieldType: {
|
||||||
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/false);
|
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/true);
|
||||||
bool isPtr = node
|
bool isPtr = node
|
||||||
&& (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
|
&& (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
|
||||||
bool isTypedPtr = isPtr && node->refId != 0;
|
bool isTypedPtr = isPtr && node->refId != 0;
|
||||||
@@ -2934,7 +3156,32 @@ void RcxController::selectSource(const QString& text) {
|
|||||||
m_doc->undoStack.clear();
|
m_doc->undoStack.clear();
|
||||||
m_doc->provider = std::move(provider);
|
m_doc->provider = std::move(provider);
|
||||||
m_doc->dataPath.clear();
|
m_doc->dataPath.clear();
|
||||||
m_doc->tree.baseAddress = (newBase != 0) ? newBase : m_doc->tree.baseAddress;
|
m_doc->tree.pointerSize = m_doc->provider->pointerSize();
|
||||||
|
|
||||||
|
// Re-evaluate formula if present (mirrors attachViaPlugin)
|
||||||
|
if (!m_doc->tree.baseAddressFormula.isEmpty()) {
|
||||||
|
AddressParserCallbacks cbs;
|
||||||
|
auto* prov = m_doc->provider.get();
|
||||||
|
cbs.resolveModule = [prov](const QString& name, bool* ok) -> uint64_t {
|
||||||
|
uint64_t base = prov->symbolToAddress(name);
|
||||||
|
*ok = (base != 0);
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
int ptrSz = m_doc->tree.pointerSize;
|
||||||
|
cbs.readPointer = [prov, ptrSz](uint64_t addr, bool* ok) -> uint64_t {
|
||||||
|
uint64_t val = 0;
|
||||||
|
*ok = prov->read(addr, &val, ptrSz);
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
auto result = AddressParser::evaluate(
|
||||||
|
m_doc->tree.baseAddressFormula, ptrSz, &cbs);
|
||||||
|
if (result.ok)
|
||||||
|
m_doc->tree.baseAddress = result.value;
|
||||||
|
} else if (newBase != 0 && m_doc->tree.baseAddress == 0x00400000) {
|
||||||
|
// Only apply provider base for fresh/default projects.
|
||||||
|
// If user loaded an .rcx with a custom base, preserve it.
|
||||||
|
m_doc->tree.baseAddress = newBase;
|
||||||
|
}
|
||||||
resetSnapshot();
|
resetSnapshot();
|
||||||
emit m_doc->documentChanged();
|
emit m_doc->documentChanged();
|
||||||
|
|
||||||
@@ -3010,6 +3257,16 @@ void RcxController::setCompactColumns(bool v) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RcxController::setTreeLines(bool v) {
|
||||||
|
m_treeLines = v;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RcxController::setBraceWrap(bool v) {
|
||||||
|
m_braceWrap = v;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
void RcxController::setupAutoRefresh() {
|
void RcxController::setupAutoRefresh() {
|
||||||
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
|
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
|
||||||
m_refreshTimer = new QTimer(this);
|
m_refreshTimer = new QTimer(this);
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ public:
|
|||||||
return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
|
return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
|
||||||
}
|
}
|
||||||
|
|
||||||
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false) const;
|
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false,
|
||||||
|
bool treeLines = false, bool braceWrap = false) const;
|
||||||
bool save(const QString& path);
|
bool save(const QString& path);
|
||||||
bool load(const QString& path);
|
bool load(const QString& path);
|
||||||
void loadData(const QString& binaryPath);
|
void loadData(const QString& binaryPath);
|
||||||
@@ -90,6 +91,7 @@ public:
|
|||||||
void changeNodeKind(int nodeIdx, NodeKind newKind);
|
void changeNodeKind(int nodeIdx, NodeKind newKind);
|
||||||
void renameNode(int nodeIdx, const QString& newName);
|
void renameNode(int nodeIdx, const QString& newName);
|
||||||
void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name);
|
void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name);
|
||||||
|
void insertNodeAbove(int beforeIdx, NodeKind kind, const QString& name);
|
||||||
void removeNode(int nodeIdx);
|
void removeNode(int nodeIdx);
|
||||||
void toggleCollapse(int nodeIdx);
|
void toggleCollapse(int nodeIdx);
|
||||||
void materializeRefChildren(int nodeIdx);
|
void materializeRefChildren(int nodeIdx);
|
||||||
@@ -127,6 +129,8 @@ public:
|
|||||||
void setEditorFont(const QString& fontName);
|
void setEditorFont(const QString& fontName);
|
||||||
void setRefreshInterval(int ms);
|
void setRefreshInterval(int ms);
|
||||||
void setCompactColumns(bool v);
|
void setCompactColumns(bool v);
|
||||||
|
void setTreeLines(bool v);
|
||||||
|
void setBraceWrap(bool v);
|
||||||
void resetProvider();
|
void resetProvider();
|
||||||
|
|
||||||
// MCP bridge accessors
|
// MCP bridge accessors
|
||||||
@@ -147,12 +151,15 @@ public:
|
|||||||
// Cross-tab type visibility: point at the project's full document list
|
// Cross-tab type visibility: point at the project's full document list
|
||||||
void setProjectDocuments(QVector<RcxDocument*>* docs) { m_projectDocs = docs; }
|
void setProjectDocuments(QVector<RcxDocument*>* docs) { m_projectDocs = docs; }
|
||||||
|
|
||||||
// Test accessor
|
// Test accessors
|
||||||
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
|
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
|
||||||
|
const ComposeResult& lastResult() const { return m_lastResult; }
|
||||||
|
int dataExtent() const { return computeDataExtent(); }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void nodeSelected(int nodeIdx);
|
void nodeSelected(int nodeIdx);
|
||||||
void selectionChanged(int count);
|
void selectionChanged(int count);
|
||||||
|
void contextMenuAboutToShow(QMenu* menu, int line);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
RcxDocument* m_doc;
|
RcxDocument* m_doc;
|
||||||
@@ -162,6 +169,8 @@ private:
|
|||||||
int m_anchorLine = -1;
|
int m_anchorLine = -1;
|
||||||
bool m_suppressRefresh = false;
|
bool m_suppressRefresh = false;
|
||||||
bool m_compactColumns = false;
|
bool m_compactColumns = false;
|
||||||
|
bool m_treeLines = false;
|
||||||
|
bool m_braceWrap = false;
|
||||||
uint64_t m_viewRootId = 0;
|
uint64_t m_viewRootId = 0;
|
||||||
|
|
||||||
// ── Saved sources for quick-switch ──
|
// ── Saved sources for quick-switch ──
|
||||||
@@ -181,6 +190,7 @@ private:
|
|||||||
QSet<int64_t> m_changedOffsets;
|
QSet<int64_t> m_changedOffsets;
|
||||||
QHash<uint64_t, ValueHistory> m_valueHistory;
|
QHash<uint64_t, ValueHistory> m_valueHistory;
|
||||||
bool m_trackValues = true;
|
bool m_trackValues = true;
|
||||||
|
int m_valueTrackCooldown = 0; // suppress value recording for N refresh cycles after clear
|
||||||
uint64_t m_refreshGen = 0;
|
uint64_t m_refreshGen = 0;
|
||||||
uint64_t m_readGen = 0;
|
uint64_t m_readGen = 0;
|
||||||
bool m_readInFlight = false;
|
bool m_readInFlight = false;
|
||||||
|
|||||||
39
src/core.h
39
src/core.h
@@ -11,6 +11,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <variant>
|
#include <variant>
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
#include "providers/provider.h"
|
#include "providers/provider.h"
|
||||||
#include "providers/buffer_provider.h"
|
#include "providers/buffer_provider.h"
|
||||||
@@ -85,8 +86,8 @@ inline constexpr KindMeta kKindMeta[] = {
|
|||||||
{NodeKind::Vec3, "Vec3", "vec3", 12, 1, 4, KF_Vector},
|
{NodeKind::Vec3, "Vec3", "vec3", 12, 1, 4, KF_Vector},
|
||||||
{NodeKind::Vec4, "Vec4", "vec4", 16, 1, 4, KF_Vector},
|
{NodeKind::Vec4, "Vec4", "vec4", 16, 1, 4, KF_Vector},
|
||||||
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
|
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
|
||||||
{NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String},
|
{NodeKind::UTF8, "UTF8", "str", 1, 1, 1, KF_String},
|
||||||
{NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String},
|
{NodeKind::UTF16, "UTF16", "wstr", 2, 1, 2, KF_String},
|
||||||
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
|
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
|
||||||
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
|
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
|
||||||
};
|
};
|
||||||
@@ -152,14 +153,11 @@ inline constexpr bool isValidPrimitivePtrTarget(NodeKind k) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
|
inline QStringList allTypeNamesForUI(bool /*stripBrackets*/ = false) {
|
||||||
QStringList out;
|
QStringList out;
|
||||||
out.reserve(std::size(kKindMeta));
|
out.reserve(std::size(kKindMeta));
|
||||||
for (const auto& m : kKindMeta) {
|
for (const auto& m : kKindMeta)
|
||||||
QString t = QString::fromLatin1(m.typeName);
|
out << QString::fromLatin1(m.typeName);
|
||||||
if (stripBrackets) t.remove(QStringLiteral("[]"));
|
|
||||||
out << t;
|
|
||||||
}
|
|
||||||
out.sort(Qt::CaseInsensitive);
|
out.sort(Qt::CaseInsensitive);
|
||||||
out.removeDuplicates();
|
out.removeDuplicates();
|
||||||
return out;
|
return out;
|
||||||
@@ -305,7 +303,7 @@ struct Node {
|
|||||||
QJsonObject bm = v.toObject();
|
QJsonObject bm = v.toObject();
|
||||||
BitfieldMember m;
|
BitfieldMember m;
|
||||||
m.name = bm["name"].toString();
|
m.name = bm["name"].toString();
|
||||||
m.bitOffset = (uint8_t)bm["bitOffset"].toInt(0);
|
m.bitOffset = (uint8_t)qBound(0, bm["bitOffset"].toInt(0), 255);
|
||||||
m.bitWidth = (uint8_t)qBound(1, bm["bitWidth"].toInt(1), 64);
|
m.bitWidth = (uint8_t)qBound(1, bm["bitWidth"].toInt(1), 64);
|
||||||
n.bitfieldMembers.append(m);
|
n.bitfieldMembers.append(m);
|
||||||
}
|
}
|
||||||
@@ -500,6 +498,7 @@ struct NodeTree {
|
|||||||
struct ValueHistory {
|
struct ValueHistory {
|
||||||
static constexpr int kCapacity = 10;
|
static constexpr int kCapacity = 10;
|
||||||
std::array<QString, kCapacity> values;
|
std::array<QString, kCapacity> values;
|
||||||
|
std::array<qint64, kCapacity> timestamps{}; // msec since epoch
|
||||||
int count = 0; // total unique values recorded
|
int count = 0; // total unique values recorded
|
||||||
int head = 0; // next write position in ring
|
int head = 0; // next write position in ring
|
||||||
|
|
||||||
@@ -509,10 +508,16 @@ struct ValueHistory {
|
|||||||
if (values[last] == v) return; // no change
|
if (values[last] == v) return; // no change
|
||||||
}
|
}
|
||||||
values[head] = v;
|
values[head] = v;
|
||||||
|
timestamps[head] = QDateTime::currentMSecsSinceEpoch();
|
||||||
head = (head + 1) % kCapacity;
|
head = (head + 1) % kCapacity;
|
||||||
if (count < INT_MAX) count++;
|
if (count < INT_MAX) count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
count = 0;
|
||||||
|
head = 0;
|
||||||
|
}
|
||||||
|
|
||||||
int uniqueCount() const { return qMin(count, kCapacity); }
|
int uniqueCount() const { return qMin(count, kCapacity); }
|
||||||
|
|
||||||
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
|
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
|
||||||
@@ -536,6 +541,16 @@ struct ValueHistory {
|
|||||||
for (int i = 0; i < n; i++)
|
for (int i = 0; i < n; i++)
|
||||||
fn(values[(start + i) % kCapacity]);
|
fn(values[(start + i) % kCapacity]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Iterate with timestamps from newest to oldest
|
||||||
|
template<typename Fn>
|
||||||
|
void forEachWithTime(Fn&& fn) const {
|
||||||
|
int n = uniqueCount();
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
int idx = (head + kCapacity - 1 - i) % kCapacity;
|
||||||
|
fn(values[idx], timestamps[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── LineMeta ──
|
// ── LineMeta ──
|
||||||
@@ -618,6 +633,7 @@ struct LayoutInfo {
|
|||||||
int nameW = 22; // Effective name column width (default = kColName)
|
int nameW = 22; // Effective name column width (default = kColName)
|
||||||
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
|
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
|
||||||
uint64_t baseAddress = 0; // Base address for relative offset computation
|
uint64_t baseAddress = 0; // Base address for relative offset computation
|
||||||
|
bool treeLines = false; // Whether tree line connectors are embedded in the text
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── ComposeResult ──
|
// ── ComposeResult ──
|
||||||
@@ -683,7 +699,7 @@ inline constexpr int kColValue = 96;
|
|||||||
inline constexpr int kColComment = 28; // "// Enter=Save Esc=Cancel" fits
|
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 kColBaseAddr = 12; // "0x" + up to 10 hex digits (40-bit address)
|
||||||
inline constexpr int kSepWidth = 1;
|
inline constexpr int kSepWidth = 1;
|
||||||
inline constexpr int kMinTypeW = 8; // Minimum type column width (fits "uint64_t")
|
inline constexpr int kMinTypeW = 7; // Minimum type column width (fits "uint8_t")
|
||||||
inline constexpr int kMaxTypeW = 128; // Maximum type column width
|
inline constexpr int kMaxTypeW = 128; // Maximum type column width
|
||||||
inline constexpr int kMinNameW = 8; // Minimum name column width (matches ASCII preview)
|
inline constexpr int kMinNameW = 8; // Minimum name column width (matches ASCII preview)
|
||||||
inline constexpr int kMaxNameW = 128; // Maximum name column width
|
inline constexpr int kMaxNameW = 128; // Maximum name column width
|
||||||
@@ -1015,6 +1031,7 @@ namespace fmt {
|
|||||||
// ── Compose function forward declaration ──
|
// ── Compose function forward declaration ──
|
||||||
|
|
||||||
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
|
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
|
||||||
bool compactColumns = false);
|
bool compactColumns = false, bool treeLines = false,
|
||||||
|
bool braceWrap = false);
|
||||||
|
|
||||||
} // namespace rcx
|
} // namespace rcx
|
||||||
|
|||||||
65
src/dock_tab_buttons.h
Normal file
65
src/dock_tab_buttons.h
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QIcon>
|
||||||
|
|
||||||
|
// Dock tab button widget (pin + close)
|
||||||
|
// Placed on the right side of each dock tab via QTabBar::setTabButton.
|
||||||
|
class DockTabButtons : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
QToolButton* pinBtn;
|
||||||
|
QToolButton* closeBtn;
|
||||||
|
bool pinned = false;
|
||||||
|
|
||||||
|
explicit DockTabButtons(QWidget* parent = nullptr) : QWidget(parent) {
|
||||||
|
auto* hl = new QHBoxLayout(this);
|
||||||
|
hl->setContentsMargins(0, 0, 0, 0);
|
||||||
|
hl->setSpacing(0);
|
||||||
|
|
||||||
|
pinBtn = new QToolButton(this);
|
||||||
|
pinBtn->setAutoRaise(true);
|
||||||
|
pinBtn->setCursor(Qt::PointingHandCursor);
|
||||||
|
pinBtn->setFixedSize(16, 16);
|
||||||
|
pinBtn->setToolTip("Pin tab");
|
||||||
|
updatePinIcon();
|
||||||
|
hl->addWidget(pinBtn);
|
||||||
|
|
||||||
|
closeBtn = new QToolButton(this);
|
||||||
|
closeBtn->setAutoRaise(true);
|
||||||
|
closeBtn->setCursor(Qt::PointingHandCursor);
|
||||||
|
closeBtn->setFixedSize(16, 16);
|
||||||
|
closeBtn->setToolTip("Close tab");
|
||||||
|
closeBtn->setIcon(QIcon(":/vsicons/close.svg"));
|
||||||
|
closeBtn->setIconSize(QSize(12, 12));
|
||||||
|
hl->addWidget(closeBtn);
|
||||||
|
|
||||||
|
connect(pinBtn, &QToolButton::clicked, this, [this]() {
|
||||||
|
pinned = !pinned;
|
||||||
|
updatePinIcon();
|
||||||
|
emit pinToggled(pinned);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyTheme(const QColor& hover) {
|
||||||
|
QString style = QStringLiteral(
|
||||||
|
"QToolButton { border: none; padding: 1px; border-radius: 0px; }"
|
||||||
|
"QToolButton:hover { background: %1; }").arg(hover.name());
|
||||||
|
pinBtn->setStyleSheet(style);
|
||||||
|
closeBtn->setStyleSheet(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPinned(bool p) { pinned = p; updatePinIcon(); emit pinToggled(pinned); }
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void pinToggled(bool pinned);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updatePinIcon() {
|
||||||
|
pinBtn->setIcon(QIcon(pinned ? ":/vsicons/pinned.svg" : ":/vsicons/pin.svg"));
|
||||||
|
pinBtn->setIconSize(QSize(12, 12));
|
||||||
|
pinBtn->setToolTip(pinned ? "Unpin tab" : "Pin tab");
|
||||||
|
}
|
||||||
|
};
|
||||||
373
src/editor.cpp
373
src/editor.cpp
@@ -19,8 +19,11 @@
|
|||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
#include <QLineEdit>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <algorithm>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include "themes/thememanager.h"
|
#include "themes/thememanager.h"
|
||||||
|
|
||||||
@@ -37,18 +40,30 @@ class ValueHistoryPopup : public QFrame {
|
|||||||
QStringList m_values;
|
QStringList m_values;
|
||||||
QVector<QLabel*> m_labels;
|
QVector<QLabel*> m_labels;
|
||||||
std::function<void(const QString&)> m_onSet;
|
std::function<void(const QString&)> m_onSet;
|
||||||
|
std::function<void(QMouseEvent*)> m_onMouseMove;
|
||||||
public:
|
public:
|
||||||
explicit ValueHistoryPopup(QWidget* parent)
|
explicit ValueHistoryPopup(QWidget* parent)
|
||||||
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
||||||
{
|
{
|
||||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||||
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
||||||
|
setMouseTracking(true);
|
||||||
setFrameShape(QFrame::NoFrame);
|
setFrameShape(QFrame::NoFrame);
|
||||||
setAutoFillBackground(true);
|
setAutoFillBackground(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
uint64_t nodeId() const { return m_nodeId; }
|
uint64_t nodeId() const { return m_nodeId; }
|
||||||
|
bool hasButtons() const { return m_hasButtons; }
|
||||||
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
|
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
|
||||||
|
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
|
||||||
|
protected:
|
||||||
|
void mouseMoveEvent(QMouseEvent* e) override {
|
||||||
|
if (!m_hasButtons && m_onMouseMove)
|
||||||
|
m_onMouseMove(e);
|
||||||
|
else
|
||||||
|
QFrame::mouseMoveEvent(e);
|
||||||
|
}
|
||||||
|
public:
|
||||||
|
|
||||||
void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font,
|
void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font,
|
||||||
bool showButtons = false) {
|
bool showButtons = false) {
|
||||||
@@ -102,7 +117,8 @@ public:
|
|||||||
sep->setPalette(sp);
|
sep->setPalette(sp);
|
||||||
vbox->addWidget(sep);
|
vbox->addWidget(sep);
|
||||||
|
|
||||||
for (const QString& v : vals) {
|
qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
hist.forEachWithTime([&](const QString& v, qint64 msec) {
|
||||||
auto* row = new QHBoxLayout;
|
auto* row = new QHBoxLayout;
|
||||||
row->setContentsMargins(0, 1, 0, 1);
|
row->setContentsMargins(0, 1, 0, 1);
|
||||||
row->setSpacing(8);
|
row->setSpacing(8);
|
||||||
@@ -113,6 +129,24 @@ public:
|
|||||||
row->addWidget(label, 1);
|
row->addWidget(label, 1);
|
||||||
m_labels.append(label);
|
m_labels.append(label);
|
||||||
|
|
||||||
|
if (msec > 0) {
|
||||||
|
qint64 elapsed = now - msec;
|
||||||
|
QString timeStr;
|
||||||
|
if (elapsed < 1000)
|
||||||
|
timeStr = QStringLiteral("now");
|
||||||
|
else if (elapsed < 60000)
|
||||||
|
timeStr = QStringLiteral("%1s ago").arg(elapsed / 1000);
|
||||||
|
else if (elapsed < 3600000)
|
||||||
|
timeStr = QStringLiteral("%1m ago").arg(elapsed / 60000);
|
||||||
|
else
|
||||||
|
timeStr = QStringLiteral("%1h ago").arg(elapsed / 3600000);
|
||||||
|
|
||||||
|
auto* timeLabel = new QLabel(timeStr);
|
||||||
|
timeLabel->setFont(font);
|
||||||
|
timeLabel->setStyleSheet(QStringLiteral("color: %1;").arg(theme.textDim.name()));
|
||||||
|
row->addWidget(timeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
if (showButtons) {
|
if (showButtons) {
|
||||||
auto* setBtn = new QToolButton;
|
auto* setBtn = new QToolButton;
|
||||||
setBtn->setText(QStringLiteral("Set"));
|
setBtn->setText(QStringLiteral("Set"));
|
||||||
@@ -130,12 +164,12 @@ public:
|
|||||||
row->addWidget(setBtn);
|
row->addWidget(setBtn);
|
||||||
}
|
}
|
||||||
vbox->addLayout(row);
|
vbox->addLayout(row);
|
||||||
}
|
});
|
||||||
|
|
||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showAt(const QPoint& globalPos) {
|
void showAt(const QPoint& globalPos, int lineHeight = 0) {
|
||||||
QSize sz = sizeHint();
|
QSize sz = sizeHint();
|
||||||
QRect screen = QApplication::screenAt(globalPos)
|
QRect screen = QApplication::screenAt(globalPos)
|
||||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||||
@@ -143,7 +177,7 @@ public:
|
|||||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||||
int y = globalPos.y();
|
int y = globalPos.y();
|
||||||
if (y + sz.height() > screen.bottom())
|
if (y + sz.height() > screen.bottom())
|
||||||
y = globalPos.y() - sz.height() - 4;
|
y = globalPos.y() - sz.height() - lineHeight - 4;
|
||||||
move(x, y);
|
move(x, y);
|
||||||
if (!isVisible()) show();
|
if (!isVisible()) show();
|
||||||
}
|
}
|
||||||
@@ -163,12 +197,14 @@ class DisasmPopup : public QFrame {
|
|||||||
QString m_body;
|
QString m_body;
|
||||||
QLabel* m_titleLabel = nullptr;
|
QLabel* m_titleLabel = nullptr;
|
||||||
QLabel* m_bodyLabel = nullptr;
|
QLabel* m_bodyLabel = nullptr;
|
||||||
|
std::function<void(QMouseEvent*)> m_onMouseMove;
|
||||||
public:
|
public:
|
||||||
explicit DisasmPopup(QWidget* parent)
|
explicit DisasmPopup(QWidget* parent)
|
||||||
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
||||||
{
|
{
|
||||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||||
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
||||||
|
setMouseTracking(true);
|
||||||
setFrameShape(QFrame::NoFrame);
|
setFrameShape(QFrame::NoFrame);
|
||||||
setAutoFillBackground(true);
|
setAutoFillBackground(true);
|
||||||
|
|
||||||
@@ -194,8 +230,14 @@ public:
|
|||||||
vbox->addWidget(m_bodyLabel);
|
vbox->addWidget(m_bodyLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
|
||||||
uint64_t nodeId() const { return m_nodeId; }
|
uint64_t nodeId() const { return m_nodeId; }
|
||||||
|
protected:
|
||||||
|
void mouseMoveEvent(QMouseEvent* e) override {
|
||||||
|
if (m_onMouseMove) m_onMouseMove(e);
|
||||||
|
else QFrame::mouseMoveEvent(e);
|
||||||
|
}
|
||||||
|
public:
|
||||||
void populate(uint64_t nodeId, const QString& title, const QString& body,
|
void populate(uint64_t nodeId, const QString& title, const QString& body,
|
||||||
const QFont& font) {
|
const QFont& font) {
|
||||||
if (nodeId == m_nodeId && body == m_body && isVisible())
|
if (nodeId == m_nodeId && body == m_body && isVisible())
|
||||||
@@ -236,7 +278,7 @@ public:
|
|||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showAt(const QPoint& globalPos) {
|
void showAt(const QPoint& globalPos, int lineHeight = 0) {
|
||||||
QSize sz = sizeHint();
|
QSize sz = sizeHint();
|
||||||
QRect screen = QApplication::screenAt(globalPos)
|
QRect screen = QApplication::screenAt(globalPos)
|
||||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||||
@@ -244,7 +286,7 @@ public:
|
|||||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||||
int y = globalPos.y();
|
int y = globalPos.y();
|
||||||
if (y + sz.height() > screen.bottom())
|
if (y + sz.height() > screen.bottom())
|
||||||
y = globalPos.y() - sz.height() - 4;
|
y = globalPos.y() - sz.height() - lineHeight - 4;
|
||||||
move(x, y);
|
move(x, y);
|
||||||
if (!isVisible()) show();
|
if (!isVisible()) show();
|
||||||
}
|
}
|
||||||
@@ -261,12 +303,14 @@ class StructPreviewPopup : public QFrame {
|
|||||||
QString m_body;
|
QString m_body;
|
||||||
QLabel* m_titleLabel = nullptr;
|
QLabel* m_titleLabel = nullptr;
|
||||||
QLabel* m_bodyLabel = nullptr;
|
QLabel* m_bodyLabel = nullptr;
|
||||||
|
std::function<void(QMouseEvent*)> m_onMouseMove;
|
||||||
public:
|
public:
|
||||||
explicit StructPreviewPopup(QWidget* parent)
|
explicit StructPreviewPopup(QWidget* parent)
|
||||||
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
|
||||||
{
|
{
|
||||||
setAttribute(Qt::WA_DeleteOnClose, false);
|
setAttribute(Qt::WA_DeleteOnClose, false);
|
||||||
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
setAttribute(Qt::WA_ShowWithoutActivating, true);
|
||||||
|
setMouseTracking(true);
|
||||||
setFrameShape(QFrame::NoFrame);
|
setFrameShape(QFrame::NoFrame);
|
||||||
setAutoFillBackground(true);
|
setAutoFillBackground(true);
|
||||||
|
|
||||||
@@ -293,7 +337,13 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
uint64_t nodeId() const { return m_nodeId; }
|
uint64_t nodeId() const { return m_nodeId; }
|
||||||
|
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
|
||||||
|
protected:
|
||||||
|
void mouseMoveEvent(QMouseEvent* e) override {
|
||||||
|
if (m_onMouseMove) m_onMouseMove(e);
|
||||||
|
else QFrame::mouseMoveEvent(e);
|
||||||
|
}
|
||||||
|
public:
|
||||||
void populate(uint64_t nodeId, const QString& title, const QString& body,
|
void populate(uint64_t nodeId, const QString& title, const QString& body,
|
||||||
const QFont& font) {
|
const QFont& font) {
|
||||||
if (nodeId == m_nodeId && body == m_body && isVisible())
|
if (nodeId == m_nodeId && body == m_body && isVisible())
|
||||||
@@ -333,7 +383,7 @@ public:
|
|||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showAt(const QPoint& globalPos) {
|
void showAt(const QPoint& globalPos, int lineHeight = 0) {
|
||||||
QSize sz = sizeHint();
|
QSize sz = sizeHint();
|
||||||
QRect screen = QApplication::screenAt(globalPos)
|
QRect screen = QApplication::screenAt(globalPos)
|
||||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||||
@@ -341,7 +391,7 @@ public:
|
|||||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||||
int y = globalPos.y();
|
int y = globalPos.y();
|
||||||
if (y + sz.height() > screen.bottom())
|
if (y + sz.height() > screen.bottom())
|
||||||
y = globalPos.y() - sz.height() - 4;
|
y = globalPos.y() - sz.height() - lineHeight - 4;
|
||||||
move(x, y);
|
move(x, y);
|
||||||
if (!isVisible()) show();
|
if (!isVisible()) show();
|
||||||
}
|
}
|
||||||
@@ -364,6 +414,7 @@ static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text
|
|||||||
static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode
|
static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode
|
||||||
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
|
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
|
||||||
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
|
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
|
||||||
|
static constexpr int IND_FIND = 19; // Search match highlight
|
||||||
|
|
||||||
static QString g_fontName = "JetBrains Mono";
|
static QString g_fontName = "JetBrains Mono";
|
||||||
|
|
||||||
@@ -380,6 +431,30 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
|||||||
m_sci = new QsciScintilla(this);
|
m_sci = new QsciScintilla(this);
|
||||||
layout->addWidget(m_sci);
|
layout->addWidget(m_sci);
|
||||||
|
|
||||||
|
// Find bar (hidden by default, shown with Ctrl+F)
|
||||||
|
m_findBarContainer = new QWidget(this);
|
||||||
|
auto* fbLayout = new QHBoxLayout(m_findBarContainer);
|
||||||
|
fbLayout->setContentsMargins(4, 1, 4, 1);
|
||||||
|
fbLayout->setSpacing(2);
|
||||||
|
auto* findPrevBtn = new QToolButton(m_findBarContainer);
|
||||||
|
findPrevBtn->setText(QStringLiteral("\u25C0"));
|
||||||
|
findPrevBtn->setFixedSize(24, 24);
|
||||||
|
auto* findNextBtn = new QToolButton(m_findBarContainer);
|
||||||
|
findNextBtn->setText(QStringLiteral("\u25B6"));
|
||||||
|
findNextBtn->setFixedSize(24, 24);
|
||||||
|
auto* findCloseBtn = new QToolButton(m_findBarContainer);
|
||||||
|
findCloseBtn->setText(QStringLiteral("\u2715"));
|
||||||
|
findCloseBtn->setFixedSize(24, 24);
|
||||||
|
m_findBar = new QLineEdit(m_findBarContainer);
|
||||||
|
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
|
||||||
|
m_findBar->setFixedHeight(24);
|
||||||
|
fbLayout->addWidget(findPrevBtn);
|
||||||
|
fbLayout->addWidget(findNextBtn);
|
||||||
|
fbLayout->addWidget(findCloseBtn);
|
||||||
|
fbLayout->addWidget(m_findBar);
|
||||||
|
m_findBarContainer->setVisible(false);
|
||||||
|
layout->addWidget(m_findBarContainer);
|
||||||
|
|
||||||
setupScintilla();
|
setupScintilla();
|
||||||
setupLexer();
|
setupLexer();
|
||||||
setupMargins();
|
setupMargins();
|
||||||
@@ -395,6 +470,55 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
|||||||
m_sci->viewport()->installEventFilter(this);
|
m_sci->viewport()->installEventFilter(this);
|
||||||
m_sci->viewport()->setMouseTracking(true);
|
m_sci->viewport()->setMouseTracking(true);
|
||||||
|
|
||||||
|
// Find bar: indicator-based search (selection is disabled in our Scintilla)
|
||||||
|
auto doFind = [this](bool forward) {
|
||||||
|
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (long)0, docLen);
|
||||||
|
|
||||||
|
QString text = m_findBar->text();
|
||||||
|
if (text.isEmpty()) return;
|
||||||
|
QByteArray needle = text.toUtf8();
|
||||||
|
|
||||||
|
long startPos = forward ? m_findPos : (m_findPos > 0 ? m_findPos - 1 : docLen);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, startPos);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND,
|
||||||
|
forward ? docLen : (long)0);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEARCHFLAGS, (long)0);
|
||||||
|
|
||||||
|
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
|
||||||
|
(uintptr_t)needle.size(), needle.constData());
|
||||||
|
if (pos == -1) { // wrap
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART,
|
||||||
|
forward ? (long)0 : docLen);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND,
|
||||||
|
forward ? startPos : (long)0);
|
||||||
|
pos = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
|
||||||
|
(uintptr_t)needle.size(), needle.constData());
|
||||||
|
}
|
||||||
|
if (pos >= 0) {
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, (long)needle.size());
|
||||||
|
int line = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_LINEFROMPOSITION, pos);
|
||||||
|
m_sci->ensureLineVisible(line);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
|
||||||
|
m_findPos = pos + (forward ? needle.size() : 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
connect(m_findBar, &QLineEdit::textChanged, this, [doFind]() { doFind(true); });
|
||||||
|
connect(m_findBar, &QLineEdit::returnPressed, this, [doFind]() { doFind(true); });
|
||||||
|
connect(findNextBtn, &QToolButton::clicked, this, [doFind]() { doFind(true); });
|
||||||
|
connect(findPrevBtn, &QToolButton::clicked, this, [doFind]() { doFind(false); });
|
||||||
|
connect(findCloseBtn, &QToolButton::clicked, this, [this]() { hideFindBar(); });
|
||||||
|
// Escape hides find bar
|
||||||
|
{
|
||||||
|
auto* escAction = new QAction(m_findBar);
|
||||||
|
escAction->setShortcut(QKeySequence(Qt::Key_Escape));
|
||||||
|
escAction->setShortcutContext(Qt::WidgetShortcut);
|
||||||
|
m_findBar->addAction(escAction);
|
||||||
|
connect(escAction, &QAction::triggered, this, [this]() { hideFindBar(); });
|
||||||
|
}
|
||||||
|
|
||||||
// Recalculate hover when the viewport scrolls (scrollbar drag, wheel
|
// Recalculate hover when the viewport scrolls (scrollbar drag, wheel
|
||||||
// deceleration, etc.) so the highlight tracks whatever is under the cursor.
|
// deceleration, etc.) so the highlight tracks whatever is under the cursor.
|
||||||
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
|
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
|
||||||
@@ -598,6 +722,12 @@ void RcxEditor::setupScintilla() {
|
|||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
|
||||||
IND_LOCAL_OFF, 17 /*INDIC_TEXTFORE*/);
|
IND_LOCAL_OFF, 17 /*INDIC_TEXTFORE*/);
|
||||||
|
|
||||||
|
// Find match highlight — thick underline (avoids box rendering artifacts)
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
|
||||||
|
IND_FIND, 14 /*INDIC_COMPOSITIONTHICK*/);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
|
||||||
|
IND_FIND, (long)1);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void RcxEditor::setupLexer() {
|
void RcxEditor::setupLexer() {
|
||||||
@@ -734,6 +864,8 @@ void RcxEditor::applyTheme(const Theme& theme) {
|
|||||||
IND_HINT_GREEN, theme.indHintGreen);
|
IND_HINT_GREEN, theme.indHintGreen);
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
|
||||||
IND_LOCAL_OFF, theme.textFaint);
|
IND_LOCAL_OFF, theme.textFaint);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
|
||||||
|
IND_FIND, theme.borderFocused);
|
||||||
|
|
||||||
// Lexer colors
|
// Lexer colors
|
||||||
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword);
|
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword);
|
||||||
@@ -763,7 +895,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
|
|||||||
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
|
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
|
||||||
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
|
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
|
||||||
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
|
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
|
||||||
m_sci->setMarkerForegroundColor(QColor("#ffffff"), M_ERR);
|
m_sci->setMarkerForegroundColor(theme.text, M_ERR);
|
||||||
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
|
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
|
||||||
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
|
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
|
||||||
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
|
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
|
||||||
@@ -782,6 +914,20 @@ void RcxEditor::applyTheme(const Theme& theme) {
|
|||||||
abs, theme.background);
|
abs, theme.background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find bar
|
||||||
|
if (m_findBarContainer) {
|
||||||
|
m_findBar->setStyleSheet(
|
||||||
|
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
|
||||||
|
" padding: 2px 6px; font-size: 13px; }")
|
||||||
|
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
|
||||||
|
m_findBarContainer->setStyleSheet(
|
||||||
|
QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
|
||||||
|
"QToolButton:hover { background: %4; }"
|
||||||
|
"QToolButton:pressed { background: %5; }")
|
||||||
|
.arg(theme.background.name(), theme.text.name(), theme.border.name(),
|
||||||
|
theme.hover.name(), theme.backgroundAlt.name()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void RcxEditor::applyDocument(const ComposeResult& result) {
|
void RcxEditor::applyDocument(const ComposeResult& result) {
|
||||||
@@ -821,9 +967,9 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
|
|||||||
int maxLen = 0;
|
int maxLen = 0;
|
||||||
const QStringList lines = result.text.split(QChar('\n'));
|
const QStringList lines = result.text.split(QChar('\n'));
|
||||||
for (const auto& line : lines) {
|
for (const auto& line : lines) {
|
||||||
int len = line.size();
|
int len = (int)line.size();
|
||||||
while (len > 0 && line[len - 1] == QChar(' ')) --len;
|
while (len > 0 && line[len - 1] == QChar(' ')) --len;
|
||||||
if (len > maxLen) maxLen = len;
|
maxLen = std::max(len, maxLen);
|
||||||
}
|
}
|
||||||
QFontMetrics fm(editorFont());
|
QFontMetrics fm(editorFont());
|
||||||
int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0')));
|
int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0')));
|
||||||
@@ -849,12 +995,20 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
|
|||||||
// Reset hint line - applySelectionOverlay will repaint indicators
|
// Reset hint line - applySelectionOverlay will repaint indicators
|
||||||
m_hintLine = -1;
|
m_hintLine = -1;
|
||||||
|
|
||||||
// Restore hover state
|
// Restore hover state — but clear if the node was deleted
|
||||||
m_hoveredNodeId = savedHoverId;
|
m_hoveredNodeId = savedHoverId;
|
||||||
m_hoveredLine = savedHoverLine;
|
m_hoveredLine = savedHoverLine;
|
||||||
m_hoverInside = savedHoverInside;
|
m_hoverInside = savedHoverInside;
|
||||||
m_applyingDocument = false;
|
m_applyingDocument = false;
|
||||||
|
|
||||||
|
if (m_hoveredNodeId != 0 && !m_nodeLineIndex.contains(m_hoveredNodeId)) {
|
||||||
|
m_hoveredNodeId = 0;
|
||||||
|
m_hoveredLine = -1;
|
||||||
|
dismissHistoryPopup();
|
||||||
|
if (m_disasmPopup) m_disasmPopup->hide();
|
||||||
|
if (m_structPreviewPopup) m_structPreviewPopup->hide();
|
||||||
|
}
|
||||||
|
|
||||||
// Re-apply hover markers (setText() clears all Scintilla markers).
|
// Re-apply hover markers (setText() clears all Scintilla markers).
|
||||||
// Reset m_prevHoveredNodeId so the incremental logic re-adds markers.
|
// Reset m_prevHoveredNodeId so the incremental logic re-adds markers.
|
||||||
// applyHoverCursor() is NOT called here — it evaluates hitTest() against
|
// applyHoverCursor() is NOT called here — it evaluates hitTest() against
|
||||||
@@ -863,6 +1017,27 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
|
|||||||
m_prevHoveredNodeId = 0;
|
m_prevHoveredNodeId = 0;
|
||||||
m_prevHoveredLine = -1;
|
m_prevHoveredLine = -1;
|
||||||
applyHoverHighlight();
|
applyHoverHighlight();
|
||||||
|
|
||||||
|
// Re-apply find indicator (setText() clears all indicators)
|
||||||
|
if (m_findBarContainer && m_findBarContainer->isVisible()) {
|
||||||
|
QString needle = m_findBar->text();
|
||||||
|
if (!needle.isEmpty()) {
|
||||||
|
QByteArray nb = needle.toUtf8();
|
||||||
|
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEARCHFLAGS, (long)0);
|
||||||
|
long pos = 0;
|
||||||
|
while (pos < docLen) {
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, pos);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, docLen);
|
||||||
|
long found = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
|
||||||
|
(uintptr_t)nb.size(), nb.constData());
|
||||||
|
if (found < 0) break;
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, found, (long)nb.size());
|
||||||
|
pos = found + nb.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void RcxEditor::applyMarginText(const QVector<LineMeta>& meta) {
|
void RcxEditor::applyMarginText(const QVector<LineMeta>& meta) {
|
||||||
@@ -923,6 +1098,11 @@ void RcxEditor::reformatMargins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Pass 2: inline local offsets in the text indent area ──
|
// ── Pass 2: inline local offsets in the text indent area ──
|
||||||
|
// Skip when tree lines are active — the compose step already placed
|
||||||
|
// Unicode tree connectors in the indent area; overwriting with spaces
|
||||||
|
// or offsets would destroy them.
|
||||||
|
if (m_layout.treeLines)
|
||||||
|
return;
|
||||||
m_sci->setReadOnly(false);
|
m_sci->setReadOnly(false);
|
||||||
for (int i = 0; i < m_meta.size(); i++) {
|
for (int i = 0; i < m_meta.size(); i++) {
|
||||||
const auto& lm = m_meta[i];
|
const auto& lm = m_meta[i];
|
||||||
@@ -1243,6 +1423,27 @@ int RcxEditor::currentNodeIndex() const {
|
|||||||
return lm ? lm->nodeIdx : -1;
|
return lm ? lm->nodeIdx : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RcxEditor::showFindBar() {
|
||||||
|
m_findBarContainer->setVisible(true);
|
||||||
|
m_findBar->setFocus();
|
||||||
|
m_findBar->selectAll();
|
||||||
|
m_findPos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RcxEditor::dismissHistoryPopup() {
|
||||||
|
if (m_historyPopup)
|
||||||
|
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RcxEditor::hideFindBar() {
|
||||||
|
m_findBarContainer->setVisible(false);
|
||||||
|
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (long)0, docLen);
|
||||||
|
m_findPos = 0;
|
||||||
|
m_sci->setFocus();
|
||||||
|
}
|
||||||
|
|
||||||
void RcxEditor::scrollToNodeId(uint64_t nodeId) {
|
void RcxEditor::scrollToNodeId(uint64_t nodeId) {
|
||||||
for (int i = 0; i < m_meta.size(); i++) {
|
for (int i = 0; i < m_meta.size(); i++) {
|
||||||
if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) {
|
if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) {
|
||||||
@@ -1318,7 +1519,12 @@ void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
|
|||||||
int typeW = lm.effectiveTypeW;
|
int typeW = lm.effectiveTypeW;
|
||||||
int nameW = lm.effectiveNameW;
|
int nameW = lm.effectiveNameW;
|
||||||
|
|
||||||
if (heat <= 0) continue;
|
if (heat <= 0) {
|
||||||
|
// Clear any stale heat indicators from a previous frame
|
||||||
|
for (int hi : heatIndicators)
|
||||||
|
clearIndicatorLine(hi, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
|
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
|
||||||
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
|
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
|
||||||
@@ -1810,6 +2016,10 @@ static bool hitTestTarget(QsciScintilla* sci,
|
|||||||
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
|
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
|
||||||
if (obj == m_sci && event->type() == QEvent::KeyPress) {
|
if (obj == m_sci && event->type() == QEvent::KeyPress) {
|
||||||
auto* ke = static_cast<QKeyEvent*>(event);
|
auto* ke = static_cast<QKeyEvent*>(event);
|
||||||
|
if (ke->matches(QKeySequence::Find)) {
|
||||||
|
showFindBar();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
|
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
|
||||||
if (!handled && !m_editState.active) {
|
if (!handled && !m_editState.active) {
|
||||||
// Clear hover on keyboard navigation (stale after scroll)
|
// Clear hover on keyboard navigation (stale after scroll)
|
||||||
@@ -2036,11 +2246,25 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
|
|||||||
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
|
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
|
||||||
m_hoverInside = true;
|
m_hoverInside = true;
|
||||||
} else if (event->type() == QEvent::Leave) {
|
} else if (event->type() == QEvent::Leave) {
|
||||||
m_hoverInside = false;
|
// Don't dismiss if cursor moved onto one of our own popups
|
||||||
if (!m_editState.active) {
|
QPoint globalCursor = QCursor::pos();
|
||||||
m_hoveredNodeId = 0;
|
bool onPopup = false;
|
||||||
m_hoveredLine = -1;
|
if (m_historyPopup && m_historyPopup->isVisible()
|
||||||
applyHoverHighlight();
|
&& m_historyPopup->geometry().contains(globalCursor))
|
||||||
|
onPopup = true;
|
||||||
|
if (m_disasmPopup && m_disasmPopup->isVisible()
|
||||||
|
&& m_disasmPopup->geometry().contains(globalCursor))
|
||||||
|
onPopup = true;
|
||||||
|
if (m_structPreviewPopup && m_structPreviewPopup->isVisible()
|
||||||
|
&& m_structPreviewPopup->geometry().contains(globalCursor))
|
||||||
|
onPopup = true;
|
||||||
|
if (!onPopup) {
|
||||||
|
m_hoverInside = false;
|
||||||
|
if (!m_editState.active) {
|
||||||
|
m_hoveredNodeId = 0;
|
||||||
|
m_hoveredLine = -1;
|
||||||
|
applyHoverHighlight();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (event->type() == QEvent::Wheel) {
|
} else if (event->type() == QEvent::Wheel) {
|
||||||
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
|
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
|
||||||
@@ -2085,6 +2309,12 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) {
|
|||||||
case Qt::Key_Return:
|
case Qt::Key_Return:
|
||||||
case Qt::Key_Enter:
|
case Qt::Key_Enter:
|
||||||
return beginInlineEdit(EditTarget::Value);
|
return beginInlineEdit(EditTarget::Value);
|
||||||
|
case Qt::Key_Insert:
|
||||||
|
if (ke->modifiers() & Qt::ShiftModifier)
|
||||||
|
emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex32);
|
||||||
|
else
|
||||||
|
emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex64);
|
||||||
|
return true;
|
||||||
case Qt::Key_Tab: {
|
case Qt::Key_Tab: {
|
||||||
EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value,
|
EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value,
|
||||||
EditTarget::ArrayElementType, EditTarget::ArrayElementCount,
|
EditTarget::ArrayElementType, EditTarget::ArrayElementCount,
|
||||||
@@ -2346,8 +2576,10 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
|||||||
m_editState.commentCol = -1;
|
m_editState.commentCol = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable Scintilla undo during inline edit
|
// Keep undo collection enabled during inline edit so CellBuffer::DeleteChars
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)0);
|
// returns valid text pointers (collectingUndo=false returns nullptr, which
|
||||||
|
// crashes QsciAccessibleBase::textDeleted). We clear the buffer on edit end.
|
||||||
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)1);
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1);
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1);
|
||||||
m_sci->setReadOnly(false);
|
m_sci->setReadOnly(false);
|
||||||
|
|
||||||
@@ -2798,8 +3030,26 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
if (lm.heatLevel > 0 && lm.nodeId != 0) {
|
if (lm.heatLevel > 0 && lm.nodeId != 0) {
|
||||||
auto it = m_valueHistory->find(lm.nodeId);
|
auto it = m_valueHistory->find(lm.nodeId);
|
||||||
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
|
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
|
||||||
if (!m_historyPopup)
|
if (!m_historyPopup) {
|
||||||
m_historyPopup = new ValueHistoryPopup(this);
|
m_historyPopup = new ValueHistoryPopup(this);
|
||||||
|
static_cast<ValueHistoryPopup*>(m_historyPopup)->setOnMouseMove([this](QMouseEvent* e) {
|
||||||
|
QPoint gp = e->globalPosition().toPoint();
|
||||||
|
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
|
||||||
|
m_lastHoverPos = vp;
|
||||||
|
m_hoverInside = m_sci->viewport()->rect().contains(vp);
|
||||||
|
if (!m_editState.active) {
|
||||||
|
auto h2 = hitTest(m_lastHoverPos);
|
||||||
|
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
|
||||||
|
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
|
||||||
|
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
|
||||||
|
m_hoveredNodeId = nid;
|
||||||
|
m_hoveredLine = nln;
|
||||||
|
applyHoverHighlight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyHoverCursor();
|
||||||
|
});
|
||||||
|
}
|
||||||
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
|
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
|
||||||
popup->setOnSet([this](const QString& val) {
|
popup->setOnSet([this](const QString& val) {
|
||||||
if (!m_editState.active) return;
|
if (!m_editState.active) return;
|
||||||
@@ -2818,7 +3068,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
||||||
(unsigned long)m_editState.line);
|
(unsigned long)m_editState.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showPopup = true;
|
showPopup = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2959,8 +3209,26 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
QString lineText = getLineText(m_sci, h.line);
|
QString lineText = getLineText(m_sci, h.line);
|
||||||
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
|
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
|
||||||
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
|
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
|
||||||
if (!m_historyPopup)
|
if (!m_historyPopup) {
|
||||||
m_historyPopup = new ValueHistoryPopup(this);
|
m_historyPopup = new ValueHistoryPopup(this);
|
||||||
|
static_cast<ValueHistoryPopup*>(m_historyPopup)->setOnMouseMove([this](QMouseEvent* e) {
|
||||||
|
QPoint gp = e->globalPosition().toPoint();
|
||||||
|
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
|
||||||
|
m_lastHoverPos = vp;
|
||||||
|
m_hoverInside = m_sci->viewport()->rect().contains(vp);
|
||||||
|
if (!m_editState.active) {
|
||||||
|
auto h2 = hitTest(m_lastHoverPos);
|
||||||
|
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
|
||||||
|
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
|
||||||
|
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
|
||||||
|
m_hoveredNodeId = nid;
|
||||||
|
m_hoveredLine = nln;
|
||||||
|
applyHoverHighlight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyHoverCursor();
|
||||||
|
});
|
||||||
|
}
|
||||||
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
|
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
|
||||||
popup->populate(lm.nodeId, *it, editorFont(), false);
|
popup->populate(lm.nodeId, *it, editorFont(), false);
|
||||||
long linePos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
|
long linePos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
|
||||||
@@ -2973,7 +3241,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
||||||
(unsigned long)h.line);
|
(unsigned long)h.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showPopup = true;
|
showPopup = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3044,8 +3312,26 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!body.isEmpty()) {
|
if (!body.isEmpty()) {
|
||||||
if (!m_disasmPopup)
|
if (!m_disasmPopup) {
|
||||||
m_disasmPopup = new DisasmPopup(this);
|
m_disasmPopup = new DisasmPopup(this);
|
||||||
|
static_cast<DisasmPopup*>(m_disasmPopup)->setOnMouseMove([this](QMouseEvent* e) {
|
||||||
|
QPoint gp = e->globalPosition().toPoint();
|
||||||
|
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
|
||||||
|
m_lastHoverPos = vp;
|
||||||
|
m_hoverInside = m_sci->viewport()->rect().contains(vp);
|
||||||
|
if (!m_editState.active) {
|
||||||
|
auto h2 = hitTest(m_lastHoverPos);
|
||||||
|
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
|
||||||
|
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
|
||||||
|
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
|
||||||
|
m_hoveredNodeId = nid;
|
||||||
|
m_hoveredLine = nln;
|
||||||
|
applyHoverHighlight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyHoverCursor();
|
||||||
|
});
|
||||||
|
}
|
||||||
auto* popup = static_cast<DisasmPopup*>(
|
auto* popup = static_cast<DisasmPopup*>(
|
||||||
m_disasmPopup);
|
m_disasmPopup);
|
||||||
popup->populate(lm.nodeId, title, body,
|
popup->populate(lm.nodeId, title, body,
|
||||||
@@ -3066,7 +3352,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
(unsigned long)h.line);
|
(unsigned long)h.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
||||||
QPoint(px, py + lh));
|
QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showDisasm = true;
|
showDisasm = true;
|
||||||
// Dismiss value history popup to avoid fighting
|
// Dismiss value history popup to avoid fighting
|
||||||
if (m_historyPopup && m_historyPopup->isVisible())
|
if (m_historyPopup && m_historyPopup->isVisible())
|
||||||
@@ -3113,8 +3399,26 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!body.isEmpty()) {
|
if (!body.isEmpty()) {
|
||||||
if (!m_structPreviewPopup)
|
if (!m_structPreviewPopup) {
|
||||||
m_structPreviewPopup = new StructPreviewPopup(this);
|
m_structPreviewPopup = new StructPreviewPopup(this);
|
||||||
|
static_cast<StructPreviewPopup*>(m_structPreviewPopup)->setOnMouseMove([this](QMouseEvent* e) {
|
||||||
|
QPoint gp = e->globalPosition().toPoint();
|
||||||
|
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
|
||||||
|
m_lastHoverPos = vp;
|
||||||
|
m_hoverInside = m_sci->viewport()->rect().contains(vp);
|
||||||
|
if (!m_editState.active) {
|
||||||
|
auto h2 = hitTest(m_lastHoverPos);
|
||||||
|
uint64_t nid = (m_hoverInside && h2.line >= 0) ? h2.nodeId : 0;
|
||||||
|
int nln = (m_hoverInside && h2.line >= 0) ? h2.line : -1;
|
||||||
|
if (nid != m_hoveredNodeId || nln != m_hoveredLine) {
|
||||||
|
m_hoveredNodeId = nid;
|
||||||
|
m_hoveredLine = nln;
|
||||||
|
applyHoverHighlight();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyHoverCursor();
|
||||||
|
});
|
||||||
|
}
|
||||||
auto* popup = static_cast<StructPreviewPopup*>(m_structPreviewPopup);
|
auto* popup = static_cast<StructPreviewPopup*>(m_structPreviewPopup);
|
||||||
popup->populate(lm.nodeId,
|
popup->populate(lm.nodeId,
|
||||||
lm.pointerTargetName, body, editorFont());
|
lm.pointerTargetName, body, editorFont());
|
||||||
@@ -3133,7 +3437,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
(unsigned long)h.line);
|
(unsigned long)h.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
||||||
QPoint(px, py + lh));
|
QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showPreview = true;
|
showPreview = true;
|
||||||
if (m_historyPopup && m_historyPopup->isVisible())
|
if (m_historyPopup && m_historyPopup->isVisible())
|
||||||
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||||
@@ -3259,14 +3563,8 @@ void RcxEditor::setCommandRowText(const QString& line) {
|
|||||||
long savedPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
|
long savedPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
|
||||||
long savedAnchor = m_sci->SendScintilla(QsciScintillaBase::SCI_GETANCHOR);
|
long savedAnchor = m_sci->SendScintilla(QsciScintillaBase::SCI_GETANCHOR);
|
||||||
|
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, 0);
|
|
||||||
m_sci->setReadOnly(false);
|
m_sci->setReadOnly(false);
|
||||||
|
|
||||||
// Suppress modification notifications during replace to avoid
|
|
||||||
// QScintilla accessibility crash (textDeleted called with null text).
|
|
||||||
long savedMask = m_sci->SendScintilla(QsciScintillaBase::SCI_GETMODEVENTMASK);
|
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETMODEVENTMASK, 0);
|
|
||||||
|
|
||||||
long start = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0);
|
long start = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0);
|
||||||
long end = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, 0);
|
long end = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, 0);
|
||||||
QByteArray utf8 = s.toUtf8();
|
QByteArray utf8 = s.toUtf8();
|
||||||
@@ -3275,15 +3573,12 @@ void RcxEditor::setCommandRowText(const QString& line) {
|
|||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, end);
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, end);
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData());
|
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData());
|
||||||
|
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETMODEVENTMASK, savedMask);
|
|
||||||
|
|
||||||
// Adjust saved cursor/anchor for length change in line 0
|
// Adjust saved cursor/anchor for length change in line 0
|
||||||
long delta = (long)utf8.size() - oldLen;
|
long delta = (long)utf8.size() - oldLen;
|
||||||
if (savedPos > end) savedPos += delta;
|
if (savedPos > end) savedPos += delta;
|
||||||
if (savedAnchor > end) savedAnchor += delta;
|
if (savedAnchor > end) savedAnchor += delta;
|
||||||
|
|
||||||
if (wasReadOnly) m_sci->setReadOnly(true);
|
if (wasReadOnly) m_sci->setReadOnly(true);
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, 1);
|
|
||||||
if (!wasModified) m_sci->SendScintilla(QsciScintillaBase::SCI_SETSAVEPOINT);
|
if (!wasModified) m_sci->SendScintilla(QsciScintillaBase::SCI_SETSAVEPOINT);
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCURRENTPOS, savedPos);
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCURRENTPOS, savedPos);
|
||||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETANCHOR, savedAnchor);
|
m_sci->SendScintilla(QsciScintillaBase::SCI_SETANCHOR, savedAnchor);
|
||||||
|
|||||||
12
src/editor.h
12
src/editor.h
@@ -6,6 +6,7 @@
|
|||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
|
||||||
|
class QLineEdit;
|
||||||
class QsciScintilla;
|
class QsciScintilla;
|
||||||
class QsciLexerCPP;
|
class QsciLexerCPP;
|
||||||
|
|
||||||
@@ -28,10 +29,14 @@ public:
|
|||||||
void restoreViewState(const ViewState& vs);
|
void restoreViewState(const ViewState& vs);
|
||||||
|
|
||||||
QsciScintilla* scintilla() const { return m_sci; }
|
QsciScintilla* scintilla() const { return m_sci; }
|
||||||
|
QWidget* historyPopup() const { return m_historyPopup; }
|
||||||
|
QWidget* disasmPopup() const { return m_disasmPopup; }
|
||||||
QWidget* structPreviewPopup() const { return m_structPreviewPopup; }
|
QWidget* structPreviewPopup() const { return m_structPreviewPopup; }
|
||||||
const LineMeta* metaForLine(int line) const;
|
const LineMeta* metaForLine(int line) const;
|
||||||
int currentNodeIndex() const;
|
int currentNodeIndex() const;
|
||||||
void scrollToNodeId(uint64_t nodeId);
|
void scrollToNodeId(uint64_t nodeId);
|
||||||
|
void showFindBar();
|
||||||
|
void dismissHistoryPopup();
|
||||||
|
|
||||||
// ── Column span computation ──
|
// ── Column span computation ──
|
||||||
static ColumnSpan typeSpan(const LineMeta& lm, int typeW = kColType);
|
static ColumnSpan typeSpan(const LineMeta& lm, int typeW = kColType);
|
||||||
@@ -78,6 +83,7 @@ signals:
|
|||||||
void inlineEditCancelled();
|
void inlineEditCancelled();
|
||||||
void typeSelectorRequested();
|
void typeSelectorRequested();
|
||||||
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
|
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
|
||||||
|
void insertAboveRequested(int nodeIdx, NodeKind kind);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
bool eventFilter(QObject* obj, QEvent* event) override;
|
bool eventFilter(QObject* obj, QEvent* event) override;
|
||||||
@@ -154,6 +160,12 @@ private:
|
|||||||
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
|
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
|
||||||
const NodeTree* m_disasmTree = nullptr;
|
const NodeTree* m_disasmTree = nullptr;
|
||||||
|
|
||||||
|
// ── Find bar ──
|
||||||
|
QWidget* m_findBarContainer = nullptr;
|
||||||
|
QLineEdit* m_findBar = nullptr;
|
||||||
|
long m_findPos = 0;
|
||||||
|
void hideFindBar();
|
||||||
|
|
||||||
// ── Reentrancy guards ──
|
// ── Reentrancy guards ──
|
||||||
bool m_applyingDocument = false;
|
bool m_applyingDocument = false;
|
||||||
bool m_clampingSelection = false;
|
bool m_clampingSelection = false;
|
||||||
|
|||||||
245257
src/examples/WinSDK.rcx
Normal file
245257
src/examples/WinSDK.rcx
Normal file
File diff suppressed because it is too large
Load Diff
10755
src/examples/t6zm.rcx
Normal file
10755
src/examples/t6zm.rcx
Normal file
File diff suppressed because it is too large
Load Diff
42817
src/examples/windows-x86_64.h
Normal file
42817
src/examples/windows-x86_64.h
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/icons/class.icns
Normal file
BIN
src/icons/class.icns
Normal file
Binary file not shown.
13
src/macos_titlebar.h
Normal file
13
src/macos_titlebar.h
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
namespace rcx {
|
||||||
|
|
||||||
|
struct Theme;
|
||||||
|
|
||||||
|
// Apply macOS native title bar color to match the theme.
|
||||||
|
// No-op on non-macOS platforms (implementation is platform-specific).
|
||||||
|
void applyMacTitleBarTheme(QWidget* window, const Theme& theme);
|
||||||
|
|
||||||
|
} // namespace rcx
|
||||||
43
src/macos_titlebar.mm
Normal file
43
src/macos_titlebar.mm
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
#include "macos_titlebar.h"
|
||||||
|
#include "themes/theme.h"
|
||||||
|
|
||||||
|
#import <Cocoa/Cocoa.h>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
namespace rcx {
|
||||||
|
|
||||||
|
static NSColor* toNSColor(const QColor& color) {
|
||||||
|
return [NSColor colorWithCalibratedRed:color.redF()
|
||||||
|
green:color.greenF()
|
||||||
|
blue:color.blueF()
|
||||||
|
alpha:color.alphaF()];
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyMacTitleBarTheme(QWidget* window, const Theme& theme) {
|
||||||
|
if (!window) return;
|
||||||
|
|
||||||
|
// Ensure native window is created.
|
||||||
|
window->winId();
|
||||||
|
|
||||||
|
auto* nsView = reinterpret_cast<NSView*>(window->winId());
|
||||||
|
if (!nsView) return;
|
||||||
|
|
||||||
|
NSWindow* nsWindow = [nsView window];
|
||||||
|
if (!nsWindow) return;
|
||||||
|
|
||||||
|
// Keep native traffic lights while tinting the title bar to the theme.
|
||||||
|
// Match the title text contrast by selecting the appropriate system appearance.
|
||||||
|
const qreal luminance =
|
||||||
|
0.2126 * theme.background.redF() +
|
||||||
|
0.7152 * theme.background.greenF() +
|
||||||
|
0.0722 * theme.background.blueF();
|
||||||
|
const bool isLight = luminance >= 0.5;
|
||||||
|
[nsWindow setAppearance:[NSAppearance appearanceNamed:
|
||||||
|
(isLight ? NSAppearanceNameAqua : NSAppearanceNameDarkAqua)]];
|
||||||
|
[nsWindow setTitlebarAppearsTransparent:YES];
|
||||||
|
[nsWindow setTitleVisibility:NSWindowTitleVisible];
|
||||||
|
[nsWindow setBackgroundColor:toNSColor(theme.background)];
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace rcx
|
||||||
1665
src/main.cpp
1665
src/main.cpp
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,8 @@
|
|||||||
#include "titlebar.h"
|
#include "titlebar.h"
|
||||||
#include "pluginmanager.h"
|
#include "pluginmanager.h"
|
||||||
#include "scannerpanel.h"
|
#include "scannerpanel.h"
|
||||||
|
#include "startpage.h"
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QMdiArea>
|
|
||||||
#include <QMdiSubWindow>
|
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
@@ -24,6 +23,7 @@ namespace rcx {
|
|||||||
|
|
||||||
class McpBridge;
|
class McpBridge;
|
||||||
class ShimmerLabel;
|
class ShimmerLabel;
|
||||||
|
class DockGripWidget;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow {
|
class MainWindow : public QMainWindow {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -71,34 +71,35 @@ public:
|
|||||||
void clearMcpStatus();
|
void clearMcpStatus();
|
||||||
|
|
||||||
// Project Lifecycle API
|
// Project Lifecycle API
|
||||||
QMdiSubWindow* project_new(const QString& classKeyword = QString());
|
QDockWidget* project_new(const QString& classKeyword = QString());
|
||||||
QMdiSubWindow* project_open(const QString& path = {});
|
QDockWidget* project_open(const QString& path = {});
|
||||||
bool project_save(QMdiSubWindow* sub = nullptr, bool saveAs = false);
|
bool project_save(QDockWidget* dock = nullptr, bool saveAs = false);
|
||||||
void project_close(QMdiSubWindow* sub = nullptr);
|
void project_close(QDockWidget* dock = nullptr);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
enum ViewMode { VM_Reclass, VM_Rendered };
|
enum ViewMode { VM_Reclass, VM_Rendered };
|
||||||
|
|
||||||
QMdiArea* m_mdiArea;
|
QWidget* m_centralPlaceholder;
|
||||||
ShimmerLabel* m_statusLabel;
|
ShimmerLabel* m_statusLabel;
|
||||||
QString m_appStatus;
|
QString m_appStatus;
|
||||||
bool m_mcpBusy = false;
|
bool m_mcpBusy = false;
|
||||||
QTimer* m_mcpClearTimer = nullptr;
|
QTimer* m_mcpClearTimer = nullptr;
|
||||||
QButtonGroup* m_viewBtnGroup = nullptr;
|
|
||||||
QPushButton* m_btnReclass = nullptr;
|
|
||||||
QPushButton* m_btnRendered = nullptr;
|
|
||||||
TitleBarWidget* m_titleBar = nullptr;
|
TitleBarWidget* m_titleBar = nullptr;
|
||||||
|
QMenuBar* m_menuBar = nullptr;
|
||||||
|
bool m_menuBarTitleCase = false;
|
||||||
QWidget* m_borderOverlay = nullptr;
|
QWidget* m_borderOverlay = nullptr;
|
||||||
PluginManager m_pluginManager;
|
PluginManager m_pluginManager;
|
||||||
McpBridge* m_mcp = nullptr;
|
McpBridge* m_mcp = nullptr;
|
||||||
QAction* m_mcpAction = nullptr;
|
QAction* m_mcpAction = nullptr;
|
||||||
QMenu* m_sourceMenu = nullptr;
|
QMenu* m_sourceMenu = nullptr;
|
||||||
|
QMenu* m_recentFilesMenu = nullptr;
|
||||||
|
|
||||||
struct SplitPane {
|
struct SplitPane {
|
||||||
QTabWidget* tabWidget = nullptr;
|
QTabWidget* tabWidget = nullptr;
|
||||||
RcxEditor* editor = nullptr;
|
RcxEditor* editor = nullptr;
|
||||||
QsciScintilla* rendered = nullptr;
|
QsciScintilla* rendered = nullptr;
|
||||||
QLineEdit* findBar = nullptr;
|
QLineEdit* findBar = nullptr;
|
||||||
|
QWidget* findContainer = nullptr;
|
||||||
QWidget* renderedContainer = nullptr;
|
QWidget* renderedContainer = nullptr;
|
||||||
ViewMode viewMode = VM_Reclass;
|
ViewMode viewMode = VM_Reclass;
|
||||||
uint64_t lastRenderedRootId = 0;
|
uint64_t lastRenderedRootId = 0;
|
||||||
@@ -111,22 +112,29 @@ private:
|
|||||||
QVector<SplitPane> panes;
|
QVector<SplitPane> panes;
|
||||||
int activePaneIdx = 0;
|
int activePaneIdx = 0;
|
||||||
};
|
};
|
||||||
QMap<QMdiSubWindow*, TabState> m_tabs;
|
QMap<QDockWidget*, TabState> m_tabs;
|
||||||
|
QVector<QDockWidget*> m_docDocks; // ordered list for tabByIndex
|
||||||
|
QDockWidget* m_activeDocDock = nullptr; // tracks active document dock
|
||||||
QVector<RcxDocument*> m_allDocs; // all open docs, shared with controllers
|
QVector<RcxDocument*> m_allDocs; // all open docs, shared with controllers
|
||||||
void rebuildAllDocs();
|
void rebuildAllDocs();
|
||||||
|
|
||||||
void createMenus();
|
void createMenus();
|
||||||
|
void applyMenuBarTitleCase(bool titleCase);
|
||||||
void createStatusBar();
|
void createStatusBar();
|
||||||
void showPluginsDialog();
|
void showPluginsDialog();
|
||||||
void populateSourceMenu();
|
void populateSourceMenu();
|
||||||
|
void addRecentFile(const QString& path);
|
||||||
|
void updateRecentFilesMenu();
|
||||||
QIcon makeIcon(const QString& svgPath);
|
QIcon makeIcon(const QString& svgPath);
|
||||||
|
|
||||||
RcxController* activeController() const;
|
RcxController* activeController() const;
|
||||||
TabState* activeTab();
|
TabState* activeTab();
|
||||||
TabState* tabByIndex(int index);
|
TabState* tabByIndex(int index);
|
||||||
int tabCount() const { return m_tabs.size(); }
|
int tabCount() const { return m_tabs.size(); }
|
||||||
QMdiSubWindow* createTab(RcxDocument* doc);
|
QDockWidget* createTab(RcxDocument* doc);
|
||||||
|
void setupDockTabBars();
|
||||||
void updateWindowTitle();
|
void updateWindowTitle();
|
||||||
|
void closeAllDocDocks();
|
||||||
|
|
||||||
void setViewMode(ViewMode mode);
|
void setViewMode(ViewMode mode);
|
||||||
void updateRenderedView(TabState& tab, SplitPane& pane);
|
void updateRenderedView(TabState& tab, SplitPane& pane);
|
||||||
@@ -136,7 +144,6 @@ private:
|
|||||||
|
|
||||||
SplitPane createSplitPane(TabState& tab);
|
SplitPane createSplitPane(TabState& tab);
|
||||||
void applyTheme(const Theme& theme);
|
void applyTheme(const Theme& theme);
|
||||||
void styleTabCloseButtons();
|
|
||||||
void syncViewButtons(ViewMode mode);
|
void syncViewButtons(ViewMode mode);
|
||||||
SplitPane* findPaneByTabWidget(QTabWidget* tw);
|
SplitPane* findPaneByTabWidget(QTabWidget* tw);
|
||||||
SplitPane* findActiveSplitPane();
|
SplitPane* findActiveSplitPane();
|
||||||
@@ -150,6 +157,7 @@ private:
|
|||||||
QLineEdit* m_workspaceSearch = nullptr;
|
QLineEdit* m_workspaceSearch = nullptr;
|
||||||
QLabel* m_dockTitleLabel = nullptr;
|
QLabel* m_dockTitleLabel = nullptr;
|
||||||
QToolButton* m_dockCloseBtn = nullptr;
|
QToolButton* m_dockCloseBtn = nullptr;
|
||||||
|
DockGripWidget* m_dockGrip = nullptr;
|
||||||
void createWorkspaceDock();
|
void createWorkspaceDock();
|
||||||
void rebuildWorkspaceModel();
|
void rebuildWorkspaceModel();
|
||||||
void updateBorderColor(const QColor& color);
|
void updateBorderColor(const QColor& color);
|
||||||
@@ -159,8 +167,14 @@ private:
|
|||||||
ScannerPanel* m_scannerPanel = nullptr;
|
ScannerPanel* m_scannerPanel = nullptr;
|
||||||
QLabel* m_scanDockTitle = nullptr;
|
QLabel* m_scanDockTitle = nullptr;
|
||||||
QToolButton* m_scanDockCloseBtn = nullptr;
|
QToolButton* m_scanDockCloseBtn = nullptr;
|
||||||
|
DockGripWidget* m_scanDockGrip = nullptr;
|
||||||
void createScannerDock();
|
void createScannerDock();
|
||||||
|
|
||||||
|
// Start page
|
||||||
|
StartPageWidget* m_startPage = nullptr;
|
||||||
|
Q_INVOKABLE void showStartPage();
|
||||||
|
void dismissStartPage();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void changeEvent(QEvent* event) override;
|
void changeEvent(QEvent* event) override;
|
||||||
void resizeEvent(QResizeEvent* event) override;
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
|
|||||||
@@ -203,7 +203,28 @@ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&
|
|||||||
{"serverInfo", QJsonObject{
|
{"serverInfo", QJsonObject{
|
||||||
{"name", "reclass-mcp"},
|
{"name", "reclass-mcp"},
|
||||||
{"version", "1.0.0"}
|
{"version", "1.0.0"}
|
||||||
}}
|
}},
|
||||||
|
{"instructions",
|
||||||
|
"You are connected to ReClass, a live memory structure editor for reverse engineering. "
|
||||||
|
"You have two types of data available:\n"
|
||||||
|
"1. STRUCTURE: The node tree defines typed fields (project.state, tree.search, tree.apply). "
|
||||||
|
"Each node has a kind (the data type: UInt32, Float, Hex64, etc.) and a name.\n"
|
||||||
|
"2. LIVE DATA: The provider reads real memory from an attached process (hex.read, hex.write). "
|
||||||
|
"node.history returns timestamped value changes with heat levels (0=static, 1=cold, 2=warm, 3=hot).\n\n"
|
||||||
|
"CRITICAL RULES:\n"
|
||||||
|
"- When labeling/identifying a field, ALWAYS change BOTH name AND kind in one tree.apply call. "
|
||||||
|
"Example: [{op:'rename',nodeId:'X',name:'health'},{op:'change_kind',nodeId:'X',kind:'Int32'}]. "
|
||||||
|
"A node named 'health' with kind Hex64 is WRONG — the kind must match the actual data type.\n"
|
||||||
|
"- To detect what changed after an in-game event: call ui.action with action:'reset_tracking', "
|
||||||
|
"then have the user perform the action, then call node.history on the relevant nodes "
|
||||||
|
"to see which ones have new timestamped entries.\n"
|
||||||
|
"- hex.read offset is relative to the struct base address by default. "
|
||||||
|
"Use baseRelative=true for absolute virtual addresses in the process.\n"
|
||||||
|
"- tree.apply operations are atomic (undo macro). Batch related changes into one call.\n"
|
||||||
|
"- Use tree.search to quickly find nodes by name instead of paging through project.state.\n"
|
||||||
|
"- project.state returns structure metadata only (kinds, names, offsets), NOT live values. "
|
||||||
|
"Use hex.read for actual memory values and node.history for tracking changes over time."
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return okReply(id, result);
|
return okReply(id, result);
|
||||||
}
|
}
|
||||||
@@ -219,6 +240,8 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "project.state"},
|
{"name", "project.state"},
|
||||||
{"description", "Returns project state with paginated node tree. "
|
{"description", "Returns project state with paginated node tree. "
|
||||||
|
"NOTE: This returns structure metadata only (kinds, names, offsets), NOT live memory values. "
|
||||||
|
"Use hex.read to read actual values and node.history to track value changes over time. "
|
||||||
"Responses return max 'limit' nodes (default 50). "
|
"Responses return max 'limit' nodes (default 50). "
|
||||||
"Use depth:1 first, then parentId to drill into a struct. "
|
"Use depth:1 first, then parentId to drill into a struct. "
|
||||||
"Enum/bitfield member arrays are omitted by default (counts shown instead); "
|
"Enum/bitfield member arrays are omitted by default (counts shown instead); "
|
||||||
@@ -249,6 +272,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "tree.apply"},
|
{"name", "tree.apply"},
|
||||||
{"description", "Apply batch of tree operations atomically (undo macro). "
|
{"description", "Apply batch of tree operations atomically (undo macro). "
|
||||||
|
"IMPORTANT: When identifying/labeling a field, you MUST use BOTH rename AND change_kind "
|
||||||
|
"in the same batch. A renamed node still has its original kind (e.g. Hex64) unless you "
|
||||||
|
"explicitly change it. Example: "
|
||||||
|
"[{op:'rename',nodeId:'ID',name:'health'},{op:'change_kind',nodeId:'ID',kind:'Int32'}]. "
|
||||||
"Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. "
|
"Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. "
|
||||||
"Operations: "
|
"Operations: "
|
||||||
"remove: {op:'remove', nodeId:'ID'}. "
|
"remove: {op:'remove', nodeId:'ID'}. "
|
||||||
@@ -256,7 +283,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
|
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
|
||||||
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
|
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
|
||||||
"change_offset: {op:'change_offset', nodeId:'ID', offset:16}. "
|
"change_offset: {op:'change_offset', nodeId:'ID', offset:16}. "
|
||||||
"change_base: {op:'change_base', baseAddress:'0x400000'}. "
|
"change_base: {op:'change_base', baseAddress:'0x400000', formula:'[0x233CA80]'} — formula is optional, enables auto-resolve on provider attach. "
|
||||||
"change_struct_type: {op:'change_struct_type', nodeId:'ID', structTypeName:'Name'}. "
|
"change_struct_type: {op:'change_struct_type', nodeId:'ID', structTypeName:'Name'}. "
|
||||||
"change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. "
|
"change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. "
|
||||||
"change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. "
|
"change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. "
|
||||||
@@ -301,10 +328,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
// 4. hex.read
|
// 4. hex.read
|
||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "hex.read"},
|
{"name", "hex.read"},
|
||||||
{"description", "Read raw bytes from provider. Returns hex dump, ASCII, and multi-type "
|
{"description", "Read raw bytes from provider (live process memory). Returns hex dump, ASCII, and multi-type "
|
||||||
"interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). "
|
"interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). "
|
||||||
|
"Use this to see what actual values are in memory at any offset. "
|
||||||
"Offset is tree-relative (0-based, baseAddress added automatically) "
|
"Offset is tree-relative (0-based, baseAddress added automatically) "
|
||||||
"unless baseRelative=true (offset is absolute)."},
|
"unless baseRelative=true (offset is absolute virtual address in the process)."},
|
||||||
{"inputSchema", QJsonObject{
|
{"inputSchema", QJsonObject{
|
||||||
{"type", "object"},
|
{"type", "object"},
|
||||||
{"properties", QJsonObject{
|
{"properties", QJsonObject{
|
||||||
@@ -359,8 +387,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
|
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
|
||||||
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
|
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
|
||||||
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
|
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
|
||||||
"select_node, refresh. "
|
"select_node, refresh, reset_tracking. "
|
||||||
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects)."},
|
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects). "
|
||||||
|
"reset_tracking clears all value change histories — use before an in-game event, "
|
||||||
|
"then check node.history afterward to see what changed."},
|
||||||
{"inputSchema", QJsonObject{
|
{"inputSchema", QJsonObject{
|
||||||
{"type", "object"},
|
{"type", "object"},
|
||||||
{"properties", QJsonObject{
|
{"properties", QJsonObject{
|
||||||
@@ -396,6 +426,27 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
}}
|
}}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 9. node.history
|
||||||
|
tools.append(QJsonObject{
|
||||||
|
{"name", "node.history"},
|
||||||
|
{"description", "Returns timestamped value change history (up to 10 entries) for specified nodes. "
|
||||||
|
"Use this to detect what changed after an in-game event — no need to manually snapshot memory. "
|
||||||
|
"Each node returns: entries[] with {value, timestamp}, heatLevel (0=static to 3=hot), "
|
||||||
|
"and uniqueCount. Heat level 3 means the field is actively changing. "
|
||||||
|
"Requires live provider with value tracking enabled."},
|
||||||
|
{"inputSchema", QJsonObject{
|
||||||
|
{"type", "object"},
|
||||||
|
{"properties", QJsonObject{
|
||||||
|
{"nodeIds", QJsonObject{{"type", "array"},
|
||||||
|
{"items", QJsonObject{{"type", "string"}}},
|
||||||
|
{"description", "Array of node IDs to get history for."}}},
|
||||||
|
{"tabIndex", QJsonObject{{"type", "integer"},
|
||||||
|
{"description", "MDI tab index. Omit for active tab."}}}
|
||||||
|
}},
|
||||||
|
{"required", QJsonArray{"nodeIds"}}
|
||||||
|
}}
|
||||||
|
});
|
||||||
|
|
||||||
return okReply(id, QJsonObject{{"tools", tools}});
|
return okReply(id, QJsonObject{{"tools", tools}});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,6 +471,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
|
|||||||
else if (toolName == "status.set") result = toolStatusSet(args);
|
else if (toolName == "status.set") result = toolStatusSet(args);
|
||||||
else if (toolName == "ui.action") result = toolUiAction(args);
|
else if (toolName == "ui.action") result = toolUiAction(args);
|
||||||
else if (toolName == "tree.search") result = toolTreeSearch(args);
|
else if (toolName == "tree.search") result = toolTreeSearch(args);
|
||||||
|
else if (toolName == "node.history") result = toolNodeHistory(args);
|
||||||
else return errReply(id, -32601, "Unknown tool: " + toolName);
|
else return errReply(id, -32601, "Unknown tool: " + toolName);
|
||||||
|
|
||||||
m_mainWindow->clearMcpStatus();
|
m_mainWindow->clearMcpStatus();
|
||||||
@@ -751,8 +803,10 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
|
|||||||
}
|
}
|
||||||
else if (opType == "change_base") {
|
else if (opType == "change_base") {
|
||||||
uint64_t newBase = op.value("baseAddress").toString().toULongLong(nullptr, 16);
|
uint64_t newBase = op.value("baseAddress").toString().toULongLong(nullptr, 16);
|
||||||
|
QString oldFormula = tree.baseAddressFormula;
|
||||||
|
QString newFormula = op.value("formula").toString();
|
||||||
doc->undoStack.push(new RcxCommand(ctrl,
|
doc->undoStack.push(new RcxCommand(ctrl,
|
||||||
cmd::ChangeBase{tree.baseAddress, newBase}));
|
cmd::ChangeBase{tree.baseAddress, newBase, oldFormula, newFormula}));
|
||||||
applied++;
|
applied++;
|
||||||
}
|
}
|
||||||
else if (opType == "change_struct_type") {
|
else if (opType == "change_struct_type") {
|
||||||
@@ -1159,6 +1213,16 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
|
|||||||
return makeTextResult("Selected node " + nodeIdStr);
|
return makeTextResult("Selected node " + nodeIdStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action == "reset_tracking") {
|
||||||
|
int count = m_mainWindow->tabCount();
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
auto* t = m_mainWindow->tabByIndex(i);
|
||||||
|
if (t && t->ctrl)
|
||||||
|
t->ctrl->resetChangeTracking();
|
||||||
|
}
|
||||||
|
return makeTextResult(QStringLiteral("Value tracking reset on all %1 tabs.").arg(count));
|
||||||
|
}
|
||||||
|
|
||||||
return makeTextResult("Unknown action: " + action, true);
|
return makeTextResult("Unknown action: " + action, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,6 +1290,43 @@ QJsonObject McpBridge::toolTreeSearch(const QJsonObject& args) {
|
|||||||
QJsonDocument(out).toJson(QJsonDocument::Indented)));
|
QJsonDocument(out).toJson(QJsonDocument::Indented)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
// Tool: node.history — return timestamped value history for nodes
|
||||||
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
QJsonObject McpBridge::toolNodeHistory(const QJsonObject& args) {
|
||||||
|
auto* tab = resolveTab(args);
|
||||||
|
if (!tab) return makeTextResult("No active tab.", true);
|
||||||
|
|
||||||
|
const auto& histMap = tab->ctrl->valueHistory();
|
||||||
|
QJsonArray requestedIds = args.value("nodeIds").toArray();
|
||||||
|
if (requestedIds.isEmpty())
|
||||||
|
return makeTextResult("nodeIds array is required.", true);
|
||||||
|
|
||||||
|
QJsonObject result;
|
||||||
|
for (const auto& idVal : requestedIds) {
|
||||||
|
QString idStr = idVal.toString();
|
||||||
|
uint64_t nodeId = idStr.toULongLong();
|
||||||
|
auto it = histMap.find(nodeId);
|
||||||
|
QJsonArray entries;
|
||||||
|
if (it != histMap.end()) {
|
||||||
|
it->forEachWithTime([&](const QString& val, qint64 msec) {
|
||||||
|
QJsonObject entry;
|
||||||
|
entry.insert(QStringLiteral("value"), val);
|
||||||
|
entry.insert(QStringLiteral("timestamp"), msec);
|
||||||
|
entries.append(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
QJsonObject nodeResult;
|
||||||
|
nodeResult.insert(QStringLiteral("entries"), entries);
|
||||||
|
nodeResult.insert(QStringLiteral("heatLevel"), it != histMap.end() ? it->heatLevel() : 0);
|
||||||
|
nodeResult.insert(QStringLiteral("uniqueCount"), it != histMap.end() ? it->uniqueCount() : 0);
|
||||||
|
result.insert(idStr, nodeResult);
|
||||||
|
}
|
||||||
|
return makeTextResult(QString::fromUtf8(
|
||||||
|
QJsonDocument(result).toJson(QJsonDocument::Compact)));
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Notifications (call from MainWindow/Controller hooks)
|
// Notifications (call from MainWindow/Controller hooks)
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ private:
|
|||||||
QJsonObject toolStatusSet(const QJsonObject& args);
|
QJsonObject toolStatusSet(const QJsonObject& args);
|
||||||
QJsonObject toolUiAction(const QJsonObject& args);
|
QJsonObject toolUiAction(const QJsonObject& args);
|
||||||
QJsonObject toolTreeSearch(const QJsonObject& args);
|
QJsonObject toolTreeSearch(const QJsonObject& args);
|
||||||
|
QJsonObject toolNodeHistory(const QJsonObject& args);
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
QJsonObject makeTextResult(const QString& text, bool isError = false);
|
QJsonObject makeTextResult(const QString& text, bool isError = false);
|
||||||
|
|||||||
@@ -40,9 +40,21 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
|||||||
m_tree->setHeaderHidden(true);
|
m_tree->setHeaderHidden(true);
|
||||||
m_tree->setRootIsDecorated(true);
|
m_tree->setRootIsDecorated(true);
|
||||||
m_tree->setFixedWidth(200);
|
m_tree->setFixedWidth(200);
|
||||||
|
m_tree->setMouseTracking(true);
|
||||||
|
m_tree->setIconSize(QSize(16, 16));
|
||||||
|
{
|
||||||
|
const auto& t = ThemeManager::instance().current();
|
||||||
|
QPalette tp = m_tree->palette();
|
||||||
|
tp.setColor(QPalette::Text, t.textDim);
|
||||||
|
tp.setColor(QPalette::Highlight, t.hover);
|
||||||
|
tp.setColor(QPalette::HighlightedText, t.text);
|
||||||
|
m_tree->setPalette(tp);
|
||||||
|
}
|
||||||
|
|
||||||
auto* envItem = new QTreeWidgetItem(m_tree, {"Environment"});
|
auto* envItem = new QTreeWidgetItem(m_tree, {"Environment"});
|
||||||
|
envItem->setIcon(0, QIcon(":/vsicons/folder.svg"));
|
||||||
auto* generalItem = new QTreeWidgetItem(envItem, {"General"});
|
auto* generalItem = new QTreeWidgetItem(envItem, {"General"});
|
||||||
|
generalItem->setIcon(0, QIcon(":/vsicons/settings-gear.svg"));
|
||||||
m_tree->expandAll();
|
m_tree->expandAll();
|
||||||
m_tree->setCurrentItem(generalItem);
|
m_tree->setCurrentItem(generalItem);
|
||||||
leftColumn->addWidget(m_tree, 1);
|
leftColumn->addWidget(m_tree, 1);
|
||||||
@@ -102,7 +114,7 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
|||||||
m_fontCombo->setObjectName("fontCombo");
|
m_fontCombo->setObjectName("fontCombo");
|
||||||
visualLayout->addRow("Editor Font:", m_fontCombo);
|
visualLayout->addRow("Editor Font:", m_fontCombo);
|
||||||
|
|
||||||
m_titleCaseCheck = new QCheckBox("Apply title case styling to menu bar");
|
m_titleCaseCheck = new QCheckBox("Uppercase menu items");
|
||||||
m_titleCaseCheck->setChecked(current.menuBarTitleCase);
|
m_titleCaseCheck->setChecked(current.menuBarTitleCase);
|
||||||
visualLayout->addRow(m_titleCaseCheck);
|
visualLayout->addRow(m_titleCaseCheck);
|
||||||
|
|
||||||
@@ -110,25 +122,11 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
|||||||
m_showIconCheck->setChecked(current.showIcon);
|
m_showIconCheck->setChecked(current.showIcon);
|
||||||
visualLayout->addRow(m_showIconCheck);
|
visualLayout->addRow(m_showIconCheck);
|
||||||
|
|
||||||
|
m_braceWrapCheck = new QCheckBox("Opening brace on new line");
|
||||||
|
m_braceWrapCheck->setChecked(current.braceWrap);
|
||||||
|
visualLayout->addRow(m_braceWrapCheck);
|
||||||
|
|
||||||
generalLayout->addWidget(visualGroup);
|
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);
|
|
||||||
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();
|
generalLayout->addStretch();
|
||||||
|
|
||||||
m_pages->addWidget(generalPage); // index 0
|
m_pages->addWidget(generalPage); // index 0
|
||||||
@@ -136,6 +134,7 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
|||||||
|
|
||||||
// -- AI Features page --
|
// -- AI Features page --
|
||||||
auto* aiItem = new QTreeWidgetItem(envItem, {"AI Features"});
|
auto* aiItem = new QTreeWidgetItem(envItem, {"AI Features"});
|
||||||
|
aiItem->setIcon(0, QIcon(":/vsicons/remote.svg"));
|
||||||
|
|
||||||
auto* aiPage = new QWidget;
|
auto* aiPage = new QWidget;
|
||||||
auto* aiLayout = new QVBoxLayout(aiPage);
|
auto* aiLayout = new QVBoxLayout(aiPage);
|
||||||
@@ -165,6 +164,7 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
|||||||
|
|
||||||
// -- Generator page --
|
// -- Generator page --
|
||||||
auto* generatorItem = new QTreeWidgetItem(envItem, {"Generator"});
|
auto* generatorItem = new QTreeWidgetItem(envItem, {"Generator"});
|
||||||
|
generatorItem->setIcon(0, QIcon(":/vsicons/code.svg"));
|
||||||
|
|
||||||
auto* generatorPage = new QWidget;
|
auto* generatorPage = new QWidget;
|
||||||
auto* generatorLayout = new QVBoxLayout(generatorPage);
|
auto* generatorLayout = new QVBoxLayout(generatorPage);
|
||||||
@@ -213,10 +213,10 @@ OptionsResult OptionsDialog::result() const {
|
|||||||
r.fontName = m_fontCombo->currentText();
|
r.fontName = m_fontCombo->currentText();
|
||||||
r.menuBarTitleCase = m_titleCaseCheck->isChecked();
|
r.menuBarTitleCase = m_titleCaseCheck->isChecked();
|
||||||
r.showIcon = m_showIconCheck->isChecked();
|
r.showIcon = m_showIconCheck->isChecked();
|
||||||
r.safeMode = m_safeModeCheck->isChecked();
|
|
||||||
r.autoStartMcp = m_autoMcpCheck->isChecked();
|
r.autoStartMcp = m_autoMcpCheck->isChecked();
|
||||||
r.refreshMs = m_refreshSpin->value();
|
r.refreshMs = m_refreshSpin->value();
|
||||||
r.generatorAsserts = m_assertCheck->isChecked();
|
r.generatorAsserts = m_assertCheck->isChecked();
|
||||||
|
r.braceWrap = m_braceWrapCheck->isChecked();
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ struct OptionsResult {
|
|||||||
QString fontName;
|
QString fontName;
|
||||||
bool menuBarTitleCase = true;
|
bool menuBarTitleCase = true;
|
||||||
bool showIcon = false;
|
bool showIcon = false;
|
||||||
bool safeMode = false;
|
|
||||||
bool autoStartMcp = true;
|
bool autoStartMcp = true;
|
||||||
int refreshMs = 660;
|
int refreshMs = 660;
|
||||||
bool generatorAsserts = false;
|
bool generatorAsserts = false;
|
||||||
|
bool braceWrap = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
class OptionsDialog : public QDialog {
|
class OptionsDialog : public QDialog {
|
||||||
@@ -39,10 +39,10 @@ private:
|
|||||||
QComboBox* m_fontCombo = nullptr;
|
QComboBox* m_fontCombo = nullptr;
|
||||||
QCheckBox* m_titleCaseCheck = nullptr;
|
QCheckBox* m_titleCaseCheck = nullptr;
|
||||||
QCheckBox* m_showIconCheck = nullptr;
|
QCheckBox* m_showIconCheck = nullptr;
|
||||||
QCheckBox* m_safeModeCheck = nullptr;
|
|
||||||
QCheckBox* m_autoMcpCheck = nullptr;
|
QCheckBox* m_autoMcpCheck = nullptr;
|
||||||
QSpinBox* m_refreshSpin = nullptr;
|
QSpinBox* m_refreshSpin = nullptr;
|
||||||
QCheckBox* m_assertCheck = nullptr;
|
QCheckBox* m_assertCheck = nullptr;
|
||||||
|
QCheckBox* m_braceWrapCheck = nullptr;
|
||||||
|
|
||||||
// searchable keywords per leaf tree item
|
// searchable keywords per leaf tree item
|
||||||
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;
|
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QMenu>
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -27,22 +31,9 @@ ProcessPicker::ProcessPicker(QWidget *parent)
|
|||||||
, m_useCustomList(false)
|
, m_useCustomList(false)
|
||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
initUi();
|
||||||
// Configure table
|
|
||||||
ui->processTable->setColumnWidth(0, 80); // PID column - fixed width
|
|
||||||
ui->processTable->setColumnWidth(1, 200); // Name column - fixed width
|
|
||||||
ui->processTable->horizontalHeader()->setStretchLastSection(true); // Path column - fills remaining space
|
|
||||||
ui->processTable->setWordWrap(false); // Disable word wrap for single-line display
|
|
||||||
ui->processTable->setTextElideMode(Qt::ElideLeft); // Elide from left (show end of path)
|
|
||||||
|
|
||||||
// Connect signals
|
|
||||||
connect(ui->refreshButton, &QPushButton::clicked, this, &ProcessPicker::refreshProcessList);
|
|
||||||
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
|
|
||||||
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
|
|
||||||
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
|
|
||||||
|
|
||||||
// Initial process enumeration
|
|
||||||
refreshProcessList();
|
refreshProcessList();
|
||||||
|
selectPreferredProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget *parent)
|
ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget *parent)
|
||||||
@@ -51,23 +42,103 @@ ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget
|
|||||||
, m_useCustomList(true)
|
, m_useCustomList(true)
|
||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
initUi();
|
||||||
// Configure table
|
ui->refreshButton->setVisible(false);
|
||||||
ui->processTable->setColumnWidth(0, 80);
|
m_allProcesses = customProcesses;
|
||||||
ui->processTable->setColumnWidth(1, 200);
|
applyFilter();
|
||||||
|
selectPreferredProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ProcessPicker::initUi()
|
||||||
|
{
|
||||||
|
// Table configuration
|
||||||
|
ui->processTable->setColumnWidth(0, 80); // PID column
|
||||||
|
ui->processTable->setColumnWidth(1, 200); // Name column
|
||||||
ui->processTable->horizontalHeader()->setStretchLastSection(true);
|
ui->processTable->horizontalHeader()->setStretchLastSection(true);
|
||||||
|
ui->processTable->setSortingEnabled(true);
|
||||||
ui->processTable->setWordWrap(false);
|
ui->processTable->setWordWrap(false);
|
||||||
ui->processTable->setTextElideMode(Qt::ElideLeft);
|
ui->processTable->setTextElideMode(Qt::ElideLeft);
|
||||||
|
ui->processTable->setShowGrid(false);
|
||||||
// Connect signals (no refresh button for custom lists)
|
ui->processTable->verticalHeader()->setDefaultSectionSize(fontMetrics().height() + 6);
|
||||||
ui->refreshButton->setVisible(false);
|
|
||||||
|
// Signal connections
|
||||||
|
connect(ui->refreshButton, &QPushButton::clicked, this, &ProcessPicker::refreshProcessList);
|
||||||
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
|
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
|
||||||
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
|
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
|
||||||
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
|
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
|
||||||
|
|
||||||
// Use custom process list
|
// Derive theme colors from the global palette (set by applyGlobalTheme)
|
||||||
m_allProcesses = customProcesses;
|
QPalette pal = qApp->palette();
|
||||||
applyFilter();
|
QString bg = pal.color(QPalette::Base).name();
|
||||||
|
QString text = pal.color(QPalette::Text).name();
|
||||||
|
QString hover = pal.color(QPalette::Mid).name();
|
||||||
|
QString surface = pal.color(QPalette::AlternateBase).name();
|
||||||
|
QString button = pal.color(QPalette::Button).name();
|
||||||
|
QString highlight= pal.color(QPalette::Highlight).name();
|
||||||
|
QString border = pal.color(QPalette::Mid).darker(120).name();
|
||||||
|
QString mutedText= pal.color(QPalette::Disabled, QPalette::WindowText).name();
|
||||||
|
QString hoverDk = pal.color(QPalette::Mid).darker(130).name();
|
||||||
|
|
||||||
|
ui->processTable->setStyleSheet(QStringLiteral(
|
||||||
|
"QTableWidget { background: %1; color: %2; border: none; }"
|
||||||
|
"QTableWidget::item { padding: 2px 6px; border: none; }"
|
||||||
|
"QTableWidget::item:hover { background: %3; padding: 2px 6px; border: none; }"
|
||||||
|
"QTableWidget::item:selected { background: %3; color: %2; padding: 2px 6px; border: none; }")
|
||||||
|
.arg(bg, text, hover));
|
||||||
|
|
||||||
|
ui->processTable->horizontalHeader()->setStyleSheet(QStringLiteral(
|
||||||
|
"QHeaderView::section { background: %1; color: %2; border: none;"
|
||||||
|
" padding: 4px 6px; text-align: left; }")
|
||||||
|
.arg(surface, text));
|
||||||
|
ui->processTable->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||||
|
|
||||||
|
ui->filterEdit->setStyleSheet(QStringLiteral(
|
||||||
|
"QLineEdit { background: %1; color: %2; border: 1px solid %3; padding: 2px 4px; }"
|
||||||
|
"QLineEdit:focus { border-color: %4; }")
|
||||||
|
.arg(bg, text, border, highlight));
|
||||||
|
|
||||||
|
QString btnStyle = QStringLiteral(
|
||||||
|
"QPushButton { background: %1; color: %2; border: 1px solid %3; padding: 4px 12px; }"
|
||||||
|
"QPushButton:hover { background: %4; }"
|
||||||
|
"QPushButton:pressed { background: %5; }"
|
||||||
|
"QPushButton:disabled { color: %6; }")
|
||||||
|
.arg(button, text, border, hover, hoverDk, mutedText);
|
||||||
|
ui->refreshButton->setStyleSheet(btnStyle);
|
||||||
|
ui->attachButton->setStyleSheet(btnStyle);
|
||||||
|
ui->cancelButton->setStyleSheet(btnStyle);
|
||||||
|
|
||||||
|
// Right-click context menu
|
||||||
|
ui->processTable->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
connect(ui->processTable, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
|
||||||
|
int row = ui->processTable->rowAt(pos.y());
|
||||||
|
if (row < 0) return;
|
||||||
|
auto* pidItem = ui->processTable->item(row, 0);
|
||||||
|
auto* nameItem = ui->processTable->item(row, 1);
|
||||||
|
auto* pathItem = ui->processTable->item(row, 2);
|
||||||
|
if (!pidItem || !nameItem) return;
|
||||||
|
|
||||||
|
QString pid = QString::number(pidItem->data(Qt::EditRole).toUInt());
|
||||||
|
QString name = nameItem->data(Qt::UserRole).toString();
|
||||||
|
QString path = pathItem ? pathItem->text() : QString();
|
||||||
|
|
||||||
|
QMenu menu;
|
||||||
|
auto* copyPid = menu.addAction(QStringLiteral("Copy PID"));
|
||||||
|
auto* copyName = menu.addAction(QStringLiteral("Copy Name"));
|
||||||
|
QAction* copyPath = nullptr;
|
||||||
|
if (!path.isEmpty())
|
||||||
|
copyPath = menu.addAction(QStringLiteral("Copy Path"));
|
||||||
|
|
||||||
|
auto* chosen = menu.exec(ui->processTable->viewport()->mapToGlobal(pos));
|
||||||
|
if (chosen == copyPid)
|
||||||
|
QApplication::clipboard()->setText(pid);
|
||||||
|
else if (chosen == copyName)
|
||||||
|
QApplication::clipboard()->setText(name);
|
||||||
|
else if (copyPath && chosen == copyPath)
|
||||||
|
QApplication::clipboard()->setText(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-focus filter for immediate typing
|
||||||
|
ui->filterEdit->setFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
ProcessPicker::~ProcessPicker()
|
ProcessPicker::~ProcessPicker()
|
||||||
@@ -97,31 +168,31 @@ void ProcessPicker::onProcessSelected()
|
|||||||
{
|
{
|
||||||
auto* item = ui->processTable->currentItem();
|
auto* item = ui->processTable->currentItem();
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
int row = item->row();
|
int row = item->row();
|
||||||
m_selectedPid = ui->processTable->item(row, 0)->data(Qt::EditRole).toUInt();
|
m_selectedPid = ui->processTable->item(row, 0)->data(Qt::EditRole).toUInt();
|
||||||
// Use original name stored in UserRole (without architecture suffix)
|
// Use original name stored in UserRole (without architecture suffix)
|
||||||
QVariant origName = ui->processTable->item(row, 1)->data(Qt::UserRole);
|
QVariant origName = ui->processTable->item(row, 1)->data(Qt::UserRole);
|
||||||
m_selectedName = origName.isValid() ? origName.toString()
|
m_selectedName = origName.isValid() ? origName.toString()
|
||||||
: ui->processTable->item(row, 1)->text();
|
: ui->processTable->item(row, 1)->text();
|
||||||
|
|
||||||
accept();
|
accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProcessPicker::enumerateProcesses()
|
void ProcessPicker::enumerateProcesses()
|
||||||
{
|
{
|
||||||
QList<ProcessInfo> processes;
|
QList<ProcessInfo> processes;
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||||
if (snapshot == INVALID_HANDLE_VALUE) {
|
if (snapshot == INVALID_HANDLE_VALUE) {
|
||||||
QMessageBox::warning(this, "Error", "Failed to enumerate processes.");
|
QMessageBox::warning(this, "Error", "Failed to enumerate processes.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PROCESSENTRY32W pe32;
|
PROCESSENTRY32W pe32;
|
||||||
pe32.dwSize = sizeof(PROCESSENTRY32W);
|
pe32.dwSize = sizeof(PROCESSENTRY32W);
|
||||||
|
|
||||||
if (Process32FirstW(snapshot, &pe32))
|
if (Process32FirstW(snapshot, &pe32))
|
||||||
{
|
{
|
||||||
do
|
do
|
||||||
@@ -129,10 +200,7 @@ void ProcessPicker::enumerateProcesses()
|
|||||||
ProcessInfo info;
|
ProcessInfo info;
|
||||||
info.pid = pe32.th32ProcessID;
|
info.pid = pe32.th32ProcessID;
|
||||||
info.name = QString::fromWCharArray(pe32.szExeFile);
|
info.name = QString::fromWCharArray(pe32.szExeFile);
|
||||||
|
|
||||||
// Try to get full path and extract icon
|
|
||||||
// If we can't open a process with PROCESS_QUERY_LIMITED_INFORMATION then
|
|
||||||
// we for sure can't access their memory. - Skip in this case
|
|
||||||
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pe32.th32ProcessID);
|
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pe32.th32ProcessID);
|
||||||
if (hProcess)
|
if (hProcess)
|
||||||
{
|
{
|
||||||
@@ -143,7 +211,7 @@ void ProcessPicker::enumerateProcesses()
|
|||||||
GetModuleFileNameExW(hProcess, nullptr, path, pathLen))
|
GetModuleFileNameExW(hProcess, nullptr, path, pathLen))
|
||||||
{
|
{
|
||||||
info.path = QString::fromWCharArray(path);
|
info.path = QString::fromWCharArray(path);
|
||||||
|
|
||||||
// Extract icon from executable
|
// Extract icon from executable
|
||||||
SHFILEINFOW sfi = {};
|
SHFILEINFOW sfi = {};
|
||||||
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
|
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
|
||||||
@@ -262,6 +330,9 @@ void ProcessPicker::populateTable(const QList<ProcessInfo>& processes)
|
|||||||
pathItem->setToolTip(proc.path); // Show full path on hover
|
pathItem->setToolTip(proc.path); // Show full path on hover
|
||||||
ui->processTable->setItem(i, 2, pathItem);
|
ui->processTable->setItem(i, 2, pathItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default sort: highest PID first (most recently launched processes on top)
|
||||||
|
ui->processTable->sortItems(0, Qt::DescendingOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProcessPicker::filterProcesses(const QString& text)
|
void ProcessPicker::filterProcesses(const QString& text)
|
||||||
@@ -292,3 +363,22 @@ void ProcessPicker::applyFilter()
|
|||||||
|
|
||||||
populateTable(filtered);
|
populateTable(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ProcessPicker::selectPreferredProcess()
|
||||||
|
{
|
||||||
|
// Try to select the last-attached process if it's in the list
|
||||||
|
QSettings s("Reclass", "Reclass");
|
||||||
|
QString lastProc = s.value("lastAttachedProcess").toString();
|
||||||
|
if (lastProc.isEmpty()) return;
|
||||||
|
|
||||||
|
for (int row = 0; row < ui->processTable->rowCount(); ++row) {
|
||||||
|
auto* nameItem = ui->processTable->item(row, 1);
|
||||||
|
if (!nameItem) continue;
|
||||||
|
QString name = nameItem->data(Qt::UserRole).toString();
|
||||||
|
if (name.compare(lastProc, Qt::CaseInsensitive) == 0) {
|
||||||
|
ui->processTable->selectRow(row);
|
||||||
|
ui->processTable->scrollToItem(nameItem);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ private slots:
|
|||||||
void filterProcesses(const QString& text);
|
void filterProcesses(const QString& text);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void initUi();
|
||||||
void enumerateProcesses();
|
void enumerateProcesses();
|
||||||
void populateTable(const QList<ProcessInfo>& processes);
|
void populateTable(const QList<ProcessInfo>& processes);
|
||||||
void applyFilter();
|
void applyFilter();
|
||||||
|
void selectPreferredProcess();
|
||||||
|
|
||||||
Ui::ProcessPicker *ui;
|
Ui::ProcessPicker *ui;
|
||||||
uint32_t m_selectedPid = 0;
|
uint32_t m_selectedPid = 0;
|
||||||
|
|||||||
@@ -127,22 +127,6 @@
|
|||||||
</widget>
|
</widget>
|
||||||
<resources/>
|
<resources/>
|
||||||
<connections>
|
<connections>
|
||||||
<connection>
|
|
||||||
<sender>attachButton</sender>
|
|
||||||
<signal>clicked()</signal>
|
|
||||||
<receiver>ProcessPicker</receiver>
|
|
||||||
<slot>accept()</slot>
|
|
||||||
<hints>
|
|
||||||
<hint type="sourcelabel">
|
|
||||||
<x>600</x>
|
|
||||||
<y>470</y>
|
|
||||||
</hint>
|
|
||||||
<hint type="destinationlabel">
|
|
||||||
<x>350</x>
|
|
||||||
<y>250</y>
|
|
||||||
</hint>
|
|
||||||
</hints>
|
|
||||||
</connection>
|
|
||||||
<connection>
|
<connection>
|
||||||
<sender>cancelButton</sender>
|
<sender>cancelButton</sender>
|
||||||
<signal>clicked()</signal>
|
<signal>clicked()</signal>
|
||||||
|
|||||||
241
src/rcxtooltip.h
Normal file
241
src/rcxtooltip.h
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "themes/thememanager.h"
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QPropertyAnimation>
|
||||||
|
#include <QCursor>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#define TIP_LOG(...) do { \
|
||||||
|
FILE* _f = fopen("E:/game_dev/util/reclass2027-main/build/tip_trace.log", "a"); \
|
||||||
|
if (_f) { fprintf(_f, __VA_ARGS__); fclose(_f); } \
|
||||||
|
} while(0)
|
||||||
|
|
||||||
|
namespace rcx {
|
||||||
|
|
||||||
|
class RcxTooltip : public QWidget {
|
||||||
|
public:
|
||||||
|
static RcxTooltip* instance() {
|
||||||
|
static RcxTooltip* s = nullptr;
|
||||||
|
if (!s) {
|
||||||
|
s = new RcxTooltip;
|
||||||
|
QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
|
||||||
|
s, [](const rcx::Theme&) { /* colors read live in paintEvent */ });
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void showFor(QWidget* trigger, const QString& text) {
|
||||||
|
if (!trigger || text.isEmpty()) {
|
||||||
|
TIP_LOG("[TIP] showFor: null trigger or empty text -- dismiss\n");
|
||||||
|
dismiss(); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same widget+text already showing — do nothing (prevents teleport)
|
||||||
|
if (m_trigger == trigger && m_text == text && isVisible()) {
|
||||||
|
TIP_LOG("[TIP] showFor: same widget+text, already visible -- skip\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TIP_LOG("[TIP] showFor: text='%s' trigger=%p class=%s\n",
|
||||||
|
qPrintable(text), (void*)trigger, trigger->metaObject()->className());
|
||||||
|
|
||||||
|
// Cancel pending dismiss
|
||||||
|
if (m_dismissTimer) m_dismissTimer->stop();
|
||||||
|
|
||||||
|
m_trigger = trigger;
|
||||||
|
m_text = text;
|
||||||
|
|
||||||
|
m_label->setText(text);
|
||||||
|
m_label->adjustSize();
|
||||||
|
|
||||||
|
// ── Size: label + padding + arrow ──
|
||||||
|
const int pad = 8;
|
||||||
|
const int vpad = 4;
|
||||||
|
int bodyW = m_label->sizeHint().width() + pad * 2;
|
||||||
|
int bodyH = m_label->sizeHint().height() + vpad * 2;
|
||||||
|
int totalW = bodyW;
|
||||||
|
int totalH = bodyH + kArrowH;
|
||||||
|
|
||||||
|
// ── Position relative to trigger widget ──
|
||||||
|
QRect trigGlobal = QRect(trigger->mapToGlobal(QPoint(0, 0)), trigger->size());
|
||||||
|
int trigCenterX = trigGlobal.center().x();
|
||||||
|
|
||||||
|
QScreen* screen = QApplication::screenAt(trigGlobal.center());
|
||||||
|
QRect scr = screen ? screen->availableGeometry() : QRect(0, 0, 1920, 1080);
|
||||||
|
|
||||||
|
// Default: above the trigger
|
||||||
|
m_arrowDown = true;
|
||||||
|
int x = trigCenterX - totalW / 2;
|
||||||
|
int y = trigGlobal.top() - totalH - kGap;
|
||||||
|
|
||||||
|
// Flip below if not enough room above
|
||||||
|
if (y < scr.top()) {
|
||||||
|
m_arrowDown = false;
|
||||||
|
y = trigGlobal.bottom() + kGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp horizontally
|
||||||
|
if (x < scr.left()) x = scr.left() + 2;
|
||||||
|
if (x + totalW > scr.right()) x = scr.right() - totalW - 2;
|
||||||
|
|
||||||
|
// Arrow X in local coords
|
||||||
|
m_arrowLocalX = trigCenterX - x;
|
||||||
|
m_arrowLocalX = qBound(kArrowHalfW + 4, m_arrowLocalX, totalW - kArrowHalfW - 4);
|
||||||
|
|
||||||
|
// Position label inside the body
|
||||||
|
if (m_arrowDown)
|
||||||
|
m_label->move(pad, vpad);
|
||||||
|
else
|
||||||
|
m_label->move(pad, kArrowH + vpad);
|
||||||
|
|
||||||
|
m_bodyRect = m_arrowDown
|
||||||
|
? QRect(0, 0, bodyW, bodyH)
|
||||||
|
: QRect(0, kArrowH, bodyW, bodyH);
|
||||||
|
|
||||||
|
setFixedSize(totalW, totalH);
|
||||||
|
move(x, y);
|
||||||
|
|
||||||
|
if (!isVisible()) {
|
||||||
|
TIP_LOG("[TIP] showFor: showing at (%d,%d) size=%dx%d arrowDown=%d arrowX=%d\n",
|
||||||
|
x, y, totalW, totalH, m_arrowDown, m_arrowLocalX);
|
||||||
|
setWindowOpacity(0.0);
|
||||||
|
show();
|
||||||
|
raise();
|
||||||
|
// Fade in
|
||||||
|
auto* anim = new QPropertyAnimation(this, "windowOpacity", this);
|
||||||
|
anim->setDuration(80);
|
||||||
|
anim->setStartValue(0.0);
|
||||||
|
anim->setEndValue(1.0);
|
||||||
|
anim->setEasingCurve(QEasingCurve::OutCubic);
|
||||||
|
anim->start(QAbstractAnimation::DeleteWhenStopped);
|
||||||
|
} else {
|
||||||
|
TIP_LOG("[TIP] showFor: already visible, updating\n");
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void dismiss() {
|
||||||
|
TIP_LOG("[TIP] dismiss: wasVisible=%d\n", isVisible());
|
||||||
|
if (m_dismissTimer) m_dismissTimer->stop();
|
||||||
|
if (isVisible()) hide();
|
||||||
|
m_trigger = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule dismiss with a delay — but only if the cursor has truly
|
||||||
|
// left the trigger+tooltip zone. Qt fires synthetic Leave events
|
||||||
|
// when a tooltip window appears above the trigger; we must ignore those.
|
||||||
|
void scheduleDismiss() {
|
||||||
|
if (m_trigger) {
|
||||||
|
QPoint cursor = QCursor::pos();
|
||||||
|
QRect trigRect(m_trigger->mapToGlobal(QPoint(0, 0)), m_trigger->size());
|
||||||
|
QRect tipRect(pos(), size());
|
||||||
|
QRect zone = trigRect.united(tipRect).adjusted(-4, -4, 4, 4);
|
||||||
|
bool inside = zone.contains(cursor);
|
||||||
|
TIP_LOG("[TIP] scheduleDismiss: cursor=(%d,%d) zone=(%d,%d %dx%d) inside=%d\n",
|
||||||
|
cursor.x(), cursor.y(),
|
||||||
|
zone.x(), zone.y(), zone.width(), zone.height(), inside);
|
||||||
|
if (inside)
|
||||||
|
return; // cursor still inside — ignore spurious Leave
|
||||||
|
}
|
||||||
|
if (!m_dismissTimer) {
|
||||||
|
m_dismissTimer = new QTimer(this);
|
||||||
|
m_dismissTimer->setSingleShot(true);
|
||||||
|
connect(m_dismissTimer, &QTimer::timeout, this, &RcxTooltip::dismiss);
|
||||||
|
}
|
||||||
|
m_dismissTimer->start(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget* currentTrigger() const { return m_trigger; }
|
||||||
|
|
||||||
|
// ── Geometry accessors (for testing) ──
|
||||||
|
bool arrowPointsDown() const { return m_arrowDown; }
|
||||||
|
int arrowLocalX() const { return m_arrowLocalX; }
|
||||||
|
QRect bodyRect() const { return m_bodyRect; }
|
||||||
|
QString currentText() const { return m_text; }
|
||||||
|
|
||||||
|
// Constants exposed for testing
|
||||||
|
static constexpr int kArrowH = 6;
|
||||||
|
static constexpr int kArrowHalfW = 6;
|
||||||
|
static constexpr int kGap = 2;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent*) override {
|
||||||
|
TIP_LOG("[TIP] paintEvent: size=%dx%d bodyRect=(%d,%d %dx%d)\n",
|
||||||
|
width(), height(),
|
||||||
|
m_bodyRect.x(), m_bodyRect.y(), m_bodyRect.width(), m_bodyRect.height());
|
||||||
|
const auto& theme = ThemeManager::instance().current();
|
||||||
|
|
||||||
|
QPainter p(this);
|
||||||
|
p.setRenderHint(QPainter::Antialiasing);
|
||||||
|
|
||||||
|
// Fill entire widget with the tooltip background first
|
||||||
|
// (no WA_TranslucentBackground, so unpainted areas would be opaque garbage)
|
||||||
|
p.fillRect(rect(), theme.backgroundAlt);
|
||||||
|
|
||||||
|
// Build path: rounded body + triangle arrow
|
||||||
|
QPainterPath path;
|
||||||
|
path.addRoundedRect(QRectF(m_bodyRect), 4.0, 4.0);
|
||||||
|
|
||||||
|
// Triangle arrow
|
||||||
|
QPolygonF arrow;
|
||||||
|
if (m_arrowDown) {
|
||||||
|
int ay = m_bodyRect.bottom();
|
||||||
|
arrow << QPointF(m_arrowLocalX - kArrowHalfW, ay)
|
||||||
|
<< QPointF(m_arrowLocalX, ay + kArrowH)
|
||||||
|
<< QPointF(m_arrowLocalX + kArrowHalfW, ay);
|
||||||
|
} else {
|
||||||
|
int ay = kArrowH;
|
||||||
|
arrow << QPointF(m_arrowLocalX - kArrowHalfW, ay)
|
||||||
|
<< QPointF(m_arrowLocalX, 0)
|
||||||
|
<< QPointF(m_arrowLocalX + kArrowHalfW, ay);
|
||||||
|
}
|
||||||
|
QPainterPath arrowPath;
|
||||||
|
arrowPath.addPolygon(arrow);
|
||||||
|
arrowPath.closeSubpath();
|
||||||
|
path = path.united(arrowPath);
|
||||||
|
|
||||||
|
// Stroke the shape border
|
||||||
|
p.setPen(QPen(theme.border, 1.0));
|
||||||
|
p.setBrush(theme.backgroundAlt);
|
||||||
|
p.drawPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
explicit RcxTooltip()
|
||||||
|
: QWidget(nullptr, Qt::ToolTip | Qt::FramelessWindowHint)
|
||||||
|
{
|
||||||
|
// NOTE: WA_TranslucentBackground removed — it breaks under DWM dark mode
|
||||||
|
// (DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE kills layered compositing)
|
||||||
|
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||||
|
setAutoFillBackground(false); // we paint everything ourselves in paintEvent
|
||||||
|
|
||||||
|
m_label = new QLabel(this);
|
||||||
|
m_label->setAlignment(Qt::AlignCenter);
|
||||||
|
updateLabelStyle();
|
||||||
|
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
|
||||||
|
this, [this](const rcx::Theme&) { updateLabelStyle(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateLabelStyle() {
|
||||||
|
const auto& theme = ThemeManager::instance().current();
|
||||||
|
m_label->setStyleSheet(
|
||||||
|
QStringLiteral("QLabel { color: %1; background: transparent; padding: 0; }")
|
||||||
|
.arg(theme.text.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
QLabel* m_label = nullptr;
|
||||||
|
QWidget* m_trigger = nullptr;
|
||||||
|
QString m_text;
|
||||||
|
QTimer* m_dismissTimer = nullptr;
|
||||||
|
bool m_arrowDown = true;
|
||||||
|
int m_arrowLocalX = 0;
|
||||||
|
QRect m_bodyRect;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace rcx
|
||||||
@@ -60,5 +60,10 @@
|
|||||||
<file alias="search.svg">vsicons/search.svg</file>
|
<file alias="search.svg">vsicons/search.svg</file>
|
||||||
<file alias="regex.svg">vsicons/regex.svg</file>
|
<file alias="regex.svg">vsicons/regex.svg</file>
|
||||||
<file alias="refresh.svg">vsicons/refresh.svg</file>
|
<file alias="refresh.svg">vsicons/refresh.svg</file>
|
||||||
|
<file alias="pin.svg">vsicons/pin.svg</file>
|
||||||
|
<file alias="pinned.svg">vsicons/pinned.svg</file>
|
||||||
|
<file alias="close-all.svg">vsicons/close-all.svg</file>
|
||||||
|
<file alias="split-vertical.svg">vsicons/split-vertical.svg</file>
|
||||||
|
<file alias="book.svg">vsicons/book.svg</file>
|
||||||
</qresource>
|
</qresource>
|
||||||
</RCC>
|
</RCC>
|
||||||
|
|||||||
200
src/scanner.cpp
200
src/scanner.cpp
@@ -347,6 +347,64 @@ int naturalAlignment(ValueType type) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int valueSizeForType(ValueType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ValueType::Int8: case ValueType::UInt8: return 1;
|
||||||
|
case ValueType::Int16: case ValueType::UInt16: return 2;
|
||||||
|
case ValueType::Int32: case ValueType::UInt32: case ValueType::Float: return 4;
|
||||||
|
case ValueType::Int64: case ValueType::UInt64: case ValueType::Double: return 8;
|
||||||
|
case ValueType::Vec2: return 8;
|
||||||
|
case ValueType::Vec3: return 12;
|
||||||
|
case ValueType::Vec4: return 16;
|
||||||
|
default: return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Typed comparison for rescan conditions ──
|
||||||
|
|
||||||
|
static int compareTyped(const QByteArray& a, const QByteArray& b, ValueType vt) {
|
||||||
|
const char* da = a.constData();
|
||||||
|
const char* db = b.constData();
|
||||||
|
int sz = qMin(a.size(), b.size());
|
||||||
|
|
||||||
|
switch (vt) {
|
||||||
|
case ValueType::Int8:
|
||||||
|
if (sz >= 1) { int8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt8:
|
||||||
|
if (sz >= 1) { uint8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Int16:
|
||||||
|
if (sz >= 2) { int16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt16:
|
||||||
|
if (sz >= 2) { uint16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Int32:
|
||||||
|
if (sz >= 4) { int32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt32:
|
||||||
|
if (sz >= 4) { uint32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Int64:
|
||||||
|
if (sz >= 8) { int64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt64:
|
||||||
|
if (sz >= 8) { uint64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Float:
|
||||||
|
if (sz >= 4) { float va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Double:
|
||||||
|
if (sz >= 8) { double va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Fallback: byte comparison
|
||||||
|
return memcmp(da, db, sz);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scan engine ──
|
// ── Scan engine ──
|
||||||
|
|
||||||
ScanEngine::ScanEngine(QObject* parent)
|
ScanEngine::ScanEngine(QObject* parent)
|
||||||
@@ -366,13 +424,15 @@ void ScanEngine::abort() {
|
|||||||
void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) {
|
void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) {
|
||||||
if (isRunning()) return;
|
if (isRunning()) return;
|
||||||
|
|
||||||
if (req.pattern.isEmpty()) {
|
if (req.condition != ScanCondition::UnknownValue) {
|
||||||
emit error(QStringLiteral("Empty pattern"));
|
if (req.pattern.isEmpty()) {
|
||||||
return;
|
emit error(QStringLiteral("Empty pattern"));
|
||||||
}
|
return;
|
||||||
if (req.pattern.size() != req.mask.size()) {
|
}
|
||||||
emit error(QStringLiteral("Pattern and mask size mismatch"));
|
if (req.pattern.size() != req.mask.size()) {
|
||||||
return;
|
emit error(QStringLiteral("Pattern and mask size mismatch"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m_abort.store(false);
|
m_abort.store(false);
|
||||||
@@ -400,14 +460,16 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
timer.start();
|
timer.start();
|
||||||
|
|
||||||
QVector<ScanResult> results;
|
QVector<ScanResult> results;
|
||||||
|
const bool isUnknown = (req.condition == ScanCondition::UnknownValue);
|
||||||
|
|
||||||
if (!prov || req.pattern.isEmpty())
|
if (!prov || (!isUnknown && req.pattern.isEmpty()))
|
||||||
return results;
|
return results;
|
||||||
|
|
||||||
auto regions = prov->enumerateRegions();
|
auto regions = prov->enumerateRegions();
|
||||||
qDebug() << "[scan] regions:" << regions.size()
|
qDebug() << "[scan] regions:" << regions.size()
|
||||||
<< " pattern:" << req.pattern.size() << "bytes"
|
<< " pattern:" << req.pattern.size() << "bytes"
|
||||||
<< " align:" << req.alignment
|
<< " align:" << req.alignment
|
||||||
|
<< " condition:" << (int)req.condition
|
||||||
<< " filterExec:" << req.filterExecutable
|
<< " filterExec:" << req.filterExecutable
|
||||||
<< " filterWrite:" << req.filterWritable;
|
<< " filterWrite:" << req.filterWritable;
|
||||||
|
|
||||||
@@ -422,17 +484,26 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
regions.append(fallback);
|
regions.append(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
const int patternLen = req.pattern.size();
|
const int patternLen = isUnknown ? req.valueSize : req.pattern.size();
|
||||||
const char* pat = req.pattern.constData();
|
const char* pat = isUnknown ? nullptr : req.pattern.constData();
|
||||||
const char* msk = req.mask.constData();
|
const char* msk = isUnknown ? nullptr : req.mask.constData();
|
||||||
const int alignment = qMax(1, req.alignment);
|
const int alignment = qMax(1, req.alignment);
|
||||||
|
const int valSize = isUnknown ? req.valueSize : patternLen;
|
||||||
|
const bool hasRange = (req.startAddress != 0 || req.endAddress != 0) &&
|
||||||
|
req.endAddress > req.startAddress;
|
||||||
|
|
||||||
// Pre-compute total bytes for progress
|
// Pre-compute total bytes for progress
|
||||||
uint64_t totalBytes = 0;
|
uint64_t totalBytes = 0;
|
||||||
for (const auto& r : regions) {
|
for (const auto& r : regions) {
|
||||||
if (req.filterExecutable && !r.executable) continue;
|
if (req.filterExecutable && !r.executable) continue;
|
||||||
if (req.filterWritable && !r.writable) continue;
|
if (req.filterWritable && !r.writable) continue;
|
||||||
totalBytes += r.size;
|
uint64_t rStart = r.base, rEnd = r.base + r.size;
|
||||||
|
if (hasRange) {
|
||||||
|
if (rEnd <= req.startAddress || rStart >= req.endAddress) continue;
|
||||||
|
rStart = qMax(rStart, req.startAddress);
|
||||||
|
rEnd = qMin(rEnd, req.endAddress);
|
||||||
|
}
|
||||||
|
totalBytes += rEnd - rStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
qDebug() << "[scan] total scannable:" << (totalBytes / 1024) << "KB across filtered regions";
|
qDebug() << "[scan] total scannable:" << (totalBytes / 1024) << "KB across filtered regions";
|
||||||
@@ -450,21 +521,36 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
if (req.filterExecutable && !region.executable) continue;
|
if (req.filterExecutable && !region.executable) continue;
|
||||||
if (req.filterWritable && !region.writable) continue;
|
if (req.filterWritable && !region.writable) continue;
|
||||||
|
|
||||||
if ((uint64_t)patternLen > region.size) {
|
// Clip region to requested address range
|
||||||
scannedBytes += region.size;
|
uint64_t regStart = region.base;
|
||||||
|
uint64_t regEnd = region.base + region.size;
|
||||||
|
if (hasRange) {
|
||||||
|
if (regEnd <= req.startAddress || regStart >= req.endAddress) {
|
||||||
|
// Entirely outside range — skip
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
regStart = qMax(regStart, req.startAddress);
|
||||||
|
regEnd = qMin(regEnd, req.endAddress);
|
||||||
|
}
|
||||||
|
uint64_t regSize = regEnd - regStart;
|
||||||
|
if (regSize == 0) continue;
|
||||||
|
|
||||||
|
if ((uint64_t)patternLen > regSize) {
|
||||||
|
scannedBytes += regSize;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int overlap = patternLen - 1;
|
const int overlap = patternLen - 1;
|
||||||
QByteArray chunk(qMin((uint64_t)kChunk, region.size), Qt::Uninitialized);
|
QByteArray chunk(qMin((uint64_t)kChunk, regSize), Qt::Uninitialized);
|
||||||
|
uint64_t regOffset = regStart - region.base; // offset within provider region
|
||||||
|
|
||||||
for (uint64_t off = 0; off < region.size; ) {
|
for (uint64_t off = 0; off < regSize; ) {
|
||||||
if (m_abort.load()) break;
|
if (m_abort.load()) break;
|
||||||
|
|
||||||
uint64_t remaining = region.size - off;
|
uint64_t remaining = regSize - off;
|
||||||
int readLen = (int)qMin((uint64_t)chunk.size(), remaining);
|
int readLen = (int)qMin((uint64_t)chunk.size(), remaining);
|
||||||
|
|
||||||
if (!prov->read(region.base + off, chunk.data(), readLen)) {
|
if (!prov->read(regStart + off, chunk.data(), readLen)) {
|
||||||
// Skip unreadable chunk
|
// Skip unreadable chunk
|
||||||
off += readLen;
|
off += readLen;
|
||||||
scannedBytes += readLen;
|
scannedBytes += readLen;
|
||||||
@@ -474,24 +560,38 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
int scanEnd = readLen - patternLen;
|
int scanEnd = readLen - patternLen;
|
||||||
const char* data = chunk.constData();
|
const char* data = chunk.constData();
|
||||||
|
|
||||||
for (int i = 0; i <= scanEnd; i += alignment) {
|
if (isUnknown) {
|
||||||
bool match = true;
|
// Unknown value: capture every aligned address
|
||||||
for (int j = 0; j < patternLen; j++) {
|
for (int i = 0; i <= scanEnd; i += alignment) {
|
||||||
if ((data[i + j] & msk[j]) != (pat[j] & msk[j])) {
|
|
||||||
match = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (match) {
|
|
||||||
ScanResult r;
|
ScanResult r;
|
||||||
r.address = region.base + off + (uint64_t)i;
|
r.address = regStart + off + (uint64_t)i;
|
||||||
r.regionModule = region.moduleName;
|
r.scanValue = QByteArray(data + i, valSize);
|
||||||
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
|
|
||||||
results.append(r);
|
results.append(r);
|
||||||
|
|
||||||
if (results.size() >= req.maxResults)
|
if (results.size() >= req.maxResults)
|
||||||
goto done;
|
goto done;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Exact pattern match
|
||||||
|
for (int i = 0; i <= scanEnd; i += alignment) {
|
||||||
|
bool match = true;
|
||||||
|
for (int j = 0; j < patternLen; j++) {
|
||||||
|
if ((data[i + j] & msk[j]) != (pat[j] & msk[j])) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
ScanResult r;
|
||||||
|
r.address = regStart + off + (uint64_t)i;
|
||||||
|
r.regionModule = region.moduleName;
|
||||||
|
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
|
||||||
|
results.append(r);
|
||||||
|
|
||||||
|
if (results.size() >= req.maxResults)
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance with overlap to catch patterns that straddle chunks
|
// Advance with overlap to catch patterns that straddle chunks
|
||||||
@@ -522,6 +622,7 @@ done:
|
|||||||
|
|
||||||
void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
|
void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition, ValueType valueType,
|
||||||
const QByteArray& filterPattern,
|
const QByteArray& filterPattern,
|
||||||
const QByteArray& filterMask) {
|
const QByteArray& filterMask) {
|
||||||
if (isRunning()) return;
|
if (isRunning()) return;
|
||||||
@@ -541,14 +642,15 @@ void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
|
|||||||
|
|
||||||
watcher->setFuture(QtConcurrent::run(
|
watcher->setFuture(QtConcurrent::run(
|
||||||
[this, provider, results = std::move(results), readSize,
|
[this, provider, results = std::move(results), readSize,
|
||||||
filterPattern, filterMask]() mutable {
|
condition, valueType, filterPattern, filterMask]() mutable {
|
||||||
return runRescan(provider, std::move(results), readSize,
|
return runRescan(provider, std::move(results), readSize,
|
||||||
filterPattern, filterMask);
|
condition, valueType, filterPattern, filterMask);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition, ValueType valueType,
|
||||||
const QByteArray& filterPattern,
|
const QByteArray& filterPattern,
|
||||||
const QByteArray& filterMask) {
|
const QByteArray& filterMask) {
|
||||||
QElapsedTimer timer;
|
QElapsedTimer timer;
|
||||||
@@ -557,9 +659,17 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
int total = results.size();
|
int total = results.size();
|
||||||
if (total == 0 || !prov) return results;
|
if (total == 0 || !prov) return results;
|
||||||
|
|
||||||
bool hasFilter = !filterPattern.isEmpty();
|
bool hasExactFilter = !filterPattern.isEmpty() && condition == ScanCondition::ExactValue;
|
||||||
|
bool hasComparison = (condition == ScanCondition::Changed ||
|
||||||
|
condition == ScanCondition::Unchanged ||
|
||||||
|
condition == ScanCondition::Increased ||
|
||||||
|
condition == ScanCondition::Decreased);
|
||||||
|
bool needsFilter = hasExactFilter || hasComparison;
|
||||||
|
|
||||||
qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize
|
qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize
|
||||||
<< "filter:" << (hasFilter ? "yes" : "no");
|
<< "condition:" << (int)condition
|
||||||
|
<< "exactFilter:" << (hasExactFilter ? "yes" : "no")
|
||||||
|
<< "comparison:" << (hasComparison ? "yes" : "no");
|
||||||
|
|
||||||
// Save previous values
|
// Save previous values
|
||||||
for (auto& r : results)
|
for (auto& r : results)
|
||||||
@@ -579,8 +689,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
uint64_t totalBytesRead = 0;
|
uint64_t totalBytesRead = 0;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
// Track which results matched the filter (by original index)
|
// Track which results matched (by original index)
|
||||||
QVector<bool> matched(total, !hasFilter); // if no filter, all match
|
QVector<bool> matched(total, !needsFilter); // if no filter, all match
|
||||||
|
|
||||||
while (i < total && !m_abort.load()) {
|
while (i < total && !m_abort.load()) {
|
||||||
uint64_t spanBase = results[order[i]].address;
|
uint64_t spanBase = results[order[i]].address;
|
||||||
@@ -604,8 +714,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
int off = (int)(r.address - spanBase);
|
int off = (int)(r.address - spanBase);
|
||||||
r.scanValue = chunk.mid(off, readSize);
|
r.scanValue = chunk.mid(off, readSize);
|
||||||
|
|
||||||
// Apply filter: compare re-read bytes against the new pattern
|
// Apply exact-value filter
|
||||||
if (hasFilter) {
|
if (hasExactFilter) {
|
||||||
int patLen = filterPattern.size();
|
int patLen = filterPattern.size();
|
||||||
if (r.scanValue.size() >= patLen) {
|
if (r.scanValue.size() >= patLen) {
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
@@ -621,6 +731,18 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
matched[idx] = ok;
|
matched[idx] = ok;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply comparison-based filter
|
||||||
|
if (hasComparison && !r.previousValue.isEmpty()) {
|
||||||
|
int cmp = compareTyped(r.scanValue, r.previousValue, valueType);
|
||||||
|
switch (condition) {
|
||||||
|
case ScanCondition::Changed: matched[idx] = (cmp != 0); break;
|
||||||
|
case ScanCondition::Unchanged: matched[idx] = (cmp == 0); break;
|
||||||
|
case ScanCondition::Increased: matched[idx] = (cmp > 0); break;
|
||||||
|
case ScanCondition::Decreased: matched[idx] = (cmp < 0); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks++;
|
chunks++;
|
||||||
@@ -637,7 +759,7 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out non-matching results
|
// Filter out non-matching results
|
||||||
if (hasFilter) {
|
if (needsFilter) {
|
||||||
QVector<ScanResult> filtered;
|
QVector<ScanResult> filtered;
|
||||||
filtered.reserve(total);
|
filtered.reserve(total);
|
||||||
for (int k = 0; k < total; k++) {
|
for (int k = 0; k < total; k++) {
|
||||||
|
|||||||
@@ -10,26 +10,6 @@
|
|||||||
|
|
||||||
namespace rcx {
|
namespace rcx {
|
||||||
|
|
||||||
// ── Scan request / result ──
|
|
||||||
|
|
||||||
struct ScanRequest {
|
|
||||||
QByteArray pattern; // literal bytes to match
|
|
||||||
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
|
|
||||||
|
|
||||||
bool filterExecutable = false; // only scan +x regions
|
|
||||||
bool filterWritable = false; // only scan +w regions
|
|
||||||
|
|
||||||
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
|
|
||||||
int maxResults = 50000;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct ScanResult {
|
|
||||||
uint64_t address;
|
|
||||||
QString regionModule;
|
|
||||||
QByteArray scanValue; // cached bytes at scan/update time
|
|
||||||
QByteArray previousValue; // value before last update
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Value scan types ──
|
// ── Value scan types ──
|
||||||
|
|
||||||
enum class ValueType {
|
enum class ValueType {
|
||||||
@@ -41,6 +21,43 @@ enum class ValueType {
|
|||||||
HexBytes
|
HexBytes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Scan condition (Cheat Engine-style) ──
|
||||||
|
|
||||||
|
enum class ScanCondition {
|
||||||
|
ExactValue, // first scan + rescan: match specific bytes
|
||||||
|
UnknownValue, // first scan only: capture all aligned addresses
|
||||||
|
Changed, // rescan: current != previous
|
||||||
|
Unchanged, // rescan: current == previous
|
||||||
|
Increased, // rescan: current > previous (numeric)
|
||||||
|
Decreased // rescan: current < previous (numeric)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Scan request / result ──
|
||||||
|
|
||||||
|
struct ScanRequest {
|
||||||
|
QByteArray pattern; // literal bytes to match (empty for UnknownValue)
|
||||||
|
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
|
||||||
|
|
||||||
|
bool filterExecutable = false; // only scan +x regions
|
||||||
|
bool filterWritable = false; // only scan +w regions
|
||||||
|
|
||||||
|
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
|
||||||
|
int maxResults = 50000;
|
||||||
|
|
||||||
|
ScanCondition condition = ScanCondition::ExactValue;
|
||||||
|
int valueSize = 4; // bytes per value (for unknown scans)
|
||||||
|
|
||||||
|
uint64_t startAddress = 0; // 0 = no limit (scan all regions)
|
||||||
|
uint64_t endAddress = 0; // 0 = no limit (scan all regions)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ScanResult {
|
||||||
|
uint64_t address;
|
||||||
|
QString regionModule;
|
||||||
|
QByteArray scanValue; // cached bytes at scan/update time
|
||||||
|
QByteArray previousValue; // value before last update
|
||||||
|
};
|
||||||
|
|
||||||
// ── Pattern parsing ──
|
// ── Pattern parsing ──
|
||||||
|
|
||||||
// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask.
|
// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask.
|
||||||
@@ -57,6 +74,9 @@ bool serializeValue(ValueType type, const QString& input,
|
|||||||
// Natural alignment for a value type (used as default alignment for value scans).
|
// Natural alignment for a value type (used as default alignment for value scans).
|
||||||
int naturalAlignment(ValueType type);
|
int naturalAlignment(ValueType type);
|
||||||
|
|
||||||
|
// Byte-size for a value type (used for unknown scans and rescan read size).
|
||||||
|
int valueSizeForType(ValueType type);
|
||||||
|
|
||||||
// ── Scan engine ──
|
// ── Scan engine ──
|
||||||
|
|
||||||
class ScanEngine : public QObject {
|
class ScanEngine : public QObject {
|
||||||
@@ -67,6 +87,8 @@ public:
|
|||||||
void start(std::shared_ptr<Provider> provider, const ScanRequest& req);
|
void start(std::shared_ptr<Provider> provider, const ScanRequest& req);
|
||||||
void startRescan(std::shared_ptr<Provider> provider,
|
void startRescan(std::shared_ptr<Provider> provider,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition = ScanCondition::ExactValue,
|
||||||
|
ValueType valueType = ValueType::Int32,
|
||||||
const QByteArray& filterPattern = {},
|
const QByteArray& filterPattern = {},
|
||||||
const QByteArray& filterMask = {});
|
const QByteArray& filterMask = {});
|
||||||
void abort();
|
void abort();
|
||||||
@@ -82,6 +104,7 @@ private:
|
|||||||
QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req);
|
QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req);
|
||||||
QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov,
|
QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition, ValueType valueType,
|
||||||
const QByteArray& filterPattern,
|
const QByteArray& filterPattern,
|
||||||
const QByteArray& filterMask);
|
const QByteArray& filterMask);
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
m_typeCombo->setCurrentIndex(2); // default: int32
|
m_typeCombo->setCurrentIndex(2); // default: int32
|
||||||
inputRow->addWidget(m_typeCombo);
|
inputRow->addWidget(m_typeCombo);
|
||||||
|
|
||||||
|
m_condLabel = new QLabel(QStringLiteral("Scan:"), this);
|
||||||
|
inputRow->addWidget(m_condLabel);
|
||||||
|
|
||||||
|
m_condCombo = new QComboBox(this);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Exact Value"), (int)ScanCondition::ExactValue);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Unknown Value"), (int)ScanCondition::UnknownValue);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Changed"), (int)ScanCondition::Changed);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Unchanged"), (int)ScanCondition::Unchanged);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Increased"), (int)ScanCondition::Increased);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Decreased"), (int)ScanCondition::Decreased);
|
||||||
|
inputRow->addWidget(m_condCombo);
|
||||||
|
|
||||||
m_valueLabel = new QLabel(QStringLiteral("Value:"), this);
|
m_valueLabel = new QLabel(QStringLiteral("Value:"), this);
|
||||||
inputRow->addWidget(m_valueLabel);
|
inputRow->addWidget(m_valueLabel);
|
||||||
|
|
||||||
@@ -112,6 +124,9 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this);
|
m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this);
|
||||||
filterRow->addWidget(m_writeCheck);
|
filterRow->addWidget(m_writeCheck);
|
||||||
|
|
||||||
|
m_structOnlyCheck = new QCheckBox(QStringLiteral("Current Struct"), this);
|
||||||
|
filterRow->addWidget(m_structOnlyCheck);
|
||||||
|
|
||||||
filterRow->addStretch();
|
filterRow->addStretch();
|
||||||
|
|
||||||
m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")),
|
m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")),
|
||||||
@@ -168,12 +183,15 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
QStringLiteral("Copy Address"), this);
|
QStringLiteral("Copy Address"), this);
|
||||||
m_copyBtn->setEnabled(false);
|
m_copyBtn->setEnabled(false);
|
||||||
actionRow->addWidget(m_copyBtn);
|
actionRow->addWidget(m_copyBtn);
|
||||||
|
actionRow->addSpacing(20); // room for resize grip when floating
|
||||||
|
|
||||||
mainLayout->addLayout(actionRow);
|
mainLayout->addLayout(actionRow);
|
||||||
|
|
||||||
// ── Initial state: signature mode ──
|
// ── Initial state: signature mode ──
|
||||||
m_typeLabel->hide();
|
m_typeLabel->hide();
|
||||||
m_typeCombo->hide();
|
m_typeCombo->hide();
|
||||||
|
m_condLabel->hide();
|
||||||
|
m_condCombo->hide();
|
||||||
m_valueLabel->hide();
|
m_valueLabel->hide();
|
||||||
m_valueEdit->hide();
|
m_valueEdit->hide();
|
||||||
m_execCheck->setChecked(true);
|
m_execCheck->setChecked(true);
|
||||||
@@ -181,6 +199,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
// ── Connections ──
|
// ── Connections ──
|
||||||
connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||||
this, &ScannerPanel::onModeChanged);
|
this, &ScannerPanel::onModeChanged);
|
||||||
|
connect(m_condCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||||
|
this, &ScannerPanel::onConditionChanged);
|
||||||
connect(m_scanBtn, &QPushButton::clicked,
|
connect(m_scanBtn, &QPushButton::clicked,
|
||||||
this, &ScannerPanel::onScanClicked);
|
this, &ScannerPanel::onScanClicked);
|
||||||
connect(m_updateBtn, &QPushButton::clicked,
|
connect(m_updateBtn, &QPushButton::clicked,
|
||||||
@@ -241,6 +261,10 @@ void ScannerPanel::setProviderGetter(ProviderGetter getter) {
|
|||||||
m_providerGetter = std::move(getter);
|
m_providerGetter = std::move(getter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScannerPanel::setBoundsGetter(BoundsGetter getter) {
|
||||||
|
m_boundsGetter = std::move(getter);
|
||||||
|
}
|
||||||
|
|
||||||
void ScannerPanel::setEditorFont(const QFont& font) {
|
void ScannerPanel::setEditorFont(const QFont& font) {
|
||||||
m_resultTable->setFont(font);
|
m_resultTable->setFont(font);
|
||||||
QFontMetrics fm(font);
|
QFontMetrics fm(font);
|
||||||
@@ -251,15 +275,18 @@ void ScannerPanel::setEditorFont(const QFont& font) {
|
|||||||
m_valueEdit->setFont(font);
|
m_valueEdit->setFont(font);
|
||||||
m_modeCombo->setFont(font);
|
m_modeCombo->setFont(font);
|
||||||
m_typeCombo->setFont(font);
|
m_typeCombo->setFont(font);
|
||||||
|
m_condCombo->setFont(font);
|
||||||
m_statusLabel->setFont(font);
|
m_statusLabel->setFont(font);
|
||||||
m_scanBtn->setFont(font);
|
m_scanBtn->setFont(font);
|
||||||
m_gotoBtn->setFont(font);
|
m_gotoBtn->setFont(font);
|
||||||
m_copyBtn->setFont(font);
|
m_copyBtn->setFont(font);
|
||||||
m_patternLabel->setFont(font);
|
m_patternLabel->setFont(font);
|
||||||
m_typeLabel->setFont(font);
|
m_typeLabel->setFont(font);
|
||||||
|
m_condLabel->setFont(font);
|
||||||
m_valueLabel->setFont(font);
|
m_valueLabel->setFont(font);
|
||||||
m_execCheck->setFont(font);
|
m_execCheck->setFont(font);
|
||||||
m_writeCheck->setFont(font);
|
m_writeCheck->setFont(font);
|
||||||
|
m_structOnlyCheck->setFont(font);
|
||||||
m_updateBtn->setFont(font);
|
m_updateBtn->setFont(font);
|
||||||
updateComboWidth();
|
updateComboWidth();
|
||||||
}
|
}
|
||||||
@@ -280,14 +307,29 @@ void ScannerPanel::onModeChanged(int index) {
|
|||||||
|
|
||||||
m_typeLabel->setVisible(!isSig);
|
m_typeLabel->setVisible(!isSig);
|
||||||
m_typeCombo->setVisible(!isSig);
|
m_typeCombo->setVisible(!isSig);
|
||||||
|
m_condLabel->setVisible(!isSig);
|
||||||
|
m_condCombo->setVisible(!isSig);
|
||||||
|
|
||||||
|
// Enable/disable value input based on condition
|
||||||
|
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
bool needsValue = !isSig && (cond == ScanCondition::ExactValue);
|
||||||
m_valueLabel->setVisible(!isSig);
|
m_valueLabel->setVisible(!isSig);
|
||||||
m_valueEdit->setVisible(!isSig);
|
m_valueEdit->setVisible(!isSig);
|
||||||
|
m_valueEdit->setEnabled(needsValue);
|
||||||
|
m_valueLabel->setEnabled(needsValue);
|
||||||
|
|
||||||
// Auto-toggle filters: signatures → executable code, values → writable data
|
// Auto-toggle filters: signatures → executable code, values → writable data
|
||||||
m_execCheck->setChecked(isSig);
|
m_execCheck->setChecked(isSig);
|
||||||
m_writeCheck->setChecked(!isSig);
|
m_writeCheck->setChecked(!isSig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScannerPanel::onConditionChanged(int /*index*/) {
|
||||||
|
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
bool needsValue = (cond == ScanCondition::ExactValue);
|
||||||
|
m_valueEdit->setEnabled(needsValue);
|
||||||
|
m_valueLabel->setEnabled(needsValue);
|
||||||
|
}
|
||||||
|
|
||||||
void ScannerPanel::onScanClicked() {
|
void ScannerPanel::onScanClicked() {
|
||||||
if (m_engine->isRunning()) {
|
if (m_engine->isRunning()) {
|
||||||
m_engine->abort();
|
m_engine->abort();
|
||||||
@@ -306,12 +348,14 @@ void ScannerPanel::onScanClicked() {
|
|||||||
|
|
||||||
// Build request
|
// Build request
|
||||||
ScanRequest req = buildRequest();
|
ScanRequest req = buildRequest();
|
||||||
if (req.pattern.isEmpty())
|
if (req.condition != ScanCondition::UnknownValue && req.pattern.isEmpty())
|
||||||
return; // error already shown by buildRequest
|
return; // error already shown by buildRequest
|
||||||
|
|
||||||
m_lastScanMode = m_modeCombo->currentIndex();
|
m_lastScanMode = m_modeCombo->currentIndex();
|
||||||
if (m_lastScanMode == 1)
|
if (m_lastScanMode == 1) {
|
||||||
m_lastValueType = (ValueType)m_typeCombo->currentData().toInt();
|
m_lastValueType = (ValueType)m_typeCombo->currentData().toInt();
|
||||||
|
m_lastCondition = req.condition;
|
||||||
|
}
|
||||||
m_lastPattern = req.pattern;
|
m_lastPattern = req.pattern;
|
||||||
|
|
||||||
m_scanBtn->setText(QStringLiteral("Cancel"));
|
m_scanBtn->setText(QStringLiteral("Cancel"));
|
||||||
@@ -336,16 +380,41 @@ ScanRequest ScannerPanel::buildRequest() {
|
|||||||
} else {
|
} else {
|
||||||
// Value mode
|
// Value mode
|
||||||
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
||||||
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
|
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
|
|
||||||
return {};
|
// Comparison conditions on fresh scan → treat as unknown
|
||||||
|
if (cond == ScanCondition::Changed || cond == ScanCondition::Unchanged ||
|
||||||
|
cond == ScanCondition::Increased || cond == ScanCondition::Decreased) {
|
||||||
|
cond = ScanCondition::UnknownValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.condition = cond;
|
||||||
req.alignment = naturalAlignment(vt);
|
req.alignment = naturalAlignment(vt);
|
||||||
|
req.valueSize = valueSizeForType(vt);
|
||||||
|
|
||||||
|
if (cond == ScanCondition::UnknownValue) {
|
||||||
|
// No pattern needed — capture all aligned addresses
|
||||||
|
req.maxResults = 10000000;
|
||||||
|
} else {
|
||||||
|
// Exact value mode
|
||||||
|
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
|
||||||
|
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.filterExecutable = m_execCheck->isChecked();
|
req.filterExecutable = m_execCheck->isChecked();
|
||||||
req.filterWritable = m_writeCheck->isChecked();
|
req.filterWritable = m_writeCheck->isChecked();
|
||||||
|
|
||||||
|
if (m_structOnlyCheck->isChecked() && m_boundsGetter) {
|
||||||
|
auto bounds = m_boundsGetter();
|
||||||
|
if (bounds.size > 0) {
|
||||||
|
req.startAddress = bounds.start;
|
||||||
|
req.endAddress = bounds.start + bounds.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +424,11 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
|
|||||||
m_results = std::move(results);
|
m_results = std::move(results);
|
||||||
|
|
||||||
// Bytes are cached by the engine during scan.
|
// Bytes are cached by the engine during scan.
|
||||||
// Value mode: override with exact search pattern (engine caches raw chunk bytes).
|
// Value mode (exact): override with exact search pattern (engine caches raw chunk bytes).
|
||||||
|
// Unknown mode: keep engine-captured bytes as-is (they're the baseline).
|
||||||
for (auto& r : m_results) {
|
for (auto& r : m_results) {
|
||||||
r.previousValue.clear();
|
r.previousValue.clear();
|
||||||
if (m_lastScanMode == 1)
|
if (m_lastScanMode == 1 && m_lastCondition == ScanCondition::ExactValue)
|
||||||
r.scanValue = m_lastPattern;
|
r.scanValue = m_lastPattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,8 +442,10 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int n = m_results.size();
|
int n = m_results.size();
|
||||||
m_statusLabel->setText(QStringLiteral("%1 result%2")
|
if (m_lastCondition == ScanCondition::UnknownValue && n >= 10000000)
|
||||||
.arg(n).arg(n == 1 ? "" : "s"));
|
m_statusLabel->setText(QStringLiteral("%1 results (capped — narrow with Re-scan)").arg(n));
|
||||||
|
else
|
||||||
|
m_statusLabel->setText(QStringLiteral("%1 result%2").arg(n).arg(n == 1 ? "" : "s"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScannerPanel::populateTable(bool showPrevious) {
|
void ScannerPanel::populateTable(bool showPrevious) {
|
||||||
@@ -425,29 +497,41 @@ void ScannerPanel::onUpdateClicked() {
|
|||||||
|
|
||||||
int readSize = (m_lastScanMode == 1) ? valueSize() : 16;
|
int readSize = (m_lastScanMode == 1) ? valueSize() : 16;
|
||||||
|
|
||||||
// Build filter from current input field
|
// Determine rescan condition
|
||||||
|
ScanCondition cond = ScanCondition::ExactValue;
|
||||||
|
if (m_lastScanMode == 1)
|
||||||
|
cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
|
||||||
|
// For UnknownValue on rescan, just re-read all (update only, no filter)
|
||||||
|
if (cond == ScanCondition::UnknownValue)
|
||||||
|
cond = ScanCondition::ExactValue; // with empty filter = update only
|
||||||
|
|
||||||
|
// Build filter from current input field (only for ExactValue condition)
|
||||||
QByteArray filterPattern, filterMask;
|
QByteArray filterPattern, filterMask;
|
||||||
if (m_lastScanMode == 0) {
|
if (cond == ScanCondition::ExactValue) {
|
||||||
// Signature mode
|
if (m_lastScanMode == 0) {
|
||||||
QString err;
|
// Signature mode
|
||||||
if (!m_patternEdit->text().trimmed().isEmpty()) {
|
QString err;
|
||||||
if (!parseSignature(m_patternEdit->text(), filterPattern, filterMask, &err)) {
|
if (!m_patternEdit->text().trimmed().isEmpty()) {
|
||||||
m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err));
|
if (!parseSignature(m_patternEdit->text(), filterPattern, filterMask, &err)) {
|
||||||
return;
|
m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
// Value mode — exact value filter
|
||||||
// Value mode
|
QString err;
|
||||||
QString err;
|
if (!m_valueEdit->text().trimmed().isEmpty()) {
|
||||||
if (!m_valueEdit->text().trimmed().isEmpty()) {
|
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
||||||
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
if (!serializeValue(vt, m_valueEdit->text(), filterPattern, filterMask, &err)) {
|
||||||
if (!serializeValue(vt, m_valueEdit->text(), filterPattern, filterMask, &err)) {
|
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
|
||||||
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
|
return;
|
||||||
return;
|
}
|
||||||
|
m_lastValueType = vt;
|
||||||
}
|
}
|
||||||
m_lastValueType = vt;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Comparison conditions (Changed/Unchanged/Increased/Decreased) don't need a filter pattern
|
||||||
|
|
||||||
// Update last pattern so display uses the new value
|
// Update last pattern so display uses the new value
|
||||||
if (!filterPattern.isEmpty())
|
if (!filterPattern.isEmpty())
|
||||||
@@ -460,7 +544,8 @@ void ScannerPanel::onUpdateClicked() {
|
|||||||
m_progressBar->setValue(0);
|
m_progressBar->setValue(0);
|
||||||
m_progressBar->show();
|
m_progressBar->show();
|
||||||
|
|
||||||
m_engine->startRescan(prov, m_results, readSize, filterPattern, filterMask);
|
m_engine->startRescan(prov, m_results, readSize, cond, m_lastValueType,
|
||||||
|
filterPattern, filterMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScannerPanel::onRescanFinished(QVector<ScanResult> results) {
|
void ScannerPanel::onRescanFinished(QVector<ScanResult> results) {
|
||||||
@@ -666,12 +751,14 @@ void ScannerPanel::applyTheme(const Theme& theme) {
|
|||||||
theme.border.name(), theme.hover.name());
|
theme.border.name(), theme.hover.name());
|
||||||
m_modeCombo->setStyleSheet(comboStyle);
|
m_modeCombo->setStyleSheet(comboStyle);
|
||||||
m_typeCombo->setStyleSheet(comboStyle);
|
m_typeCombo->setStyleSheet(comboStyle);
|
||||||
|
m_condCombo->setStyleSheet(comboStyle);
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
QPalette lp;
|
QPalette lp;
|
||||||
lp.setColor(QPalette::WindowText, theme.textDim);
|
lp.setColor(QPalette::WindowText, theme.textDim);
|
||||||
m_patternLabel->setPalette(lp);
|
m_patternLabel->setPalette(lp);
|
||||||
m_typeLabel->setPalette(lp);
|
m_typeLabel->setPalette(lp);
|
||||||
|
m_condLabel->setPalette(lp);
|
||||||
m_valueLabel->setPalette(lp);
|
m_valueLabel->setPalette(lp);
|
||||||
m_statusLabel->setPalette(lp);
|
m_statusLabel->setPalette(lp);
|
||||||
|
|
||||||
@@ -680,6 +767,7 @@ void ScannerPanel::applyTheme(const Theme& theme) {
|
|||||||
cp.setColor(QPalette::WindowText, theme.textDim);
|
cp.setColor(QPalette::WindowText, theme.textDim);
|
||||||
m_execCheck->setPalette(cp);
|
m_execCheck->setPalette(cp);
|
||||||
m_writeCheck->setPalette(cp);
|
m_writeCheck->setPalette(cp);
|
||||||
|
m_structOnlyCheck->setPalette(cp);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
QString btnStyle = QStringLiteral(
|
QString btnStyle = QStringLiteral(
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public:
|
|||||||
using ProviderGetter = std::function<std::shared_ptr<Provider>()>;
|
using ProviderGetter = std::function<std::shared_ptr<Provider>()>;
|
||||||
void setProviderGetter(ProviderGetter getter);
|
void setProviderGetter(ProviderGetter getter);
|
||||||
|
|
||||||
|
struct StructBounds { uint64_t start = 0; uint64_t size = 0; };
|
||||||
|
using BoundsGetter = std::function<StructBounds()>;
|
||||||
|
void setBoundsGetter(BoundsGetter getter);
|
||||||
|
|
||||||
void setEditorFont(const QFont& font);
|
void setEditorFont(const QFont& font);
|
||||||
void applyTheme(const Theme& theme);
|
void applyTheme(const Theme& theme);
|
||||||
|
|
||||||
@@ -52,6 +56,9 @@ public:
|
|||||||
QPushButton* gotoButton() const { return m_gotoBtn; }
|
QPushButton* gotoButton() const { return m_gotoBtn; }
|
||||||
QPushButton* copyButton() const { return m_copyBtn; }
|
QPushButton* copyButton() const { return m_copyBtn; }
|
||||||
ScanEngine* engine() const { return m_engine; }
|
ScanEngine* engine() const { return m_engine; }
|
||||||
|
QComboBox* condCombo() const { return m_condCombo; }
|
||||||
|
QLabel* condLabel() const { return m_condLabel; }
|
||||||
|
QCheckBox* structOnlyCheck() const { return m_structOnlyCheck; }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void goToAddress(uint64_t address);
|
void goToAddress(uint64_t address);
|
||||||
@@ -72,18 +79,23 @@ private:
|
|||||||
void populateTable(bool showPrevious);
|
void populateTable(bool showPrevious);
|
||||||
void updateComboWidth();
|
void updateComboWidth();
|
||||||
|
|
||||||
|
void onConditionChanged(int index);
|
||||||
|
|
||||||
// Input widgets
|
// Input widgets
|
||||||
QComboBox* m_modeCombo; // Signature / Value
|
QComboBox* m_modeCombo; // Signature / Value
|
||||||
QLineEdit* m_patternEdit; // Signature pattern input
|
QLineEdit* m_patternEdit; // Signature pattern input
|
||||||
QComboBox* m_typeCombo; // Value type dropdown
|
QComboBox* m_typeCombo; // Value type dropdown
|
||||||
|
QComboBox* m_condCombo; // Scan condition (Exact/Unknown/Changed/...)
|
||||||
QLineEdit* m_valueEdit; // Value input
|
QLineEdit* m_valueEdit; // Value input
|
||||||
QLabel* m_patternLabel;
|
QLabel* m_patternLabel;
|
||||||
QLabel* m_typeLabel;
|
QLabel* m_typeLabel;
|
||||||
|
QLabel* m_condLabel;
|
||||||
QLabel* m_valueLabel;
|
QLabel* m_valueLabel;
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
QCheckBox* m_execCheck;
|
QCheckBox* m_execCheck;
|
||||||
QCheckBox* m_writeCheck;
|
QCheckBox* m_writeCheck;
|
||||||
|
QCheckBox* m_structOnlyCheck;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
QPushButton* m_scanBtn;
|
QPushButton* m_scanBtn;
|
||||||
@@ -100,9 +112,11 @@ private:
|
|||||||
// Engine
|
// Engine
|
||||||
ScanEngine* m_engine;
|
ScanEngine* m_engine;
|
||||||
ProviderGetter m_providerGetter;
|
ProviderGetter m_providerGetter;
|
||||||
|
BoundsGetter m_boundsGetter;
|
||||||
QVector<ScanResult> m_results;
|
QVector<ScanResult> m_results;
|
||||||
int m_lastScanMode = 0; // 0=signature, 1=value
|
int m_lastScanMode = 0; // 0=signature, 1=value
|
||||||
ValueType m_lastValueType = ValueType::Int32;
|
ValueType m_lastValueType = ValueType::Int32;
|
||||||
|
ScanCondition m_lastCondition = ScanCondition::ExactValue;
|
||||||
QByteArray m_lastPattern; // serialized search value
|
QByteArray m_lastPattern; // serialized search value
|
||||||
int m_preRescanCount = 0; // result count before last rescan
|
int m_preRescanCount = 0; // result count before last rescan
|
||||||
|
|
||||||
|
|||||||
360
src/startpage.h
Normal file
360
src/startpage.h
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "themes/thememanager.h"
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QPainterPath>
|
||||||
|
|
||||||
|
namespace rcx {
|
||||||
|
|
||||||
|
// Single-widget start page: everything painted in paintEvent.
|
||||||
|
// Zero CSS, zero Fusion conflicts, zero child-widget styling issues.
|
||||||
|
|
||||||
|
class StartPageWidget : public QDialog {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit StartPageWidget(QWidget* parent = nullptr) : QDialog(parent) {
|
||||||
|
setWindowFlags(Qt::FramelessWindowHint | Qt::Dialog);
|
||||||
|
setMouseTracking(true);
|
||||||
|
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||||
|
|
||||||
|
m_search = new QLineEdit(this);
|
||||||
|
m_search->setPlaceholderText("Search recent...");
|
||||||
|
m_search->setFixedHeight(30);
|
||||||
|
m_search->setMaximumWidth(330);
|
||||||
|
m_search->addAction(QIcon(":/vsicons/search.svg"), QLineEdit::TrailingPosition);
|
||||||
|
connect(m_search, &QLineEdit::textChanged, this, [this]{ buildGroups(); update(); });
|
||||||
|
|
||||||
|
loadEntries();
|
||||||
|
buildGroups();
|
||||||
|
applyTheme(ThemeManager::instance().current());
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyTheme(const Theme& t) {
|
||||||
|
m_t = t;
|
||||||
|
m_search->setStyleSheet(
|
||||||
|
"QLineEdit { background: " + t.background.name() + "; color: " + t.text.name()
|
||||||
|
+ "; border: 1px solid " + t.border.name()
|
||||||
|
+ "; padding: 2px 8px; font-size: 13px; }"
|
||||||
|
"QLineEdit:focus { border: 1px solid " + t.borderFocused.name() + "; }");
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void openProject();
|
||||||
|
void newClass();
|
||||||
|
void importSource();
|
||||||
|
void importXml();
|
||||||
|
void importPdb();
|
||||||
|
void continueClicked();
|
||||||
|
void fileSelected(const QString& path);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent*) override {
|
||||||
|
QPainter p(this);
|
||||||
|
p.setRenderHint(QPainter::Antialiasing);
|
||||||
|
|
||||||
|
const int LX = 48, TM = 36, RM = 32, GAP = 40, RW = 340;
|
||||||
|
const int rpX = width() - RW - RM;
|
||||||
|
const int lW = qMax(100, rpX - GAP - LX);
|
||||||
|
|
||||||
|
p.fillRect(rect(), m_t.background);
|
||||||
|
|
||||||
|
// ── Title ──
|
||||||
|
int y = TM;
|
||||||
|
QFont titleF = font(); titleF.setPixelSize(30); titleF.setWeight(QFont::Light);
|
||||||
|
p.setFont(titleF); p.setPen(m_t.text);
|
||||||
|
QFontMetrics titleFm(titleF);
|
||||||
|
p.drawText(LX, y + titleFm.ascent(), "Reclass");
|
||||||
|
y += titleFm.height() + 24;
|
||||||
|
|
||||||
|
// ── Headings (left + right at same y) ──
|
||||||
|
QFont headF = font(); headF.setPixelSize(20); headF.setWeight(QFont::DemiBold);
|
||||||
|
p.setFont(headF); QFontMetrics headFm(headF);
|
||||||
|
p.drawText(LX, y + headFm.ascent(), "Open recent");
|
||||||
|
int ry = y;
|
||||||
|
p.drawText(rpX, ry + headFm.ascent(), "Get started");
|
||||||
|
ry += headFm.height() + 14;
|
||||||
|
y += headFm.height() + 14;
|
||||||
|
|
||||||
|
// ── Search bar (only child widget) ──
|
||||||
|
m_search->setGeometry(LX, y, qMin(330, lW), 30);
|
||||||
|
y += 46;
|
||||||
|
m_listTop = y;
|
||||||
|
|
||||||
|
// ── Right panel ──
|
||||||
|
drawCards(p, rpX, ry, RW);
|
||||||
|
|
||||||
|
// ── File list ──
|
||||||
|
drawFileList(p, LX, lW);
|
||||||
|
|
||||||
|
// ── Border ──
|
||||||
|
p.setPen(QPen(m_t.border, 1));
|
||||||
|
p.setBrush(Qt::NoBrush);
|
||||||
|
p.drawRect(rect().adjusted(0, 0, -1, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void mouseMoveEvent(QMouseEvent* e) override {
|
||||||
|
auto [z, i] = hitTest(e->pos());
|
||||||
|
if (z != m_hz || i != m_hi) {
|
||||||
|
m_hz = z; m_hi = i;
|
||||||
|
setCursor(z != HZ_None ? Qt::PointingHandCursor : Qt::ArrowCursor);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void mousePressEvent(QMouseEvent* e) override {
|
||||||
|
if (e->button() != Qt::LeftButton) return;
|
||||||
|
auto [z, i] = hitTest(e->pos());
|
||||||
|
if (z == HZ_Entry) emit fileSelected(m_filtered[i].path);
|
||||||
|
if (z == HZ_Group) { m_groups[i].expanded = !m_groups[i].expanded; update(); }
|
||||||
|
if (z == HZ_Card && i == 0) emit newClass();
|
||||||
|
if (z == HZ_Card && i == 1) emit openProject();
|
||||||
|
if (z == HZ_Card && i == 2) emit importSource();
|
||||||
|
if (z == HZ_Card && i == 3) emit importXml();
|
||||||
|
if (z == HZ_Card && i == 4) emit importPdb();
|
||||||
|
if (z == HZ_Continue) emit continueClicked();
|
||||||
|
}
|
||||||
|
|
||||||
|
void wheelEvent(QWheelEvent* e) override {
|
||||||
|
m_scrollY = qBound(0, m_scrollY - e->angleDelta().y() / 2, m_maxScroll);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void resizeEvent(QResizeEvent* e) override { QWidget::resizeEvent(e); update(); }
|
||||||
|
void leaveEvent(QEvent*) override { m_hz = HZ_None; m_hi = -1; setCursor(Qt::ArrowCursor); update(); }
|
||||||
|
void keyPressEvent(QKeyEvent* e) override { if (e->key() == Qt::Key_Escape) reject(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum HZ { HZ_None, HZ_Entry, HZ_Group, HZ_Card, HZ_Continue };
|
||||||
|
struct Hit { HZ zone; int idx; };
|
||||||
|
|
||||||
|
struct Entry {
|
||||||
|
QString path, fileName, dirPath;
|
||||||
|
QDateTime lastModified;
|
||||||
|
bool isExample;
|
||||||
|
};
|
||||||
|
struct Group {
|
||||||
|
QString name;
|
||||||
|
bool expanded = true;
|
||||||
|
QVector<int> entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
Theme m_t;
|
||||||
|
QLineEdit* m_search;
|
||||||
|
QVector<Entry> m_all, m_filtered;
|
||||||
|
QVector<Group> m_groups;
|
||||||
|
int m_scrollY = 0, m_maxScroll = 0, m_listTop = 0, m_contentH = 0;
|
||||||
|
|
||||||
|
HZ m_hz = HZ_None;
|
||||||
|
int m_hi = -1;
|
||||||
|
|
||||||
|
// Hit rects populated during paint
|
||||||
|
QVector<QPair<int, QRectF>> m_grpRects, m_entRects;
|
||||||
|
QRectF m_cardR[5], m_contR;
|
||||||
|
|
||||||
|
void drawIcon(QPainter& p, const QString& path, int x, int y, int sz) {
|
||||||
|
QIcon(path).paint(&p, x, y, sz, sz);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──
|
||||||
|
|
||||||
|
void loadEntries() {
|
||||||
|
m_all.clear();
|
||||||
|
QSettings s("Reclass", "Reclass");
|
||||||
|
for (const auto& path : s.value("recentFiles").toStringList()) {
|
||||||
|
QFileInfo fi(path);
|
||||||
|
if (!fi.exists()) continue;
|
||||||
|
m_all.append({fi.absoluteFilePath(), fi.fileName(), fi.absolutePath(),
|
||||||
|
fi.lastModified(), false});
|
||||||
|
}
|
||||||
|
#ifdef __APPLE__
|
||||||
|
QDir exDir(QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../Resources/examples"));
|
||||||
|
#else
|
||||||
|
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
|
||||||
|
#endif
|
||||||
|
for (const auto& fn : exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name))
|
||||||
|
m_all.append({exDir.absoluteFilePath(fn), fn, exDir.absolutePath(),
|
||||||
|
QFileInfo(exDir.filePath(fn)).lastModified(), true});
|
||||||
|
}
|
||||||
|
|
||||||
|
void buildGroups() {
|
||||||
|
QString f = m_search->text().trimmed().toLower();
|
||||||
|
m_filtered.clear();
|
||||||
|
for (const auto& e : m_all)
|
||||||
|
if (f.isEmpty() || e.fileName.toLower().contains(f) || e.dirPath.toLower().contains(f))
|
||||||
|
m_filtered.append(e);
|
||||||
|
|
||||||
|
QDate today = QDate::currentDate();
|
||||||
|
QVector<int> bk[6];
|
||||||
|
for (int i = 0; i < m_filtered.size(); i++) {
|
||||||
|
auto& e = m_filtered[i];
|
||||||
|
if (e.isExample) { bk[5].append(i); continue; }
|
||||||
|
int d = e.lastModified.date().daysTo(today);
|
||||||
|
if (d == 0) bk[0].append(i);
|
||||||
|
else if (d == 1) bk[1].append(i);
|
||||||
|
else if (d < 7) bk[2].append(i);
|
||||||
|
else if (e.lastModified.date().month() == today.month()
|
||||||
|
&& e.lastModified.date().year() == today.year()) bk[3].append(i);
|
||||||
|
else bk[4].append(i);
|
||||||
|
}
|
||||||
|
static const char* names[] = {"Today","Yesterday","This week","This month","Older","Examples"};
|
||||||
|
m_groups.clear();
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
if (!bk[i].isEmpty()) m_groups.append({names[i], true, bk[i]});
|
||||||
|
m_scrollY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing ──
|
||||||
|
|
||||||
|
void drawCards(QPainter& p, int x, int y, int w) {
|
||||||
|
struct C { const char* icon; const char* title; const char* desc; };
|
||||||
|
static const C cards[] = {
|
||||||
|
{":/vsicons/symbol-structure.svg", "New Class", "Start a new binary class definition"},
|
||||||
|
{":/vsicons/folder-opened.svg", "Open project", "Open an existing .rcx project"},
|
||||||
|
{":/vsicons/file-binary.svg", "Import from Source", "Import C/C++ header or source file"},
|
||||||
|
{":/vsicons/code.svg", "Import ReClass XML", "Import from ReClass .xml format"},
|
||||||
|
{":/vsicons/debug.svg", "Import PDB", "Import types from a .pdb symbol file"}
|
||||||
|
};
|
||||||
|
|
||||||
|
const int N = 5, CH = 84, R = 6, panelH = N * CH;
|
||||||
|
|
||||||
|
// Rounded panel background
|
||||||
|
QPainterPath clip;
|
||||||
|
clip.addRoundedRect(QRectF(x, y, w, panelH), R, R);
|
||||||
|
p.save();
|
||||||
|
p.setClipPath(clip);
|
||||||
|
p.fillRect(x, y, w, panelH, m_t.background);
|
||||||
|
|
||||||
|
for (int i = 0; i < N; i++) {
|
||||||
|
int cy = y + i * CH;
|
||||||
|
QRectF cr(x, cy, w, CH);
|
||||||
|
m_cardR[i] = cr;
|
||||||
|
bool hov = (m_hz == HZ_Card && m_hi == i);
|
||||||
|
|
||||||
|
if (hov) {
|
||||||
|
p.fillRect(cr, m_t.hover);
|
||||||
|
p.fillRect(QRectF(x, cy, 3, CH), m_t.indHoverSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon (32px, centered vertically)
|
||||||
|
int iconSz = 32;
|
||||||
|
drawIcon(p, cards[i].icon, x + 24, cy + (CH - iconSz) / 2, iconSz);
|
||||||
|
|
||||||
|
// Title + description block, centered vertically
|
||||||
|
int tx = x + 24 + iconSz + 16;
|
||||||
|
QFont tf = font(); tf.setPixelSize(15);
|
||||||
|
QFont df = font(); df.setPixelSize(12);
|
||||||
|
QFontMetrics tfm(tf), dfm(df);
|
||||||
|
int blockH = tfm.height() + 5 + dfm.height();
|
||||||
|
int by = cy + (CH - blockH) / 2;
|
||||||
|
|
||||||
|
p.setFont(tf); p.setPen(m_t.text);
|
||||||
|
p.drawText(tx, by + tfm.ascent(), cards[i].title);
|
||||||
|
p.setFont(df); p.setPen(m_t.textDim);
|
||||||
|
p.drawText(tx, by + tfm.height() + 5 + dfm.ascent(), cards[i].desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
p.restore();
|
||||||
|
|
||||||
|
// "Continue →" centered under the panel
|
||||||
|
int cy = y + panelH + 8;
|
||||||
|
QFont lf = font(); lf.setPixelSize(13);
|
||||||
|
if (m_hz == HZ_Continue) lf.setUnderline(true);
|
||||||
|
p.setFont(lf); p.setPen(m_t.indHoverSpan);
|
||||||
|
QFontMetrics lfm(lf);
|
||||||
|
QString ct = QStringLiteral("Continue \u2192");
|
||||||
|
int cw = lfm.horizontalAdvance(ct);
|
||||||
|
m_contR = QRectF(x + (w - cw) / 2, cy, cw, lfm.height());
|
||||||
|
p.drawText(int(m_contR.x()), cy + lfm.ascent(), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawFileList(QPainter& p, int x, int w) {
|
||||||
|
int listH = height() - 24 - m_listTop;
|
||||||
|
p.save();
|
||||||
|
p.setClipRect(x, m_listTop, w, listH);
|
||||||
|
|
||||||
|
int fy = m_listTop - m_scrollY;
|
||||||
|
m_grpRects.clear();
|
||||||
|
m_entRects.clear();
|
||||||
|
|
||||||
|
for (int gi = 0; gi < m_groups.size(); gi++) {
|
||||||
|
auto& g = m_groups[gi];
|
||||||
|
if (gi > 0) fy += 15;
|
||||||
|
|
||||||
|
// Group header
|
||||||
|
m_grpRects.append({gi, QRectF(x, fy, w, 28)});
|
||||||
|
p.setPen(Qt::NoPen); p.setBrush(m_t.text);
|
||||||
|
int triX = x + 8, triY = fy + 11;
|
||||||
|
QPolygonF tri;
|
||||||
|
if (g.expanded) tri << QPointF(triX,triY) << QPointF(triX+6,triY) << QPointF(triX+3,triY+6);
|
||||||
|
else tri << QPointF(triX,triY) << QPointF(triX+6,triY+3) << QPointF(triX,triY+6);
|
||||||
|
p.drawPolygon(tri);
|
||||||
|
|
||||||
|
QFont gf = font(); gf.setPixelSize(13);
|
||||||
|
p.setFont(gf); p.setPen(m_t.text);
|
||||||
|
p.drawText(triX + 14, fy + 14 + QFontMetrics(gf).ascent() / 2 - 1, g.name);
|
||||||
|
fy += 28;
|
||||||
|
|
||||||
|
if (!g.expanded) continue;
|
||||||
|
|
||||||
|
for (int ei : g.entries) {
|
||||||
|
auto& e = m_filtered[ei];
|
||||||
|
QRectF er(x, fy, w, 52);
|
||||||
|
m_entRects.append({ei, er});
|
||||||
|
if (m_hz == HZ_Entry && m_hi == ei) p.fillRect(er, m_t.hover);
|
||||||
|
|
||||||
|
drawIcon(p, e.isExample ? ":/vsicons/book.svg" : ":/vsicons/symbol-structure.svg",
|
||||||
|
x + 24, fy + 17, 18);
|
||||||
|
|
||||||
|
int tx = x + 52, avail = w - 64;
|
||||||
|
QFont nf = font(); nf.setPixelSize(14);
|
||||||
|
p.setFont(nf); p.setPen(m_t.text);
|
||||||
|
QFontMetrics nm(nf);
|
||||||
|
int ny = fy + 8;
|
||||||
|
p.drawText(tx, ny + nm.ascent(),
|
||||||
|
nm.elidedText(e.fileName, Qt::ElideMiddle, avail * 0.65));
|
||||||
|
|
||||||
|
if (!e.isExample) {
|
||||||
|
p.setPen(m_t.textDim);
|
||||||
|
QString dt = e.lastModified.toString("M/d/yyyy h:mm AP");
|
||||||
|
p.drawText(x + w - 12 - nm.horizontalAdvance(dt), ny + nm.ascent(), dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFont pf = font(); pf.setPixelSize(12);
|
||||||
|
p.setFont(pf); p.setPen(m_t.textDim);
|
||||||
|
QFontMetrics pm(pf);
|
||||||
|
p.drawText(tx, ny + nm.height() + 4 + pm.ascent(),
|
||||||
|
pm.elidedText(e.dirPath, Qt::ElideMiddle, avail));
|
||||||
|
fy += 52;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_contentH = fy + m_scrollY - m_listTop;
|
||||||
|
m_maxScroll = qMax(0, m_contentH - listH);
|
||||||
|
p.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hit testing ──
|
||||||
|
|
||||||
|
Hit hitTest(QPoint pos) const {
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
if (m_cardR[i].contains(pos)) return {HZ_Card, i};
|
||||||
|
if (m_contR.contains(pos)) return {HZ_Continue, 0};
|
||||||
|
if (pos.y() >= m_listTop && pos.y() < height() - 24) {
|
||||||
|
for (const auto& [gi, r] : m_grpRects)
|
||||||
|
if (r.contains(pos)) return {HZ_Group, gi};
|
||||||
|
for (const auto& [ei, r] : m_entRects)
|
||||||
|
if (r.contains(pos)) return {HZ_Entry, ei};
|
||||||
|
}
|
||||||
|
return {HZ_None, -1};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace rcx
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
"textDim": "#505C74",
|
"textDim": "#505C74",
|
||||||
"textMuted": "#384258",
|
"textMuted": "#384258",
|
||||||
"textFaint": "#2C3448",
|
"textFaint": "#2C3448",
|
||||||
"hover": "#121720",
|
"hover": "#181E2A",
|
||||||
"selected": "#121720",
|
"selected": "#1A2D4A",
|
||||||
"selection": "#1A2038",
|
"selection": "#1A2038",
|
||||||
"syntaxKeyword": "#5688C0",
|
"syntaxKeyword": "#5688C0",
|
||||||
"syntaxNumber": "#90B480",
|
"syntaxNumber": "#90B480",
|
||||||
|
|||||||
32
src/themes/defaults/tw.json
Normal file
32
src/themes/defaults/tw.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Light",
|
||||||
|
"background": "#e8e8ec",
|
||||||
|
"backgroundAlt": "#dcdce0",
|
||||||
|
"surface": "#d4d4d8",
|
||||||
|
"border": "#b8b8be",
|
||||||
|
"borderFocused": "#6870a0",
|
||||||
|
"button": "#ccccd0",
|
||||||
|
"text": "#1b1b22",
|
||||||
|
"textDim": "#5c5c68",
|
||||||
|
"textMuted": "#6a6a78",
|
||||||
|
"textFaint": "#8a8a94",
|
||||||
|
"hover": "#d8d8de",
|
||||||
|
"selected": "#d0d0d8",
|
||||||
|
"selection": "#b4c8e8",
|
||||||
|
"syntaxKeyword": "#4455aa",
|
||||||
|
"syntaxNumber": "#2a7a4c",
|
||||||
|
"syntaxString": "#9a4040",
|
||||||
|
"syntaxComment": "#6a7a6a",
|
||||||
|
"syntaxPreproc": "#787880",
|
||||||
|
"syntaxType": "#2e7a8a",
|
||||||
|
"indHoverSpan": "#5a68a0",
|
||||||
|
"indCmdPill": "#dcdce0",
|
||||||
|
"indDataChanged": "#2a7a4c",
|
||||||
|
"indHeatCold": "#6a6a30",
|
||||||
|
"indHeatWarm": "#a06828",
|
||||||
|
"indHeatHot": "#b83030",
|
||||||
|
"indHintGreen": "#387a44",
|
||||||
|
"markerPtr": "#b83030",
|
||||||
|
"markerCycle": "#9a7010",
|
||||||
|
"markerError": "#e8c8c8"
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"selection": "#21213A",
|
"selection": "#21213A",
|
||||||
"syntaxKeyword": "#AA9565",
|
"syntaxKeyword": "#AA9565",
|
||||||
"syntaxNumber": "#AAA98C",
|
"syntaxNumber": "#AAA98C",
|
||||||
"syntaxString": "#6B3B21",
|
"syntaxString": "#C0825A",
|
||||||
"syntaxComment": "#464646",
|
"syntaxComment": "#8A8878",
|
||||||
"syntaxPreproc": "#AA9565",
|
"syntaxPreproc": "#AA9565",
|
||||||
"syntaxType": "#6B959F",
|
"syntaxType": "#6B959F",
|
||||||
"indHoverSpan": "#AA9565",
|
"indHoverSpan": "#AA9565",
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
"indHeatCold": "#C4A44A",
|
"indHeatCold": "#C4A44A",
|
||||||
"indHeatWarm": "#AA9565",
|
"indHeatWarm": "#AA9565",
|
||||||
"indHeatHot": "#A05040",
|
"indHeatHot": "#A05040",
|
||||||
"indHintGreen": "#464646",
|
"indHintGreen": "#688A58",
|
||||||
"markerPtr": "#6B3B21",
|
"markerPtr": "#B85A42",
|
||||||
"markerCycle": "#AA9565",
|
"markerCycle": "#AA9565",
|
||||||
"markerError": "#3C2121"
|
"markerError": "#3C2121"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
|
|||||||
|
|
||||||
// ── File info ──
|
// ── File info ──
|
||||||
m_fileInfoLabel = new QLabel;
|
m_fileInfoLabel = new QLabel;
|
||||||
m_fileInfoLabel->setStyleSheet(QStringLiteral("color: #666; font-size: 10px; padding: 0 0 4px 0;"));
|
m_fileInfoLabel->setStyleSheet(QStringLiteral("color: %1; font-size: 10px; padding: 0 0 4px 0;")
|
||||||
|
.arg(tm.current().textDim.name()));
|
||||||
QString path = tm.themeFilePath(themeIndex);
|
QString path = tm.themeFilePath(themeIndex);
|
||||||
m_fileInfoLabel->setText(path.isEmpty()
|
m_fileInfoLabel->setText(path.isEmpty()
|
||||||
? QStringLiteral("Built-in theme (edits save as user copy)")
|
? QStringLiteral("Built-in theme (edits save as user copy)")
|
||||||
@@ -109,7 +110,8 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
|
|||||||
|
|
||||||
auto* hexLbl = new QLabel;
|
auto* hexLbl = new QLabel;
|
||||||
hexLbl->setFixedWidth(60);
|
hexLbl->setFixedWidth(60);
|
||||||
hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;"));
|
hexLbl->setStyleSheet(QStringLiteral("color: %1; font-size: 10px;")
|
||||||
|
.arg(tm.current().textMuted.name()));
|
||||||
row->addWidget(hexLbl);
|
row->addWidget(hexLbl);
|
||||||
|
|
||||||
row->addStretch();
|
row->addStretch();
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ ThemeManager::ThemeManager() {
|
|||||||
// ── Load built-in themes from JSON files next to the executable ──
|
// ── Load built-in themes from JSON files next to the executable ──
|
||||||
|
|
||||||
QString ThemeManager::builtInDir() const {
|
QString ThemeManager::builtInDir() const {
|
||||||
|
#ifdef Q_OS_MACOS
|
||||||
|
// In a macOS .app bundle, resources live in Contents/Resources, not Contents/MacOS
|
||||||
|
return QCoreApplication::applicationDirPath() + "/../Resources/themes";
|
||||||
|
#else
|
||||||
return QCoreApplication::applicationDirPath() + "/themes";
|
return QCoreApplication::applicationDirPath() + "/themes";
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void ThemeManager::loadBuiltInThemes() {
|
void ThemeManager::loadBuiltInThemes() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "titlebar.h"
|
#include "titlebar.h"
|
||||||
#include "themes/thememanager.h"
|
#include "themes/thememanager.h"
|
||||||
|
#include <QMenu>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QStyle>
|
#include <QStyle>
|
||||||
@@ -74,17 +75,37 @@ void TitleBarWidget::applyTheme(const Theme& theme) {
|
|||||||
// App label
|
// App label
|
||||||
m_appLabel->setStyleSheet(
|
m_appLabel->setStyleSheet(
|
||||||
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
|
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
|
||||||
.arg(theme.textDim.name()));
|
.arg(theme.text.name()));
|
||||||
|
|
||||||
// Menu bar palette — hover/bg handled by MenuBarStyle QProxyStyle.
|
// Menu bar palette — all roles used by MenuBarStyle, so live theme
|
||||||
// Set Window + Button to background so Fusion never paints a foreign color.
|
// switches don't rely on app-palette inheritance (which can stall
|
||||||
|
// once setPalette has been called on a widget).
|
||||||
{
|
{
|
||||||
QPalette mbPal = m_menuBar->palette();
|
QPalette mbPal = m_menuBar->palette();
|
||||||
mbPal.setColor(QPalette::Window, theme.background);
|
mbPal.setColor(QPalette::Window, theme.background);
|
||||||
mbPal.setColor(QPalette::Button, theme.background);
|
mbPal.setColor(QPalette::Button, theme.background);
|
||||||
mbPal.setColor(QPalette::ButtonText, theme.textDim);
|
mbPal.setColor(QPalette::ButtonText, theme.text);
|
||||||
|
mbPal.setColor(QPalette::Text, theme.text);
|
||||||
|
mbPal.setColor(QPalette::Highlight, theme.selected);
|
||||||
|
mbPal.setColor(QPalette::Link, theme.indHoverSpan);
|
||||||
|
mbPal.setColor(QPalette::AlternateBase, theme.surface);
|
||||||
|
mbPal.setColor(QPalette::Dark, theme.border);
|
||||||
|
mbPal.setColor(QPalette::Mid, theme.hover);
|
||||||
m_menuBar->setPalette(mbPal);
|
m_menuBar->setPalette(mbPal);
|
||||||
m_menuBar->setAutoFillBackground(false);
|
m_menuBar->setAutoFillBackground(false);
|
||||||
|
|
||||||
|
// Propagate to existing QMenu children so dropdown popups update too
|
||||||
|
for (auto* menu : m_menuBar->findChildren<QMenu*>()) {
|
||||||
|
QPalette mp = menu->palette();
|
||||||
|
mp.setColor(QPalette::Window, theme.background);
|
||||||
|
mp.setColor(QPalette::WindowText, theme.text);
|
||||||
|
mp.setColor(QPalette::Text, theme.text);
|
||||||
|
mp.setColor(QPalette::Highlight, theme.selected);
|
||||||
|
mp.setColor(QPalette::Link, theme.indHoverSpan);
|
||||||
|
mp.setColor(QPalette::AlternateBase, theme.surface);
|
||||||
|
mp.setColor(QPalette::Dark, theme.border);
|
||||||
|
menu->setPalette(mp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chrome buttons
|
// Chrome buttons
|
||||||
@@ -95,10 +116,10 @@ void TitleBarWidget::applyTheme(const Theme& theme) {
|
|||||||
m_btnMin->setStyleSheet(btnStyle);
|
m_btnMin->setStyleSheet(btnStyle);
|
||||||
m_btnMax->setStyleSheet(btnStyle);
|
m_btnMax->setStyleSheet(btnStyle);
|
||||||
|
|
||||||
// Close button: red hover
|
// Close button: themed red hover
|
||||||
m_btnClose->setStyleSheet(QStringLiteral(
|
m_btnClose->setStyleSheet(QStringLiteral(
|
||||||
"QToolButton { background: transparent; border: none; }"
|
"QToolButton { background: transparent; border: none; }"
|
||||||
"QToolButton:hover { background: #c42b1c; }"));
|
"QToolButton:hover { background: %1; }").arg(theme.indHeatHot.name()));
|
||||||
|
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
@@ -107,12 +128,14 @@ void TitleBarWidget::setShowIcon(bool show) {
|
|||||||
if (show) {
|
if (show) {
|
||||||
m_appLabel->setText(QString());
|
m_appLabel->setText(QString());
|
||||||
m_appLabel->setPixmap(QIcon(":/icons/class.png").pixmap(24, 24));
|
m_appLabel->setPixmap(QIcon(":/icons/class.png").pixmap(24, 24));
|
||||||
|
setFixedHeight(34);
|
||||||
} else {
|
} else {
|
||||||
m_appLabel->setPixmap(QPixmap());
|
m_appLabel->setPixmap(QPixmap());
|
||||||
m_appLabel->setText(QStringLiteral("Reclass"));
|
m_appLabel->setText(QStringLiteral("Reclass"));
|
||||||
m_appLabel->setStyleSheet(
|
m_appLabel->setStyleSheet(
|
||||||
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
|
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
|
||||||
.arg(m_theme.textDim.name()));
|
.arg(m_theme.text.name()));
|
||||||
|
setFixedHeight(32);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace rcx {
|
|||||||
struct TabInfo {
|
struct TabInfo {
|
||||||
const NodeTree* tree;
|
const NodeTree* tree;
|
||||||
QString name;
|
QString name;
|
||||||
void* subPtr; // QMdiSubWindow* as void*
|
void* subPtr; // QDockWidget* as void*
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sentinel value stored in UserRole+1 to mark the Project group node.
|
// Sentinel value stored in UserRole+1 to mark the Project group node.
|
||||||
@@ -46,11 +46,12 @@ inline void buildProjectExplorer(QStandardItemModel* model,
|
|||||||
auto nameOf = [](const Node* n) {
|
auto nameOf = [](const Node* n) {
|
||||||
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
|
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
|
||||||
};
|
};
|
||||||
auto cmpName = [&](const Entry& a, const Entry& b) {
|
|
||||||
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
|
// Helper: is a Hex padding node
|
||||||
|
auto isHexPad = [](NodeKind k) {
|
||||||
|
return k == NodeKind::Hex8 || k == NodeKind::Hex16
|
||||||
|
|| k == NodeKind::Hex32 || k == NodeKind::Hex64;
|
||||||
};
|
};
|
||||||
std::sort(types.begin(), types.end(), cmpName);
|
|
||||||
std::sort(enums.begin(), enums.end(), cmpName);
|
|
||||||
|
|
||||||
// Helper: type display string for a member node
|
// Helper: type display string for a member node
|
||||||
auto memberTypeName = [](const Node& m) -> QString {
|
auto memberTypeName = [](const Node& m) -> QString {
|
||||||
@@ -62,11 +63,10 @@ inline void buildProjectExplorer(QStandardItemModel* model,
|
|||||||
return QString::fromLatin1(kindToString(m.kind));
|
return QString::fromLatin1(kindToString(m.kind));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: is a Hex padding node
|
// TODO: re-enable sorting once startup perf is acceptable
|
||||||
auto isHexPad = [](NodeKind k) {
|
// auto countVisible = [&](const Entry& e) { ... };
|
||||||
return k == NodeKind::Hex8 || k == NodeKind::Hex16
|
// std::sort(types.begin(), types.end(), cmpChildren);
|
||||||
|| k == NodeKind::Hex32 || k == NodeKind::Hex64;
|
// std::sort(enums.begin(), enums.end(), cmpName);
|
||||||
};
|
|
||||||
|
|
||||||
for (const auto& e : types) {
|
for (const auto& e : types) {
|
||||||
QVector<int> members = e.tree->childrenOf(e.node->id);
|
QVector<int> members = e.tree->childrenOf(e.node->id);
|
||||||
|
|||||||
@@ -2627,6 +2627,122 @@ private slots:
|
|||||||
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
|
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
|
||||||
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
|
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
|
||||||
}
|
}
|
||||||
|
void testTreeLinesDepth2() {
|
||||||
|
// Diagnostic test: verify tree chars at depth 2+ with hex64 nodes
|
||||||
|
// (matches user's actual scenario — Hex64 inside pointer expansion)
|
||||||
|
NodeTree tree;
|
||||||
|
tree.baseAddress = 0;
|
||||||
|
|
||||||
|
// Root struct "Unnamed"
|
||||||
|
Node root;
|
||||||
|
root.kind = NodeKind::Struct;
|
||||||
|
root.name = "Unnamed";
|
||||||
|
root.parentId = 0;
|
||||||
|
root.offset = 0;
|
||||||
|
int ri = tree.addNode(root);
|
||||||
|
uint64_t rootId = tree.nodes[ri].id;
|
||||||
|
|
||||||
|
// First child: hex64 at depth 1
|
||||||
|
Node f1;
|
||||||
|
f1.kind = NodeKind::Hex64;
|
||||||
|
f1.name = "";
|
||||||
|
f1.parentId = rootId;
|
||||||
|
f1.offset = 0;
|
||||||
|
tree.addNode(f1);
|
||||||
|
|
||||||
|
// Ref struct "NewClass" (separate root-level definition)
|
||||||
|
Node inner;
|
||||||
|
inner.kind = NodeKind::Struct;
|
||||||
|
inner.name = "NewClass";
|
||||||
|
inner.parentId = 0;
|
||||||
|
inner.offset = 200;
|
||||||
|
int ii = tree.addNode(inner);
|
||||||
|
uint64_t innerId = tree.nodes[ii].id;
|
||||||
|
|
||||||
|
// hex64 children of NewClass
|
||||||
|
Node if1;
|
||||||
|
if1.kind = NodeKind::Hex64;
|
||||||
|
if1.name = "";
|
||||||
|
if1.parentId = innerId;
|
||||||
|
if1.offset = 0;
|
||||||
|
tree.addNode(if1);
|
||||||
|
|
||||||
|
Node if2;
|
||||||
|
if2.kind = NodeKind::Hex64;
|
||||||
|
if2.name = "";
|
||||||
|
if2.parentId = innerId;
|
||||||
|
if2.offset = 8;
|
||||||
|
tree.addNode(if2);
|
||||||
|
|
||||||
|
Node if3;
|
||||||
|
if3.kind = NodeKind::Hex64;
|
||||||
|
if3.name = "";
|
||||||
|
if3.parentId = innerId;
|
||||||
|
if3.offset = 16;
|
||||||
|
tree.addNode(if3);
|
||||||
|
|
||||||
|
// Pointer in root referencing NewClass
|
||||||
|
Node ptr;
|
||||||
|
ptr.kind = NodeKind::Pointer64;
|
||||||
|
ptr.name = "field_0008";
|
||||||
|
ptr.parentId = rootId;
|
||||||
|
ptr.offset = 8;
|
||||||
|
ptr.refId = innerId;
|
||||||
|
tree.addNode(ptr);
|
||||||
|
|
||||||
|
// Last child: hex64 at depth 1
|
||||||
|
Node f2;
|
||||||
|
f2.kind = NodeKind::Hex64;
|
||||||
|
f2.name = "";
|
||||||
|
f2.parentId = rootId;
|
||||||
|
f2.offset = 16;
|
||||||
|
tree.addNode(f2);
|
||||||
|
|
||||||
|
// Provider with pointer value
|
||||||
|
QByteArray data(256, '\0');
|
||||||
|
uint64_t ptrVal = 100;
|
||||||
|
memcpy(data.data() + 8, &ptrVal, 8);
|
||||||
|
BufferProvider prov(data);
|
||||||
|
|
||||||
|
// Compose WITH tree lines
|
||||||
|
ComposeResult result = compose(tree, prov, 0, false, true);
|
||||||
|
|
||||||
|
QStringList lines = result.text.split('\n');
|
||||||
|
|
||||||
|
// Print output with char codes for debugging
|
||||||
|
qDebug() << "=== Tree lines compose output (hex64 scenario) ===";
|
||||||
|
for (int i = 0; i < lines.size(); i++) {
|
||||||
|
// Also show hex of first 15 chars to see tree chars
|
||||||
|
QString hexChars;
|
||||||
|
for (int c = 0; c < qMin(15, lines[i].size()); c++)
|
||||||
|
hexChars += QString("U+%1 ").arg(static_cast<uint>(lines[i][c].unicode()), 4, 16, QChar('0'));
|
||||||
|
qDebug().noquote() << QString("[%1] d=%2 k=%3: %4")
|
||||||
|
.arg(i, 2).arg(result.meta[i].depth).arg((int)result.meta[i].lineKind).arg(lines[i]);
|
||||||
|
qDebug().noquote() << QString(" hex: %1").arg(hexChars);
|
||||||
|
}
|
||||||
|
qDebug() << "=== end ===";
|
||||||
|
|
||||||
|
// Verify depth-2 lines contain tree chars
|
||||||
|
QChar vertLine(0x2502); // │
|
||||||
|
QChar tee(0x251C); // ├
|
||||||
|
QChar corner(0x2514); // └
|
||||||
|
|
||||||
|
bool foundDepth2TreeChar = false;
|
||||||
|
for (int i = 0; i < result.meta.size(); i++) {
|
||||||
|
if (result.meta[i].depth == 2
|
||||||
|
&& result.meta[i].lineKind != LineKind::Footer) {
|
||||||
|
bool has = lines[i].contains(vertLine)
|
||||||
|
|| lines[i].contains(tee)
|
||||||
|
|| lines[i].contains(corner);
|
||||||
|
if (has) foundDepth2TreeChar = true;
|
||||||
|
QVERIFY2(has,
|
||||||
|
qPrintable(QString("Depth-2 line %1 missing tree chars: %2")
|
||||||
|
.arg(i).arg(lines[i])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QVERIFY2(foundDepth2TreeChar,
|
||||||
|
qPrintable("No depth-2 lines with tree chars found:\n" + result.text));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
QTEST_MAIN(TestCompose)
|
QTEST_MAIN(TestCompose)
|
||||||
|
|||||||
@@ -815,6 +815,68 @@ private slots:
|
|||||||
QCOMPARE(m_doc->tree.nodes[idx].isStatic, true);
|
QCOMPARE(m_doc->tree.nodes[idx].isStatic, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Test: clearing value history actually resets heat to 0 ──
|
||||||
|
void testClearValueHistoryResetsHeat() {
|
||||||
|
// Use a live provider so value tracking runs during refresh()
|
||||||
|
m_doc->provider = std::make_unique<BaseAwareProvider>(makeSmallBuffer(), 0);
|
||||||
|
m_ctrl->setTrackValues(true);
|
||||||
|
|
||||||
|
// Do initial refresh to populate m_lastResult.meta
|
||||||
|
m_ctrl->refresh();
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
// Find field_u32 nodeId
|
||||||
|
uint64_t targetId = 0;
|
||||||
|
for (const auto& n : m_doc->tree.nodes) {
|
||||||
|
if (n.name == "field_u32") { targetId = n.id; break; }
|
||||||
|
}
|
||||||
|
QVERIFY(targetId != 0);
|
||||||
|
|
||||||
|
// Seed value history with multiple changes to get heat > 0
|
||||||
|
auto& history = const_cast<QHash<uint64_t, ValueHistory>&>(m_ctrl->valueHistory());
|
||||||
|
history[targetId].record("val_1");
|
||||||
|
history[targetId].record("val_2");
|
||||||
|
history[targetId].record("val_3");
|
||||||
|
QVERIFY2(history[targetId].heatLevel() >= 2,
|
||||||
|
"Pre-clear: should have heat >= 2 (warm)");
|
||||||
|
|
||||||
|
// Refresh so heatLevel propagates to LineMeta
|
||||||
|
m_ctrl->refresh();
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
// Verify heat is visible in meta
|
||||||
|
bool foundHot = false;
|
||||||
|
for (const auto& lm : m_ctrl->lastResult().meta) {
|
||||||
|
if (lm.nodeId == targetId && lm.heatLevel > 0) {
|
||||||
|
foundHot = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QVERIFY2(foundHot, "Pre-clear: LineMeta should show heat > 0");
|
||||||
|
|
||||||
|
// Now simulate what the "Clear Value History" context menu does:
|
||||||
|
// remove from history map + clear subtree + refresh
|
||||||
|
history.remove(targetId);
|
||||||
|
for (int ci : m_doc->tree.subtreeIndices(targetId))
|
||||||
|
history.remove(m_doc->tree.nodes[ci].id);
|
||||||
|
|
||||||
|
m_ctrl->refresh();
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
// After clear + refresh, heatLevel must be 0 for this node
|
||||||
|
for (const auto& lm : m_ctrl->lastResult().meta) {
|
||||||
|
if (lm.nodeId == targetId) {
|
||||||
|
QCOMPARE(lm.heatLevel, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The history entry should exist again (re-recorded by refresh)
|
||||||
|
// but with only 1 unique value → heatLevel 0
|
||||||
|
QVERIFY(history.contains(targetId));
|
||||||
|
QCOMPARE(history[targetId].heatLevel(), 0);
|
||||||
|
QCOMPARE(history[targetId].uniqueCount(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
void testStaticFieldTypeChangePreservesFlags() {
|
void testStaticFieldTypeChangePreservesFlags() {
|
||||||
uint64_t rootId = m_doc->tree.nodes[0].id;
|
uint64_t rootId = m_doc->tree.nodes[0].id;
|
||||||
|
|
||||||
|
|||||||
112
tests/test_dbgdump.cpp
Normal file
112
tests/test_dbgdump.cpp
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#include <cstdio>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <windows.h>
|
||||||
|
#include <initguid.h>
|
||||||
|
#include <dbgeng.h>
|
||||||
|
|
||||||
|
int main(int argc, char* argv[])
|
||||||
|
{
|
||||||
|
const char* dumpPath = "F:\\MEMORY_EaService2024.DMP";
|
||||||
|
if (argc > 1) dumpPath = argv[1];
|
||||||
|
|
||||||
|
HRESULT hrCom = CoInitializeEx(NULL, COINIT_MULTITHREADED);
|
||||||
|
printf("CoInitializeEx: 0x%08lX\n", hrCom);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
IDebugClient* client = nullptr;
|
||||||
|
HRESULT hr = DebugCreate(IID_IDebugClient, (void**)&client);
|
||||||
|
printf("DebugCreate: 0x%08lX, client=%p\n", hr, (void*)client);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
if (FAILED(hr) || !client) {
|
||||||
|
printf("FAILED to create debug client\n");
|
||||||
|
if (SUCCEEDED(hrCom)) CoUninitialize();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
printf("Opening dump: %s\n", dumpPath);
|
||||||
|
fflush(stdout);
|
||||||
|
hr = client->OpenDumpFile(dumpPath);
|
||||||
|
printf("OpenDumpFile: 0x%08lX\n", hr);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
if (FAILED(hr)) {
|
||||||
|
printf("FAILED to open dump\n");
|
||||||
|
client->Release();
|
||||||
|
if (SUCCEEDED(hrCom)) CoUninitialize();
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
IDebugControl* ctrl = nullptr;
|
||||||
|
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
|
||||||
|
|
||||||
|
if (ctrl) {
|
||||||
|
printf("WaitForEvent(10s)...\n");
|
||||||
|
fflush(stdout);
|
||||||
|
hr = ctrl->WaitForEvent(0, 10000);
|
||||||
|
printf("WaitForEvent: 0x%08lX\n", hr);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
ULONG debugClass = 0, debugQual = 0;
|
||||||
|
hr = ctrl->GetDebuggeeType(&debugClass, &debugQual);
|
||||||
|
printf("GetDebuggeeType: 0x%08lX, class=%lu, qualifier=%lu\n",
|
||||||
|
hr, debugClass, debugQual);
|
||||||
|
printf(" -> %s\n", debugQual >= 1024 ? "DUMP" : "LIVE");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
IDebugDataSpaces* ds = nullptr;
|
||||||
|
client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
|
||||||
|
|
||||||
|
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, loaded=%lu, unloaded=%lu\n",
|
||||||
|
hr, numMods, numUnloaded);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
if (numMods > 0) {
|
||||||
|
ULONG64 base = 0;
|
||||||
|
hr = sym->GetModuleByIndex(0, &base);
|
||||||
|
printf("Module[0] base: 0x%llX (hr=0x%08lX)\n", base, hr);
|
||||||
|
fflush(stdout);
|
||||||
|
|
||||||
|
if (SUCCEEDED(hr) && base && ds) {
|
||||||
|
uint8_t buf[16] = {};
|
||||||
|
ULONG got = 0;
|
||||||
|
hr = ds->ReadVirtual(base, buf, 16, &got);
|
||||||
|
printf("ReadVirtual(0x%llX, 16): hr=0x%08lX, got=%lu\n", base, hr, got);
|
||||||
|
printf(" data: ");
|
||||||
|
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
|
||||||
|
printf("\n");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try reading kernel base directly
|
||||||
|
uint64_t ntBase = 0xfffff80123c00000ULL;
|
||||||
|
if (ds) {
|
||||||
|
uint8_t buf[16] = {};
|
||||||
|
ULONG got = 0;
|
||||||
|
hr = ds->ReadVirtual(ntBase, buf, 16, &got);
|
||||||
|
printf("ReadVirtual(nt base 0x%llX, 16): hr=0x%08lX, got=%lu\n", ntBase, hr, got);
|
||||||
|
printf(" data: ");
|
||||||
|
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
|
||||||
|
printf("\n");
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sym) sym->Release();
|
||||||
|
if (ds) ds->Release();
|
||||||
|
if (ctrl) ctrl->Release();
|
||||||
|
client->DetachProcesses();
|
||||||
|
client->Release();
|
||||||
|
|
||||||
|
printf("Done.\n");
|
||||||
|
if (SUCCEEDED(hrCom)) CoUninitialize();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -2768,6 +2768,125 @@ private slots:
|
|||||||
"Static fields should not have a separator line");
|
"Static fields should not have a separator line");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Test: disasm popup dismisses when mouse moves onto it ("see-through") ──
|
||||||
|
//
|
||||||
|
// Scenario: hover a FuncPtr row → disasm popup appears below the row.
|
||||||
|
// User moves mouse down onto the popup. The popup covers rows behind it
|
||||||
|
// but the mouse position maps to a different node's row in the viewport
|
||||||
|
// underneath, so the popup must dismiss.
|
||||||
|
void testDisasmPopupDismissesOnMouseMoveThrough() {
|
||||||
|
NodeTree tree;
|
||||||
|
tree.baseAddress = 0;
|
||||||
|
|
||||||
|
Node root;
|
||||||
|
root.kind = NodeKind::Struct;
|
||||||
|
root.structTypeName = "TestClass";
|
||||||
|
root.name = "TestClass";
|
||||||
|
root.parentId = 0;
|
||||||
|
root.offset = 0;
|
||||||
|
int ri = tree.addNode(root);
|
||||||
|
uint64_t rootId = tree.nodes[ri].id;
|
||||||
|
|
||||||
|
// FuncPtr64 at offset 0 — its value points to "code" at byte 256
|
||||||
|
Node fp;
|
||||||
|
fp.kind = NodeKind::FuncPtr64;
|
||||||
|
fp.name = "VFunc1";
|
||||||
|
fp.parentId = rootId;
|
||||||
|
fp.offset = 0;
|
||||||
|
tree.addNode(fp);
|
||||||
|
|
||||||
|
// A plain UInt64 after it so there's a non-FuncPtr row below
|
||||||
|
Node pad;
|
||||||
|
pad.kind = NodeKind::UInt64;
|
||||||
|
pad.name = "padding";
|
||||||
|
pad.parentId = rootId;
|
||||||
|
pad.offset = 8;
|
||||||
|
tree.addNode(pad);
|
||||||
|
|
||||||
|
// Buffer layout:
|
||||||
|
// [0..7] FuncPtr value = 256 (points to code bytes)
|
||||||
|
// [8..15] padding field value
|
||||||
|
// [256..383] x86 code bytes (push rbp; mov rbp,rsp; nop...; ret)
|
||||||
|
QByteArray data(512, '\0');
|
||||||
|
uint64_t codeAddr = 256;
|
||||||
|
memcpy(data.data(), &codeAddr, 8);
|
||||||
|
const uint8_t code[] = {
|
||||||
|
0x55, // push rbp
|
||||||
|
0x48, 0x89, 0xE5, // mov rbp, rsp
|
||||||
|
0x90, // nop
|
||||||
|
0x90, // nop
|
||||||
|
0x5D, // pop rbp
|
||||||
|
0xC3 // ret
|
||||||
|
};
|
||||||
|
memcpy(data.data() + 256, code, sizeof(code));
|
||||||
|
BufferProvider prov(data, "test_disasm_dismiss");
|
||||||
|
|
||||||
|
ComposeResult cr = compose(tree, prov);
|
||||||
|
m_editor->applyDocument(cr);
|
||||||
|
m_editor->setProviderRef(&prov, nullptr, &tree);
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
// Find the FuncPtr line
|
||||||
|
int fpLine = -1;
|
||||||
|
for (int i = 0; i < cr.meta.size(); ++i) {
|
||||||
|
if (isFuncPtr(cr.meta[i].nodeKind)) {
|
||||||
|
fpLine = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QVERIFY2(fpLine >= 0, "Could not find FuncPtr64 line in compose output");
|
||||||
|
|
||||||
|
// Hover over the FuncPtr value column to trigger the disasm popup
|
||||||
|
const LineMeta& lm = cr.meta[fpLine];
|
||||||
|
QString lineText;
|
||||||
|
{
|
||||||
|
long len = m_editor->scintilla()->SendScintilla(
|
||||||
|
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)fpLine);
|
||||||
|
QByteArray buf(len + 1, '\0');
|
||||||
|
m_editor->scintilla()->SendScintilla(
|
||||||
|
QsciScintillaBase::SCI_GETLINE, (uintptr_t)fpLine,
|
||||||
|
static_cast<const char*>(buf.data()));
|
||||||
|
lineText = QString::fromUtf8(buf.left(len));
|
||||||
|
}
|
||||||
|
ColumnSpan vs = m_editor->valueSpan(lm, lineText.size(),
|
||||||
|
lm.effectiveTypeW, lm.effectiveNameW);
|
||||||
|
QVERIFY2(vs.valid, "Value span for FuncPtr line is not valid");
|
||||||
|
|
||||||
|
int hoverCol = (vs.start + vs.end) / 2;
|
||||||
|
QPoint vpFP = colToViewport(m_editor->scintilla(), fpLine, hoverCol);
|
||||||
|
sendMouseMove(m_editor->scintilla()->viewport(), vpFP);
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
QWidget* popup = m_editor->disasmPopup();
|
||||||
|
QVERIFY2(popup && popup->isVisible(),
|
||||||
|
"Disasm popup should be visible after hovering the FuncPtr value");
|
||||||
|
|
||||||
|
// See-through behavior: when the user moves the mouse down from the
|
||||||
|
// viewport onto the popup, the popup's mouseMoveEvent override forwards
|
||||||
|
// the global position back to the viewport hover logic. If the row
|
||||||
|
// underneath the popup represents a different node, the popup dismisses.
|
||||||
|
//
|
||||||
|
// Simulate by sending a MouseMove event to the popup at a global
|
||||||
|
// position that maps to the CommandRow (line 0) — a non-FuncPtr row.
|
||||||
|
// sendEvent triggers the virtual mouseMoveEvent directly.
|
||||||
|
QPoint vpCmdRow = colToViewport(m_editor->scintilla(), 0, hoverCol);
|
||||||
|
QPoint globalCmdRow = m_editor->scintilla()->viewport()->mapToGlobal(vpCmdRow);
|
||||||
|
QPoint localOnPopup = popup->mapFromGlobal(globalCmdRow);
|
||||||
|
QMouseEvent moveOnPopup(QEvent::MouseMove,
|
||||||
|
QPointF(localOnPopup), QPointF(globalCmdRow),
|
||||||
|
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
|
||||||
|
QApplication::sendEvent(popup, &moveOnPopup);
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
QVERIFY2(!popup->isVisible(),
|
||||||
|
"Disasm popup must dismiss when mouseMoveEvent forwards "
|
||||||
|
"to a non-FuncPtr row underneath (see-through behavior)");
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
m_editor->setProviderRef(nullptr, nullptr, nullptr);
|
||||||
|
m_editor->applyDocument(m_result);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
QTEST_MAIN(TestEditor)
|
QTEST_MAIN(TestEditor)
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ private slots:
|
|||||||
defaults.themeIndex = 0;
|
defaults.themeIndex = 0;
|
||||||
defaults.fontName = "JetBrains Mono";
|
defaults.fontName = "JetBrains Mono";
|
||||||
defaults.menuBarTitleCase = true;
|
defaults.menuBarTitleCase = true;
|
||||||
defaults.safeMode = false;
|
|
||||||
defaults.autoStartMcp = false;
|
defaults.autoStartMcp = false;
|
||||||
|
|
||||||
OptionsDialog dlg(defaults);
|
OptionsDialog dlg(defaults);
|
||||||
@@ -93,7 +92,6 @@ private slots:
|
|||||||
input.themeIndex = 1;
|
input.themeIndex = 1;
|
||||||
input.fontName = "Consolas";
|
input.fontName = "Consolas";
|
||||||
input.menuBarTitleCase = false;
|
input.menuBarTitleCase = false;
|
||||||
input.safeMode = true;
|
|
||||||
input.autoStartMcp = true;
|
input.autoStartMcp = true;
|
||||||
|
|
||||||
OptionsDialog dlg(input);
|
OptionsDialog dlg(input);
|
||||||
@@ -102,7 +100,6 @@ private slots:
|
|||||||
QCOMPARE(r.themeIndex, 1);
|
QCOMPARE(r.themeIndex, 1);
|
||||||
QCOMPARE(r.fontName, QString("Consolas"));
|
QCOMPARE(r.fontName, QString("Consolas"));
|
||||||
QCOMPARE(r.menuBarTitleCase, false);
|
QCOMPARE(r.menuBarTitleCase, false);
|
||||||
QCOMPARE(r.safeMode, true);
|
|
||||||
QCOMPARE(r.autoStartMcp, true);
|
QCOMPARE(r.autoStartMcp, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1072,6 +1072,120 @@ private slots:
|
|||||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions
|
QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Address range filtering — "Current Struct" support
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void scan_addressRangeNoLimit() {
|
||||||
|
// startAddress=0, endAddress=0 → scan all (default behavior unchanged)
|
||||||
|
QByteArray data(32, '\x00');
|
||||||
|
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
ScanEngine engine;
|
||||||
|
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||||
|
|
||||||
|
ScanRequest req;
|
||||||
|
req.pattern = QByteArray("\xAA", 1);
|
||||||
|
req.mask = QByteArray("\xFF", 1);
|
||||||
|
|
||||||
|
engine.start(prov, req);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 3); // all 3 found
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_addressRangeClipsResults() {
|
||||||
|
// Only scan addresses [8, 20) — should find match at offset 8 and 16 but not 24
|
||||||
|
QByteArray data(32, '\x00');
|
||||||
|
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
ScanEngine engine;
|
||||||
|
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||||
|
|
||||||
|
ScanRequest req;
|
||||||
|
req.pattern = QByteArray("\xAA", 1);
|
||||||
|
req.mask = QByteArray("\xFF", 1);
|
||||||
|
req.startAddress = 8;
|
||||||
|
req.endAddress = 20;
|
||||||
|
|
||||||
|
engine.start(prov, req);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 2);
|
||||||
|
QCOMPARE(results[0].address, (uint64_t)8);
|
||||||
|
QCOMPARE(results[1].address, (uint64_t)16);
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_addressRangeOutsideData() {
|
||||||
|
// Range entirely outside data → no results
|
||||||
|
QByteArray data(16, '\xAA');
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
ScanEngine engine;
|
||||||
|
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||||
|
|
||||||
|
ScanRequest req;
|
||||||
|
req.pattern = QByteArray("\xAA", 1);
|
||||||
|
req.mask = QByteArray("\xFF", 1);
|
||||||
|
req.startAddress = 100;
|
||||||
|
req.endAddress = 200;
|
||||||
|
|
||||||
|
engine.start(prov, req);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_addressRangeWithRegions() {
|
||||||
|
// Two regions: [1000, 1016) and [2000, 2016). Range [1000, 1020) clips to first region only.
|
||||||
|
QByteArray data(4096, '\x00');
|
||||||
|
// Place \xBB at offset 1000 and 2000
|
||||||
|
data[1000] = '\xBB';
|
||||||
|
data[2000] = '\xBB';
|
||||||
|
|
||||||
|
QVector<MemoryRegion> regions;
|
||||||
|
{ MemoryRegion r; r.base = 1000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
|
||||||
|
{ MemoryRegion r; r.base = 2000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
|
||||||
|
|
||||||
|
auto prov = std::make_shared<RegionProvider>(data, regions);
|
||||||
|
ScanEngine engine;
|
||||||
|
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||||
|
|
||||||
|
ScanRequest req;
|
||||||
|
req.pattern = QByteArray("\xBB", 1);
|
||||||
|
req.mask = QByteArray("\xFF", 1);
|
||||||
|
req.startAddress = 1000;
|
||||||
|
req.endAddress = 1020;
|
||||||
|
|
||||||
|
engine.start(prov, req);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 1);
|
||||||
|
QCOMPARE(results[0].address, (uint64_t)1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_unknownWithAddressRange() {
|
||||||
|
// Unknown scan with address range should only capture within range
|
||||||
|
QByteArray data(32, '\x42');
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
ScanEngine engine;
|
||||||
|
QSignalSpy finSpy(&engine, &ScanEngine::finished);
|
||||||
|
|
||||||
|
ScanRequest req;
|
||||||
|
req.condition = ScanCondition::UnknownValue;
|
||||||
|
req.valueSize = 4;
|
||||||
|
req.alignment = 4;
|
||||||
|
req.startAddress = 8;
|
||||||
|
req.endAddress = 24;
|
||||||
|
|
||||||
|
engine.start(prov, req);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
// Range [8, 24) = 16 bytes, alignment 4, valueSize 4 → offsets 8, 12, 16, 20 = 4 results
|
||||||
|
QCOMPARE(results.size(), 4);
|
||||||
|
QCOMPARE(results[0].address, (uint64_t)8);
|
||||||
|
QCOMPARE(results[3].address, (uint64_t)20);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
QTEST_MAIN(TestScanner)
|
QTEST_MAIN(TestScanner)
|
||||||
|
|||||||
@@ -1103,6 +1103,89 @@ private slots:
|
|||||||
// Provider getter is lazy (captures at scan time)
|
// Provider getter is lazy (captures at scan time)
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// "Current Struct" checkbox
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
void structOnly_checkboxExists() {
|
||||||
|
QVERIFY(m_panel->structOnlyCheck() != nullptr);
|
||||||
|
QCOMPARE(m_panel->structOnlyCheck()->isChecked(), false);
|
||||||
|
QCOMPARE(m_panel->structOnlyCheck()->text(), QStringLiteral("Current Struct"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void structOnly_setsAddressRange() {
|
||||||
|
// Set up a bounds getter that returns a known range
|
||||||
|
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
|
||||||
|
return { 0x1000, 0x200 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up a simple buffer provider
|
||||||
|
QByteArray data(0x2000, '\x00');
|
||||||
|
data[0x1000] = '\xCC';
|
||||||
|
data[0x1100] = '\xCC';
|
||||||
|
data[0x1500] = '\xCC'; // outside bounds (0x1000 + 0x200 = 0x1200)
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
m_panel->setProviderGetter([prov]() { return prov; });
|
||||||
|
|
||||||
|
// Enable struct-only mode
|
||||||
|
m_panel->structOnlyCheck()->setChecked(true);
|
||||||
|
|
||||||
|
// Scan for \xCC
|
||||||
|
m_panel->patternEdit()->setText("CC");
|
||||||
|
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
|
||||||
|
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
// Should only find results within [0x1000, 0x1200)
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void structOnly_uncheckedScansAll() {
|
||||||
|
// Same setup but with checkbox unchecked — should find all 3
|
||||||
|
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
|
||||||
|
return { 0x1000, 0x200 };
|
||||||
|
});
|
||||||
|
|
||||||
|
QByteArray data(0x2000, '\x00');
|
||||||
|
data[0x1000] = '\xCC';
|
||||||
|
data[0x1100] = '\xCC';
|
||||||
|
data[0x1500] = '\xCC';
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
m_panel->setProviderGetter([prov]() { return prov; });
|
||||||
|
|
||||||
|
m_panel->structOnlyCheck()->setChecked(false); // unchecked
|
||||||
|
|
||||||
|
m_panel->patternEdit()->setText("CC");
|
||||||
|
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
|
||||||
|
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
void structOnly_noBoundsGetterIgnored() {
|
||||||
|
// No bounds getter set — checkbox checked but no effect
|
||||||
|
QByteArray data(16, '\xDD');
|
||||||
|
auto prov = std::make_shared<BufferProvider>(data);
|
||||||
|
m_panel->setProviderGetter([prov]() { return prov; });
|
||||||
|
|
||||||
|
m_panel->structOnlyCheck()->setChecked(true);
|
||||||
|
// Don't set bounds getter
|
||||||
|
|
||||||
|
m_panel->patternEdit()->setText("DD");
|
||||||
|
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
|
||||||
|
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
|
||||||
|
QVERIFY(finSpy.wait(5000));
|
||||||
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
|
QCOMPARE(results.size(), 16); // all 16 bytes match
|
||||||
|
}
|
||||||
|
|
||||||
void providerGetter_lazy() {
|
void providerGetter_lazy() {
|
||||||
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
|
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
|
||||||
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));
|
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));
|
||||||
|
|||||||
432
tests/test_tooltip.cpp
Normal file
432
tests/test_tooltip.cpp
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
#include <QtTest>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QImage>
|
||||||
|
#include "rcxtooltip.h"
|
||||||
|
#include "themes/thememanager.h"
|
||||||
|
|
||||||
|
using namespace rcx;
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// Test suite for the RcxTooltip callout widget
|
||||||
|
//
|
||||||
|
// These tests verify both geometry math AND real-world behavior:
|
||||||
|
// - Actual pixel rendering (catches WA_TranslucentBackground failures)
|
||||||
|
// - Leave-event resilience (catches spurious dismiss on tooltip popup)
|
||||||
|
// - Dismiss correctness (cursor truly leaves trigger zone)
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
class TestTooltip : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private:
|
||||||
|
QWidget* m_window = nullptr;
|
||||||
|
QPushButton* m_btnTop = nullptr;
|
||||||
|
QPushButton* m_btnMid = nullptr;
|
||||||
|
QPushButton* m_btnLeft = nullptr;
|
||||||
|
QPushButton* m_btnRight= nullptr;
|
||||||
|
|
||||||
|
void showAndProcess(QWidget* trigger, const QString& text) {
|
||||||
|
RcxTooltip::instance()->showFor(trigger, text);
|
||||||
|
// Process events + allow paint to complete
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(20);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count non-transparent pixels in a QImage region
|
||||||
|
int countOpaquePixels(const QImage& img, const QRect& region) {
|
||||||
|
int count = 0;
|
||||||
|
QRect r = region.intersected(img.rect());
|
||||||
|
for (int y = r.top(); y <= r.bottom(); ++y)
|
||||||
|
for (int x = r.left(); x <= r.right(); ++x)
|
||||||
|
if (qAlpha(img.pixel(x, y)) > 0)
|
||||||
|
++count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void initTestCase() {
|
||||||
|
m_window = new QWidget;
|
||||||
|
m_window->setFixedSize(800, 600);
|
||||||
|
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
m_window->move(avail.center() - QPoint(400, 300));
|
||||||
|
|
||||||
|
m_btnMid = new QPushButton("Middle", m_window);
|
||||||
|
m_btnMid->setFixedSize(80, 24);
|
||||||
|
m_btnMid->move(360, 288);
|
||||||
|
|
||||||
|
m_btnTop = new QPushButton("Top", m_window);
|
||||||
|
m_btnTop->setFixedSize(80, 24);
|
||||||
|
m_btnTop->move(360, 0);
|
||||||
|
|
||||||
|
m_btnLeft = new QPushButton("Left", m_window);
|
||||||
|
m_btnLeft->setFixedSize(80, 24);
|
||||||
|
m_btnLeft->move(0, 288);
|
||||||
|
|
||||||
|
m_btnRight = new QPushButton("Right", m_window);
|
||||||
|
m_btnRight->setFixedSize(80, 24);
|
||||||
|
m_btnRight->move(720, 288);
|
||||||
|
|
||||||
|
m_window->show();
|
||||||
|
QVERIFY(QTest::qWaitForWindowExposed(m_window));
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanupTestCase() {
|
||||||
|
RcxTooltip::instance()->dismiss();
|
||||||
|
delete m_window;
|
||||||
|
m_window = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanup() {
|
||||||
|
RcxTooltip::instance()->dismiss();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton ──
|
||||||
|
void testSingleton() {
|
||||||
|
QCOMPARE(RcxTooltip::instance(), RcxTooltip::instance());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic show/dismiss ──
|
||||||
|
void testShowAndDismiss() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
QVERIFY(!tip->isVisible());
|
||||||
|
|
||||||
|
showAndProcess(m_btnMid, "Hello");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QCOMPARE(tip->currentText(), QString("Hello"));
|
||||||
|
QCOMPARE(tip->currentTrigger(), m_btnMid);
|
||||||
|
|
||||||
|
tip->dismiss();
|
||||||
|
QVERIFY(!tip->isVisible());
|
||||||
|
QVERIFY(tip->currentTrigger() == nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty text / null trigger = dismiss ──
|
||||||
|
void testEmptyTextDismisses() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Test");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
showAndProcess(m_btnMid, "");
|
||||||
|
QVERIFY(!tip->isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
void testNullTriggerDismisses() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Test");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
showAndProcess(nullptr, "Test");
|
||||||
|
QVERIFY(!tip->isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arrow direction ──
|
||||||
|
void testArrowDownByDefault() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Default placement");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QVERIFY(tip->arrowPointsDown());
|
||||||
|
|
||||||
|
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
|
||||||
|
int tipBottom = tip->y() + tip->height();
|
||||||
|
QVERIFY2(tipBottom <= trigGlobal.top() + RcxTooltip::kGap + 2,
|
||||||
|
qPrintable(QStringLiteral("tipBottom=%1 trigTop=%2")
|
||||||
|
.arg(tipBottom).arg(trigGlobal.top())));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testArrowFlipsAtScreenTop() {
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
QPoint oldPos = m_window->pos();
|
||||||
|
m_window->move(avail.center().x() - 400, avail.top());
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnTop, "Flipped");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QVERIFY2(!tip->arrowPointsDown(),
|
||||||
|
"Expected arrow to flip upward when trigger is near screen top");
|
||||||
|
|
||||||
|
QRect trigGlobal(m_btnTop->mapToGlobal(QPoint(0,0)), m_btnTop->size());
|
||||||
|
QVERIFY2(tip->y() >= trigGlobal.bottom(),
|
||||||
|
qPrintable(QStringLiteral("tipY=%1 trigBottom=%2")
|
||||||
|
.arg(tip->y()).arg(trigGlobal.bottom())));
|
||||||
|
|
||||||
|
m_window->move(oldPos);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Arrow centering ──
|
||||||
|
void testArrowCenteredOnTrigger() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Center");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
|
||||||
|
int trigCenterX = trigGlobal.center().x();
|
||||||
|
int arrowGlobalX = tip->x() + tip->arrowLocalX();
|
||||||
|
int delta = qAbs(arrowGlobalX - trigCenterX);
|
||||||
|
QVERIFY2(delta <= 2,
|
||||||
|
qPrintable(QStringLiteral("arrowGlobalX=%1 trigCenterX=%2 delta=%3")
|
||||||
|
.arg(arrowGlobalX).arg(trigCenterX).arg(delta)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anti-teleport ──
|
||||||
|
void testNoTeleportSameWidget() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Stable");
|
||||||
|
QPoint pos1 = tip->pos();
|
||||||
|
showAndProcess(m_btnMid, "Stable");
|
||||||
|
QCOMPARE(tip->pos(), pos1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repositions for different widget ──
|
||||||
|
void testRepositionsForDifferentWidget() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnLeft, "Left");
|
||||||
|
QPoint pos1 = tip->pos();
|
||||||
|
showAndProcess(m_btnRight, "Right");
|
||||||
|
QVERIFY2(tip->pos() != pos1, "Tooltip should move when trigger widget changes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal clamping ──
|
||||||
|
void testHorizontalClampLeft() {
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
QPoint oldPos = m_window->pos();
|
||||||
|
m_window->move(avail.left(), avail.center().y() - 300);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnLeft, "Clamped left");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QVERIFY2(tip->x() >= avail.left(),
|
||||||
|
qPrintable(QStringLiteral("tipX=%1 screenLeft=%2")
|
||||||
|
.arg(tip->x()).arg(avail.left())));
|
||||||
|
|
||||||
|
m_window->move(oldPos);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
void testHorizontalClampRight() {
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
QPoint oldPos = m_window->pos();
|
||||||
|
m_window->move(avail.right() - m_window->width(), avail.center().y() - 300);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnRight, "Clamped right");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QVERIFY2(tip->x() + tip->width() <= avail.right() + 2,
|
||||||
|
qPrintable(QStringLiteral("tipRight=%1 screenRight=%2")
|
||||||
|
.arg(tip->x() + tip->width()).arg(avail.right())));
|
||||||
|
|
||||||
|
m_window->move(oldPos);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Body rect dimensions ──
|
||||||
|
void testBodyRectSanity() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Body");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
QRect body = tip->bodyRect();
|
||||||
|
QVERIFY(body.width() > 0);
|
||||||
|
QVERIFY(body.height() > 0);
|
||||||
|
QCOMPARE(tip->height(), body.height() + RcxTooltip::kArrowH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──
|
||||||
|
void testConstants() {
|
||||||
|
QCOMPARE(RcxTooltip::kArrowH, 6);
|
||||||
|
QCOMPARE(RcxTooltip::kArrowHalfW, 6);
|
||||||
|
QCOMPARE(RcxTooltip::kGap, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// RENDERING VERIFICATION — catches invisible tooltip bugs
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void testShowForRendersBodyPixels() {
|
||||||
|
// Show tooltip and grab its rendered pixels.
|
||||||
|
// Verify that the body area has non-transparent content.
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Render test");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
// Force full opacity so grab gets real pixels
|
||||||
|
tip->setWindowOpacity(1.0);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||||
|
QVERIFY2(!img.isNull(), "grab() returned null image");
|
||||||
|
QVERIFY2(img.width() > 0 && img.height() > 0, "grab() returned empty image");
|
||||||
|
|
||||||
|
// Check body rect area for opaque pixels
|
||||||
|
QRect body = tip->bodyRect();
|
||||||
|
// Inset by 2px to avoid anti-aliased border edges
|
||||||
|
QRect checkRect = body.adjusted(2, 2, -2, -2);
|
||||||
|
int opaquePixels = countOpaquePixels(img, checkRect);
|
||||||
|
int totalPixels = checkRect.width() * checkRect.height();
|
||||||
|
|
||||||
|
QVERIFY2(opaquePixels > totalPixels / 2,
|
||||||
|
qPrintable(QStringLiteral(
|
||||||
|
"Body area has too few opaque pixels: %1 / %2 (< 50%%). "
|
||||||
|
"The tooltip is not rendering its background.")
|
||||||
|
.arg(opaquePixels).arg(totalPixels)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void testArrowRendersPixels() {
|
||||||
|
// Verify the triangle arrow region has some opaque pixels.
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Arrow test");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QVERIFY(tip->arrowPointsDown());
|
||||||
|
|
||||||
|
tip->setWindowOpacity(1.0);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||||
|
|
||||||
|
// Arrow region: below the body rect, centered on arrowLocalX
|
||||||
|
QRect body = tip->bodyRect();
|
||||||
|
int arrowTop = body.bottom();
|
||||||
|
int arrowLeft = tip->arrowLocalX() - RcxTooltip::kArrowHalfW;
|
||||||
|
int arrowRight = tip->arrowLocalX() + RcxTooltip::kArrowHalfW;
|
||||||
|
QRect arrowRect(arrowLeft, arrowTop, arrowRight - arrowLeft, RcxTooltip::kArrowH);
|
||||||
|
|
||||||
|
int opaquePixels = countOpaquePixels(img, arrowRect);
|
||||||
|
QVERIFY2(opaquePixels > 0,
|
||||||
|
qPrintable(QStringLiteral(
|
||||||
|
"Arrow region has 0 opaque pixels — triangle not painted. "
|
||||||
|
"arrowRect=(%1,%2 %3x%4) imgSize=(%5x%6)")
|
||||||
|
.arg(arrowRect.x()).arg(arrowRect.y())
|
||||||
|
.arg(arrowRect.width()).arg(arrowRect.height())
|
||||||
|
.arg(img.width()).arg(img.height())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// LEAVE EVENT RESILIENCE — catches spurious dismiss bugs
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void testSurvivesLeaveEvent() {
|
||||||
|
// The tooltip should NOT be dismissed when a Leave event fires
|
||||||
|
// on the trigger widget while the cursor is still in the
|
||||||
|
// trigger+tooltip zone (simulates the synthetic Leave that Qt
|
||||||
|
// sends when a tooltip window pops up above the trigger).
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Survive Leave");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
tip->setWindowOpacity(1.0);
|
||||||
|
|
||||||
|
// Move real cursor to center of trigger (so geometry check passes)
|
||||||
|
QPoint trigCenter = m_btnMid->mapToGlobal(
|
||||||
|
QPoint(m_btnMid->width() / 2, m_btnMid->height() / 2));
|
||||||
|
QCursor::setPos(trigCenter);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Send a Leave event to the trigger (like DarkApp::notify would)
|
||||||
|
QEvent leaveEvent(QEvent::Leave);
|
||||||
|
QApplication::sendEvent(m_btnMid, &leaveEvent);
|
||||||
|
|
||||||
|
// Now call scheduleDismiss as DarkApp would
|
||||||
|
tip->scheduleDismiss();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Tooltip should STILL be visible — cursor is inside trigger zone
|
||||||
|
QVERIFY2(tip->isVisible(),
|
||||||
|
"Tooltip was dismissed by spurious Leave event while cursor "
|
||||||
|
"was still over the trigger widget");
|
||||||
|
|
||||||
|
// Wait beyond the dismiss timer to be sure
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QVERIFY2(tip->isVisible(),
|
||||||
|
"Tooltip was dismissed after 200ms despite cursor being over trigger");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testDismissesOnRealLeave() {
|
||||||
|
// When the cursor truly leaves the trigger+tooltip zone,
|
||||||
|
// scheduleDismiss() should queue dismissal and it should fire.
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Real leave");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
tip->setWindowOpacity(1.0);
|
||||||
|
|
||||||
|
// Move cursor far away from both trigger and tooltip
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
QCursor::setPos(avail.bottomRight() - QPoint(10, 10));
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// scheduleDismiss should detect cursor is outside zone
|
||||||
|
tip->scheduleDismiss();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Wait for the 100ms dismiss timer
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
QVERIFY2(!tip->isVisible(),
|
||||||
|
"Tooltip should have been dismissed when cursor left the zone");
|
||||||
|
}
|
||||||
|
|
||||||
|
void testLeaveAndReshow() {
|
||||||
|
// Dismiss via real leave, then re-show on a different widget.
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "First");
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
// Force dismiss
|
||||||
|
tip->dismiss();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QVERIFY(!tip->isVisible());
|
||||||
|
|
||||||
|
// Re-show on different widget
|
||||||
|
showAndProcess(m_btnLeft, "Second");
|
||||||
|
QVERIFY2(tip->isVisible(), "Tooltip failed to re-appear after dismiss");
|
||||||
|
QCOMPARE(tip->currentText(), QString("Second"));
|
||||||
|
QCOMPARE(tip->currentTrigger(), m_btnLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scheduled dismiss cancelled by new showFor ──
|
||||||
|
void testScheduledDismissCancelledByShow() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "First");
|
||||||
|
|
||||||
|
// Move cursor far away and schedule dismiss
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QCursor::setPos(scr->availableGeometry().bottomRight() - QPoint(10, 10));
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
tip->scheduleDismiss();
|
||||||
|
|
||||||
|
// Before timer fires, show on a different widget
|
||||||
|
showAndProcess(m_btnLeft, "Second");
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Should still be visible — new showFor cancelled the timer
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QCOMPARE(tip->currentText(), QString("Second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text change on same widget ──
|
||||||
|
void testTextChangeOnSameWidget() {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
showAndProcess(m_btnMid, "Text A");
|
||||||
|
QCOMPARE(tip->currentText(), QString("Text A"));
|
||||||
|
|
||||||
|
tip->dismiss();
|
||||||
|
showAndProcess(m_btnMid, "Text B");
|
||||||
|
QCOMPARE(tip->currentText(), QString("Text B"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_MAIN(TestTooltip)
|
||||||
|
#include "test_tooltip.moc"
|
||||||
292
tests/test_tooltip_event.cpp
Normal file
292
tests/test_tooltip_event.cpp
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
// Tests the full tooltip flow including DarkApp-style ToolTip interception.
|
||||||
|
// Verifies that QEvent::ToolTip fires and our custom tooltip appears.
|
||||||
|
|
||||||
|
#include <QtTest>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QHelpEvent>
|
||||||
|
#include <QImage>
|
||||||
|
#include "rcxtooltip.h"
|
||||||
|
#include "themes/thememanager.h"
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
using namespace rcx;
|
||||||
|
|
||||||
|
static void LOG(const char* fmt, ...) {
|
||||||
|
va_list ap;
|
||||||
|
va_start(ap, fmt);
|
||||||
|
vfprintf(stdout, fmt, ap);
|
||||||
|
va_end(ap);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates DarkApp::notify behavior — installed as a global event filter
|
||||||
|
class DarkAppSimulator : public QObject {
|
||||||
|
public:
|
||||||
|
int tooltipEventCount = 0;
|
||||||
|
int leaveEventCount = 0;
|
||||||
|
int showForCallCount = 0;
|
||||||
|
|
||||||
|
bool eventFilter(QObject* obj, QEvent* ev) override {
|
||||||
|
if (ev->type() == QEvent::ToolTip) {
|
||||||
|
tooltipEventCount++;
|
||||||
|
if (obj->isWidgetType()) {
|
||||||
|
auto* w = static_cast<QWidget*>(obj);
|
||||||
|
QString tip = w->toolTip();
|
||||||
|
LOG(" [darkapp-sim] ToolTip #%d on '%s' tip='%s'\n",
|
||||||
|
tooltipEventCount, qPrintable(w->objectName()),
|
||||||
|
qPrintable(tip.left(60)));
|
||||||
|
if (!tip.isEmpty()) {
|
||||||
|
showForCallCount++;
|
||||||
|
LOG(" [darkapp-sim] calling showFor #%d\n", showForCallCount);
|
||||||
|
RcxTooltip::instance()->showFor(w, tip);
|
||||||
|
LOG(" [darkapp-sim] after showFor: visible=%d pos=(%d,%d) size=%dx%d\n",
|
||||||
|
RcxTooltip::instance()->isVisible(),
|
||||||
|
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
|
||||||
|
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
|
||||||
|
return true; // consume — same as DarkApp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true; // suppress default QToolTip
|
||||||
|
}
|
||||||
|
if (ev->type() == QEvent::Leave && obj->isWidgetType()) {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
if (tip->isVisible() && tip->currentTrigger() == obj) {
|
||||||
|
leaveEventCount++;
|
||||||
|
LOG(" [darkapp-sim] Leave #%d on trigger\n", leaveEventCount);
|
||||||
|
tip->scheduleDismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class TestTooltipEvent : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private:
|
||||||
|
QWidget* m_window = nullptr;
|
||||||
|
QPushButton* m_btn = nullptr;
|
||||||
|
QPushButton* m_btn2 = nullptr;
|
||||||
|
DarkAppSimulator* m_sim = nullptr;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void initTestCase() {
|
||||||
|
LOG("=== TestTooltipEvent starting ===\n");
|
||||||
|
|
||||||
|
m_window = new QWidget;
|
||||||
|
m_window->setFixedSize(400, 300);
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
m_window->move(avail.center() - QPoint(200, 150));
|
||||||
|
|
||||||
|
m_btn = new QPushButton("Scan", m_window);
|
||||||
|
m_btn->setToolTip("Start scanning memory");
|
||||||
|
m_btn->setFixedSize(120, 40);
|
||||||
|
m_btn->move(30, 130);
|
||||||
|
m_btn->setObjectName("btnScan");
|
||||||
|
|
||||||
|
m_btn2 = new QPushButton("Copy", m_window);
|
||||||
|
m_btn2->setToolTip("Copy to clipboard");
|
||||||
|
m_btn2->setFixedSize(120, 40);
|
||||||
|
m_btn2->move(250, 130);
|
||||||
|
m_btn2->setObjectName("btnCopy");
|
||||||
|
|
||||||
|
// Install DarkApp simulator as global event filter
|
||||||
|
m_sim = new DarkAppSimulator;
|
||||||
|
qApp->installEventFilter(m_sim);
|
||||||
|
|
||||||
|
m_window->show();
|
||||||
|
m_window->activateWindow();
|
||||||
|
m_window->raise();
|
||||||
|
QVERIFY(QTest::qWaitForWindowExposed(m_window));
|
||||||
|
// Let window become active
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" window at (%d,%d)\n", m_window->x(), m_window->y());
|
||||||
|
LOG(" btn global: (%d,%d)\n",
|
||||||
|
m_btn->mapToGlobal(QPoint(60, 20)).x(),
|
||||||
|
m_btn->mapToGlobal(QPoint(60, 20)).y());
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanupTestCase() {
|
||||||
|
qApp->removeEventFilter(m_sim);
|
||||||
|
RcxTooltip::instance()->dismiss();
|
||||||
|
delete m_sim;
|
||||||
|
delete m_window;
|
||||||
|
LOG("=== TestTooltipEvent finished ===\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanup() {
|
||||||
|
RcxTooltip::instance()->dismiss();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
m_sim->tooltipEventCount = 0;
|
||||||
|
m_sim->leaveEventCount = 0;
|
||||||
|
m_sim->showForCallCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Post QHelpEvent → DarkApp simulator intercepts → RcxTooltip shows
|
||||||
|
void testManualEventShowsTooltip() {
|
||||||
|
LOG("\n--- testManualEventShowsTooltip ---\n");
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
|
||||||
|
QPoint btnGlobal = m_btn->mapToGlobal(QPoint(60, 20));
|
||||||
|
QCursor::setPos(btnGlobal);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" posting QHelpEvent\n");
|
||||||
|
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnGlobal);
|
||||||
|
QApplication::sendEvent(m_btn, &helpEvent);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" sim: tooltipEvents=%d showForCalls=%d\n",
|
||||||
|
m_sim->tooltipEventCount, m_sim->showForCallCount);
|
||||||
|
LOG(" tip: visible=%d text='%s'\n",
|
||||||
|
tip->isVisible(), qPrintable(tip->currentText()));
|
||||||
|
|
||||||
|
QVERIFY2(m_sim->tooltipEventCount > 0, "Event filter didn't see ToolTip event");
|
||||||
|
QVERIFY2(m_sim->showForCallCount > 0, "showFor was never called");
|
||||||
|
QVERIFY2(tip->isVisible(), "RcxTooltip not visible after manual event");
|
||||||
|
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
|
||||||
|
|
||||||
|
// Verify pixels
|
||||||
|
tip->setWindowOpacity(1.0);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||||
|
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
|
||||||
|
int opaque = 0;
|
||||||
|
for (int y = body.top(); y <= body.bottom(); ++y)
|
||||||
|
for (int x = body.left(); x <= body.right(); ++x)
|
||||||
|
if (qAlpha(img.pixel(x, y)) > 0) opaque++;
|
||||||
|
LOG(" pixels: %d/%d opaque\n", opaque, body.width() * body.height());
|
||||||
|
QVERIFY2(opaque > body.width() * body.height() / 2, "Body not rendered");
|
||||||
|
|
||||||
|
LOG("--- testManualEventShowsTooltip PASSED ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Qt's native tooltip timer fires → our filter intercepts → tooltip shows
|
||||||
|
void testNativeTimerShowsTooltip() {
|
||||||
|
LOG("\n--- testNativeTimerShowsTooltip ---\n");
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
|
||||||
|
// Move cursor away first
|
||||||
|
QPoint away = m_window->mapToGlobal(QPoint(380, 10));
|
||||||
|
QCursor::setPos(away);
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Move to button
|
||||||
|
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
|
||||||
|
LOG(" moving cursor to (%d,%d)\n", btnCenter.x(), btnCenter.y());
|
||||||
|
QCursor::setPos(btnCenter);
|
||||||
|
|
||||||
|
// Send Enter + MouseMove to kick the tooltip timer
|
||||||
|
QEvent enterEv(QEvent::Enter);
|
||||||
|
QApplication::sendEvent(m_btn, &enterEv);
|
||||||
|
QMouseEvent moveEv(QEvent::MouseMove, QPointF(60, 20),
|
||||||
|
m_btn->mapToGlobal(QPointF(60, 20)),
|
||||||
|
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
|
||||||
|
QApplication::sendEvent(m_btn, &moveEv);
|
||||||
|
|
||||||
|
// Wait up to 2000ms for tooltip to appear
|
||||||
|
LOG(" waiting for Qt tooltip timer...\n");
|
||||||
|
bool appeared = false;
|
||||||
|
for (int i = 0; i < 20; i++) {
|
||||||
|
QTest::qWait(100);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
if (m_sim->tooltipEventCount > 0) {
|
||||||
|
LOG(" tooltip event at ~%dms! events=%d showFor=%d\n",
|
||||||
|
(i+1)*100, m_sim->tooltipEventCount, m_sim->showForCallCount);
|
||||||
|
appeared = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remaining events
|
||||||
|
QTest::qWait(100);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" final: events=%d showFor=%d visible=%d text='%s'\n",
|
||||||
|
m_sim->tooltipEventCount, m_sim->showForCallCount,
|
||||||
|
tip->isVisible(), qPrintable(tip->currentText()));
|
||||||
|
|
||||||
|
QVERIFY2(appeared, "Qt tooltip timer never fired (no ToolTip event in 2 seconds)");
|
||||||
|
QVERIFY2(tip->isVisible(), "Tooltip not visible after native timer fired");
|
||||||
|
|
||||||
|
LOG("--- testNativeTimerShowsTooltip PASSED ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Leave after tooltip shown → tooltip survives (cursor still in zone)
|
||||||
|
void testLeaveSurvival() {
|
||||||
|
LOG("\n--- testLeaveSurvival ---\n");
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
|
||||||
|
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
|
||||||
|
QCursor::setPos(btnCenter);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
// Show via manual event
|
||||||
|
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnCenter);
|
||||||
|
QApplication::sendEvent(m_btn, &helpEvent);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
|
||||||
|
// Send Leave (cursor still on button)
|
||||||
|
LOG(" sending Leave while cursor on button\n");
|
||||||
|
QEvent leaveEv(QEvent::Leave);
|
||||||
|
QApplication::sendEvent(m_btn, &leaveEv);
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" after Leave+200ms: visible=%d leaves=%d\n",
|
||||||
|
tip->isVisible(), m_sim->leaveEventCount);
|
||||||
|
QVERIFY2(tip->isVisible(), "Tooltip dismissed by spurious Leave");
|
||||||
|
|
||||||
|
LOG("--- testLeaveSurvival PASSED ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Switch between widgets
|
||||||
|
void testWidgetSwitch() {
|
||||||
|
LOG("\n--- testWidgetSwitch ---\n");
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
|
||||||
|
// Show on btn1
|
||||||
|
QPoint btn1Center = m_btn->mapToGlobal(QPoint(60, 20));
|
||||||
|
QCursor::setPos(btn1Center);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QHelpEvent ev1(QEvent::ToolTip, QPoint(60, 20), btn1Center);
|
||||||
|
QApplication::sendEvent(m_btn, &ev1);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100);
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
|
||||||
|
QPoint pos1 = tip->pos();
|
||||||
|
|
||||||
|
// Switch to btn2
|
||||||
|
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(60, 20));
|
||||||
|
QCursor::setPos(btn2Center);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QHelpEvent ev2(QEvent::ToolTip, QPoint(60, 20), btn2Center);
|
||||||
|
QApplication::sendEvent(m_btn2, &ev2);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100);
|
||||||
|
|
||||||
|
LOG(" after switch: visible=%d text='%s' pos=(%d,%d)\n",
|
||||||
|
tip->isVisible(), qPrintable(tip->currentText()),
|
||||||
|
tip->x(), tip->y());
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QCOMPARE(tip->currentText(), QString("Copy to clipboard"));
|
||||||
|
QVERIFY(tip->pos() != pos1);
|
||||||
|
|
||||||
|
LOG("--- testWidgetSwitch PASSED ---\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_MAIN(TestTooltipEvent)
|
||||||
|
#include "test_tooltip_event.moc"
|
||||||
253
tests/test_tooltip_ui.cpp
Normal file
253
tests/test_tooltip_ui.cpp
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// Integration test: simulates the full tooltip flow as DarkApp would see it.
|
||||||
|
// Posts QHelpEvent (ToolTip), sends Leave events, verifies RcxTooltip behavior
|
||||||
|
// with fprintf at every stage so we can see exactly what happens.
|
||||||
|
|
||||||
|
#include <QtTest>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QHelpEvent>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QImage>
|
||||||
|
#include "rcxtooltip.h"
|
||||||
|
#include "themes/thememanager.h"
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
using namespace rcx;
|
||||||
|
|
||||||
|
static void LOG(const char* fmt, ...) {
|
||||||
|
va_list ap;
|
||||||
|
va_start(ap, fmt);
|
||||||
|
vfprintf(stdout, fmt, ap);
|
||||||
|
va_end(ap);
|
||||||
|
fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates what DarkApp::notify does when a ToolTip event arrives
|
||||||
|
static bool simulateDarkAppToolTip(QWidget* w) {
|
||||||
|
QString tip = w->toolTip();
|
||||||
|
LOG(" [darkapp] widget='%s' class=%s tip='%s'\n",
|
||||||
|
qPrintable(w->objectName()), w->metaObject()->className(),
|
||||||
|
qPrintable(tip));
|
||||||
|
if (!tip.isEmpty()) {
|
||||||
|
LOG(" [darkapp] calling RcxTooltip::showFor\n");
|
||||||
|
RcxTooltip::instance()->showFor(w, tip);
|
||||||
|
LOG(" [darkapp] showFor returned, visible=%d opacity=%.2f pos=(%d,%d) size=%dx%d\n",
|
||||||
|
RcxTooltip::instance()->isVisible(),
|
||||||
|
RcxTooltip::instance()->windowOpacity(),
|
||||||
|
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
|
||||||
|
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulates what DarkApp::notify does when a Leave event arrives
|
||||||
|
static void simulateDarkAppLeave(QWidget* w) {
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
if (tip->isVisible() && tip->currentTrigger() == w) {
|
||||||
|
LOG(" [darkapp] Leave on trigger — calling scheduleDismiss\n");
|
||||||
|
tip->scheduleDismiss();
|
||||||
|
LOG(" [darkapp] after scheduleDismiss: visible=%d\n", tip->isVisible());
|
||||||
|
} else {
|
||||||
|
LOG(" [darkapp] Leave ignored (visible=%d trigger_match=%d)\n",
|
||||||
|
tip->isVisible(), tip->currentTrigger() == w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestTooltipUI : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private:
|
||||||
|
QWidget* m_window = nullptr;
|
||||||
|
QPushButton* m_btn = nullptr;
|
||||||
|
QPushButton* m_btn2 = nullptr;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void initTestCase() {
|
||||||
|
LOG("=== TestTooltipUI starting ===\n");
|
||||||
|
|
||||||
|
m_window = new QWidget;
|
||||||
|
m_window->setFixedSize(400, 300);
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QRect avail = scr->availableGeometry();
|
||||||
|
m_window->move(avail.center() - QPoint(200, 150));
|
||||||
|
|
||||||
|
m_btn = new QPushButton("Scan", m_window);
|
||||||
|
m_btn->setToolTip("Start scanning memory");
|
||||||
|
m_btn->setFixedSize(80, 28);
|
||||||
|
m_btn->move(160, 140);
|
||||||
|
m_btn->setObjectName("btnScan");
|
||||||
|
|
||||||
|
m_btn2 = new QPushButton("Copy", m_window);
|
||||||
|
m_btn2->setToolTip("Copy address to clipboard");
|
||||||
|
m_btn2->setFixedSize(80, 28);
|
||||||
|
m_btn2->move(260, 140);
|
||||||
|
m_btn2->setObjectName("btnCopy");
|
||||||
|
|
||||||
|
m_window->show();
|
||||||
|
QVERIFY(QTest::qWaitForWindowExposed(m_window));
|
||||||
|
LOG(" window shown at (%d,%d)\n", m_window->x(), m_window->y());
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanupTestCase() {
|
||||||
|
RcxTooltip::instance()->dismiss();
|
||||||
|
delete m_window;
|
||||||
|
LOG("=== TestTooltipUI finished ===\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void cleanup() {
|
||||||
|
RcxTooltip::instance()->dismiss();
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 1: Full tooltip lifecycle with event simulation ───
|
||||||
|
void testFullLifecycle() {
|
||||||
|
LOG("\n--- testFullLifecycle ---\n");
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
|
||||||
|
// Step 1: Post a ToolTip event (what Qt does after hover delay)
|
||||||
|
LOG("Step 1: Posting ToolTip event to btn\n");
|
||||||
|
QPoint btnCenter = m_btn->mapToGlobal(QPoint(40, 14));
|
||||||
|
LOG(" btn global center: (%d,%d)\n", btnCenter.x(), btnCenter.y());
|
||||||
|
|
||||||
|
// Move real cursor to button center
|
||||||
|
QCursor::setPos(btnCenter);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
LOG(" cursor moved to button\n");
|
||||||
|
|
||||||
|
// Simulate what DarkApp does on ToolTip event
|
||||||
|
bool handled = simulateDarkAppToolTip(m_btn);
|
||||||
|
QVERIFY2(handled, "DarkApp should have handled the tooltip");
|
||||||
|
|
||||||
|
// Process events (paint, animation start)
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100); // let fade-in animation run
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG("Step 2: Check tooltip state after 100ms\n");
|
||||||
|
LOG(" visible=%d opacity=%.2f text='%s'\n",
|
||||||
|
tip->isVisible(), tip->windowOpacity(),
|
||||||
|
qPrintable(tip->currentText()));
|
||||||
|
LOG(" pos=(%d,%d) size=%dx%d\n",
|
||||||
|
tip->x(), tip->y(), tip->width(), tip->height());
|
||||||
|
LOG(" arrowDown=%d arrowX=%d bodyRect=(%d,%d %dx%d)\n",
|
||||||
|
tip->arrowPointsDown(), tip->arrowLocalX(),
|
||||||
|
tip->bodyRect().x(), tip->bodyRect().y(),
|
||||||
|
tip->bodyRect().width(), tip->bodyRect().height());
|
||||||
|
|
||||||
|
QVERIFY2(tip->isVisible(), "Tooltip should be visible after showFor + 100ms");
|
||||||
|
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
|
||||||
|
|
||||||
|
// Step 3: Grab pixels and verify rendering
|
||||||
|
LOG("Step 3: Verify rendering\n");
|
||||||
|
tip->setWindowOpacity(1.0);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||||
|
LOG(" grabbed image: %dx%d format=%d\n", img.width(), img.height(), img.format());
|
||||||
|
|
||||||
|
int opaquePixels = 0;
|
||||||
|
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
|
||||||
|
for (int y = body.top(); y <= body.bottom(); ++y)
|
||||||
|
for (int x = body.left(); x <= body.right(); ++x)
|
||||||
|
if (qAlpha(img.pixel(x, y)) > 0)
|
||||||
|
++opaquePixels;
|
||||||
|
int totalPixels = body.width() * body.height();
|
||||||
|
LOG(" body opaque pixels: %d / %d (%.1f%%)\n",
|
||||||
|
opaquePixels, totalPixels,
|
||||||
|
totalPixels > 0 ? 100.0 * opaquePixels / totalPixels : 0.0);
|
||||||
|
|
||||||
|
QVERIFY2(opaquePixels > totalPixels / 2,
|
||||||
|
qPrintable(QStringLiteral("Only %1/%2 opaque pixels in body — tooltip not rendering")
|
||||||
|
.arg(opaquePixels).arg(totalPixels)));
|
||||||
|
|
||||||
|
// Step 4: Simulate Leave event (spurious — cursor still on button)
|
||||||
|
LOG("Step 4: Simulate spurious Leave (cursor still on button)\n");
|
||||||
|
simulateDarkAppLeave(m_btn);
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
LOG(" after 200ms: visible=%d\n", tip->isVisible());
|
||||||
|
|
||||||
|
QVERIFY2(tip->isVisible(),
|
||||||
|
"Tooltip dismissed by spurious Leave — geometry check failed");
|
||||||
|
|
||||||
|
// Step 5: Move cursor away and simulate real Leave
|
||||||
|
LOG("Step 5: Move cursor away, simulate real Leave\n");
|
||||||
|
QScreen* scr = QApplication::primaryScreen();
|
||||||
|
QPoint farAway = scr->availableGeometry().bottomRight() - QPoint(50, 50);
|
||||||
|
QCursor::setPos(farAway);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
LOG(" cursor at (%d,%d)\n", farAway.x(), farAway.y());
|
||||||
|
|
||||||
|
simulateDarkAppLeave(m_btn);
|
||||||
|
QTest::qWait(200);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
LOG(" after 200ms: visible=%d\n", tip->isVisible());
|
||||||
|
|
||||||
|
QVERIFY2(!tip->isVisible(),
|
||||||
|
"Tooltip should be dismissed when cursor truly left the zone");
|
||||||
|
|
||||||
|
// Step 6: Re-show on different widget
|
||||||
|
LOG("Step 6: Re-show on different widget\n");
|
||||||
|
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(40, 14));
|
||||||
|
QCursor::setPos(btn2Center);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
handled = simulateDarkAppToolTip(m_btn2);
|
||||||
|
QVERIFY(handled);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
|
||||||
|
|
||||||
|
LOG("--- testFullLifecycle PASSED ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 2: Rapid widget switching (no dismiss between) ───
|
||||||
|
void testRapidSwitch() {
|
||||||
|
LOG("\n--- testRapidSwitch ---\n");
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
|
||||||
|
QCursor::setPos(m_btn->mapToGlobal(QPoint(40, 14)));
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
simulateDarkAppToolTip(m_btn);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(50);
|
||||||
|
|
||||||
|
LOG(" switch to btn2 immediately\n");
|
||||||
|
QCursor::setPos(m_btn2->mapToGlobal(QPoint(40, 14)));
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
simulateDarkAppToolTip(m_btn2);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
QTest::qWait(100);
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
|
||||||
|
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
|
||||||
|
QVERIFY(tip->isVisible());
|
||||||
|
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
|
||||||
|
LOG("--- testRapidSwitch PASSED ---\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test 3: Widget with no tooltip ───
|
||||||
|
void testNoTooltipWidget() {
|
||||||
|
LOG("\n--- testNoTooltipWidget ---\n");
|
||||||
|
QPushButton noTip("NoTip", m_window);
|
||||||
|
noTip.setFixedSize(80, 28);
|
||||||
|
noTip.move(50, 50);
|
||||||
|
noTip.show();
|
||||||
|
// No setToolTip called
|
||||||
|
|
||||||
|
auto* tip = RcxTooltip::instance();
|
||||||
|
bool handled = simulateDarkAppToolTip(&noTip);
|
||||||
|
LOG(" handled=%d visible=%d\n", handled, tip->isVisible());
|
||||||
|
QVERIFY(!handled);
|
||||||
|
QVERIFY(!tip->isVisible());
|
||||||
|
LOG("--- testNoTooltipWidget PASSED ---\n");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QTEST_MAIN(TestTooltipUI)
|
||||||
|
#include "test_tooltip_ui.moc"
|
||||||
Reference in New Issue
Block a user