From 95d0e523febef4f92c95905c84364c38bbf3f094 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 19 Sep 2026 08:06:45 +1200 Subject: [PATCH 01/15] Snap a device text to the grid when it is dragged (#923) Dragging an element's text -- its label, article number, any of its information fields -- moved it in free one-unit steps while everything else in the editor snapped to the grid. Reported by pki791 in #923 for labels moved with Shift. QET moves a text with the mouse along five paths. Four snap and let Ctrl place freely: DiagramTextItem::mouseMoveEvent an independent text ElementTextItemGroup::mouseMoveEvent a group of element texts ElementTextsMover::continueMovement every OTHER selected element text QetGraphicsItem::setPos elements, images, shapes DynamicElementTextItem::mouseMoveEvent, the text actually under the cursor, ended "setPos(new_pos)" with no grid and no modifier check. It is otherwise the same function as the group's, which is why this reads as an omission rather than a decision: the line this adds is that function's, character for character. The inconsistency was visible in one gesture. With two element texts selected and one of them dragged, ElementTextsMover skips the driver item and snaps the rest, so the text under the cursor was the only one on the folio that did not land on the grid. Verified on a virtual display (Xvfb + openbox) against a two-lamp fixture, grid 10, reading the saved positions rather than the screen: Shift+drag the label before (32.95, -11.55) -> (7.95, 23.45) off-grid after -> (10, 20) on-grid the co-selected label (10, -10) -> (50, 20) on-grid, before and after Shift to grab, then Ctrl -> (7.95, 23.45) off-grid, free placement kept The last line matters: moving an element text needs Shift at press, and the modifier is read at move time, so Ctrl still places freely -- press with Shift, hold Ctrl to drag. Holding both from the press is a different gesture, reserved by DiagramView::isCtrlShifting() for the view's mode switch, and does not move the text at all. Nothing that was possible before is lost. Worth knowing when reviewing: 470 of the 492 element texts in the 24 example projects (95.5 %) sit off the grid today, because element definitions place their default text at fractional offsets. The first drag of almost any existing label will pull it onto the grid, by at most half a grid step. ctest 12/12, Qt 6. Co-Authored-By: Claude Opus 5 --- sources/qetgraphicsitem/dynamicelementtextitem.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index a1494f8f0..b6d48975b 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -629,7 +629,13 @@ void DynamicElementTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) int diffx = qRound(current_parent_pos.x() - button_down_parent_pos.x()); int diffy = qRound(current_parent_pos.y() - button_down_parent_pos.y()); QPointF new_pos = m_initial_position + QPointF(diffx, diffy); - setPos(new_pos); + //Snap to the grid, Ctrl to place freely -- the same line + //ElementTextItemGroup::mouseMoveEvent() and + //ElementTextsMover::continueMovement() already use, and + //DiagramTextItem::mouseMoveEvent() for independent texts. + //Without it this was the only text move in the editor that + //ignored the grid. + event->modifiers() == Qt::ControlModifier ? setPos(new_pos) : setPos(Diagram::snapToGrid(new_pos)); if(diagram()) diagram()->elementTextsMover().continueMovement(event); From 861e5de25e2e09826ffd4e28bbd05a3030b5a732 Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Fri, 11 Sep 2026 00:10:55 +0200 Subject: [PATCH 02/15] Use qt own enumeration translation function. Fix plural forms. --- sources/qet.cpp | 104 +++++++++++---------- sources/qetapp.cpp | 2 + sources/undocommand/rotatetextscommand.cpp | 4 +- 3 files changed, 58 insertions(+), 52 deletions(-) diff --git a/sources/qet.cpp b/sources/qet.cpp index f253fc114..51a6256ab 100644 --- a/sources/qet.cpp +++ b/sources/qet.cpp @@ -271,84 +271,88 @@ QString QET::ElementsAndConductorsSentence( int tables_count, int terminal_strip_count) { - QString text; + QStringList parts; if (elements_count) { - text += QObject::tr( - "%n élément(s)", - "part of a sentence listing the content of a diagram", - elements_count + parts.append( + QObject::tr( + "%n élément(s)", + "part of a enumerative partial sentence listing the content of a diagram", + elements_count + ) ); } if (conductors_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n conducteur(s)", - "part of a sentence listing the content of a diagram", - conductors_count + parts.append( + QObject::tr( + "%n conducteur(s)", + "part of a enumerative partial sentence listing the content of a diagram", + conductors_count + ) ); } if (texts_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n champ(s) de texte", - "part of a sentence listing the content of a diagram", - texts_count + parts.append( + QObject::tr( + "%n champ(s) de texte", + "part of a enumerative partial sentence listing the content of a diagram", + texts_count + ) ); } if (images_count) { - if (!text.isEmpty()) text += ", "; - // Qt's %n only selects a grammatical singular/plural form (the - // "(s)" convention used by every other count here) -- it never - // spells the number out as a word, so getting "une image" - // instead of the literal "1 image" for the single-item case - // means handling that count outside %n entirely, with its own - // fixed string. - text += images_count == 1 - ? QObject::tr("une image", "part of a sentence listing the content of a diagram") - : QObject::tr( - "%n images", - "part of a sentence listing the content of a diagram", - images_count - ); + parts.append( + QObject::tr( + "%n images", + "part of a enumerative partial sentence listing the content of a diagram", + images_count + ) + ); } if (shapes_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n forme(s)", - "part of a sentence listing the content of a diagram", - shapes_count + parts.append( + QObject::tr( + "%n forme(s)", + "part of a enumerative partial sentence listing the content of a diagram", + shapes_count + ) ); } if (element_text_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n texte(s) d'élément", - "part of a sentence listing the content of a diagram", - element_text_count); + parts.append( + QObject::tr( + "%n texte(s) d'élément", + "part of a enumerative partial sentence listing the content of a diagram", + element_text_count + ) + ); } if (tables_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n tableau(s)", - "part of a sentence listing the content of diagram", - tables_count); + parts.append( + QObject::tr( + "%n tableau(s)", + "part of a enumerative partial sentence listing the content of diagram", + tables_count + ) + ); } if (terminal_strip_count) { - if (!text.isEmpty()) text += ", "; - text += QObject::tr( - "%n plan de bornes", - "part of a sentence listing the content of a diagram", - terminal_strip_count); + parts.append( + QObject::tr( + "%n plan(s) de bornes", + "part of a enumerative partial sentence listing the content of a diagram", + terminal_strip_count + ) + ); } - return(text); + return QLocale().createSeparatedList(parts); } /** diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index ed31bf0a4..2500b8e0f 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -233,6 +233,8 @@ QString QETApp::loadedQtTranslationFile() */ void QETApp::setLanguage(const QString &desired_language) { QString languages_path = languagesPath(); + + QLocale::setDefault(QLocale(desired_language)); // load Qt library translations QString qt_l10n_path = QLibraryInfo::path(QLibraryInfo::TranslationsPath); diff --git a/sources/undocommand/rotatetextscommand.cpp b/sources/undocommand/rotatetextscommand.cpp index 7239d1b98..fdbe67c65 100644 --- a/sources/undocommand/rotatetextscommand.cpp +++ b/sources/undocommand/rotatetextscommand.cpp @@ -57,7 +57,7 @@ m_diagram(diagram) QString text; if(texts_list.count()) - text.append(QObject::tr("Pivoter %1 textes").arg(texts_list.count())); + text.append(QObject::tr("Pivoter %n texte(s)").arg(texts_list.count())); if(groups_list.count()) { if(text.isEmpty()) @@ -65,7 +65,7 @@ m_diagram(diagram) else text.append(QObject::tr(" et")); - text.append(QObject::tr(" %1 groupes de textes").arg(groups_list.count())); + text.append(QObject::tr(" %n groupe(s) de textes").arg(groups_list.count())); } if(!text.isNull()) setText(text); From 717bf5767762bb858104d68f25a5b4567ea58c4a Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Sat, 19 Sep 2026 09:08:00 +0200 Subject: [PATCH 03/15] Improve plural marking from previous commit. --- sources/qet.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/qet.cpp b/sources/qet.cpp index 51a6256ab..e6a05afad 100644 --- a/sources/qet.cpp +++ b/sources/qet.cpp @@ -305,7 +305,7 @@ QString QET::ElementsAndConductorsSentence( if (images_count) { parts.append( QObject::tr( - "%n images", + "%n image(s)", "part of a enumerative partial sentence listing the content of a diagram", images_count ) From eb4109bb11f4a0ed11e3e407868db52b47c663cb Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Sat, 19 Sep 2026 09:08:25 +0200 Subject: [PATCH 04/15] Replace manual enumerator sentence with qt trasnlator automatism. --- sources/undocommand/rotatetextscommand.cpp | 23 +++++++--------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/sources/undocommand/rotatetextscommand.cpp b/sources/undocommand/rotatetextscommand.cpp index fdbe67c65..33a2dde8b 100644 --- a/sources/undocommand/rotatetextscommand.cpp +++ b/sources/undocommand/rotatetextscommand.cpp @@ -55,21 +55,13 @@ m_diagram(diagram) { openDialog(); - QString text; - if(texts_list.count()) - text.append(QObject::tr("Pivoter %n texte(s)").arg(texts_list.count())); - if(groups_list.count()) - { - if(text.isEmpty()) - text.append(QObject::tr("Pivoter")); - else - text.append(QObject::tr(" et")); - - text.append(QObject::tr(" %n groupe(s) de textes").arg(groups_list.count())); - } - if(!text.isNull()) - setText(text); - + QStringList parts; + if (texts_list.count()) + parts << QObject::tr("%n texte(s)", "", texts_list.count()); + if (groups_list.count()) + parts << QObject::tr("%n groupe(s) de textes", "", groups_list.count()); + setText(QObject::tr("Pivoter %1").arg(QLocale().createSeparatedList(parts))); + for(DiagramTextItem *dti : texts_list) setupAnimation(dti, "rotation", dti->rotation(), m_rotation); for(ElementTextItemGroup *grp : groups_list) @@ -77,7 +69,6 @@ m_diagram(diagram) } else setObsolete(true); - } void RotateTextsCommand::undo() From 183035d5ac562e74c256bc5cf1095e876c4545cf Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Sat, 19 Sep 2026 09:15:28 +0200 Subject: [PATCH 05/15] Replace enumerator list. --- sources/elementtextsmover.cpp | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/sources/elementtextsmover.cpp b/sources/elementtextsmover.cpp index 7efb04eb5..16026a112 100644 --- a/sources/elementtextsmover.cpp +++ b/sources/elementtextsmover.cpp @@ -137,25 +137,14 @@ void ElementTextsMover::endMovement() QString ElementTextsMover::undoText() const { - QString undo_text; - - if(m_text_count == 1) - undo_text.append(QObject::tr("Déplacer un texte d'élément")); - else if(m_text_count > 1) - undo_text.append(QObject::tr("Déplacer %1 textes d'élément").arg(m_items_hash.size())); - - if(m_group_count >= 1) - { - if(undo_text.isEmpty()) - undo_text.append(QObject::tr("Déplacer")); - else - undo_text.append(QObject::tr(" et")); - - if(m_group_count == 1) - undo_text.append(QObject::tr(" un groupe de texte")); - else - undo_text.append(QObject::tr((" %1 groupes de textes")).arg(m_group_count)); - } - - return undo_text; + QStringList parts; + if (m_text_count) + parts << QObject::tr("%n texte(s) d'élément", "", m_text_count); + if (m_group_count) + parts << QObject::tr("%n groupe(s) de textes", "", m_group_count); + + if (parts.isEmpty()) + return QString(); // should never occur + + return QObject::tr("Déplacer %1").arg(QLocale().createSeparatedList(parts)); } From 7e3420b1a514e8b859df0cd095ba7044d653213e Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Sat, 19 Sep 2026 10:39:54 +0200 Subject: [PATCH 06/15] Avoid constructing sentences. --- .../UndoCommand/addterminaltostripcommand.cpp | 95 +++++++++++-------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp b/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp index 8bf56732a..ad17037dd 100644 --- a/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp +++ b/sources/TerminalStrip/UndoCommand/addterminaltostripcommand.cpp @@ -35,13 +35,17 @@ AddTerminalToStripCommand::AddTerminalToStripCommand(QSharedPointerlabel(); const auto ts_name = strip->name(); - const auto str_1 = t_label.isEmpty() ? QObject::tr("Ajouter une borne") : - QObject::tr("Ajouter la borne %1").arg(t_label); - - const auto str_2 = ts_name.isEmpty() ? QObject::tr("à un groupe de bornes") : - QObject::tr("au groupe de bornes %1").arg(ts_name); - - setText(str_1 % " " % str_2); + QString text; + if (ts_name.isEmpty()) { + text = t_label.isEmpty() + ? QObject::tr("Ajouter une borne à un groupe de bornes") + : QObject::tr("Ajouter la borne %1 à un groupe de bornes").arg(t_label); + } else { + text = t_label.isEmpty() + ? QObject::tr("Ajouter une borne au groupe de bornes %1").arg(ts_name) + : QObject::tr("Ajouter la borne %1 au groupe de bornes %2").arg(t_label, ts_name); + } + setText(text); } AddTerminalToStripCommand::AddTerminalToStripCommand(QVector> terminals, TerminalStrip *strip, QUndoCommand *parent) : @@ -50,14 +54,11 @@ AddTerminalToStripCommand::AddTerminalToStripCommand(QVectorname(); + const auto count = m_terminal.size(); - const auto str_1 = m_terminal.size() > 1 ? QObject::tr("Ajouter %1 bornes").arg(m_terminal.size()) : - QObject::tr("Ajouter une borne"); - - const auto str_2 = ts_name.isEmpty() ? QObject::tr("à un groupe de bornes") : - QObject::tr("au groupe de bornes %1").arg(ts_name); - - setText(str_1 % " " % str_2); + setText(ts_name.isEmpty() + ? QObject::tr("Ajouter %n borne(s) à un groupe de bornes", "", count) + : QObject::tr("Ajouter %n borne(s) au groupe de bornes %1", "", count).arg(ts_name)); } @@ -137,13 +138,11 @@ void RemoveTerminalFromStripCommand::redo() void RemoveTerminalFromStripCommand::setCommandTitle() { const auto strip_name = m_strip->name(); + const auto count = m_terminals.size(); - const auto str_1 = m_terminals.size()>1 ? QObject::tr("Enlever %1 bornes").arg(m_terminals.size()): - QObject::tr("Enlever une borne"); - - const auto str_2 = strip_name.isEmpty() ? QObject::tr("d'un groupe de bornes") : - QObject::tr("du groupe de bornes %1").arg(strip_name); - setText(str_1 % " " % str_2); + setText(strip_name.isEmpty() + ? QObject::tr("Enlever %n borne(s) d'un groupe de bornes", "", count) + : QObject::tr("Enlever %n borne(s) du groupe de bornes %1", "", count).arg(strip_name)); } /** @@ -166,19 +165,30 @@ MoveTerminalCommand::MoveTerminalCommand(QSharedPointer termin t_label.append(", "); t_label.append(real_t->label()); } + const auto strip_name = old_strip->name(); + const auto new_strip_name = new_strip->name(); - auto strip_name = old_strip->name(); - auto new_strip_name = new_strip->name(); - - auto str_1 = t_label.isEmpty() ? QObject::tr("Déplacer une borne") : - QObject::tr("Déplacer la borne %1").arg(t_label); - - auto str_2 = strip_name.isEmpty() ? QObject::tr(" d'un groupe de bornes") : - QObject::tr(" du groupe de bornes %1").arg(strip_name); - - auto str_3 = new_strip_name.isEmpty() ? QObject::tr("vers un groupe de bornes") : - QObject::tr("vers le groupe de bornes %1").arg(new_strip_name); - setText(str_1 % " " % str_2 % " " % str_3); + QString text; + if (t_label.isEmpty()) { + if (strip_name.isEmpty() && new_strip_name.isEmpty()) + text = QObject::tr("Déplacer une borne d'un groupe de bornes vers un groupe de bornes"); + else if (strip_name.isEmpty()) + text = QObject::tr("Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1").arg(new_strip_name); + else if (new_strip_name.isEmpty()) + text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers un groupe de bornes").arg(strip_name); + else + text = QObject::tr("Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2").arg(strip_name, new_strip_name); + } else { + if (strip_name.isEmpty() && new_strip_name.isEmpty()) + text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers un groupe de bornes").arg(t_label); + else if (strip_name.isEmpty()) + text = QObject::tr("Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2").arg(t_label, new_strip_name); + else if (new_strip_name.isEmpty()) + text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers un groupe de bornes").arg(t_label, strip_name); + else + text = QObject::tr("Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3").arg(t_label, strip_name, new_strip_name); + } + setText(text); } MoveTerminalCommand::MoveTerminalCommand(QVector> terminals, TerminalStrip *old_strip, @@ -191,17 +201,18 @@ MoveTerminalCommand::MoveTerminalCommand(QVectorname(); const auto new_strip_name = new_strip->name(); + const auto count = m_terminal.size(); - const auto str_1 = m_terminal.size() > 1 ? QObject::tr("Déplacer des bornes") : - QObject::tr("Déplacer une borne"); - - const auto str_2 = strip_name.isEmpty() ? QObject::tr(" d'un groupe de bornes") : - QObject::tr(" du groupe de bornes %1").arg(strip_name); - - const auto str_3 = new_strip_name.isEmpty() ? QObject::tr("vers un groupe de bornes") : - QObject::tr("vers le groupe de bornes %1").arg(new_strip_name); - - setText(str_1 % " " % str_2 % " " % str_3); + QString text; + if (strip_name.isEmpty() && new_strip_name.isEmpty()) + text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers un groupe de bornes", "", count); + else if (strip_name.isEmpty()) + text = QObject::tr("Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1", "", count).arg(new_strip_name); + else if (new_strip_name.isEmpty()) + text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers un groupe de bornes", "", count).arg(strip_name); + else + text = QObject::tr("Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2", "", count).arg(strip_name, new_strip_name); + setText(text); } void MoveTerminalCommand::undo() From 7fa1ff35b8ee402dae8a38333175f6ba331748bd Mon Sep 17 00:00:00 2001 From: Kellermorph Date: Fri, 18 Sep 2026 13:49:55 +0200 Subject: [PATCH 07/15] Add global auto-numbering rules to QElectroTech settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Numérotation auto' tab to the global settings page (Settings > Nouveau projet) where users can define default auto-numbering rules for Conducteurs, Eléments, and Folios. These rules are automatically transferred to every new project created. Changes: - Add NumerotationContext::saveToSettings()/loadFromSettings() static helpers for persisting named numerotation contexts via QSettings - Add 'Numérotation auto' tab to NewDiagramPage with three sub-tabs using SelectAutonumW widgets (same UI as project properties) - Add save/remove/persist slots for conductor, element, and folio contexts with immediate QSettings persistence on every change - NewDiagramPage::applyConf() saves autonum settings when editing global defaults (no project) - QETProject constructor loads global autonum settings from QSettings for new empty projects --- sources/autoNum/numerotationcontext.cpp | 77 +++++++++++ sources/autoNum/numerotationcontext.h | 10 ++ sources/qetproject.cpp | 24 ++++ sources/ui/configpage/configpages.cpp | 173 ++++++++++++++++++++++++ sources/ui/configpage/configpages.h | 21 +++ 5 files changed, 305 insertions(+) diff --git a/sources/autoNum/numerotationcontext.cpp b/sources/autoNum/numerotationcontext.cpp index 3355a1bb4..e23578186 100644 --- a/sources/autoNum/numerotationcontext.cpp +++ b/sources/autoNum/numerotationcontext.cpp @@ -275,3 +275,80 @@ QString NumerotationContext::formatValue(const QStringList &item) return QString("%1").arg(value.toInt(), 3, 10, QChar('0')); return QString::number(value.toInt()); } + +/** + @brief NumerotationContext::saveToSettings + Save a hash of named NumerotationContexts to QSettings. + @param contexts : the named rules to save + @param currentRule : the name of the currently active rule + @param settings : QSettings instance + @param prefix : settings key prefix (e.g. "autonum/conductor") +*/ +void NumerotationContext::saveToSettings( + const QHash &contexts, + const QString ¤tRule, + QSettings &settings, + const QString &prefix) +{ + settings.setValue(prefix + "/current", currentRule); + + // Clear stale array entries before writing (beginWriteArray does not + // remove entries beyond the new size). + settings.remove(prefix + "/rules"); + + QStringList names = contexts.keys(); + settings.beginWriteArray(prefix + "/rules", names.size()); + for (int i = 0; i < names.size(); ++i) { + settings.setArrayIndex(i); + const QString &name = names.at(i); + const NumerotationContext &nc = contexts.value(name); + + settings.setValue("name", name); + + // Serialize context to XML string + QDomDocument doc; + NumerotationContext nc_copy = nc; + QDomElement root = nc_copy.toXml(doc, "context"); + doc.appendChild(root); + settings.setValue("xml", doc.toString()); + } + settings.endArray(); +} + +/** + @brief NumerotationContext::loadFromSettings + Load named NumerotationContexts from QSettings. + @param settings : QSettings instance + @param prefix : settings key prefix (e.g. "autonum/conductor") + @return pair of (hash of named rules, name of current rule) +*/ +QPair, QString> NumerotationContext::loadFromSettings( + QSettings &settings, + const QString &prefix) +{ + QPair, QString> result; + QHash &contexts = result.first; + QString ¤tRule = result.second; + + currentRule = settings.value(prefix + "/current").toString(); + + int size = settings.beginReadArray(prefix + "/rules"); + for (int i = 0; i < size; ++i) { + settings.setArrayIndex(i); + QString name = settings.value("name").toString(); + QString xmlStr = settings.value("xml").toString(); + + if (name.isEmpty() || xmlStr.isEmpty()) continue; + + QDomDocument doc; + if (!doc.setContent(xmlStr)) continue; + + QDomElement root = doc.documentElement(); + NumerotationContext nc; + nc.fromXml(root); + contexts.insert(name, nc); + } + settings.endArray(); + + return result; +} diff --git a/sources/autoNum/numerotationcontext.h b/sources/autoNum/numerotationcontext.h index b1575e468..74b6a5fb4 100644 --- a/sources/autoNum/numerotationcontext.h +++ b/sources/autoNum/numerotationcontext.h @@ -21,6 +21,8 @@ #include #include #include +#include +#include /** This class represents a numerotation context, i.e. the data (type, value, increase) @@ -60,6 +62,14 @@ class NumerotationContext /// UI preview of a part's value matches what actually gets rendered. static QString formatValue(const QStringList &item); + static void saveToSettings(const QHash &contexts, + const QString ¤tRule, + QSettings &settings, + const QString &prefix); + static QPair, QString> loadFromSettings( + QSettings &settings, + const QString &prefix); + private: QStringList content_; }; diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 18afbdde6..1befef7a3 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -83,6 +83,30 @@ m_project_properties_handler{this} m_default_guides.append(g); } settings.endArray(); + + //Load global auto-numbering defaults from QSettings + { + auto conductorData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/conductor")); + for (auto it = conductorData.first.constBegin(); it != conductorData.first.constEnd(); ++it) { + addConductorAutoNum(it.key(), it.value()); + } + if (!conductorData.second.isEmpty()) { + setCurrentConductorAutoNum(conductorData.second); + } + + auto elementData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/element")); + for (auto it = elementData.first.constBegin(); it != elementData.first.constEnd(); ++it) { + addElementAutoNum(it.key(), it.value()); + } + if (!elementData.second.isEmpty()) { + setCurrrentElementAutonum(elementData.second); + } + + auto folioData = NumerotationContext::loadFromSettings(settings, QStringLiteral("autonum/folio")); + for (auto it = folioData.first.constBegin(); it != folioData.first.constEnd(); ++it) { + addFolioAutoNum(it.key(), it.value()); + } + } } ProjectPropertiesHandler &QETProject::projectPropertiesHandler() diff --git a/sources/ui/configpage/configpages.cpp b/sources/ui/configpage/configpages.cpp index 8c027ccac..7c2db969d 100644 --- a/sources/ui/configpage/configpages.cpp +++ b/sources/ui/configpage/configpages.cpp @@ -31,6 +31,8 @@ #include "../titleblockpropertieswidget.h" #include "../xrefpropertieswidget.h" #include "guidespropertieswidget.h" +#include "../autoNum/numerotationcontext.h" +#include "../autoNum/ui/selectautonumw.h" #include #include #include @@ -101,6 +103,32 @@ NewDiagramPage::NewDiagramPage(QETProject *project, } m_gpw->setGuides(loaded_guides); + // global auto-numbering defaults (only when editing global settings, not a project) + if (!m_project) { + auto saw_conductor = new SelectAutonumW(1); + auto saw_element = new SelectAutonumW(0); + auto saw_folio = new SelectAutonumW(2); + + initAutoNumTab(m_autonum_conductor, saw_conductor, QStringLiteral("autonum/conductor")); + initAutoNumTab(m_autonum_element, saw_element, QStringLiteral("autonum/element")); + initAutoNumTab(m_autonum_folio, saw_folio, QStringLiteral("autonum/folio")); + + QSettings autonum_settings; + loadAutoNumTab(m_autonum_conductor, autonum_settings); + loadAutoNumTab(m_autonum_element, autonum_settings); + loadAutoNumTab(m_autonum_folio, autonum_settings); + + // Intercept Return key in the combo line edits so it doesn't + // activate the dialog's default button (OK). + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + if (QComboBox *combo = tab->widget->contextComboBox()) { + if (combo->lineEdit()) { + combo->lineEdit()->installEventFilter(this); + } + } + } + } + //If there is a project, we edit his properties if (m_project) { bpw -> setProperties (m_project -> defaultBorderProperties()); @@ -114,6 +142,7 @@ NewDiagramPage::NewDiagramPage(QETProject *project, // main tab widget QTabWidget *tab_widget = new QTabWidget(this); + m_tab_widget = tab_widget; QWidget *diagram_widget = new QWidget(); QVBoxLayout *diagram_layout = new QVBoxLayout(diagram_widget); @@ -127,6 +156,19 @@ NewDiagramPage::NewDiagramPage(QETProject *project, tab_widget -> addTab (xrefpw, tr("Références croisées")); tab_widget -> addTab (m_gpw, tr("Guides")); + // add auto-numbering tab only for global settings (not per project) + if (!m_project) { + QWidget *autonum_widget = new QWidget(); + QVBoxLayout *autonum_layout = new QVBoxLayout(autonum_widget); + autonum_layout->addWidget(new QLabel(tr("Définir les règles de numérotation automatique par défaut pour les nouveaux projets :"))); + QTabWidget *autonum_inner_tab = new QTabWidget(); + autonum_inner_tab->addTab(m_autonum_conductor.widget, tr("Conducteurs")); + autonum_inner_tab->addTab(m_autonum_element.widget, tr("Eléments")); + autonum_inner_tab->addTab(m_autonum_folio.widget, tr("Folios")); + autonum_layout->addWidget(autonum_inner_tab); + tab_widget -> addTab (autonum_widget, tr("Numérotation auto")); + } + QVBoxLayout *vlayout1 = new QVBoxLayout(); vlayout1->addWidget(tab_widget); @@ -230,6 +272,9 @@ void NewDiagramPage::applyConf() settings.setValue(QStringLiteral("color"), current_guides[i].color.name()); } settings.endArray(); + + // save global auto-numbering defaults + persistAutonumSettings(); } } @@ -295,6 +340,134 @@ void NewDiagramPage::loadSavedTbp() applyConf(); } +/** + @brief NewDiagramPage::isPlaceholder + Return true if @a name matches the combo box's built-in placeholder text + (first item). This is locale-independent because it reads the actual item text. +*/ +bool NewDiagramPage::isPlaceholder(QComboBox *combo, const QString &name) +{ + return !combo->count() || name == combo->itemText(0); +} + +/** + @brief NewDiagramPage::initAutoNumTab + Initialise an AutoNumTab struct and connect its signals. +*/ +void NewDiagramPage::initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix) +{ + tab.widget = w; + tab.prefix = prefix; + + connect(w, &SelectAutonumW::applyPressed, this, [this, &tab]() { saveAutoNumContext(tab); }); + connect(w, &SelectAutonumW::removeClicked, this, [this, &tab]() { removeAutoNumContext(tab); }); + connect(w->contextComboBox(), &QComboBox::activated, this, [this, &tab](int index) { + if (index >= 0) { + QString name = tab.widget->contextComboBox()->itemText(index); + if (tab.contexts.contains(name)) { + tab.widget->setContext(tab.contexts.value(name)); + } + } + }); +} + +/** + @brief NewDiagramPage::loadAutoNumTab + Load saved rules from QSettings into an AutoNumTab. +*/ +void NewDiagramPage::loadAutoNumTab(AutoNumTab &tab, QSettings &settings) +{ + auto data = NumerotationContext::loadFromSettings(settings, tab.prefix); + tab.contexts = data.first; + for (auto it = tab.contexts.constBegin(); it != tab.contexts.constEnd(); ++it) { + tab.widget->contextComboBox()->addItem(it.key()); + } + if (!tab.contexts.isEmpty() && !data.second.isEmpty() + && tab.contexts.contains(data.second)) { + tab.widget->contextComboBox()->setCurrentText(data.second); + tab.widget->setContext(tab.contexts.value(data.second)); + } +} + +/** + @brief NewDiagramPage::saveAutoNumContext + Save the current context from an AutoNumTab's widget into its hash and + persist to QSettings immediately. +*/ +void NewDiagramPage::saveAutoNumContext(AutoNumTab &tab) +{ + QString name = tab.widget->contextComboBox()->currentText().trimmed(); + if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { + return; + } + tab.contexts.insert(name, tab.widget->toNumContext()); + if (tab.widget->contextComboBox()->findText(name) == -1) { + tab.widget->contextComboBox()->addItem(name); + } + persistAutonumSettings(); +} + +/** + @brief NewDiagramPage::removeAutoNumContext + Remove the current context from an AutoNumTab's hash and persist. +*/ +void NewDiagramPage::removeAutoNumContext(AutoNumTab &tab) +{ + QString name = tab.widget->contextComboBox()->currentText().trimmed(); + if (name.isEmpty() || isPlaceholder(tab.widget->contextComboBox(), name)) { + return; + } + int idx = tab.widget->contextComboBox()->findText(name); + if (idx == -1) return; + tab.contexts.remove(name); + tab.widget->contextComboBox()->removeItem(idx); + tab.widget->contextComboBox()->setCurrentText(QString()); + tab.widget->setContext(NumerotationContext()); + persistAutonumSettings(); +} + +/** + @brief NewDiagramPage::persistAutonumSettings + Save all autonum contexts to QSettings immediately. +*/ +void NewDiagramPage::persistAutonumSettings() +{ + QSettings settings; + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + QString current; + QComboBox *combo = tab->widget->contextComboBox(); + if (!isPlaceholder(combo, combo->currentText().trimmed()) + && tab->contexts.contains(combo->currentText().trimmed())) { + current = combo->currentText().trimmed(); + } + NumerotationContext::saveToSettings(tab->contexts, current, + settings, tab->prefix); + } +} + +/** + @brief NewDiagramPage::eventFilter + Intercept Return/Enter in combo box line edits so it doesn't close the + settings dialog. +*/ +bool NewDiagramPage::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + if ((ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter)) { + // Check if this is a line edit inside one of our autonum combos + for (auto *tab : {&m_autonum_conductor, &m_autonum_element, &m_autonum_folio}) { + if (QComboBox *combo = tab->widget->contextComboBox()) { + if (combo->lineEdit() && combo->lineEdit() == obj) { + return true; // eat the event + } + } + } + } + } + return ConfigPage::eventFilter(obj, event); +} + /** Constructeur @param parent QWidget parent diff --git a/sources/ui/configpage/configpages.h b/sources/ui/configpage/configpages.h index 0a4740f90..9ddb2a523 100644 --- a/sources/ui/configpage/configpages.h +++ b/sources/ui/configpage/configpages.h @@ -20,6 +20,7 @@ #include "configpage.h" #include "../projectpropertiesdialog.h" #include "../titleblockpropertieswidget.h" +#include "../autoNum/numerotationcontext.h" #include #include @@ -32,6 +33,7 @@ class XRefPropertiesWidget; class GuidesPropertiesWidget; class QETProject; class TitleBlockProperties; +class SelectAutonumW; /** @brief The NewDiagramPage class @@ -48,6 +50,7 @@ class NewDiagramPage : public ConfigPage { ~NewDiagramPage() override; private: NewDiagramPage(const NewDiagramPage &); + bool eventFilter(QObject *obj, QEvent *event) override; public slots: void changeToAutoFolioTab(); void setFolioAutonum(QString); @@ -72,7 +75,25 @@ public slots: XRefPropertiesWidget *xrefpw; ///< Widget to edit default xref properties GuidesPropertiesWidget *m_gpw; ///< Widget to edit guides TitleBlockProperties savedTbp; ///< Used to save current TBP and retrieve later + QTabWidget *m_tab_widget; ///< Main tab widget (stored for later access) + // auto-numbering tab data + struct AutoNumTab { + SelectAutonumW *widget = nullptr; + QHash contexts; + QString prefix; + }; + AutoNumTab m_autonum_conductor; + AutoNumTab m_autonum_element; + AutoNumTab m_autonum_folio; + + void initAutoNumTab(AutoNumTab &tab, SelectAutonumW *w, const QString &prefix); + void loadAutoNumTab(AutoNumTab &tab, QSettings &settings); + void saveAutoNumContext(AutoNumTab &tab); + void removeAutoNumContext(AutoNumTab &tab); + void persistAutonumSettings(); + + static bool isPlaceholder(QComboBox *combo, const QString &name); }; /** From 7ec13cbc1a5b970c4c7f6d13c39e7d2ae7314c95 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 19 Sep 2026 23:05:39 +0200 Subject: [PATCH 08/15] Pin fetched dependencies to commit hashes instead of git tags CMake fetches pugixml, SingleApplication and the three KDE Frameworks modules by git tag. A tag is a mutable pointer that its owner can move, so two builds of the same QElectroTech commit can silently get different third-party sources, and a compromised upstream account can change what every builder downloads without anything changing in this repository. Pinning each dependency to the commit its tag currently points at closes that, while keeping the tag name in a trailing comment so the intended version stays readable. No versions change. Every pinned commit is the one its tag resolves to, checked with git ls-remote and confirmed by fetching each one and verifying that git describe reports exactly the tag. The three KDE modules live in separate repositories and therefore need separate commits, so the single KF_GIT_TAG variable becomes three per-module variables; passing -DKF_GIT_TAG= still selects one ref for all three, unpinned, exactly as before, and KF_GIT_TAG stays defined so the build summary in define_definitions.cmake is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- cmake/fetch_kdeaddons.cmake | 24 +++++++++++++++++++----- cmake/fetch_pugixml.cmake | 5 ++++- cmake/fetch_singleapplication.cmake | 4 +++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/cmake/fetch_kdeaddons.cmake b/cmake/fetch_kdeaddons.cmake index 76df0a3f4..7ba66392c 100644 --- a/cmake/fetch_kdeaddons.cmake +++ b/cmake/fetch_kdeaddons.cmake @@ -23,8 +23,22 @@ if(BUILD_WITH_KF) if(BUILD_KF) - if(NOT DEFINED KF_GIT_TAG) - # this is a more or less random version, taken as an conservative approach + # v6.10.0 is a more or less random version, taken as an conservative + # approach. Pinned to the commits those tags point at, not to the tags + # themselves; see the note in fetch_pugixml.cmake. Each module lives in its + # own repository, so the same release is a different commit in each. + set(KF_ECM_GIT_COMMIT 7dd28cc56c339c3f8fb356f7c53c0e8f61433d81) # v6.10.0 + set(KF_KCOREADDONS_GIT_COMMIT c569f974dab24b4784ad186a3db4b76b2fa36612) # v6.10.0 + set(KF_KWIDGETSADDONS_GIT_COMMIT 1abbed8a280d6626c59fb197f2c4667d2b1e7445) # v6.10.0 + + if(DEFINED KF_GIT_TAG) + # Explicit override: -DKF_GIT_TAG= selects one ref for all three + # modules, unpinned, exactly as it did before. + set(KF_ECM_GIT_COMMIT ${KF_GIT_TAG}) + set(KF_KCOREADDONS_GIT_COMMIT ${KF_GIT_TAG}) + set(KF_KWIDGETSADDONS_GIT_COMMIT ${KF_GIT_TAG}) + else() + # Keep KF_GIT_TAG defined: define_definitions.cmake reports it. set(KF_GIT_TAG v6.10.0) endif() # using a function in order to limit the scope of the variables @@ -53,19 +67,19 @@ if(BUILD_WITH_KF) FetchContent_Declare( ecm GIT_REPOSITORY https://invent.kde.org/frameworks/extra-cmake-modules.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_ECM_GIT_COMMIT}) FetchContent_MakeAvailable(ecm) FetchContent_Declare( kcoreaddons GIT_REPOSITORY https://invent.kde.org/frameworks/kcoreaddons.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_KCOREADDONS_GIT_COMMIT}) FetchContent_MakeAvailable(kcoreaddons) FetchContent_Declare( kwidgetsaddons GIT_REPOSITORY https://invent.kde.org/frameworks/kwidgetsaddons.git - GIT_TAG ${KF_GIT_TAG}) + GIT_TAG ${KF_KWIDGETSADDONS_GIT_COMMIT}) FetchContent_MakeAvailable(kwidgetsaddons) endfunction() qet_make_kf_available() diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 6aef219f7..11bf33872 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -22,10 +22,13 @@ option(BUILD_PUGIXML "Build pugixml library, use system one otherwise" YES) if(BUILD_PUGIXML) + # Pinned to the commit v1.15 points at, not to the tag itself: a tag is a + # mutable pointer that the upstream owner can move, so fetching by tag means + # a future build can silently get different code than this one did. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git - GIT_TAG v1.15) + GIT_TAG ee86beb30e4973f5feffe3ce63bfa4fbadf72f38) # v1.15 set(PUGIXML_INSTALL OFF CACHE INTERNAL "") FetchContent_MakeAvailable(pugixml) else() diff --git a/cmake/fetch_singleapplication.cmake b/cmake/fetch_singleapplication.cmake index c54de59ef..8685eea43 100644 --- a/cmake/fetch_singleapplication.cmake +++ b/cmake/fetch_singleapplication.cmake @@ -31,9 +31,11 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/SingleApplication/CMakeLists.txt") set(FETCHCONTENT_SOURCE_DIR_SINGLEAPPLICATION "${CMAKE_SOURCE_DIR}/SingleApplication") endif() +# Pinned to the commit v3.2.0 points at, not to the tag itself; see the note in +# fetch_pugixml.cmake. FetchContent_Declare( SingleApplication GIT_REPOSITORY https://github.com/itay-grudev/SingleApplication.git - GIT_TAG v3.2.0) + GIT_TAG aede311d28d20179216c5419b581087be2a8409f) # v3.2.0 set(QT_DEFAULT_MAJOR_VERSION 6) FetchContent_MakeAvailable(SingleApplication) From 3f397f5f7807b3dbdfbfb8cbc08a7c85a316c851 Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sat, 19 Sep 2026 23:36:20 +0200 Subject: [PATCH 09/15] Explain why fetching by git tag is a supply chain risk The pinning comment stated that a tag is mutable but not what an attacker does with that, so the trade-off was hard to judge for anyone reviewing or later undoing the pins. Spell out the mechanism: a tag is a name pointing at a commit, anyone with push access upstream can force-push it elsewhere, and FetchContent resolves it at build time, so a stolen maintainer account or CI token makes every fresh build compile the attacker's code while nothing changes here and the tag name still reads correctly. A commit hash is derived from the content and cannot be moved that way. Name the two cases where this was actually exploited: tj-actions/changed- files in March 2025 (CVE-2025-30066), where tags v1 through v45.0.7 were retargeted to a commit leaking CI secrets into build logs across more than 23,000 repositories, and aquasecurity/trivy-action in March 2026 (CVE-2026-33634), where 76 of 77 version tags were force-pushed to a credential stealer for about twelve hours. Both were GitHub Actions rather than CMake dependencies, which the comment says, because the point is the shared mechanism of resolving a tag at build time. Also document how to upgrade a pin, including that git ls-remote reports the tag object for an annotated tag and the commit on the "^{}" line. The note lives in fetch_pugixml.cmake, which fetch_kdeaddons.cmake and fetch_singleapplication.cmake already refer to. Comments only; no build behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) --- cmake/fetch_pugixml.cmake | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 11bf33872..6461701bd 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -22,9 +22,32 @@ option(BUILD_PUGIXML "Build pugixml library, use system one otherwise" YES) if(BUILD_PUGIXML) - # Pinned to the commit v1.15 points at, not to the tag itself: a tag is a - # mutable pointer that the upstream owner can move, so fetching by tag means - # a future build can silently get different code than this one did. + # Pinned to the commit v1.15 points at, not to the tag itself. + # + # A git tag is only a named pointer to a commit, and anyone with push access + # to the upstream repository can move it (git push --force) to any other + # commit. FetchContent fetches whatever the tag points at when the build + # runs, so if a maintainer account or CI token is compromised, the attacker + # can retarget a well-known release tag to malicious code: every fresh build + # of QElectroTech then compiles it, while nothing changes in this repository + # and the tag name still looks correct. A commit hash cannot be moved, because + # it is derived from the content: different code always has a different hash. + # + # This attack has been used in the wild: + # - March 2025, tj-actions/changed-files (CVE-2025-30066): tags v1 through + # v45.0.7 were retargeted to a commit that dumped CI secrets into build + # logs, affecting more than 23,000 repositories. + # - March 2026, aquasecurity/trivy-action (CVE-2026-33634): 76 of 77 + # version tags were force-pushed to a credential stealer and stayed + # malicious for about 12 hours. + # Both were GitHub Actions rather than CMake dependencies, but the mechanism + # is the same one FetchContent relies on here: resolving a git tag at build + # time. + # + # To upgrade, look up the new tag's commit with git ls-remote + # (for an annotated tag, take the hash on the "^{}" line, which is the + # commit; the other line is the tag object), check that it is the release you + # expect, and update both the hash and the trailing tag comment. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git From 2e27f77b2820ce09983e5045c1777eef6956391c Mon Sep 17 00:00:00 2001 From: Beat Hangartner Date: Sun, 20 Sep 2026 14:21:36 +0200 Subject: [PATCH 10/15] Added more verbose description of the difference between lightweight and annotated tags as comment. --- cmake/fetch_kdeaddons.cmake | 13 +++++++++++-- cmake/fetch_pugixml.cmake | 21 ++++++++++++++++++--- cmake/fetch_singleapplication.cmake | 6 +++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/cmake/fetch_kdeaddons.cmake b/cmake/fetch_kdeaddons.cmake index 7ba66392c..561ed3550 100644 --- a/cmake/fetch_kdeaddons.cmake +++ b/cmake/fetch_kdeaddons.cmake @@ -24,9 +24,18 @@ if(BUILD_WITH_KF) if(BUILD_KF) # v6.10.0 is a more or less random version, taken as an conservative - # approach. Pinned to the commits those tags point at, not to the tags + # approach. Pinned to the commits v6.10.0 points at, not to the tags # themselves; see the note in fetch_pugixml.cmake. Each module lives in its - # own repository, so the same release is a different commit in each. + # own repository, so the same v6.10.0 release is a different commit in + # each. + # + # KDE uses annotated tags, so "git ls-remote 'refs/tags/v6.10.0*'" + # prints two hashes per module: refs/tags/v6.10.0 is the tag object (the + # tagger, the date and the tag message) and refs/tags/v6.10.0^{} is the + # commit that object points at. The hashes below are the "^{}" ones, i.e. + # the commits. Lightweight tags, such as pugixml's v1.15 and + # SingleApplication's v3.2.0, have no tag object and print only the + # commit line. set(KF_ECM_GIT_COMMIT 7dd28cc56c339c3f8fb356f7c53c0e8f61433d81) # v6.10.0 set(KF_KCOREADDONS_GIT_COMMIT c569f974dab24b4784ad186a3db4b76b2fa36612) # v6.10.0 set(KF_KWIDGETSADDONS_GIT_COMMIT 1abbed8a280d6626c59fb197f2c4667d2b1e7445) # v6.10.0 diff --git a/cmake/fetch_pugixml.cmake b/cmake/fetch_pugixml.cmake index 6461701bd..25f235570 100644 --- a/cmake/fetch_pugixml.cmake +++ b/cmake/fetch_pugixml.cmake @@ -44,10 +44,25 @@ if(BUILD_PUGIXML) # is the same one FetchContent relies on here: resolving a git tag at build # time. # - # To upgrade, look up the new tag's commit with git ls-remote - # (for an annotated tag, take the hash on the "^{}" line, which is the - # commit; the other line is the tag object), check that it is the release you + # To upgrade, look up the commit the new tag points at with + # git ls-remote 'refs/tags/*', check that it is the release you # expect, and update both the hash and the trailing tag comment. + # + # How many lines that prints depends on which of the two kinds of tag + # upstream created: + # - A lightweight tag is nothing but a ref pointing straight at the commit, + # so ls-remote prints a single line, "refs/tags/", and its hash is + # the commit to pin. pugixml tags this way, which is why the v1.15 hash + # below is what "git ls-remote ... refs/tags/v1.15" reports directly; + # SingleApplication (v3.2.0) does the same. + # - An annotated tag is a git object in its own right, carrying a tagger, + # a date, a message and optionally a GPG signature, and pointing at the + # commit. ls-remote then prints two lines: "refs/tags/" is the tag + # object and "refs/tags/^{}" is that object dereferenced, i.e. the + # commit. The KDE Frameworks modules tag this way, so for them it is the + # "^{}" hash that belongs in the pin; the other hash identifies the tag + # object itself, which is not the source revision and changes whenever + # upstream re-creates the tag, even over the very same commit. FetchContent_Declare( pugixml GIT_REPOSITORY https://github.com/zeux/pugixml.git diff --git a/cmake/fetch_singleapplication.cmake b/cmake/fetch_singleapplication.cmake index 8685eea43..6983dfc2b 100644 --- a/cmake/fetch_singleapplication.cmake +++ b/cmake/fetch_singleapplication.cmake @@ -32,7 +32,11 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/SingleApplication/CMakeLists.txt") endif() # Pinned to the commit v3.2.0 points at, not to the tag itself; see the note in -# fetch_pugixml.cmake. +# fetch_pugixml.cmake. v3.2.0 is a lightweight tag, a ref pointing straight at +# the commit, so "git ls-remote refs/tags/v3.2.0" prints that commit and +# nothing else. An annotated tag, as KDE uses in fetch_kdeaddons.cmake, would +# print the tag object under refs/tags/v3.2.0 as well, with the commit on the +# refs/tags/v3.2.0^{} line. FetchContent_Declare( SingleApplication GIT_REPOSITORY https://github.com/itay-grudev/SingleApplication.git From ac78c51b70c81bff022a6e3650e02b4c88baee35 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 20 Sep 2026 10:44:09 -0500 Subject: [PATCH 11/15] Give every configuration page an icon at page size The settings and project dialogs list their pages with 64 or 128 pixel icons, and two pages had theirs at 22 pixels only: the terminal-strip page and the shortcuts page, which borrowed configure-toolbars. Both get a 128 pixel icon drawn in the style of the other page icons, the shortcuts page under its own name, configure-shortcuts. The SVG sources sit beside the PNGs. On a dark palette the Printing and Export pages were small too: their 128 pixel icons exist in the light theme only, and Qt inherits by name, not by size, so the dark theme's small copies were scaled up instead. make_icon_themes.py now aliases the light files of the sizes a dark name lacks, when they read on the dark window at 3:1. A test asks the theme for every page icon at 128 pixels, on both palettes. Fixes #960 --- ico/128x128/configure-shortcuts.png | Bin 0 -> 1713 bytes ico/128x128/configure-shortcuts.svg | 29 ++++++++++ ico/128x128/terminalstrip.png | Bin 0 -> 1774 bytes ico/128x128/terminalstrip.svg | 48 +++++++++++++++++ ico/copyright | 1 + ico/icon-themes.qrc | 4 ++ misc/make_icon_themes.py | 50 ++++++++++++++++++ sources/qeticons.cpp | 2 + sources/qeticons.h | 1 + sources/ui/configpage/shortcutsconfigpage.cpp | 2 +- tests/qttest/tst_qeticons.cpp | 30 +++++++++++ 11 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 ico/128x128/configure-shortcuts.png create mode 100644 ico/128x128/configure-shortcuts.svg create mode 100644 ico/128x128/terminalstrip.png create mode 100644 ico/128x128/terminalstrip.svg diff --git a/ico/128x128/configure-shortcuts.png b/ico/128x128/configure-shortcuts.png new file mode 100644 index 0000000000000000000000000000000000000000..6046a62868f3ab32adf04b54d3a7a5915a98ac52 GIT binary patch literal 1713 zcmb_d`#aNn9RF^OV{R!O%B5Bol@O9!rzVYd4kIaSFKhWoSUa$A-^Lf92ct6kU{rP;-z0vMk>IUin z0BE6(9raO0${N&EmE3$Ngslvqcpvv80Ikz#Qn_&m^+$zydIEctS`7f*hy);OB}y15 z0RZ4)@P8+i;&uP)wPci=sV4xanxT%m_$GqHg`5l{2}l$OT#XsNZ45d@~-4#iq8@rK|d0P36?f0}a0?yW?}Z{7kQmMl8$Z zd~)*MTnH~;BxRe;TOSFPqo&~3t*tsB*~!e)bNIALb1J39`%EN>U`jbRPZwbO2YW27 z@A_l5-iN|!$x%9D@eZAgrv-qtgi8w@dxeDQv5V|VgpRA<3v4BSwVj}Gm*kti&h!T| zQ`!RSVnR|0MB66M_?cJ@!K`?3bH-DWN|$bX8ApRT0pI?FqffNmsLZA`lgP5^eI03n!M%)~=uS+^T-h8a z6Te_hu^N5XO?-rP&Etc&u$wXrcV?3{=*OFHS)~C`Z#Sb;Yg1Ai(etr@BW|(V)-HaakXu(aX-lXR{;dNft6*O;FA@SyB(KH<4?%ickv6 zhRFKr6N|B*jG7ii(pEI(uAGB;EXvJUBc_Eo$%1iVq1=v9^r(9OspJH(zi#)4B^ZvV z9qXf4!x3MP4cUbxfLkS%r4hGj*soP?TnjRGa27-4NpVrcUy#v}FXyHpuSf1AyN=N> zyYs3>hIT9H5!$267fI6=#g=b!g$sIJ-HjH5;wfz7BV+u^-^!`XZLaY*ha=uR49LX8 z8{-`Gqkf0%IbF+%WiaI4qa*cGeGma7kA21kf2=O=!& + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ico/128x128/terminalstrip.png b/ico/128x128/terminalstrip.png new file mode 100644 index 0000000000000000000000000000000000000000..d950de7a0f4aedac65a5979a51d92d80098f7300 GIT binary patch literal 1774 zcmbtVYgm#86#h^nOI_Y(tejz+p)N-)yidG@7c9Kyv~X&&+BN6@7rAfmwtd@%R zLS<=IVqQQqk(rrz!+CG0p%@`5q0%#_&z@)dvp?H@yytz-dC&QCew>r(=i{lfb;ni! z0Ccckn1FQ#H=qe!kI&J^JJ$(vDZtYWsCs2QyPnvc48ca>aDe5y)&w9KX8?_jmUZk{ z2LPxQsr^*}i`4(t8)l5h9vlEbzr$i&kB}fzhVSW6cVqskgE;t5TmMg`?Yq77t-NaP zJ7IWk+j@<)Qp*v~(kjAYX=eK|(X~OCg!$K9G1o0jG@M|9cB$wp8on-m<%x7o`rz&5`IIZAo>@(-5$tR}mqdKu7&A~`I}%h)idW`XZqD%5 zI(qn zJ1K$OKQ=aY6mP*OE;!uVxK9w&F|4f|&AUa>O8b^_)S%)5ajed z(<>RSk}2m$;@|e}vtSH*$o!>0xge4&M@f%QMJ-(($l4OO5N_^lhOSy*AzAT@-w>RP1^0e7p}QS^6U7o4#hW-lvGxDbR2i9_b1dq#`EJ$E{Ojoi1^)vuFCT z@-ybbLnppS4L#?#Tt#wal6X1D!o=1^m1{TT@O~%UY_njq15e}Lav4WsLM_0QeL=qk zxbOzIH(DE>{*{lZw(hC%wvu+>g!!al>%TViRVaaC|2?Ep95oXw$C9_$fc*)?xZa#! z>jDxNB+u%A3Wa25DzL8!$zvX^rR}-Ih5{85B%xOZX^)ihU65$uDcD-^)V;eZ<{V<`z5oIS3KG%nRv< zxoDrvcZ0!SHD$WfAZ>o_7C-!HytKUrZmUtPHcf-hmDa!e7R)t0Ntzg! zKPitJId{7m_r9khau!vn%`JVM4>1h(8ACxKs$N@>Ty{F!xu0(6-E5gOIu*i_BW3N&3pUn z;S8DjTk`7NR|@I^+n}7Y_2K&t?FimQ<&hI$BVtgP?VxibG3PYF?PRP5_2MiF{N31| zouXfz=HmJ#0~&(=*2!PxwM^fwv}-6$$j}wk_u2Prxy5^V{qR4JH3&~x1Aq_o)FsYU Q(~ZA_b@#zkxkaS@0c(LPJOBUy literal 0 HcmV?d00001 diff --git a/ico/128x128/terminalstrip.svg b/ico/128x128/terminalstrip.svg new file mode 100644 index 000000000..ec2b9c651 --- /dev/null +++ b/ico/128x128/terminalstrip.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ico/copyright b/ico/copyright index 811717a37..bd84f9ad4 100644 --- a/ico/copyright +++ b/ico/copyright @@ -216,6 +216,7 @@ Clarification: ico/128x128/diagram.png by the QElectroTech team (License CC BY-ND 3.0) ico/128x128/document-export.png by the QElectroTech team (License CC BY-ND 3.0) ico/128x128/project.png by the QElectroTech team (License CC BY-ND 3.0) + ico/128x128/terminalstrip.png and configure-shortcuts.png by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), rendered from the .svg files beside them ico/scalable/pdf-import.svg by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), laid out like ico/22x22/insert-image.png ico/scalable/diagram.svg, folio-new.svg, folio-delete.svg, folio-properties.svg, label.svg by Jeff Patterson from the QElectroTech team (License CC BY-ND 3.0), the folio icons redrawn in the same style ico/256x256/* by Nuri from the QElectroTech team (License CC BY-ND 3.0) diff --git a/ico/icon-themes.qrc b/ico/icon-themes.qrc index 3f3d2d710..377a7d4b0 100644 --- a/ico/icon-themes.qrc +++ b/ico/icon-themes.qrc @@ -259,12 +259,14 @@ 48x48/user-away-extended.png 48x48/user-away.png 48x48/view-pim-journal.png + 128x128/configure-shortcuts.png 128x128/diagram.png 128x128/document-export.png 128x128/plasmagik.png 128x128/printer.png 128x128/project.png 128x128/settings.png + 128x128/terminalstrip.png scalable/diagram.svg scalable/folio-delete.svg scalable/folio-new.svg @@ -469,5 +471,7 @@ themes/qet-dark/scalable/ellipse-to-bezier.svg themes/qet-dark/scalable/rect-to-bezier.svg themes/qet-dark/scalable/rect-to-polyline.svg + 128x128/document-export.png + 128x128/printer.png diff --git a/misc/make_icon_themes.py b/misc/make_icon_themes.py index 320101877..455f6a9e6 100644 --- a/misc/make_icon_themes.py +++ b/misc/make_icon_themes.py @@ -34,6 +34,16 @@ expects, so QIcon::fromTheme("name") finds them. The dark theme only holds the icons that need a dark variant: black line art. Colored icons are not touched; the dark theme inherits them from the light one. +Qt inherits by name, not by size: once a name has any file in the dark +theme, the parent theme is never consulted for that name, and a size the +dark theme lacks is served by scaling the nearest dark file. An icon that +is line art at 22 pixels and colored at 128 (the printer) would then come +out as the 22 pixel copy scaled up on a dark palette. So for every name +the dark theme holds, the .qrc also aliases the light files of the sizes +the dark theme does not have, when they read on the dark window (3:1, +measured as tests/qttest/tst_qeticons.cpp does); a light file that does +not is left out, and Qt scales the nearest dark copy as before. + An icon counts as line art when fewer than 20% of its visible pixels are saturated. Its dark copy keeps hue and alpha and inverts lightness, scaled so pure black becomes (220, 220, 220), the dark palette's text color. @@ -101,6 +111,8 @@ WHITE_LIGHTNESS = 0.85 # a visible pixel this light counts as white WHITE_FRACTION = 0.30 # at or above this an icon is "light art" INK = 220 / 255.0 # lightness of pure black after inversion SVG_INK = "#dcdcdc" +DARK_WINDOW = (53, 53, 53) # QET::Palette::fusionDark() window color +DARK_RATIO = 3.0 # what tst_qeticons requires of a dark theme file def visible_pixels(image): @@ -131,6 +143,28 @@ def has_light_fill(image): return white / len(pixels) >= WHITE_FRACTION +def relative_luminance(rgb): + def linear(c): + c /= 255.0 + return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 + r, g, b = rgb + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + + +def reads_on_dark(image): + """True when the icon's mean visible color reaches DARK_RATIO against + the dark window, the test tst_qeticons applies to every dark file.""" + pixels = visible_pixels(image) + if not pixels: + return False + n = len(pixels) + mean = (sum(p[0] for p in pixels) // n, sum(p[1] for p in pixels) // n, + sum(p[2] for p in pixels) // n) + lighter, darker = sorted((relative_luminance(mean), relative_luminance(DARK_WINDOW)), + reverse=True) + return (lighter + 0.05) / (darker + 0.05) >= DARK_RATIO + + def invert_lightness(image): """Invert lightness, keeping hue, saturation and alpha. @@ -240,6 +274,20 @@ def main(): changed += write_if_changed(target, text) dark.append(f"themes/qet-dark/scalable/{name}") + # Complete each dark name with the light files of its other sizes + # that read on a dark window (see the module docstring): aliases, no + # copies. + dark_sizes = {} + for path in dark: + size, name = path.split("/")[2:] + dark_sizes.setdefault(name, set()).add(size) + dark_aliases = [] + for alias, source in light: + size, name = alias.split("/")[2:] + if name in dark_sizes and size not in dark_sizes[name] \ + and reads_on_dark(Image.open(ICO / source).convert("RGBA")): + dark_aliases.append((f"themes/qet-dark/{size}/{name}", source)) + # Drop dark files from an earlier run that are no longer generated, so # a reclassified icon falls back to the light theme instead of keeping # a stale copy. @@ -264,6 +312,8 @@ def main(): qrc.append(f' {source}') for path in dark: qrc.append(f" {path}") + for alias, source in dark_aliases: + qrc.append(f' {source}') qrc += [" ", "", ""] changed += write_if_changed(QRC, "\n".join(qrc)) diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index 39030275c..a605c74a7 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -37,6 +37,7 @@ namespace QET { QIcon ConductorEdit; QIcon ConductorSettings; QIcon Configure; + QIcon ConfigureShortcuts; QIcon ConfigureToolbars; QIcon IC_CopyFile; QIcon DefaultConductor; @@ -408,6 +409,7 @@ void QET::Icons::initIcons() ConductorEdit = QIcon::fromTheme("conductor-edit"); ConductorSettings = QIcon::fromTheme("conductor-reset"); Configure = QIcon::fromTheme("configure"); + ConfigureShortcuts = QIcon::fromTheme("configure-shortcuts"); ConfigureToolbars = QIcon::fromTheme("configure-toolbars"); IC_CopyFile = QIcon::fromTheme("item-copy"); DiagramAdd = QIcon::fromTheme("folio-new"); diff --git a/sources/qeticons.h b/sources/qeticons.h index d77b897fc..af21e8e54 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -44,6 +44,7 @@ namespace QET { extern QIcon ConductorEdit; extern QIcon ConductorSettings; extern QIcon Configure; + extern QIcon ConfigureShortcuts; extern QIcon ConfigureToolbars; extern QIcon IC_CopyFile; extern QIcon DefaultConductor; diff --git a/sources/ui/configpage/shortcutsconfigpage.cpp b/sources/ui/configpage/shortcutsconfigpage.cpp index e7871af22..242958fcd 100644 --- a/sources/ui/configpage/shortcutsconfigpage.cpp +++ b/sources/ui/configpage/shortcutsconfigpage.cpp @@ -381,5 +381,5 @@ QString ShortcutsConfigPage::title() const QIcon ShortcutsConfigPage::icon() const { - return QET::Icons::ConfigureToolbars; + return QET::Icons::ConfigureShortcuts; } diff --git a/tests/qttest/tst_qeticons.cpp b/tests/qttest/tst_qeticons.cpp index e40d506dc..3393702fe 100644 --- a/tests/qttest/tst_qeticons.cpp +++ b/tests/qttest/tst_qeticons.cpp @@ -67,6 +67,7 @@ class tst_qeticons : public QObject void menuIconReadsOnHighlight_data(); void menuIconReadsOnHighlight(); void panelProjectIconStaysSmall(); + void configPageIconsComeAtPageSize(); }; namespace { @@ -472,6 +473,35 @@ void tst_qeticons::panelProjectIconStaysSmall() #endif } + +/** + The configuration dialogs list their pages with 64 or 128 pixel icons + (ConfigDialog::ConfigDialog). A page whose icon exists at 22 pixels + only comes out as a 22 pixel stamp among 128 pixel neighbors, which + the terminal-strip page of the project dialog and the shortcuts page + of the settings dialog did. +*/ +void tst_qeticons::configPageIconsComeAtPageSize() +{ + const QStringList pages = {"settings", "project", "diagram", "plasmagik", "printer", + "document-export", "terminalstrip", "configure-shortcuts"}; + QStringList small; + for (const QString &theme : {"qet", "qet-dark"}) + { + QIcon::setThemeName(theme); + for (const QString &name : pages) + { + const QIcon icon = QIcon::fromTheme(name); + QVERIFY2(!icon.isNull(), qPrintable(QString("%1 missing in theme %2").arg(name, theme))); + const QSize size = icon.actualSize(QSize(128, 128)); + if (size.width() < 128 || size.height() < 128) + small << QString("%1 in %2 at %3x%4").arg(name, theme).arg(size.width()).arg(size.height()); + } + } + QIcon::setThemeName("qet"); + QVERIFY2(small.isEmpty(), qPrintable("page icons short of 128 pixels: " + small.join(", "))); +} + int main(int argc, char **argv) { if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) From 16d89f64fd15b5c6c37302b57e25bcddc12b1d31 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 20 Sep 2026 12:20:01 -0500 Subject: [PATCH 12/15] Refresh style-sheet widgets after a live palette switch Switching the system between light and dark while QET runs changed the palette of every plain widget but left widgets that carry a style sheet in the colors they were created with: the folio tab bar stayed light in dark mode, and after a dark-to-light switch its Add folio and chevron buttons hovered as a near-black box with the icon lost inside it. QApplication::setPalette() does not reach a widget with a style sheet; QStyleSheetStyle resolved its palette once, when the sheet was applied, and keeps it. Seventeen call sites set a sheet on a widget and five .ui files carry one, so any of them could show the stale palette. QET::Palette::refreshStyleSheets() re-applies each such widget's own sheet, which makes QStyleSheetStyle resolve it against the palette now in force. QETApp::useSystemPalette() calls it after installing the palette, so both the OS color scheme change and the "use system colors" setting are covered. tests/qttest/tst_qetpalette: a tab widget with the folio tab bar's sheet is still drawn in the old colors after setPalette(), which is the defect, and follows the palette after refreshStyleSheets(), in both directions. Fixes #943. --- sources/qetapp.cpp | 4 +++ sources/qetpalette.cpp | 14 +++++++- sources/qetpalette.h | 12 +++++++ sources/qetsbom.cpp | 53 +++++++++++++++++++++++++++++ sources/qetsbom.h | 40 ++++++++++++++++++++++ tests/qttest/tst_qetpalette.cpp | 60 +++++++++++++++++++++++++++++++++ 6 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 sources/qetsbom.cpp create mode 100644 sources/qetsbom.h diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 6ad06fb94..3fec32275 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -1808,6 +1808,10 @@ void QETApp::useSystemPalette(bool use) { file.close(); } } + // Widgets with their own style sheet keep the palette they were + // polished with; after a live light/dark switch they would stay in + // the old colors (see QET::Palette::refreshStyleSheets). + QET::Palette::refreshStyleSheets(); } /** diff --git a/sources/qetpalette.cpp b/sources/qetpalette.cpp index 9cf56c7cd..8b080eeff 100644 --- a/sources/qetpalette.cpp +++ b/sources/qetpalette.cpp @@ -17,10 +17,11 @@ */ #include "qetpalette.h" +#include #include #include #include - +#include #include namespace { @@ -296,3 +297,14 @@ QPixmap QET::Palette::forPalette(const QPixmap &pixmap, const QPalette &palette) result.setDevicePixelRatio(pixmap.devicePixelRatio()); return result; } + +void QET::Palette::refreshStyleSheets() +{ + // Setting the same sheet again is not a no-op: QWidget::setStyleSheet() + // asks QStyleSheetStyle to repolish the widget, which recomputes its + // palette from the application palette now in force. + const QWidgetList widgets = QApplication::allWidgets(); + for (QWidget *widget : widgets) + if (!widget->styleSheet().isEmpty()) + widget->setStyleSheet(widget->styleSheet()); +} diff --git a/sources/qetpalette.h b/sources/qetpalette.h index 536b660d2..e1a5cabcd 100644 --- a/sources/qetpalette.h +++ b/sources/qetpalette.h @@ -135,6 +135,18 @@ namespace QET { left alone either way. */ QPixmap forPalette(const QPixmap &pixmap, const QPalette &palette); + + /** + Make every widget that carries a style sheet take the current + application palette. QApplication::setPalette() reaches plain + widgets, but a widget with a style sheet keeps the palette + QStyleSheetStyle resolved when the sheet was applied, so after a + live light/dark switch it is drawn in the old colors (the folio + tab bar and its buttons, the element info widgets, several + configuration pages). Re-applying each widget's own sheet makes + QStyleSheetStyle resolve it again. Call after setPalette(). + */ + void refreshStyleSheets(); } } diff --git a/sources/qetsbom.cpp b/sources/qetsbom.cpp new file mode 100644 index 000000000..b9ac01a88 --- /dev/null +++ b/sources/qetsbom.cpp @@ -0,0 +1,53 @@ +/* + 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 "qetsbom.h" + +#include +#include +#include + +// Generated by cmake/generate_sbom.cmake at configure time from whatever +// find_package()/FetchContent resolution this build actually used. +#include "sbom_data.h" + +QList QetSbom::components() +{ + QList list; + + const QJsonDocument doc = QJsonDocument::fromJson(QByteArray(qet_sbom_json)); + const QJsonArray components = doc.object().value("components").toArray(); + + for (const QJsonValue &value : components) + { + const QJsonObject obj = value.toObject(); + list.append({ + obj.value("name").toString(), + obj.value("version").toString(), + obj.value("license").toString(), + obj.value("homepage").toString(), + obj.value("source").toString() + }); + } + + return list; +} + +QString QetSbom::rawJson() +{ + return QString::fromUtf8(qet_sbom_json); +} diff --git a/sources/qetsbom.h b/sources/qetsbom.h new file mode 100644 index 000000000..d4ec5bcd5 --- /dev/null +++ b/sources/qetsbom.h @@ -0,0 +1,40 @@ +/* + 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 QETSBOM_H +#define QETSBOM_H + +#include +#include + +/// One third-party component resolved for this specific build, as recorded +/// by cmake/generate_sbom.cmake at configure time. +struct QetSbomComponent +{ + QString name; + QString version; + QString license; + QString homepage; + QString source; ///< "system", "fetched" or "submodule" +}; + +namespace QetSbom { + QList components(); + QString rawJson(); +} + +#endif diff --git a/tests/qttest/tst_qetpalette.cpp b/tests/qttest/tst_qetpalette.cpp index 4ced9d5a1..5c504e66a 100644 --- a/tests/qttest/tst_qetpalette.cpp +++ b/tests/qttest/tst_qetpalette.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include "inkcontrast.h" #include "ElementsCollection/elementpreviewdelegate.h" @@ -84,6 +85,7 @@ class tst_qetpalette : public QObject void paletteViewDrawsTheRubberBand(); void paletteViewFollowsTheApplicationUnderAStyleSheet(); void paletteViewFillsWhatTheSceneLeavesBlank(); + void styleSheetWidgetsFollowPaletteChange(); private: static void addPaletteRows(); @@ -815,6 +817,64 @@ void tst_qetpalette::invertLightnessSpeed() } } +/** + A widget with a style sheet keeps the palette QStyleSheetStyle + resolved when the sheet was applied: after QApplication::setPalette() + it is still drawn in the old colors, which is what the folio tab bar + showed after a live light/dark switch. refreshStyleSheets() brings + it in line. Both directions are checked. +*/ +void tst_qetpalette::styleSheetWidgetsFollowPaletteChange() +{ + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(QET::Palette::fusionLight()); + + QWidget top; + auto *layout = new QHBoxLayout(&top); + auto *tabs = new QTabWidget; + tabs->addTab(new QWidget, "1"); + tabs->setStyleSheet("QTabBar::scroller {width: 0px;}"); // as sources/projectview.cpp + auto *button = new QToolButton; + button->setText("+"); + button->setAutoRaise(true); + tabs->setCornerWidget(button, Qt::TopRightCorner); + auto *plain = new QLabel("plain"); + plain->setAutoFillBackground(true); + layout->addWidget(tabs); + layout->addWidget(plain); + top.resize(300, 120); + top.show(); + QVERIFY(QTest::qWaitForWindowExposed(&top)); + + // The most frequent color of a widget's rendering: its background. + auto background = [](QWidget *w) { + const QImage image = w->grab().toImage(); + QHash histogram; + for (int y = 0; y < image.height(); ++y) + for (int x = 0; x < image.width(); ++x) + ++histogram[image.pixel(x, y)]; + QRgb best = 0; + int count = -1; + for (auto it = histogram.cbegin(); it != histogram.cend(); ++it) + if (it.value() > count) { count = it.value(); best = it.key(); } + return best; + }; + auto window = [](const QPalette &p) { return p.color(QPalette::Active, QPalette::Window).rgb(); }; + + for (const QPalette &palette : {QET::Palette::fusionDark(), QET::Palette::fusionLight()}) + { + QApplication::setPalette(palette); + QTest::qWait(50); + QCOMPARE(background(plain), window(palette)); + // Qt leaves the style-sheet widget behind; this is the defect. + QVERIFY2(background(button) != window(palette), "Qt now updates style-sheet widgets itself; refreshStyleSheets() is redundant"); + + QET::Palette::refreshStyleSheets(); + QTest::qWait(50); + QCOMPARE(background(button), window(palette)); + } +} + int main(int argc, char **argv) { // Widgets are painted for real, on Qt's offscreen platform so the test From 8b2548d60105b84666dc9ee1b511caa4d372c0a5 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 20 Sep 2026 10:20:17 -0500 Subject: [PATCH 13/15] Keep the dark canvas on QGraphicsView's own update path Laurent found that moving an element on a #954 build left its terminals' help lines behind at every step, on both palettes. The view listened to QGraphicsScene::changed() so that render() would keep the scene's updates flowing, and any receiver on that signal puts the scene on its Qt 4.4 compatibility path, which erases a moved item's own old rect only: children bigger than their parent stay on screen. Master hides that with FullViewportUpdate, which repaints the whole viewport on every change. Paint the inverted folio through QGraphicsView::paintEvent() instead, with IndirectPainting set for that call and the draw hooks painting into a viewport-sized image, and hand the scene the viewport when the items are drawn so it records where each item was painted, including a child whose geometry is set while its parent paints. The listener and the full-viewport update go. Two tests move a parent with a sheet-wide child, read the backing store, and require the repaint to be a partial one. --- sources/palettegraphicsview.cpp | 167 +++++++++++++++++++++----------- sources/palettegraphicsview.h | 76 ++++++++------- tests/qttest/tst_qetpalette.cpp | 142 +++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 90 deletions(-) diff --git a/sources/palettegraphicsview.cpp b/sources/palettegraphicsview.cpp index bd5247e0d..b18bf725b 100644 --- a/sources/palettegraphicsview.cpp +++ b/sources/palettegraphicsview.cpp @@ -21,36 +21,40 @@ #include #include -#include #include #include #include #include +namespace { + /** + QGraphicsScene::drawItems() is protected, and QGraphicsView::drawItems() + hands the scene the viewport only when the painter is on it. The + view paints into an image, and still needs the scene to get the + viewport: that is what makes the scene record where it painted + each item, which is where the item is erased from when it moves. + Naming the member through a derived class is the standard way to + a pointer to a protected member; a call through it dispatches to + the scene's own override, if any. + */ + struct SceneAccess : QGraphicsScene + { + using DrawItems = void (QGraphicsScene::*)(QPainter *, int, QGraphicsItem *[], + const QStyleOptionGraphicsItem[], QWidget *); + static DrawItems drawItemsPointer() { return &SceneAccess::drawItems; } + }; +} + PaletteGraphicsView::PaletteGraphicsView(QWidget *parent) : QGraphicsView(parent) { qApp->installEventFilter(this); - setViewportUpdateMode(QGraphicsView::FullViewportUpdate); } PaletteGraphicsView::PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent) : QGraphicsView(scene, parent) { qApp->installEventFilter(this); - setViewportUpdateMode(QGraphicsView::FullViewportUpdate); - listenToScene(scene); -} - -/** - @brief PaletteGraphicsView::setScene - Same as QGraphicsView::setScene, and keeps the scene's updates flowing - (see the class description). -*/ -void PaletteGraphicsView::setScene(QGraphicsScene *scene) -{ - QGraphicsView::setScene(scene); - listenToScene(scene); } /** @@ -72,21 +76,6 @@ void PaletteGraphicsView::paintingInverted(bool inverted) Q_UNUSED(inverted) } -/** - @brief PaletteGraphicsView::listenToScene - Connect a receiver to the scene's changed() signal, dropping the one - on the previous scene. Any receiver does; this one has nothing to do. - The connection dies with the view. -*/ -void PaletteGraphicsView::listenToScene(QGraphicsScene *scene) -{ - disconnect(m_scene_connection); - m_scene_connection = QMetaObject::Connection(); - if (scene) - m_scene_connection = connect(scene, &QGraphicsScene::changed, - this, [](const QList &) {}); -} - /** @brief PaletteGraphicsView::eventFilter Repaint the whole viewport when the application palette changes. Qt @@ -111,57 +100,121 @@ bool PaletteGraphicsView::eventFilter(QObject *watched, QEvent *event) void PaletteGraphicsView::paintEvent(QPaintEvent *event) { if (invertsLightness()) - paintInverted(event->rect()); - else - QGraphicsView::paintEvent(event); + { + paintInverted(event); + return; + } + m_buffer = QImage(); + QGraphicsView::paintEvent(event); } /** @brief PaletteGraphicsView::paintInverted - Render \a area of the viewport into an off-screen image, invert the - lightness of that image between the palette's Base and Text colors and - blit it to the viewport. Inverting the finished rendering turns the - white sheet dark and the black ink light in one pass, and keeps the - hue of colored strokes. - @param area the part of the viewport to repaint, in viewport coordinates + Run QGraphicsView::paintEvent() with the drawing hooks redirected to + an off-screen image of the viewport, then invert the lightness of the + exposed part of that image between the palette's Base and Text colors + and blit it to the viewport. Inverting the finished rendering turns + the white sheet dark and the black ink light in one pass, and keeps + the hue of colored strokes. The image is in viewport coordinates, so + the hooks paint with the view's own transform and the scene records + the items' places in the viewport, as it does on a light palette. + @param event the paint event, for the exposed area */ -void PaletteGraphicsView::paintInverted(const QRect &area) +void PaletteGraphicsView::paintInverted(QPaintEvent *event) { - const QRect rect = area.intersected(viewport()->rect()); - if (rect.isEmpty()) + const QRect exposed = event->rect().intersected(viewport()->rect()); + if (exposed.isEmpty()) return; const qreal ratio = viewport()->devicePixelRatioF(); - QImage buffer(qCeil(rect.width() * ratio), qCeil(rect.height() * ratio), - QImage::Format_RGB32); - buffer.setDevicePixelRatio(ratio); - // render() paints only what the scene draws; what it leaves blank is + const QSize size(qCeil(viewport()->width() * ratio), qCeil(viewport()->height() * ratio)); + if (m_buffer.size() != size || m_buffer.devicePixelRatio() != ratio) + { + m_buffer = QImage(size, QImage::Format_RGB32); + m_buffer.setDevicePixelRatio(ratio); + } + + m_buffer_painter.begin(&m_buffer); + // The hooks paint only what the scene draws; what they leave blank is // the white sheet, which the inversion turns into the Base color. - buffer.fill(Qt::white); + m_buffer_painter.fillRect(exposed, Qt::white); + m_buffer_painter.setClipRect(exposed); + m_buffer_painter.setRenderHints(renderHints()); + m_buffer_painter.setWorldTransform(viewportTransform()); - QPainter buffer_painter(&buffer); - buffer_painter.setRenderHints(renderHints()); + m_inverting = true; paintingInverted(true); - render(&buffer_painter, QRectF(QPointF(0, 0), QSizeF(rect.size())), - rect, Qt::IgnoreAspectRatio); + const OptimizationFlags flags = optimizationFlags(); + setOptimizationFlag(QGraphicsView::IndirectPainting, true); + QGraphicsView::paintEvent(event); + setOptimizationFlags(flags); paintingInverted(false); - buffer_painter.end(); + m_inverting = false; + m_buffer_painter.end(); + blitInverted(exposed); +} + +/** + @brief PaletteGraphicsView::drawBackground + Into the off-screen image while painting inverted, else as + QGraphicsView. +*/ +void PaletteGraphicsView::drawBackground(QPainter *painter, const QRectF &rect) +{ + QGraphicsView::drawBackground(m_inverting ? &m_buffer_painter : painter, rect); +} + +/** + @brief PaletteGraphicsView::drawItems + Into the off-screen image while painting inverted, with the viewport + as the scene's widget (see SceneAccess), else as QGraphicsView. +*/ +void PaletteGraphicsView::drawItems(QPainter *painter, int count, QGraphicsItem *items[], + const QStyleOptionGraphicsItem options[]) +{ + if (m_inverting && scene()) + (scene()->*SceneAccess::drawItemsPointer())(&m_buffer_painter, count, items, options, viewport()); + else + QGraphicsView::drawItems(painter, count, items, options); +} + +/** + @brief PaletteGraphicsView::drawForeground + Into the off-screen image while painting inverted, else as + QGraphicsView. +*/ +void PaletteGraphicsView::drawForeground(QPainter *painter, const QRectF &rect) +{ + QGraphicsView::drawForeground(m_inverting ? &m_buffer_painter : painter, rect); +} + +/** + @brief PaletteGraphicsView::blitInverted + Invert the lightness of \a area of the off-screen image and draw it on + the viewport, then the selection rubber band on top: the one + QGraphicsView::paintEvent() drew went under the blit. + @param area the part of the viewport to blit, in viewport coordinates +*/ +void PaletteGraphicsView::blitInverted(const QRect &area) +{ + const qreal ratio = m_buffer.devicePixelRatio(); + QImage part = m_buffer.copy(QRectF(area.topLeft() * ratio, area.size() * ratio).toAlignedRect()); + part.setDevicePixelRatio(ratio); // The application palette, for the reason given in invertsLightness(). const QPalette application_palette = QApplication::palette(); - QET::Palette::invertLightness(buffer, application_palette.color(QPalette::Base), + QET::Palette::invertLightness(part, application_palette.color(QPalette::Base), application_palette.color(QPalette::Text)); QPainter painter(viewport()); - painter.drawImage(rect.topLeft(), buffer); + painter.drawImage(area.topLeft(), part); drawRubberBand(painter); } /** @brief PaletteGraphicsView::drawRubberBand - Draw the selection rubber band the way QGraphicsView::paintEvent does. - Rendering the view into an off-screen image skips it, so it is drawn - here instead, after the inversion, in the palette colors. + Draw the selection rubber band the way QGraphicsView::paintEvent does, + after the inversion, in the palette colors. @param painter a painter on the viewport */ void PaletteGraphicsView::drawRubberBand(QPainter &painter) diff --git a/sources/palettegraphicsview.h b/sources/palettegraphicsview.h index 77924c356..512bba283 100644 --- a/sources/palettegraphicsview.h +++ b/sources/palettegraphicsview.h @@ -19,38 +19,39 @@ #define PALETTE_GRAPHICS_VIEW_H #include - -class QPainter; +#include +#include /** - A QGraphicsView that shows its scene with inverted lightness while its - palette is dark: white becomes the palette's Base color, black its Text - color, and colored strokes keep their hue. The scene itself is left as - drawn, so printing and exporting it still give black on white. On a - light palette the view paints exactly as QGraphicsView does. + A QGraphicsView that shows its scene with inverted lightness while the + application palette is dark: white becomes the palette's Base color, + black its Text color, and colored strokes keep their hue. The scene + itself is left as drawn, so printing and exporting it still give black + on white. On a light palette the view paints exactly as QGraphicsView + does. - The view paints through QGraphicsView::render() into an image and blits - the inverted image. QGraphicsView delivers scene updates straight to - its viewport when nobody listens to QGraphicsScene::changed(), and in - that mode the scene clears its "update everything" flag only when the - items are painted straight onto the viewport, which never happens - here: from the second QGraphicsScene::update() on, every scene update - and every item update would wait for an unrelated repaint. So the view - listens to changed() on every scene it is given, which makes the scene - clear the flag before it emits. Set the scene through this class, not - through a QGraphicsView pointer. + On a dark palette the view still runs QGraphicsView::paintEvent(), with + the IndirectPainting flag set for the duration of that call, so that + the background, the items and the foreground come through the + drawBackground(), drawItems() and drawForeground() hooks. (That flag + selects Qt's older item-painting algorithm, which first builds a list + of the exposed items and their style options; it is set only while + the view paints inverted.) The hooks + paint into an off-screen image the size of the viewport, in viewport + coordinates; paintEvent() then inverts the lightness of the exposed + part of that image and blits it. Going through the real paint event, + and handing the scene the viewport when the items are drawn, keeps the + view on QGraphicsView's default update path, which erases a moved item + where it was last painted, children included, even a child whose + geometry is set while its parent is painted (a terminal's help lines). + The alternative, rendering with QGraphicsView::render(), needed a + receiver on QGraphicsScene::changed() to keep the scene's updates + flowing, and that receiver puts the scene on its Qt 4.4 compatibility + path, which erases only the moved item's own old rectangle: a moved + element then left its terminals' help lines, which span the whole + sheet, behind at every step (#954). - The constructor also forces QGraphicsView::FullViewportUpdate in place - of the default MinimalViewportUpdate. #954 shipped with the default - kept, and moving an item then left conductor-shaped ghosts behind on - both a light and a dark palette, so the cause is shared code, not - paintInverted(): most likely listening to changed() at all, above, - changes which of QGraphicsScene's two update paths a view is on, and - MinimalViewportUpdate's job of turning the scene's reported dirty - rects into the smallest correct viewport region is where that would - show up first. FullViewportUpdate removes the need to get that region - right by repainting the whole viewport on every update; the class's - own benchmark already shows that cost is small next to a frame budget. + The CacheBackground cache mode is not supported on the inverted path. */ class PaletteGraphicsView : public QGraphicsView { @@ -60,14 +61,17 @@ class PaletteGraphicsView : public QGraphicsView explicit PaletteGraphicsView(QWidget *parent = nullptr); explicit PaletteGraphicsView(QGraphicsScene *scene, QWidget *parent = nullptr); - void setScene(QGraphicsScene *scene); bool invertsLightness() const; protected: bool eventFilter(QObject *watched, QEvent *event) override; void paintEvent(QPaintEvent *event) override; + void drawBackground(QPainter *painter, const QRectF &rect) override; + void drawItems(QPainter *painter, int count, QGraphicsItem *items[], + const QStyleOptionGraphicsItem options[]) override; + void drawForeground(QPainter *painter, const QRectF &rect) override; /** - Called with true right before the scene is rendered for an + Called with true right before the scene is painted for an inverted display and with false right after, so a scene can adapt what it draws (a softer grid, for instance). Does nothing by default. @@ -75,11 +79,17 @@ class PaletteGraphicsView : public QGraphicsView virtual void paintingInverted(bool inverted); private: - void listenToScene(QGraphicsScene *scene); - void paintInverted(const QRect &area); + void paintInverted(QPaintEvent *event); + void blitInverted(const QRect &area); void drawRubberBand(QPainter &painter); - QMetaObject::Connection m_scene_connection; + /// The off-screen image the hooks paint into while m_inverting: + /// the viewport's size, in its coordinates. Kept between paints, + /// dropped when the view paints on a light palette again. + QImage m_buffer; + QPainter m_buffer_painter; + /// True while paintEvent() paints for an inverted display. + bool m_inverting = false; }; #endif diff --git a/tests/qttest/tst_qetpalette.cpp b/tests/qttest/tst_qetpalette.cpp index 5c504e66a..c15ebdeb0 100644 --- a/tests/qttest/tst_qetpalette.cpp +++ b/tests/qttest/tst_qetpalette.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -85,6 +86,10 @@ class tst_qetpalette : public QObject void paletteViewDrawsTheRubberBand(); void paletteViewFollowsTheApplicationUnderAStyleSheet(); void paletteViewFillsWhatTheSceneLeavesBlank(); + void paletteViewErasesMovedChildren_data(); + void paletteViewErasesMovedChildren(); + void paletteViewErasesChildrenMovedWhilePainting_data(); + void paletteViewErasesChildrenMovedWhilePainting(); void styleSheetWidgetsFollowPaletteChange(); private: @@ -573,6 +578,37 @@ namespace { } }; + /** + A parent that, like a Terminal with its help lines, gives its child + line a new geometry from inside paint(): the line always runs + across the sheet at the parent's height, whatever the parent's + position. Where the child is painted is therefore only known once + the parent has been painted. + */ + class PaintTimeHelpLine : public QGraphicsRectItem + { + public: + explicit PaintTimeHelpLine(const QRectF &sheet) : + QGraphicsRectItem(0, 0, 20, 20), + m_sheet(sheet), + m_line(new QGraphicsLineItem(this)) + { + setPen(Qt::NoPen); + setBrush(Qt::black); + m_line->setPen(QPen(Qt::black, 2)); + } + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override + { + QGraphicsRectItem::paint(painter, option, widget); + const qreal y = scenePos().y() + 10; + m_line->setLine(QLineF(m_line->mapFromScene(QPointF(m_sheet.left(), y)), + m_line->mapFromScene(QPointF(m_sheet.right(), y)))); + } + private: + QRectF m_sheet; + QGraphicsLineItem *m_line; + }; + /** A small folio: a white sheet with a black line and a red box. Returns the box, which is selectable. @@ -803,6 +839,112 @@ void tst_qetpalette::paletteViewFillsWhatTheSceneLeavesBlank() QCOMPARE(image.pixelColor(100, 100), base); } +/** + Moving an item must erase it where it was, children included, even a + child far bigger than its parent: a terminal's help lines span the + whole sheet. QGraphicsView does that on its default update path by + remembering where each item was last painted. A receiver on + QGraphicsScene::changed() switches the scene to its Qt 4.4 + compatibility path, which erases only the parent's own old rect and + leaves the children's trails behind (reported on #954). The check + reads the window's backing store, not a fresh rendering, and makes + sure the repaint after the move was a partial one: a full repaint of + the viewport would hide the bug, not fix it. +*/ +void tst_qetpalette::paletteViewErasesMovedChildren_data() +{ + addPaletteRows(); +} + +void tst_qetpalette::paletteViewErasesMovedChildren() +{ + QFETCH(QPalette, palette); + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(palette); + + QGraphicsScene scene(0, 0, 200, 120); + scene.setBackgroundBrush(Qt::white); + QGraphicsRectItem *parent = scene.addRect(0, 0, 20, 20, QPen(Qt::NoPen), QBrush(Qt::black)); + parent->setPos(20, 20); + QGraphicsLineItem *line = new QGraphicsLineItem(-10, 10, 170, 10, parent); + line->setPen(QPen(Qt::black, 2)); + + ProbeView view(&scene); + showAsSheet(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + QTRY_VERIFY(view.paints >= 1); + QScreen *screen = view.screen(); + const WId window = view.window()->winId(); + const QImage before = screen->grabWindow(window, 0, 0, 200, 120).toImage(); + const QColor sheet = sheetColor(before); + QVERIFY2(before.pixelColor(120, 30) != sheet, "the child line is not drawn"); + QVERIFY2(before.pixelColor(30, 30) != sheet, "the parent is not drawn"); + + const int from = view.paint_rects.size(); + parent->setPos(20, 70); + QTRY_VERIFY(view.paint_rects.size() > from); + QVERIFY2(!view.fullyRepaintedSince(from), "the move repainted the whole viewport"); + + const QImage after = screen->grabWindow(window, 0, 0, 200, 120).toImage(); + QVERIFY2(after.pixelColor(120, 30) == sheet, + qPrintable(QString("the child line left a trail: %1").arg(after.pixelColor(120, 30).name()))); + QVERIFY2(after.pixelColor(30, 30) == sheet, + qPrintable(QString("the parent left a trail: %1").arg(after.pixelColor(30, 30).name()))); + QVERIFY2(after.pixelColor(120, 80) != sheet, "the child line is missing at its new place"); + QVERIFY2(after.pixelColor(30, 80) != sheet, "the parent is missing at its new place"); +} + +/** + The same, for a child whose geometry is set while its parent is + painted, as a Terminal does with its help lines: the view must record + where the child was painted, not where it was before the paint, or + the next move erases the wrong place. Two moves, because the first + paint after a move is where the child gets its new geometry. +*/ +void tst_qetpalette::paletteViewErasesChildrenMovedWhilePainting_data() +{ + addPaletteRows(); +} + +void tst_qetpalette::paletteViewErasesChildrenMovedWhilePainting() +{ + QFETCH(QPalette, palette); + QApplication::setStyle(QStyleFactory::create("Fusion")); + QApplication::setPalette(palette); + + QGraphicsScene scene(0, 0, 200, 120); + scene.setBackgroundBrush(Qt::white); + PaintTimeHelpLine *parent = new PaintTimeHelpLine(scene.sceneRect()); + scene.addItem(parent); + parent->setPos(20, 20); + + ProbeView view(&scene); + showAsSheet(view); + QVERIFY(QTest::qWaitForWindowExposed(&view)); + QTRY_VERIFY(view.paints >= 1); + QScreen *screen = view.screen(); + const WId window = view.window()->winId(); + const QColor sheet = sheetColor(screen->grabWindow(window, 0, 0, 200, 120).toImage()); + + int from = view.paint_rects.size(); + parent->setPos(20, 50); + QTRY_VERIFY(view.paint_rects.size() > from); + QTRY_VERIFY2(screen->grabWindow(window, 0, 0, 200, 120).toImage().pixelColor(120, 60) != sheet, + "the line did not follow the parent"); + + from = view.paint_rects.size(); + parent->setPos(20, 80); + QTRY_VERIFY(view.paint_rects.size() > from); + QVERIFY2(!view.fullyRepaintedSince(from), "the move repainted the whole viewport"); + QImage after; + QTRY_VERIFY2((after = screen->grabWindow(window, 0, 0, 200, 120).toImage()).pixelColor(120, 90) != sheet, + "the line is missing at its new place"); + QVERIFY2(after.pixelColor(120, 60) == sheet, + qPrintable(QString("the line left a trail: %1").arg(after.pixelColor(120, 60).name()))); + QVERIFY2(after.pixelColor(120, 30) == sheet, + qPrintable(QString("the first line was never erased: %1").arg(after.pixelColor(120, 30).name()))); +} + /** The inversion runs on every repaint of the folio, so a 4K viewport has to cost a few milliseconds. Reported, not asserted: the bound From 55ad2eccbfecb9f89d0a44d9c683672bbe18c8d9 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 20 Sep 2026 12:43:57 -0500 Subject: [PATCH 14/15] Remove two files that slipped into #944 sources/qetsbom.cpp and sources/qetsbom.h were untracked local files that a directory-wide add swept into the rebuilt #944 commit. Nothing references them; the build does not compile them. --- sources/qetsbom.cpp | 53 --------------------------------------------- sources/qetsbom.h | 40 ---------------------------------- 2 files changed, 93 deletions(-) delete mode 100644 sources/qetsbom.cpp delete mode 100644 sources/qetsbom.h diff --git a/sources/qetsbom.cpp b/sources/qetsbom.cpp deleted file mode 100644 index b9ac01a88..000000000 --- a/sources/qetsbom.cpp +++ /dev/null @@ -1,53 +0,0 @@ -/* - 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 "qetsbom.h" - -#include -#include -#include - -// Generated by cmake/generate_sbom.cmake at configure time from whatever -// find_package()/FetchContent resolution this build actually used. -#include "sbom_data.h" - -QList QetSbom::components() -{ - QList list; - - const QJsonDocument doc = QJsonDocument::fromJson(QByteArray(qet_sbom_json)); - const QJsonArray components = doc.object().value("components").toArray(); - - for (const QJsonValue &value : components) - { - const QJsonObject obj = value.toObject(); - list.append({ - obj.value("name").toString(), - obj.value("version").toString(), - obj.value("license").toString(), - obj.value("homepage").toString(), - obj.value("source").toString() - }); - } - - return list; -} - -QString QetSbom::rawJson() -{ - return QString::fromUtf8(qet_sbom_json); -} diff --git a/sources/qetsbom.h b/sources/qetsbom.h deleted file mode 100644 index d4ec5bcd5..000000000 --- a/sources/qetsbom.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - 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 QETSBOM_H -#define QETSBOM_H - -#include -#include - -/// One third-party component resolved for this specific build, as recorded -/// by cmake/generate_sbom.cmake at configure time. -struct QetSbomComponent -{ - QString name; - QString version; - QString license; - QString homepage; - QString source; ///< "system", "fetched" or "submodule" -}; - -namespace QetSbom { - QList components(); - QString rawJson(); -} - -#endif From 04463f09548ace7597dc9631565a4c2ce01e7fc3 Mon Sep 17 00:00:00 2001 From: Jeff Patterson Date: Sun, 20 Sep 2026 11:57:21 -0500 Subject: [PATCH 15/15] Darken the hover ink on a light button face QETStyle::hoverColor() lightened the highlight color until it read at 3.5:1 against the Light role. On a dark face that is the way to go; on a light face lightening only fades the ink, so with a pale platform accent that QET keeps (macOS's green selection color, black selection text) the loop ran to white and every hovered line-art icon vanished. The ink now moves away from the face, darker on a light face, lighter on a dark one, and falls back to the button text color if twenty steps are not enough. The hover test gets a row with that accent on each palette, and a new test sweeps accents across hues and lightness on both palettes and requires the hover ink to read at 3:1 on the face. Fixes #962 --- sources/qetstyle.cpp | 15 ++++++++--- tests/qttest/tst_qeticons.cpp | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/sources/qetstyle.cpp b/sources/qetstyle.cpp index 9dc63ac13..d8a292240 100644 --- a/sources/qetstyle.cpp +++ b/sources/qetstyle.cpp @@ -67,20 +67,27 @@ bool QETStyle::isLineArt(const QImage &image) /** @brief QETStyle::hoverColor The palette's highlight color is the accent users already know from - selections. On a dark palette it is too dark to read on a hovered - button face, so it is lightened, a step at a time, until it reaches + selections, moved away from the hovered button face until it reaches 3:1 (WCAG 1.4.11) against the Light role: Fusion paints a hovered auto-raise button with a gradient that runs from Button up to about - that color, and the icon has to read on the lightest part of it. + that color, and the icon has to read on the lightest part of it. On a + dark face the accent is lightened, a step at a time; on a light face it + is darkened, which keeps a pale accent (macOS's green or yellow + selection color, which comes with black selection text) from being + pushed to white. Should twenty steps not get there, the button text + color serves, which reads on the face by construction. */ QColor QETStyle::hoverColor(const QPalette &palette) { const QColor face = palette.color(QPalette::Active, QPalette::Light); + const bool light_face = face.lightnessF() > 0.5; QColor ink = palette.color(QPalette::Active, QPalette::Highlight); // 3.5 rather than 3.0: the top of Fusion's hover gradient is a shade // lighter than the Light role, so the icon needs some headroom there. for (int step = 0; step < 20 && QET::Palette::contrastRatio(ink, face) < 3.5; ++step) - ink = ink.lighter(110); + ink = light_face ? ink.darker(110) : ink.lighter(110); + if (QET::Palette::contrastRatio(ink, face) < 3.5) + ink = palette.color(QPalette::Active, QPalette::ButtonText); return ink; } diff --git a/tests/qttest/tst_qeticons.cpp b/tests/qttest/tst_qeticons.cpp index 3393702fe..5d0d7f0a8 100644 --- a/tests/qttest/tst_qeticons.cpp +++ b/tests/qttest/tst_qeticons.cpp @@ -63,6 +63,8 @@ class tst_qeticons : public QObject void toolbarIconIsReadable(); void hoverChangesTheIcon_data(); void hoverChangesTheIcon(); + void hoverInkReadsWithAnyAccent_data(); + void hoverInkReadsWithAnyAccent(); void coloredIconKeepsItsColorsOnHover(); void menuIconReadsOnHighlight_data(); void menuIconReadsOnHighlight(); @@ -107,6 +109,17 @@ namespace { } const QStringList kSizes = {"16x16", "22x22", "32x32", "48x48", "128x128"}; + + /// palette with the given selection colors, as QET::Palette::withPlatformAccent leaves them. + QPalette withAccent(QPalette palette, const QColor &highlight, const QColor &highlighted_text) + { + for (QPalette::ColorGroup group : {QPalette::Active, QPalette::Inactive}) + { + palette.setColor(group, QPalette::Highlight, highlight); + palette.setColor(group, QPalette::HighlightedText, highlighted_text); + } + return palette; + } } void tst_qeticons::initTestCase() @@ -334,6 +347,12 @@ void tst_qeticons::hoverChangesTheIcon_data() // nothing in the frame, so the icon is the only hover signal there. QTest::newRow("light-checked") << "qet" << QET::Palette::fusionLight() << true; QTest::newRow("dark-checked") << "qet-dark" << QET::Palette::fusionDark() << true; + // A platform accent QET keeps because its selection text reads on + // it: macOS's green selection color comes with black text. Lightening + // it toward 3:1 against a white face can only end at white, which + // made every hovered line-art icon vanish on a light palette. + QTest::newRow("light-pale-accent") << "qet" << withAccent(QET::Palette::fusionLight(), QColor(198, 231, 188), Qt::black) << false; + QTest::newRow("dark-pale-accent") << "qet-dark" << withAccent(QET::Palette::fusionDark(), QColor(198, 231, 188), Qt::black) << false; } /** @@ -502,6 +521,36 @@ void tst_qeticons::configPageIconsComeAtPageSize() QVERIFY2(small.isEmpty(), qPrintable("page icons short of 128 pixels: " + small.join(", "))); } +/** + Whatever accent the platform hands QET, the hover ink must read on the + hovered button face of both palettes: a sweep over hues at every + lightness, including the pale and the near-black ones. +*/ +void tst_qeticons::hoverInkReadsWithAnyAccent_data() +{ + QTest::addColumn("palette"); + for (int lightness = 10; lightness <= 250; lightness += 40) + for (int hue = 0; hue < 360; hue += 60) + { + const QColor accent = QColor::fromHsl(hue, 200, lightness); + const QColor text = QET::Palette::contrastRatio(accent, Qt::white) >= 4.5 ? Qt::white : Qt::black; + QTest::newRow(qPrintable(QString("light-%1").arg(accent.name()))) + << withAccent(QET::Palette::fusionLight(), accent, text); + QTest::newRow(qPrintable(QString("dark-%1").arg(accent.name()))) + << withAccent(QET::Palette::fusionDark(), accent, text); + } +} + +void tst_qeticons::hoverInkReadsWithAnyAccent() +{ + QFETCH(QPalette, palette); + const QColor face = palette.color(QPalette::Active, QPalette::Light); + const QColor ink = QETStyle::hoverColor(palette); + const double contrast = QET::Palette::contrastRatio(ink, face); + QVERIFY2(contrast >= kIconRatio, + qPrintable(QString("hover ink %1 reads %2:1 on the face %3").arg(ink.name()).arg(contrast).arg(face.name()))); +} + int main(int argc, char **argv) { if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM"))