feat: custom arrow tooltip with transparent background

Rewrite RcxTooltip to use WA_TranslucentBackground with a single
contiguous QPainterPath (rounded rect + arrow notch). Pre-set the
DarkTitleBar property to prevent DarkApp from calling
DwmSetWindowAttribute which breaks layered window compositing.

Dismiss all popups (including arrow tooltip) on alt-tab via
MainWindow::changeEvent(ActivationChange).
This commit is contained in:
IChooseYou
2026-03-14 06:45:45 -06:00
committed by IChooseYou
parent 665138e688
commit f1a36f2ad3
7 changed files with 1079 additions and 1004 deletions

View File

@@ -1,6 +1,7 @@
#include "editor.h"
#include "disasm.h"
#include "providerregistry.h"
#include "rcxtooltip.h"
#include <QDebug>
#include <Qsci/qsciscintilla.h>
#include <Qsci/qsciscintillabase.h>
@@ -1397,6 +1398,7 @@ void RcxEditor::dismissAllPopups() {
if (m_historyPopup) static_cast<HoverPopup*>(m_historyPopup)->dismiss();
if (m_disasmPopup) static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup) static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
if (m_arrowTooltip) static_cast<RcxTooltip*>(m_arrowTooltip)->dismiss();
}
void RcxEditor::hideFindBar() {
@@ -3764,6 +3766,74 @@ void RcxEditor::applyHoverCursor() {
// else: desired stays Arrow (hovering over column padding)
}
// ── Arrow tooltip on command row spans ──
{
bool showTip = false;
if (tokenHit && h.line == 0 && h.line < m_meta.size()
&& m_meta[0].lineKind == LineKind::CommandRow) {
NormalizedSpan span;
QString lineText;
if (resolvedSpanFor(0, t, span, &lineText)
&& h.col >= span.start && h.col < span.end) {
QString tipTitle, tipBody;
switch (t) {
case EditTarget::Source:
tipTitle = QStringLiteral("Data Source");
tipBody = QStringLiteral("Click to change the attached\nmemory source (process, file)");
break;
case EditTarget::BaseAddress:
tipTitle = QStringLiteral("Base Address");
tipBody = QStringLiteral("Click to edit the struct base address\nSupports: hex, <module> + offset, [deref]");
break;
case EditTarget::RootClassName:
tipTitle = QStringLiteral("Class Name");
tipBody = QStringLiteral("Click to rename this type");
break;
case EditTarget::TypeSelector:
tipTitle = QStringLiteral("Type Selector");
tipBody = QStringLiteral("Open the type picker to switch\nbetween structs in this project");
break;
default: break;
}
if (!tipTitle.isEmpty()) {
if (!m_arrowTooltip) {
m_arrowTooltip = new RcxTooltip(this);
static_cast<RcxTooltip*>(m_arrowTooltip)->onMouseMove =
[this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
m_hoverInside = m_sci->viewport()->rect().contains(vp);
applyHoverCursor();
};
}
auto* tip = static_cast<RcxTooltip*>(m_arrowTooltip);
const auto& theme = ThemeManager::instance().current();
tip->setTheme(theme.backgroundAlt, theme.border,
theme.text, theme.textDim, theme.border);
tip->populate(tipTitle, tipBody, editorFont());
// Anchor at center of the hovered span, bottom edge of line
long posA = posFromCol(m_sci, 0, span.start);
long posB = posFromCol(m_sci, 0, span.end);
int xA = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTXFROMPOSITION, 0UL, posA);
int xB = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTXFROMPOSITION, 0UL, posB);
int py = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTYFROMPOSITION, 0UL, posA);
int lh = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_TEXTHEIGHT, 0UL);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint((xA + xB) / 2, py + lh));
tip->showAt(anchor);
showTip = true;
}
}
}
if (!showTip && m_arrowTooltip && m_arrowTooltip->isVisible())
static_cast<RcxTooltip*>(m_arrowTooltip)->dismiss();
}
m_sci->viewport()->setCursor(desired);
}

View File

@@ -159,6 +159,7 @@ private:
QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp)
QWidget* m_disasmPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp)
QWidget* m_structPreviewPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp)
QWidget* m_arrowTooltip = nullptr; // RcxTooltip (arrow callout)
const Provider* m_disasmProvider = nullptr; // snapshot or real — for reading tree data
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr;

View File

