Compare commits

...

9 Commits

Author SHA1 Message Date
Laurent Trinques 3394c1246c Merge pull request #954 from jp2images/feature-dark-canvas
Linux build and tests / Build and test (Qt 6, Debug) (push) Failing after 2m12s
Draw the folio with inverted lightness on a dark palette
2026-09-20 06:44:09 +02:00
Jeff Patterson 3e34a6d55f Fill the dark canvas buffer and keep one scene connection
QGraphicsView::render() paints only what the scene draws, so a scene
without a background brush left the off-screen buffer uninitialized
and the inversion turned that memory into noise. The buffer is now
filled white first, which the inversion turns into the Base color.

listenToScene() connected a new receiver on every setScene() call and
never dropped the previous scene's. It now keeps a single connection
and replaces it.

Test in tst_qetpalette: paletteViewFillsWhatTheSceneLeavesBlank.
2026-09-19 18:27:00 -05:00
Jeff Patterson 50792ba1ad Repaint the whole dark canvas when the application palette changes
After a live light/dark switch only the sheet changed colors; the
viewport around it kept the previous palette. Qt repaints a widget on
an application palette change only when the widget's own palette
changed with it, and under the folio tab widget's style sheet it does
not, so the only repaints came from the scene and covered the scene
rectangle alone. PaletteGraphicsView now watches the application
object for ApplicationPaletteChange, the one receiver Qt always
notifies, and repaints its whole viewport.

The test paletteViewFollowsTheApplicationUnderAStyleSheet now also
requires a full-viewport repaint after each switch, before anything
asks the view for a rendering.
2026-09-19 18:27:00 -05:00
Jeff Patterson 6404612014 Follow the application palette, not the view's, on the dark canvas
The folio tab widget carries a style sheet, and QStyleSheetStyle pins
the palette of every widget under it to the application palette in
force when the sheet was applied. After a live light/dark switch the
view's own palette() is therefore stale: the folio kept its dark sheet
after a switch to light, and kept its white sheet after a switch to
dark. PaletteGraphicsView now reads the application palette both for
the decision to invert and for the sheet and ink colors.

Test in tst_qetpalette: paletteViewFollowsTheApplicationUnderAStyleSheet
puts the view in a tab widget with a style sheet and switches the
application palette to dark and back.
2026-09-19 18:27:00 -05:00
Jeff Patterson eaf15faaa3 Move the dark canvas into PaletteGraphicsView and test it directly
The inverted painting, the rubber band replay and the changed()
receiver lived in DiagramView, which the unit tests cannot link, so the
update-flag regression was only covered through a stand-in view. They
now live in PaletteGraphicsView, a QGraphicsView subclass with no
other dependency, and DiagramView derives from it. The view tells a
subclass through paintingInverted(bool) when it renders for an
inverted display; DiagramView forwards that to the diagram. The grid
dot rule moves out of Diagram::drawBackground into
QET::Palette::gridDotColor().

tst_qetpalette now links the real class: gridDotColorSoftensInvertedDots,
paletteViewFollowsThePalette (light sheet, dark sheet at text contrast
with a red box still red and the paintingInverted calls in order, back
to light), paletteViewKeepsSceneUpdatesFlowing (three whole-scene
updates and a selection each repaint, scene set after construction),
paletteViewDrawsTheRubberBand.
2026-09-19 18:27:00 -05:00
Jeff Patterson b8c9e670c4 Keep scene updates flowing on the dark canvas and soften its grid
On a dark palette the folio view paints through QGraphicsView::render()
instead of onto its viewport. In that case QGraphicsView never clears
the scene's "update everything" flag, and while the flag is set every
further QGraphicsScene::update() and item update is dropped: from the
second Diagram::update() on, the grid toggle, the white/gray toggle and
even a selection waited for an unrelated repaint. With a receiver on
QGraphicsScene::changed() the scene clears the flag before it emits, so
DiagramView now connects an empty receiver in its constructor.

While the view paints for inversion, Diagram draws the grid dots a
third of the way from the sheet color to black, so they come out as a
soft gray on the dark sheet instead of as bright as the ink. Printing
and export never take that path.

