Skip to content

Commit f7bb50c

Browse files
committed
fix(titledelegate): enhance color handling for selected and active states
1 parent ca433fc commit f7bb50c

2 files changed

Lines changed: 144 additions & 20 deletions

File tree

‎datalab/tests/backbone/titledelegate_unit_test.py‎

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
22

3-
"""Unit tests for rich object-title sizing."""
3+
"""Unit tests for rich object-title sizing, colors and links."""
44

55
from __future__ import annotations
66

@@ -10,9 +10,118 @@
1010
from qtpy import QtGui as QG
1111
from qtpy import QtWidgets as QW
1212

13+
from datalab.widgets import titledelegate
1314
from datalab.widgets.titledelegate import TITLE_REFERENCE_ROLE, ClickableTitleDelegate
1415

1516

17+
def _contrast(first: QG.QColor, second: QG.QColor) -> float:
18+
"""Return the WCAG contrast ratio of two opaque colors."""
19+
20+
def luminance(color: QG.QColor) -> float:
21+
channels = [
22+
value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
23+
for value in (color.redF(), color.greenF(), color.blueF())
24+
]
25+
return sum(
26+
value * weight for value, weight in zip(channels, (0.2126, 0.7152, 0.0722))
27+
)
28+
29+
light, dark = sorted((luminance(first), luminance(second)), reverse=True)
30+
return (light + 0.05) / (dark + 0.05)
31+
32+
33+
@pytest.mark.parametrize("style_name", QW.QStyleFactory.keys())
34+
@pytest.mark.parametrize("dark", [False, True])
35+
@pytest.mark.parametrize("selected", [False, True])
36+
@pytest.mark.parametrize("active", [False, True])
37+
def test_reference_rendering(
38+
style_name: str,
39+
dark: bool,
40+
selected: bool,
41+
active: bool,
42+
monkeypatch: pytest.MonkeyPatch,
43+
) -> None:
44+
"""Keep actual backgrounds, text and clickable references consistent."""
45+
with qt_app_context():
46+
parent = QW.QTreeWidget()
47+
style = QW.QStyleFactory.create(style_name)
48+
parent.setStyle(style)
49+
palette = QG.QPalette()
50+
palette.setColor(QG.QPalette.Base, QG.QColor("#1f1f1f" if dark else "#ffffff"))
51+
palette.setColor(QG.QPalette.Text, QG.QColor("white" if dark else "black"))
52+
palette.setColor(
53+
QG.QPalette.Highlight, QG.QColor("#2a82da" if dark else "#0078d7")
54+
)
55+
palette.setColor(
56+
QG.QPalette.HighlightedText, QG.QColor("black" if dark else "white")
57+
)
58+
parent.setPalette(palette)
59+
model = QG.QStandardItemModel()
60+
item = QG.QStandardItem("normalize(12345678)")
61+
item.setData("87654321", TITLE_REFERENCE_ROLE)
62+
model.appendRow(item)
63+
delegate = ClickableTitleDelegate(parent, lambda reference: reference)
64+
option = QW.QStyleOptionViewItem()
65+
option.initFrom(parent)
66+
option.widget = parent
67+
option.rect = QC.QRect(0, 0, 400, 60)
68+
option.showDecorationSelected = True
69+
option.features |= QW.QStyleOptionViewItem.HasDisplay
70+
option.state = QW.QStyle.State_Enabled
71+
if selected:
72+
option.state |= QW.QStyle.State_Selected
73+
if active:
74+
option.state |= QW.QStyle.State_Active | QW.QStyle.State_HasFocus
75+
documents = []
76+
make_document = titledelegate._make_text_document
77+
78+
def record_document(*args, **kwargs):
79+
document = make_document(*args, **kwargs)
80+
documents.append((document, args[2], args[3], args[6]))
81+
return document
82+
83+
monkeypatch.setattr(titledelegate, "_make_text_document", record_document)
84+
image = QG.QImage(400, 60, QG.QImage.Format_ARGB32)
85+
image.fill(palette.color(QG.QPalette.Base))
86+
painter = QG.QPainter(image)
87+
delegate.paint(painter, option, model.index(0, 0))
88+
painter.end()
89+
document, link, text, meta = documents[0]
90+
background = image.pixelColor(350, 30)
91+
if selected:
92+
assert background == QG.QColor("#204a70" if dark else "#cce8ff")
93+
assert len({link.name(), text.name(), meta.name()}) == 3
94+
for color in (link, text, meta):
95+
assert _contrast(color, background) >= 4.5
96+
# Inspect the rendered pixels too: unformatted title text must not use
97+
# QTextDocument's default (black) painting palette in dark mode.
98+
assert any(
99+
max(
100+
abs(image.pixelColor(x, y).red() - text.red()),
101+
abs(image.pixelColor(x, y).green() - text.green()),
102+
abs(image.pixelColor(x, y).blue() - text.blue()),
103+
)
104+
< 30
105+
for y in range(image.height())
106+
for x in range(2, 70)
107+
)
108+
cursor = QG.QTextCursor(document)
109+
cursor.setPosition(document.toPlainText().index("12345678"))
110+
cursor.movePosition(QG.QTextCursor.NextCharacter, QG.QTextCursor.KeepAnchor)
111+
assert cursor.charFormat().isAnchor()
112+
assert cursor.charFormat().fontUnderline()
113+
assert cursor.charFormat().foreground().color() == link
114+
assert "87654321" in document.toPlainText()
115+
monkeypatch.setattr(titledelegate, "_make_text_document", make_document)
116+
assert any(
117+
delegate.anchor_at(model.index(0, 0), option.rect, QC.QPoint(x, y), option)
118+
== "12345678"
119+
for y in range(2, image.height(), 3)
120+
for x in range(50, 170, 3)
121+
)
122+
parent.close()
123+
124+
16125
@pytest.mark.parametrize("with_item_uuid", [False, True])
17126
def test_size_hint_height_tracks_painted_wrapping(with_item_uuid: bool) -> None:
18127
"""Increase row height when the same rich title is painted more narrowly."""