@@ -6,6 +6,9 @@
#include "imports/export_reclass_xml.h"
#include "imports/import_pdb.h"
#include "imports/import_pdb_dialog.h"
#include "symbolstore.h"
#include "symbol_downloader.h"
#include "imports/pe_debug_info.h"
#include "mcp/mcp_bridge.h"
#include <QApplication>
#include <QMainWindow>
@@ -276,6 +279,8 @@ public:
// Inset menu items from border so hover rect doesn't touch edges
if (metric == PM_MenuHMargin)
return 3;
if (metric == PM_MenuVMargin)
return 3;
// Thin draggable separator between dock widgets / central widget
if (metric == PM_DockWidgetSeparatorExtent)
return 1;
@@ -605,6 +610,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
createWorkspaceDock();
createScannerDock();
createSymbolsDock();
createMenus();
createStatusBar();
@@ -912,6 +918,11 @@ void MainWindow::createMenus() {
scanAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_S));
view->addAction(scanAct);
}
{
auto* symAct = m_symbolsDock->toggleViewAction();
symAct->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Y));
view->addAction(symAct);
}
// Tools
auto* tools = m_menuBar->addMenu("&Tools");
@@ -2788,7 +2799,7 @@ void MainWindow::applyTheme(const Theme& theme) {
tp.setColor(QPalette::HighlightedText, theme.text);
m_workspaceTree->setPalette(tp);
m_workspaceTree->setStyleSheet(QStringLiteral(
"QTreeView { background: %1; border: none; }"
"QTreeView { background: %1; border: none; padding-left: 4px; }"
"QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }"
"QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }"
"QTreeView::branch { width: 12px; }"
@@ -2871,6 +2882,62 @@ void MainWindow::applyTheme(const Theme& theme) {
if (m_scanDockGrip)
m_scanDockGrip->setGripColor(theme.textFaint);
// Symbols dock
if (m_symbolsDock)
m_symbolsDock->setStyleSheet(QStringLiteral(
"QDockWidget { border: 1px solid %1; }").arg(theme.border.name()));
if (m_symDockTitle)
m_symDockTitle->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.textDim.name()));
if (auto* titleBar = m_symbolsDock ? m_symbolsDock->titleBarWidget() : nullptr) {
QPalette tbPal = titleBar->palette();
tbPal.setColor(QPalette::Window, theme.backgroundAlt);
titleBar->setPalette(tbPal);
}
if (m_symDockCloseBtn)
m_symDockCloseBtn->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }"
"QToolButton:hover { color: %2; }")
.arg(theme.textDim.name(), theme.indHoverSpan.name()));
if (m_symDownloadBtn)
m_symDownloadBtn->setStyleSheet(QStringLiteral(
"QToolButton { border: none; padding: 2px 4px; }"
"QToolButton:hover { background: %1; }")
.arg(theme.hover.name()));
if (m_symDockGrip)
m_symDockGrip->setGripColor(theme.textFaint);
if (m_symbolsSearch) {
m_symbolsSearch->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2;"
" border: 1px solid %4;"
" padding: 4px 8px 4px 2px; }"
"QLineEdit:focus { border: 1px solid %5; }"
"QLineEdit QToolButton { padding: 0px 8px; }"
"QLineEdit QToolButton:hover { background: %3; }")
.arg(theme.background.name(), theme.textDim.name(),
theme.hover.name(), theme.border.name(),
theme.borderFocused.name()));
}
if (m_symbolsTree) {
QPalette tp = m_symbolsTree->palette();
tp.setColor(QPalette::Text, theme.textDim);
tp.setColor(QPalette::Highlight, theme.selected);
tp.setColor(QPalette::HighlightedText, theme.text);
m_symbolsTree->setPalette(tp);
m_symbolsTree->setStyleSheet(QStringLiteral(
"QTreeView { background: %1; border: none; padding-left: 4px; }"
"QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }"
"QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }"
"QTreeView::branch { width: 12px; }"
"QAbstractScrollArea::corner { background: %1; border: none; }"
"QHeaderView { background: %1; border: none; }"
"QHeaderView::section { background: %1; border: none; }")
.arg(theme.background.name()));
}
if (auto* sep = m_symbolsDock ? m_symbolsDock->findChild<QFrame*>("symbolsSep") : nullptr) {
sep->setStyleSheet(QStringLiteral("background: %1; border: none;").arg(theme.border.name()));
}
// Doc dock floating title bars
for (auto* dock : m_docDocks) {
// The float title bar is stored alongside the empty one; find by object name
@@ -3044,6 +3111,12 @@ void MainWindow::setEditorFont(const QString& fontName) {
m_scannerPanel->setEditorFont(f);
if (m_scanDockTitle)
m_scanDockTitle->setFont(f);
if (m_symDockTitle)
m_symDockTitle->setFont(f);
if (m_symbolsSearch)
m_symbolsSearch->setFont(f);
if (m_symbolsTree)
m_symbolsTree->setFont(f);
// Sync doc dock float title fonts
for (auto* dock : m_docDocks) {
if (auto* lbl = dock->findChild<QLabel*>("dockFloatTitle"))
@@ -3529,6 +3602,25 @@ void MainWindow::importPdb() {
if (dlg.exec() != QDialog::Accepted) return;
QString pdbPath = dlg.pdbPath();
// Always load symbols into the SymbolStore when importing a PDB
{
QString symErr;
auto symResult = rcx::extractPdbSymbols(pdbPath, &symErr);
if (!symResult.symbols.isEmpty()) {
QVector<QPair<QString, uint32_t>> symPairs;
symPairs.reserve(symResult.symbols.size());
for (const auto& s : symResult.symbols)
symPairs.append({s.name, s.rva});
int symCount = rcx::SymbolStore::instance().addModule(
symResult.moduleName, pdbPath, symPairs);
if (symCount > 0)
setAppStatus(QStringLiteral("Loaded %1 symbols from %2")
.arg(symCount).arg(QFileInfo(pdbPath).fileName()));
}
rebuildSymbolsModel();
}
QVector<uint32_t> indices = dlg.selectedTypeIndices();
if (indices.isEmpty()) return;
@@ -4026,7 +4118,7 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setPalette(tp);
m_workspaceTree->setStyleSheet(QStringLiteral(
"QTreeView { background: %1; border: none; }"
"QTreeView { background: %1; border: none; padding-left: 4px; }"
"QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }"
"QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }"
"QTreeView::branch { width: 12px; }"
@@ -4597,6 +4689,491 @@ void MainWindow::createScannerDock() {
});
}
void MainWindow::createSymbolsDock() {
m_symbolsDock = new QDockWidget("Symbols", this);
m_symbolsDock->setObjectName("SymbolsDock");
m_symbolsDock->setAllowedAreas(
Qt::BottomDockWidgetArea | Qt::RightDockWidgetArea | Qt::LeftDockWidgetArea);
m_symbolsDock->setFeatures(
QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable |
QDockWidget::DockWidgetFloatable);
// Custom titlebar (matches scanner dock)
{
const auto& t = ThemeManager::instance().current();
auto* titleBar = new QWidget(m_symbolsDock);
titleBar->setFixedHeight(24);
titleBar->setAutoFillBackground(true);
{
QPalette tbPal = titleBar->palette();
tbPal.setColor(QPalette::Window, t.backgroundAlt);
titleBar->setPalette(tbPal);
}
auto* layout = new QHBoxLayout(titleBar);
layout->setContentsMargins(4, 2, 2, 2);
layout->setSpacing(4);
m_symDockGrip = new DockGripWidget(titleBar);
layout->addWidget(m_symDockGrip);
m_symDockTitle = new QLabel("Symbols", titleBar);
m_symDockTitle->setStyleSheet(
QStringLiteral("color: %1;").arg(t.textDim.name()));
layout->addWidget(m_symDockTitle);
layout->addStretch();
m_symDownloadBtn = new QToolButton(titleBar);
m_symDownloadBtn->setIcon(QIcon(QStringLiteral(":/vsicons/cloud-download.svg")));
m_symDownloadBtn->setIconSize(QSize(14, 14));
m_symDownloadBtn->setAutoRaise(true);
m_symDownloadBtn->setCursor(Qt::PointingHandCursor);
m_symDownloadBtn->setToolTip(QStringLiteral("Download symbols for attached process"));
m_symDownloadBtn->setStyleSheet(QStringLiteral(
"QToolButton { border: none; padding: 2px 4px; }"
"QToolButton:hover { background: %1; }")
.arg(t.hover.name()));
connect(m_symDownloadBtn, &QToolButton::clicked, this, &MainWindow::downloadSymbolsForProcess);
layout->addWidget(m_symDownloadBtn);
m_symDockCloseBtn = new QToolButton(titleBar);
m_symDockCloseBtn->setText(QStringLiteral("\u2715"));
m_symDockCloseBtn->setAutoRaise(true);
m_symDockCloseBtn->setCursor(Qt::PointingHandCursor);
m_symDockCloseBtn->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }"
"QToolButton:hover { color: %2; }")
.arg(t.textDim.name(), t.indHoverSpan.name()));
connect(m_symDockCloseBtn, &QToolButton::clicked, m_symbolsDock, &QDockWidget::close);
layout->addWidget(m_symDockCloseBtn);
m_symbolsDock->setTitleBarWidget(titleBar);
}
{
const auto& t = ThemeManager::instance().current();
m_symbolsDock->setStyleSheet(QStringLiteral(
"QDockWidget { border: 1px solid %1; }").arg(t.border.name()));
}
// Container: search box + tree view
auto* container = new QWidget(m_symbolsDock);
auto* containerLayout = new QVBoxLayout(container);
containerLayout->setContentsMargins(0, 0, 0, 0);
containerLayout->setSpacing(0);
// Search/filter box
m_symbolsSearch = new QLineEdit(container);
m_symbolsSearch->setPlaceholderText(QStringLiteral("Filter symbols..."));
{
QSettings s("Reclass", "Reclass");
QFont f(s.value("font", "JetBrains Mono").toString(), 10);
f.setFixedPitch(true);
m_symbolsSearch->setFont(f);
m_symDockTitle->setFont(f);
}
{
auto* searchAction = m_symbolsSearch->addAction(
QIcon(QStringLiteral(":/vsicons/search.svg")),
QLineEdit::LeadingPosition);
for (auto* btn : m_symbolsSearch->findChildren<QToolButton*>()) {
if (btn->defaultAction() == searchAction) {
btn->setIconSize(QSize(14, 14));
break;
}
}
}
{
auto* clearAction = m_symbolsSearch->addAction(
QIcon(QStringLiteral(":/vsicons/close.svg")),
QLineEdit::TrailingPosition);
clearAction->setVisible(false);
connect(clearAction, &QAction::triggered,
m_symbolsSearch, &QLineEdit::clear);
connect(m_symbolsSearch, &QLineEdit::textChanged,
clearAction, [clearAction](const QString& text) {
clearAction->setVisible(!text.isEmpty());
});
for (auto* btn : m_symbolsSearch->findChildren<QToolButton*>()) {
if (btn->defaultAction() == clearAction) {
btn->setIconSize(QSize(14, 14));
break;
}
}
}
{
const auto& t = ThemeManager::instance().current();
m_symbolsSearch->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2;"
" border: 1px solid %4;"
" padding: 4px 8px 4px 2px; }"
"QLineEdit:focus { border: 1px solid %5; }"
"QLineEdit QToolButton { padding: 0px 8px; }"
"QLineEdit QToolButton:hover { background: %3; }")
.arg(t.background.name(), t.textDim.name(),
t.hover.name(), t.border.name(),
t.borderFocused.name()));
}
m_symbolsSearch->setContentsMargins(6, 6, 6, 6);
containerLayout->addWidget(m_symbolsSearch);
// Separator
{
const auto& t = ThemeManager::instance().current();
auto* sep = new QFrame(container);
sep->setObjectName(QStringLiteral("symbolsSep"));
sep->setFrameShape(QFrame::HLine);
sep->setFixedHeight(1);
sep->setStyleSheet(QStringLiteral("background: %1; border: none;").arg(t.border.name()));
containerLayout->addWidget(sep);
}
// Tree view
m_symbolsTree = new QTreeView(container);
m_symbolsModel = new QStandardItemModel(this);
m_symbolsModel->setHorizontalHeaderLabels({"Name"});
m_symbolsProxy = new QSortFilterProxyModel(this);
m_symbolsProxy->setSourceModel(m_symbolsModel);
m_symbolsProxy->setFilterCaseSensitivity(Qt::CaseInsensitive);
m_symbolsProxy->setRecursiveFilteringEnabled(true);
m_symbolsTree->setModel(m_symbolsProxy);
m_symbolsTree->setHeaderHidden(true);
m_symbolsTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_symbolsTree->setExpandsOnDoubleClick(true);
m_symbolsTree->setMouseTracking(true);
m_symbolsTree->setSelectionMode(QAbstractItemView::SingleSelection);
{
QSettings s("Reclass", "Reclass");
QFont f(s.value("font", "JetBrains Mono").toString(), 10);
f.setFixedPitch(true);
m_symbolsTree->setFont(f);
}
// Debounced search
auto* searchTimer = new QTimer(this);
searchTimer->setSingleShot(true);
searchTimer->setInterval(150);
connect(searchTimer, &QTimer::timeout, this, [this]() {
QString text = m_symbolsSearch->text();
m_symbolsProxy->setFilterFixedString(text);
if (!text.isEmpty())
m_symbolsTree->expandAll();
else
m_symbolsTree->collapseAll();
});
connect(m_symbolsSearch, &QLineEdit::textChanged, this, [searchTimer]() {
searchTimer->start();
});
// Tree styling
{
const auto& t = ThemeManager::instance().current();
QPalette tp = m_symbolsTree->palette();
tp.setColor(QPalette::Text, t.textDim);
tp.setColor(QPalette::Highlight, t.selected);
tp.setColor(QPalette::HighlightedText, t.text);
m_symbolsTree->setPalette(tp);
m_symbolsTree->setStyleSheet(QStringLiteral(
"QTreeView { background: %1; border: none; padding-left: 4px; }"
"QTreeView::branch:has-children:closed { image: url(:/vsicons/chevron-right.svg); }"
"QTreeView::branch:has-children:open { image: url(:/vsicons/chevron-down.svg); }"
"QTreeView::branch { width: 12px; }"
"QAbstractScrollArea::corner { background: %1; border: none; }"
"QHeaderView { background: %1; border: none; }"
"QHeaderView::section { background: %1; border: none; }")
.arg(t.background.name()));
}
m_symbolsTree->setIndentation(12);
containerLayout->addWidget(m_symbolsTree);
// Lazy-load children when a module node is expanded
connect(m_symbolsTree, &QTreeView::expanded, this, [this](const QModelIndex& proxyIdx) {
QModelIndex srcIdx = m_symbolsProxy->mapToSource(proxyIdx);
auto* item = m_symbolsModel->itemFromIndex(srcIdx);
if (!item || item->parent()) return; // only top-level (module) items
// Check if already populated (sentinel child with empty text)
if (item->rowCount() == 1 && item->child(0)->text().isEmpty()) {
item->removeRows(0, 1); // remove sentinel
QString moduleName = item->data(Qt::UserRole).toString();
const auto* set = rcx::SymbolStore::instance().moduleData(moduleName);
if (set) {
for (const auto& sym : set->rvaToName) {
auto* child = new QStandardItem(
QStringLiteral("%1 [0x%2]")
.arg(sym.second)
.arg(sym.first, 8, 16, QLatin1Char('0')));
child->setData(moduleName, Qt::UserRole); // module name
child->setData(sym.first, Qt::UserRole + 1); // RVA
child->setData(sym.second, Qt::UserRole + 2); // symbol name
item->appendRow(child);
}
}
}
});
// Context menu
m_symbolsTree->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_symbolsTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
QModelIndex proxyIdx = m_symbolsTree->indexAt(pos);
if (!proxyIdx.isValid()) return;
QModelIndex srcIdx = m_symbolsProxy->mapToSource(proxyIdx);
auto* item = m_symbolsModel->itemFromIndex(srcIdx);
if (!item) return;
QMenu menu;
if (!item->parent()) {
// Module-level item
QString moduleName = item->data(Qt::UserRole).toString();
auto* actUnload = menu.addAction("Unload Module");
connect(actUnload, &QAction::triggered, this, [this, moduleName]() {
rcx::SymbolStore::instance().unloadModule(moduleName);
rebuildSymbolsModel();
// Refresh active view to clear stale annotations
if (auto* ctrl = activeController())
ctrl->refresh();
});
} else {
// Symbol-level item
QString moduleName = item->data(Qt::UserRole).toString();
QString symName = item->data(Qt::UserRole + 2).toString();
uint32_t rva = item->data(Qt::UserRole + 1).toUInt();
QString fullName = moduleName + QStringLiteral("!") + symName;
auto* actCopyName = menu.addAction("Copy Symbol Name");
connect(actCopyName, &QAction::triggered, this, [fullName]() {
QApplication::clipboard()->setText(fullName);
});
auto* actCopyRva = menu.addAction("Copy RVA");
connect(actCopyRva, &QAction::triggered, this, [rva]() {
QApplication::clipboard()->setText(
QStringLiteral("0x%1").arg(rva, 8, 16, QLatin1Char('0')));
});
}
menu.exec(m_symbolsTree->viewport()->mapToGlobal(pos));
});
m_symbolsDock->setWidget(container);
addDockWidget(Qt::RightDockWidgetArea, m_symbolsDock);
m_symbolsDock->hide();
// Border overlay and resize grip for floating state
{
auto* border = new BorderOverlay(m_symbolsDock);
border->color = ThemeManager::instance().current().borderFocused;
border->hide();
auto* grip = new ResizeGrip(m_symbolsDock);
grip->hide();
connect(m_symbolsDock, &QDockWidget::topLevelChanged,
this, [this, border, grip](bool floating) {
if (floating) {
border->setGeometry(0, 0, m_symbolsDock->width(), m_symbolsDock->height());
border->raise();
border->show();
grip->reposition();
grip->raise();
grip->show();
} else {
border->hide();
grip->hide();
}
});
m_symbolsDock->installEventFilter(new DockBorderFilter(border, grip, m_symbolsDock));
}
}
void MainWindow::rebuildSymbolsModel() {
if (!m_symbolsModel) return;
m_symbolsModel->clear();
m_symbolsModel->setHorizontalHeaderLabels({"Name"});
auto& store = rcx::SymbolStore::instance();
for (const auto& moduleName : store.loadedModules()) {
const auto* set = store.moduleData(moduleName);
if (!set) continue;
int count = set->nameToRva.size();
auto* moduleItem = new QStandardItem(
QStringLiteral("%1 (%2 symbols)").arg(moduleName).arg(count));
moduleItem->setData(moduleName, Qt::UserRole);
moduleItem->setToolTip(set->pdbPath);
// Sentinel child for lazy loading (shows expand arrow)
moduleItem->appendRow(new QStandardItem());
m_symbolsModel->appendRow(moduleItem);
}
}
void MainWindow::downloadSymbolsForProcess() {
auto* ctrl = activeController();
if (!ctrl || !ctrl->document()->provider) {
setAppStatus(QStringLiteral("No process attached"));
return;
}
auto prov = ctrl->document()->provider;
auto modules = prov->enumerateModules();
if (modules.isEmpty()) {
setAppStatus(QStringLiteral("No modules found in target process"));
return;
}
// Create downloader on first use
if (!m_symDownloader) {
m_symDownloader = new rcx::SymbolDownloader(this);
connect(m_symDownloader, &rcx::SymbolDownloader::progress,
this, [this](const QString& mod, int received, int total) {
if (total > 0)
setAppStatus(QStringLiteral("Downloading %1... %2/%3 KB")
.arg(mod).arg(received/1024).arg(total/1024));
else
setAppStatus(QStringLiteral("Downloading %1... %2 KB")
.arg(mod).arg(received/1024));
});
connect(m_symDownloader, &rcx::SymbolDownloader::finished,
this, [this](const QString& mod, const QString& localPath,
bool success, const QString& error) {
if (!success) {
qDebug() << "[SymbolDownloader]" << mod << "failed:" << error;
return;
}
// Extract symbols and add to store
QString symErr;
auto result = rcx::extractPdbSymbols(localPath, &symErr);
if (!result.symbols.isEmpty()) {
QVector<QPair<QString, uint32_t>> pairs;
pairs.reserve(result.symbols.size());
for (const auto& s : result.symbols)
pairs.append({s.name, s.rva});
int count = rcx::SymbolStore::instance().addModule(
result.moduleName, localPath, pairs);
setAppStatus(QStringLiteral("Loaded %1 symbols for %2")
.arg(count).arg(mod));
}
rebuildSymbolsModel();
if (auto* c = activeController())
c->refresh();
});
}
// Build download queue: skip modules already loaded
struct PendingModule {
QString name;
QString fullPath;
uint64_t base;
rcx::PdbDebugInfo debugInfo;
};
QVector<PendingModule> pending;
setAppStatus(QStringLiteral("Scanning %1 modules for debug info...").arg(modules.size()));
QApplication::processEvents();
auto& store = rcx::SymbolStore::instance();
for (const auto& mod : modules) {
// Strip extension for canonical name check
QString canonical = store.resolveAlias(mod.name);
if (store.moduleData(canonical))
continue; // already loaded
// Extract PDB debug info from PE header in memory
auto info = rcx::extractPdbDebugInfo(*prov, mod.base);
if (!info.valid)
continue;
// Check local first (same directory as module)
QString localPdb = rcx::SymbolDownloader::findLocal(mod.fullPath, info.pdbName);
if (!localPdb.isEmpty()) {
// Load directly
QString symErr;
auto result = rcx::extractPdbSymbols(localPdb, &symErr);
if (!result.symbols.isEmpty()) {
QVector<QPair<QString, uint32_t>> pairs;
pairs.reserve(result.symbols.size());
for (const auto& s : result.symbols)
pairs.append({s.name, s.rva});
int count = store.addModule(result.moduleName, localPdb, pairs);
setAppStatus(QStringLiteral("Loaded %1 symbols for %2 (local)")
.arg(count).arg(mod.name));
QApplication::processEvents();
}
continue;
}
// Check cache
rcx::SymbolDownloader::DownloadRequest req;
req.moduleName = mod.name;
req.pdbName = info.pdbName;
req.guidString = info.guidString;
req.age = info.age;
QString cached = m_symDownloader->findCached(req);
if (!cached.isEmpty()) {
QString symErr;
auto result = rcx::extractPdbSymbols(cached, &symErr);
if (!result.symbols.isEmpty()) {
QVector<QPair<QString, uint32_t>> pairs;
pairs.reserve(result.symbols.size());
for (const auto& s : result.symbols)
pairs.append({s.name, s.rva});
int count = store.addModule(result.moduleName, cached, pairs);
setAppStatus(QStringLiteral("Loaded %1 symbols for %2 (cached)")
.arg(count).arg(mod.name));
QApplication::processEvents();
}
continue;
}
pending.append({mod.name, mod.fullPath, mod.base, info});
}
rebuildSymbolsModel();
if (pending.isEmpty()) {
setAppStatus(QStringLiteral("All available symbols loaded"));
if (auto* c = activeController())
c->refresh();
return;
}
// Download pending modules sequentially
auto queue = std::make_shared<QVector<PendingModule>>(std::move(pending));
auto idx = std::make_shared<int>(0);
auto conn = std::make_shared<QMetaObject::Connection>();
auto processNext = [this, queue, idx, conn]() {
if (*idx >= queue->size()) {
setAppStatus(QStringLiteral("Symbol download complete (%1 modules)")
.arg(queue->size()));
disconnect(*conn);
return;
}
const auto& mod = (*queue)[*idx];
(*idx)++;
rcx::SymbolDownloader::DownloadRequest req;
req.moduleName = mod.name;
req.pdbName = mod.debugInfo.pdbName;
req.guidString = mod.debugInfo.guidString;
req.age = mod.debugInfo.age;
m_symDownloader->download(req);
};
// Chain downloads: when one finishes, start the next
*conn = connect(m_symDownloader, &rcx::SymbolDownloader::finished,
this, [this, processNext](const QString&, const QString&, bool, const QString&) {
QTimer::singleShot(0, this, processNext);
});
setAppStatus(QStringLiteral("Downloading symbols for %1 modules...").arg(queue->size()));
processNext();
}
void MainWindow::rebuildAllDocs() {
m_allDocs.clear();
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
@@ -4824,6 +5401,11 @@ void MainWindow::changeEvent(QEvent* event) {
if (event->type() == QEvent::ActivationChange) {
const auto& t = ThemeManager::instance().current();
updateBorderColor(isActiveWindow() ? t.borderFocused : t.border);
if (!isActiveWindow()) {
for (auto& tab : m_tabs)
for (auto& pane : tab.panes)
if (pane.editor) pane.editor->dismissAllPopups();
}
}
if (event->type() == QEvent::WindowStateChange && m_titleBar)
m_titleBar->updateMaximizeIcon();

View File

@@ -1,241 +1,171 @@
#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)
#include <QApplication>
#include <QMouseEvent>
#include <functional>
namespace rcx {
// ── Modern arrow tooltip ──
// Draws a rounded-rect body with a triangular arrow whose tip touches
// the anchor point (center of the dwell area).
//
// Bypasses Fusion/CSS/DWM entirely — everything is manual QPainter on a
// WA_TranslucentBackground layered window. The DarkTitleBar property is
// pre-set to prevent DarkApp::notify from calling DwmSetWindowAttribute
// (which was the root cause of the previous transparent-window failure).
//
// Usage:
// tip->setTheme(bg, border, titleCol, bodyCol, sepCol);
// tip->populate("Title", "line1\nline2", font);
// tip->showAt(QPoint(midX, lineBottom)); // arrow tip at this point
// tip->dismiss();
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;
static constexpr int kArrowH = 8;
static constexpr int kArrowW = 14;
static constexpr int kRadius = 6;
static constexpr int kPad = 10;
static constexpr int kGap = 4;
static constexpr int kMaxW = 550;
std::function<void(QMouseEvent*)> onMouseMove;
explicit RcxTooltip(QWidget* parent = nullptr)
: QWidget(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
// ── Key fix: prevent DwmSetWindowAttribute on this window ──
// DarkApp::notify checks this property and skips DWM calls.
// Without this, DWMWA_USE_IMMERSIVE_DARK_MODE breaks WS_EX_LAYERED
// alpha compositing on Windows 10/11.
setProperty("DarkTitleBar", true);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_ShowWithoutActivating);
setAttribute(Qt::WA_DeleteOnClose, false);
setMouseTracking(true);
}
void showFor(QWidget* trigger, const QString& text) {
if (!trigger || text.isEmpty()) {
TIP_LOG("[TIP] showFor: null trigger or empty text -- dismiss\n");
dismiss(); return;
}
void setTheme(const QColor& bg, const QColor& border,
const QColor& title, const QColor& body, const QColor& sep) {
m_bg = bg; m_border = border;
m_titleCol = title; m_bodyCol = body; m_sepCol = sep;
}
// 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;
}
void populate(const QString& title, const QString& body, const QFont& font) {
if (title == m_title && body == m_body && isVisible()) return;
m_title = title; m_body = body;
m_lines = body.split('\n');
m_font = font; m_bold = font; m_bold.setBold(true);
recalc();
}
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);
// `anchor`: global screen point where the arrow tip touches.
// Typically the center-bottom of the hovered span.
void showAt(const QPoint& anchor) {
QRect scr = screenAt(anchor);
int w = m_bw, h = m_bh + kArrowH;
m_up = (anchor.y() + h <= scr.bottom());
int x = qBound(scr.left() + 2, anchor.x() - w / 2, scr.right() - w - 2);
int y = m_up ? anchor.y() : anchor.y() - h;
m_ax = qBound(kRadius + kArrowW/2 + 1, anchor.x() - x,
w - kRadius - kArrowW/2 - 1);
setFixedSize(w, h);
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();
}
if (!isVisible()) show();
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;
void dismiss() { if (isVisible()) hide(); }
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);
// Body rect (excludes arrow space)
QRectF b(0.5, m_up ? kArrowH + 0.5 : 0.5,
width() - 1.0, m_bh - 1.0);
qreal r = kRadius, ax = m_ax, ah = kArrowW / 2.0;
// 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);
// ── Single contiguous path: rounded rect + arrow notch ──
// No QPainterPath::united() — that causes junction artifacts.
// Clockwise from top-left, inserting the arrow inline.
QPainterPath pp;
pp.moveTo(b.left() + r, b.top());
if (m_up) {
pp.lineTo(ax - ah, b.top());
pp.lineTo(ax, 0.5);
pp.lineTo(ax + ah, b.top());
}
QPainterPath arrowPath;
arrowPath.addPolygon(arrow);
arrowPath.closeSubpath();
path = path.united(arrowPath);
pp.lineTo(b.right() - r, b.top());
pp.arcTo(b.right() - 2*r, b.top(), 2*r, 2*r, 90, -90);
pp.lineTo(b.right(), b.bottom() - r);
pp.arcTo(b.right() - 2*r, b.bottom() - 2*r, 2*r, 2*r, 0, -90);
if (!m_up) {
pp.lineTo(ax + ah, b.bottom());
pp.lineTo(ax, height() - 0.5);
pp.lineTo(ax - ah, b.bottom());
}
pp.lineTo(b.left() + r, b.bottom());
pp.arcTo(b.left(), b.bottom() - 2*r, 2*r, 2*r, 270, -90);
pp.lineTo(b.left(), b.top() + r);
pp.arcTo(b.left(), b.top(), 2*r, 2*r, 180, -90);
pp.closeSubpath();
// Stroke the shape border
p.setPen(QPen(theme.border, 1.0));
p.setBrush(theme.backgroundAlt);
p.drawPath(path);
p.setPen(QPen(m_border, 1));
p.setBrush(m_bg);
p.drawPath(pp);
// ── Content: title + separator + body ──
qreal cy = (m_up ? kArrowH : 0) + kPad;
QFontMetrics tf(m_bold), bf(m_font);
if (!m_title.isEmpty()) {
p.setFont(m_bold); p.setPen(m_titleCol);
p.drawText(QPointF(kPad, cy + tf.ascent()), m_title);
cy += tf.height() + kGap;
p.setPen(m_sepCol);
p.drawLine(QPointF(kPad, cy), QPointF(width() - kPad, cy));
cy += 1 + kGap;
}
p.setFont(m_font); p.setPen(m_bodyCol);
for (const auto& l : m_lines) {
p.drawText(QPointF(kPad, cy + bf.ascent()), l);
cy += bf.lineSpacing();
}
}
void mouseMoveEvent(QMouseEvent* e) override {
if (onMouseMove) onMouseMove(e); else QWidget::mouseMoveEvent(e);
}
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(); });
static QRect screenAt(const QPoint& pt) {
auto* s = QApplication::screenAt(pt);
return s ? s->availableGeometry() : QRect(0, 0, 1920, 1080);
}
void updateLabelStyle() {
const auto& theme = ThemeManager::instance().current();
m_label->setStyleSheet(
QStringLiteral("QLabel { color: %1; background: transparent; padding: 0; }")
.arg(theme.text.name()));
void recalc() {
QFontMetrics tf(m_bold), bf(m_font);
int maxW = m_title.isEmpty() ? 0 : tf.horizontalAdvance(m_title);
for (const auto& l : m_lines) maxW = qMax(maxW, bf.horizontalAdvance(l));
m_bw = qMin(maxW + 2 * kPad, kMaxW);
m_bh = kPad + (m_title.isEmpty() ? 0 : tf.height() + kGap + 1 + kGap)
+ m_lines.size() * bf.lineSpacing() + kPad;
}
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;
QString m_title, m_body;
QStringList m_lines;
QFont m_font, m_bold;
QColor m_bg{30, 30, 30}, m_border{60, 60, 60};
QColor m_titleCol{220, 220, 220}, m_bodyCol{180, 180, 180}, m_sepCol{60, 60, 60};
bool m_up = true;
int m_ax = 0, m_bw = 0, m_bh = 0;
};
} // namespace rcx