From e73b783cda6859cfde936ce3a29d5ef67d013c6e Mon Sep 17 00:00:00 2001 From: IChooseYou Date: Thu, 12 Feb 2026 10:22:00 -0700 Subject: [PATCH] QMenu + QMenuBar hover: amber indHoverSpan text via QProxyStyle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MenuBarStyle.drawControl overrides CE_MenuBarItem and CE_MenuItem: - Menu bar: strips hover state flags, swaps ButtonText to Link (amber), delegates to Fusion for identical layout (no text shift) - Popup menus: patches Highlight→Mid, HighlightedText→Link, delegates to Fusion for icons/shortcuts/checkmarks Test: real QEvent::Enter + MouseMove delivery, QScreen::grabWindow screenshot, pixel-scan for amber, always saves PNGs for inspection. --- src/main.cpp | 126 +++++++++++----- tests/test_editor.cpp | 338 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 360 insertions(+), 104 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index aab1810..b7a986f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -155,8 +155,49 @@ public: QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w); if (type == CT_MenuBarItem) s.setHeight(s.height() + qRound(s.height() * 0.5)); + if (type == CT_MenuItem) + s = QSize(s.width() + 24, s.height() + 4); return s; } + void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt, + QPainter* p, const QWidget* w) const override { + // Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough + if (elem == PE_FrameMenu) + return; + QProxyStyle::drawPrimitive(elem, opt, p, w); + } + void drawControl(ControlElement element, const QStyleOption* opt, + QPainter* p, const QWidget* w) const override { + // Menu bar items (File, Edit, View…) — direct paint, Fusion ignores palette + if (element == CE_MenuBarItem) { + if (auto* mi = qstyleoption_cast(opt)) { + if (mi->state & (State_Selected | State_Sunken)) { + QStyleOptionMenuItem patched = *mi; + patched.state &= ~(State_Selected | State_Sunken); + patched.palette.setColor(QPalette::ButtonText, + mi->palette.color(QPalette::Link)); // amber text only + QProxyStyle::drawControl(element, &patched, p, w); + return; + } + } + } + // Popup menu items — palette patch then delegate to Fusion + if (element == CE_MenuItem) { + if (auto* mi = qstyleoption_cast(opt)) { + if ((mi->state & State_Selected) + && mi->menuItemType != QStyleOptionMenuItem::Separator) { + QStyleOptionMenuItem patched = *mi; + patched.palette.setColor(QPalette::Highlight, + mi->palette.color(QPalette::Mid)); // theme.border + patched.palette.setColor(QPalette::HighlightedText, + mi->palette.color(QPalette::Link)); // theme.indHoverSpan + QProxyStyle::drawControl(element, &patched, p, w); + return; + } + } + } + QProxyStyle::drawControl(element, opt, p, w); + } }; static void applyGlobalTheme(const rcx::Theme& theme) { @@ -169,32 +210,24 @@ static void applyGlobalTheme(const rcx::Theme& theme) { pal.setColor(QPalette::Button, theme.button); pal.setColor(QPalette::ButtonText, theme.text); pal.setColor(QPalette::Highlight, theme.hover); - pal.setColor(QPalette::HighlightedText, theme.text); + pal.setColor(QPalette::HighlightedText, theme.indHoverSpan); pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt); pal.setColor(QPalette::ToolTipText, theme.text); pal.setColor(QPalette::Mid, theme.border); pal.setColor(QPalette::Dark, theme.background); pal.setColor(QPalette::Light, theme.textFaint); + pal.setColor(QPalette::Link, theme.indHoverSpan); + + // Disabled group: Fusion reads these for disabled menu items, buttons, etc. + pal.setColor(QPalette::Disabled, QPalette::WindowText, theme.textMuted); + pal.setColor(QPalette::Disabled, QPalette::Text, theme.textMuted); + pal.setColor(QPalette::Disabled, QPalette::ButtonText, theme.textMuted); + pal.setColor(QPalette::Disabled, QPalette::HighlightedText, theme.textMuted); + pal.setColor(QPalette::Disabled, QPalette::Light, theme.background); + qApp->setPalette(pal); - qApp->setStyleSheet(QStringLiteral( - "QMenu {" - " background-color: %1;" - " color: %2;" - " border: 1px solid %3;" - " padding: 4px 6px;" - "}" - "QMenu::item { padding: 4px 24px; }" - "QMenu::item:selected { background-color: %4; }" - "QMenu::separator { height: 1px; background: %3; margin: 4px 8px; }" - "QMenu::item:disabled { color: %5; }" - "QToolTip {" - " background-color: %1;" - " color: %2;" - " border: 1px solid %3;" - "}") - .arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name(), - theme.hover.name(), theme.textMuted.name())); + qApp->setStyleSheet(QString()); } namespace rcx { @@ -227,10 +260,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { createStatusBar(); - // Larger click targets on menu bar - { - menuBar()->setStyle(new MenuBarStyle(menuBar()->style())); - } + // MenuBarStyle is set as app style in main() — covers both QMenuBar and QMenu connect(&ThemeManager::instance(), &ThemeManager::themeChanged, this, &MainWindow::applyTheme); @@ -355,9 +385,11 @@ void MainWindow::createStatusBar() { statusBar()->addWidget(m_statusLabel, 1); { const auto& t = ThemeManager::instance().current(); - statusBar()->setStyleSheet(QStringLiteral( - "QStatusBar { background: %1; color: %2; }") - .arg(t.backgroundAlt.name(), t.textDim.name())); + QPalette sbPal = statusBar()->palette(); + sbPal.setColor(QPalette::Window, t.background); + sbPal.setColor(QPalette::WindowText, t.textDim); + statusBar()->setPalette(sbPal); + statusBar()->setAutoFillBackground(true); } QSettings settings("ReclassX", "ReclassX"); @@ -786,8 +818,12 @@ void MainWindow::about() { }); lay->addWidget(ghBtn, 0, Qt::AlignCenter); - dlg.setStyleSheet(QStringLiteral("QDialog { background: %1; }") - .arg(ThemeManager::instance().current().background.name())); + { + QPalette dlgPal = dlg.palette(); + dlgPal.setColor(QPalette::Window, ThemeManager::instance().current().background); + dlg.setPalette(dlgPal); + dlg.setAutoFillBackground(true); + } dlg.exec(); } @@ -817,9 +853,12 @@ void MainWindow::applyTheme(const Theme& theme) { theme.backgroundAlt.name(), theme.hover.name())); // Status bar - statusBar()->setStyleSheet(QStringLiteral( - "QStatusBar { background: %1; color: %2; }") - .arg(theme.backgroundAlt.name(), theme.textDim.name())); + { + QPalette sbPal = statusBar()->palette(); + sbPal.setColor(QPalette::Window, theme.background); + sbPal.setColor(QPalette::WindowText, theme.textDim); + statusBar()->setPalette(sbPal); + } // Split pane tab widgets for (auto& state : m_tabs) { @@ -831,14 +870,21 @@ void MainWindow::applyTheme(const Theme& theme) { void MainWindow::editTheme() { auto& tm = ThemeManager::instance(); - Theme edited = tm.current(); - ThemeEditor dlg(edited, this); + int idx = tm.currentIndex(); + ThemeEditor dlg(idx, this); if (dlg.exec() == QDialog::Accepted) { - edited = dlg.result(); - int idx = tm.currentIndex(); - if (idx < tm.themes().size() && idx >= 0) { - tm.updateTheme(idx, edited); - } + tm.revertPreview(); + int selectedIdx = dlg.selectedIndex(); + Theme edited = dlg.result(); + // Switch to selected theme first (if changed) + if (selectedIdx != idx && selectedIdx >= 0 && selectedIdx < tm.themes().size()) + tm.setCurrent(selectedIdx); + // Apply edits + int applyIdx = selectedIdx >= 0 ? selectedIdx : idx; + if (applyIdx >= 0 && applyIdx < tm.themes().size()) + tm.updateTheme(applyIdx, edited); + } else { + tm.revertPreview(); } } @@ -870,6 +916,8 @@ void MainWindow::setEditorFont(const QString& fontName) { m_workspaceTree->setFont(f); // Sync status bar font statusBar()->setFont(f); + // Sync menu bar / menu font via global stylesheet + applyGlobalTheme(ThemeManager::instance().current()); } RcxController* MainWindow::activeController() const { @@ -1396,7 +1444,7 @@ int main(int argc, char* argv[]) { DarkApp app(argc, argv); app.setApplicationName("ReclassX"); app.setOrganizationName("ReclassX"); - app.setStyle("Fusion"); // Fusion style respects dark palette well + app.setStyle(new MenuBarStyle("Fusion")); // Fusion + generous menu sizing // Load embedded fonts int fontId = QFontDatabase::addApplicationFont(":/fonts/JetBrainsMono.ttf"); diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index ee62bc2..5b276c2 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -5,6 +5,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include "editor.h" @@ -473,27 +480,17 @@ private slots: QCOMPARE(cancelSpy.count(), 0); } - // ── Test: type edit begins and can be cancelled ── + // ── Test: type edit emits typePickerRequested (popup-based, not inline edit) ── void testTypeEditCancel() { m_editor->applyDocument(m_result); - // Begin type edit on a field line + QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested); + + // Begin type edit on a field line — now handled by TypeSelectorPopup bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine); QVERIFY(ok); - QVERIFY(m_editor->isEditing()); - - // Process deferred events (showTypeAutocomplete is deferred via QTimer) - QApplication::processEvents(); - - // First Escape closes autocomplete popup (if active) or cancels edit - QKeyEvent esc1(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); - QApplication::sendEvent(m_editor->scintilla(), &esc1); - - // If autocomplete was open, first Esc only closed popup; need second Esc - if (m_editor->isEditing()) { - QKeyEvent esc2(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); - QApplication::sendEvent(m_editor->scintilla(), &esc2); - } + QCOMPARE(spy.count(), 1); + // Type editing uses popup, not inline edit state QVERIFY(!m_editor->isEditing()); } @@ -523,11 +520,11 @@ private slots: QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine); QApplication::processEvents(); - // Type edit on header should succeed + // Type edit on header should succeed (emits popup signal, not inline edit) + QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested); bool ok = m_editor->beginInlineEdit(EditTarget::Type, headerLine); QVERIFY(ok); - QVERIFY(m_editor->isEditing()); - m_editor->cancelInlineEdit(); + QCOMPARE(typeSpy.count(), 1); // Name edit on header should succeed ok = m_editor->beginInlineEdit(EditTarget::Name, headerLine); @@ -598,35 +595,19 @@ private slots: void testTypeAutocompleteTypingAndCommit() { m_editor->applyDocument(m_result); + QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested); + + // Type edit now emits typePickerRequested for TypeSelectorPopup bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine); QVERIFY(ok); - - // Autocomplete is deferred via QTimer::singleShot(0) — poll until active - QTRY_VERIFY2(m_editor->scintilla()->SendScintilla( - QsciScintillaBase::SCI_AUTOCACTIVE) != 0, - "Autocomplete should be active"); - - // Simulate typing 'i' — filters to typeName entries starting with 'i' - QKeyEvent keyI(QEvent::KeyPress, Qt::Key_I, Qt::NoModifier, "i"); - QApplication::sendEvent(m_editor->scintilla(), &keyI); - - // Still editing - QVERIFY(m_editor->isEditing()); - - // Simulate Enter to select from autocomplete (handled synchronously) - QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted); - QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); - QApplication::sendEvent(m_editor->scintilla(), &enter); - - // Should have committed immediately (no deferred timer for type edits) QCOMPARE(spy.count(), 1); - QVERIFY(!m_editor->isEditing()); - // The committed text should be a valid typeName starting with 'i' + // Verify signal carries valid nodeIdx (second arg) QList args = spy.first(); - QString committedText = args.at(3).toString(); - QVERIFY2(committedText.startsWith('i'), - qPrintable("Expected typeName starting with 'i', got: " + committedText)); + QVERIFY(args.at(1).toInt() >= 0); + + // No inline edit state — popup handles everything + QVERIFY(!m_editor->isEditing()); m_editor->applyDocument(m_result); } @@ -635,28 +616,15 @@ private slots: void testTypeEditClickAwayNoChange() { m_editor->applyDocument(m_result); + QSignalSpy spy(m_editor, &RcxEditor::typePickerRequested); + + // Type edit emits typePickerRequested (popup handles click-away) bool ok = m_editor->beginInlineEdit(EditTarget::Type, kFirstDataLine); QVERIFY(ok); + QCOMPARE(spy.count(), 1); - // Process deferred autocomplete - QApplication::processEvents(); - - // Click away on viewport — should commit (not cancel) - QSignalSpy commitSpy(m_editor, &RcxEditor::inlineEditCommitted); - QMouseEvent click(QEvent::MouseButtonPress, QPointF(10, 10), - QPointF(10, 10), Qt::LeftButton, - Qt::LeftButton, Qt::NoModifier); - QApplication::sendEvent(m_editor->scintilla()->viewport(), &click); - + // No inline edit state — popup handles click-away behavior QVERIFY(!m_editor->isEditing()); - QCOMPARE(commitSpy.count(), 1); - - // The committed text should be the original typeName (no change) - // First field at kFirstDataLine is InheritedAddressSpace (UInt8 → "uint8_t") - QList args = commitSpy.first(); - QString committedText = args.at(3).toString(); - QVERIFY2(committedText == "uint8_t", - qPrintable("Expected 'uint8_t', got: " + committedText)); m_editor->applyDocument(m_result); } @@ -813,12 +781,11 @@ private slots: QVERIFY(m_editor->isEditing()); m_editor->cancelInlineEdit(); - // Type edit on Padding SHOULD succeed + // Type edit on Padding SHOULD succeed (emits popup signal) + QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested); ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine); QVERIFY2(ok, "Type edit should be allowed on Padding lines"); - QVERIFY(m_editor->isEditing()); - m_editor->cancelInlineEdit(); - QApplication::processEvents(); // flush deferred autocomplete timer + QCOMPARE(typeSpy.count(), 1); } // ── Test: resolvedSpanFor rejects Value on Padding (defense-in-depth) ── @@ -1096,6 +1063,247 @@ private slots: QVERIFY2(!foundRootHeader, "Root header should be suppressed from compose output"); } + + // ── Test: MenuBarStyle gives QMenu items generous click targets ── + // ── Test: M_ACCENT marker appears on selected rows ── + void testAccentMarkerOnSelectedRows() { + m_editor->applyDocument(m_result); + + // Find a data line with a valid nodeId + uint64_t targetId = 0; + int targetLine = -1; + for (int i = kFirstDataLine; i < m_result.meta.size(); i++) { + const auto& lm = m_result.meta[i]; + if (lm.nodeId != 0 && lm.nodeId != kCommandRowId + && lm.lineKind == LineKind::Field) { + targetId = lm.nodeId; + targetLine = i; + break; + } + } + QVERIFY2(targetLine >= 0, "No data line found for accent test"); + + // Apply selection overlay with that node + QSet selIds; + selIds.insert(targetId); + m_editor->applySelectionOverlay(selIds); + + auto* sci = m_editor->scintilla(); + + // Direct test: add M_ACCENT manually and read it back + int directHandle = sci->markerAdd(targetLine, M_ACCENT); + int directMarkers = (int)sci->SendScintilla( + QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine); + QVERIFY2(directMarkers & (1 << M_ACCENT), + qPrintable(QString("Direct markerAdd(M_ACCENT=%1) failed on line %2 (handle=%3, mask=0x%4)") + .arg(M_ACCENT).arg(targetLine).arg(directHandle).arg(directMarkers, 0, 16))); + sci->markerDelete(targetLine, M_ACCENT); + + // Now test via applySelectionOverlay + m_editor->applySelectionOverlay(selIds); + + // Verify M_SELECTED is set on the target line + int markers = (int)sci->SendScintilla( + QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine); + QVERIFY2(markers & (1 << M_SELECTED), + qPrintable(QString("M_SELECTED not set on line %1 (mask=0x%2)") + .arg(targetLine).arg(markers, 0, 16))); + + // Verify M_ACCENT is set on the target line + QVERIFY2(markers & (1 << M_ACCENT), + qPrintable(QString("M_ACCENT not set on line %1 (mask=0x%2)") + .arg(targetLine).arg(markers, 0, 16))); + + // Verify a non-selected line does NOT have M_ACCENT + int otherLine = -1; + for (int i = kFirstDataLine; i < m_result.meta.size(); i++) { + const auto& lm = m_result.meta[i]; + if (lm.nodeId != targetId && lm.nodeId != 0 + && lm.nodeId != kCommandRowId && lm.lineKind == LineKind::Field) { + otherLine = i; + break; + } + } + if (otherLine >= 0) { + int otherMarkers = (int)sci->SendScintilla( + QsciScintillaBase::SCI_MARKERGET, (unsigned long)otherLine); + QVERIFY2(!(otherMarkers & (1 << M_ACCENT)), + qPrintable(QString("M_ACCENT should NOT be set on non-selected line %1 (mask=0x%2)") + .arg(otherLine).arg(otherMarkers, 0, 16))); + } + + // Clear selection and verify accent is removed + m_editor->applySelectionOverlay(QSet()); + markers = (int)sci->SendScintilla( + QsciScintillaBase::SCI_MARKERGET, (unsigned long)targetLine); + QVERIFY2(!(markers & (1 << M_ACCENT)), + qPrintable(QString("M_ACCENT should be cleared after deselection on line %1 (mask=0x%2)") + .arg(targetLine).arg(markers, 0, 16))); + } + + void testMenuItemSizeIsAccessible() { + // Instantiate the same QProxyStyle used by the app (MenuBarStyle is + // defined in main.cpp — we replicate the logic here to test it) + class TestMenuStyle : public QProxyStyle { + public: + using QProxyStyle::QProxyStyle; + QSize sizeFromContents(ContentsType type, const QStyleOption* opt, + const QSize& sz, const QWidget* w) const override { + QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w); + if (type == CT_MenuBarItem) + s.setHeight(s.height() + qRound(s.height() * 0.5)); + if (type == CT_MenuItem) + s = QSize(s.width() + 24, s.height() + 4); + return s; + } + }; + + TestMenuStyle style; + QMenu menu; + auto* action = menu.addAction("Delete Node"); + + QStyleOptionMenuItem opt; + opt.initFrom(&menu); + opt.text = action->text(); + + QSize base = style.QProxyStyle::sizeFromContents( + QStyle::CT_MenuItem, &opt, QSize(80, 20), &menu); + QSize styled = style.sizeFromContents( + QStyle::CT_MenuItem, &opt, QSize(80, 20), &menu); + + // Width must grow by at least 24px + QVERIFY2(styled.width() >= base.width() + 24, + qPrintable(QString("Menu item width %1 too narrow (base %2, need +24)") + .arg(styled.width()).arg(base.width()))); + + // Height must grow by at least 4px + QVERIFY2(styled.height() >= base.height() + 4, + qPrintable(QString("Menu item height %1 too short (base %2, need +4)") + .arg(styled.height()).arg(base.height()))); + } + + void testMenuHoverRendersAmberText() { + // Replicate MenuBarStyle with drawControl hover override + class TestMenuStyle : public QProxyStyle { + public: + using QProxyStyle::QProxyStyle; + QSize sizeFromContents(ContentsType type, const QStyleOption* opt, + const QSize& sz, const QWidget* w) const override { + QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w); + if (type == CT_MenuBarItem) + s.setHeight(s.height() + qRound(s.height() * 0.5)); + if (type == CT_MenuItem) + s = QSize(s.width() + 24, s.height() + 4); + return s; + } + void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt, + QPainter* p, const QWidget* w) const override { + if (elem == PE_FrameMenu) return; + QProxyStyle::drawPrimitive(elem, opt, p, w); + } + void drawControl(ControlElement element, const QStyleOption* opt, + QPainter* p, const QWidget* w) const override { + if (element == CE_MenuItem || element == CE_MenuBarItem) { + if (auto* mi = qstyleoption_cast(opt)) { + if ((mi->state & State_Selected) + && mi->menuItemType != QStyleOptionMenuItem::Separator) { + QStyleOptionMenuItem patched = *mi; + patched.palette.setColor(QPalette::Highlight, + mi->palette.color(QPalette::Mid)); + patched.palette.setColor(QPalette::HighlightedText, + mi->palette.color(QPalette::Link)); + QProxyStyle::drawControl(element, &patched, p, w); + return; + } + } + } + QProxyStyle::drawControl(element, opt, p, w); + } + }; + + // Install our style as the app style (same as main.cpp does) + qApp->setStyle(new TestMenuStyle("Fusion")); + + // Set app palette matching applyGlobalTheme for Reclass Dark + QPalette pal; + pal.setColor(QPalette::Window, QColor("#1e1e1e")); + pal.setColor(QPalette::WindowText, QColor("#d4d4d4")); + pal.setColor(QPalette::Base, QColor("#252526")); + pal.setColor(QPalette::AlternateBase, QColor("#2a2d2e")); + pal.setColor(QPalette::Text, QColor("#d4d4d4")); + pal.setColor(QPalette::Button, QColor("#333333")); + pal.setColor(QPalette::ButtonText, QColor("#d4d4d4")); + pal.setColor(QPalette::Highlight, QColor("#2b2b2b")); + pal.setColor(QPalette::HighlightedText, QColor("#E6B450")); + pal.setColor(QPalette::Mid, QColor("#3c3c3c")); + pal.setColor(QPalette::Dark, QColor("#1e1e1e")); + pal.setColor(QPalette::Light, QColor("#505050")); + pal.setColor(QPalette::Link, QColor("#E6B450")); + qApp->setPalette(pal); + + // Build and show a real QMenu + QMenu menu; + menu.addAction("First Item"); + menu.addAction("Second Item"); + menu.addAction("Third Item"); + menu.popup(QPoint(100, 100)); + QVERIFY(QTest::qWaitForWindowExposed(&menu)); + QApplication::processEvents(); + + // ── Deliver real mouse events to trigger hover on second item ── + QList actions = menu.actions(); + QRect itemRect = menu.actionGeometry(actions[1]); + QPoint localCenter = itemRect.center(); + + // Enter event — tells QMenu the mouse is inside + QEvent enter(QEvent::Enter); + QApplication::sendEvent(&menu, &enter); + QApplication::processEvents(); + + // MouseMove to the second item — triggers hover/select + QMouseEvent move(QEvent::MouseMove, QPointF(localCenter), + menu.mapToGlobal(localCenter), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QApplication::sendEvent(&menu, &move); + QApplication::processEvents(); + QTest::qWait(50); // let repaint settle + + // Verify QMenu internally considers the action hovered + QVERIFY2(menu.activeAction() == actions[1], + "QMenu did not set activeAction after mouse move — " + "hover event delivery failed"); + + // ── Capture what's actually on screen ── + QScreen* screen = QGuiApplication::primaryScreen(); + QVERIFY(screen); + QPixmap grab = screen->grabWindow(menu.winId()); + QImage img = grab.toImage().convertToFormat(QImage::Format_ARGB32); + + // Crop to just the hovered item rect + QImage itemImg = img.copy(itemRect); + + // Scan hovered item for amber pixels (E6B450 = R:230 G:180 B:80) + int amberPixels = 0; + int totalPixels = itemImg.width() * itemImg.height(); + for (int y = 0; y < itemImg.height(); ++y) { + for (int x = 0; x < itemImg.width(); ++x) { + QColor c = itemImg.pixelColor(x, y); + if (c.red() > 180 && c.green() > 140 && c.blue() < 100) + ++amberPixels; + } + } + + // Always save screenshots so we can visually inspect + img.save("menu_hover_full.png"); + itemImg.save("menu_hover_item.png"); + + menu.close(); + + QVERIFY2(amberPixels > 10, + qPrintable(QString("Expected amber text pixels in hovered item, " + "found %1 / %2 total (see menu_hover_full.png, menu_hover_item.png)") + .arg(amberPixels).arg(totalPixels))); + } }; QTEST_MAIN(TestEditor)