From a556204ba1f1b77a3e1ad3926eec1120c497edc7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Thu, 1 Oct 2026 23:15:32 +1300 Subject: [PATCH 1/3] Edit several wires at once in the Selection properties panel Selecting more than one wire left the Selection properties panel empty, and so did a box selection over a single wire, because it also picks up the wire's number label. Now the panel opens for any number of selected wires. A selected wire label counts as its wire, once, so a box drawn over wires works. The fields show the first wire's values, a line above them says how many wires are selected, and an edit is applied to each wire field by field: setting the function on ten wires leaves each with its own number, cable and section. With "apply to all conductors of this potential" ticked, the potentials of all the selected wires follow the same rule. Each edit is one undo step. Fields whose values differ between the selected wires are not marked as such; that can follow if wanted. Co-Authored-By: Claude Opus 5.5 --- sources/factory/propertieseditorfactory.cpp | 39 +++--- .../ui/conductorpropertieseditorwidget.cpp | 118 ++++++++++++------ sources/ui/conductorpropertieseditorwidget.h | 15 ++- 3 files changed, 116 insertions(+), 56 deletions(-) diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index 48a39738b..83b554f79 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -82,22 +82,28 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( PropertiesEditorWidget *editor, QWidget *parent) { + //Selecting a conductor's text label edits its parent conductor (#500), + //mirroring how double-clicking the label opens the conductor dialog. + //A rubber band over wires selects their labels too: each label + //counts as its conductor, once. + QList mapped; + for (QGraphicsItem *qgi : std::as_const(items)) { + if (auto *cti = qgraphicsitem_cast(qgi)) { + if (Conductor *parent_cond = cti->parentConductor()) { + qgi = parent_cond; + } + } + if (!mapped.contains(qgi)) { + mapped << qgi; + } + } + items = mapped; + const int count_ = items.size(); if (count_ == 0) { return nullptr; } QGraphicsItem *item = items.first(); - - //Selecting a conductor's text label edits its parent conductor (#500), - //mirroring how double-clicking the label opens the conductor dialog. - if (count_ == 1) { - if (auto *cti = qgraphicsitem_cast(item)) { - if (Conductor *parent_cond = cti->parentConductor()) { - items = {parent_cond}; - item = parent_cond; - } - } - } const int type_ = item->type(); //The editor widget can only edit one item @@ -124,18 +130,17 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( false).toBool()) { return nullptr; } - //Prototype (#500): single-conductor editing in the dock. - if (count_ > 1) { - return nullptr; + QList conductors; + for (QGraphicsItem *qgi : std::as_const(items)) { + conductors << static_cast(qgi); } - auto conductor = static_cast(item); if (class_name == ConductorPropertiesEditorWidget::staticMetaObject.className()) { - static_cast(editor)->setConductor(conductor); + static_cast(editor)->setConductors(conductors); return editor; } - return new ConductorPropertiesEditorWidget(conductor, parent); + return new ConductorPropertiesEditorWidget(conductors, parent); } case Element::Type: //1000 { diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index 8b50fa02d..af3e0be33 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -44,11 +45,11 @@ /** @brief ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget - @param conductor : conductor to edit + @param conductors : conductors to edit @param parent : parent widget */ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( - Conductor *conductor, QWidget *parent) : + const QList &conductors, QWidget *parent) : PropertiesEditorWidget(parent), m_cpw(new ConductorPropertiesWidget(this)) { @@ -76,12 +77,19 @@ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( QStringLiteral("diagrameditor/conductor_apply_all"), on); }); + //Shown only when several conductors are selected: the fields hold + //the first one's values, and only the edited ones reach the others. + m_count_label = new QLabel(this); + m_count_label->setWordWrap(true); + m_count_label->hide(); + auto *scroll = new QScrollArea(this); scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); scroll->setWidget(m_cpw); auto *layout = new QVBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_count_label); layout->addWidget(m_apply_all_cb); layout->addWidget(scroll); setMinimumWidth(120); @@ -100,7 +108,7 @@ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( gb->installEventFilter(this); setDisabled(true); - setConductor(conductor); + setConductors(conductors); } /** @@ -123,26 +131,56 @@ ConductorPropertiesEditorWidget::~ConductorPropertiesEditorWidget() {} /** - @brief ConductorPropertiesEditorWidget::setConductor - Set (or change) the conductor whose properties are edited. - @param conductor + @brief ConductorPropertiesEditorWidget::setConductors + Set (or change) the conductors whose properties are edited. + @param conductors */ -void ConductorPropertiesEditorWidget::setConductor(Conductor *conductor) +void ConductorPropertiesEditorWidget::setConductors( + const QList &conductors) { - if (!conductor) return; - if (m_conductor && m_conductor != conductor) - disconnect(m_conductor, &Conductor::propertiesChange, - this, &ConductorPropertiesEditorWidget::updateUi); - m_conductor = conductor; - //Keep the dock in sync when the conductor is edited elsewhere (e.g. the - //modal "Edit conductor" dialog); otherwise a stale snapshot would be - //written back on the next apply() and overwrite that change (issue #500). - connect(m_conductor, &Conductor::propertiesChange, - this, &ConductorPropertiesEditorWidget::updateUi, Qt::UniqueConnection); + if (conductors.isEmpty()) return; + + for (const QPointer &c : std::as_const(m_conductors)) + if (c) + disconnect(c, &Conductor::propertiesChange, + this, &ConductorPropertiesEditorWidget::updateUi); + m_conductors.clear(); + + for (Conductor *c : conductors) + { + m_conductors << c; + //Keep the dock in sync when a conductor is edited elsewhere (e.g. + //the modal "Edit conductor" dialog); otherwise a stale snapshot + //would be written back on the next apply() and overwrite that + //change (issue #500). + connect(c, &Conductor::propertiesChange, + this, &ConductorPropertiesEditorWidget::updateUi, + Qt::UniqueConnection); + } + + const int count = conductors.size(); + m_count_label->setText( + tr("%n conducteurs sélectionnés : seuls les champs modifiés leur sont appliqués.", + "selection properties panel", count)); + m_count_label->setVisible(count > 1); + setEnabled(true); updateUi(); } +/** + @brief ConductorPropertiesEditorWidget::firstConductor + @return the first edited conductor still alive, its values are the ones + shown, or nullptr +*/ +Conductor *ConductorPropertiesEditorWidget::firstConductor() const +{ + for (const QPointer &c : m_conductors) + if (c) + return c; + return nullptr; +} + /** @brief ConductorPropertiesEditorWidget::apply Push the edit onto the diagram's undo stack. @@ -153,9 +191,10 @@ void ConductorPropertiesEditorWidget::apply() // programmatically (updateUi/reset): mid-load the widget holds a partial // state that must not be committed onto the conductor. if (m_updating) return; - if (!m_conductor || !m_conductor->diagram()) return; + Conductor *first = firstConductor(); + if (!first || !first->diagram()) return; if (QUndoCommand *undo = associatedUndo()) - m_conductor->diagram()->undoStack().push(undo); + first->diagram()->undoStack().push(undo); m_shown = m_cpw->properties(); } @@ -242,7 +281,7 @@ void ConductorPropertiesEditorWidget::disconnectChangeSignals() */ void ConductorPropertiesEditorWidget::reset() { - if (!m_conductor) return; + if (!firstConductor()) return; m_updating = true; m_cpw->setProperties(m_shown); m_updating = false; @@ -250,13 +289,15 @@ void ConductorPropertiesEditorWidget::reset() /** @brief ConductorPropertiesEditorWidget::updateUi - Reload the widget from the conductor (e.g. when the selection changes). + Reload the widget from the first conductor (e.g. when the selection + changes). */ void ConductorPropertiesEditorWidget::updateUi() { - if (!m_conductor) return; + Conductor *first = firstConductor(); + if (!first) return; m_updating = true; - m_cpw->setProperties(m_conductor->properties()); + m_cpw->setProperties(first->properties()); //Read back rather than keep the conductor's own values: a value the //widget cannot show exactly must not count as an edit. m_shown = m_cpw->properties(); @@ -267,25 +308,32 @@ void ConductorPropertiesEditorWidget::updateUi() @brief ConductorPropertiesEditorWidget::associatedUndo @return the edit as one undo step, or nullptr if nothing changes. - Only the fields the user changed are written: the conductor keeps every - other value, including one the widget cannot show exactly. + Only the fields the user changed are applied, to each edited conductor: + with several conductors selected, each keeps its own text, function, + cable... unless that is the field being edited. - When "apply to all" is ticked, every conductor on the same potential is - updated too, in the same undo step (one undo reverts them all), as the - modal dialog does (ConductorPropertiesDialog::PropertiesDialog). + When "apply to all" is ticked, every conductor on the same potential as + an edited one is updated too, in the same undo step (one undo reverts + them all), as the modal dialog does + (ConductorPropertiesDialog::PropertiesDialog). */ QUndoCommand *ConductorPropertiesEditorWidget::associatedUndo() const { - if (!m_conductor) return nullptr; - const ConductorProperties new_properties = m_cpw->properties(); if (new_properties == m_shown) return nullptr; - QList targets {m_conductor}; - if (m_apply_all_cb && m_apply_all_cb->isChecked()) - for (Conductor *potential_conductor : m_conductor->relatedPotentialConductors()) - if (!targets.contains(potential_conductor)) - targets << potential_conductor; + const bool apply_all = m_apply_all_cb && m_apply_all_cb->isChecked(); + QList targets; + for (const QPointer &c : m_conductors) + { + if (!c) continue; + if (!targets.contains(c.data())) + targets << c.data(); + if (apply_all) + for (Conductor *potential_conductor : c->relatedPotentialConductors()) + if (!targets.contains(potential_conductor)) + targets << potential_conductor; + } auto *undo = new QUndoCommand(); int changed = 0; diff --git a/sources/ui/conductorpropertieseditorwidget.h b/sources/ui/conductorpropertieseditorwidget.h index a5f517738..f0b0d0d0c 100644 --- a/sources/ui/conductorpropertieseditorwidget.h +++ b/sources/ui/conductorpropertieseditorwidget.h @@ -23,18 +23,22 @@ #include #include +#include class Conductor; class ConductorPropertiesWidget; class QCheckBox; +class QLabel; /** @brief The ConductorPropertiesEditorWidget class Hosts the existing ConductorPropertiesWidget in the dockable selection- - properties panel, so a selected conductor can be edited in place like the + properties panel, so selected conductors can be edited in place like the other item types, instead of only through the modal dialog (issue #500). A pinned "apply to all conductors of the potential" checkbox (persisted) mirrors the modal dialog's option to propagate edits to the whole potential. + When several conductors are selected, the panel shows the first one and + applies to each only the fields the user changed. */ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget { @@ -42,10 +46,11 @@ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget public: explicit ConductorPropertiesEditorWidget( - Conductor *conductor = nullptr, QWidget *parent = nullptr); + const QList &conductors = {}, + QWidget *parent = nullptr); ~ConductorPropertiesEditorWidget() override; - void setConductor(Conductor *conductor); + void setConductors(const QList &conductors); void apply() override; void reset() override; @@ -60,11 +65,13 @@ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget private: void connectChangeSignals(); void disconnectChangeSignals(); + Conductor *firstConductor() const; private: ConductorPropertiesWidget *m_cpw = nullptr; QCheckBox *m_apply_all_cb = nullptr; - Conductor *m_conductor = nullptr; + QLabel *m_count_label = nullptr; + QList> m_conductors; //What the widget showed before the edit: the fields that //differ from it are the ones the user changed. ConductorProperties m_shown; From 1c5fd5a284dcb38cbc59fa4b9ed8a0ea1dbffcda Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 06:20:17 +1300 Subject: [PATCH 2/3] Multi-wire panel: stable first wire, mixed fields, locked text, one reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1205: - The scene gives no order for its selection, so the "first" wire whose values the panel shows could change between two selections of the same wires. The wires are now sorted top to bottom, then left to right. - A text field the selected wires do not agree on (function, section, cable...) is shown blank with the hint "Plusieurs valeurs". Typing any value there, including the first wire's own, now differs from what was shown, so it reaches every wire: 1.5 mm² on a 1.5 and a 2.5 wire works. - The conductor text is locked while several wires are selected: the same number on all of them would make duplicates. "Apply to all conductors of this potential" with one wire selected still sets the number on the whole potential. - One edit of N wires emitted N signals, each reloading the panel. They are now gathered into one reload, which matters since a reload reads every selected wire to find the mixed fields. Selections and targets are de-duplicated with sets, and a wire reached through an earlier wire's potential is not walked again. An edit that changes nothing returns before any potential is walked. The rules move to sources/conductormultiedit.h, templates over the wire type, so tst_conductormultiedit can check them against a fake wire: order, targets with and without potentials, one undo step and its redo for three wires, and the mixed fields. tst_conductorapplychanges now gives the other wire the opposite value in every boolean field, so copying one by mistake fails the test. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 1 + sources/conductormultiedit.h | 213 ++++++++++++++++++ sources/factory/propertieseditorfactory.cpp | 5 +- .../ui/conductorpropertieseditorwidget.cpp | 102 +++++---- sources/ui/conductorpropertieseditorwidget.h | 2 + sources/ui/conductorpropertieswidget.cpp | 44 +++- sources/ui/conductorpropertieswidget.h | 4 + tests/qttest/CMakeLists.txt | 15 ++ tests/qttest/tst_conductorapplychanges.cpp | 14 +- tests/qttest/tst_conductormultiedit.cpp | 189 ++++++++++++++++ 10 files changed, 533 insertions(+), 56 deletions(-) create mode 100644 sources/conductormultiedit.h create mode 100644 tests/qttest/tst_conductormultiedit.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 4593d5d56..9b1d9ab2f 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -188,6 +188,7 @@ set(QET_SRC_FILES ${QET_DIR}/sources/conductorprofile.h ${QET_DIR}/sources/conductorproperties.cpp ${QET_DIR}/sources/conductorproperties.h + ${QET_DIR}/sources/conductormultiedit.h ${QET_DIR}/sources/conductorsegment.cpp ${QET_DIR}/sources/conductorsegment.h ${QET_DIR}/sources/conductorsegmentprofile.h diff --git a/sources/conductormultiedit.h b/sources/conductormultiedit.h new file mode 100644 index 000000000..e234feae6 --- /dev/null +++ b/sources/conductormultiedit.h @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + 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 CONDUCTORMULTIEDIT_H +#define CONDUCTORMULTIEDIT_H + +#include "QPropertyUndoCommand/qpropertyundocommand.h" +#include "conductorproperties.h" + +#include +#include +#include +#include + +#include + +/** + @brief ConductorMultiEdit + The rules for editing several conductors at once in the Selection + properties panel (#500). They are templates over the conductor type so + they can be tested without a diagram: T needs properties(), a + "properties" Q_PROPERTY, and relatedPotentialConductors() returning + QSet. +*/ +namespace ConductorMultiEdit +{ + /** + @brief sortByPosition + Sort top to bottom, then left to right, so the panel shows the same + "first" conductor whatever order the scene lists the selection in. + @param list : the conductors + @param pos_of : callable giving a conductor's position + */ + template + void sortByPosition(QList &list, PosOf pos_of) + { + std::stable_sort(list.begin(), list.end(), [&pos_of](T *a, T *b) { + const QPointF pa = pos_of(a); + const QPointF pb = pos_of(b); + if (pa.y() != pb.y()) + return pa.y() < pb.y(); + return pa.x() < pb.x(); + }); + } + + /** + @brief targets + @param selected : the edited conductors + @param apply_all : true to add every conductor of their potentials + @return each conductor an edit reaches, once + */ + template + QList targets(const QList &selected, bool apply_all) + { + QList list; + QSet seen; + for (T *conductor : selected) + { + //Already reached through an earlier conductor's potential: + //its own potential is the same one. + if (!conductor || seen.contains(conductor)) + continue; + seen.insert(conductor); + list << conductor; + if (!apply_all) + continue; + const QSet potential = conductor->relatedPotentialConductors(); + for (T *other : potential) + { + if (!seen.contains(other)) + { + seen.insert(other); + list << other; + } + } + } + return list; + } + + /** + @brief undo + @param targets : the conductors to change, see targets() + @param shown : the properties as the panel showed them + @param edited : the properties after the user's edit + @return one undo step giving each target the fields that differ + between shown and edited, or nullptr if no conductor changes + */ + template + QUndoCommand *undo(const QList &targets, + const ConductorProperties &shown, + const ConductorProperties &edited) + { + if (edited == shown) + return nullptr; + + auto *undo = new QUndoCommand(); + int changed = 0; + for (T *conductor : targets) + { + const ConductorProperties old_properties = conductor->properties(); + ConductorProperties properties = old_properties; + properties.applyChanges(shown, edited); + if (properties == old_properties) + continue; + + QVariant old_value, new_value; + old_value.setValue(old_properties); + new_value.setValue(properties); + new QPropertyUndoCommand(conductor, "properties", old_value, new_value, undo); + ++changed; + } + + if (!changed) + { + delete undo; + return nullptr; + } + undo->setText(changed == 1 + ? QCoreApplication::translate("ConductorPropertiesEditorWidget", + "Modifier les propriétés d'un conducteur", "undo caption") + : QCoreApplication::translate("ConductorPropertiesEditorWidget", + "Modifier les propriétés de plusieurs conducteurs", "undo caption")); + return undo; + } + + /// The fields a user types in. When the selected conductors do not + /// agree on one, the panel shows it blank. + enum TextField { + Text, Formula, Function, TensionProtocol, + WireColor, WireSection, Cable, Bus + }; + + inline QList textFields() + { + return {Text, Formula, Function, TensionProtocol, + WireColor, WireSection, Cable, Bus}; + } + + inline QString &textField(ConductorProperties &p, TextField field) + { + switch (field) + { + case Text: return p.text; + case Formula: return p.m_formula; + case Function: return p.m_function; + case TensionProtocol: return p.m_tension_protocol; + case WireColor: return p.m_wire_color; + case WireSection: return p.m_wire_section; + case Cable: return p.m_cable; + case Bus: break; + } + return p.m_bus; + } + + /** + @brief mixedTextFields + @return the text fields whose value is not the same in all of list + */ + inline QList mixedTextFields(QList list) + { + QList mixed; + if (list.size() < 2) + return mixed; + for (TextField field : textFields()) + { + const QString first = textField(list.first(), field); + for (ConductorProperties &p : list) + { + if (textField(p, field) != first) + { + mixed << field; + break; + } + } + } + return mixed; + } + + /** + @brief shown + @return what the panel shows for list: the first conductor's + properties, with the mixed text fields blank. Typing any value + in a blank field, even the first conductor's, then differs from + what was shown, so it is applied to every conductor. + */ + inline ConductorProperties shown(const QList &list, + const QList &mixed) + { + if (list.isEmpty()) + return ConductorProperties(); + ConductorProperties p = list.first(); + for (TextField field : mixed) + textField(p, field).clear(); + return p; + } +} + +#endif // CONDUCTORMULTIEDIT_H diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index 83b554f79..ba8b6acb3 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -38,6 +38,7 @@ #include "../ui/shapegraphicsitempropertieswidget.h" #include +#include #include /** @@ -87,13 +88,15 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( //A rubber band over wires selects their labels too: each label //counts as its conductor, once. QList mapped; + QSet seen; for (QGraphicsItem *qgi : std::as_const(items)) { if (auto *cti = qgraphicsitem_cast(qgi)) { if (Conductor *parent_cond = cti->parentConductor()) { qgi = parent_cond; } } - if (!mapped.contains(qgi)) { + if (!seen.contains(qgi)) { + seen.insert(qgi); mapped << qgi; } } diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index af3e0be33..60dd4ba43 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -17,7 +17,7 @@ */ #include "conductorpropertieseditorwidget.h" -#include "../QPropertyUndoCommand/qpropertyundocommand.h" +#include "../conductormultiedit.h" #include "../diagram.h" #include "../qetgraphicsitem/conductor.h" #include "conductorpropertieswidget.h" @@ -143,18 +143,26 @@ void ConductorPropertiesEditorWidget::setConductors( for (const QPointer &c : std::as_const(m_conductors)) if (c) disconnect(c, &Conductor::propertiesChange, - this, &ConductorPropertiesEditorWidget::updateUi); + this, &ConductorPropertiesEditorWidget::scheduleUpdateUi); m_conductors.clear(); - for (Conductor *c : conductors) + //The scene gives no order for its selection: sort, so the same + //wires always show the same "first" one. + QList sorted = conductors; + ConductorMultiEdit::sortByPosition(sorted, [](Conductor *c) { + return c->sceneBoundingRect().topLeft(); + }); + + for (Conductor *c : std::as_const(sorted)) { m_conductors << c; //Keep the dock in sync when a conductor is edited elsewhere (e.g. //the modal "Edit conductor" dialog); otherwise a stale snapshot //would be written back on the next apply() and overwrite that - //change (issue #500). + //change (issue #500). One edit of N conductors emits N signals: + //they are gathered into one reload. connect(c, &Conductor::propertiesChange, - this, &ConductorPropertiesEditorWidget::updateUi, + this, &ConductorPropertiesEditorWidget::scheduleUpdateUi, Qt::UniqueConnection); } @@ -195,7 +203,21 @@ void ConductorPropertiesEditorWidget::apply() if (!first || !first->diagram()) return; if (QUndoCommand *undo = associatedUndo()) first->diagram()->undoStack().push(undo); - m_shown = m_cpw->properties(); + updateUi(); +} + +/** + @brief ConductorPropertiesEditorWidget::scheduleUpdateUi + Reload the widget once the current event is done, however many + conductors changed in it. +*/ +void ConductorPropertiesEditorWidget::scheduleUpdateUi() +{ + if (m_update_pending) return; + m_update_pending = true; + QMetaObject::invokeMethod(this, [this]() { + if (m_update_pending) updateUi(); + }, Qt::QueuedConnection); } /** @@ -289,15 +311,24 @@ void ConductorPropertiesEditorWidget::reset() /** @brief ConductorPropertiesEditorWidget::updateUi - Reload the widget from the first conductor (e.g. when the selection - changes). + Reload the widget from the conductors (e.g. when the selection + changes): the first one's values, with the text fields they do not + agree on left blank. */ void ConductorPropertiesEditorWidget::updateUi() { - Conductor *first = firstConductor(); - if (!first) return; + m_update_pending = false; + QList list; + for (const QPointer &c : std::as_const(m_conductors)) + if (c) + list << c->properties(); + if (list.isEmpty()) return; + + const auto mixed = ConductorMultiEdit::mixedTextFields(list); m_updating = true; - m_cpw->setProperties(first->properties()); + m_cpw->setProperties(ConductorMultiEdit::shown(list, mixed)); + m_cpw->setMixedTextFields(mixed); + m_cpw->setTextLocked(list.size() > 1); //Read back rather than keep the conductor's own values: a value the //widget cannot show exactly must not count as an edit. m_shown = m_cpw->properties(); @@ -319,47 +350,20 @@ void ConductorPropertiesEditorWidget::updateUi() */ QUndoCommand *ConductorPropertiesEditorWidget::associatedUndo() const { - const ConductorProperties new_properties = m_cpw->properties(); - if (new_properties == m_shown) return nullptr; + //Most calls are a field losing focus with nothing edited: answer + //before walking the potentials. + const ConductorProperties edited = m_cpw->properties(); + if (edited == m_shown) return nullptr; - const bool apply_all = m_apply_all_cb && m_apply_all_cb->isChecked(); - QList targets; + QList selected; for (const QPointer &c : m_conductors) - { - if (!c) continue; - if (!targets.contains(c.data())) - targets << c.data(); - if (apply_all) - for (Conductor *potential_conductor : c->relatedPotentialConductors()) - if (!targets.contains(potential_conductor)) - targets << potential_conductor; - } + if (c) + selected << c.data(); - auto *undo = new QUndoCommand(); - int changed = 0; - for (Conductor *conductor : std::as_const(targets)) - { - const ConductorProperties old_properties = conductor->properties(); - ConductorProperties properties = old_properties; - properties.applyChanges(m_shown, new_properties); - if (properties == old_properties) continue; - - QVariant old_value, new_value; - old_value.setValue(old_properties); - new_value.setValue(properties); - new QPropertyUndoCommand(conductor, "properties", old_value, new_value, undo); - ++changed; - } - - if (!changed) - { - delete undo; - return nullptr; - } - undo->setText(changed == 1 - ? tr("Modifier les propriétés d'un conducteur", "undo caption") - : tr("Modifier les propriétés de plusieurs conducteurs", "undo caption")); - return undo; + return ConductorMultiEdit::undo( + ConductorMultiEdit::targets( + selected, m_apply_all_cb && m_apply_all_cb->isChecked()), + m_shown, edited); } /** diff --git a/sources/ui/conductorpropertieseditorwidget.h b/sources/ui/conductorpropertieseditorwidget.h index f0b0d0d0c..ea915a706 100644 --- a/sources/ui/conductorpropertieseditorwidget.h +++ b/sources/ui/conductorpropertieseditorwidget.h @@ -65,6 +65,7 @@ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget private: void connectChangeSignals(); void disconnectChangeSignals(); + void scheduleUpdateUi(); Conductor *firstConductor() const; private: @@ -77,6 +78,7 @@ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget ConductorProperties m_shown; QList m_live_connections; bool m_updating = false; + bool m_update_pending = false; }; #endif // CONDUCTORPROPERTIESEDITORWIDGET_H diff --git a/sources/ui/conductorpropertieswidget.cpp b/sources/ui/conductorpropertieswidget.cpp index e184c6133..46729cfe7 100644 --- a/sources/ui/conductorpropertieswidget.cpp +++ b/sources/ui/conductorpropertieswidget.cpp @@ -191,6 +191,48 @@ void ConductorPropertiesWidget::setHiddenOneTextPerFolio(const bool &hide) { ui -> m_one_text_per_folio_cb -> setHidden(hide); } +/** + @brief ConductorPropertiesWidget::setMixedTextFields + Mark the text fields whose value differs between several edited + conductors: they are shown blank, with a hint saying why. + @param mixed +*/ +void ConductorPropertiesWidget::setMixedTextFields( + const QList &mixed) +{ + using namespace ConductorMultiEdit; + const QList> edits { + {Text, ui->m_text_le}, + {Formula, ui->m_formula_le}, + {Function, ui->m_function_le}, + {TensionProtocol, ui->m_tension_protocol_le}, + {WireColor, ui->m_wire_color_le}, + {WireSection, ui->m_wire_section_le}, + {Cable, ui->m_cable_le}, + {Bus, ui->m_bus_le}}; + + for (const auto &edit : edits) + edit.second->setPlaceholderText(mixed.contains(edit.first) + ? tr("Plusieurs valeurs", "several conductors, different values") + : QString()); +} + +/** + @brief ConductorPropertiesWidget::setTextLocked + Lock the conductor text while several conductors are edited at once: + the same number on all of them would make duplicates. + @param locked +*/ +void ConductorPropertiesWidget::setTextLocked(bool locked) +{ + m_text_locked = locked; + ui->m_text_le->setEnabled(!locked && ui->m_formula_le->text().isEmpty()); + ui->m_text_le->setToolTip(locked + ? tr("Plusieurs conducteurs sélectionnés : le texte se modifie " + "sur un seul conducteur à la fois.") + : tr("Texte")); +} + /** @brief ConductorPropertiesWidget::setDisabledShowText @param disable @@ -239,7 +281,7 @@ void ConductorPropertiesWidget::initWidget() connect(ui->m_multiwires_gb, &QGroupBox::toggled, [this](bool toggle) {this->ui->m_singlewire_gb->setChecked(!toggle);}); connect(ui->m_singlewire_gb, &QGroupBox::toggled, [this](bool toggle) {this->ui->m_multiwires_gb->setChecked(!toggle);}); - connect(ui->m_formula_le, &QLineEdit::textChanged, [this](QString text) {this->ui->m_text_le->setEnabled(text.isEmpty());}); + connect(ui->m_formula_le, &QLineEdit::textChanged, [this](QString text) {this->ui->m_text_le->setEnabled(!m_text_locked && text.isEmpty());}); ui->m_multiwires_gb->setChecked(true); ui->m_singlewire_gb->setChecked(true); #if TODO_LIST diff --git a/sources/ui/conductorpropertieswidget.h b/sources/ui/conductorpropertieswidget.h index a27ef868a..a52178269 100644 --- a/sources/ui/conductorpropertieswidget.h +++ b/sources/ui/conductorpropertieswidget.h @@ -18,6 +18,7 @@ #ifndef CONDUCTORPROPERTIESWIDGET_H #define CONDUCTORPROPERTIESWIDGET_H +#include "../conductormultiedit.h" #include "../conductorproperties.h" #include @@ -49,6 +50,8 @@ class ConductorPropertiesWidget : public QWidget void setHiddenOneTextPerFolio (const bool &hide); void setDisabledShowText (const bool &disable = true); void setHiddenAvailableAutonum (const bool &hide); + void setMixedTextFields(const QList &mixed); + void setTextLocked(bool locked); QComboBox *autonumComboBox() const; QPushButton *editAutonumPushButton() const; @@ -72,6 +75,7 @@ class ConductorPropertiesWidget : public QWidget Ui::ConductorPropertiesWidget *ui; ConductorProperties m_properties; QTextOrientationSpinBoxWidget *m_verti_select, *m_horiz_select; + bool m_text_locked = false; }; #endif // CONDUCTORPROPERTIESWIDGET_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e339f3721..d8bb2b612 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -523,6 +523,21 @@ add_test(NAME tst_conductorapplychanges COMMAND tst_conductorapplychanges) target_include_directories(tst_conductorapplychanges PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_conductorapplychanges PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml) +# ConductorMultiEdit -- editing several wires at once in the Selection +# properties panel: order, which wires an edit reaches, one undo step for +# all of them, and fields shown blank because the wires disagree. +add_executable( + tst_conductormultiedit + tst_conductormultiedit.cpp + ${QET_DIR}/sources/conductorproperties.cpp + ${QET_DIR}/sources/QPropertyUndoCommand/qpropertyundocommand.cpp + ${QET_DIR}/sources/qet.cpp + ${QET_DIR}/sources/qeticons.cpp + ${QET_DIR}/sources/shortcutmanager.cpp) +add_test(NAME tst_conductormultiedit COMMAND tst_conductormultiedit) +target_include_directories(tst_conductormultiedit PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_conductormultiedit PRIVATE Qt::Test Qt::Widgets Qt::Xml pugixml::pugixml) + # DiagramContext::fromXml() -- the two readers (QDom for projects, pugixml # for element definitions in the collection) give the same values: stray # spaces trimmed, accents kept. diff --git a/tests/qttest/tst_conductorapplychanges.cpp b/tests/qttest/tst_conductorapplychanges.cpp index aa286b0a0..75be52264 100644 --- a/tests/qttest/tst_conductorapplychanges.cpp +++ b/tests/qttest/tst_conductorapplychanges.cpp @@ -102,9 +102,11 @@ class tst_conductorapplychanges : public QObject QTest::newRow(list.at(i).first) << i; } - // The shown wire is all variant 0, the user changes one field - // to variant 1, another selected wire is all variant 2: it - // takes that one field and keeps every other one. + // The shown wire is all variant 0 and the user changes one + // field to variant 1. The other wire holds variant 1 in every + // other field, so copying one of them by mistake (they are + // variant 0 in the edit) shows, booleans included; and variant + // 2 in the edited field, so not copying it shows too. void eachFieldAlone() { QFETCH(int, index); @@ -115,9 +117,11 @@ class tst_conductorapplychanges : public QObject set(after, 1); QVERIFY(after != before); - ConductorProperties other = all(2); + ConductorProperties other = all(1); + set(other, 2); ConductorProperties expected = other; set(expected, 1); + QVERIFY(expected != other); other.applyChanges(before, after); QVERIFY(other == expected); @@ -127,7 +131,7 @@ class tst_conductorapplychanges : public QObject void noEditChangesNothing() { const ConductorProperties shown = all(0); - ConductorProperties other = all(2); + ConductorProperties other = all(1); const ConductorProperties kept = other; other.applyChanges(shown, shown); QVERIFY(other == kept); diff --git a/tests/qttest/tst_conductormultiedit.cpp b/tests/qttest/tst_conductormultiedit.cpp new file mode 100644 index 000000000..9a6d36e92 --- /dev/null +++ b/tests/qttest/tst_conductormultiedit.cpp @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include + +#include "conductormultiedit.h" +#include "qetapp.h" + + // qet.cpp needs it; the application is not linked +QString QETApp::m_interface_language; + +// A wire as ConductorMultiEdit sees one: properties through the same +// Q_PROPERTY the undo command writes, a potential, and a position. +class FakeWire : public QObject +{ + Q_OBJECT + Q_PROPERTY(ConductorProperties properties READ properties WRITE setProperties) + + public: + FakeWire(const QString &num, const QString &function, + const QString &cable, QPointF pos) : + m_pos(pos) + { + m_properties.text = num; + m_properties.m_function = function; + m_properties.m_cable = cable; + } + + ConductorProperties properties() const { return m_properties; } + void setProperties(const ConductorProperties &p) { m_properties = p; } + QSet relatedPotentialConductors() const { return m_potential; } + QPointF pos() const { return m_pos; } + + // Joins wires into one potential; like the real one, a wire's + // potential lists the others, not itself. + static void join(const QList &wires) + { + for (FakeWire *w : wires) + for (FakeWire *o : wires) + if (o != w) + w->m_potential.insert(o); + } + + ConductorProperties m_properties; + + private: + QSet m_potential; + QPointF m_pos; +}; + +// The rules for editing several wires at once in the Selection properties +// panel (#500): which wire is shown first, which wires an edit reaches, +// what one undo step does to all of them, and the fields shown blank +// because the wires do not agree on them. +class tst_conductormultiedit : public QObject +{ + Q_OBJECT + + using Wires = QList; + + static ConductorProperties edited(ConductorProperties p, + QString ConductorProperties::*field, + const QString &value) + { + p.*field = value; + return p; + } + + private slots: + // Same wires, any selection order: same first wire + void firstWireIsStable() + { + FakeWire a("1", "", "", {50, 10}), b("2", "", "", {10, 10}), + c("3", "", "", {0, 90}); + const auto pos = [](FakeWire *w) { return w->pos(); }; + + Wires one {&c, &a, &b}, two {&a, &b, &c}; + ConductorMultiEdit::sortByPosition(one, pos); + ConductorMultiEdit::sortByPosition(two, pos); + QCOMPARE(one, (Wires {&b, &a, &c})); + QCOMPARE(two, one); + } + + void targetsWithoutPotential() + { + FakeWire a("1", "", "", {}), b("2", "", "", {}), c("3", "", "", {}); + FakeWire::join({&a, &c}); + QCOMPARE(ConductorMultiEdit::targets(Wires {&a, &b, &a}, false), + (Wires {&a, &b})); + } + + // Each wire once: a selected wire already reached through + // another one's potential is not added again + void targetsWithPotential() + { + FakeWire a("1", "", "", {}), b("1", "", "", {}), c("1", "", "", {}), + d("2", "", "", {}); + FakeWire::join({&a, &b, &c}); + const Wires t = ConductorMultiEdit::targets(Wires {&a, &d, &c}, true); + QCOMPARE(t.size(), 4); + QCOMPARE(QSet(t.begin(), t.end()), + (QSet {&a, &b, &c, &d})); + } + + // One edit of three wires: each takes the function, keeps its + // own number and cable; one undo restores all three exactly + void undoRedoThreeWires() + { + FakeWire a("101", "fnA", "W1", {}), b("102", "fnB", "W2", {}), + c("103", "fnC", "W3", {}); + const Wires wires {&a, &b, &c}; + const ConductorProperties before_a = a.properties(), + before_b = b.properties(), before_c = c.properties(); + + const ConductorProperties shown = a.properties(); + QUndoStack stack; + stack.push(ConductorMultiEdit::undo(wires, shown, + edited(shown, &ConductorProperties::m_function, "PWR"))); + QCOMPARE(stack.count(), 1); + + for (FakeWire *w : wires) + QCOMPARE(w->m_properties.m_function, QStringLiteral("PWR")); + QCOMPARE(b.m_properties.text, QStringLiteral("102")); + QCOMPARE(c.m_properties.m_cable, QStringLiteral("W3")); + + stack.undo(); + QVERIFY(a.properties() == before_a); + QVERIFY(b.properties() == before_b); + QVERIFY(c.properties() == before_c); + + stack.redo(); + QCOMPARE(c.m_properties.m_function, QStringLiteral("PWR")); + QCOMPARE(c.m_properties.text, QStringLiteral("103")); + } + + void noUndoWhenNothingChanges() + { + FakeWire a("1", "PWR", "", {}), b("2", "PWR", "", {}); + const ConductorProperties shown = a.properties(); + QCOMPARE(ConductorMultiEdit::undo(Wires {&a, &b}, shown, shown), + static_cast(nullptr)); + // An edit every wire already has + QCOMPARE(ConductorMultiEdit::undo(Wires {&a, &b}, edited(shown, + &ConductorProperties::m_function, "X"), shown), + static_cast(nullptr)); + } + + // Wires that disagree on a text field show it blank; typing the + // first wire's own value then reaches every wire (1.5 mm² on + // a 1.5 and a 2.5 wire) + void mixedFieldTakesFirstWiresValue() + { + FakeWire a("1", "fn", "", {}), b("2", "fn", "", {}); + a.m_properties.m_wire_section = "1.5"; + b.m_properties.m_wire_section = "2.5"; + const QList list {a.properties(), b.properties()}; + + const auto mixed = ConductorMultiEdit::mixedTextFields(list); + QCOMPARE(mixed, (QList { + ConductorMultiEdit::Text, ConductorMultiEdit::WireSection})); + + const ConductorProperties shown = ConductorMultiEdit::shown(list, mixed); + QVERIFY(shown.m_wire_section.isEmpty()); + QCOMPARE(shown.m_function, QStringLiteral("fn")); + + QUndoStack stack; + stack.push(ConductorMultiEdit::undo(Wires {&a, &b}, shown, + edited(shown, &ConductorProperties::m_wire_section, "1.5"))); + QCOMPARE(a.m_properties.m_wire_section, QStringLiteral("1.5")); + QCOMPARE(b.m_properties.m_wire_section, QStringLiteral("1.5")); + // Another field edited while the section is mixed leaves + // each wire's section alone + a.m_properties.m_wire_section = "1.5"; + b.m_properties.m_wire_section = "2.5"; + stack.push(ConductorMultiEdit::undo(Wires {&a, &b}, shown, + edited(shown, &ConductorProperties::m_function, "PWR"))); + QCOMPARE(a.m_properties.m_wire_section, QStringLiteral("1.5")); + QCOMPARE(b.m_properties.m_wire_section, QStringLiteral("2.5")); + } + + void oneWireIsNeverMixed() + { + FakeWire a("1", "fn", "", {}); + QVERIFY(ConductorMultiEdit::mixedTextFields({a.properties()}).isEmpty()); + } +}; + +QTEST_GUILESS_MAIN(tst_conductormultiedit) +#include "tst_conductormultiedit.moc" From 0bc27f00233c6f961e54f83fe1fc8b11d3ea4689 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 06:20:17 +1300 Subject: [PATCH 3/3] Translations for the multi-wire panel strings (English, French) The three new strings only. A full update_translations run rewrites 33 files (+87,518/-52,666 lines, 184 new strings from other merged work), so that is left to the next translations refresh. Co-Authored-By: Claude Opus 5.5 --- lang/qet_en.ts | 20 ++++++++++++++++++++ lang/qet_fr.ts | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lang/qet_en.ts b/lang/qet_en.ts index 4368b8d93..50d4860fa 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -1055,6 +1055,15 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Conducteur Conductor + + + %n conducteurs sélectionnés : seuls les champs modifiés leur sont appliqués. + selection properties panel + + %n conductor selected: only the fields you change are applied to it. + %n conductors selected: only the fields you change are applied to them. + + ConductorPropertiesWidget @@ -1302,6 +1311,17 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Style : Style: + + + Plusieurs valeurs + several conductors, different values + Several values + + + + Plusieurs conducteurs sélectionnés : le texte se modifie sur un seul conducteur à la fois. + Several conductors selected: the text is changed on one conductor at a time. + ConfigSaveLoaderWidget diff --git a/lang/qet_fr.ts b/lang/qet_fr.ts index 01e15a8c4..fccaeef64 100644 --- a/lang/qet_fr.ts +++ b/lang/qet_fr.ts @@ -1054,6 +1054,15 @@ Remarque: Ces options n'autorisent ou bloquent l'auto numérotation, s Conducteur + + + %n conducteurs sélectionnés : seuls les champs modifiés leur sont appliqués. + selection properties panel + + %n conducteur sélectionné : seuls les champs modifiés lui sont appliqués. + %n conducteurs sélectionnés : seuls les champs modifiés leur sont appliqués. + + ConductorPropertiesWidget @@ -1301,6 +1310,17 @@ Remarque: Ces options n'autorisent ou bloquent l'auto numérotation, s conductor style: dashed and dotted line + + + Plusieurs valeurs + several conductors, different values + + + + + Plusieurs conducteurs sélectionnés : le texte se modifie sur un seul conducteur à la fois. + + ConfigSaveLoaderWidget