‎datalab/widgets/titledelegate.py‎

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -145,34 +145,46 @@ def paint(
145145
return
146146
opt = QW.QStyleOptionViewItem(option)
147147
self.initStyleOption(opt, index)
148+
palette = opt.palette
149+
dark = palette.color(QG.QPalette.Base).lightness() < 128
150+
selected = bool(opt.state & QW.QStyle.State_Selected)
151+
link_color = QG.QColor("#8ed4ff" if dark else "#004b99")
152+
if selected:
153+
background = QG.QColor("#204a70" if dark else "#cce8ff")
154+
text_color = QG.QColor("#f0f0f0" if dark else "#202020")
155+
meta_color = QG.QColor("#cbd5e1" if dark else "#4a5563")
156+
# Native selection painting may ignore Highlight (notably on
157+
# Windows). Use an explicit brush so the contrast is predictable.
158+
opt.state &= ~(
159+
QW.QStyle.State_Selected
160+
| QW.QStyle.State_MouseOver
161+
| QW.QStyle.State_HasFocus
162+
)
163+
opt.backgroundBrush = QG.QBrush(background)
164+
else:
165+
group = (
166+
QG.QPalette.Active
167+
if opt.state & QW.QStyle.State_Active
168+
else QG.QPalette.Inactive
169+
)
170+
text_color = palette.color(group, QG.QPalette.Text)
171+
meta_color = QG.QColor("#aeb8c4" if dark else "#59636e")
148172
# Let the style draw the background, focus rect and decoration
149173
# (icon), but not the text.
150174
opt.text = ""
151175
style = opt.widget.style() if opt.widget else QW.QApplication.style()
152176
style.drawControl(QW.QStyle.CE_ItemViewItem, opt, painter, opt.widget)
177+
if selected and option.state & QW.QStyle.State_HasFocus:
178+
painter.save()
179+
painter.setPen(QG.QPen(meta_color, 1, QC.Qt.DotLine))
180+
painter.setBrush(QC.Qt.NoBrush)
181+
painter.drawRect(opt.rect.adjusted(1, 1, -2, -2))
182+
painter.restore()
153183

154184
text_rect = style.subElementRect(QW.QStyle.SE_ItemViewItemText, opt, opt.widget)
155-
palette = option.palette
156-
selected = bool(option.state & QW.QStyle.State_Selected)
157-
# ``QPalette.Highlight`` is the theme's accent color (vivid in both
158-
# light and dark modes) — much more readable than the default
159-
# ``QPalette.Link`` role, which many themes leave at Qt's hard-coded
160-
# dark blue.
161-
accent = palette.color(QG.QPalette.Active, QG.QPalette.Highlight)
162-
if selected:
163-
text_color = palette.color(QG.QPalette.Active, QG.QPalette.HighlightedText)
164-
# The selection background uses ``Highlight`` itself, so links must
165-
# use the theme's contrasting text role. Underlining preserves the
166-
# link affordance even when link and selected text share a color.
167-
link_color = text_color
168-
meta_color = text_color
169-
else:
170-
text_color = palette.color(QG.QPalette.Active, QG.QPalette.Text)
171-
link_color = accent
172-
meta_color = palette.color(QG.QPalette.Active, QG.QPalette.Mid)
173185
doc = _make_text_document(
174186
text,
175-
option,
187+
opt,
176188
link_color,
177189
text_color,
178190
self.reference_resolver,
@@ -183,6 +195,9 @@ def paint(
183195
painter.save()
184196
painter.translate(text_rect.topLeft())
185197
ctx = QG.QAbstractTextDocumentLayout.PaintContext()
198+
# Unformatted fragments do not inherit the document's body CSS.
199+
ctx.palette = QG.QPalette(palette)
200+
ctx.palette.setColor(QG.QPalette.Text, text_color)
186201
clip = QC.QRectF(0, 0, text_rect.width(), text_rect.height())
187202
ctx.clip = clip
188203
painter.setClipRect(clip)

0 commit comments

Comments
 (0)