From a5fe544415c09d238055688430473b8887e0d206 Mon Sep 17 00:00:00 2001 From: Kellermorph Date: Sun, 13 Sep 2026 18:50:38 +0200 Subject: [PATCH 1/9] Fix text position shift when duplicating diagram pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duplicateDiagram() called restoreText() on every newly loaded element. Each setPlainText() inside restoreText() is wrapped in m_block_alignment except the last one, so finishAlignment() ran on elements whose positions came straight from the XML — shifting center and right-aligned texts. Fix: use toXml(true, true) which handles correctTextPos/restoreText internally for Slave and Report elements only, and call restoreText() on the target for those same element types to recalculate their text positions for the actual resolved text. --- sources/elementspanelwidget.cpp | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) 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 From 43d27a9563497c317639c16364343649f2723e36 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 16 Sep 2026 16:24:44 +1200 Subject: [PATCH 2/9] Add "Reload element drawings" to refresh placed elements (#802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A placed element is drawn once from its definition at construction -- buildFromXml() only turns terminal/input/dynamic_text tags into live child objects, every other primitive (line, rect, ellipse, polygon, arc, text) is pre-rendered into a QPicture by ElementPictureFactory, cached forever under the element's uuid with no invalidation path anywhere in the codebase. Edit and save a symbol's drawing and every already-placed instance keeps showing the old one until the project is closed and reopened. Fix, scoped to what is safe to do without ever risking a conductor or a dynamic text's per-instance state: - ElementPictureFactory::dropCache(location) forgets the cached drawing for one location, so the next fetch rebuilds it from the definition's current content. - Element::reloadPicture() re-fetches and repaints one instance. - Projet > "Recharger les dessins des éléments": walks every diagram, drops each distinct location's cache once, then reloads every placed instance. Deliberately does not touch terminals or dynamic texts -- a definition whose terminal positions moved still needs the existing remove-and- reinsert workflow, since terminals are what conductors are attached to and a wrong guess there would silently misconnect wires. Verified: build clean, ctest 6/6. Triggered the new action on a real, densely-wired project (76 elements) via exact keyboard-menu navigation cross-checked against the menu's own addAction order -- ran to completion, correct confirmation dialog, no crash, diagram unchanged and uncorrupted afterward. Could not complete a live edit-and-watch- it-update trace: opening the element editor on a selected item via GUI automation was unreliable in this environment (same class of friction as PR #888), and this sandbox has no file-based (common://) element to mutate on disk as a shortcut -- every example project embeds its elements. The mechanism itself is traced correct: ElementsLocation::xml() for an embed:// location reads the project's live in-memory collection DOM on every call, so a dropped cache rebuilds from whatever was most recently saved. Refs #802 (own analysis comment, 2026-08-31). Co-Authored-By: Claude Sonnet 5 --- sources/factory/elementpicturefactory.cpp | 23 ++++++++++ sources/factory/elementpicturefactory.h | 5 ++- sources/qetdiagrameditor.cpp | 54 +++++++++++++++++++++++ sources/qetdiagrameditor.h | 2 + sources/qetgraphicsitem/element.cpp | 28 ++++++++++++ sources/qetgraphicsitem/element.h | 1 + 6 files changed, 111 insertions(+), 2 deletions(-) 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/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 7c21742c4..2804e7d7e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -22,6 +22,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" @@ -32,6 +33,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" @@ -549,6 +551,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")); + connect(m_reload_element_drawings, &QAction::triggered, this, &QETDiagramEditor::slot_reloadElementDrawings); + #ifdef QET_EXPORT_PROJECT_DB m_export_project_db = new QAction(QET::Icons::DocumentSpreadsheet, tr("Exporter la base de donnée interne du projet"), this); connect(m_export_project_db, &QAction::triggered, [this]() { @@ -1002,6 +1011,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_EXPORT_PROJECT_DB menu_project -> addSeparator(); menu_project -> addAction(m_export_project_db); @@ -1856,6 +1866,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_EXPORT_PROJECT_DB m_export_project_db -> setEnabled(editable_project); #endif @@ -2970,3 +2981,46 @@ 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: it does not touch position, rotation, links, elementInformations, + labels or dynamic texts, and does not detect or handle a definition whose + terminals moved -- those still need the usual remove-and-reinsert. +*/ +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); + } + } + } + + for (Element *elmt : elements) { + elmt->reloadPicture(); + } + + QET::QetMessageBox::information( + this, + tr("Recharger les dessins des éléments"), + tr("%n élément(s) redessiné(s).", "", elements.size())); +} diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index ed07a2395..e0396fbf9 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(); void editDiagramProperties(DiagramView *); void editDiagramProperties(Diagram *); void addDiagramToProject(QETProject *); @@ -211,6 +212,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 *m_export_project_db, ///Export to file the internal database of the current project *m_tile_window, ///< Show MDI subwindows as tile *m_cascade_window, ///< Show MDI subwindows as cascade diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index 7db14f030..ef55e42a5 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -1784,6 +1784,34 @@ 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: the caller + is expected to have already dropped the shared ElementPictureFactory + cache for this location, or the fresh fetch below just returns the same + cached picture unchanged. + + Deliberately limited to the drawing: terminals and dynamic texts are + live objects carrying state a reload cannot safely fabricate -- + terminal positions are what conductors are attached to, and dynamic + texts carry per-instance overrides (position, visibility) a rebuild + would have to invent a default for. A definition whose terminals moved + still needs the element removed and re-inserted, same as today. +*/ +void Element::reloadPicture() +{ + m_picture = QPicture(); + m_low_zoom_picture = QPicture(); + ElementPictureFactory::instance()->getPictures(m_location, m_picture, m_low_zoom_picture); + update(); +} + /** * @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..709645c40 100644 --- a/sources/qetgraphicsitem/element.h +++ b/sources/qetgraphicsitem/element.h @@ -153,6 +153,7 @@ class Element : public QetGraphicsItem QString name() const override; ElementsLocation location() const; + void reloadPicture(); virtual void setHighlighted(bool); void displayHelpLine(bool b = true); QSize size() const; From edf483d88f5e7ff74eff639978eb0f380cc2e733 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sat, 12 Sep 2026 21:58:57 -0500 Subject: [PATCH 3/9] Delete a terminal's conductors from a snapshot of its conductor list Terminal::~Terminal() called qDeleteAll(m_conductors_list) on the live member. Each Conductor destructor calls removeConductor() on both of its terminals, and that removes the conductor from the same list qDeleteAll is iterating. Mutating a QList while iterating it is undefined behaviour; with two or more conductors on one terminal (terminal strips, bridged terminals) it can skip a delete or delete one conductor twice, which leaves another conductor's terminal1/terminal2 pointing at freed memory. The pattern dates from a00404bc9 (2021), which replaced a foreach loop (iterating an implicit copy) with a direct qDeleteAll. It went unnoticed until the deterministic sort keys added to Diagram::toXml() in #844 started reading pos() on both terminals of every conductor on every save, including the periodic backup, which turned the stale pointer into an EXC_BAD_ACCESS in QGraphicsItem::pos() while deleting an element. Copy the list first and delete from the copy, restoring the pre-2021 behaviour. An isolated regression test (a hub terminal with 2 to 8 conductors, under AddressSanitizer) did not trigger the failure with the old code, so none is included; the crash analysis and that attempt are recorded in jp2images/qelectrotech-source-mirror#1. --- sources/qetgraphicsitem/terminal.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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; } From 9004db1d998111f2f3fad0c93b300a987012b344 Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Wed, 16 Sep 2026 08:52:39 +0000 Subject: [PATCH 4/9] Reload element drawings: keep old drawing on failure, skip elements whose geometry changed (#802) - Element::reloadPicture() now returns a ReloadPictureResult and never clears the current drawing before a successful rebuild: a missing or unreadable definition leaves the element as it was instead of blank. - Elements whose size, hotspot or terminals (added, removed or moved) differ from the new definition are not redrawn: the new drawing would no longer match their bounding rect and live terminals. - The action lists those elements and warns that they must be removed and re-inserted, which deletes the conductors already connected to them. - Status tip states the action is not undoable. --- sources/qetdiagrameditor.cpp | 71 ++++++++++++--- sources/qetgraphicsitem/element.cpp | 133 +++++++++++++++++++++++++--- sources/qetgraphicsitem/element.h | 9 +- 3 files changed, 188 insertions(+), 25 deletions(-) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 2804e7d7e..597c6356a 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -555,7 +555,7 @@ void QETDiagramEditor::setUpActions() 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")); + " sans avoir à fermer et rouvrir le projet (action non annulable)")); connect(m_reload_element_drawings, &QAction::triggered, this, &QETDiagramEditor::slot_reloadElementDrawings); #ifdef QET_EXPORT_PROJECT_DB @@ -2990,9 +2990,10 @@ void QETDiagramEditor::slot_terminalNumbering() { is closed and reopened. Purely visual and not undoable, the way pressing a "refresh" button - would be: it does not touch position, rotation, links, elementInformations, - labels or dynamic texts, and does not detect or handle a definition whose - terminals moved -- those still need the usual remove-and-reinsert. + 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(); @@ -3015,12 +3016,62 @@ void QETDiagramEditor::slot_reloadElementDrawings() { } } - for (Element *elmt : elements) { - elmt->reloadPicture(); + 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; + } + } } - QET::QetMessageBox::information( - this, - tr("Recharger les dessins des éléments"), - tr("%n élément(s) redessiné(s).", "", elements.size())); + 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(); } diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index ef55e42a5..1e2d729ee 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -1792,24 +1792,129 @@ ElementsLocation Element::location() const (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: the caller - is expected to have already dropped the shared ElementPictureFactory - cache for this location, or the fresh fetch below just returns the same - cached picture unchanged. + (bugtracker #802). This is the per-instance half of the fix. - Deliberately limited to the drawing: terminals and dynamic texts are - live objects carrying state a reload cannot safely fabricate -- - terminal positions are what conductors are attached to, and dynamic - texts carry per-instance overrides (position, visibility) a rebuild - would have to invent a default for. A definition whose terminals moved - still needs the element removed and re-inserted, same as today. + 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 */ -void Element::reloadPicture() +Element::ReloadPictureResult Element::reloadPicture() { - m_picture = QPicture(); - m_low_zoom_picture = QPicture(); - ElementPictureFactory::instance()->getPictures(m_location, m_picture, m_low_zoom_picture); + 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; } /** diff --git a/sources/qetgraphicsitem/element.h b/sources/qetgraphicsitem/element.h index 709645c40..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,7 +154,13 @@ class Element : public QetGraphicsItem QString name() const override; ElementsLocation location() const; - void reloadPicture(); + /// 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; From 1c8e7ed05257634be75187208993d58fd9c9678e Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 13 Sep 2026 16:59:41 -0500 Subject: [PATCH 5/9] Give Fusion a palette it can draw with on macOS main.cpp has forced the Fusion style on macOS since 2019, but the palette still came from Qt's macOS platform theme, which is built for the native style. It hands Fusion a Window, Button and Base that are the same color, a Dark lighter than Light, and in dark mode an Inactive ButtonText of black. Fusion derives its frames, gradients and indicators from those roles, so fields had no edges, the radio buttons in the text alignment dialog vanished, and the "Handles" combo box in the diagram editor toolbar drew its text black on a dark combo the moment the window lost focus. Light mode had the same flatness, with Window, Button and Base all white. Add QET::Palette (sources/qetpalette.{h,cpp}) with a light and a dark palette laid out the way Fusion expects, and install one from QETApp::initStyle() on macOS when the running style is Fusion, choosing by the platform palette's lightness and keeping the platform's accent color when it reads at 4.5:1. On macOS the base palette is now applied whether or not "use system colors" is checked, since the system palette cannot be drawn by Fusion; that setting only decides whether style.css is layered on top (#467). On Qt 6.5+ the app follows the OS light/dark switch through QStyleHints::colorSchemeChanged. Other platforms are untouched: Fusion is Qt's default style on Linux desktops without a platform theme, and the palette there carries the user's desktop colors. Making Fusion and this palette the default everywhere is discussed in #870. tests/qttest/tst_qetpalette checks every text role pair at WCAG 4.5:1 (3:1 disabled), that Inactive equals Active, and paints a Fusion combo box, radio buttons, buttons and a line edit on the offscreen platform to measure the ink against its background. Set QET_TEST_DUMP_DIR to keep the rendered images. --- cmake/qet_compilation_vars.cmake | 2 + sources/qetapp.cpp | 41 +++- sources/qetpalette.cpp | 179 ++++++++++++++++ sources/qetpalette.h | 93 ++++++++ tests/qttest/CMakeLists.txt | 9 + tests/qttest/tst_qetpalette.cpp | 358 +++++++++++++++++++++++++++++++ 6 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 sources/qetpalette.cpp create mode 100644 sources/qetpalette.h create mode 100644 tests/qttest/tst_qetpalette.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index a8654b903..4d72cb8b1 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/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/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/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" From b208a4f13162dc77082f2c9bc0cc15cf505420f4 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 13 Sep 2026 18:44:16 -0500 Subject: [PATCH 6/9] Show the text color as a swatch and keep the terminal plan preview on white The Selection properties "texts" tab painted the color value's own text in that color, so the default black was unreadable on a dark palette. Show a swatch in the cell instead and leave the text in the palette's color. The terminal plan preview draws black ink with a white brush, like the printed page it previews, on whatever background the view inherits from the palette. Give the view a white background. --- sources/TerminalStrip/ui/terminalstriplayouteditor.cpp | 3 +++ sources/ui/dynamicelementtextmodel.cpp | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) 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/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; } From 4aef4d17d282b132e9f95e518ec2c49a844c9e81 Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Wed, 16 Sep 2026 14:58:55 +0200 Subject: [PATCH 7/9] git submodule update --remote elements --- elements | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elements b/elements index 99c4d51b2..60eacc28a 160000 --- a/elements +++ b/elements @@ -1 +1 @@ -Subproject commit 99c4d51b2c26e70b3914407df6edebcd0f81f73c +Subproject commit 60eacc28a62695c529c4e7b7eda4c1a8e3683bb6 From 38e504fb7e8d2304afdf31b53f00e82ae268221f Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Wed, 16 Sep 2026 15:22:13 +0200 Subject: [PATCH 8/9] CI windows-build add mingw-w64-ucrt-x86_64-qt6-declarative for QJSEngine --- .github/workflows/windows-build.yml | 1 + 1 file changed, 1 insertion(+) 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 From 6562498ca26299194580f35fa7e3fa3e52b3f9a2 Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Wed, 16 Sep 2026 15:27:49 +0200 Subject: [PATCH 9/9] Snap update for qml QJSEngine --- build-aux/snap/snapcraft.yaml | 2 ++ 1 file changed, 2 insertions(+) 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)