feat: custom arrow tooltip with transparent background

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

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

View File

@@ -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")));
}
};

View File

@@ -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");
}
};

View File

@@ -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());
}
};