mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-20 07:14:13 +02:00
Draw the folio with inverted lightness on a dark palette
On a dark palette the folio stayed a white sheet with black ink, and the white/gray toggle only darkened the sheet while the ink stayed black. DiagramView now renders each repaint into an image and inverts its lightness before blitting it: white becomes the palette's Base, black becomes its Text, and colored conductors and elements keep their hue. The document, printing and export are untouched; only the screen rendering changes, and only while the palette is dark. QET::Palette::invertLightness does the inversion in one integer pass (adding 255 - max - min to the three channels inverts the HSL lightness and keeps hue and saturation), then stretches the result between the sheet and ink colors through three lookup tables. A 4K viewport costs about 9 ms in a release build. QGraphicsView::render() skips the selection rubber band, so the view draws it again after the inversion. Tests in tst_qetpalette: invertLightnessMapsSheetAndInk, invertedViewReadsOnDarkSheet, invertLightnessSpeed.
This commit is contained in:
+81
-1
@@ -29,6 +29,7 @@
|
||||
#include "qetgraphicsitem/conductortextitem.h"
|
||||
#include "qetgraphicsitem/independenttextitem.h"
|
||||
#include "qeticons.h"
|
||||
#include "qetpalette.h"
|
||||
#include "titleblock/integrationmovetemplateshandler.h"
|
||||
#include "ui/diagrampropertiesdialog.h"
|
||||
#include "ui/multipastedialog.h"
|
||||
@@ -39,8 +40,13 @@
|
||||
#include "ElementsCollection/xmlelementcollection.h"
|
||||
#include "NameList/nameslist.h"
|
||||
#include "elementdialog.h"
|
||||
#include <QApplication>
|
||||
#include <QDropEvent>
|
||||
#include <QPainter>
|
||||
#include <QPointer>
|
||||
#include <QStyleHintReturnMask>
|
||||
#include <QStyleOptionRubberBand>
|
||||
#include <QtMath>
|
||||
|
||||
/**
|
||||
Constructeur
|
||||
@@ -1081,6 +1087,77 @@ bool DiagramView::event(QEvent *e) {
|
||||
return(QGraphicsView::event(e));
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::canvasIsInverted
|
||||
@return true when the folio must be drawn with its lightness inverted,
|
||||
i.e. when the application palette is dark. The document keeps its own
|
||||
colors; only the screen rendering changes, so printing and exporting
|
||||
stay black on white.
|
||||
*/
|
||||
bool DiagramView::canvasIsInverted() const
|
||||
{
|
||||
return QET::Palette::isDark(qApp->palette());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::paintInverted
|
||||
Render \a area of the viewport into an off-screen image, invert the
|
||||
lightness of that image and blit it to the viewport. Inverting the
|
||||
finished rendering turns the white sheet black and the black ink white
|
||||
in one pass, and keeps the hue of colored conductors and elements.
|
||||
@param area the part of the viewport to repaint, in viewport coordinates
|
||||
*/
|
||||
void DiagramView::paintInverted(const QRect &area)
|
||||
{
|
||||
const QRect rect = area.intersected(viewport()->rect());
|
||||
if (rect.isEmpty())
|
||||
return;
|
||||
|
||||
const qreal ratio = viewport()->devicePixelRatioF();
|
||||
QImage buffer(qCeil(rect.width() * ratio), qCeil(rect.height() * ratio),
|
||||
QImage::Format_RGB32);
|
||||
buffer.setDevicePixelRatio(ratio);
|
||||
|
||||
QPainter buffer_painter(&buffer);
|
||||
buffer_painter.setRenderHints(renderHints());
|
||||
render(&buffer_painter, QRectF(QPointF(0, 0), QSizeF(rect.size())),
|
||||
rect, Qt::IgnoreAspectRatio);
|
||||
buffer_painter.end();
|
||||
|
||||
QET::Palette::invertLightness(buffer, palette().color(QPalette::Base),
|
||||
palette().color(QPalette::Text));
|
||||
|
||||
QPainter painter(viewport());
|
||||
painter.drawImage(rect.topLeft(), buffer);
|
||||
drawRubberBand(painter);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::drawRubberBand
|
||||
Draw the selection rubber band the way QGraphicsView::paintEvent does.
|
||||
Rendering the view into an off-screen image skips it, so it is drawn
|
||||
here instead, after the inversion, and keeps the palette colors.
|
||||
@param painter a painter on the viewport
|
||||
*/
|
||||
void DiagramView::drawRubberBand(QPainter &painter)
|
||||
{
|
||||
const QRect band = rubberBandRect();
|
||||
if (band.isNull())
|
||||
return;
|
||||
|
||||
QStyleOptionRubberBand option;
|
||||
option.initFrom(viewport());
|
||||
option.rect = band;
|
||||
option.shape = QRubberBand::Rectangle;
|
||||
|
||||
QStyleHintReturnMask mask;
|
||||
if (viewport()->style()->styleHint(QStyle::SH_RubberBand_Mask, &option,
|
||||
viewport(), &mask))
|
||||
painter.setClipRegion(mask.region, Qt::IntersectClip);
|
||||
viewport()->style()->drawControl(QStyle::CE_RubberBand, &option,
|
||||
&painter, viewport());
|
||||
}
|
||||
|
||||
/**
|
||||
@brief DiagramView::paintEvent
|
||||
Reimplemented from QGraphicsView
|
||||
@@ -1088,7 +1165,10 @@ bool DiagramView::event(QEvent *e) {
|
||||
*/
|
||||
void DiagramView::paintEvent(QPaintEvent *event)
|
||||
{
|
||||
QGraphicsView::paintEvent(event);
|
||||
if (canvasIsInverted())
|
||||
paintInverted(event->rect());
|
||||
else
|
||||
QGraphicsView::paintEvent(event);
|
||||
|
||||
if (m_free_rubberbanding && m_free_rubberband.count() >= 3)
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ class QETDiagramEditor;
|
||||
class DVEventInterface;
|
||||
class QInputEvent;
|
||||
class QGestureEvent;
|
||||
class QPainter;
|
||||
|
||||
/**
|
||||
This class provides a widget to render an electric diagram in an editable,
|
||||
@@ -97,6 +98,9 @@ class DiagramView : public QGraphicsView
|
||||
virtual bool selectedItemHasFocus();
|
||||
|
||||
private:
|
||||
bool canvasIsInverted() const;
|
||||
void paintInverted(const QRect &area);
|
||||
void drawRubberBand(QPainter &painter);
|
||||
void handleElementDrop(QDropEvent *);
|
||||
void handleTitleBlockDrop(QDropEvent *);
|
||||
void handleTextDrop(QDropEvent *);
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
#include "qetpalette.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QImage>
|
||||
#include <QStyle>
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace {
|
||||
@@ -75,6 +77,48 @@ bool QET::Palette::isDark(const QPalette &palette)
|
||||
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128;
|
||||
}
|
||||
|
||||
void QET::Palette::invertLightness(QImage &image, const QColor &sheet,
|
||||
const QColor &ink)
|
||||
{
|
||||
if (image.format() != QImage::Format_RGB32)
|
||||
image.convertTo(QImage::Format_RGB32);
|
||||
|
||||
// One table per channel maps the inverted value (0 = was white,
|
||||
// 255 = was black) onto the sheet..ink span.
|
||||
uchar red_of[256], green_of[256], blue_of[256];
|
||||
for (int v = 0; v < 256; ++v) {
|
||||
red_of[v] = uchar(sheet.red() + (ink.red() - sheet.red()) * v / 255);
|
||||
green_of[v] = uchar(sheet.green() + (ink.green() - sheet.green()) * v / 255);
|
||||
blue_of[v] = uchar(sheet.blue() + (ink.blue() - sheet.blue()) * v / 255);
|
||||
}
|
||||
|
||||
/* Inverting the lightness of an HSL color while keeping its hue and
|
||||
saturation leaves the distance between the highest and the lowest
|
||||
channel unchanged, so it comes down to one offset per pixel:
|
||||
c + 255 - max - min. The offset turns the highest channel into
|
||||
255 - min and the lowest into 255 - max, so no channel can leave
|
||||
the 0..255 range and no clamping is needed. The loop runs on every
|
||||
repaint of a folio, hence the plain integer arithmetic. */
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
quint32 *line = reinterpret_cast<quint32 *>(image.scanLine(y));
|
||||
for (int x = 0, width = image.width(); x < width; ++x) {
|
||||
const quint32 pixel = line[x];
|
||||
const int red = (pixel >> 16) & 0xff;
|
||||
const int green = (pixel >> 8) & 0xff;
|
||||
const int blue = pixel & 0xff;
|
||||
int highest = red > green ? red : green;
|
||||
int lowest = red < green ? red : green;
|
||||
if (blue > highest) highest = blue;
|
||||
if (blue < lowest) lowest = blue;
|
||||
const int offset = 255 - highest - lowest;
|
||||
line[x] = 0xff000000u
|
||||
| (quint32(red_of[red + offset]) << 16)
|
||||
| (quint32(green_of[green + offset]) << 8)
|
||||
| quint32(blue_of[blue + offset]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
double QET::Palette::contrastRatio(const QColor &a, const QColor &b)
|
||||
{
|
||||
double lighter = relativeLuminance(a);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
|
||||
class QImage;
|
||||
class QStyle;
|
||||
|
||||
/**
|
||||
@@ -54,6 +55,18 @@ namespace QET {
|
||||
*/
|
||||
bool isDark(const QPalette &palette);
|
||||
|
||||
/**
|
||||
Invert the lightness of every pixel of \a image, keeping its hue
|
||||
and saturation, then stretch the result between two colors: pure
|
||||
white becomes \a sheet, pure black becomes \a ink, and a red
|
||||
line stays red, only lighter. Made for a rendering of a white
|
||||
sheet that has to read on a dark palette, with sheet = Base and
|
||||
ink = Text. The image must be opaque; an image in another format
|
||||
is converted to RGB32 first.
|
||||
*/
|
||||
void invertLightness(QImage &image, const QColor &sheet = Qt::black,
|
||||
const QColor &ink = Qt::white);
|
||||
|
||||
/**
|
||||
WCAG 2 contrast ratio between two opaque colors, from 1 (equal)
|
||||
to 21 (black on white). Normal text needs at least 4.5, large
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGraphicsView>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
@@ -70,6 +72,9 @@ class tst_qetpalette : public QObject
|
||||
void invertedLightnessKeepsHueAndAlpha();
|
||||
void elementPreviewReadsOnBothPalettes();
|
||||
void previewDelegateAdaptsLineArtOnly();
|
||||
void invertLightnessMapsSheetAndInk();
|
||||
void invertedViewReadsOnDarkSheet();
|
||||
void invertLightnessSpeed();
|
||||
|
||||
private:
|
||||
static void addPaletteRows();
|
||||
@@ -437,6 +442,107 @@ void tst_qetpalette::previewDelegateAdaptsLineArtOnly()
|
||||
QVERIFY2(blue, "the colored icon lost its color");
|
||||
}
|
||||
|
||||
/**
|
||||
The lightness inversion sends white to the sheet color and black to
|
||||
the ink color, lands a mid gray between the two, and keeps the hue of
|
||||
a colored line. Without sheet and ink it is a plain inversion.
|
||||
*/
|
||||
void tst_qetpalette::invertLightnessMapsSheetAndInk()
|
||||
{
|
||||
const QColor sheet(30, 30, 30);
|
||||
const QColor ink(220, 220, 220);
|
||||
QImage image(4, 1, QImage::Format_ARGB32);
|
||||
image.setPixelColor(0, 0, Qt::white);
|
||||
image.setPixelColor(1, 0, Qt::black);
|
||||
image.setPixelColor(2, 0, QColor(128, 128, 128));
|
||||
image.setPixelColor(3, 0, QColor(200, 0, 0));
|
||||
QImage plain = image;
|
||||
|
||||
QET::Palette::invertLightness(image, sheet, ink);
|
||||
QCOMPARE(image.format(), QImage::Format_RGB32);
|
||||
QCOMPARE(image.pixelColor(0, 0), sheet);
|
||||
QCOMPARE(image.pixelColor(1, 0), ink);
|
||||
const int middle = (sheet.red() + ink.red()) / 2;
|
||||
QVERIFY(qAbs(image.pixelColor(2, 0).red() - middle) <= 2);
|
||||
const QColor red = image.pixelColor(3, 0);
|
||||
QCOMPARE(red.hslHue(), 0);
|
||||
QVERIFY2(red.hslSaturationF() > 0.5, qPrintable(red.name()));
|
||||
QVERIFY2(red.lightness() > QColor(200, 0, 0).lightness(), qPrintable(red.name()));
|
||||
|
||||
QET::Palette::invertLightness(plain);
|
||||
QCOMPARE(plain.pixelColor(0, 0), QColor(Qt::black));
|
||||
QCOMPARE(plain.pixelColor(1, 0), QColor(Qt::white));
|
||||
QCOMPARE(plain.pixelColor(3, 0), QColor(255, 55, 55));
|
||||
}
|
||||
|
||||
/**
|
||||
A view rendered the way DiagramView does it on a dark palette: a part
|
||||
of the viewport goes into an image, which is inverted between the
|
||||
palette's Base and Text. The white sheet comes out as Base, black
|
||||
lines read at text contrast, and a blue box is still blue.
|
||||
*/
|
||||
void tst_qetpalette::invertedViewReadsOnDarkSheet()
|
||||
{
|
||||
QGraphicsScene scene(0, 0, 200, 120);
|
||||
scene.setBackgroundBrush(Qt::white);
|
||||
scene.addLine(10, 60, 190, 60, QPen(Qt::black, 2));
|
||||
scene.addRect(20, 20, 40, 20, QPen(Qt::NoPen), QBrush(QColor(30, 96, 176)));
|
||||
|
||||
QGraphicsView view(&scene);
|
||||
view.setFrameShape(QFrame::NoFrame);
|
||||
view.setAlignment(Qt::AlignLeft | Qt::AlignTop);
|
||||
view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
view.resize(200, 120);
|
||||
view.show();
|
||||
QVERIFY(QTest::qWaitForWindowExposed(&view));
|
||||
|
||||
// A part of the viewport that is not at its origin, as a partial
|
||||
// repaint after a scroll would be.
|
||||
const QRect area(10, 10, 100, 60);
|
||||
QImage buffer(area.size(), QImage::Format_RGB32);
|
||||
QPainter painter(&buffer);
|
||||
view.render(&painter, QRectF(QPointF(0, 0), QSizeF(area.size())), area);
|
||||
painter.end();
|
||||
|
||||
const QPalette dark = QET::Palette::fusionDark();
|
||||
const QColor base = dark.color(QPalette::Active, QPalette::Base);
|
||||
const QColor text = dark.color(QPalette::Active, QPalette::Text);
|
||||
QET::Palette::invertLightness(buffer, base, text);
|
||||
|
||||
QHash<QRgb, int> histogram;
|
||||
for (int y = 0; y < buffer.height(); ++y)
|
||||
for (int x = 0; x < buffer.width(); ++x)
|
||||
++histogram[buffer.pixel(x, y)];
|
||||
QRgb dominant = 0;
|
||||
for (auto it = histogram.cbegin(); it != histogram.cend(); ++it)
|
||||
if (it.value() > histogram.value(dominant)) dominant = it.key();
|
||||
QCOMPARE(QColor(dominant), base);
|
||||
|
||||
const double contrast = inkContrast(buffer, buffer.rect());
|
||||
QVERIFY2(contrast >= QET::Palette::contrastRatio(base, text) - 0.5,
|
||||
qPrintable(QString("ink reads %1:1 on the dark sheet").arg(contrast)));
|
||||
|
||||
// The box at scene (20..60, 20..40) sits at (10..50, 10..30) in the buffer.
|
||||
const QColor box = buffer.pixelColor(30, 20);
|
||||
QVERIFY2(box.hslSaturationF() > 0.3 && box.blue() > box.red() + 60,
|
||||
qPrintable(QString("the blue box became %1").arg(box.name())));
|
||||
}
|
||||
|
||||
/**
|
||||
The inversion runs on every repaint of the folio, so a 4K viewport
|
||||
has to cost a few milliseconds. Reported, not asserted: the bound
|
||||
depends on the build box.
|
||||
*/
|
||||
void tst_qetpalette::invertLightnessSpeed()
|
||||
{
|
||||
QImage image(3840, 2000, QImage::Format_RGB32);
|
||||
image.fill(Qt::white);
|
||||
QBENCHMARK {
|
||||
QET::Palette::invertLightness(image, QColor(30, 30, 30), QColor(220, 220, 220));
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
// Widgets are painted for real, on Qt's offscreen platform so the test
|
||||
|
||||
Reference in New Issue
Block a user