diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 86e47e7cc..3b5f37d67 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -56,6 +56,7 @@ jobs: mingw-w64-ucrt-x86_64-extra-cmake-modules mingw-w64-ucrt-x86_64-nsis mingw-w64-ucrt-x86_64-angleproject + mingw-w64-ucrt-x86_64-qt6-declarative - name: Cache ccache uses: actions/cache@v5 diff --git a/build-aux/snap/snapcraft.yaml b/build-aux/snap/snapcraft.yaml index 48fedc152..9f27a510c 100644 --- a/build-aux/snap/snapcraft.yaml +++ b/build-aux/snap/snapcraft.yaml @@ -73,6 +73,7 @@ parts: - git - sqlite3 - xdg-user-dirs + - libqt6qml6 # libxcb-cursor0 workaround was needed against the Qt5/KF5 core22 content # snap (issue #373). Not yet re-verified against kf6-core24 — remove this # if the Qt6 xcb platform plugin loads cleanly without it. @@ -84,6 +85,7 @@ parts: - libsqlite3-dev - qt6-tools-dev - qt6-base-private-dev + - qt6-declarative-dev - pkgconf # Qt6 PrintSupport records Cups::Cups as a third-party dependency # (qprint_p.h includes ), so find_package(Qt6 PrintSupport) diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 189011bf5..ee2571d24 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -249,6 +249,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/qet.h ${QET_DIR}/sources/qeticons.cpp ${QET_DIR}/sources/qeticons.h + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetpalette.h ${QET_DIR}/sources/qetinformation.cpp ${QET_DIR}/sources/qetinformation.h ${QET_DIR}/sources/qetmainwindow.cpp diff --git a/elements b/elements index 99c4d51b2..60eacc28a 160000 --- a/elements +++ b/elements @@ -1 +1 @@ -Subproject commit 99c4d51b2c26e70b3914407df6edebcd0f81f73c +Subproject commit 60eacc28a62695c529c4e7b7eda4c1a8e3683bb6 diff --git a/sources/TerminalStrip/ui/terminalstriplayouteditor.cpp b/sources/TerminalStrip/ui/terminalstriplayouteditor.cpp index 1801cf318..9b1a3506a 100644 --- a/sources/TerminalStrip/ui/terminalstriplayouteditor.cpp +++ b/sources/TerminalStrip/ui/terminalstriplayouteditor.cpp @@ -29,6 +29,9 @@ TerminalStripLayoutEditor::TerminalStripLayoutEditor(QSharedPointersetupUi(this); ui->m_graphics_view->setScene(new QGraphicsScene{ this }); + // The preview is printed output: black ink on a white page, whatever + // the application palette. + ui->m_graphics_view->setBackgroundBrush(Qt::white); ui->m_graphics_view->scene()->addItem(&m_preview_strip_item); updateUi(); } diff --git a/sources/elementspanelwidget.cpp b/sources/elementspanelwidget.cpp index 4b1167119..018064da5 100644 --- a/sources/elementspanelwidget.cpp +++ b/sources/elementspanelwidget.cpp @@ -658,21 +658,14 @@ void ElementsPanelWidget::duplicateDiagram() BorderProperties bp = source_diagram->border_and_titleblock.exportBorder(); new_diagram->border_and_titleblock.importBorder(bp); - for (QGraphicsItem *item : source_diagram->items()) { - if (Element *elmt = dynamic_cast(item)) { - source_diagram->correctTextPos(elmt); - } - } - - QDomDocument doc = source_diagram->toXml(); + // Serialize the whole diagram with is_copy_command=true. + // This is the same mechanism as Ctrl+C: toXml(true, true) + // internally calls correctTextPos/restoreText for Slave and + // Report elements only — producing correct text positions + // in the XML. No manual correctTextPos/restoreText needed. + QDomDocument doc = source_diagram->toXml(true, true); QDomElement diagram_elmt = doc.documentElement(); - for (QGraphicsItem *item : source_diagram->items()) { - if (Element *elmt = dynamic_cast(item)) { - source_diagram->restoreText(elmt); - } - } - new_diagram->fromXml(diagram_elmt, QPointF(0, 0), false, nullptr); for (QGraphicsItem *item : new_diagram->items()) { @@ -683,7 +676,18 @@ void ElementsPanelWidget::duplicateDiagram() // of the project database, so duplicates fail to insert and // silently vanish from nomenclature/summary tables. elmt->newUuid(); - new_diagram->restoreText(elmt); + + // toXml(true, true) applied correctTextPos to Slave and + // Report elements, which shifted their text positions to + // match the stripped composite text. restoreText() + // recalculates the position for the actual resolved text. + // Only Slave and Report need this — other element types + // were not affected by correctTextPos. + if (elmt->linkType() == Element::Slave || + elmt->linkType() & Element::AllReport) + { + new_diagram->restoreText(elmt); + } } else if (Conductor *cond = dynamic_cast(item)) { // Same reasoning for conductors: conductor.uuid is the PRIMARY diff --git a/sources/factory/elementpicturefactory.cpp b/sources/factory/elementpicturefactory.cpp index e95cdd6d4..b3c55792f 100644 --- a/sources/factory/elementpicturefactory.cpp +++ b/sources/factory/elementpicturefactory.cpp @@ -94,6 +94,29 @@ void ElementPictureFactory::getPictures(const ElementsLocation &location, QPictu } } +/** + @brief ElementPictureFactory::dropCache + Forget the cached drawing of the element at @p location, so the next + getPictures()/pixmap()/getPrimitives() call rebuilds it from the + definition's current content instead of returning what was cached the + first time this location was drawn. + + A placed Element keeps its own copy of the picture in m_picture / + m_low_zoom_picture (set once, in buildFromXml()), so dropping the shared + cache here does not by itself change what is on screen -- callers doing + a manual refresh (bugtracker #802) still need each Element to re-fetch + its picture afterwards. + @param location +*/ +void ElementPictureFactory::dropCache(const ElementsLocation &location) +{ + const QUuid uuid = cacheKey(location); + m_pictures_H.remove(uuid); + m_low_pictures_H.remove(uuid); + m_pixmap_H.remove(uuid); + m_primitives_H.remove(uuid); +} + /** @brief ElementPictureFactory::pixmap @param location diff --git a/sources/factory/elementpicturefactory.h b/sources/factory/elementpicturefactory.h index f840a9dc8..d1f2c307a 100644 --- a/sources/factory/elementpicturefactory.h +++ b/sources/factory/elementpicturefactory.h @@ -85,13 +85,14 @@ class ElementPictureFactory void getPictures(const ElementsLocation &location, QPicture &picture, QPicture &low_picture); QPixmap pixmap(const ElementsLocation &location); ElementPictureFactory::primitives getPrimitives(const ElementsLocation &location); - + void dropCache(const ElementsLocation &location); + private: ElementPictureFactory() {} ElementPictureFactory (const ElementPictureFactory &); ElementPictureFactory operator= (const ElementPictureFactory &); ~ElementPictureFactory(); - + static QUuid cacheKey(const ElementsLocation &location); bool build(const ElementsLocation &location, QPicture *picture=nullptr, QPicture *low_picture=nullptr); void parseElement(const QDomElement &dom, QPainter &painter, primitives &prim) const; diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index c02d99850..09a27bdb5 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -26,6 +26,7 @@ #include "projectview.h" #include "qetdiagrameditor.h" #include "qeticons.h" +#include "qetpalette.h" #include "utils/qetutils.h" #include "qetmessagebox.h" #include "qetproject.h" @@ -50,6 +51,7 @@ #include #include #include +#include #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" #else @@ -1786,8 +1788,10 @@ void QETApp::invertMainWindowVisibility(QWidget *window) { false pour utiliser celles du theme en cours */ void QETApp::useSystemPalette(bool use) { + // The base palette is always initial_palette_ (see initStyle()); the + // setting only decides whether the user's style.css is layered on top. + qApp->setPalette(initial_palette_); if (use) { - qApp->setPalette(initial_palette_); // Drop any stylesheet previously loaded from style.css: with system // colors requested, the palette set just above is what provides them. // @@ -2341,9 +2345,44 @@ void QETApp::initStyle() { initial_palette_ = qApp->palette(); +#ifdef Q_OS_MACOS + // main.cpp forces the Fusion style on macOS, but the palette Qt hands + // us there is the one its platform theme builds for the native style: + // Window, Button and Base share one color, and in dark mode the + // Inactive ButtonText is black. Fusion draws its frames, gradients and + // combo box text from those roles, so controls lose their edges and + // combo text goes black once the window loses focus. Replace it with a + // palette laid out the way Fusion expects (see qetpalette.h). + // + // macOS only: on Linux, Fusion is Qt's default style on desktops + // without a platform theme, and the palette there carries the user's + // desktop colors, which must stay in effect. Making Fusion and this + // palette the default everywhere is discussed in #870. + if (QET::Palette::styleIsFusion(qApp->style())) + initial_palette_ = QET::Palette::forFusion(initial_palette_); +#endif + //Apply or not the system style QSettings settings; useSystemPalette(settings.value("usesystemcolors", true).toBool()); + +#if defined(Q_OS_MACOS) && QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + // Setting an application palette stops Qt from following the OS + // light/dark switch on its own, so follow it here. The platform accent + // color is not reachable any more at this point; the palette's own + // selection blue is used instead. + connect(qApp->styleHints(), &QStyleHints::colorSchemeChanged, this, + [this](Qt::ColorScheme scheme) + { + if (!QET::Palette::styleIsFusion(qApp->style())) + return; + initial_palette_ = scheme == Qt::ColorScheme::Dark + ? QET::Palette::fusionDark() + : QET::Palette::fusionLight(); + QSettings settings; + useSystemPalette(settings.value("usesystemcolors", true).toBool()); + }); +#endif } /** diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 687e164b0..c9f4c868e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -25,6 +25,7 @@ #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" #include "diagramcommands.h" +#include "diagramcontent.h" #include "diagramevent/diagrameventaddimage.h" #ifdef QET_HAS_QTPDF #include "diagramevent/diagrameventaddpdf.h" @@ -35,6 +36,7 @@ #include "diagramevent/diagrameventaddpaste.h" #include "diagramview.h" #include "elementspanelwidget.h" +#include "factory/elementpicturefactory.h" #include "factory/qetgraphicstablefactory.h" #include "print/projectprintwindow.h" #include "project/projectpropertieshandler.h" @@ -552,6 +554,13 @@ void QETDiagramEditor::setUpActions() m_terminal_numbering = new QAction(QET::Icons::TerminalStrip, tr("Numérotation automatique des bornes"), this); connect(m_terminal_numbering, &QAction::triggered, this, &QETDiagramEditor::slot_terminalNumbering); + // Reload element drawings from their current definition (bugtracker #802) + m_reload_element_drawings = new QAction(QET::Icons::ViewRefresh, tr("Recharger les dessins des éléments"), this); + m_reload_element_drawings->setStatusTip( + tr("Redessine chaque élément placé d'après sa définition actuelle," + " sans avoir à fermer et rouvrir le projet (action non annulable)")); + connect(m_reload_element_drawings, &QAction::triggered, this, &QETDiagramEditor::slot_reloadElementDrawings); + #ifdef QET_HAS_SCRIPTING // Run a JavaScript macro against the current project (bugtracker #162). m_run_script = new QAction(tr("Exécuter un script..."), this); @@ -1014,6 +1023,7 @@ void QETDiagramEditor::setUpMenu() menu_project -> addAction(m_project_export_wiring_list); menu_project -> addAction(m_project_wiring_list_view); menu_project -> addAction(m_terminal_numbering); + menu_project -> addAction(m_reload_element_drawings); #ifdef QET_HAS_SCRIPTING menu_project -> addAction(m_run_script); #endif @@ -1871,6 +1881,7 @@ void QETDiagramEditor::slot_updateActions() m_project_export_wiring_list -> setEnabled(opened_project); m_project_wiring_list_view -> setEnabled(opened_project); m_terminal_numbering -> setEnabled(editable_project); + m_reload_element_drawings -> setEnabled(opened_project); #ifdef QET_HAS_SCRIPTING m_run_script -> setEnabled(opened_project); #endif @@ -2989,6 +3000,100 @@ void QETDiagramEditor::slot_terminalNumbering() { } } +/** + @brief QETDiagramEditor::slot_reloadElementDrawings + Redraw every placed element of the current project from its current + definition (bugtracker #802): a symbol edited and saved after being + placed keeps showing its old drawing otherwise, until the whole project + is closed and reopened. + + Purely visual and not undoable, the way pressing a "refresh" button + would be: nothing is pushed on the undo stack and the project is not + marked as modified. Elements whose size, hotspot or terminals changed + are skipped and listed: they must be removed and re-inserted, which + deletes the conductors already connected to them. +*/ +void QETDiagramEditor::slot_reloadElementDrawings() { + QETProject *project = currentProject(); + if (!project) return; + + QList elements; + QSet dropped_locations; + for (Diagram *diagram : project->diagrams()) + { + DiagramContent content(diagram, false); + for (Element *elmt : content.m_elements) + { + elements << elmt; + const QString key = elmt->location().toString(); + if (!dropped_locations.contains(key)) + { + ElementPictureFactory::instance()->dropCache(elmt->location()); + dropped_locations.insert(key); + } + } + } + + int reloaded = 0; + int unavailable = 0; + QStringList geometry_changed; + for (Element *elmt : elements) + { + switch (elmt->reloadPicture()) + { + case Element::ReloadPictureResult::Reloaded: + ++reloaded; + break; + case Element::ReloadPictureResult::Unavailable: + ++unavailable; + break; + case Element::ReloadPictureResult::GeometryChanged: + { + const Diagram *diagram = elmt->diagram(); + const QString folio = diagram + ? tr("folio %1").arg(project->folioIndex(diagram) + 1) + : QString(); + geometry_changed << QStringLiteral("%1 (%2)").arg(elmt->name(), folio); + break; + } + } + } + + QString message = tr("%n élément(s) redessiné(s).", "", reloaded); + + if (unavailable) { + message += QStringLiteral("\n\n") + % tr("%n élément(s) dont la définition est introuvable ou illisible :" + " leur dessin actuel a été conservé.", "", unavailable); + } + + if (geometry_changed.isEmpty()) + { + QET::QetMessageBox::information( + this, tr("Recharger les dessins des éléments"), message); + return; + } + + message += QStringLiteral("\n\n") + % tr("%n élément(s) non redessiné(s) : leur taille, leur point de saisie" + " ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée).", + "", geometry_changed.size()) + % QStringLiteral("\n\n") + % tr("Pour les mettre à jour, il faut les supprimer puis les réinsérer." + " Attention : cette opération supprime les conducteurs déjà reliés" + " à ces éléments, qu'il faudra retracer."); + + //The full list goes in the expandable, scrollable details area + //so the dialog stays readable on large projects. + QMessageBox box(QMessageBox::Warning, + tr("Recharger les dessins des éléments"), + message, + QMessageBox::Ok, + this); + box.setDetailedText(geometry_changed.join(QLatin1Char('\n'))); + box.exec(); +} + #ifdef QET_HAS_SCRIPTING /** @brief QETDiagramEditor::slot_runScript diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 4a8b3f1fe..e0964c4ac 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -136,6 +136,7 @@ class QETDiagramEditor : public QETMainWindow void editProjectProperties(ProjectView *); void editProjectProperties(QETProject *); void slot_terminalNumbering(); + void slot_reloadElementDrawings(); #ifdef QET_HAS_SCRIPTING void slot_runScript(); #endif @@ -214,6 +215,7 @@ class QETDiagramEditor : public QETMainWindow *m_project_export_wiring_list, ///< Action to export the wiring list *m_project_wiring_list_view, ///< Action to show the wiring list read from the project database *m_terminal_numbering, ///< Action to launch terminal numbering + *m_reload_element_drawings, ///< Action to redraw every placed element from its current definition #ifdef QET_HAS_SCRIPTING *m_run_script, ///< Action to run a JavaScript macro against the current project #endif diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index 7db14f030..1e2d729ee 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -1784,6 +1784,139 @@ ElementsLocation Element::location() const return m_location; } +/** + @brief Element::reloadPicture + Re-fetch this element's drawing from its location and repaint. + + A placed element is drawn once from its definition, at construction + (buildFromXml()), and nothing afterwards ever makes it look again -- + editing and saving the definition leaves every already-placed instance + showing the old drawing until the project is closed and reopened + (bugtracker #802). This is the per-instance half of the fix. + + Deliberately limited to the drawing. Terminals are what conductors are + attached to: if the new definition adds, removes or moves a terminal, + or changes the element size or hotspot, the new drawing would no longer + match the live terminals and bounding rect. Such an element is left + untouched and GeometryChanged is returned; it has to be removed and + re-inserted, which deletes the conductors already connected to it. + + If the definition cannot be found or read, the current drawing is kept + and Unavailable is returned, so the element never goes blank. + + Purely visual: nothing is pushed on the undo stack and the project is + not marked as modified. + @return what happened to this element +*/ +Element::ReloadPictureResult Element::reloadPicture() +{ + if (!m_location.exist()) { + return ReloadPictureResult::Unavailable; + } + + const QDomElement definition = m_location.xml(); + if (definition.isNull()) { + return ReloadPictureResult::Unavailable; + } + + if (!definitionGeometryMatches(definition)) { + return ReloadPictureResult::GeometryChanged; + } + + QPicture picture; + QPicture low_zoom_picture; + ElementPictureFactory::instance()->getPictures(m_location, + picture, + low_zoom_picture); + if (picture.isNull()) { + return ReloadPictureResult::Unavailable; + } + + m_picture = picture; + m_low_zoom_picture = low_zoom_picture; + update(); + return ReloadPictureResult::Reloaded; +} + +/** + @brief Element::definitionGeometryMatches + Compare the geometry described by @p definition with this live element: + size and hotspot (normalized the same way setSize()/setHotspot() do it) + and the set of terminal positions (same parsing rules as + TerminalData::fromXml()). + @param definition : the root of the element + @return true if the new drawing can be applied without desynchronizing + the bounding rect or the terminals +*/ +bool Element::definitionGeometryMatches(const QDomElement &definition) const +{ + int w = 0, h = 0, hot_x = 0, hot_y = 0; + if (!QET::attributeIsAnInteger(definition, QStringLiteral("width"), &w) || + !QET::attributeIsAnInteger(definition, QStringLiteral("height"), &h) || + !QET::attributeIsAnInteger(definition, QStringLiteral("hotspot_x"), &hot_x) || + !QET::attributeIsAnInteger(definition, QStringLiteral("hotspot_y"), &hot_y)) { + return false; + } + + //Same rounding as setSize() + while (w % 10) ++w; + while (h % 10) ++h; + if (QSize(w, h) != dimensions) { + return false; + } + + //Same clamping as setHotspot() + const QPoint new_hotspot = dimensions.isNull() + ? QPoint(0, 0) + : QPoint(qMin(hot_x, w), qMin(hot_y, h)); + if (new_hotspot != hotspot_coord) { + return false; + } + + //Terminal positions described by the new definition + QList new_terminals; + for (QDomElement description = definition.firstChildElement(QStringLiteral("description")) ; + !description.isNull() ; + description = description.nextSiblingElement(QStringLiteral("description"))) + { + for (QDomElement terminal = description.firstChildElement(QStringLiteral("terminal")) ; + !terminal.isNull() ; + terminal = terminal.nextSiblingElement(QStringLiteral("terminal"))) + { + qreal x = 0.0, y = 0.0; + if (QET::attributeIsAReal(terminal, QStringLiteral("x"), &x) && + QET::attributeIsAReal(terminal, QStringLiteral("y"), &y)) { + new_terminals << QPointF(x, y); + } + } + } + + if (new_terminals.size() != m_terminals.size()) { + return false; + } + + //Every live terminal must still exist at the same place + for (const Terminal *terminal : m_terminals) + { + const QPointF live_pos = mapFromScene(terminal->dockConductor()); + bool found = false; + for (int i = 0 ; i < new_terminals.size() ; ++i) + { + const QPointF delta = new_terminals.at(i) - live_pos; + if (qAbs(delta.x()) < 0.01 && qAbs(delta.y()) < 0.01) { + new_terminals.removeAt(i); + found = true; + break; + } + } + if (!found) { + return false; + } + } + + return true; +} + /** * @brief Element::updateConductorTexts *Slot that is triggered when a cable is * diff --git a/sources/qetgraphicsitem/element.h b/sources/qetgraphicsitem/element.h index 9e94d2c4e..1b9ee9409 100644 --- a/sources/qetgraphicsitem/element.h +++ b/sources/qetgraphicsitem/element.h @@ -83,6 +83,7 @@ class Element : public QetGraphicsItem Element::kind link_type = Element::Simple); ~Element() override; private: + bool definitionGeometryMatches(const QDomElement &definition) const; Element(const Element &); // attributes @@ -153,6 +154,13 @@ class Element : public QetGraphicsItem QString name() const override; ElementsLocation location() const; + /// Result of Element::reloadPicture() + enum class ReloadPictureResult { + Reloaded, ///< drawing replaced by the current definition + Unavailable, ///< definition missing or unreadable, old drawing kept + GeometryChanged ///< size, hotspot or terminals changed, old drawing kept + }; + ReloadPictureResult reloadPicture(); virtual void setHighlighted(bool); void displayHelpLine(bool b = true); QSize size() const; diff --git a/sources/qetgraphicsitem/terminal.cpp b/sources/qetgraphicsitem/terminal.cpp index aa2b3024f..f559e326d 100644 --- a/sources/qetgraphicsitem/terminal.cpp +++ b/sources/qetgraphicsitem/terminal.cpp @@ -86,7 +86,12 @@ Terminal::Terminal(TerminalData* data, Element* e) : * Destruction of the terminal, and also docked conductor */ Terminal::~Terminal() { - qDeleteAll(m_conductors_list); + // Each conductor's destructor calls removeConductor() on both its + // terminals, which mutates m_conductors_list while qDeleteAll() is + // still iterating it. Delete from a snapshot so the live list can + // change underneath without affecting the iteration. + const QList conductors_to_delete = m_conductors_list; + qDeleteAll(conductors_to_delete); delete d; } diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp new file mode 100644 index 000000000..6589e7603 --- /dev/null +++ b/sources/qetpalette.cpp @@ -0,0 +1,179 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "qetpalette.h" + +#include +#include + +namespace { + /** + Set one role for the Active and Inactive groups (same color: Fusion + does not draw an inactive window differently, and a differing + Inactive color is what turned combo box text black on macOS) and + for the Disabled group. + */ + void setRole(QPalette &p, QPalette::ColorRole role, + const QColor &active, const QColor &disabled) + { + p.setColor(QPalette::Active, role, active); + p.setColor(QPalette::Inactive, role, active); + p.setColor(QPalette::Disabled, role, disabled); + } + + /// Same color in all three groups. + void setRole(QPalette &p, QPalette::ColorRole role, const QColor &c) + { + setRole(p, role, c, c); + } + + /// sRGB channel to linear light, as defined by WCAG 2. + double linear(int channel) + { + const double c = channel / 255.0; + return c <= 0.03928 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4); + } + + double relativeLuminance(const QColor &c) + { + return 0.2126 * linear(c.red()) + + 0.7152 * linear(c.green()) + + 0.0722 * linear(c.blue()); + } + + /** + The selection color shared by both palettes: a blue that reads at + better than 6:1 under white text. Fusion's own default (48,140,198) + only reaches 3.7:1. + */ + const QColor kHighlight(30, 96, 176); +} + +bool QET::Palette::styleIsFusion(const QStyle *style) +{ + return style && style->objectName().compare(QLatin1String("fusion"), + Qt::CaseInsensitive) == 0; +} + +bool QET::Palette::isDark(const QPalette &palette) +{ + return palette.color(QPalette::Active, QPalette::Window).lightness() < 128; +} + +double QET::Palette::contrastRatio(const QColor &a, const QColor &b) +{ + double lighter = relativeLuminance(a); + double darker = relativeLuminance(b); + if (lighter < darker) + std::swap(lighter, darker); + return (lighter + 0.05) / (darker + 0.05); +} + +QPalette QET::Palette::fusionLight() +{ + QPalette p; + const QColor window(239, 239, 239); + const QColor text(Qt::black); + const QColor disabled_text(128, 128, 128); + + setRole(p, QPalette::Window, window); + setRole(p, QPalette::WindowText, text, disabled_text); + setRole(p, QPalette::Base, Qt::white, window); + setRole(p, QPalette::AlternateBase, QColor(247, 247, 247)); + setRole(p, QPalette::Text, text, disabled_text); + setRole(p, QPalette::Button, window); + setRole(p, QPalette::ButtonText, text, disabled_text); + setRole(p, QPalette::BrightText, Qt::white); + setRole(p, QPalette::Highlight, kHighlight, QColor(145, 145, 145)); + setRole(p, QPalette::HighlightedText, Qt::white); + setRole(p, QPalette::ToolTipBase, QColor(255, 255, 220)); + setRole(p, QPalette::ToolTipText, text); + setRole(p, QPalette::Link, QColor(0, 90, 170)); + setRole(p, QPalette::LinkVisited, QColor(110, 40, 140)); + setRole(p, QPalette::PlaceholderText, QColor(118, 118, 118)); + // The 3D roles Fusion shades frames and gradients with. + setRole(p, QPalette::Light, Qt::white); + setRole(p, QPalette::Midlight, QColor(202, 202, 202)); + setRole(p, QPalette::Mid, QColor(184, 184, 184)); + setRole(p, QPalette::Dark, QColor(159, 159, 159)); + setRole(p, QPalette::Shadow, QColor(118, 118, 118)); +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + setRole(p, QPalette::Accent, kHighlight); +#endif + return p; +} + +QPalette QET::Palette::fusionDark() +{ + QPalette p; + const QColor window(53, 53, 53); + const QColor text(220, 220, 220); + // Fusion lifts a button's fill to about (89,89,89) at the top of its + // gradient; 175 is the darkest grey that still reads 3:1 on that. + const QColor disabled_text(175, 175, 175); + + setRole(p, QPalette::Window, window); + setRole(p, QPalette::WindowText, text, disabled_text); + setRole(p, QPalette::Base, QColor(30, 30, 30), QColor(45, 45, 45)); + setRole(p, QPalette::AlternateBase, QColor(45, 45, 45)); + setRole(p, QPalette::Text, text, disabled_text); + setRole(p, QPalette::Button, QColor(66, 66, 66)); + setRole(p, QPalette::ButtonText, text, disabled_text); + setRole(p, QPalette::BrightText, QColor(255, 90, 90)); + setRole(p, QPalette::Highlight, kHighlight, QColor(80, 80, 80)); + setRole(p, QPalette::HighlightedText, Qt::white); + setRole(p, QPalette::ToolTipBase, QColor(66, 66, 66)); + setRole(p, QPalette::ToolTipText, text); + setRole(p, QPalette::Link, QColor(100, 170, 255)); + setRole(p, QPalette::LinkVisited, QColor(190, 130, 255)); + setRole(p, QPalette::PlaceholderText, QColor(150, 150, 150)); + setRole(p, QPalette::Light, QColor(90, 90, 90)); + setRole(p, QPalette::Midlight, QColor(75, 75, 75)); + setRole(p, QPalette::Mid, QColor(35, 35, 35)); + setRole(p, QPalette::Dark, QColor(20, 20, 20)); + setRole(p, QPalette::Shadow, QColor(10, 10, 10)); +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + setRole(p, QPalette::Accent, kHighlight); +#endif + return p; +} + +QPalette QET::Palette::withPlatformAccent(QPalette palette, const QPalette &platform) +{ + const QColor highlight = platform.color(QPalette::Active, QPalette::Highlight); + const QColor highlighted_text = platform.color(QPalette::Active, QPalette::HighlightedText); + if (!highlight.isValid() || !highlighted_text.isValid()) + return palette; + if (contrastRatio(highlight, highlighted_text) < 4.5) + return palette; + + palette.setColor(QPalette::Active, QPalette::Highlight, highlight); + palette.setColor(QPalette::Inactive, QPalette::Highlight, highlight); + palette.setColor(QPalette::Active, QPalette::HighlightedText, highlighted_text); + palette.setColor(QPalette::Inactive, QPalette::HighlightedText, highlighted_text); +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + palette.setColor(QPalette::Active, QPalette::Accent, highlight); + palette.setColor(QPalette::Inactive, QPalette::Accent, highlight); +#endif + return palette; +} + +QPalette QET::Palette::forFusion(const QPalette &platform) +{ + return withPlatformAccent(isDark(platform) ? fusionDark() : fusionLight(), + platform); +} diff --git a/sources/qetpalette.h b/sources/qetpalette.h new file mode 100644 index 000000000..cbc7e6edb --- /dev/null +++ b/sources/qetpalette.h @@ -0,0 +1,93 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef QET_PALETTE_H +#define QET_PALETTE_H + +#include + +class QStyle; + +/** + Palettes tuned for the Fusion style. + + main.cpp forces the Fusion style on macOS, but the palette still comes + from Qt's macOS platform theme, which is built for the native style. + That palette gives Window, Button and Base the same color, makes Dark + lighter than Light, and in dark mode sets the Inactive ButtonText to + black. Fusion derives its outlines, gradients and indicator colors from + those roles, so under that palette controls lose their edges, radio + indicators vanish, and combo box text turns black as soon as the window + loses focus. + + This namespace provides a light and a dark palette with the role + separation Fusion expects, and the WCAG 2 contrast helper the tests use + to keep them readable. +*/ +namespace QET { + namespace Palette { + /** + @return true when the style draws with Fusion's rules, + i.e. its object name is "fusion". + */ + bool styleIsFusion(const QStyle *style); + + /** + @return true when the palette's Window color is dark + (lightness below 128). + */ + bool isDark(const QPalette &palette); + + /** + WCAG 2 contrast ratio between two opaque colors, from 1 (equal) + to 21 (black on white). Normal text needs at least 4.5, large + text and non-text controls at least 3. + */ + double contrastRatio(const QColor &a, const QColor &b); + + /** + A light palette with the role separation Fusion expects: Window + and Button a light grey, Base white, Text black, and Inactive + equal to Active so nothing changes when the window loses focus. + */ + QPalette fusionLight(); + + /** + A dark palette with the same role separation: Window a mid + grey, Button lighter than Window, Base darker than Window, Text + light, and Inactive equal to Active. + */ + QPalette fusionDark(); + + /** + Copy the Highlight and HighlightedText colors of the platform + palette into ours, so the user's accent color survives, but only + when the pair reads at 4.5:1 or better. Otherwise ours is kept. + */ + QPalette withPlatformAccent(QPalette palette, const QPalette &platform); + + /** + The palette QET should use under Fusion, given the palette the + platform handed to the application: fusionDark() when the + platform palette is dark, fusionLight() otherwise, with the + platform's accent color kept when it is readable. + */ + QPalette forFusion(const QPalette &platform); + } +} + +#endif diff --git a/sources/ui/dynamicelementtextmodel.cpp b/sources/ui/dynamicelementtextmodel.cpp index 6ad12570a..3939d50b3 100644 --- a/sources/ui/dynamicelementtextmodel.cpp +++ b/sources/ui/dynamicelementtextmodel.cpp @@ -251,8 +251,10 @@ QList DynamicElementTextModel::itemsForText( QStandardItem *color = new QStandardItem(tr("Couleur")); color->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + // Shown as a swatch next to the value, not as the text color: black + // text (the default) is unreadable on a dark palette. QStandardItem *colora = new QStandardItem; - colora->setData(deti->color(), Qt::ForegroundRole); + colora->setData(deti->color(), Qt::DecorationRole); colora->setData(deti->color(), Qt::EditRole); colora->setData(DynamicElementTextModel::color, Qt::UserRole+1); colora->setFlags(Qt::ItemIsSelectable @@ -1500,7 +1502,7 @@ void DynamicElementTextModel::updateDataFromText(DynamicElementTextItem *deti, case color: { qsi->child(color_txt_row,1)->setData(deti->color(), Qt::EditRole); - qsi->child(color_txt_row,1)->setData(deti->color(), Qt::ForegroundRole); + qsi->child(color_txt_row,1)->setData(deti->color(), Qt::DecorationRole); break; } case pos: @@ -1832,7 +1834,7 @@ void DynamicTextItemDelegate::setModelData( { QColor color = editor->property("color").value(); qsi->setData(color, Qt::EditRole); - qsi->setData(color, Qt::ForegroundRole); + qsi->setData(color, Qt::DecorationRole); } return; } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e891d5aac..ffa69832c 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -92,6 +92,15 @@ add_test(NAME tst_contactusage COMMAND tst_contactusage) target_include_directories(tst_contactusage PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_contactusage PRIVATE Qt::Test) +add_executable( + tst_qetpalette + tst_qetpalette.cpp + ${QET_DIR}/sources/qetpalette.cpp + ${QET_DIR}/sources/qetpalette.h) +add_test(NAME tst_qetpalette COMMAND tst_qetpalette) +target_include_directories(tst_qetpalette PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_qetpalette PRIVATE Qt::Test Qt::Widgets) + add_executable( tst_smart_device tst_smart_device.cpp diff --git a/tests/qttest/tst_qetpalette.cpp b/tests/qttest/tst_qetpalette.cpp new file mode 100644 index 000000000..7cc589bdd --- /dev/null +++ b/tests/qttest/tst_qetpalette.cpp @@ -0,0 +1,358 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "qetpalette.h" + +using QET::Palette::contrastRatio; + +/** + Contrast checks for the palettes QET installs under the Fusion style. + + Two levels. The role tests check the numbers directly: every + foreground/background pair Fusion draws text with must read at 4.5:1 + (WCAG AA for normal text), 3:1 when disabled. The render tests paint + real widgets with Fusion on the offscreen platform and measure the ink + against its background, which is what a user sees and what broke on + macOS: the combo box text went black on a dark combo as soon as the + window lost focus, and radio indicators disappeared into the window. +*/ +class tst_qetpalette : public QObject +{ + Q_OBJECT + + private slots: + void textRolesAreReadable_data(); + void textRolesAreReadable(); + void controlsStandOutFromWindow_data(); + void controlsStandOutFromWindow(); + void inactiveEqualsActive_data(); + void inactiveEqualsActive(); + void platformAccentKeptOnlyWhenReadable(); + void forFusionPicksVariantByLightness(); + void styleIsFusionMatchesObjectName(); + void renderedWidgetsAreReadable_data(); + void renderedWidgetsAreReadable(); + + private: + static void addPaletteRows(); +}; + +namespace { + const double kTextRatio = 4.5; ///< WCAG AA, normal text + const double kDisabledRatio = 3.0; ///< disabled text + /** + An unchecked Fusion radio button is a disc filled with Base and + outlined with Window darkened by 40%: on a dark palette only the + fill is left to see it by, and 3:1 is out of reach for any Fusion + dark theme. The dark palette here reaches 1.36 (light: 2.4); the + macOS platform palette gave 1.06, which is the invisible case. + */ + const double kIndicatorRatio = 1.3; + + struct TextPair { + const char *name; + QPalette::ColorRole fg; + QPalette::ColorRole bg; + }; + const TextPair kTextPairs[] = { + {"WindowText/Window", QPalette::WindowText, QPalette::Window}, + {"Text/Base", QPalette::Text, QPalette::Base}, + {"Text/AlternateBase", QPalette::Text, QPalette::AlternateBase}, + {"ButtonText/Button", QPalette::ButtonText, QPalette::Button}, + {"ButtonText/Window", QPalette::ButtonText, QPalette::Window}, + {"HighlightedText/Highlight", QPalette::HighlightedText, QPalette::Highlight}, + {"ToolTipText/ToolTipBase", QPalette::ToolTipText, QPalette::ToolTipBase}, + {"PlaceholderText/Base", QPalette::PlaceholderText, QPalette::Base}, + {"Link/Base", QPalette::Link, QPalette::Base}, + {"Link/Window", QPalette::Link, QPalette::Window}, + }; + + /** + Grab a widget for measuring. With QET_TEST_DUMP_DIR set, the image + is also written there as -.png so a failure can be + looked at. + */ + QImage grab(QWidget *widget, const char *name) + { + const QImage image = widget->grab().toImage(); + const QByteArray dir = qgetenv("QET_TEST_DUMP_DIR"); + if (!dir.isEmpty()) + image.save(QString("%1/%2-%3.png").arg(QString::fromLocal8Bit(dir), + QTest::currentDataTag(), name)); + return image; + } + + /** + Contrast between the background of a rendered widget (its most + frequent color) and the ink drawn on it (the pixel whose luminance + differs most from the background), inside rect. + */ + double inkContrast(const QImage &image, const QRect &rect) + { + QHash histogram; + for (int y = rect.top(); y <= rect.bottom(); ++y) + for (int x = rect.left(); x <= rect.right(); ++x) + ++histogram[image.pixel(x, y)]; + + QRgb background = 0; + int best = -1; + for (auto it = histogram.cbegin(); it != histogram.cend(); ++it) + if (it.value() > best) { best = it.value(); background = it.key(); } + + double contrast = 1.0; + for (auto it = histogram.cbegin(); it != histogram.cend(); ++it) + contrast = qMax(contrast, contrastRatio(QColor(background), + QColor(it.key()))); + return contrast; + } +} + +void tst_qetpalette::addPaletteRows() +{ + QTest::addColumn("palette"); + QTest::newRow("light") << QET::Palette::fusionLight(); + QTest::newRow("dark") << QET::Palette::fusionDark(); +} + +void tst_qetpalette::textRolesAreReadable_data() +{ + addPaletteRows(); +} + +void tst_qetpalette::textRolesAreReadable() +{ + QFETCH(QPalette, palette); + for (const TextPair &pair : kTextPairs) + { + const double active = contrastRatio( + palette.color(QPalette::Active, pair.fg), + palette.color(QPalette::Active, pair.bg)); + const double disabled = contrastRatio( + palette.color(QPalette::Disabled, pair.fg), + palette.color(QPalette::Disabled, pair.bg)); + QVERIFY2(active >= kTextRatio, + qPrintable(QString("%1 active: %2 < %3") + .arg(pair.name).arg(active).arg(kTextRatio))); + QVERIFY2(disabled >= kDisabledRatio, + qPrintable(QString("%1 disabled: %2 < %3") + .arg(pair.name).arg(disabled).arg(kDisabledRatio))); + } +} + +void tst_qetpalette::controlsStandOutFromWindow_data() +{ + addPaletteRows(); +} + +/** + Fusion fills fields with Base and buttons with Button, and outlines + both with Window darkened by 40%. The macOS platform palette set Base, + Button and Window equal, so fields had no edge at all. In a dark + palette the darkened outline is nearly black on near-black and the + Button fill has to carry the separation on its own; in a light palette + the outline does that job, as in Fusion's own standard palette where + Button equals Window. +*/ +void tst_qetpalette::controlsStandOutFromWindow() +{ + QFETCH(QPalette, palette); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor base = palette.color(QPalette::Active, QPalette::Base); + // White fields on Fusion's standard light grey (239) panel are 1.15:1; + // the macOS platform palette had them equal. + QVERIFY2(contrastRatio(window, base) >= 1.1, "Base blends into Window"); + if (QET::Palette::isDark(palette)) + QVERIFY2(contrastRatio(window, button) >= 1.1, "Button blends into Window"); + else + QVERIFY2(contrastRatio(window, window.darker(140)) >= 1.5, "Fusion's outline blends into Window"); + QVERIFY2(contrastRatio(palette.color(QPalette::Light), palette.color(QPalette::Dark)) >= 1.5, + "Light and Dark too close for Fusion's bevels"); + QVERIFY2(palette.color(QPalette::Light).lightness() > palette.color(QPalette::Dark).lightness(), + "Light must be lighter than Dark"); +} + +void tst_qetpalette::inactiveEqualsActive_data() +{ + addPaletteRows(); +} + +/** + The macOS platform palette sets Inactive ButtonText to black in dark + mode, because native inactive buttons are drawn light. Fusion never + draws them light, so combo box and button text vanished when the + window lost focus. Our palettes keep every role identical between the + two groups. +*/ +void tst_qetpalette::inactiveEqualsActive() +{ + QFETCH(QPalette, palette); + for (int role = 0; role < QPalette::NColorRoles; ++role) + { + const auto r = static_cast(role); + QCOMPARE(palette.color(QPalette::Inactive, r), + palette.color(QPalette::Active, r)); + } +} + +void tst_qetpalette::platformAccentKeptOnlyWhenReadable() +{ + QPalette readable; + readable.setColor(QPalette::Active, QPalette::Highlight, QColor(74, 100, 66)); + readable.setColor(QPalette::Active, QPalette::HighlightedText, Qt::white); + QPalette kept = QET::Palette::withPlatformAccent(QET::Palette::fusionDark(), readable); + QCOMPARE(kept.color(QPalette::Active, QPalette::Highlight), QColor(74, 100, 66)); + QCOMPARE(kept.color(QPalette::Inactive, QPalette::Highlight), QColor(74, 100, 66)); + QCOMPARE(kept.color(QPalette::Active, QPalette::HighlightedText), QColor(Qt::white)); + + QPalette faint; + faint.setColor(QPalette::Active, QPalette::Highlight, QColor(200, 200, 200)); + faint.setColor(QPalette::Active, QPalette::HighlightedText, Qt::white); + QPalette ours = QET::Palette::fusionDark(); + QPalette rejected = QET::Palette::withPlatformAccent(ours, faint); + QCOMPARE(rejected.color(QPalette::Active, QPalette::Highlight), + ours.color(QPalette::Active, QPalette::Highlight)); +} + +void tst_qetpalette::forFusionPicksVariantByLightness() +{ + QPalette dark_platform; + dark_platform.setColor(QPalette::Active, QPalette::Window, QColor(30, 30, 30)); + QVERIFY(QET::Palette::isDark(dark_platform)); + QVERIFY(QET::Palette::isDark(QET::Palette::forFusion(dark_platform))); + + QPalette light_platform; + light_platform.setColor(QPalette::Active, QPalette::Window, Qt::white); + QVERIFY(!QET::Palette::isDark(light_platform)); + QVERIFY(!QET::Palette::isDark(QET::Palette::forFusion(light_platform))); +} + +void tst_qetpalette::styleIsFusionMatchesObjectName() +{ + QScopedPointer fusion(QStyleFactory::create("Fusion")); + QVERIFY(!fusion.isNull()); + QVERIFY(QET::Palette::styleIsFusion(fusion.data())); + QScopedPointer windows(QStyleFactory::create("Windows")); + if (!windows.isNull()) + QVERIFY(!QET::Palette::styleIsFusion(windows.data())); + QVERIFY(!QET::Palette::styleIsFusion(nullptr)); +} + +void tst_qetpalette::renderedWidgetsAreReadable_data() +{ + addPaletteRows(); +} + +/** + Paint the widgets that broke on macOS with Fusion and measure the ink. + The combo box sits in a toolbar exactly like the "Handles" size + selector in the diagram editor; the radio buttons have no text, like + the ones in the text alignment dialog. +*/ +void tst_qetpalette::renderedWidgetsAreReadable() +{ + QFETCH(QPalette, palette); + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(palette); + + QMainWindow window; + QToolBar *toolbar = window.addToolBar("view"); + auto *host = new QWidget; + auto *host_layout = new QHBoxLayout(host); + host_layout->addWidget(new QLabel("Handles :")); + auto *combo = new QComboBox; + combo->addItems({"x 1", "x 2", "x 3"}); + host_layout->addWidget(combo); + toolbar->addWidget(host); + + auto *central = new QWidget; + auto *layout = new QHBoxLayout(central); + auto *radio_off = new QRadioButton; + auto *radio_on = new QRadioButton; + radio_on->setChecked(true); + auto *check = new QCheckBox("check"); + auto *button = new QPushButton("Button"); + auto *disabled = new QPushButton("Disabled"); + disabled->setEnabled(false); + auto *edit = new QLineEdit("field"); + for (QWidget *w : {static_cast(radio_off), static_cast(radio_on), + static_cast(check), static_cast(button), + static_cast(disabled), static_cast(edit)}) + layout->addWidget(w); + window.setCentralWidget(central); + window.resize(520, 140); + window.show(); + QVERIFY(QTest::qWaitForWindowExposed(&window)); + + // Text area of the combo: skip the frame and the arrow on the right. + const QImage combo_image = grab(combo, "combo"); + const QRect combo_text = combo->rect().adjusted(6, 4, -22, -4); + const double combo_contrast = inkContrast(combo_image, combo_text); + QVERIFY2(combo_contrast >= kTextRatio, + qPrintable(QString("combo box text: %1").arg(combo_contrast))); + + // Radio indicators: the whole widget is the indicator, no text. The + // checked one has a dot to read by, the unchecked one only its disc. + const double radio_on_contrast = inkContrast(grab(radio_on, "radio-checked"), radio_on->rect()); + QVERIFY2(radio_on_contrast >= kDisabledRatio, + qPrintable(QString("radio (checked): %1").arg(radio_on_contrast))); + const double radio_off_contrast = inkContrast(grab(radio_off, "radio-unchecked"), radio_off->rect()); + QVERIFY2(radio_off_contrast >= kIndicatorRatio, + qPrintable(QString("radio (unchecked): %1").arg(radio_off_contrast))); + + const QImage button_image = grab(button, "button"); + const double button_contrast = inkContrast(button_image, button->rect().adjusted(6, 4, -6, -4)); + QVERIFY2(button_contrast >= kTextRatio, + qPrintable(QString("push button text: %1").arg(button_contrast))); + + const QImage disabled_image = grab(disabled, "button-disabled"); + const double disabled_contrast = inkContrast(disabled_image, disabled->rect().adjusted(6, 4, -6, -4)); + QVERIFY2(disabled_contrast >= kDisabledRatio, + qPrintable(QString("disabled button text: %1").arg(disabled_contrast))); + + const QImage edit_image = grab(edit, "lineedit"); + const double edit_contrast = inkContrast(edit_image, edit->rect().adjusted(4, 3, -4, -3)); + QVERIFY2(edit_contrast >= kTextRatio, + qPrintable(QString("line edit text: %1").arg(edit_contrast))); +} + +int main(int argc, char **argv) +{ + // Widgets are painted for real, on Qt's offscreen platform so the test + // runs the same on a build box and on a desktop. + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + tst_qetpalette test; + QTEST_SET_MAIN_SOURCE_PATH + return QTest::qExec(&test, argc, argv); +} + +#include "tst_qetpalette.moc"