mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
feat: tree lines, scanner improvements, themes, tooltips, README overhaul
- Tree line connectors (Unicode box-drawing ├─ └─ │) at arbitrary depth - Fix editor overwriting tree chars at depth 2+ (applyMarginText Pass 2) - Scanner: unknown value scan, comparison rescan modes (Changed/Unchanged/Increased/Decreased) - New Tailwind theme (tw.json), WCAG contrast fixes for warm/mid themes - Tooltip system (rcxtooltip.h) - Comprehensive README rewrite with full feature inventory - New tests for compose tree lines, scanner, tooltips Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
173
README.md
173
README.md
@@ -12,64 +12,143 @@
|
|||||||
[](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.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Structured binary view** — render raw bytes as typed fields (integers, floats, pointers, vectors, matrices, strings, booleans, padding)
|
### Type System — 25 Data Types
|
||||||
- **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
|
| Category | Types |
|
||||||
- **Inline editing** — click to edit type names, field names, values, and base addresses directly in the editor
|
|----------|-------|
|
||||||
- **Undo/redo** — full undo history for all mutations via command stack
|
| **Hex preview** | Hex8, Hex16, Hex32, Hex64 |
|
||||||
- **Multi-document tabs** — open multiple projects simultaneously in MDI sub-windows
|
| **Integers** | Int8/16/32/64, UInt8/16/32/64 |
|
||||||
|
| **Floating point** | Float, Double |
|
||||||
|
| **Boolean** | Bool |
|
||||||
|
| **Pointers** | Pointer32, Pointer64, FuncPtr32, FuncPtr64 |
|
||||||
|
| **Vectors & matrices** | Vec2, Vec3, Vec4, Mat4x4 |
|
||||||
|
| **Strings** | UTF8, UTF16 (length-aware) |
|
||||||
|
| **Containers** | Struct (class/struct/union/enum keywords), Array (typed elements with viewport scrolling) |
|
||||||
|
|
||||||
|
### Editor
|
||||||
|
|
||||||
|
- **Structured binary view** — render raw bytes as typed fields with columnar alignment
|
||||||
|
- **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
|
||||||
|
- **Tab-cycling** — tab through editable fields within a line
|
||||||
|
- **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
|
### Struct & Container Support
|
||||||
|
|
||||||
- [ ] Process memory section enumeration
|
- **Struct nesting** — define nested structs and arrays with collapsible fold regions
|
||||||
- [ ] Address parser auto-complete
|
- **Enums** — define enums with named value members, inline editing, and auto-sort
|
||||||
- [ ] Safe mode
|
- **Bitfields** — named bit-range members within structs, per-bit toggle, masked extraction
|
||||||
- [ ] File import for other Reclass instances
|
- **Unions** — group nodes into unions, dissolve unions back to flat fields
|
||||||
- [ ] Expose UI functionality to plugins
|
- **Pointer virtual expansion** — pointer nodes auto-dereference and inline-expand the target struct's fields, with cycle detection to prevent infinite recursion
|
||||||
- [ ] iOS support
|
- **Cross-document type resolution** — pointer targets resolve across all open tabs
|
||||||
- [ ] Display RTTI information
|
- **Static fields** — C/C++ expression-evaluated offsets for globals, vtable entries, and computed addresses
|
||||||
|
|
||||||
|
### Live Memory Analysis
|
||||||
|
|
||||||
|
- **Auto-refresh** — configurable interval (default 660ms) with async page-based reads for non-blocking UI
|
||||||
|
- **Value history & heatmap** — per-node ring buffer (10 samples with timestamps), color-coded heat indicators (static/cold/warm/hot) based on change frequency
|
||||||
|
- **Changed-byte highlighting** — per-byte change indicators within hex preview lines
|
||||||
|
- **Memory write-back** — edit values inline, writes propagate through the provider to live process memory
|
||||||
|
- **Pointer chasing** — automatic reads of dereferenced memory regions across pointer chains
|
||||||
|
- **Address parser** — formula expressions like `<module.exe>+0x1A0`, pointer dereference chains, symbol resolution
|
||||||
|
|
||||||
|
### Visualization
|
||||||
|
|
||||||
|
- **Tree line connectors** — Unicode box-drawing characters (│ ├─ └─) at arbitrary nesting depth
|
||||||
|
- **Compact column mode** — caps type column width, overflows long type names
|
||||||
|
- **Relative / absolute offsets** — toggle inline offset display in the indent area
|
||||||
|
- **Disassembly preview** — hover over code pointers to see decoded x86 instructions (32/64-bit via Fadec)
|
||||||
|
- **Themes** — 5 built-in themes (ReClass Dark, VS Light, Warm, Midtone, Tailwind), live theme editor with 25-color customization, JSON import/export
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Scanner / Memory Search
|
||||||
|
|
||||||
|
- **Signature scanning** — IDA-style pattern matching (`48 8B ?? 05`)
|
||||||
|
- **Typed value search** — Int8-64, UInt8-64, Float, Double, Vec2/3/4, UTF8, UTF16, HexBytes
|
||||||
|
- **Scan conditions** — ExactValue, UnknownValue, Changed, Unchanged, Increased, Decreased
|
||||||
|
- **Region filtering** — filter by executable, writable, or struct-only regions
|
||||||
|
- **Alignment control** — 1, 4, or 8-byte alignment
|
||||||
|
- **Async multi-threaded** — progress bar, abort capability, up to 50K results
|
||||||
|
- **Rescan** — refine results with condition-based filtering
|
||||||
|
|
||||||
|
### 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 | — |
|
||||||
|
| **Binary files** | Raw file loading as memory buffer | — |
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||

|
Extensible provider architecture via DLL plugins with `IPlugin` interface, factory function discovery, and auto/manual loading from a Plugins folder.
|
||||||
|
|
||||||

|
**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
|
||||||
{
|
{
|
||||||
@@ -82,6 +161,20 @@ Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] iOS support
|
||||||
|
- [ ] Display RTTI information
|
||||||
|
- [ ] Expose UI functionality to plugins
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -92,7 +185,7 @@ Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `
|
|||||||
|
|
||||||
### Quick Build
|
### Quick Build
|
||||||
|
|
||||||
```/dev/null/commands.sh#L1-4
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
|
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
|
||||||
cd Reclass
|
cd Reclass
|
||||||
.\scripts\build_qscintilla.ps1
|
.\scripts\build_qscintilla.ps1
|
||||||
@@ -103,13 +196,13 @@ The build script auto-detects your Qt install location.
|
|||||||
|
|
||||||
### macOS Build
|
### macOS Build
|
||||||
|
|
||||||
```/dev/null/commands.sh#L1-2
|
```bash
|
||||||
./scripts/build_macos.sh --qt-dir /opt/homebrew/opt/qt --build-type Release --package
|
./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`.
|
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**.
|
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)
|
||||||
|
|
||||||
@@ -132,6 +225,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)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ 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
|
||||||
|
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 +43,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 +63,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++;
|
||||||
}
|
}
|
||||||
@@ -305,6 +338,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 +387,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 +450,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 +496,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 +513,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 +553,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 +614,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 +686,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 +733,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -818,8 +875,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 +936,10 @@ 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) {
|
||||||
ComposeState state;
|
ComposeState state;
|
||||||
state.compactColumns = compactColumns;
|
state.compactColumns = compactColumns;
|
||||||
|
state.treeLines = treeLines;
|
||||||
|
|
||||||
// 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++)
|
||||||
@@ -1026,7 +1085,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) const {
|
||||||
|
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RcxDocument::save(const QString& path) {
|
bool RcxDocument::save(const QString& path) {
|
||||||
@@ -319,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
|
||||||
@@ -546,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;
|
||||||
}
|
}
|
||||||
@@ -556,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);
|
||||||
else
|
else
|
||||||
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns);
|
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines);
|
||||||
|
|
||||||
s_composeDoc = nullptr;
|
s_composeDoc = nullptr;
|
||||||
|
|
||||||
@@ -602,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;
|
||||||
@@ -1710,6 +1713,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
m_refreshGen++; // discard in-flight async reads
|
m_refreshGen++; // discard in-flight async reads
|
||||||
m_prevPages.clear(); // clean baseline for next read cycle
|
m_prevPages.clear(); // clean baseline for next read cycle
|
||||||
m_changedOffsets.clear(); // no phantom change indicators
|
m_changedOffsets.clear(); // no phantom change indicators
|
||||||
|
m_valueTrackCooldown = 5; // suppress tracking for ~1s
|
||||||
refresh();
|
refresh();
|
||||||
for (auto* editor : m_editors)
|
for (auto* editor : m_editors)
|
||||||
editor->dismissHistoryPopup();
|
editor->dismissHistoryPopup();
|
||||||
@@ -1935,6 +1939,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
m_refreshGen++; // discard in-flight async reads
|
m_refreshGen++; // discard in-flight async reads
|
||||||
m_prevPages.clear(); // clean baseline for next read cycle
|
m_prevPages.clear(); // clean baseline for next read cycle
|
||||||
m_changedOffsets.clear(); // no phantom change indicators
|
m_changedOffsets.clear(); // no phantom change indicators
|
||||||
|
m_valueTrackCooldown = 5; // suppress tracking for ~1s
|
||||||
refresh();
|
refresh();
|
||||||
for (auto* editor : m_editors)
|
for (auto* editor : m_editors)
|
||||||
editor->dismissHistoryPopup();
|
editor->dismissHistoryPopup();
|
||||||
@@ -2608,7 +2613,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;
|
||||||
@@ -3181,6 +3186,11 @@ void RcxController::setCompactColumns(bool v) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RcxController::setTreeLines(bool v) {
|
||||||
|
m_treeLines = 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) 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);
|
||||||
@@ -128,6 +129,7 @@ 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 resetProvider();
|
void resetProvider();
|
||||||
|
|
||||||
// MCP bridge accessors
|
// MCP bridge accessors
|
||||||
@@ -151,6 +153,7 @@ public:
|
|||||||
// Test accessors
|
// 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; }
|
const ComposeResult& lastResult() const { return m_lastResult; }
|
||||||
|
int dataExtent() const { return computeDataExtent(); }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void nodeSelected(int nodeIdx);
|
void nodeSelected(int nodeIdx);
|
||||||
@@ -164,6 +167,7 @@ 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;
|
||||||
uint64_t m_viewRootId = 0;
|
uint64_t m_viewRootId = 0;
|
||||||
|
|
||||||
// ── Saved sources for quick-switch ──
|
// ── Saved sources for quick-switch ──
|
||||||
@@ -183,6 +187,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;
|
||||||
|
|||||||
16
src/core.h
16
src/core.h
@@ -86,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},
|
||||||
};
|
};
|
||||||
@@ -153,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;
|
||||||
@@ -636,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 ──
|
||||||
@@ -1033,6 +1031,6 @@ 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);
|
||||||
|
|
||||||
} // namespace rcx
|
} // namespace rcx
|
||||||
|
|||||||
@@ -156,7 +156,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()
|
||||||
@@ -164,7 +164,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();
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,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()
|
||||||
@@ -265,7 +265,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();
|
||||||
}
|
}
|
||||||
@@ -354,7 +354,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()
|
||||||
@@ -362,7 +362,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();
|
||||||
}
|
}
|
||||||
@@ -866,7 +866,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);
|
||||||
@@ -1061,6 +1061,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];
|
||||||
@@ -2204,11 +2209,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());
|
||||||
@@ -2992,7 +3011,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3147,7 +3166,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3240,7 +3259,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())
|
||||||
@@ -3307,7 +3326,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();
|
||||||
|
|||||||
143
src/examples/Demo.rcx
Normal file
143
src/examples/Demo.rcx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"baseAddress": "0",
|
||||||
|
"nextId": "20",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"kind": "Struct",
|
||||||
|
"name": "player",
|
||||||
|
"structTypeName": "PlayerEntity",
|
||||||
|
"classKeyword": "class",
|
||||||
|
"parentId": "0",
|
||||||
|
"offset": 0,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"kind": "Pointer64",
|
||||||
|
"name": "__vptr",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 0,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"kind": "Int32",
|
||||||
|
"name": "health",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 8,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"kind": "Int32",
|
||||||
|
"name": "armor",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 12,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"kind": "Float",
|
||||||
|
"name": "pos_x",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 16,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6",
|
||||||
|
"kind": "Float",
|
||||||
|
"name": "pos_y",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 20,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7",
|
||||||
|
"kind": "Float",
|
||||||
|
"name": "pos_z",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 24,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8",
|
||||||
|
"kind": "Hex32",
|
||||||
|
"name": "pad_1C",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 28,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9",
|
||||||
|
"kind": "Pointer64",
|
||||||
|
"name": "name",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 32,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64,
|
||||||
|
"ptrDepth": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10",
|
||||||
|
"kind": "UInt64",
|
||||||
|
"name": "flags",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 40,
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "11",
|
||||||
|
"kind": "Hex64",
|
||||||
|
"name": "static_field",
|
||||||
|
"parentId": "1",
|
||||||
|
"offset": 0,
|
||||||
|
"isStatic": true,
|
||||||
|
"offsetExpr": "base + pos_x",
|
||||||
|
"collapsed": false,
|
||||||
|
"refId": "0",
|
||||||
|
"elementKind": "UInt8",
|
||||||
|
"arrayLen": 1,
|
||||||
|
"strLen": 64
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
59
src/main.cpp
59
src/main.cpp
@@ -236,6 +236,14 @@ public:
|
|||||||
class MenuBarStyle : public QProxyStyle {
|
class MenuBarStyle : public QProxyStyle {
|
||||||
public:
|
public:
|
||||||
using QProxyStyle::QProxyStyle;
|
using QProxyStyle::QProxyStyle;
|
||||||
|
void polish(QWidget* w) override {
|
||||||
|
// Strip OS window border/shadow from QMenu popups — we draw our own
|
||||||
|
// 1px border in PE_FrameMenu. Same pattern as TypeSelectorPopup.
|
||||||
|
if (qobject_cast<QMenu*>(w))
|
||||||
|
w->setWindowFlag(Qt::FramelessWindowHint, true);
|
||||||
|
QProxyStyle::polish(w);
|
||||||
|
}
|
||||||
|
using QProxyStyle::polish;
|
||||||
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
|
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
|
||||||
const QSize& sz, const QWidget* w) const override {
|
const QSize& sz, const QWidget* w) const override {
|
||||||
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
||||||
@@ -247,9 +255,12 @@ public:
|
|||||||
}
|
}
|
||||||
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
|
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
|
||||||
const QWidget* w) const override {
|
const QWidget* w) const override {
|
||||||
// Kill the 1px frame margin Fusion reserves around QMenu contents
|
// Reserve 1px for our own menu border (drawn in PE_FrameMenu)
|
||||||
if (metric == PM_MenuPanelWidth)
|
if (metric == PM_MenuPanelWidth)
|
||||||
return 0;
|
return 1;
|
||||||
|
// Inset menu items from border so hover rect doesn't touch edges
|
||||||
|
if (metric == PM_MenuHMargin)
|
||||||
|
return 3;
|
||||||
// Thin draggable separator between dock widgets / central widget
|
// Thin draggable separator between dock widgets / central widget
|
||||||
if (metric == PM_DockWidgetSeparatorExtent)
|
if (metric == PM_DockWidgetSeparatorExtent)
|
||||||
return 1;
|
return 1;
|
||||||
@@ -257,9 +268,13 @@ public:
|
|||||||
}
|
}
|
||||||
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
|
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
|
||||||
QPainter* p, const QWidget* w) const override {
|
QPainter* p, const QWidget* w) const override {
|
||||||
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
|
// Clean 1px border on QMenu (replaces Fusion's 3D bevel + OS shadow)
|
||||||
if (elem == PE_FrameMenu)
|
if (elem == PE_FrameMenu) {
|
||||||
|
p->setPen(opt->palette.color(QPalette::Dark));
|
||||||
|
p->setBrush(Qt::NoBrush);
|
||||||
|
p->drawRect(opt->rect.adjusted(0, 0, -1, -1));
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
// Kill the status bar item frame and panel border
|
// Kill the status bar item frame and panel border
|
||||||
if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar)
|
if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar)
|
||||||
return;
|
return;
|
||||||
@@ -355,7 +370,7 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
|
|||||||
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
|
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
|
||||||
pal.setColor(QPalette::ToolTipText, theme.text);
|
pal.setColor(QPalette::ToolTipText, theme.text);
|
||||||
pal.setColor(QPalette::Mid, theme.hover);
|
pal.setColor(QPalette::Mid, theme.hover);
|
||||||
pal.setColor(QPalette::Dark, theme.background);
|
pal.setColor(QPalette::Dark, theme.border);
|
||||||
pal.setColor(QPalette::Light, theme.textFaint);
|
pal.setColor(QPalette::Light, theme.textFaint);
|
||||||
pal.setColor(QPalette::Link, theme.indHoverSpan);
|
pal.setColor(QPalette::Link, theme.indHoverSpan);
|
||||||
|
|
||||||
@@ -657,6 +672,15 @@ void MainWindow::createMenus() {
|
|||||||
tab.ctrl->setCompactColumns(checked);
|
tab.ctrl->setCompactColumns(checked);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auto* actTreeLines = view->addAction("&Tree Lines");
|
||||||
|
actTreeLines->setCheckable(true);
|
||||||
|
actTreeLines->setChecked(settings.value("treeLines", false).toBool());
|
||||||
|
connect(actTreeLines, &QAction::triggered, this, [this](bool checked) {
|
||||||
|
QSettings("Reclass", "Reclass").setValue("treeLines", checked);
|
||||||
|
for (auto& tab : m_tabs)
|
||||||
|
tab.ctrl->setTreeLines(checked);
|
||||||
|
});
|
||||||
|
|
||||||
auto* actRelOfs = view->addAction("R&elative Offsets");
|
auto* actRelOfs = view->addAction("R&elative Offsets");
|
||||||
actRelOfs->setCheckable(true);
|
actRelOfs->setCheckable(true);
|
||||||
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
|
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
|
||||||
@@ -1307,6 +1331,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
|
|||||||
|
|
||||||
// Apply global compact columns setting to new tab
|
// Apply global compact columns setting to new tab
|
||||||
ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool());
|
ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool());
|
||||||
|
ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", false).toBool());
|
||||||
|
|
||||||
// Give every controller the shared document list for cross-tab type visibility
|
// Give every controller the shared document list for cross-tab type visibility
|
||||||
ctrl->setProjectDocuments(&m_allDocs);
|
ctrl->setProjectDocuments(&m_allDocs);
|
||||||
@@ -2977,6 +3002,30 @@ void MainWindow::createScannerDock() {
|
|||||||
return ctrl ? ctrl->document()->provider : nullptr;
|
return ctrl ? ctrl->document()->provider : nullptr;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire bounds getter: struct base + size for "Current Struct" filter
|
||||||
|
m_scannerPanel->setBoundsGetter([this]() -> rcx::ScannerPanel::StructBounds {
|
||||||
|
auto* ctrl = activeController();
|
||||||
|
if (!ctrl) return {};
|
||||||
|
auto& tree = ctrl->document()->tree;
|
||||||
|
uint64_t base = tree.baseAddress;
|
||||||
|
uint64_t viewRoot = ctrl->viewRootId();
|
||||||
|
int span = 0;
|
||||||
|
if (viewRoot != 0) {
|
||||||
|
span = tree.structSpan(viewRoot);
|
||||||
|
} else {
|
||||||
|
// Compute extent from all top-level nodes
|
||||||
|
for (int i = 0; i < tree.nodes.size(); i++) {
|
||||||
|
const auto& n = tree.nodes[i];
|
||||||
|
int64_t off = tree.computeOffset(i);
|
||||||
|
int sz = (n.kind == rcx::NodeKind::Struct || n.kind == rcx::NodeKind::Array)
|
||||||
|
? tree.structSpan(n.id) : n.byteSize();
|
||||||
|
int64_t end = off + sz;
|
||||||
|
if (end > span) span = static_cast<int>(end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { base, static_cast<uint64_t>(span) };
|
||||||
|
});
|
||||||
|
|
||||||
// Wire "Go to Address" to rebase the active tab
|
// Wire "Go to Address" to rebase the active tab
|
||||||
connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) {
|
connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) {
|
||||||
auto* ctrl = activeController();
|
auto* ctrl = activeController();
|
||||||
|
|||||||
@@ -1214,9 +1214,13 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action == "reset_tracking") {
|
if (action == "reset_tracking") {
|
||||||
if (!ctrl) return makeTextResult("No active tab", true);
|
int count = m_mainWindow->tabCount();
|
||||||
ctrl->resetChangeTracking();
|
for (int i = 0; i < count; ++i) {
|
||||||
return makeTextResult("Value tracking reset. All histories cleared.");
|
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);
|
||||||
|
|||||||
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
|
||||||
@@ -489,13 +489,21 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
const char* msk = isUnknown ? nullptr : 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 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";
|
||||||
@@ -513,21 +521,35 @@ 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 ((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;
|
||||||
@@ -541,7 +563,7 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
// Unknown value: capture every aligned address
|
// Unknown value: capture every aligned address
|
||||||
for (int i = 0; i <= scanEnd; i += alignment) {
|
for (int i = 0; i <= scanEnd; i += alignment) {
|
||||||
ScanResult r;
|
ScanResult r;
|
||||||
r.address = region.base + off + (uint64_t)i;
|
r.address = regStart + off + (uint64_t)i;
|
||||||
r.scanValue = QByteArray(data + i, valSize);
|
r.scanValue = QByteArray(data + i, valSize);
|
||||||
results.append(r);
|
results.append(r);
|
||||||
|
|
||||||
@@ -560,7 +582,7 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
}
|
}
|
||||||
if (match) {
|
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.regionModule = region.moduleName;
|
||||||
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
|
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
|
||||||
results.append(r);
|
results.append(r);
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ struct ScanRequest {
|
|||||||
|
|
||||||
ScanCondition condition = ScanCondition::ExactValue;
|
ScanCondition condition = ScanCondition::ExactValue;
|
||||||
int valueSize = 4; // bytes per value (for unknown scans)
|
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 {
|
struct ScanResult {
|
||||||
|
|||||||
@@ -124,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")),
|
||||||
@@ -257,6 +260,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);
|
||||||
@@ -278,6 +285,7 @@ void ScannerPanel::setEditorFont(const QFont& 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();
|
||||||
}
|
}
|
||||||
@@ -398,6 +406,14 @@ ScanRequest ScannerPanel::buildRequest() {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,6 +766,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);
|
||||||
|
|
||||||
@@ -54,6 +58,7 @@ public:
|
|||||||
ScanEngine* engine() const { return m_engine; }
|
ScanEngine* engine() const { return m_engine; }
|
||||||
QComboBox* condCombo() const { return m_condCombo; }
|
QComboBox* condCombo() const { return m_condCombo; }
|
||||||
QLabel* condLabel() const { return m_condLabel; }
|
QLabel* condLabel() const { return m_condLabel; }
|
||||||
|
QCheckBox* structOnlyCheck() const { return m_structOnlyCheck; }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void goToAddress(uint64_t address);
|
void goToAddress(uint64_t address);
|
||||||
@@ -90,6 +95,7 @@ private:
|
|||||||
// 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;
|
||||||
@@ -106,6 +112,7 @@ 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;
|
||||||
|
|||||||
@@ -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": "#84848e",
|
||||||
|
"textFaint": "#a8a8b0",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(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)
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -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