Test in tst_qetpalette: sceneUpdatesReachARenderedView.
2026-09-19 18:26:45 -05:00
Jeff Patterson 85dc638a42 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.
2026-09-19 18:26:45 -05:00
Laurent Trinques dfe56cddbd Merge pull request #928 from ispyisail/fix/903-escape-cancels-text-tool
Let Escape cancel the text tool, like every other placement (#903)
2026-09-19 23:43:31 +02:00
ispyisail 3c1fb2fe72 Let Escape cancel the text tool, like every other placement (#903)
Issue #903 reported that Escape stopped cancelling an in-progress
placement. PR #899 fixed that by letting Escape through to the active
tool whenever Diagram::eventInterfaceIsRunning(), and 863ac5f0a tightened
it to isRunning() so a second Escape cannot retrigger an abort already in
flight. That covers seven of the eight tools. It cannot cover the eighth.

eventInterfaceIsRunning() is m_event_interface && isRunning(), and
isRunning() returns m_running. DiagramEventAddText is the only class
under sources/diagramevent/ that never sets m_running, so the guard reads
false for the whole time the tool is armed and DiagramView::keyPressEvent
keeps swallowing Escape for its own selection/focus handling.

It is also the only one of the eight with no RightButton branch --
right-clicking the folio with the text tool armed opens the folio context
menu. So this tool currently has no way to cancel at all: the only way
out is to pick a different tool, and any stray click drops a text field
the user did not want.

The tool is armed from the moment it is attached, so m_running is set in
the constructor and cleared where the text is placed, before finish().

Measured on a virtual display against a fixture holding one free text
field, counting diagram-level text fields in the saved project:

  arm the text tool, click            2 fields   places, as it should
  arm it, press Escape, then click    2 fields   before  -- not cancelled
                                      1 field    after   -- cancelled

The same run on master's rectangle tool cancels correctly, which is what
made the text tool look fixed when it was not.

ctest 12/12, Qt 6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:32:58 +12:00
12 changed files with 758 additions and 7 deletions
+2
View File
@@ -248,6 +248,8 @@ set(QET_SRC_FILES
${QET_DIR}/sources/qet.h ${QET_DIR}/sources/qet.h
${QET_DIR}/sources/qeticons.cpp ${QET_DIR}/sources/qeticons.cpp
${QET_DIR}/sources/qeticons.h ${QET_DIR}/sources/qeticons.h
${QET_DIR}/sources/palettegraphicsview.cpp
${QET_DIR}/sources/palettegraphicsview.h
${QET_DIR}/sources/qetpalette.cpp ${QET_DIR}/sources/qetpalette.cpp
${QET_DIR}/sources/qetpalette.h ${QET_DIR}/sources/qetpalette.h
${QET_DIR}/sources/qetstyle.cpp ${QET_DIR}/sources/qetstyle.cpp
+4 -2
View File
@@ -26,6 +26,7 @@
#include "diagramposition.h" #include "diagramposition.h"
#include "factory/elementfactory.h" #include "factory/elementfactory.h"
#include "qetapp.h" #include "qetapp.h"
#include "qetpalette.h"
#include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h" #include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h"
#include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/conductortextitem.h" #include "qetgraphicsitem/conductortextitem.h"
@@ -283,10 +284,11 @@ void Diagram::drawBackground(QPainter *p, const QRectF &r) {
* if background color is black, * if background color is black,
* then grid spots shall be white, * then grid spots shall be white,
* else they shall be black in color. * else they shall be black in color.
* A view that shows the sheet with its lightness inverted
* gets softer dots, see QET::Palette::gridDotColor.
*/ */
QPen pen; QPen pen;
Diagram::background_color == Qt::black? pen.setColor(Qt::white) pen.setColor(QET::Palette::gridDotColor(Diagram::background_color, m_inverted_lightness));
: pen.setColor(Qt::black);
pen.setCosmetic(true); pen.setCosmetic(true);
p->setPen(pen); p->setPen(pen);
+14
View File
@@ -123,6 +123,7 @@ class Diagram : public QGraphicsScene
qreal diagram_qet_version_; qreal diagram_qet_version_;
bool draw_grid_; bool draw_grid_;
bool m_inverted_lightness = false;
bool use_border_; bool use_border_;
bool draw_guides_; bool draw_guides_;
QList<Diagram::Guide> m_guides_list; QList<Diagram::Guide> m_guides_list;
@@ -222,6 +223,7 @@ class Diagram : public QGraphicsScene
ExportProperties applyProperties(const ExportProperties &); ExportProperties applyProperties(const ExportProperties &);
void setDisplayGrid(bool); void setDisplayGrid(bool);
bool displayGrid(); bool displayGrid();
void setInvertedLightness(bool);
void setDisplayGuides(bool); void setDisplayGuides(bool);
bool displayGuides(); bool displayGuides();
void updateProjectGuides(const QList<GuideProperties> &guides); void updateProjectGuides(const QList<GuideProperties> &guides);
@@ -355,6 +357,18 @@ inline void Diagram::setDisplayGrid(bool dg) {
draw_grid_ = dg; draw_grid_ = dg;
} }
/**
@brief Diagram::setInvertedLightness
Tell the diagram whether the view painting it will show the result
with its lightness inverted (PaletteGraphicsView on a dark palette).
drawBackground draws a softer grid in that case. Printing
and export never set this.
@param inverted
*/
inline void Diagram::setInvertedLightness(bool inverted) {
m_inverted_lightness = inverted;
}
/** /**
@brief Diagram::displayGrid @brief Diagram::displayGrid
@return draw_grid_ true if the grid is drawn, false otherwise. @return draw_grid_ true if the grid is drawn, false otherwise.
+10 -1
View File
@@ -29,7 +29,14 @@
*/ */
DiagramEventAddText::DiagramEventAddText(Diagram *diagram) : DiagramEventAddText::DiagramEventAddText(Diagram *diagram) :
DiagramEventInterface(diagram) DiagramEventInterface(diagram)
{} {
//The tool is armed from the moment it is attached: the next left
//click places a text. DiagramView::keyPressEvent() asks
//Diagram::eventInterfaceIsRunning() -- which is isRunning(), i.e.
//m_running -- before letting Escape through to the tool, so without
//this the view swallows Escape and the tool cannot be cancelled.
m_running = true;
}
/** /**
@brief DiagramEventAddText::~DiagramEventAddText @brief DiagramEventAddText::~DiagramEventAddText
@@ -52,6 +59,8 @@ void DiagramEventAddText::mousePressEvent(QGraphicsSceneMouseEvent *event)
event->scenePos())); event->scenePos()));
text->setTextInteractionFlags(Qt::TextEditorInteraction); text->setTextInteractionFlags(Qt::TextEditorInteraction);
text->setFocus(Qt::MouseFocusReason); text->setFocus(Qt::MouseFocusReason);
//Placed: the tool is done before it announces it.
m_running = false;
emit finish(); emit finish();
event->setAccepted(true); event->setAccepted(true);
} }
+14 -2
View File
@@ -39,7 +39,9 @@
#include "ElementsCollection/xmlelementcollection.h" #include "ElementsCollection/xmlelementcollection.h"
#include "NameList/nameslist.h" #include "NameList/nameslist.h"
#include "elementdialog.h" #include "elementdialog.h"
#include <QApplication>
#include <QDropEvent> #include <QDropEvent>
#include <QPainter>
#include <QPointer> #include <QPointer>
/** /**
@@ -48,7 +50,7 @@
@param parent Le QWidget parent de cette vue de schema @param parent Le QWidget parent de cette vue de schema
*/ */
DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
QGraphicsView (parent), PaletteGraphicsView (parent),
m_diagram (diagram) m_diagram (diagram)
{ {
grabGesture(Qt::PinchGesture); grabGesture(Qt::PinchGesture);
@@ -1081,6 +1083,16 @@ bool DiagramView::event(QEvent *e) {
return(QGraphicsView::event(e)); return(QGraphicsView::event(e));
} }
/**
@brief DiagramView::paintingInverted
Reimplemented from PaletteGraphicsView: tell the diagram it is being
drawn for an inverted display, so it softens its grid.
*/
void DiagramView::paintingInverted(bool inverted)
{
m_diagram->setInvertedLightness(inverted);
}
/** /**
@brief DiagramView::paintEvent @brief DiagramView::paintEvent
Reimplemented from QGraphicsView Reimplemented from QGraphicsView
@@ -1088,7 +1100,7 @@ bool DiagramView::event(QEvent *e) {
*/ */
void DiagramView::paintEvent(QPaintEvent *event) void DiagramView::paintEvent(QPaintEvent *event)
{ {
QGraphicsView::paintEvent(event); PaletteGraphicsView::paintEvent(event);
if (m_free_rubberbanding && m_free_rubberband.count() >= 3) if (m_free_rubberbanding && m_free_rubberband.count() >= 3)
{ {
+3 -2
View File
@@ -22,7 +22,7 @@
#include "titleblock/templatelocation.h" #include "titleblock/templatelocation.h"
#include <QClipboard> #include <QClipboard>
#include <QGraphicsView> #include "palettegraphicsview.h"
class Conductor; class Conductor;
class Diagram; class Diagram;
@@ -35,7 +35,7 @@ class QGestureEvent;
This class provides a widget to render an electric diagram in an editable, This class provides a widget to render an electric diagram in an editable,
interactive way. interactive way.
*/ */
class DiagramView : public QGraphicsView class DiagramView : public PaletteGraphicsView
{ {
Q_OBJECT Q_OBJECT
@@ -84,6 +84,7 @@ class DiagramView : public QGraphicsView
///Set for one call only, by the Escape handler, to let focus leave the view. ///Set for one call only, by the Escape handler, to let focus leave the view.
bool m_releasing_focus = false; bool m_releasing_focus = false;
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;
void paintingInverted(bool inverted) override;
void mousePressEvent(QMouseEvent *) override; void mousePressEvent(QMouseEvent *) override;
void mouseMoveEvent(QMouseEvent *) override; void mouseMoveEvent(QMouseEvent *) override;
void mouseReleaseEvent(QMouseEvent *) override; void mouseReleaseEvent(QMouseEvent *) override;
+182
View File
@@ -0,0 +1,182 @@
/*
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 "palettegraphicsview.h"
#include "qetpalette.h"
#include <QApplication>
#include <QEvent>
#include <QPainter>
#include <QPaintEvent>
#include <QStyleHintReturnMask>
#include <QStyleOptionRubberBand>
#include <QtMath>
PaletteGraphicsView::PaletteGraphicsView(QWidget *parent) :
QGraphicsView(parent)
{
qApp->installEventFilter(this);
}
PaletteGraphicsView::PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent) :
QGraphicsView(scene, parent)
{
qApp->installEventFilter(this);
listenToScene(scene);
}
/**
@brief PaletteGraphicsView::setScene
Same as QGraphicsView::setScene, and keeps the scene's updates flowing
(see the class description).
*/
void PaletteGraphicsView::setScene(QGraphicsScene *scene)
{
QGraphicsView::setScene(scene);
listenToScene(scene);
}
/**
@brief PaletteGraphicsView::invertsLightness
@return true when the scene is shown with its lightness inverted,
i.e. when the application palette is dark. The application palette,
not the view's own: a style sheet on an ancestor (the folio tab
widget has one) makes QStyleSheetStyle pin the palette of every
widget under it to the application palette in force when the sheet
was applied, so after a live light/dark switch palette() is stale.
*/
bool PaletteGraphicsView::invertsLightness() const
{
return QET::Palette::isDark(QApplication::palette());
}
void PaletteGraphicsView::paintingInverted(bool inverted)
{
Q_UNUSED(inverted)
}
/**
@brief PaletteGraphicsView::listenToScene
Connect a receiver to the scene's changed() signal, dropping the one
on the previous scene. Any receiver does; this one has nothing to do.
The connection dies with the view.
*/
void PaletteGraphicsView::listenToScene(QGraphicsScene *scene)
{
disconnect(m_scene_connection);
m_scene_connection = QMetaObject::Connection();
if (scene)
m_scene_connection = connect(scene, &QGraphicsScene::changed,
this, [](const QList<QRectF> &) {});
}
/**
@brief PaletteGraphicsView::eventFilter
Repaint the whole viewport when the application palette changes. Qt
sends that change to the application object and then repaints only
the widgets whose own palette changed with it, which under a style
sheet is not the case (see invertsLightness()): the scene would then
repaint only what it updates itself, and the viewport around the
sheet would keep the colors of the previous palette. The filter sits
on the application object, the one receiver Qt always notifies.
*/
bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event)
{
if (watched == qApp && event->type() == QEvent::ApplicationPaletteChange)
viewport()->update();
return QGraphicsView::eventFilter(watched, event);
}
/**
@brief PaletteGraphicsView::paintEvent
Paints as QGraphicsView on a light palette, inverted on a dark one.
*/
void PaletteGraphicsView::paintEvent(QPaintEvent *event)
{
if (invertsLightness())
paintInverted(event->rect());
else
QGraphicsView::paintEvent(event);
}
/**
@brief PaletteGraphicsView::paintInverted
Render \a area of the viewport into an off-screen image, invert the
lightness of that image between the palette's Base and Text colors and
blit it to the viewport. Inverting the finished rendering turns the
white sheet dark and the black ink light in one pass, and keeps the
hue of colored strokes.
@param area the part of the viewport to repaint, in viewport coordinates
*/
void PaletteGraphicsView::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);
// render() paints only what the scene draws; what it leaves blank is
// the white sheet, which the inversion turns into the Base color.
buffer.fill(Qt::white);
QPainter buffer_painter(&buffer);
buffer_painter.setRenderHints(renderHints());
paintingInverted(true);
render(&buffer_painter, QRectF(QPointF(0, 0), QSizeF(rect.size())),
rect, Qt::IgnoreAspectRatio);
paintingInverted(false);
buffer_painter.end();
// The application palette, for the reason given in invertsLightness().
const QPalette application_palette = QApplication::palette();
QET::Palette::invertLightness(buffer, application_palette.color(QPalette::Base),
application_palette.color(QPalette::Text));
QPainter painter(viewport());
painter.drawImage(rect.topLeft(), buffer);
drawRubberBand(painter);
}
/**
@brief PaletteGraphicsView::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, in the palette colors.
@param painter a painter on the viewport
*/
void PaletteGraphicsView::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());
}
+73
View File
@@ -0,0 +1,73 @@
/*
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 PALETTE_GRAPHICS_VIEW_H
#define PALETTE_GRAPHICS_VIEW_H
#include <QGraphicsView>
class QPainter;
/**
A QGraphicsView that shows its scene with inverted lightness while its
palette is dark: white becomes the palette's Base color, black its Text
color, and colored strokes keep their hue. The scene itself is left as
drawn, so printing and exporting it still give black on white. On a
light palette the view paints exactly as QGraphicsView does.
The view paints through QGraphicsView::render() into an image and blits
the inverted image. QGraphicsView delivers scene updates straight to
its viewport when nobody listens to QGraphicsScene::changed(), and in
that mode the scene clears its "update everything" flag only when the
items are painted straight onto the viewport, which never happens
here: from the second QGraphicsScene::update() on, every scene update
and every item update would wait for an unrelated repaint. So the view
listens to changed() on every scene it is given, which makes the scene
clear the flag before it emits. Set the scene through this class, not
through a QGraphicsView pointer.
*/
class PaletteGraphicsView : public QGraphicsView
{
Q_OBJECT
public:
explicit PaletteGraphicsView(QWidget *parent = nullptr);
explicit PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent = nullptr);
void setScene(QGraphicsScene *scene);
bool invertsLightness() const;
protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
/**
Called with true right before the scene is rendered for an
inverted display and with false right after, so a scene can
adapt what it draws (a softer grid, for instance). Does
nothing by default.
*/
virtual void paintingInverted(bool inverted);
private:
void listenToScene(QGraphicsScene *scene);
void paintInverted(const QRect &area);
void drawRubberBand(QPainter &painter);
QMetaObject::Connection m_scene_connection;
};
#endif
+53
View File
@@ -18,7 +18,9 @@
#include "qetpalette.h" #include "qetpalette.h"
#include <QColor> #include <QColor>
#include <QImage>
#include <QStyle> #include <QStyle>
#include <cmath> #include <cmath>
namespace { namespace {
@@ -75,6 +77,57 @@ bool QET::Palette::isDark(const QPalette &palette)
return palette.color(QPalette::Active, QPalette::Window).lightness() < 128; 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]);
}
}
}
QColor QET::Palette::gridDotColor(const QColor &sheet, bool inverted)
{
if (sheet == QColor(Qt::black))
return Qt::white;
if (inverted)
return QColor(sheet.red() * 2 / 3, sheet.green() * 2 / 3, sheet.blue() * 2 / 3);
return Qt::black;
}
double QET::Palette::contrastRatio(const QColor &a, const QColor &b) double QET::Palette::contrastRatio(const QColor &a, const QColor &b)
{ {
double lighter = relativeLuminance(a); double lighter = relativeLuminance(a);
+23
View File
@@ -22,6 +22,7 @@
#include <QPalette> #include <QPalette>
#include <QPixmap> #include <QPixmap>
class QImage;
class QStyle; class QStyle;
/** /**
@@ -54,6 +55,28 @@ namespace QET {
*/ */
bool isDark(const QPalette &palette); 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);
/**
The color of the grid dots on a sheet of color \a sheet: black,
or white on a black sheet. With \a inverted the sheet is about to
be shown with its lightness inverted (PaletteGraphicsView), where
black dots would come out as bright as the ink; the dots are then
a third of the way from the sheet color to black, which shows as
a soft gray.
*/
QColor gridDotColor(const QColor &sheet, bool inverted);
/** /**
WCAG 2 contrast ratio between two opaque colors, from 1 (equal) WCAG 2 contrast ratio between two opaque colors, from 1 (equal)
to 21 (black on white). Normal text needs at least 4.5, large to 21 (black on white). Normal text needs at least 4.5, large
+2
View File
@@ -95,6 +95,8 @@ add_executable(
tst_qetpalette tst_qetpalette
tst_qetpalette.cpp tst_qetpalette.cpp
inkcontrast.h inkcontrast.h
${QET_DIR}/sources/palettegraphicsview.cpp
${QET_DIR}/sources/palettegraphicsview.h
${QET_DIR}/sources/qetpalette.cpp ${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.cpp
+378
View File
@@ -19,6 +19,9 @@
#include <QApplication> #include <QApplication>
#include <QCheckBox> #include <QCheckBox>
#include <QComboBox> #include <QComboBox>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
@@ -29,10 +32,12 @@
#include <QStandardItemModel> #include <QStandardItemModel>
#include <QTreeView> #include <QTreeView>
#include <QStyleFactory> #include <QStyleFactory>
#include <QTabWidget>
#include <QToolBar> #include <QToolBar>
#include "inkcontrast.h" #include "inkcontrast.h"
#include "ElementsCollection/elementpreviewdelegate.h" #include "ElementsCollection/elementpreviewdelegate.h"
#include "palettegraphicsview.h"
#include "qetpalette.h" #include "qetpalette.h"
using QET::Palette::contrastRatio; using QET::Palette::contrastRatio;
@@ -70,6 +75,15 @@ class tst_qetpalette : public QObject
void invertedLightnessKeepsHueAndAlpha(); void invertedLightnessKeepsHueAndAlpha();
void elementPreviewReadsOnBothPalettes(); void elementPreviewReadsOnBothPalettes();
void previewDelegateAdaptsLineArtOnly(); void previewDelegateAdaptsLineArtOnly();
void invertLightnessMapsSheetAndInk();
void invertedViewReadsOnDarkSheet();
void invertLightnessSpeed();
void gridDotColorSoftensInvertedDots();
void paletteViewFollowsThePalette();
void paletteViewKeepsSceneUpdatesFlowing();
void paletteViewDrawsTheRubberBand();
void paletteViewFollowsTheApplicationUnderAStyleSheet();
void paletteViewFillsWhatTheSceneLeavesBlank();
private: private:
static void addPaletteRows(); static void addPaletteRows();
@@ -437,6 +451,370 @@ void tst_qetpalette::previewDelegateAdaptsLineArtOnly()
QVERIFY2(blue, "the colored icon lost its color"); 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())));
}
namespace {
/**
A PaletteGraphicsView that counts its paints and records the
paintingInverted() calls it receives.
*/
class ProbeView : public PaletteGraphicsView
{
public:
int paints = 0;
QList<QRect> paint_rects;
QList<bool> inverted_calls;
using PaletteGraphicsView::PaletteGraphicsView;
/// True when a paint since index \a from covered the whole viewport.
bool fullyRepaintedSince(int from) const
{
for (int i = from; i < paint_rects.size(); ++i)
if (paint_rects.at(i).contains(viewport()->rect()))
return true;
return false;
}
protected:
void paintEvent(QPaintEvent *event) override
{
++paints;
paint_rects << event->rect();
PaletteGraphicsView::paintEvent(event);
}
void paintingInverted(bool inverted) override
{
inverted_calls << inverted;
}
};
/**
A small folio: a white sheet with a black line and a red box.
Returns the box, which is selectable.
*/
QGraphicsRectItem *fillSheet(QGraphicsScene &scene)
{
scene.setSceneRect(0, 0, 200, 120);
scene.setBackgroundBrush(Qt::white);
scene.addLine(10, 60, 190, 60, QPen(Qt::black, 2));
QGraphicsRectItem *box = scene.addRect(20, 20, 40, 20, QPen(Qt::NoPen), QBrush(QColor(200, 0, 0)));
box->setFlag(QGraphicsItem::ItemIsSelectable);
return box;
}
/// The view sized to its scene, without frame or scroll bars.
void showAsSheet(QGraphicsView &view)
{
view.setFrameShape(QFrame::NoFrame);
view.setAlignment(Qt::AlignLeft | Qt::AlignTop);
view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
view.resize(200, 120);
view.show();
}
/// The most frequent color of an image: the sheet.
QColor sheetColor(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;
for (auto it = histogram.cbegin(); it != histogram.cend(); ++it)
if (it.value() > histogram.value(best)) best = it.key();
return QColor(best);
}
}
/**
Grid dots are black, white on a black sheet, and a third of the way
from the sheet color to black when the sheet is about to be shown
inverted, so that they do not come out as bright as the ink.
*/
void tst_qetpalette::gridDotColorSoftensInvertedDots()
{
QCOMPARE(QET::Palette::gridDotColor(Qt::white, false), QColor(Qt::black));
QCOMPARE(QET::Palette::gridDotColor(Qt::white, true), QColor(170, 170, 170));
QCOMPARE(QET::Palette::gridDotColor(Qt::darkGray, true), QColor(85, 85, 85));
QCOMPARE(QET::Palette::gridDotColor(Qt::black, false), QColor(Qt::white));
QCOMPARE(QET::Palette::gridDotColor(Qt::black, true), QColor(Qt::white));
}
/**
On a light palette the view shows the sheet as drawn and never tells
the scene it inverts. On a dark palette, set while the view is
showing, the sheet comes out as Base, the black line at text contrast,
the red box still red, and the scene hears paintingInverted(true)
before and (false) after. Back on a light palette the sheet is white
again.
*/
void tst_qetpalette::paletteViewFollowsThePalette()
{
QApplication::setStyle(QStyleFactory::create("Fusion"));
QApplication::setPalette(QET::Palette::fusionLight());
QGraphicsScene scene;
fillSheet(scene);
ProbeView view(&scene);
showAsSheet(view);
QVERIFY(QTest::qWaitForWindowExposed(&view));
const QImage light = view.viewport()->grab().toImage();
QCOMPARE(sheetColor(light), QColor(Qt::white));
QVERIFY(view.inverted_calls.isEmpty());
QApplication::setPalette(QET::Palette::fusionDark());
QTRY_VERIFY(view.invertsLightness());
const QImage dark = view.viewport()->grab().toImage();
const QColor base = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Base);
const QColor text = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Text);
QCOMPARE(sheetColor(dark), base);
const double contrast = inkContrast(dark, dark.rect());
QVERIFY2(contrast >= QET::Palette::contrastRatio(base, text) - 0.5,
qPrintable(QString("ink reads %1:1 on the dark sheet").arg(contrast)));
const QColor box = dark.pixelColor(40, 30);
QVERIFY2(box.hslHue() == 0 && box.hslSaturationF() > 0.3 && box.red() > box.blue() + 60,
qPrintable(QString("the red box became %1").arg(box.name())));
QVERIFY(!view.inverted_calls.isEmpty());
QCOMPARE(view.inverted_calls.first(), true);
QCOMPARE(view.inverted_calls.last(), false);
QCOMPARE(view.inverted_calls.count(true), view.inverted_calls.count(false));
QApplication::setPalette(QET::Palette::fusionLight());
QTRY_VERIFY(!view.invertsLightness());
QCOMPARE(sheetColor(view.viewport()->grab().toImage()), QColor(Qt::white));
}
/**
QGraphicsView clears the scene's "update everything" flag only when it
paints the items straight onto its viewport, and while the flag is set
every further QGraphicsScene::update() and item update is dropped. On
a dark palette the view paints through render() instead, so it listens
to the scene's changed() signal, which makes the scene clear the flag
before it emits. Three whole-scene updates and a selection must each
repaint the view, with the scene set after construction as
DiagramView does it.
*/
void tst_qetpalette::paletteViewKeepsSceneUpdatesFlowing()
{
QApplication::setStyle(QStyleFactory::create("Fusion"));
QApplication::setPalette(QET::Palette::fusionDark());
QGraphicsScene scene;
QGraphicsRectItem *box = fillSheet(scene);
ProbeView view;
view.setScene(&scene);
showAsSheet(view);
QVERIFY(QTest::qWaitForWindowExposed(&view));
QTRY_VERIFY(view.paints >= 1);
QVERIFY(view.invertsLightness());
for (int round = 1; round <= 3; ++round)
{
const int before = view.paints;
scene.update();
QTRY_VERIFY2(view.paints > before, qPrintable(QString("scene update %1 was dropped").arg(round)));
}
const int before = view.paints;
box->setSelected(true);
QTRY_VERIFY2(view.paints > before, "the selection change was dropped");
}
/**
render() skips Qt's selection rubber band, so the view draws it after
the inversion: while a drag on the sheet is in progress, the dragged
area no longer shows the bare sheet.
*/
void tst_qetpalette::paletteViewDrawsTheRubberBand()
{
QApplication::setStyle(QStyleFactory::create("Fusion"));
QApplication::setPalette(QET::Palette::fusionDark());
QGraphicsScene scene;
fillSheet(scene);
ProbeView view(&scene);
view.setDragMode(QGraphicsView::RubberBandDrag);
showAsSheet(view);
QVERIFY(QTest::qWaitForWindowExposed(&view));
const QColor base = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Base);
QCOMPARE(view.viewport()->grab().toImage().pixelColor(170, 100), base);
QTest::mousePress(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(120, 80));
QTest::mouseMove(view.viewport(), QPoint(190, 115));
QTRY_VERIFY(!view.rubberBandRect().isNull());
const QColor inside = view.viewport()->grab().toImage().pixelColor(170, 100);
QVERIFY2(inside != base, qPrintable(QString("no rubber band drawn, pixel is %1").arg(inside.name())));
QTest::mouseRelease(view.viewport(), Qt::LeftButton, Qt::NoModifier, QPoint(190, 115));
}
/**
A style sheet on an ancestor pins the palette of every widget under it
to the application palette in force when the sheet was applied
(QStyleSheetStyle keeps its own copy; the folio tab widget carries
such a sheet). The view's own palette() is therefore stale after a
live switch, and the view must follow the application palette
instead: inside a tab widget with a style sheet, switching the
application to dark and back still changes the sheet. Qt also skips
the repaint of a widget whose palette did not change, so the view
has to repaint its whole viewport by itself on each switch, without
anyone asking for a rendering: otherwise the area around the sheet
keeps the old colors.
*/
void tst_qetpalette::paletteViewFollowsTheApplicationUnderAStyleSheet()
{
QApplication::setStyle(QStyleFactory::create("Fusion"));
QApplication::setPalette(QET::Palette::fusionLight());
QGraphicsScene scene;
fillSheet(scene);
QTabWidget tabs;
tabs.setStyleSheet("QTabBar::scroller {width: 0px;}"); // as sources/projectview.cpp
auto *view = new ProbeView(&scene);
view->setFrameShape(QFrame::NoFrame);
view->setAlignment(Qt::AlignLeft | Qt::AlignTop);
tabs.addTab(view, "folio");
tabs.resize(320, 240);
tabs.show();
QVERIFY(QTest::qWaitForWindowExposed(&tabs));
QCOMPARE(sheetColor(view->viewport()->grab().toImage()), QColor(Qt::white));
int since = view->paint_rects.size();
QApplication::setPalette(QET::Palette::fusionDark());
QTRY_VERIFY(view->invertsLightness());
QTRY_VERIFY2(view->fullyRepaintedSince(since), "no full repaint after the switch to dark");
const QColor base = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Base);
QCOMPARE(sheetColor(view->viewport()->grab().toImage()), base);
since = view->paint_rects.size();
QApplication::setPalette(QET::Palette::fusionLight());
QTRY_VERIFY(!view->invertsLightness());
QTRY_VERIFY2(view->fullyRepaintedSince(since), "no full repaint after the switch to light");
QCOMPARE(sheetColor(view->viewport()->grab().toImage()), QColor(Qt::white));
}
/**
render() paints only what the scene draws. A scene without a
background brush leaves the rest of the buffer untouched, so the view
fills it first: on the dark palette the blank area comes out as Base,
not as whatever the memory held.
*/
void tst_qetpalette::paletteViewFillsWhatTheSceneLeavesBlank()
{
QApplication::setStyle(QStyleFactory::create("Fusion"));
QApplication::setPalette(QET::Palette::fusionDark());
QGraphicsScene scene(0, 0, 200, 120);
scene.addLine(10, 60, 190, 60, QPen(Qt::black, 2));
ProbeView view(&scene);
showAsSheet(view);
QVERIFY(QTest::qWaitForWindowExposed(&view));
const QImage image = view.viewport()->grab().toImage();
const QColor base = QET::Palette::fusionDark().color(QPalette::Active, QPalette::Base);
QCOMPARE(sheetColor(image), base);
QCOMPARE(image.pixelColor(100, 20), base);
QCOMPARE(image.pixelColor(100, 100), base);
}
/**
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) int main(int argc, char **argv)
{ {
// Widgets are painted for real, on Qt's offscreen platform so the test // Widgets are painted for real, on Qt's offscreen platform so the test