diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index ff89ebf68..b0df63774 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -434,6 +434,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ElementsCollection/elementcollectionhandler.h ${QET_DIR}/sources/ElementsCollection/elementcollectionitem.cpp ${QET_DIR}/sources/ElementsCollection/elementcollectionitem.h + ${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.cpp + ${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.h ${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.cpp ${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.h ${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.cpp diff --git a/sources/ElementsCollection/elementpreviewdelegate.cpp b/sources/ElementsCollection/elementpreviewdelegate.cpp new file mode 100644 index 000000000..72ed74b6a --- /dev/null +++ b/sources/ElementsCollection/elementpreviewdelegate.cpp @@ -0,0 +1,54 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "elementpreviewdelegate.h" + +#include + +#include "../qetpalette.h" + +/** + @brief ElementPreviewDelegate::initStyleOption + After the base class has filled the option from the model, replace + the icon with one that reads on a dark palette. The adapted icon is + built from the pixmap the view is about to draw, at the view's + decoration size and device pixel ratio. +*/ +void ElementPreviewDelegate::initStyleOption(QStyleOptionViewItem *option, + const QModelIndex &index) const +{ + QStyledItemDelegate::initStyleOption(option, index); + if (option->icon.isNull() || !QET::Palette::isDark(option->palette)) + return; + + const qint64 key = option->icon.cacheKey(); + const auto it = m_dark_icons.constFind(key); + if (it != m_dark_icons.constEnd()) + { + option->icon = *it; + return; + } + + const qreal dpr = option->widget ? option->widget->devicePixelRatio() : 1.0; + const QPixmap source = option->icon.pixmap(option->decorationSize, dpr); + const QPixmap adapted = QET::Palette::forPalette(source, option->palette); + QIcon icon = option->icon; + if (adapted.cacheKey() != source.cacheKey()) + icon = QIcon(adapted); + m_dark_icons.insert(key, icon); + option->icon = icon; +} diff --git a/sources/ElementsCollection/elementpreviewdelegate.h b/sources/ElementsCollection/elementpreviewdelegate.h new file mode 100644 index 000000000..1fb17c2a9 --- /dev/null +++ b/sources/ElementsCollection/elementpreviewdelegate.h @@ -0,0 +1,50 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef ELEMENTPREVIEWDELEGATE_H +#define ELEMENTPREVIEWDELEGATE_H + +#include +#include +#include + +/** + @brief The ElementPreviewDelegate class + Draws the items of an element collection tree with icons that read on + the current palette. Element previews are black line art drawn for a + white sheet; on a dark palette this delegate hands the view the same + picture with its lightness inverted (QET::Palette::forPalette), so the + ink is light on the dark row. Colored icons, such as folders, are left + alone, and nothing changes on a light palette. Adapted icons are kept + per source icon, so a repaint costs a hash lookup. +*/ +class ElementPreviewDelegate : public QStyledItemDelegate +{ + Q_OBJECT + + public: + using QStyledItemDelegate::QStyledItemDelegate; + + protected: + void initStyleOption(QStyleOptionViewItem *option, + const QModelIndex &index) const override; + + private: + mutable QHash m_dark_icons; +}; + +#endif diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp index 605489430..3c015c716 100644 --- a/sources/ElementsCollection/elementscollectionwidget.cpp +++ b/sources/ElementsCollection/elementscollectionwidget.cpp @@ -725,7 +725,10 @@ void ElementsCollectionWidget::showThisDir() ElementCollectionItem *eci = elementCollectionItemForIndex(m_showed_index); if (eci) + { eci->setBackground(QBrush()); + eci->setForeground(QBrush()); + } } m_showed_index = m_index_at_context_menu; @@ -736,7 +739,11 @@ void ElementsCollectionWidget::showThisDir() ElementCollectionItem *eci = elementCollectionItemForIndex(m_showed_index); if (eci) + { + // Amber under black, whatever the palette's text color. eci->setBackground(QBrush(QColor(255, 204, 0, 255))); + eci->setForeground(QBrush(Qt::black)); + } search(); } else @@ -755,7 +762,10 @@ void ElementsCollectionWidget::resetShowThisDir() ElementCollectionItem *eci = elementCollectionItemForIndex( m_showed_index); if (eci) + { eci->setBackground(QBrush()); + eci->setForeground(QBrush()); + } } m_showed_index = QModelIndex(); diff --git a/sources/ElementsCollection/elementstreeview.cpp b/sources/ElementsCollection/elementstreeview.cpp index 39fe4a70b..581bc1b64 100644 --- a/sources/ElementsCollection/elementstreeview.cpp +++ b/sources/ElementsCollection/elementstreeview.cpp @@ -16,6 +16,8 @@ along with QElectroTech. If not, see . */ #include "elementstreeview.h" +#include "elementpreviewdelegate.h" +#include "../qetpalette.h" #include "../factory/elementfactory.h" #include "../qetgraphicsitem/element.h" @@ -41,24 +43,11 @@ static int MAX_DND_PIXMAP_HEIGHT = 375; ElementsTreeView::ElementsTreeView(QWidget *parent) : QTreeView(parent) { - // force du noir sur une alternance de blanc (comme le schema) et de gris - // clair, avec du blanc sur bleu pas trop fonce pour la selection - // - // Element icons are rendered with colors read directly from each .elmt - // file (almost always black linework, matching printed-schematic - // convention) onto a transparent background -- so this view must keep - // a light background regardless of the OS/desktop theme, or the icons - // become invisible on dark themes. QAbstractItemView paints its rows - // using the viewport's palette, not the view's own, so the palette - // must be applied to both to actually take effect under every style. - QPalette qp = palette(); - qp.setColor(QPalette::Text, Qt::black); - qp.setColor(QPalette::Base, Qt::white); - qp.setColor(QPalette::AlternateBase, QColor("#e8e8e8")); - qp.setColor(QPalette::Highlight, QColor("#678db2")); - qp.setColor(QPalette::HighlightedText, Qt::black); - setPalette(qp); - viewport()->setPalette(qp); + // Rows follow the application palette. Element previews are black + // line art drawn for a white sheet; ElementPreviewDelegate adapts them + // to a dark palette, so this view no longer has to force a light one + // (bugtracker #335). + setItemDelegate(new ElementPreviewDelegate(this)); } /** @@ -220,7 +209,7 @@ void ElementsTreeView::startElementDrag(const ElementsLocation &location) &elmt_creation_state)); if (elmt_creation_state) { return; } - QPixmap elmt_pixmap(temp_elmt->pixmap()); + QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), palette())); QPoint elmt_hotspot(temp_elmt->hotspot()); //Adjust the size of the pixmap if he is too big diff --git a/sources/elementscollectioncache.cpp b/sources/elementscollectioncache.cpp index 10a3ab92d..aa510b104 100644 --- a/sources/elementscollectioncache.cpp +++ b/sources/elementscollectioncache.cpp @@ -52,6 +52,18 @@ ElementsCollectionCache::ElementsCollectionCache(const QString &database_path, Q QSqlQuery(cache_db_).exec("PRAGMA locking_mode = EXCLUSIVE"); QSqlQuery(cache_db_).exec("PRAGMA synchronous = OFF"); + // Previews used to be stored on an opaque white sheet; they are + // transparent now, so the collection tree can adapt them to a + // dark palette. A cache written before that is dropped once. + QSqlQuery(cache_db_).exec("CREATE TABLE IF NOT EXISTS meta" + "(key VARCHAR(32) NOT NULL PRIMARY KEY, value VARCHAR(64));"); + QSqlQuery meta(cache_db_); + meta.exec("SELECT value FROM meta WHERE key = 'pixmaps'"); + if (!meta.next() || meta.value(0).toString() != QLatin1String("transparent")) { + QSqlQuery(cache_db_).exec("DROP TABLE IF EXISTS pixmaps"); + QSqlQuery(cache_db_).exec("DROP TABLE IF EXISTS names"); + QSqlQuery(cache_db_).exec("REPLACE INTO meta (key, value) VALUES ('pixmaps', 'transparent')"); + } #if TODO_LIST #pragma message("@TODO the tables could already exist, handle that case.") #endif diff --git a/sources/elementspanel.cpp b/sources/elementspanel.cpp index 9e20e2f37..114394f7d 100644 --- a/sources/elementspanel.cpp +++ b/sources/elementspanel.cpp @@ -54,24 +54,8 @@ ElementsPanel::ElementsPanel(QWidget *parent) : setDropIndicatorShown(true); setAutoExpandDelay(1000); - // force du noir sur une alternance de blanc (comme le schema) et de gris - // clair, avec du blanc sur bleu pas trop fonce pour la selection - // - // Element icons are rendered with colors read directly from each .elmt - // file (almost always black linework, matching printed-schematic - // convention) onto a transparent background -- so this view must keep - // a light background regardless of the OS/desktop theme, or the icons - // become invisible on dark themes. QAbstractItemView paints its rows - // using the viewport's palette, not the view's own, so the palette - // must be applied to both to actually take effect under every style. - QPalette qp = palette(); - qp.setColor(QPalette::Text, Qt::black); - qp.setColor(QPalette::Base, Qt::white); - qp.setColor(QPalette::AlternateBase, QColor("#e8e8e8")); - qp.setColor(QPalette::Highlight, QColor("#678db2")); - qp.setColor(QPalette::HighlightedText, Qt::black); - setPalette(qp); - viewport()->setPalette(qp); + // Rows follow the application palette; the icons shown here come from + // the icon theme, which has a dark variant. // we handle double click on items ourselves connect(this, &ElementsPanel::itemDoubleClicked, this, &ElementsPanel::slot_doubleClick); diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index b3c55792f..c3b0f0745 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -146,12 +146,13 @@ QPixmap ElementPictureFactory::pixmap(const ElementsLocation &location) QPixmap pix(w, h); //Element definitions almost always draw with a hardcoded black //stroke color, on the assumption of the white diagram sheet they - //are normally placed on. A transparent background here makes - //that stroke disappear against a dark widget/tree-view background - //(bugtracker #335). Give it an opaque white background instead - - //exactly what the element already assumes visually, in every - //context this pixmap is used (tree icons, drag icon, previews). - pix.fill(Qt::white); + //are normally placed on. The pixmap is kept as drawn, on a + //transparent background: the places that show it (the + //collection tree through ElementPreviewDelegate, the drag icon) + //adapt it to the palette with QET::Palette::forPalette(), so a + //dark palette gets light ink instead of black on black + //(bugtracker #335). + pix.fill(Qt::transparent); QPainter painter(&pix); painter.setRenderHint(QPainter::Antialiasing, true); diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index 6589e7603..63c5f98bb 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -17,6 +17,7 @@ */ #include "qetpalette.h" +#include #include #include @@ -177,3 +178,68 @@ QPalette QET::Palette::forFusion(const QPalette &platform) return withPlatformAccent(isDark(platform) ? fusionDark() : fusionLight(), platform); } + +bool QET::Palette::isLineArt(const QImage &image) +{ + const QImage source = image.convertToFormat(QImage::Format_ARGB32); + int visible = 0; + int saturated = 0; + for (int y = 0; y < source.height(); ++y) + { + const QRgb *line = reinterpret_cast(source.constScanLine(y)); + for (int x = 0; x < source.width(); ++x) + { + if (qAlpha(line[x]) <= 64) + continue; + ++visible; + const QColor color(line[x]); + if (color.hslSaturationF() > 0.25 && color.value() > 60) + ++saturated; + } + } + return visible > 0 && saturated < visible * 0.20; +} + +QImage QET::Palette::invertedLightness(const QImage &image) +{ + // Lightness of pure black after inversion: the dark palette's text. + const qreal ink = 220.0 / 255.0; + QImage result = image.convertToFormat(QImage::Format_ARGB32); + qreal darkest = 1.0; + for (int y = 0; y < result.height(); ++y) + { + const QRgb *line = reinterpret_cast(result.constScanLine(y)); + for (int x = 0; x < result.width(); ++x) + if (qAlpha(line[x]) > 64) + darkest = qMin(darkest, QColor(line[x]).lightnessF()); + } + const qreal span = qMax(1.0 - darkest, 1e-6); + for (int y = 0; y < result.height(); ++y) + { + QRgb *line = reinterpret_cast(result.scanLine(y)); + for (int x = 0; x < result.width(); ++x) + { + const int alpha = qAlpha(line[x]); + if (alpha == 0) + continue; + const QColor color(line[x]); + const qreal lightness = qBound(0.0, ink * (1.0 - (color.lightnessF() - darkest) / span), 1.0); + QColor out = QColor::fromHslF(qMax(color.hslHueF(), 0.0), color.hslSaturationF(), lightness); + out.setAlpha(alpha); + line[x] = out.rgba(); + } + } + return result; +} + +QPixmap QET::Palette::forPalette(const QPixmap &pixmap, const QPalette &palette) +{ + if (pixmap.isNull() || !isDark(palette)) + return pixmap; + const QImage image = pixmap.toImage(); + if (!isLineArt(image)) + return pixmap; + QPixmap result = QPixmap::fromImage(invertedLightness(image)); + result.setDevicePixelRatio(pixmap.devicePixelRatio()); + return result; +} diff --git a/sources/qetpalette.h b/sources/qetpalette.h index cbc7e6edb..0df5bb3a8 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -18,7 +18,9 @@ #ifndef QET_PALETTE_H #define QET_PALETTE_H +#include #include +#include class QStyle; @@ -87,6 +89,29 @@ namespace QET { platform's accent color kept when it is readable. */ QPalette forFusion(const QPalette &platform); + + /** + True when fewer than a fifth of the visible pixels are + saturated: black or gray line art, which is what element + previews and most of QET's own icons are. + */ + bool isLineArt(const QImage &image); + + /** + The image with its lightness inverted and hue, saturation and + alpha kept: the darkest ink becomes light gray (220), white + becomes black. The rule misc/make_icon_themes.py applies when + it builds the dark icon theme. + */ + QImage invertedLightness(const QImage &image); + + /** + A picture drawn for a white sheet, made to read on palette: + returned as is on a light palette, and with its lightness + inverted on a dark one when it is line art. Colored art is + left alone either way. + */ + QPixmap forPalette(const QPixmap &pixmap, const QPalette &palette); } } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index c06489b20..8bfccf9b1 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -96,7 +96,9 @@ add_executable( tst_qetpalette.cpp inkcontrast.h ${QET_DIR}/sources/qetpalette.cpp - ${QET_DIR}/sources/qetpalette.h) + ${QET_DIR}/sources/qetpalette.h + ${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.cpp + ${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.h) add_test(NAME tst_qetpalette COMMAND tst_qetpalette) target_include_directories(tst_qetpalette PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_qetpalette PRIVATE Qt::Test Qt::Widgets) diff --git a/tests/qttest/tst_qetpalette.cpp b/tests/qttest/tst_qetpalette.cpp index ccbe07f15..4aea6d76d 100644 --- a/tests/qttest/tst_qetpalette.cpp +++ b/tests/qttest/tst_qetpalette.cpp @@ -24,11 +24,15 @@ #include #include #include +#include #include +#include +#include #include #include #include "inkcontrast.h" +#include "ElementsCollection/elementpreviewdelegate.h" #include "qetpalette.h" using QET::Palette::contrastRatio; @@ -62,6 +66,10 @@ class tst_qetpalette : public QObject void styleIsFusionMatchesObjectName(); void renderedWidgetsAreReadable_data(); void renderedWidgetsAreReadable(); + void lineArtRuleSeparatesInkFromColor(); + void invertedLightnessKeepsHueAndAlpha(); + void elementPreviewReadsOnBothPalettes(); + void previewDelegateAdaptsLineArtOnly(); private: static void addPaletteRows(); @@ -308,6 +316,127 @@ void tst_qetpalette::renderedWidgetsAreReadable() qPrintable(QString("line edit text: %1").arg(edit_contrast))); } +namespace { + /// A 40 x 40 transparent picture with a 3 px stroke square in color, as + /// an element preview is drawn for the white sheet. + QImage strokeSquare(const QColor &color) + { + QImage image(40, 40, QImage::Format_ARGB32); + image.fill(Qt::transparent); + QPainter painter(&image); + painter.setPen(QPen(color, 3)); + painter.drawRect(6, 6, 27, 27); + return image; + } + + /// The most frequent color of a rendering: its background. + QRgb dominant(const QImage &image) + { + QHash histogram; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + ++histogram[image.pixel(x, y)]; + QRgb best = 0; + int count = -1; + for (auto it = histogram.cbegin(); it != histogram.cend(); ++it) + if (it.value() > count) { count = it.value(); best = it.key(); } + return best; + } +} + +void tst_qetpalette::lineArtRuleSeparatesInkFromColor() +{ + QVERIFY(QET::Palette::isLineArt(strokeSquare(Qt::black))); + QVERIFY(QET::Palette::isLineArt(strokeSquare(QColor(80, 80, 80)))); + QVERIFY(!QET::Palette::isLineArt(strokeSquare(Qt::red))); + QVERIFY(!QET::Palette::isLineArt(strokeSquare(QColor(30, 96, 176)))); + QVERIFY(!QET::Palette::isLineArt(QImage())); +} + +/** + Black ink becomes the dark palette's light gray, a colored stroke keeps + its hue, and transparency is untouched. +*/ +void tst_qetpalette::invertedLightnessKeepsHueAndAlpha() +{ + const QImage black = QET::Palette::invertedLightness(strokeSquare(Qt::black)); + QCOMPARE(black.pixelColor(6, 20).alpha(), 255); + QVERIFY2(black.pixelColor(6, 20).lightnessF() > 0.8, "black ink did not become light"); + QCOMPARE(black.pixelColor(20, 20).alpha(), 0); + + const QImage red = QET::Palette::invertedLightness(strokeSquare(Qt::red)); + const QColor stroke = red.pixelColor(6, 20); + QVERIFY2(qAbs(stroke.hslHueF() - QColor(Qt::red).hslHueF()) < 0.02, "hue changed"); + QVERIFY(stroke.hslSaturationF() > 0.9); +} + +/** + An element preview drawn for the white sheet must read at 3:1 on the + Base color of both palettes: unchanged on the light one, inverted on + the dark one. +*/ +void tst_qetpalette::elementPreviewReadsOnBothPalettes() +{ + const QPixmap preview = QPixmap::fromImage(strokeSquare(Qt::black)); + for (const QPalette &palette : {QET::Palette::fusionLight(), QET::Palette::fusionDark()}) + { + const QColor base = palette.color(QPalette::Active, QPalette::Base); + const QPixmap shown = QET::Palette::forPalette(preview, palette); + QImage row(shown.size(), QImage::Format_ARGB32); + row.fill(base); + QPainter painter(&row); + painter.drawPixmap(0, 0, shown); + painter.end(); + const double contrast = QET::Test::inkContrast(row, row.rect()); + QVERIFY2(contrast >= 3.0, qPrintable(QString("preview reads %1:1 on Base %2").arg(contrast).arg(base.name()))); + } + // A light palette hands the picture back untouched. + QCOMPARE(QET::Palette::forPalette(preview, QET::Palette::fusionLight()).cacheKey(), preview.cacheKey()); +} + +/** + In a tree on the dark palette, the delegate inverts a line-art icon so + it reads on the row, and leaves a colored icon (a folder) as it is. +*/ +void tst_qetpalette::previewDelegateAdaptsLineArtOnly() +{ + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(QET::Palette::fusionDark()); + + QStandardItemModel model; + auto *element = new QStandardItem(QIcon(QPixmap::fromImage(strokeSquare(Qt::black))), "element"); + auto *folder = new QStandardItem(QIcon(QPixmap::fromImage(strokeSquare(QColor(30, 96, 176)))), "folder"); + model.appendRow(element); + model.appendRow(folder); + + QTreeView view; + view.setModel(&model); + view.setIconSize(QSize(40, 40)); + view.setItemDelegate(new ElementPreviewDelegate(&view)); + view.resize(300, 200); + view.show(); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + + const QImage image = view.viewport()->grab().toImage(); + const QRect element_icon(view.visualRect(element->index()).topLeft(), QSize(40, 40)); + const QRect folder_icon(view.visualRect(folder->index()).topLeft(), QSize(40, 40)); + const QColor base = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Base); + QCOMPARE(QColor(dominant(image)), base); + + const double element_contrast = QET::Test::inkContrast(image, element_icon); + QVERIFY2(element_contrast >= 3.0, qPrintable(QString("element preview reads %1:1 on the dark row").arg(element_contrast))); + + // The folder icon keeps its blue: some pixel in its slot is still saturated blue. + bool blue = false; + for (int y = folder_icon.top(); y <= folder_icon.bottom() && !blue; ++y) + for (int x = folder_icon.left(); x <= folder_icon.right() && !blue; ++x) + { + const QColor c = image.pixelColor(x, y); + blue = c.hslSaturationF() > 0.5 && c.blue() > c.red() + 60; + } + QVERIFY2(blue, "the colored icon lost its color"); +} + int main(int argc, char **argv) { // Widgets are painted for real, on Qt's offscreen platform so the test