diff --git a/src/main.cpp b/src/main.cpp index a40970e..a0e3f7a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -251,6 +251,9 @@ public: // Kill the 1px frame margin Fusion reserves around QMenu contents if (metric == PM_MenuPanelWidth) return 0; + // Kill the separator between dock widgets / central widget + if (metric == PM_DockWidgetSeparatorExtent) + return 0; return QProxyStyle::pixelMetric(metric, opt, w); } void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt, @@ -261,18 +264,28 @@ public: // Kill the status bar item frame and panel border if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar) return; + // Transparent menu bar background (no CSS needed) + if (elem == PE_PanelMenuBar) + 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 + // Suppress Fusion's CE_MenuBarEmptyArea — it fills with palette.window() + // bypassing PE_PanelMenuBar. TitleBarWidget paints the background. + if (element == CE_MenuBarEmptyArea) + return; + // Menu bar items (File, Edit, View…) — hover bg + amber text, no CSS if (element == CE_MenuBarItem) { if (auto* mi = qstyleoption_cast(opt)) { if (mi->state & (State_Selected | State_Sunken)) { + // Draw hover background + p->fillRect(mi->rect, mi->palette.color(QPalette::Mid)); + // Draw text with amber color, no highlight state QStyleOptionMenuItem patched = *mi; patched.state &= ~(State_Selected | State_Sunken); patched.palette.setColor(QPalette::ButtonText, - mi->palette.color(QPalette::Link)); // amber text only + mi->palette.color(QPalette::Link)); // amber text QProxyStyle::drawControl(element, &patched, p, w); return; } @@ -285,7 +298,7 @@ public: && mi->menuItemType != QStyleOptionMenuItem::Separator) { QStyleOptionMenuItem patched = *mi; patched.palette.setColor(QPalette::Highlight, - mi->palette.color(QPalette::Mid)); // theme.border + mi->palette.color(QPalette::Mid)); // theme.hover patched.palette.setColor(QPalette::HighlightedText, mi->palette.color(QPalette::Link)); // theme.indHoverSpan QProxyStyle::drawControl(element, &patched, p, w); @@ -1352,8 +1365,7 @@ void MainWindow::toggleMcp() { void MainWindow::applyTheme(const Theme& theme) { applyGlobalTheme(theme); - // Kill the 1px separator line between central widget and status bar - setStyleSheet("QMainWindow::separator { height: 0px; width: 0px; }"); + // Separator killed via PM_DockWidgetSeparatorExtent in MenuBarStyle // Custom title bar m_titleBar->applyTheme(theme); @@ -2280,8 +2292,16 @@ void MainWindow::populateSourceMenu() { {QStringLiteral("reclass.netcompatlayer"), QStringLiteral(":/vsicons/plug.svg")}, }; - m_sourceMenu->addAction(QIcon(QStringLiteral(":/vsicons/file-binary.svg")), - QStringLiteral("File"), this, [this]() { + auto addSourceAction = [this](const QString& text, const QIcon& icon, auto&& slot) { + auto* act = m_sourceMenu->addAction(icon, text); + act->setIconVisibleInMenu(true); + connect(act, &QAction::triggered, this, std::forward(slot)); + return act; + }; + + addSourceAction(QStringLiteral("File"), + makeIcon(QStringLiteral(":/vsicons/file-binary.svg")), + [this]() { if (auto* c = activeController()) c->selectSource(QStringLiteral("File")); }); @@ -2289,14 +2309,14 @@ void MainWindow::populateSourceMenu() { for (const auto& prov : providers) { QString name = prov.name; auto it = s_providerIcons.constFind(prov.identifier); - QIcon icon(it != s_providerIcons.constEnd() ? *it - : QStringLiteral(":/vsicons/extensions.svg")); + QIcon icon = makeIcon(it != s_providerIcons.constEnd() ? *it + : QStringLiteral(":/vsicons/extensions.svg")); QString label = prov.dllFileName.isEmpty() ? name : QStringLiteral("%1 (%2)").arg(name, prov.dllFileName); - m_sourceMenu->addAction(icon, label, this, [this, name]() { + addSourceAction(label, icon, [this, name]() { if (auto* c = activeController()) c->selectSource(name); }); } @@ -2306,18 +2326,20 @@ void MainWindow::populateSourceMenu() { for (int i = 0; i < ctrl->savedSources().size(); i++) { const auto& e = ctrl->savedSources()[i]; auto* act = m_sourceMenu->addAction( - QStringLiteral("%1 '%2'").arg(e.kind, e.displayName), - this, [this, i]() { - if (auto* c = activeController()) c->switchSource(i); - }); + QStringLiteral("%1 '%2'").arg(e.kind, e.displayName)); act->setCheckable(true); act->setChecked(i == ctrl->activeSourceIndex()); + connect(act, &QAction::triggered, this, [this, i]() { + if (auto* c = activeController()) c->switchSource(i); + }); } m_sourceMenu->addSeparator(); - m_sourceMenu->addAction(QIcon(QStringLiteral(":/vsicons/clear-all.svg")), - QStringLiteral("Clear All"), this, [this]() { + auto* clearAct = addSourceAction(QStringLiteral("Clear All"), + makeIcon(QStringLiteral(":/vsicons/clear-all.svg")), + [this]() { if (auto* c = activeController()) c->clearSources(); }); + Q_UNUSED(clearAct); } } diff --git a/src/themes/theme.cpp b/src/themes/theme.cpp index 35fefb8..0c152f2 100644 --- a/src/themes/theme.cpp +++ b/src/themes/theme.cpp @@ -1,4 +1,5 @@ #include "theme.h" +#include #include namespace rcx { @@ -61,6 +62,15 @@ Theme Theme::fromJson(const QJsonObject& o) { t.indHeatWarm = t.indHoverSpan.isValid() ? t.indHoverSpan : t.syntaxString; if (!t.indHeatHot.isValid()) t.indHeatHot = t.markerPtr; + + // Ensure hover is visually distinct from background + if (t.hover.isValid() && t.background.isValid()) { + int dist = qAbs(t.hover.red() - t.background.red()) + + qAbs(t.hover.green() - t.background.green()) + + qAbs(t.hover.blue() - t.background.blue()); + if (dist < 20) + t.hover = t.background.lighter(130); + } return t; } diff --git a/src/titlebar.cpp b/src/titlebar.cpp index 4c7f410..fdba351 100644 --- a/src/titlebar.cpp +++ b/src/titlebar.cpp @@ -76,14 +76,16 @@ void TitleBarWidget::applyTheme(const Theme& theme) { QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }") .arg(theme.textDim.name())); - // Menu bar styling — transparent background, themed text - m_menuBar->setStyleSheet( - QStringLiteral( - "QMenuBar { background: transparent; border: none; }" - "QMenuBar::item { background: transparent; color: %1; padding: 8px 8px 4px 8px; }" - "QMenuBar::item:selected { background: %2; }" - "QMenuBar::item:pressed { background: %2; }") - .arg(theme.textDim.name(), theme.hover.name())); + // Menu bar palette — hover/bg handled by MenuBarStyle QProxyStyle. + // Set Window + Button to background so Fusion never paints a foreign color. + { + QPalette mbPal = m_menuBar->palette(); + mbPal.setColor(QPalette::Window, theme.background); + mbPal.setColor(QPalette::Button, theme.background); + mbPal.setColor(QPalette::ButtonText, theme.textDim); + m_menuBar->setPalette(mbPal); + m_menuBar->setAutoFillBackground(false); + } // Chrome buttons QString btnStyle = QStringLiteral( diff --git a/tools/test_hover.py b/tools/test_hover.py new file mode 100644 index 0000000..ea94b97 --- /dev/null +++ b/tools/test_hover.py @@ -0,0 +1,159 @@ +""" +Structural hover test: validate that all themes produce visible hover colors +and that the QProxyStyle code handles the required control elements. + +No pixel sampling — checks theme JSON values and source code patterns. +""" +import json +import os +import re +import sys + + +def hex_to_rgb(h): + h = h.lstrip('#') + return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) + + +def color_dist(c1, c2): + return sum(abs(a - b) for a, b in zip(c1, c2)) + + +def lighter_130(rgb): + """Approximate Qt's QColor::lighter(130) for dark grays.""" + r, g, b = rgb + return (min(255, int(r * 1.3) + 1), + min(255, int(g * 1.3) + 1), + min(255, int(b * 1.3) + 1)) + + +def load_themes(): + themes = {} + theme_dir = os.path.join(os.path.dirname(__file__), + '..', 'src', 'themes', 'defaults') + if not os.path.isdir(theme_dir): + return themes + for name in os.listdir(theme_dir): + if name.endswith('.json'): + with open(os.path.join(theme_dir, name)) as f: + themes[name] = json.load(f) + return themes + + +def test_hover_visibility(themes): + """Every theme must have hover visually distinct from background. + If raw values are identical, Theme::fromJson applies lighter(130).""" + ok = True + for name, data in sorted(themes.items()): + bg = hex_to_rgb(data['background']) + hover = hex_to_rgb(data['hover']) + dist = color_dist(bg, hover) + + if dist < 20: + # fromJson will fix this — verify the fix produces sufficient contrast + fixed = lighter_130(bg) + fixed_dist = color_dist(bg, fixed) + if fixed_dist < 15: + print(f" FAIL: {name}: hover==bg and lighter(130) still too close " + f"(dist={fixed_dist})") + ok = False + else: + print(f" OK: {name}: hover==bg, fromJson fixup -> " + f"dist {dist}->{fixed_dist}") + else: + print(f" OK: {name}: hover distinct (dist={dist})") + return ok + + +def test_proxystyle_handlers(): + """Verify MenuBarStyle handles CE_MenuBarItem, CE_MenuItem, CE_MenuBarEmptyArea.""" + src = os.path.join(os.path.dirname(__file__), '..', 'src', 'main.cpp') + with open(src) as f: + code = f.read() + + required = { + 'CE_MenuBarItem': r'element\s*==\s*CE_MenuBarItem', + 'CE_MenuItem': r'element\s*==\s*CE_MenuItem', + 'CE_MenuBarEmptyArea': r'element\s*==\s*CE_MenuBarEmptyArea', + 'State_Selected': r'State_Selected', + 'QPalette::Mid': r'QPalette::Mid', + } + + ok = True + for label, pattern in required.items(): + if re.search(pattern, code): + print(f" OK: MenuBarStyle handles {label}") + else: + print(f" FAIL: MenuBarStyle missing {label}") + ok = False + return ok + + +def test_no_menubar_css(): + """Verify no CSS stylesheet is set on QMenuBar (would bypass QProxyStyle).""" + src_dir = os.path.join(os.path.dirname(__file__), '..', 'src') + ok = True + for root, _, files in os.walk(src_dir): + for fname in files: + if not fname.endswith('.cpp'): + continue + path = os.path.join(root, fname) + with open(path, encoding='utf-8', errors='replace') as f: + for i, line in enumerate(f, 1): + # Check for menuBar/m_menuBar stylesheet calls + if ('menuBar' in line or 'm_menuBar' in line) and \ + 'setStyleSheet' in line: + print(f" FAIL: CSS on QMenuBar at {fname}:{i}: " + f"{line.strip()}") + ok = False + if ok: + print(" OK: No CSS on QMenuBar") + return ok + + +def test_hover_fixup_in_fromjson(): + """Verify Theme::fromJson applies the hover fixup.""" + src = os.path.join(os.path.dirname(__file__), + '..', 'src', 'themes', 'theme.cpp') + with open(src) as f: + code = f.read() + + if 'lighter(130)' in code and 't.hover' in code: + print(" OK: Theme::fromJson has hover fixup") + return True + else: + print(" FAIL: Theme::fromJson missing hover fixup") + return False + + +def main(): + themes = load_themes() + if not themes: + print("FAIL: No theme files found") + return 1 + + all_ok = True + + print("--- Test 1: Hover visibility across themes ---") + all_ok &= test_hover_visibility(themes) + + print("\n--- Test 2: QProxyStyle handles required elements ---") + all_ok &= test_proxystyle_handlers() + + print("\n--- Test 3: No CSS on QMenuBar ---") + all_ok &= test_no_menubar_css() + + print("\n--- Test 4: Theme::fromJson hover fixup ---") + all_ok &= test_hover_fixup_in_fromjson() + + print(f"\n{'='*50}") + if all_ok: + print("ALL HOVER TESTS PASSED") + return 0 + else: + print("SOME HOVER TESTS FAILED") + return 1 + + +if __name__ == '__main__': + sys.exit(main())