From f1a36f2ad370e4bdc8e9f4915a704ba8dcc202d0 Mon Sep 17 00:00:00 2001 From: IChooseYou Date: Sat, 14 Mar 2026 06:45:45 -0600 Subject: [PATCH] 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). --- src/editor.cpp | 70 +++++ src/editor.h | 1 + src/main.cpp | 586 ++++++++++++++++++++++++++++++++++- src/rcxtooltip.h | 340 ++++++++------------ tests/test_tooltip.cpp | 473 ++++++++-------------------- tests/test_tooltip_event.cpp | 310 ++++-------------- tests/test_tooltip_ui.cpp | 303 ++++++------------ 7 files changed, 1079 insertions(+), 1004 deletions(-) diff --git a/src/editor.cpp b/src/editor.cpp index b292a0f..db152bc 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1,6 +1,7 @@ #include "editor.h" #include "disasm.h" #include "providerregistry.h" +#include "rcxtooltip.h" #include #include #include @@ -1397,6 +1398,7 @@ void RcxEditor::dismissAllPopups() { if (m_historyPopup) static_cast(m_historyPopup)->dismiss(); if (m_disasmPopup) static_cast(m_disasmPopup)->dismiss(); if (m_structPreviewPopup) static_cast(m_structPreviewPopup)->dismiss(); + if (m_arrowTooltip) static_cast(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, + 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(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(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(m_arrowTooltip)->dismiss(); + } + m_sci->viewport()->setCursor(desired); } diff --git a/src/editor.h b/src/editor.h index d7d9cf2..dd7bba7 100644 --- a/src/editor.h +++ b/src/editor.h @@ -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; diff --git a/src/main.cpp b/src/main.cpp index 7e63b27..7c6cd35 100644 --- a/src/main.cpp +++ b/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 #include @@ -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("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("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> 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 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()) { + 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()) { + 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> 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 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> 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> 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>(std::move(pending)); + auto idx = std::make_shared(0); + auto conn = std::make_shared(); + + 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(); diff --git a/src/rcxtooltip.h b/src/rcxtooltip.h index 9ee8a95..d8b474b 100644 --- a/src/rcxtooltip.h +++ b/src/rcxtooltip.h @@ -1,241 +1,171 @@ #pragma once -#include "themes/thememanager.h" #include -#include #include #include -#include #include -#include -#include -#include -#include - -#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 +#include +#include 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 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 diff --git a/tests/test_tooltip.cpp b/tests/test_tooltip.cpp index 988e143..c8bffb3 100644 --- a/tests/test_tooltip.cpp +++ b/tests/test_tooltip.cpp @@ -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"))); } }; diff --git a/tests/test_tooltip_event.cpp b/tests/test_tooltip_event.cpp index 3b2eb5d..dd03cd8 100644 --- a/tests/test_tooltip_event.cpp +++ b/tests/test_tooltip_event.cpp @@ -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 #include -#include #include -#include #include #include "rcxtooltip.h" #include "themes/thememanager.h" -#include 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(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"); } }; diff --git a/tests/test_tooltip_ui.cpp b/tests/test_tooltip_ui.cpp index 41a2e9c..4ae637e 100644 --- a/tests/test_tooltip_ui.cpp +++ b/tests/test_tooltip_ui.cpp @@ -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 #include -#include -#include #include #include #include "rcxtooltip.h" #include "themes/thememanager.h" -#include 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()); } };