From 8ff5fbac0ca9777b4a5db1dfb6a3dde707605e8c Mon Sep 17 00:00:00 2001 From: saschbe Date: Tue, 6 Oct 2026 21:09:41 +0200 Subject: [PATCH] Preserve imported image colours by default in dark mode --- sources/darkimagerendering.h | 42 ++++++ sources/palettegraphicsview.cpp | 53 ++++++- sources/palettegraphicsview.h | 4 + sources/qetgraphicsitem/diagramimageitem.cpp | 13 +- sources/qetgraphicsitem/diagramimageitem.h | 5 + sources/qetpalette.cpp | 13 +- sources/qetpalette.h | 3 + sources/ui/imagepropertieswidget.cpp | 53 ++++++- sources/ui/imagepropertieswidget.h | 5 + sources/ui/imagepropertieswidget.ui | 12 +- tests/qttest/CMakeLists.txt | 16 +++ tests/qttest/image_dark_integration_probe.cpp | 124 +++++++++++++++++ tests/qttest/run_image_dark_probe.py | 96 +++++++++++++ tests/qttest/tst_darkimagelayers.cpp | 129 ++++++++++++++++++ 14 files changed, 553 insertions(+), 15 deletions(-) create mode 100644 sources/darkimagerendering.h create mode 100644 tests/qttest/image_dark_integration_probe.cpp create mode 100644 tests/qttest/run_image_dark_probe.py create mode 100644 tests/qttest/tst_darkimagelayers.cpp diff --git a/sources/darkimagerendering.h b/sources/darkimagerendering.h new file mode 100644 index 000000000..ad7c25206 --- /dev/null +++ b/sources/darkimagerendering.h @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef DARK_IMAGE_RENDERING_H +#define DARK_IMAGE_RENDERING_H + +#include +#include +#include + +namespace DarkImageRendering { +// View-local raster composition boundary. Qt still paints the scene in +// its native order, with its own transforms, clipping and opacity. +// A different painter (printing, export, another view or an item cache) +// cannot consume this display-only context. +struct Context { + QPainter *painter; + bool invertScene; + std::function flush; +}; +inline thread_local Context *current = nullptr; +class Scope { + Context *previous; +public: + explicit Scope(Context *context) : previous(current) { current = context; } + ~Scope() { current = previous; } + Scope(const Scope &) = delete; + Scope &operator=(const Scope &) = delete; +}; +inline void paintPixmap(QPainter *painter, const QPixmap &pixmap, bool adapt) +{ + Context *context = current; + const bool boundary = context && context->painter == painter && adapt != context->invertScene; + if (boundary) context->flush(context->invertScene, QRect()); + painter->drawPixmap(pixmap.rect(), pixmap); + if (boundary) { + // Only the raster was drawn since the preceding flush. Avoid a + // whole-viewport copy for a small logo; include filtering/AA edges. + const QRect area = painter->worldTransform().mapRect(QRectF(pixmap.rect())).toAlignedRect().adjusted(-2,-2,2,2); + context->flush(adapt, area); + } +} +} +#endif diff --git a/sources/palettegraphicsview.cpp b/sources/palettegraphicsview.cpp index ab9b322db..77c7ddeda 100644 --- a/sources/palettegraphicsview.cpp +++ b/sources/palettegraphicsview.cpp @@ -18,6 +18,7 @@ #include "palettegraphicsview.h" #include "qetpalette.h" +#include "darkimagerendering.h" #include #include @@ -99,12 +100,13 @@ bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event) */ void PaletteGraphicsView::paintEvent(QPaintEvent *event) { - if (invertsLightness() && !customBackgroundColor()) + if (invertsLightness()) { paintInverted(event); return; } m_buffer = QImage(); + m_composite = QImage(); QGraphicsView::paintEvent(event); } @@ -130,7 +132,7 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) const QSize size(qCeil(viewport()->width() * ratio), qCeil(viewport()->height() * ratio)); if (m_buffer.size() != size || m_buffer.devicePixelRatio() != ratio) { - m_buffer = QImage(size, QImage::Format_RGB32); + m_buffer = QImage(size, QImage::Format_ARGB32_Premultiplied); m_buffer.setDevicePixelRatio(ratio); } @@ -141,9 +143,13 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) m_buffer_painter.setClipRect(exposed); m_buffer_painter.setRenderHints(renderHints()); m_buffer_painter.setWorldTransform(viewportTransform()); + m_exposed = exposed; + m_layered = false; + DarkImageRendering::Context context{&m_buffer_painter, !customBackgroundColor(), [this](bool invert, const QRect &bounds) { flushLayer(invert, bounds); }}; + DarkImageRendering::Scope rasterScope(&context); m_inverting = true; - paintingInverted(true); + paintingInverted(!customBackgroundColor()); const OptimizationFlags flags = optimizationFlags(); setOptimizationFlag(QGraphicsView::IndirectPainting, true); QGraphicsView::paintEvent(event); @@ -151,10 +157,44 @@ void PaletteGraphicsView::paintInverted(QPaintEvent *event) paintingInverted(false); m_inverting = false; + if (m_layered) flushLayer(!customBackgroundColor()); m_buffer_painter.end(); blitInverted(exposed); } +void PaletteGraphicsView::flushLayer(bool invert, const QRect &bounds) +{ + const QRect area = bounds.isNull() ? m_exposed : bounds.intersected(m_exposed); + if (area.isEmpty()) return; + const qreal ratio = m_buffer.devicePixelRatio(); + const QRect pixels = QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect(); + QImage layer = m_buffer.copy(pixels); + layer.setDevicePixelRatio(ratio); + if (invert) { + const QPalette palette = QApplication::palette(); + QET::Palette::invertLightnessLayer(layer, palette.color(QPalette::Base), palette.color(QPalette::Text)); + } + if (!m_layered) { + if (m_composite.size() != m_buffer.size() || m_composite.devicePixelRatio() != ratio) { + m_composite = QImage(m_buffer.size(), QImage::Format_ARGB32_Premultiplied); + m_composite.setDevicePixelRatio(ratio); + } + m_composite.fill(Qt::transparent); + m_layered = true; + } + QPainter composite(&m_composite); + composite.drawImage(QPointF(pixels.topLeft()) / ratio, layer); + // Keep Qt's active painter and its saved state stack intact. Replacing + // or restarting it here would lose ancestor clipping and opacity. + m_buffer_painter.save(); + m_buffer_painter.resetTransform(); + m_buffer_painter.setClipping(false); + m_buffer_painter.setOpacity(1.0); + m_buffer_painter.setCompositionMode(QPainter::CompositionMode_Source); + m_buffer_painter.fillRect(QRectF(QPointF(pixels.topLeft()) / ratio, QSizeF(pixels.size()) / ratio), Qt::transparent); + m_buffer_painter.restore(); +} + /** @brief PaletteGraphicsView::drawBackground Into the off-screen image while painting inverted, else as @@ -199,15 +239,16 @@ void PaletteGraphicsView::drawForeground(QPainter *painter, const QRectF &rect) void PaletteGraphicsView::blitInverted(const QRect &area) { const qreal ratio = m_buffer.devicePixelRatio(); - QImage part = m_buffer.copy(QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect()); + const QRect pixels = QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect(); + QImage part = (m_layered ? m_composite : m_buffer).copy(pixels); part.setDevicePixelRatio(ratio); // The application palette, for the reason given in invertsLightness(). const QPalette application_palette = QApplication::palette(); - QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), + if (!m_layered && !customBackgroundColor()) QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), application_palette.color(QPalette::Text)); QPainter painter(viewport()); - painter.drawImage(area.topLeft(), part); + painter.drawImage(QPointF(pixels.topLeft()) / ratio, part); drawRubberBand(painter); } diff --git a/sources/palettegraphicsview.h b/sources/palettegraphicsview.h index 96d2ace36..36818e51b 100644 --- a/sources/palettegraphicsview.h +++ b/sources/palettegraphicsview.h @@ -90,11 +90,15 @@ class PaletteGraphicsView : public QGraphicsView void paintInverted(QPaintEvent *event); void blitInverted(const QRect &area); void drawRubberBand(QPainter &painter); + void flushLayer(bool invert, const QRect &bounds = QRect()); /// The off-screen image the hooks paint into while m_inverting: /// the viewport's size, in its coordinates. Kept between paints, /// dropped when the view paints on a light palette again. QImage m_buffer; + QImage m_composite; + QRect m_exposed; + bool m_layered = false; QPainter m_buffer_painter; /// True while paintEvent() paints for an inverted display. bool m_inverting = false; diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 00f8b030b..c8ef93ce7 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "diagramimageitem.h" +#include "../darkimagerendering.h" #include "../shownkinds.h" #include "../PropertiesEditor/propertieseditordialog.h" @@ -97,7 +98,7 @@ DiagramImageItem::~DiagramImageItem() @param widget the QWidget where we draw the pixmap */ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { - painter -> drawPixmap(pixmap_.rect(),pixmap_); + DarkImageRendering::paintPixmap(painter, pixmap_, m_adapt_to_dark_theme); Q_UNUSED(option); Q_UNUSED(widget); @@ -155,6 +156,14 @@ void DiagramImageItem::setLabel(const QString &label) emit labelChanged(); } +void DiagramImageItem::setAdaptToDarkTheme(bool adapt) +{ + if (m_adapt_to_dark_theme == adapt) return; + m_adapt_to_dark_theme = adapt; + update(); + emit adaptToDarkThemeChanged(); +} + /** @brief DiagramImageItem::editProperty Open the appropriate dialog to edit this image @@ -1479,6 +1488,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) if (e.tagName() != "image") { return (false); } + setAdaptToDarkTheme(e.attribute("adapt_to_dark_theme", "false") == "true"); const QUuid uuid(e.attribute(QStringLiteral("uuid"))); if (!uuid.isNull() && uuid != m_uuid) setUuid(uuid); @@ -1623,6 +1633,7 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const { QDomElement result = document.createElement("image"); result.setAttribute("uuid", m_uuid.toString()); + if (m_adapt_to_dark_theme) result.setAttribute("adapt_to_dark_theme", "true"); //write some attribute result.setAttribute("x", QString::number(pos().x())); result.setAttribute("y", QString::number(pos().y())); diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index b3263f9b3..1298563a4 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -53,6 +53,7 @@ class DiagramImageItem : public QetGraphicsItem { // colours -- as one value, so that every edit of it (crop, colour // key, mirror, replace) is undone together with the displayed pixmap. Q_PROPERTY(QVariant imageSource READ imageSourceVariant WRITE setImageSourceVariant) + Q_PROPERTY(bool adaptToDarkTheme READ adaptToDarkTheme WRITE setAdaptToDarkTheme NOTIFY adaptToDarkThemeChanged) // A second, deliberately non-compensating property on the SAME // underlying value -- setPivot() (above) intentionally adjusts // pos() to keep the image visually in place, which is exactly @@ -149,12 +150,15 @@ class DiagramImageItem : public QetGraphicsItem { void setPivotRaw(const QPointF &pivot); QString label() const { return m_label; } void setLabel(const QString &label); + bool adaptToDarkTheme() const { return m_adapt_to_dark_theme; } + void setAdaptToDarkTheme(bool adapt); signals: void pixmapChanged(); void transformChanged(); void uuidChanged(); void labelChanged(); + void adaptToDarkThemeChanged(); protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; @@ -258,6 +262,7 @@ class DiagramImageItem : public QetGraphicsItem { // Empty by default, and then neither saved nor painted, so a picture // without one costs exactly what it did before. QString m_label; + bool m_adapt_to_dark_theme = false; QFont m_label_font; QSizeF m_label_size; // in scene units, measured once in setLabel() QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale() diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index e8910a297..c8e661533 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -83,8 +83,14 @@ bool QET::Palette::isDark(const QPalette &palette) void QET::Palette::invertLightness(QImage &image, const QColor &sheet, const QColor &ink) { - if (image.format() != QImage::Format_RGB32) - image.convertTo(QImage::Format_RGB32); + if (image.format() != QImage::Format_RGB32) image.convertTo(QImage::Format_RGB32); + invertLightnessLayer(image, sheet, ink); +} + +void QET::Palette::invertLightnessLayer(QImage &image, const QColor &sheet, const QColor &ink) +{ + if (image.format() != QImage::Format_RGB32 && image.format() != QImage::Format_ARGB32) + image.convertTo(QImage::Format_ARGB32); // One table per channel maps the inverted value (0 = was white, // 255 = was black) onto the sheet..ink span. @@ -106,6 +112,7 @@ void QET::Palette::invertLightness(QImage &image, const QColor &sheet, quint32 *line = reinterpret_cast(image.scanLine(y)); for (int x = 0, width = image.width(); x < width; ++x) { const quint32 pixel = line[x]; + if ((pixel & 0xff000000u) == 0) continue; const int red = (pixel >> 16) & 0xff; const int green = (pixel >> 8) & 0xff; const int blue = pixel & 0xff; @@ -114,7 +121,7 @@ void QET::Palette::invertLightness(QImage &image, const QColor &sheet, if (blue > highest) highest = blue; if (blue < lowest) lowest = blue; const int offset = 255 - highest - lowest; - line[x] = 0xff000000u + line[x] = (pixel & 0xff000000u) | (quint32(red_of[red + offset]) << 16) | (quint32(green_of[green + offset]) << 8) | quint32(blue_of[blue + offset]); diff --git a/sources/qetpalette.h b/sources/qetpalette.h index e1a5cabcd..a60190c0b 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -66,6 +66,9 @@ namespace QET { */ void invertLightness(QImage &image, const QColor &sheet = Qt::black, const QColor &ink = Qt::white); + /// The same colour mapping for a transparent composition layer. + /// Retains alpha; premultiplied input is converted to straight ARGB32. + void invertLightnessLayer(QImage &image, const QColor &sheet, const QColor &ink); /** The color of the grid dots on a sheet of color \a sheet: black, diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp index 3b0931437..3ee2df604 100644 --- a/sources/ui/imagepropertieswidget.cpp +++ b/sources/ui/imagepropertieswidget.cpp @@ -22,6 +22,9 @@ #include "../qetgraphicsitem/diagramimageitem.h" #include "../qeticons.h" #include "../ui_imagepropertieswidget.h" +#include +#include +#include /** @brief ImagePropertiesWidget::ImagePropertiesWidget @@ -53,6 +56,19 @@ ImagePropertiesWidget::~ImagePropertiesWidget() delete ui; } +bool ImagePropertiesWidget::event(QEvent *event) +{ + // The generic properties dialog resets on its Cancel button, but its + // title-bar close and Escape reach reject() directly. Roll back the + // same preview in all rejection paths, without changing other editors. + if (event->type() == QEvent::ParentChange) { + if (auto *dialog = qobject_cast(parentWidget())) { + connect(dialog, &QDialog::rejected, this, &ImagePropertiesWidget::reset, Qt::UniqueConnection); + } + } + return PropertiesEditorWidget::event(event); +} + /** @brief ImagePropertiesWidget::setImageItem Set the image to edit properties @@ -67,12 +83,15 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image) { disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); disconnect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + disconnect(m_image, &DiagramImageItem::adaptToDarkThemeChanged, this, &ImagePropertiesWidget::updateUi); } m_image = image; connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); connect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + connect(m_image, &DiagramImageItem::adaptToDarkThemeChanged, this, &ImagePropertiesWidget::updateUi); m_movable = image->isMovable(); + m_adapt_to_dark_theme = image->adaptToDarkTheme(); m_scaleX = m_image->scaleFactorX(); m_scaleY = m_image->scaleFactorY(); m_rotation = m_image->rotationAngle(); @@ -107,6 +126,7 @@ void ImagePropertiesWidget::apply() m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); m_label = m_image->label(); + m_adapt_to_dark_theme = m_image->adaptToDarkTheme(); } /** @@ -124,6 +144,9 @@ void ImagePropertiesWidget::reset() m_image->setSkewY(m_skewY); m_image->setLabel(m_label); m_image->setMovable(m_movable); + m_preview_dark_theme = true; + m_image->setAdaptToDarkTheme(m_adapt_to_dark_theme); + m_preview_dark_theme = false; updateUi(); } @@ -146,6 +169,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_adapt_to_dark_theme_cb, &QCheckBox::clicked, this, &ImagePropertiesWidget::apply); } else { @@ -155,6 +179,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_adapt_to_dark_theme_cb, &QCheckBox::clicked, this, &ImagePropertiesWidget::apply); } return true; @@ -179,7 +204,7 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const const qreal newSkewX = ui->m_skew_x_sb->value(); const qreal newSkewY = ui->m_skew_y_sb->value(); - QPropertyUndoCommand *undo = nullptr; + QUndoCommand *undo = nullptr; auto chain = [&](const char *property, qreal oldValue, qreal newValue, const QString &text) { if (qFuzzyCompare(oldValue, newValue)) @@ -188,9 +213,10 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const new QPropertyUndoCommand(m_image, property, oldValue, newValue, undo); else { - undo = new QPropertyUndoCommand(m_image, property, oldValue, newValue); - undo->enableAnimation(); - undo->setText(text); + auto *change = new QPropertyUndoCommand(m_image, property, oldValue, newValue); + change->enableAnimation(); + change->setText(text); + undo = change; } }; @@ -213,6 +239,14 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const } } + const bool adapt = ui->m_adapt_to_dark_theme_cb->isChecked(); + if (adapt != m_adapt_to_dark_theme) { + if (undo) new QPropertyUndoCommand(m_image, "adaptToDarkTheme", m_adapt_to_dark_theme, adapt, undo); + else { + undo = new QUndoCommand(tr("Modifier l'adaptation d'une image au thème sombre")); + new QPropertyUndoCommand(m_image, "adaptToDarkTheme", m_adapt_to_dark_theme, adapt, undo); + } + } return undo; } @@ -233,6 +267,9 @@ void ImagePropertiesWidget::updateUi() // signal from a resize handle used directly on the canvas while // this dialog is open). if (!m_image || m_updating_ratio) return; + if (m_live_edit && !m_preview_dark_theme) m_adapt_to_dark_theme = m_image->adaptToDarkTheme(); + const QSignalBlocker darkThemeBlocker(ui->m_adapt_to_dark_theme_cb); + ui->m_adapt_to_dark_theme_cb->setChecked(m_image->adaptToDarkTheme()); m_updating_ratio = true; ui->m_width_sb->setValue(m_image->scaleFactorX() * 100.0); @@ -395,3 +432,11 @@ void ImagePropertiesWidget::on_m_lock_pos_cb_clicked() { m_image->setMovable(!ui->m_lock_pos_cb->isChecked()); } + +void ImagePropertiesWidget::on_m_adapt_to_dark_theme_cb_toggled(bool checked) +{ + if (!m_image) return; + m_preview_dark_theme = true; + m_image->setAdaptToDarkTheme(checked); + m_preview_dark_theme = false; +} diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h index c692d7892..57ddf1108 100644 --- a/sources/ui/imagepropertieswidget.h +++ b/sources/ui/imagepropertieswidget.h @@ -43,6 +43,8 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void reset() override; bool setLiveEdit(bool live_edit) override; QUndoCommand* associatedUndo() const override; + protected: + bool event(QEvent *event) override; private: void updateUi() override; @@ -56,11 +58,14 @@ class ImagePropertiesWidget : public PropertiesEditorWidget void on_m_lock_ratio_tb_toggled(bool checked); void on_m_restore_ratio_pb_clicked(); void on_m_lock_pos_cb_clicked(); + void on_m_adapt_to_dark_theme_cb_toggled(bool checked); private: Ui::ImagePropertiesWidget *ui; DiagramImageItem *m_image; bool m_movable; + bool m_adapt_to_dark_theme = false; + bool m_preview_dark_theme = false; // All tracked independently -- the image may already be // non-uniformly scaled, rotated, and/or skewed via the resize/ // rotate/skew handles before this dialog is even opened, and diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui index 4c9954cd6..4d00b6f29 100644 --- a/sources/ui/imagepropertieswidget.ui +++ b/sources/ui/imagepropertieswidget.ui @@ -173,7 +173,17 @@ - + + + + Adapter l’image au thème sombre + + + Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés. + + + + Qt::Vertical diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index b03046a5a..7fd6172df 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -66,6 +66,22 @@ include(../../cmake/fetch_pugixml.cmake) enable_testing() +add_executable(tst_darkimagelayers tst_darkimagelayers.cpp + ${QET_DIR}/sources/palettegraphicsview.cpp ${QET_DIR}/sources/palettegraphicsview.h + ${QET_DIR}/sources/qetpalette.cpp) +add_test(NAME tst_darkimagelayers COMMAND tst_darkimagelayers) +target_include_directories(tst_darkimagelayers PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_darkimagelayers PRIVATE Qt::Test Qt::Widgets) +find_package(Python3 QUIET COMPONENTS Interpreter) +if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") + add_test(NAME tst_darkimageintegration + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/run_image_dark_probe.py + --build ${CMAKE_BINARY_DIR} --binary $ + --source ${CMAKE_CURRENT_SOURCE_DIR}/image_dark_integration_probe.cpp + --fixture ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/unlinked_contact_label.qet) + set_tests_properties(tst_darkimageintegration PROPERTIES TIMEOUT 300 RUN_SERIAL TRUE) +endif() + add_executable(${PROJECT_NAME} tst_mytest.cpp) add_test(NAME ${PROJECT_NAME} COMMAND ${PROJECT_NAME}) diff --git a/tests/qttest/image_dark_integration_probe.cpp b/tests/qttest/image_dark_integration_probe.cpp new file mode 100644 index 000000000..23d86f61c --- /dev/null +++ b/tests/qttest/image_dark_integration_probe.cpp @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Linked by run_image_dark_probe.py against the application's own objects. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "../../sources/diagram.h" +#include "../../sources/qetproject.h" +#include "../../sources/qetresult.h" +#include "../../sources/qetmessagebox.h" +#include "../../sources/qetpalette.h" +#include "../../sources/palettegraphicsview.h" +#include "../../sources/qetgraphicsitem/diagramimageitem.h" +#include "../../sources/ui/imagepropertieswidget.h" +#include "../../sources/PropertiesEditor/propertieseditordialog.h" + +static void check(bool value, const char *message) { if (!value) throw std::runtime_error(message); } +static QByteArray png(const QPixmap &pixmap) { + QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); pixmap.save(&buffer,"PNG"); return bytes; +} +static QByteArray read(const QString &path) { QFile file(path); check(file.open(QIODevice::ReadOnly),"read export"); return file.readAll(); } +static DiagramImageItem *findImage(Diagram *diagram) { + for (auto *item : diagram->items()) if (auto *image = dynamic_cast(item)) return image; + return nullptr; +} +int main(int argc, char **argv) +{ + QApplication app(argc,argv); + std::freopen(argv[2],"w",stdout); std::freopen(argv[2],"a",stderr); + QTemporaryDir settings; + QSettings::setDefaultFormat(QSettings::IniFormat); + QSettings::setPath(QSettings::IniFormat,QSettings::UserScope,settings.path()); + QCoreApplication::setOrganizationName("QETImageDarkRegression"); + QETProject::setBackupEnabled(false); QET::QetMessageBox::setNonInteractive(true); + QFontDatabase::addApplicationFont(":/fonts/LiberationSans-Regular.ttf"); + try { + const QString output=QString::fromLocal8Bit(argv[3]); + QETProject project(QString::fromLocal8Bit(argv[1])); check(project.state()==QETProject::Ok,"load fixture"); + auto *diagram=project.diagrams().first(); + QImage original(40,30,QImage::Format_ARGB32); original.fill(QColor(197,81,33)); + for(int y=0;y<30;++y) for(int x=10;x<20;++x) original.setPixelColor(x,y,QColor(197,81,33,128)); + for(int y=10;y<16;++y) for(int x=24;x<30;++x) original.setPixelColor(x,y,Qt::transparent); + QPixmap source=QPixmap::fromImage(original); + auto *image=new DiagramImageItem(source); diagram->addItem(image); + image->setPos(55,55); image->setLabel("Image test"); image->setRotationAngle(17); image->setScaleFactorX(1.3); image->setSkewX(8); + check(!image->adaptToDarkTheme(),"new image defaults to original colours"); + QDomDocument xml; auto legacy=image->toXml(xml); + check(!legacy.hasAttribute("adapt_to_dark_theme"),"default does not add an attribute to old files"); + const QRect crop(4,3,24,20); + legacy.firstChild().setNodeValue(QString::fromLatin1(png(source.copy(crop)).toBase64())); + auto base=xml.createElement("image_base"); base.appendChild(xml.createTextNode(QString::fromLatin1(png(source).toBase64()))); legacy.appendChild(base); + auto cropElement=xml.createElement("crop"); cropElement.setAttribute("x",4); cropElement.setAttribute("y",3); cropElement.setAttribute("w",24); cropElement.setAttribute("h",20); legacy.appendChild(cropElement); + image->setAdaptToDarkTheme(true); check(image->fromXml(legacy),"load cropped legacy image"); + check(!image->adaptToDarkTheme(),"missing attribute resets an existing image to original colours"); + check(image->pixmap().size()==crop.size(),"crop survives"); + const QByteArray pristine=png(image->pixmap()); const auto transform=image->transform(); const auto bounds=image->boundingRect(); + ImagePropertiesWidget widget(image); auto *box=widget.findChild("m_adapt_to_dark_theme_cb"); + check(box && !box->isChecked(),"unchecked properties default"); + check(box->text()==QString::fromUtf8("Adapter l’image au thème sombre"),"checkbox wording"); + check(box->toolTip()==QString::fromUtf8("Adapte les couleurs à l’affichage sombre uniquement. L’image originale, les exports et les impressions restent inchangés."),"tooltip wording"); + box->setChecked(true); check(image->adaptToDarkTheme(),"preview"); widget.reset(); check(!image->adaptToDarkTheme(),"reset cancels preview"); + diagram->undoStack().clear(); box->setChecked(true); widget.apply(); check(image->adaptToDarkTheme(),"apply"); + diagram->undoStack().undo(); check(!image->adaptToDarkTheme()&&!box->isChecked(),"undo updates checkbox"); + diagram->undoStack().redo(); check(image->adaptToDarkTheme()&&box->isChecked(),"redo updates checkbox"); + widget.setLiveEdit(true); box->click(); check(!image->adaptToDarkTheme(),"live edit"); + check(diagram->undoStack().count()==2,"deliberate toggles do not merge"); + diagram->undoStack().undo(); check(image->adaptToDarkTheme(),"live undo"); + diagram->undoStack().redo(); check(!image->adaptToDarkTheme(),"live redo"); + box->click(); check(image->adaptToDarkTheme()&&diagram->undoStack().count()==3,"edit after undo has current baseline"); + for (auto role : {QDialogButtonBox::Cancel,QDialogButtonBox::Reset,QDialogButtonBox::Apply}) { + image->setAdaptToDarkTheme(false); + auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor); + editor->findChild("m_adapt_to_dark_theme_cb")->setChecked(true); + dialog.findChild()->button(role)->click(); + check(image->adaptToDarkTheme()==(role==QDialogButtonBox::Apply),"actual dialog button semantics"); + } + check(png(image->pixmap())==pristine && image->transform()==transform && image->boundingRect()==bounds && image->label()=="Image test","choice cannot modify pixels, transform, crop or caption"); + image->setAdaptToDarkTheme(false); + { + auto *editor=new ImagePropertiesWidget(image); PropertiesEditorDialog dialog(editor); + editor->findChild("m_adapt_to_dark_theme_cb")->setChecked(true); + dialog.reject(); check(!image->adaptToDarkTheme(),"Escape/title-bar rejection cancels preview"); + } + PaletteGraphicsView view(diagram); view.setSceneRect(0,0,180,180); view.setFrameShape(QFrame::NoFrame); + view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.setAlignment(Qt::AlignLeft|Qt::AlignTop); view.resize(180,180); view.setAttribute(Qt::WA_DontShowOnScreen); view.show(); + for (bool dark : {false,true}) for(bool adapt : {false,true}) { + const auto palette=dark?QET::Palette::fusionDark():QET::Palette::fusionLight(); QApplication::setPalette(palette); + image->setAdaptToDarkTheme(adapt); QApplication::processEvents(); const auto shown=view.viewport()->grab().toImage(); + shown.save(output+QString("/screen-%1-%2.png").arg(dark).arg(adapt)); + const auto point=view.mapFromScene(image->mapToScene(QPointF(2,2))); const auto ratio=shown.devicePixelRatio(); + QImage expected(1,1,QImage::Format_RGB32); expected.fill(QColor(197,81,33)); + if(dark&&adapt) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text)); + check(shown.pixelColor(qRound(point.x()*ratio),qRound(point.y()*ratio))==expected.pixelColor(0,0),"actual rotated/cropped image display colour"); + project.setFilePath(output+QString("/project-%1-%2.qet").arg(dark).arg(adapt)); check(project.write().isOk(),"project save"); + QETProject reopened(project.filePath()); check(reopened.state()==QETProject::Ok,"project reopen"); + auto *loaded=findImage(reopened.diagrams().first()); check(loaded && loaded->adaptToDarkTheme()==adapt,"per-image persisted choice"); + check(png(loaded->pixmap())==pristine && loaded->transform()==transform && loaded->label()=="Image test","reopen keeps original image/crop/transform/caption"); + } + QApplication::setPalette(QET::Palette::fusionDark()); QImage exports[2]; + for(int i=0;i<2;++i) { + image->setAdaptToDarkTheme(i); exports[i]=QImage(180,180,QImage::Format_RGB32); exports[i].fill(Qt::white); + {QPainter painter(&exports[i]); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + exports[i].save(output+QString("/export-%1.png").arg(i)); + QSvgGenerator svg; svg.setFileName(output+QString("/export-%1.svg").arg(i)); svg.setSize(QSize(180,180)); svg.setViewBox(QRect(0,0,180,180)); + {QPainter painter(&svg); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + QPrinter printer; printer.setOutputFormat(QPrinter::PdfFormat); printer.setOutputFileName(output+QString("/print-%1.pdf").arg(i)); printer.setResolution(72); + {QPainter painter(&printer); check(painter.isActive(),"PDF print painter"); diagram->render(&painter,QRectF(0,0,180,180),QRectF(0,0,180,180));} + } + check(exports[0]==exports[1],"raster export unchanged"); check(read(output+"/export-0.svg")==read(output+"/export-1.svg"),"SVG export unchanged"); + std::puts("PASS: image defaults, XML crop/alpha/transform/label, properties preview/apply/reset/cancel, undo/redo and live edit, four display modes, project save/reopen, raster/SVG export, PDF print generation"); + } catch(const std::exception &error) {std::fprintf(stderr,"FAIL: %s\n",error.what());return 1;} + return 0; +} diff --git a/tests/qttest/run_image_dark_probe.py b/tests/qttest/run_image_dark_probe.py new file mode 100644 index 000000000..cd5831d90 --- /dev/null +++ b/tests/qttest/run_image_dark_probe.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Link the real image/widget integration probe using an existing Ninja app build. + +Reuses app objects without duplicating the app or introducing a core-library +refactor. No application source or existing executable is replaced. The +generated manifest, probe binary, reports and export fixtures stay in build/. +""" +import argparse +import pathlib +import re +import shutil +import subprocess +import sys +import zlib + +parser = argparse.ArgumentParser() +parser.add_argument('--build', required=True) +parser.add_argument('--binary', required=True) +parser.add_argument('--source', required=True) +parser.add_argument('--fixture', required=True) +args = parser.parse_args() +build = pathlib.Path(args.build).resolve() +binary = pathlib.Path(args.binary).resolve() +source = pathlib.Path(args.source).resolve() +fixture = pathlib.Path(args.fixture).resolve() +manifest = (build / 'build.ninja').read_text(encoding='utf-8') +match = re.search(r'^build (\S*?/sources/main\.cpp\.(?:obj|o)):', manifest, re.MULTILINE) +if not match: + sys.exit('Cannot find the application main object in the Ninja build.') +main_object = match.group(1) +probe_object = main_object.replace('main.cpp.', 'image_dark_integration_probe.cpp.') +probe_binary = binary.with_name('image_dark_integration_probe' + binary.suffix) +def escape(path): + return pathlib.Path(path).as_posix().replace('$', '$$').replace(':', '$:').replace(' ', '$ ') +main_source = source.parents[2] / 'sources' / 'main.cpp' +manifest = manifest.replace(main_object, probe_object) +manifest = manifest.replace(escape(main_source), escape(source)) +if binary.suffix: + manifest = manifest.replace(binary.name, probe_binary.name) +else: + old_target = escape(binary.name if binary.parent == build else binary) + new_target = escape(probe_binary.name if probe_binary.parent == build else probe_binary) + manifest = manifest.replace('build ' + old_target + ':', 'build ' + new_target + ':') + old_file = binary.name if binary.parent == build else binary.as_posix() + new_file = probe_binary.name if probe_binary.parent == build else probe_binary.as_posix() + manifest = re.sub(r'^ TARGET_FILE = ' + re.escape(old_file) + r'$', + ' TARGET_FILE = ' + new_file, manifest, flags=re.MULTILINE) +probe_manifest = build / 'image-dark-probe.ninja' +probe_manifest.write_text(manifest, encoding='utf-8') +ninja = shutil.which('ninja') +if not ninja: + sys.exit('Ninja is required for the integration probe.') +target = probe_binary.name if probe_binary.parent == build else str(probe_binary) +subprocess.run([ninja, '-C', str(build), '-f', probe_manifest.name, target], check=True) +output = build / 'image-dark-integration' +output.mkdir(exist_ok=True) +report = output / 'results.txt' +result = subprocess.run([str(probe_binary), str(fixture), str(report), str(output)], timeout=90) +if report.exists(): + print(report.read_text(encoding='utf-8', errors='replace')) +if result.returncode == 0: + def print_streams(path): + streams = re.findall(rb'\nstream\r?\n(.*?)\nendstream', path.read_bytes(), re.DOTALL) + decoded = [] + for stream in streams: + try: + data = zlib.decompress(stream) + except zlib.error: + data = stream + # Qt assigns every PDF a fresh XMP DocumentID (and dates). + # Metadata is not printing content and must not be compared + # as if it were a graphics/image/font stream. + if b' +#include +#include +#include "darkimagerendering.h" +#include "palettegraphicsview.h" +#include "qetpalette.h" + +class Raster : public QGraphicsItem { +public: + QPixmap pixmap; + bool adapt = false; + explicit Raster(QGraphicsItem *parent = nullptr) : QGraphicsItem(parent) { + QImage image(40, 30, QImage::Format_ARGB32); + image.fill(QColor(197, 81, 33)); + for (int y = 0; y < 30; ++y) + for (int x = 10; x < 20; ++x) image.setPixelColor(x, y, QColor(197,81,33,128)); + for (int y = 0; y < 30; ++y) + for (int x = 30; x < 40; ++x) image.setPixelColor(x, y, Qt::transparent); + pixmap = QPixmap::fromImage(image); + } + QRectF boundingRect() const override { return QRectF(0,0,40,40); } + void paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) override { + DarkImageRendering::paintPixmap(p, pixmap, adapt); + p->fillRect(QRectF(0,34,8,4), Qt::black); // caption/decorations remain adapted + } +}; + +class tst_darkimagelayers : public QObject { + Q_OBJECT + static QColor sample(const QImage &image, const PaletteGraphicsView &view, QPointF scene) { + const QPoint point = view.mapFromScene(scene); + const qreal dpr = image.devicePixelRatio(); + return image.pixelColor(qRound(point.x()*dpr), qRound(point.y()*dpr)); + } + static void setup(PaletteGraphicsView &view) { + view.setFrameShape(QFrame::NoFrame); + view.setAlignment(Qt::AlignLeft | Qt::AlignTop); + view.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + view.resize(100,80); view.show(); + } +private slots: + void cleanup() { + QApplication::setPalette(QET::Palette::fusionLight()); + PaletteGraphicsView::setCustomBackgroundColor(false); + } + void modes_data() { + QTest::addColumn("dark"); QTest::addColumn("adapt"); QTest::addColumn("custom"); + QTest::newRow("light-original") << false << false << false; + QTest::newRow("light-adapt") << false << true << false; + QTest::newRow("dark-original") << true << false << false; + QTest::newRow("dark-adapt") << true << true << false; + QTest::newRow("light-custom-original") << false << false << true; + QTest::newRow("light-custom-adapt") << false << true << true; + QTest::newRow("dark-custom-original") << true << false << true; + QTest::newRow("dark-custom-adapt") << true << true << true; + } + void modes() { + QFETCH(bool,dark); QFETCH(bool,adapt); QFETCH(bool,custom); + PaletteGraphicsView::setCustomBackgroundColor(custom); + const QPalette palette = dark ? QET::Palette::fusionDark() : QET::Palette::fusionLight(); + QApplication::setPalette(palette); + const QColor background = custom ? QColor(80,90,100) : QColor(Qt::white); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(background); + auto *raster = new Raster; raster->adapt = adapt; raster->setPos(10,10); scene.addItem(raster); + scene.addRect(32,10,5,20,QPen(Qt::NoPen),QBrush(Qt::black))->setZValue(1); + PaletteGraphicsView view(&scene); setup(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + const QImage shown = view.viewport()->grab().toImage(); + QImage expected(100,80,QImage::Format_RGB32); + expected.fill(custom ? background : (dark && !adapt ? palette.color(QPalette::Base) : QColor(Qt::white))); + QPainter p(&expected); + if (dark && custom && adapt) { + QImage adapted = raster->pixmap.toImage(); + QET::Palette::invertLightnessLayer(adapted,palette.color(QPalette::Base),palette.color(QPalette::Text)); + p.drawImage(QPoint(10,10),adapted); + } else p.drawPixmap(QPoint(10,10),raster->pixmap); + p.fillRect(QRect(32,10,5,20), dark && !adapt && !custom ? palette.color(QPalette::Text) : QColor(Qt::black)); + p.end(); + if (dark && adapt && !custom) QET::Palette::invertLightness(expected,palette.color(QPalette::Base),palette.color(QPalette::Text)); + for (const QPoint point : {QPoint(15,15),QPoint(25,15),QPoint(45,15),QPoint(34,15)}) + QCOMPARE(sample(shown,view,point), expected.pixelColor(point)); + QCOMPARE(sample(shown,view,QPoint(13,45)), dark && !custom ? palette.color(QPalette::Text) : QColor(Qt::black)); + } + void parentClippingRotationOpacityAndOverlap() { + const auto palette = QET::Palette::fusionDark(); QApplication::setPalette(palette); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white); + auto *parent = scene.addRect(0,0,25,40,QPen(Qt::NoPen),QBrush(Qt::NoBrush)); + parent->setFlag(QGraphicsItem::ItemClipsChildrenToShape); parent->setPos(35,15); parent->setRotation(12); + auto *first = new Raster(parent); first->setOpacity(0.5); + auto *second = new Raster; second->setPos(36,30); scene.addItem(second); second->setZValue(2); + QImage blue(40,30,QImage::Format_ARGB32); blue.fill(QColor(20,80,210,128)); second->pixmap=QPixmap::fromImage(blue); + PaletteGraphicsView view(&scene); setup(view); QVERIFY(QTest::qWaitForWindowExposed(&view)); + const auto shown = view.viewport()->grab().toImage(); + // Original pixels blend once over the dark sheet, then the upper + // original image blends once over those pixels. No inverse transform. + QImage reference(1,1,QImage::Format_RGB32); reference.fill(palette.color(QPalette::Base)); + QPainter p(&reference); p.setOpacity(0.5); p.fillRect(reference.rect(),QColor(197,81,33)); p.end(); + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,5))),reference.pixelColor(0,0)); + QPainter upper(&reference); upper.fillRect(reference.rect(),QColor(20,80,210,128)); upper.end(); + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(5,20))),reference.pixelColor(0,0)); + // Pixel in the child's transparent stripe / beyond the parent's clip. + QCOMPARE(sample(shown,view,first->mapToScene(QPointF(28,5))),palette.color(QPalette::Base)); + first->setPos(3,0); scene.update(); QApplication::processEvents(); + QVERIFY(!view.viewport()->grab().isNull()); + } + void alphaMapping() { + QImage image(2,1,QImage::Format_ARGB32_Premultiplied); + image.setPixelColor(0,0,QColor(0,0,0,128)); image.setPixelColor(1,0,Qt::transparent); + QET::Palette::invertLightnessLayer(image,QColor(30,30,30),QColor(220,220,220)); + QCOMPARE(image.pixelColor(0,0),QColor(220,220,220,128)); + QCOMPARE(image.pixelColor(1,0).alpha(),0); + } + void exportUnaffected() { + QApplication::setPalette(QET::Palette::fusionDark()); + QGraphicsScene scene(0,0,100,80); scene.setBackgroundBrush(Qt::white); + auto *raster=new Raster; raster->setPos(10,10); scene.addItem(raster); + QImage results[2]; + for(int i=0;i<2;++i) { + raster->adapt=i; results[i]=QImage(100,80,QImage::Format_RGB32); results[i].fill(Qt::white); + QPainter p(&results[i]); scene.render(&p,QRectF(0,0,100,80),scene.sceneRect()); + } + QCOMPARE(results[0],results[1]); + QCOMPARE(results[0].pixelColor(15,15),QColor(197,81,33)); + } +}; +QTEST_MAIN(tst_darkimagelayers) +#include "tst_darkimagelayers.moc"