fix: hover invisible when theme.hover == background, remove CSS on QMenuBar

Move hover color fixup into Theme::fromJson so all consumers get a
visible hover automatically. Remove duplicate lighter(130) fallback
from applyGlobalTheme. Replace QMenuBar CSS with QPalette so
MenuBarStyle QProxyStyle is not bypassed. Add PE_PanelMenuBar and
CE_MenuBarEmptyArea suppression so Fusion never paints over the
title bar background.
This commit is contained in:
IChooseYou
2026-02-22 08:58:57 -07:00
parent 48409d1d38
commit 7efe740ec1
4 changed files with 217 additions and 24 deletions

View File

@@ -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<const QStyleOptionMenuItem*>(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<decltype(slot)>(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);
}
}

View File

@@ -1,4 +1,5 @@
#include "theme.h"
#include <QtGlobal>
#include <type_traits>
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;
}

View File

@@ -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(

159
tools/test_hover.py Normal file
View File

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