mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
586
src/main.cpp
586
src/main.cpp
@@ -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();
|
||||
|
||||
340
src/rcxtooltip.h
340
src/rcxtooltip.h
@@ -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
|
||||
|
||||
@@ -9,32 +9,35 @@
|
||||
using namespace rcx;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Test suite for the RcxTooltip callout widget
|
||||
// Test suite for the RcxTooltip arrow 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)
|
||||
// Validates:
|
||||
// - Arrow direction auto-detection (above/below based on screen space)
|
||||
// - Arrow X clamped to stay within rounded corners
|
||||
// - WA_TranslucentBackground rendering (arrow + body have opaque pixels,
|
||||
// corners are transparent)
|
||||
// - Content sizing (title + separator + body)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
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;
|
||||
QWidget* m_window = nullptr;
|
||||
RcxTooltip* m_tip = nullptr;
|
||||
|
||||
void showAndProcess(QWidget* trigger, const QString& text) {
|
||||
RcxTooltip::instance()->showFor(trigger, text);
|
||||
// Process events + allow paint to complete
|
||||
QFont testFont() {
|
||||
QFont f("JetBrains Mono", 12);
|
||||
f.setFixedPitch(true);
|
||||
return f;
|
||||
}
|
||||
|
||||
void showAndProcess(const QPoint& anchor) {
|
||||
m_tip->showAt(anchor);
|
||||
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());
|
||||
@@ -49,382 +52,180 @@ 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));
|
||||
|
||||
m_tip = new RcxTooltip(m_window);
|
||||
const auto& t = ThemeManager::instance().current();
|
||||
m_tip->setTheme(t.backgroundAlt, t.border, t.text, t.syntaxNumber, t.border);
|
||||
}
|
||||
|
||||
void cleanupTestCase() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
m_tip->dismiss();
|
||||
delete m_tip;
|
||||
delete m_window;
|
||||
m_window = nullptr;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
m_tip->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);
|
||||
QVERIFY(!m_tip->isVisible());
|
||||
m_tip->populate("Title", "Body text", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
QVERIFY(m_tip->isVisible());
|
||||
m_tip->dismiss();
|
||||
QVERIFY(!m_tip->isVisible());
|
||||
}
|
||||
|
||||
// ── Empty text / null trigger = dismiss ──
|
||||
void testEmptyTextDismisses() {
|
||||
auto* tip = RcxTooltip::instance();
|
||||
showAndProcess(m_btnMid, "Test");
|
||||
QVERIFY(tip->isVisible());
|
||||
showAndProcess(m_btnMid, "");
|
||||
QVERIFY(!tip->isVisible());
|
||||
// ── Duplicate populate is no-op ──
|
||||
void testDuplicatePopulateSkipped() {
|
||||
m_tip->populate("Title", "Body", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
QPoint pos1 = m_tip->pos();
|
||||
// Same content — populate returns early, position unchanged
|
||||
m_tip->populate("Title", "Body", testFont());
|
||||
QCOMPARE(m_tip->pos(), pos1);
|
||||
}
|
||||
|
||||
void testNullTriggerDismisses() {
|
||||
auto* tip = RcxTooltip::instance();
|
||||
showAndProcess(m_btnMid, "Test");
|
||||
QVERIFY(tip->isVisible());
|
||||
showAndProcess(nullptr, "Test");
|
||||
QVERIFY(!tip->isVisible());
|
||||
// ── Arrow direction: below when room exists ──
|
||||
void testArrowUpWhenBelow() {
|
||||
m_tip->populate("Test", "Below", testFont());
|
||||
// Anchor in middle of screen — plenty of room below
|
||||
QPoint anchor = m_window->mapToGlobal(QPoint(400, 300));
|
||||
showAndProcess(anchor);
|
||||
QVERIFY(m_tip->isVisible());
|
||||
// Arrow up (tooltip below anchor): widget top == anchor.y
|
||||
QCOMPARE(m_tip->y(), anchor.y());
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
// ── Arrow direction: above when no room below ──
|
||||
void testArrowDownWhenAbove() {
|
||||
m_tip->populate("Test", "Above", testFont());
|
||||
// Anchor near bottom of screen
|
||||
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");
|
||||
QPoint anchor(avail.center().x(), avail.bottom() - 5);
|
||||
showAndProcess(anchor);
|
||||
QVERIFY(m_tip->isVisible());
|
||||
// Arrow down (tooltip above anchor): widget bottom == anchor.y
|
||||
int tipBottom = m_tip->y() + m_tip->height();
|
||||
QCOMPARE(tipBottom, anchor.y());
|
||||
}
|
||||
|
||||
// ── Horizontal clamping ──
|
||||
void testHorizontalClampLeft() {
|
||||
m_tip->populate("Test", "Wide body text for clamping", testFont());
|
||||
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();
|
||||
QPoint anchor(avail.left() + 5, avail.center().y());
|
||||
showAndProcess(anchor);
|
||||
QVERIFY(m_tip->x() >= avail.left());
|
||||
}
|
||||
|
||||
void testHorizontalClampRight() {
|
||||
m_tip->populate("Test", "Wide body text for clamping", testFont());
|
||||
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);
|
||||
QPoint anchor(avail.right() - 5, avail.center().y());
|
||||
showAndProcess(anchor);
|
||||
QVERIFY(m_tip->x() + m_tip->width() <= avail.right() + 2);
|
||||
}
|
||||
|
||||
// ── Constants ──
|
||||
void testConstants() {
|
||||
QCOMPARE(RcxTooltip::kArrowH, 6);
|
||||
QCOMPARE(RcxTooltip::kArrowHalfW, 6);
|
||||
QCOMPARE(RcxTooltip::kGap, 2);
|
||||
QCOMPARE(RcxTooltip::kArrowH, 8);
|
||||
QCOMPARE(RcxTooltip::kArrowW, 14);
|
||||
QCOMPARE(RcxTooltip::kRadius, 6);
|
||||
}
|
||||
|
||||
// ── Title-only vs title+body sizing ──
|
||||
void testTitleOnlySizing() {
|
||||
m_tip->dismiss();
|
||||
m_tip->populate("", "Just body", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
int hNoTitle = m_tip->height();
|
||||
|
||||
m_tip->dismiss();
|
||||
m_tip->populate("Title", "Just body", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
int hWithTitle = m_tip->height();
|
||||
|
||||
QVERIFY2(hWithTitle > hNoTitle,
|
||||
"Tooltip with title should be taller than body-only");
|
||||
}
|
||||
|
||||
// ── Multi-line body ──
|
||||
void testMultilineBody() {
|
||||
m_tip->dismiss();
|
||||
m_tip->populate("Title", "Line 1", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
int h1 = m_tip->height();
|
||||
|
||||
m_tip->dismiss();
|
||||
m_tip->populate("Title", "Line 1\nLine 2\nLine 3", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
int h3 = m_tip->height();
|
||||
|
||||
QVERIFY2(h3 > h1, "3-line tooltip should be taller than 1-line");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// RENDERING VERIFICATION — catches invisible tooltip bugs
|
||||
// RENDERING VERIFICATION — WA_TranslucentBackground works
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
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());
|
||||
void testBodyRendersOpaquePixels() {
|
||||
m_tip->populate("Render", "Test body", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
QVERIFY(m_tip->isVisible());
|
||||
|
||||
// Force full opacity so grab gets real pixels
|
||||
tip->setWindowOpacity(1.0);
|
||||
QCoreApplication::processEvents();
|
||||
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
QVERIFY(!img.isNull());
|
||||
|
||||
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 center of body for opaque pixels (avoid edges/corners)
|
||||
QRect center(img.width() / 4, img.height() / 4,
|
||||
img.width() / 2, img.height() / 2);
|
||||
int opaque = countOpaquePixels(img, center);
|
||||
int total = center.width() * center.height();
|
||||
QVERIFY2(opaque > total / 2,
|
||||
qPrintable(QStringLiteral("Body center has %1/%2 opaque pixels (<50%%)")
|
||||
.arg(opaque).arg(total)));
|
||||
}
|
||||
|
||||
// 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();
|
||||
void testCornersAreTransparent() {
|
||||
m_tip->populate("Corner", "Test", testFont());
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
QVERIFY(m_tip->isVisible());
|
||||
|
||||
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)));
|
||||
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
|
||||
// Top-left 2x2 corner should be fully transparent (rounded corner)
|
||||
QRect corner(0, 0, 2, 2);
|
||||
int opaque = countOpaquePixels(img, corner);
|
||||
QCOMPARE(opaque, 0);
|
||||
}
|
||||
|
||||
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());
|
||||
m_tip->populate("Arrow", "Test", testFont());
|
||||
// Show below (arrow up) — arrow is in the top strip
|
||||
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
|
||||
QVERIFY(m_tip->isVisible());
|
||||
|
||||
tip->setWindowOpacity(1.0);
|
||||
QCoreApplication::processEvents();
|
||||
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
|
||||
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"));
|
||||
// Arrow region: top kArrowH pixels, centered horizontally
|
||||
int centerX = img.width() / 2;
|
||||
QRect arrowRect(centerX - RcxTooltip::kArrowW / 2, 0,
|
||||
RcxTooltip::kArrowW, RcxTooltip::kArrowH);
|
||||
int opaque = countOpaquePixels(img, arrowRect);
|
||||
QVERIFY2(opaque > 0,
|
||||
qPrintable(QStringLiteral("Arrow region has 0 opaque pixels")));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,290 +1,106 @@
|
||||
// Tests the full tooltip flow including DarkApp-style ToolTip interception.
|
||||
// Verifies that QEvent::ToolTip fires and our custom tooltip appears.
|
||||
// Tests RcxTooltip positioning and arrow direction across screen edges.
|
||||
// Validates that the arrow tip touches the anchor point and the tooltip
|
||||
// body stays within screen bounds.
|
||||
|
||||
#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;
|
||||
RcxTooltip* m_tip = nullptr;
|
||||
|
||||
QFont testFont() {
|
||||
QFont f("JetBrains Mono", 12);
|
||||
f.setFixedPitch(true);
|
||||
return f;
|
||||
}
|
||||
|
||||
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());
|
||||
m_tip = new RcxTooltip;
|
||||
const auto& t = ThemeManager::instance().current();
|
||||
m_tip->setTheme(t.backgroundAlt, t.border, t.text, t.syntaxNumber, t.border);
|
||||
}
|
||||
|
||||
void cleanupTestCase() {
|
||||
qApp->removeEventFilter(m_sim);
|
||||
RcxTooltip::instance()->dismiss();
|
||||
delete m_sim;
|
||||
delete m_window;
|
||||
LOG("=== TestTooltipEvent finished ===\n");
|
||||
m_tip->dismiss();
|
||||
delete m_tip;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
m_tip->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);
|
||||
// Arrow tip Y matches anchor Y when showing below
|
||||
void testArrowTipMatchesAnchorBelow() {
|
||||
m_tip->populate("Test", "Body", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QPoint anchor = scr->availableGeometry().center();
|
||||
m_tip->showAt(anchor);
|
||||
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");
|
||||
QVERIFY(m_tip->isVisible());
|
||||
// Arrow up (tooltip below): widget top == anchor.y
|
||||
QCOMPARE(m_tip->y(), anchor.y());
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Arrow tip Y matches anchor Y when showing above
|
||||
void testArrowTipMatchesAnchorAbove() {
|
||||
m_tip->populate("Test", "Body", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QRect avail = scr->availableGeometry();
|
||||
QPoint anchor(avail.center().x(), avail.bottom() - 2);
|
||||
m_tip->showAt(anchor);
|
||||
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");
|
||||
QVERIFY(m_tip->isVisible());
|
||||
// Arrow down (tooltip above): widget bottom == anchor.y
|
||||
QCOMPARE(m_tip->y() + m_tip->height(), anchor.y());
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Tooltip stays within screen bounds at left edge
|
||||
void testScreenLeftEdge() {
|
||||
m_tip->populate("Test", "Wide body content for edge test", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QRect avail = scr->availableGeometry();
|
||||
QPoint anchor(avail.left() + 2, avail.center().y());
|
||||
m_tip->showAt(anchor);
|
||||
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");
|
||||
QVERIFY(m_tip->x() >= avail.left());
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Tooltip stays within screen bounds at right edge
|
||||
void testScreenRightEdge() {
|
||||
m_tip->populate("Test", "Wide body content for edge test", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
QRect avail = scr->availableGeometry();
|
||||
QPoint anchor(avail.right() - 2, avail.center().y());
|
||||
m_tip->showAt(anchor);
|
||||
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();
|
||||
QVERIFY(m_tip->x() + m_tip->width() <= avail.right() + 2);
|
||||
}
|
||||
|
||||
// Switch to btn2
|
||||
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(60, 20));
|
||||
QCursor::setPos(btn2Center);
|
||||
// Content change triggers resize
|
||||
void testContentResize() {
|
||||
m_tip->populate("Short", "A", testFont());
|
||||
m_tip->showAt(QPoint(500, 500));
|
||||
QCoreApplication::processEvents();
|
||||
QHelpEvent ev2(QEvent::ToolTip, QPoint(60, 20), btn2Center);
|
||||
QApplication::sendEvent(m_btn2, &ev2);
|
||||
int w1 = m_tip->width();
|
||||
|
||||
m_tip->dismiss();
|
||||
m_tip->populate("Much Longer Title", "A much wider body line that should be larger", testFont());
|
||||
m_tip->showAt(QPoint(500, 500));
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(100);
|
||||
int w2 = m_tip->width();
|
||||
|
||||
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");
|
||||
QVERIFY2(w2 > w1, "Wider content should produce a wider tooltip");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,251 +1,126 @@
|
||||
// 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.
|
||||
// Rendering verification for RcxTooltip.
|
||||
// Grabs widget pixels to confirm WA_TranslucentBackground works correctly
|
||||
// and the arrow/body are painted with the expected alpha.
|
||||
|
||||
#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;
|
||||
RcxTooltip* m_tip = nullptr;
|
||||
|
||||
QFont testFont() {
|
||||
QFont f("JetBrains Mono", 12);
|
||||
f.setFixedPitch(true);
|
||||
return f;
|
||||
}
|
||||
|
||||
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() {
|
||||
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());
|
||||
m_tip = new RcxTooltip;
|
||||
const auto& t = ThemeManager::instance().current();
|
||||
m_tip->setTheme(t.backgroundAlt, t.border, t.text, t.syntaxNumber, t.border);
|
||||
}
|
||||
|
||||
void cleanupTestCase() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
delete m_window;
|
||||
LOG("=== TestTooltipUI finished ===\n");
|
||||
m_tip->dismiss();
|
||||
delete m_tip;
|
||||
}
|
||||
|
||||
void cleanup() {
|
||||
RcxTooltip::instance()->dismiss();
|
||||
m_tip->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");
|
||||
// Body center should be opaque (background painted)
|
||||
void testBodyIsOpaque() {
|
||||
m_tip->populate("Render Test", "Body content here", testFont());
|
||||
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);
|
||||
m_tip->showAt(scr->availableGeometry().center());
|
||||
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();
|
||||
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
QVERIFY(!img.isNull());
|
||||
|
||||
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");
|
||||
// Center 50% of widget should be mostly opaque
|
||||
QRect center(img.width() / 4, img.height() / 4,
|
||||
img.width() / 2, img.height() / 2);
|
||||
int opaque = countOpaquePixels(img, center);
|
||||
int total = center.width() * center.height();
|
||||
QVERIFY2(opaque > total * 0.8,
|
||||
qPrintable(QStringLiteral("Body has %1/%2 opaque pixels — expected >80%%")
|
||||
.arg(opaque).arg(total)));
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
// Top-left corner should be transparent (rounded corner + WA_TranslucentBackground)
|
||||
void testCornerTransparency() {
|
||||
m_tip->populate("Corner", "Test", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
m_tip->showAt(scr->availableGeometry().center());
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(50);
|
||||
|
||||
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");
|
||||
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
|
||||
// When arrow is up, body starts at kArrowH. The corner at (0, kArrowH)
|
||||
// should be transparent due to rounding.
|
||||
QRect corner(0, 0, 2, 2);
|
||||
int opaque = countOpaquePixels(img, corner);
|
||||
QCOMPARE(opaque, 0);
|
||||
}
|
||||
|
||||
// Arrow region should have some opaque pixels
|
||||
void testArrowHasPixels() {
|
||||
m_tip->populate("Arrow", "Test", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
m_tip->showAt(scr->availableGeometry().center());
|
||||
QCoreApplication::processEvents();
|
||||
QTest::qWait(50);
|
||||
|
||||
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
|
||||
|
||||
// Arrow is at top (m_up = true): check top kArrowH pixels around center
|
||||
int cx = img.width() / 2;
|
||||
QRect arrowRect(cx - RcxTooltip::kArrowW / 2, 0,
|
||||
RcxTooltip::kArrowW, RcxTooltip::kArrowH);
|
||||
int opaque = countOpaquePixels(img, arrowRect);
|
||||
QVERIFY2(opaque > 0, "Arrow region has no opaque pixels");
|
||||
}
|
||||
|
||||
// Grabbing after dismiss should not crash
|
||||
void testDismissAndReshow() {
|
||||
m_tip->populate("First", "Body", testFont());
|
||||
QScreen* scr = QApplication::primaryScreen();
|
||||
m_tip->showAt(scr->availableGeometry().center());
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(m_tip->isVisible());
|
||||
|
||||
m_tip->dismiss();
|
||||
QVERIFY(!m_tip->isVisible());
|
||||
|
||||
m_tip->populate("Second", "Different", testFont());
|
||||
m_tip->showAt(scr->availableGeometry().center());
|
||||
QCoreApplication::processEvents();
|
||||
QVERIFY(m_tip->isVisible());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user