|
1 | 1 | # Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. |
2 | 2 |
|
3 | | -"""Unit tests for rich object-title sizing.""" |
| 3 | +"""Unit tests for rich object-title sizing, colors and links.""" |
4 | 4 |
|
5 | 5 | from __future__ import annotations |
6 | 6 |
|
|
10 | 10 | from qtpy import QtGui as QG |
11 | 11 | from qtpy import QtWidgets as QW |
12 | 12 |
|
| 13 | +from datalab.widgets import titledelegate |
13 | 14 | from datalab.widgets.titledelegate import TITLE_REFERENCE_ROLE, ClickableTitleDelegate |
14 | 15 |
|
15 | 16 |
|
| 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 | + |
16 | 125 | @pytest.mark.parametrize("with_item_uuid", [False, True]) |
17 | 126 | def test_size_hint_height_tracks_painted_wrapping(with_item_uuid: bool) -> None: |
18 | 127 | """Increase row height when the same rich title is painted more narrowly.""" |
|
0 commit comments