Merge pull request #946 from jp2images/feature-panels-follow-palette

Let the Collections and Projects panels follow the palette
This commit is contained in:
Laurent Trinques
2026-09-19 20:24:05 +02:00
committed by GitHub
12 changed files with 368 additions and 44 deletions
+2
View File
@@ -436,6 +436,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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "elementpreviewdelegate.h"
#include <QWidget>
#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;
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef ELEMENTPREVIEWDELEGATE_H
#define ELEMENTPREVIEWDELEGATE_H
#include <QHash>
#include <QIcon>
#include <QStyledItemDelegate>
/**
@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<qint64, QIcon> m_dark_icons;
};
#endif
@@ -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();
@@ -16,6 +16,8 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#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
+12
View File
@@ -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
+2 -18
View File
@@ -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);
+7 -6
View File
@@ -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);
+66
View File
@@ -17,6 +17,7 @@
*/
#include "qetpalette.h"
#include <QColor>
#include <QStyle>
#include <cmath>
@@ -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<const QRgb *>(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<const QRgb *>(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<QRgb *>(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;
}
+25
View File
@@ -18,7 +18,9 @@
#ifndef QET_PALETTE_H
#define QET_PALETTE_H
#include <QImage>
#include <QPalette>
#include <QPixmap>
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);
}
}
+3 -1
View File
@@ -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)
+129
View File
@@ -24,11 +24,15 @@
#include <QLineEdit>
#include <QMainWindow>
#include <QPushButton>
#include <QPainter>
#include <QRadioButton>
#include <QStandardItemModel>
#include <QTreeView>
#include <QStyleFactory>
#include <QToolBar>
#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<QRgb, int> 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