From 95c2f6cf6d3fe966ed153b9c1f7a04284089f6d6 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Sat, 13 Jun 2026 08:19:35 +1200 Subject: [PATCH 01/24] proto: conductor properties in selection-properties dock (#500) --- cmake/qet_compilation_vars.cmake | 2 + sources/factory/propertieseditorfactory.cpp | 17 +++ .../ui/conductorpropertieseditorwidget.cpp | 126 ++++++++++++++++++ sources/ui/conductorpropertieseditorwidget.h | 58 ++++++++ 4 files changed, 203 insertions(+) create mode 100644 sources/ui/conductorpropertieseditorwidget.cpp create mode 100644 sources/ui/conductorpropertieseditorwidget.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 7e150a6e7..95f471694 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -616,6 +616,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/borderpropertieswidget.h ${QET_DIR}/sources/ui/compositetexteditdialog.cpp ${QET_DIR}/sources/ui/compositetexteditdialog.h + ${QET_DIR}/sources/ui/conductorpropertieseditorwidget.cpp + ${QET_DIR}/sources/ui/conductorpropertieseditorwidget.h ${QET_DIR}/sources/ui/conductorpropertiesdialog.cpp ${QET_DIR}/sources/ui/conductorpropertiesdialog.h ${QET_DIR}/sources/ui/conductorpropertieswidget.cpp diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index 6a18db924..f53ec4b1e 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -22,6 +22,7 @@ #include "../qetgraphicsitem/ViewItem/qetgraphicstableitem.h" #include "../qetgraphicsitem/ViewItem/ui/graphicstablepropertieseditor.h" #include "../qetgraphicsitem/ViewItem/ui/projectdbmodelpropertieswidget.h" +#include "../qetgraphicsitem/conductor.h" #include "../qetgraphicsitem/diagramimageitem.h" #include "../qetgraphicsitem/dynamicelementtextitem.h" #include "../qetgraphicsitem/element.h" @@ -30,6 +31,7 @@ #include "../qetgraphicsitem/qetshapeitem.h" #include "../ui/dynamicelementtextitemeditor.h" #include "../ui/elementpropertieswidget.h" +#include "../ui/conductorpropertieseditorwidget.h" #include "../ui/imagepropertieswidget.h" #include "../ui/inditextpropertieswidget.h" #include "../ui/shapegraphicsitempropertieswidget.h" @@ -100,6 +102,21 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( switch (type_) { + case Conductor::Type: //1001 + { + //Prototype (#500): single-conductor editing in the dock. + if (count_ > 1) { + return nullptr; + } + auto conductor = static_cast(item); + + if (class_name == ConductorPropertiesEditorWidget::staticMetaObject.className()) + { + static_cast(editor)->setConductor(conductor); + return editor; + } + return new ConductorPropertiesEditorWidget(conductor, parent); + } case Element::Type: //1000 { if (count_ > 1) { diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp new file mode 100644 index 000000000..33a73077d --- /dev/null +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -0,0 +1,126 @@ +/* + 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 "conductorpropertieseditorwidget.h" + +#include "../QPropertyUndoCommand/qpropertyundocommand.h" +#include "../diagram.h" +#include "../qetgraphicsitem/conductor.h" +#include "conductorpropertieswidget.h" + +#include + +/** + @brief ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget + @param conductor : conductor to edit + @param parent : parent widget +*/ +ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( + Conductor *conductor, QWidget *parent) : + PropertiesEditorWidget(parent), + m_cpw(new ConductorPropertiesWidget(this)) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(m_cpw); + setDisabled(true); + setConductor(conductor); +} + +ConductorPropertiesEditorWidget::~ConductorPropertiesEditorWidget() +{} + +/** + @brief ConductorPropertiesEditorWidget::setConductor + Set (or change) the conductor whose properties are edited. + @param conductor +*/ +void ConductorPropertiesEditorWidget::setConductor(Conductor *conductor) +{ + if (!conductor) return; + m_conductor = conductor; + setEnabled(true); + updateUi(); +} + +/** + @brief ConductorPropertiesEditorWidget::apply + Push the edit onto the diagram's undo stack. +*/ +void ConductorPropertiesEditorWidget::apply() +{ + if (!m_conductor || !m_conductor->diagram()) return; + if (QUndoCommand *undo = associatedUndo()) + m_conductor->diagram()->undoStack().push(undo); + m_initial = m_conductor->properties(); +} + +/** + @brief ConductorPropertiesEditorWidget::reset + Discard the in-progress edit, restoring the conductor's current properties. +*/ +void ConductorPropertiesEditorWidget::reset() +{ + if (!m_conductor) return; + m_cpw->setProperties(m_initial); +} + +/** + @brief ConductorPropertiesEditorWidget::updateUi + Reload the widget from the conductor (e.g. when the selection changes). +*/ +void ConductorPropertiesEditorWidget::updateUi() +{ + if (!m_conductor) return; + m_initial = m_conductor->properties(); + m_cpw->setProperties(m_initial); +} + +/** + @brief ConductorPropertiesEditorWidget::associatedUndo + @return the edit as a QPropertyUndoCommand, or nullptr if unchanged. + + Prototype note: applies only to the selected conductor. The modal dialog + additionally offers to propagate to every conductor on the same potential + (relatedPotentialConductors()); whether/how to expose that in the dock is + the open design decision for #500. +*/ +QUndoCommand *ConductorPropertiesEditorWidget::associatedUndo() const +{ + if (!m_conductor) return nullptr; + + const ConductorProperties new_properties = m_cpw->properties(); + if (new_properties == m_conductor->properties()) return nullptr; + + QVariant old_value, new_value; + old_value.setValue(m_conductor->properties()); + new_value.setValue(new_properties); + + auto *undo = new QPropertyUndoCommand( + m_conductor, "properties", old_value, new_value); + undo->setText(tr("Modifier les propriétés d'un conducteur", "undo caption")); + return undo; +} + +/** + @brief ConductorPropertiesEditorWidget::title + @return the panel title. +*/ +QString ConductorPropertiesEditorWidget::title() const +{ + return tr("Conducteur"); +} diff --git a/sources/ui/conductorpropertieseditorwidget.h b/sources/ui/conductorpropertieseditorwidget.h new file mode 100644 index 000000000..365e34ff7 --- /dev/null +++ b/sources/ui/conductorpropertieseditorwidget.h @@ -0,0 +1,58 @@ +/* + 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 CONDUCTORPROPERTIESEDITORWIDGET_H +#define CONDUCTORPROPERTIESEDITORWIDGET_H + +#include "../PropertiesEditor/propertieseditorwidget.h" +#include "../conductorproperties.h" + +class Conductor; +class ConductorPropertiesWidget; + +/** + @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 + other item types, instead of only through the modal dialog (issue #500). + Prototype: single-conductor editing; applying to the whole potential is the + open design question (the modal dialog offers it via a prompt). +*/ +class ConductorPropertiesEditorWidget : public PropertiesEditorWidget +{ + Q_OBJECT + + public: + explicit ConductorPropertiesEditorWidget( + Conductor *conductor = nullptr, QWidget *parent = nullptr); + ~ConductorPropertiesEditorWidget() override; + + void setConductor(Conductor *conductor); + + void apply() override; + void reset() override; + void updateUi() override; + QUndoCommand *associatedUndo() const override; + QString title() const override; + + private: + ConductorPropertiesWidget *m_cpw = nullptr; + Conductor *m_conductor = nullptr; + ConductorProperties m_initial; +}; + +#endif // CONDUCTORPROPERTIESEDITORWIDGET_H From e3ede85efd2496f62c688a0f15edd8a653f4db02 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Sat, 13 Jun 2026 18:05:26 +1200 Subject: [PATCH 02/24] conductor dock: scrollable/narrowable + View-menu enable toggle (#500) --- sources/factory/propertieseditorfactory.cpp | 8 ++++++++ sources/qetdiagrameditor.cpp | 17 +++++++++++++++++ sources/ui/conductorpropertieseditorwidget.cpp | 13 ++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index f53ec4b1e..f08935423 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -37,6 +37,7 @@ #include "../ui/shapegraphicsitempropertieswidget.h" #include +#include /** @brief PropertiesEditorFactory::propertiesEditor @@ -104,6 +105,13 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( { case Conductor::Type: //1001 { + //Feature toggle (#500): when disabled in the View menu, selecting a + //conductor brings up nothing in the dock. Default enabled. + if (!QSettings().value( + QStringLiteral("diagrameditor/conductor_properties_panel"), + true).toBool()) { + return nullptr; + } //Prototype (#500): single-conductor editing in the dock. if (count_ > 1) { return nullptr; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 2b1d9607c..c38acd2fa 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -879,6 +879,23 @@ void QETDiagramEditor::setUpMenu() menu_affichage -> addSeparator(); menu_affichage -> addAction(m_draw_grid); menu_affichage -> addAction(m_grey_background); + + // Toggle: show a selected conductor's properties in the selection-properties + // dock (#500). When off, selecting a conductor brings up nothing there. + QAction *cond_panel = menu_affichage -> addAction(tr("Propriétés du conducteur dans le panneau")); + cond_panel -> setCheckable(true); + cond_panel -> setStatusTip(tr("Affiche ou non les propriétés d'un conducteur sélectionné dans le panneau de sélection")); + { + QSettings settings; + cond_panel -> setChecked(settings.value(QStringLiteral("diagrameditor/conductor_properties_panel"), true).toBool()); + } + connect(cond_panel, &QAction::toggled, this, [this](bool on) { + QSettings().setValue(QStringLiteral("diagrameditor/conductor_properties_panel"), on); + // Apply immediately to the current selection. + if (DiagramView *dv = currentDiagramView()) + m_selection_properties_editor -> setDiagram(dv -> diagram()); + }); + menu_affichage -> addSeparator(); menu_affichage -> addActions(m_zoom_actions_group.actions()); diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index 33a73077d..e79450c71 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -22,6 +22,7 @@ #include "../qetgraphicsitem/conductor.h" #include "conductorpropertieswidget.h" +#include #include /** @@ -34,9 +35,19 @@ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( PropertiesEditorWidget(parent), m_cpw(new ConductorPropertiesWidget(this)) { + // The conductor widget is dialog-sized (min width ~600px), far too wide for + // a dock. Host it in a scroll area with a small minimum width so the dock + // can be dragged to any width (a scrollbar appears when narrower than the + // content). QET already persists dock geometry across restarts via + // QETDiagramEditor save/restoreState, so the chosen width is remembered. + 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_cpw); + layout->addWidget(scroll); + setMinimumWidth(120); setDisabled(true); setConductor(conductor); } From 280051745ae25fe6f21d81d251ab1c4fc1f2dc14 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Wed, 17 Jun 2026 04:12:47 +1200 Subject: [PATCH 03/24] Conductor dock: apply edits live, edit via label, fill height (#500) Addresses the testing feedback on the conductor selection-properties prototype: 1. Edits were never applied. The dock drives every editor through setLiveEdit(true), which each editor overrides to connect its field changes to apply(); ConductorPropertiesEditorWidget didn't override it, so the call was a no-op. Implement setLiveEdit() to connect the hosted ConductorPropertiesWidget's controls (commit-style signals) to apply(). A m_updating guard suppresses the signals emitted while the widget is loaded programmatically, so a partial mid-load state is never committed. 2. Clicking a conductor's text label showed nothing, because the label is a ConductorTextItem, not a Conductor. Map it to its parentConductor() in the editor factory, mirroring the double-click-the-label dialog behaviour. 3. The panel sat at a small size hint with empty space below it. Give the editor an Expanding vertical size policy (and a minimum height) so it fills the dock like the other editors. Refs #500 Co-Authored-By: Claude Opus 4.8 --- sources/factory/propertieseditorfactory.cpp | 12 +++ .../ui/conductorpropertieseditorwidget.cpp | 100 ++++++++++++++++++ sources/ui/conductorpropertieseditorwidget.h | 10 ++ 3 files changed, 122 insertions(+) diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index f08935423..8098f995a 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -23,6 +23,7 @@ #include "../qetgraphicsitem/ViewItem/ui/graphicstablepropertieseditor.h" #include "../qetgraphicsitem/ViewItem/ui/projectdbmodelpropertieswidget.h" #include "../qetgraphicsitem/conductor.h" +#include "../qetgraphicsitem/conductortextitem.h" #include "../qetgraphicsitem/diagramimageitem.h" #include "../qetgraphicsitem/dynamicelementtextitem.h" #include "../qetgraphicsitem/element.h" @@ -86,6 +87,17 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( 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 diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index e79450c71..b0a0d7d33 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -21,8 +21,18 @@ #include "../diagram.h" #include "../qetgraphicsitem/conductor.h" #include "conductorpropertieswidget.h" +#include "../qtextorientationspinboxwidget.h" +#include + +#include +#include +#include +#include +#include #include +#include +#include #include /** @@ -48,6 +58,11 @@ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(scroll); setMinimumWidth(120); + // Expand vertically to fill the dock like the other editors do (otherwise + // the panel sits at its small size hint with empty space below it, #500), + // while keeping a minimum height so it stays usable when the dock is short. + setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + setMinimumHeight(200); setDisabled(true); setConductor(conductor); } @@ -74,12 +89,93 @@ void ConductorPropertiesEditorWidget::setConductor(Conductor *conductor) */ void ConductorPropertiesEditorWidget::apply() { + // Ignore the field-change signals emitted while the widget is being loaded + // 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; if (QUndoCommand *undo = associatedUndo()) m_conductor->diagram()->undoStack().push(undo); m_initial = m_conductor->properties(); } +/** + @brief ConductorPropertiesEditorWidget::setLiveEdit + In live-edit mode (how the dock uses every editor), each field change is + applied immediately instead of via an explicit apply() call. Without this + override the base class is a no-op and edits in the dock were never applied + (issue #500). + @param live_edit true to enable live edit + @return always true +*/ +bool ConductorPropertiesEditorWidget::setLiveEdit(bool live_edit) +{ + if (m_live_edit == live_edit) return true; + m_live_edit = live_edit; + + if (m_live_edit) connectChangeSignals(); + else disconnectChangeSignals(); + + return true; +} + +/** + @brief ConductorPropertiesEditorWidget::connectChangeSignals + Wire every editable control of the hosted ConductorPropertiesWidget to + apply(). Commit-style signals (editingFinished / activated / toggled / + sliderReleased) are used rather than per-keystroke ones so each edit yields + a single, clean undo step. Loading the widget programmatically (updateUi) + also fires some of these, but apply() is a no-op then because + associatedUndo() returns nullptr when the properties are unchanged. +*/ +void ConductorPropertiesEditorWidget::connectChangeSignals() +{ + if (!m_cpw) return; + + const auto add = [this](QMetaObject::Connection c) { + m_live_connections << c; + }; + + for (auto *w : m_cpw->findChildren()) + add(connect(w, &QLineEdit::editingFinished, + this, &ConductorPropertiesEditorWidget::apply)); + for (auto *w : m_cpw->findChildren()) + add(connect(w, &QAbstractSpinBox::editingFinished, + this, &ConductorPropertiesEditorWidget::apply)); + for (auto *w : m_cpw->findChildren()) + add(connect(w, QOverload::of(&QComboBox::activated), + this, &ConductorPropertiesEditorWidget::apply)); + for (auto *w : m_cpw->findChildren()) + add(connect(w, &QSlider::sliderReleased, + this, &ConductorPropertiesEditorWidget::apply)); + for (auto *w : m_cpw->findChildren()) + add(connect(w, &KColorButton::changed, + this, &ConductorPropertiesEditorWidget::apply)); + for (auto *w : m_cpw->findChildren()) + add(connect(w, QOverload<>::of(&QTextOrientationSpinBoxWidget::editingFinished), + this, &ConductorPropertiesEditorWidget::apply)); + // Checkboxes and the checkable group boxes (single/multi wire, bicolor…). + for (auto *w : m_cpw->findChildren()) + if (w->isCheckable()) + add(connect(w, &QAbstractButton::toggled, + this, &ConductorPropertiesEditorWidget::apply)); + for (auto *w : m_cpw->findChildren()) + if (w->isCheckable()) + add(connect(w, &QGroupBox::toggled, + this, &ConductorPropertiesEditorWidget::apply)); +} + +/** + @brief ConductorPropertiesEditorWidget::disconnectChangeSignals + Tear down the live-edit connections made by connectChangeSignals(). +*/ +void ConductorPropertiesEditorWidget::disconnectChangeSignals() +{ + for (const QMetaObject::Connection &c : m_live_connections) + disconnect(c); + m_live_connections.clear(); +} + /** @brief ConductorPropertiesEditorWidget::reset Discard the in-progress edit, restoring the conductor's current properties. @@ -87,7 +183,9 @@ void ConductorPropertiesEditorWidget::apply() void ConductorPropertiesEditorWidget::reset() { if (!m_conductor) return; + m_updating = true; m_cpw->setProperties(m_initial); + m_updating = false; } /** @@ -97,8 +195,10 @@ void ConductorPropertiesEditorWidget::reset() void ConductorPropertiesEditorWidget::updateUi() { if (!m_conductor) return; + m_updating = true; m_initial = m_conductor->properties(); m_cpw->setProperties(m_initial); + m_updating = false; } /** diff --git a/sources/ui/conductorpropertieseditorwidget.h b/sources/ui/conductorpropertieseditorwidget.h index 365e34ff7..42df3b93b 100644 --- a/sources/ui/conductorpropertieseditorwidget.h +++ b/sources/ui/conductorpropertieseditorwidget.h @@ -21,6 +21,9 @@ #include "../PropertiesEditor/propertieseditorwidget.h" #include "../conductorproperties.h" +#include +#include + class Conductor; class ConductorPropertiesWidget; @@ -48,11 +51,18 @@ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget void updateUi() override; QUndoCommand *associatedUndo() const override; QString title() const override; + bool setLiveEdit(bool live_edit) override; + + private: + void connectChangeSignals(); + void disconnectChangeSignals(); private: ConductorPropertiesWidget *m_cpw = nullptr; Conductor *m_conductor = nullptr; ConductorProperties m_initial; + QList m_live_connections; + bool m_updating = false; }; #endif // CONDUCTORPROPERTIESEDITORWIDGET_H From 0902725a5901c45a936f079b718a4522a4387a6c Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Thu, 18 Jun 2026 07:48:14 +1200 Subject: [PATCH 04/24] Conductor dock: apply-to-all-potential checkbox (#500) Port the modal dialog's "apply to all conductors" option into the dock panel, resolving the open propagation question. A persisted checkbox, pinned above the tabs, makes each edit propagate to every conductor on the same potential in one undo step - identical semantics to ConductorPropertiesDialog, but the choice is remembered across edits. Co-Authored-By: Claude Opus 4.8 --- .../ui/conductorpropertieseditorwidget.cpp | 47 +++++++++++++++++-- sources/ui/conductorpropertieseditorwidget.h | 6 ++- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index b0a0d7d33..a2b6d91f2 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -27,10 +27,12 @@ #include #include +#include #include #include #include #include +#include #include #include #include @@ -50,12 +52,29 @@ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( // can be dragged to any width (a scrollbar appears when narrower than the // content). QET already persists dock geometry across restarts via // QETDiagramEditor save/restoreState, so the chosen width is remembered. + // "Apply to all conductors of the potential": same semantics as the modal + // dialog's checkbox (ConductorPropertiesDialog::applyAll), pinned at the top + // of the panel so it stays visible above the scrolling tabs (#500). Unlike + // the dialog the choice is persisted, so a user who always wants it on (or + // off) sets it once. It is a child of this editor, not of m_cpw, so it is + // deliberately outside the live-edit signal wiring in connectChangeSignals() + // (toggling it must not push an edit, only change how the next edit applies). + m_apply_all_cb = new QCheckBox( + tr("Appliquer à tous les conducteurs du potentiel"), this); + m_apply_all_cb->setChecked(QSettings().value( + QStringLiteral("diagrameditor/conductor_apply_all"), true).toBool()); + connect(m_apply_all_cb, &QCheckBox::toggled, this, [](bool on) { + QSettings().setValue( + QStringLiteral("diagrameditor/conductor_apply_all"), on); + }); + 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_apply_all_cb); layout->addWidget(scroll); setMinimumWidth(120); // Expand vertically to fill the dock like the other editors do (otherwise @@ -205,10 +224,10 @@ void ConductorPropertiesEditorWidget::updateUi() @brief ConductorPropertiesEditorWidget::associatedUndo @return the edit as a QPropertyUndoCommand, or nullptr if unchanged. - Prototype note: applies only to the selected conductor. The modal dialog - additionally offers to propagate to every conductor on the same potential - (relatedPotentialConductors()); whether/how to expose that in the dock is - the open design decision for #500. + When "apply to all" is ticked, every conductor on the same potential is + updated in the same undo step (one undo reverts them all), exactly as the + modal dialog does (ConductorPropertiesDialog::PropertiesDialog). Otherwise + only the selected conductor is changed. */ QUndoCommand *ConductorPropertiesEditorWidget::associatedUndo() const { @@ -224,6 +243,26 @@ QUndoCommand *ConductorPropertiesEditorWidget::associatedUndo() const auto *undo = new QPropertyUndoCommand( m_conductor, "properties", old_value, new_value); undo->setText(tr("Modifier les propriétés d'un conducteur", "undo caption")); + + // Propagate to every conductor on the same potential, as the modal dialog + // does: each related conductor becomes a child command of the same undo + // step, set to the same target properties. + if (m_apply_all_cb && m_apply_all_cb->isChecked()) + { + const auto potential = m_conductor->relatedPotentialConductors(); + if (!potential.isEmpty()) + { + undo->setText(tr("Modifier les propriétés de plusieurs conducteurs", + "undo caption")); + for (Conductor *potential_conductor : potential) + { + QVariant old_v; + old_v.setValue(potential_conductor->properties()); + new QPropertyUndoCommand( + potential_conductor, "properties", old_v, new_value, undo); + } + } + } return undo; } diff --git a/sources/ui/conductorpropertieseditorwidget.h b/sources/ui/conductorpropertieseditorwidget.h index 42df3b93b..0479a6bee 100644 --- a/sources/ui/conductorpropertieseditorwidget.h +++ b/sources/ui/conductorpropertieseditorwidget.h @@ -26,14 +26,15 @@ class Conductor; class ConductorPropertiesWidget; +class QCheckBox; /** @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 other item types, instead of only through the modal dialog (issue #500). - Prototype: single-conductor editing; applying to the whole potential is the - open design question (the modal dialog offers it via a prompt). + A pinned "apply to all conductors of the potential" checkbox (persisted) + mirrors the modal dialog's option to propagate edits to the whole potential. */ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget { @@ -59,6 +60,7 @@ class ConductorPropertiesEditorWidget : public PropertiesEditorWidget private: ConductorPropertiesWidget *m_cpw = nullptr; + QCheckBox *m_apply_all_cb = nullptr; Conductor *m_conductor = nullptr; ConductorProperties m_initial; QList m_live_connections; From 151b16ba761cc68cabfbc7a18c47035701928f64 Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Thu, 18 Jun 2026 08:14:00 +1200 Subject: [PATCH 05/24] Conductor dock: English translations + align checkbox wording (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add English translations (qet_en.ts) for the new conductor-panel strings so non-French users don't see untranslated source text: the panel title, the two undo captions, the apply-to-all checkbox, and the View-menu toggle + status tip. The apply-to-all checkbox now reuses the modal dialog's exact wording ("Appliquer les propriétés a l'ensemble des conducteurs de ce potentiel") for consistency and so it shares the existing translation. Co-Authored-By: Claude Opus 4.8 --- lang/qet_en.ts | 35 +++++++++++++++++++ .../ui/conductorpropertieseditorwidget.cpp | 5 ++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lang/qet_en.ts b/lang/qet_en.ts index d78e8268f..1170c49b5 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -834,6 +834,31 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Edit the properties of several conductors + + ConductorPropertiesEditorWidget + + + Appliquer les propriétés à l'ensemble des conducteurs de ce potentiel + Apply properties to all conductors of this potential + + + + Modifier les propriétés d'un conducteur + undo caption + Edit conductor properties + + + + Modifier les propriétés de plusieurs conducteurs + undo caption + Edit the properties of several conductors + + + + Conducteur + Conductor + + ConductorPropertiesWidget @@ -6068,6 +6093,16 @@ Available options: QETDiagramEditor + + + Propriétés du conducteur dans le panneau + Conductor properties in the panel + + + + Affiche ou non les propriétés d'un conducteur sélectionné dans le panneau de sélection + Show or hide the properties of a selected conductor in the selection panel + Afficha&ge diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index a2b6d91f2..5e4c15701 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -59,8 +59,11 @@ ConductorPropertiesEditorWidget::ConductorPropertiesEditorWidget( // off) sets it once. It is a child of this editor, not of m_cpw, so it is // deliberately outside the live-edit signal wiring in connectChangeSignals() // (toggling it must not push an edit, only change how the next edit applies). + // Reuse the modal dialog's exact wording for consistency (and so the + // existing translation applies). m_apply_all_cb = new QCheckBox( - tr("Appliquer à tous les conducteurs du potentiel"), this); + tr("Appliquer les propriétés à l'ensemble des conducteurs de ce potentiel"), + this); m_apply_all_cb->setChecked(QSettings().value( QStringLiteral("diagrameditor/conductor_apply_all"), true).toBool()); connect(m_apply_all_cb, &QCheckBox::toggled, this, [](bool on) { From 4e754cfac1e4468e2b0c89cfaa49855d4074e3ed Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 19 Jun 2026 07:43:27 +1200 Subject: [PATCH 06/24] Conductor dock: sync to external property changes (#500) The dock editor snapshotted the conductor once and never refreshed, so a change made via the modal Edit-conductor dialog left the dock holding stale values; on deselection apply() wrote that stale snapshot back, overwriting the dialog's change. Subscribe to Conductor::propertiesChange and refresh via updateUi() so the dock mirrors external edits and apply() becomes a no-op when nothing changed in the dock. --- sources/ui/conductorpropertieseditorwidget.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp index 5e4c15701..384e43f11 100644 --- a/sources/ui/conductorpropertieseditorwidget.cpp +++ b/sources/ui/conductorpropertieseditorwidget.cpp @@ -100,7 +100,15 @@ ConductorPropertiesEditorWidget::~ConductorPropertiesEditorWidget() void ConductorPropertiesEditorWidget::setConductor(Conductor *conductor) { 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); setEnabled(true); updateUi(); } From e6291eb8e8a8abf4375dedc098fe16d6994c1015 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 8 Aug 2026 17:18:30 +1200 Subject: [PATCH 07/24] Place an element from the collection without dragging it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiagramEventAddElement is already a good placement mode: the element follows the cursor on the grid, a left click drops it, Space rotates it, and it stays loaded for a run of the same symbol. Its only caller was DiagramView::handleElementDrop(), so it could be reached only by finishing a drag. Double-clicking a symbol in the Collections dock opened the element editor instead. - DiagramView::startElementPlacement() is split out of handleElementDrop(). defaultPlacementPos() uses the cursor when it is over the view and the centre of the visible area otherwise. - ElementsCollectionWidget emits insertElementRequested() on double click, or Enter on the highlighted item. The host decides which view receives it, so the widget can later be reused outside the editor. - When there is nowhere to place it (no folio open, read-only project), the element editor opens, as a double click did before. - "Insérer le dernier élément" (Édition menu, default key A) places the last element again. DiagramView reports every placement it starts, so an element dropped by drag counts too. Macros are not remembered. A rather than Space: Space rotates the pending element inside placement mode and is bound three more times in this editor. The key is a ShortcutManager default and can be changed in the Shortcuts page. Double click placing is a behaviour change, so it has a preference, "elementscollection/double-click-inserts" (default true), shown in Configuration as an opt-out: "Double-cliquer dans la collection ouvre l'éditeur d'élément au lieu de l'insérer". Discussions #676 and #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- .../elementscollectionwidget.cpp | 103 ++++++++++++++---- .../elementscollectionwidget.h | 13 +++ sources/diagramview.cpp | 62 ++++++++++- sources/diagramview.h | 7 ++ sources/qetdiagrameditor.cpp | 77 +++++++++++++ sources/qetdiagrameditor.h | 6 + .../configpage/generalconfigurationpage.cpp | 4 + .../ui/configpage/generalconfigurationpage.ui | 10 ++ 8 files changed, 259 insertions(+), 23 deletions(-) diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp index 3c015c716..511dd0ef8 100644 --- a/sources/ElementsCollection/elementscollectionwidget.cpp +++ b/sources/ElementsCollection/elementscollectionwidget.cpp @@ -35,6 +35,8 @@ #include #include +#include +#include #include #include #include @@ -266,15 +268,7 @@ void ElementsCollectionWidget::setUpConnection() this, &ElementsCollectionWidget::dirProperties); connect(m_tree_view, &QTreeView::doubleClicked, - [this](const QModelIndex &index) - { - this->m_index_at_context_menu = index ; - ElementCollectionItem *eci = elementCollectionItemForIndex(index); - if (eci && eci->collectionPath().endsWith(".qetmak")) { - return; // Do nothing on double click for macros - } - this->editElement(); - }); + [this](const QModelIndex &index) { this->activateIndex(index); }); connect(m_tree_view, &QTreeView::entered, [this] (const QModelIndex &index) { @@ -284,19 +278,23 @@ void ElementsCollectionWidget::setUpConnection() qde->statusBar()->showMessage(eci->localName()); }); + //Enter on the highlighted item does the same as a double click, so a + //run of elements can be placed without leaving the keyboard. Bound as + //a shortcut on the view rather than by reimplementing keyPressEvent, + //which would mean subclassing ElementsTreeView for one key. + for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) { + auto *sc = new QShortcut(QKeySequence(key), m_tree_view); + sc->setContext(Qt::WidgetShortcut); + connect(sc, &QShortcut::activated, this, [this]() { + this->activateIndex(m_tree_view->currentIndex()); + }); + } + connect(m_macros_tree_view, &QTreeView::customContextMenuRequested, this, &ElementsCollectionWidget::customContextMenu); connect(m_macros_tree_view, &QTreeView::doubleClicked, - [this](const QModelIndex &index) - { - this->m_index_at_context_menu = index ; - ElementCollectionItem *eci = elementCollectionItemForIndex(index); - if (eci && eci->collectionPath().endsWith(".qetmak")) { - return; // Do nothing on double click for macros - } - this->editElement(); - }); + [this](const QModelIndex &index) { this->activateIndex(index); }); connect(m_macros_tree_view, &QTreeView::entered, [this] (const QModelIndex &index) { @@ -405,6 +403,60 @@ void ElementsCollectionWidget::openDir() } +/** + @brief ElementsCollectionWidget::activateIndex + What a double click (or Enter) on @a index does. + + Historically this opened the element editor, which is the slowest action + available on a symbol you are most likely about to place. Placing is now + the default and editing has moved to the context menu, where it already + was. The old behaviour is preserved behind a preference for anyone who + relies on it. + @param index +*/ +void ElementsCollectionWidget::activateIndex(const QModelIndex &index) +{ + m_index_at_context_menu = index; + + ElementCollectionItem *eci = elementCollectionItemForIndex(index); + if (!eci) { + return; + } + //Macros are placed, never edited, and were already skipped here. + const bool is_macro = eci->collectionPath().endsWith(".qetmak"); + + QSettings settings; + const bool insert = settings.value( + QStringLiteral("elementscollection/double-click-inserts"), true).toBool(); + + if (insert) { + insertCurrentElement(); + return; + } + if (!is_macro) { + editElement(); + } +} + +/** + @brief ElementsCollectionWidget::insertCurrentElement + Ask for the current item to be placed on the folio. +*/ +void ElementsCollectionWidget::insertCurrentElement() +{ + ElementCollectionItem *eci = + elementCollectionItemForIndex(m_index_at_context_menu); + if (!(eci && eci->isElement())) { + return; + } + + ElementsLocation location(eci->collectionPath()); + if (!location.exist()) { + return; + } + emit insertElementRequested(location); +} + /** @brief ElementsCollectionWidget::editElement Edit the element represented by the current selected item @@ -415,10 +467,19 @@ void ElementsCollectionWidget::editElement() if ( !(eci && eci->isElement()) ) return; - // Prevent the element editor from opening for macros - if (eci->collectionPath().endsWith(".qetmak")) return; + editLocation(ElementsLocation(eci->collectionPath())); +} - ElementsLocation location(eci->collectionPath()); +/** + @brief ElementsCollectionWidget::editLocation + Open the element editor on @a location. Macros have no editor and are + ignored. + @param location +*/ +void ElementsCollectionWidget::editLocation(const ElementsLocation &location) +{ + // Prevent the element editor from opening for macros + if (!location.exist() || location.path().endsWith(".qetmak")) return; QETApp *app = QETApp::instance(); app->openElementLocations(QList() << location); diff --git a/sources/ElementsCollection/elementscollectionwidget.h b/sources/ElementsCollection/elementscollectionwidget.h index bf00f37d6..b6997b7a8 100644 --- a/sources/ElementsCollection/elementscollectionwidget.h +++ b/sources/ElementsCollection/elementscollectionwidget.h @@ -86,9 +86,22 @@ class ElementsCollectionWidget : public QWidget public slots: void reload(); void loadingFinished(); + void insertCurrentElement(); + void editLocation(const ElementsLocation &location); + + signals: + /** + Emitted when the user asks for an element to be placed on the + current folio. Whoever hosts this widget decides which view + receives it -- the dock is inside a diagram editor, but the + picker popup is not, so the widget must not reach for an + ancestor editor itself. + */ + void insertElementRequested(const ElementsLocation &location); private: void locationWasSaved(const ElementsLocation& location); + void activateIndex(const QModelIndex &index); private: diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 843b694be..06149836b 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -223,14 +223,72 @@ void DiagramView::handleElementDrop(QDropEvent *event) QPointF drop_pos; drop_pos = mapToScene(event->position().toPoint()); + startElementPlacement(location, drop_pos); +} + +/** + @brief DiagramView::startElementPlacement + Enter the interactive placement mode for @a location, with the pending + element starting at @a scene_pos. + + This is the mode where the element follows the cursor on the grid, a left + click drops a copy, Space rotates it and the element stays loaded so a run + of identical symbols can be placed with successive clicks. + + Split out of handleElementDrop() so that placement is reachable without a + drag: the mode itself was always general, it simply had no caller other + than the end of a drop. + @param location : the element or macro to place + @param scene_pos : where the pending element first appears, in scene + coordinates + @return true if the placement mode was entered +*/ +bool DiagramView::startElementPlacement(const ElementsLocation &location, + const QPointF &scene_pos) +{ + if (!diagram() || !(location.isElement() && location.exist())) { + return false; + } + if (diagram()->isReadOnly()) { + return false; + } + if (location.path().endsWith(".qetmak")) { - diagram()->setEventInterface(new DiagramEventAddMacro(location, diagram(), drop_pos)); + diagram()->setEventInterface( + new DiagramEventAddMacro(location, diagram(), scene_pos)); } else { - diagram()->setEventInterface(new DiagramEventAddElement(location, diagram(), drop_pos)); + //DiagramEventAddElement takes a non-const reference, so it needs + //an lvalue it may modify. Copying keeps the caller's location + //untouched -- QETDiagramEditor stores the same one for + //"insert last element". + ElementsLocation loc(location); + diagram()->setEventInterface( + new DiagramEventAddElement(loc, diagram(), scene_pos)); + emit elementPlacementStarted(location); } //Set focus to the view to get event this->setFocus(); + return true; +} + +/** + @brief DiagramView::defaultPlacementPos + @return where a pending element should appear when placement was not + started by a drop, so there is no cursor position to use. + + The cursor is used when it is over the view -- picking up a placement where + the user is already looking -- and the centre of the visible area + otherwise. +*/ +QPointF DiagramView::defaultPlacementPos() const +{ + const QPoint local = mapFromGlobal(QCursor::pos()); + if (viewport() && viewport()->rect().contains(local)) { + return mapToScene(local); + } + return mapToScene(viewport() ? viewport()->rect().center() + : rect().center()); } /** diff --git a/sources/diagramview.h b/sources/diagramview.h index eb73432cb..43201fd2e 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -79,6 +79,10 @@ class DiagramView : public PaletteGraphicsView /// several window managers and compositors, Wayland included). QPoint lastMousePos() const { return m_last_mouse_pos; } + bool startElementPlacement(const ElementsLocation &location, + const QPointF &scene_pos); + QPointF defaultPlacementPos() const; + protected: void mouseDoubleClickEvent(QMouseEvent *) override; void contextMenuEvent(QContextMenuEvent *) override; @@ -132,6 +136,9 @@ class DiagramView : public PaletteGraphicsView /// Signal emitted when free rubberband changed. /// When free rubberband selection ends this signal will be emitted with null value. void freeRubberBandChanged(QPolygonF polygon); + /// Signal emitted when the placement mode is entered for an + /// element (not a macro), whether from a drop or not. + void elementPlacementStarted(const ElementsLocation &); public slots: void setVisualisationMode(); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index da26262b2..cc5c546f3 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -226,6 +226,13 @@ void QETDiagramEditor::setUpElementsCollectionWidget() m_qdw_elmt_collection->setWidget(m_element_collection_widget); m_element_collection_widget->expandFirstItems(); + //The widget does not know which view should receive the element -- it + //is also used by the picker popup, which has no editor ancestor -- so + //the host decides. + connect(m_element_collection_widget, + &ElementsCollectionWidget::insertElementRequested, + this, &QETDiagramEditor::insertElementFromCollection); + addDockWidget(Qt::RightDockWidgetArea, m_qdw_elmt_collection); } @@ -755,6 +762,26 @@ void QETDiagramEditor::setUpActions() ShortcutManager::instance().registerAction(m_rotate_texts, "diagrameditor.rotate_texts", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_Space); ShortcutManager::instance().registerAction(m_edit_selection, "diagrameditor.edit_selection", tr("Éditeur de schémas"), Qt::CTRL | Qt::Key_E); + //Re-enter placement mode with the element placed last. Bare A rather + //than Space: Space already rotates the pending element *inside* + //placement mode (diagrameventaddelement.cpp), and is taken three times + //over in this editor besides. A matches KiCad's add-symbol key and + //reads correctly in the source language ("Ajouter"). ShortcutManager + //makes it a default, not a commitment -- it appears in the Shortcuts + //preference page like every other binding. + m_insert_last_element = new QAction(QET::Icons::ElementNew, + tr("Insérer le dernier élément"), this); + m_insert_last_element->setStatusTip( + tr("Place à nouveau le dernier élément inséré", "status bar tip")); + m_insert_last_element->setData("insert_last_element"); + m_insert_last_element->setEnabled(false); + ShortcutManager::instance().registerAction( + m_insert_last_element, "diagrameditor.insert_last_element", + tr("Éditeur de schémas"), Qt::Key_A); + connect(m_insert_last_element, &QAction::triggered, + this, &QETDiagramEditor::insertLastElement); + addAction(m_insert_last_element); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -1038,6 +1065,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_paste); menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); + menu_edition -> addAction(m_insert_last_element); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a @@ -1937,6 +1965,7 @@ void QETDiagramEditor::slot_updateActions() m_zoom_actions_group. setEnabled(opened_diagram); m_select_actions_group. setEnabled(opened_diagram); m_add_item_actions_group. setEnabled(editable_project); + m_insert_last_element-> setEnabled(opened_diagram && editable_project && !m_last_inserted_element.isNull()); m_row_column_actions_group. setEnabled(editable_project); m_background_color_button-> setEnabled(opened_diagram); m_draw_grid-> setEnabled(opened_diagram); @@ -2830,6 +2859,7 @@ void QETDiagramEditor::diagramWasAdded(DiagramView *dv) &QETDiagramEditor::selectionChanged, Qt::DirectConnection); connect(dv, &DiagramView::modeChanged, this, &QETDiagramEditor::slot_updateModeActions); + connect(dv, &DiagramView::elementPlacementStarted, this, &QETDiagramEditor::rememberPlacedElement); } /** @@ -2939,6 +2969,53 @@ void QETDiagramEditor::selectionChanged() } +/** + @brief QETDiagramEditor::insertElementFromCollection + Place @a location on the current folio using the interactive placement + mode -- the same mode a drag and drop ends in, entered without the drag. + @param location +*/ +void QETDiagramEditor::insertElementFromCollection(const ElementsLocation &location) +{ + DiagramView *dv = currentDiagramView(); + if (dv && dv->startElementPlacement(location, dv->defaultPlacementPos())) { + return; + } + + //Nowhere to place it: no folio open, or a read-only project. Do what + //a double click did before it placed, rather than nothing. + m_element_collection_widget->editLocation(location); +} + +/** + @brief QETDiagramEditor::rememberPlacedElement + Remember @a location for "insert last element". Connected to every view, + so an element placed by drag and drop counts as well as one placed from + the collection without a drag. Macros never reach here: the view does not + report them, since re-entering their placement mode from a shortcut has + not been thought through. + @param location +*/ +void QETDiagramEditor::rememberPlacedElement(const ElementsLocation &location) +{ + m_last_inserted_element = location; + m_insert_last_element->setEnabled(true); +} + +/** + @brief QETDiagramEditor::insertLastElement + Re-enter placement mode with the element placed most recently, so a run of + the same symbol can be dropped without returning to the collection. +*/ +void QETDiagramEditor::insertLastElement() +{ + if (!m_last_inserted_element.isElement() + || !m_last_inserted_element.exist()) { + return; + } + insertElementFromCollection(m_last_inserted_element); +} + /** @brief QETDiagramEditor::generateTerminalBlock */ diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 71ad56244..622c78c16 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -125,6 +125,9 @@ class QETDiagramEditor : public QETMainWindow void slot_updatePasteAction(); void slot_updateWindowsMenu(); void slot_updateAutoNumDock(); + void insertElementFromCollection(const ElementsLocation &location); + void insertLastElement(); + void rememberPlacedElement(const ElementsLocation &location); void generateTerminalBlock(); void setWindowedMode(); void setTabbedMode(); @@ -259,6 +262,9 @@ class QETDiagramEditor : public QETMainWindow *qdw_undo; /// Dock for the undo list ElementsCollectionWidget *m_element_collection_widget; + /// Last element placed from the collection, for "insert last" + ElementsLocation m_last_inserted_element; + QAction *m_insert_last_element = nullptr; DiagramPropertiesEditorDockWidget *m_selection_properties_editor; /// Elements panel diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index f5689046c..5f6194410 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -66,6 +66,9 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : ui->grid_startup_cb->setChecked(settings.value("diagrameditor/grid_display_startup", true).toBool()); ui->guides_startup_cb->setChecked(settings.value("diagrameditor/guides_display_startup", false).toBool()); + //Stored as "inserts" but presented as "edits", so the default (insert) + //is the unchecked state -- a preference reads better as an opt-out. + ui->m_collection_dblclick_edits->setChecked(!settings.value("elementscollection/double-click-inserts", true).toBool()); ui->DiagramEditor_xGrid_sb->setValue(settings.value("diagrameditor/Xgrid", 10).toInt()); ui->DiagramEditor_yGrid_sb->setValue(settings.value("diagrameditor/Ygrid", 10).toInt()); ui->DiagramEditor_xKeyGrid_sb->setValue(settings.value("diagrameditor/key_Xgrid", 10).toInt()); @@ -284,6 +287,7 @@ void GeneralConfigurationPage::applyConf() settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked()); settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked()); + settings.setValue("elementscollection/double-click-inserts", !ui->m_collection_dblclick_edits->isChecked()); //Grid step and key navigation settings.setValue("diagrameditor/Xgrid", ui->DiagramEditor_xGrid_sb->value()); settings.setValue("diagrameditor/Ygrid", ui->DiagramEditor_yGrid_sb->value()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 030b66877..9d4bd92e9 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -77,6 +77,16 @@ + + + + Par défaut, un double-clic insère l'élément sur le folio ; l'édition reste accessible par le menu contextuel. + + + Double-cliquer dans la collection ouvre l'éditeur d'élément au lieu de l'insérer + + + From c1d9e60656e5a41f2c6d2cd5343c35e528c57766 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 12:06:50 +1200 Subject: [PATCH 08/24] Place the last element and folio references from the folio's context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking an empty folio now also offers: - "Insérer le dernier élément": the same action as Édition and the A key, shown once something has been placed. - "Renvoi de folio": the folio report elements the project already uses, read from its embedded collection, with dated copies of the same element listed once. A project that has none yet gets the coming and going arrows of the common collection. An entry places its element where the menu was opened. The submenu is rebuilt each time the menu opens, from the embedded collection's XML, not from the folios' scenes. It is left empty, and so hidden, on a read-only folio. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 94e3f61bf93689066b3964f2316e3c8f1ee7b673) --- sources/diagramview.cpp | 79 ++++++++++++++++++++++++++++++++++++++ sources/diagramview.h | 3 ++ sources/qetdiagrameditor.h | 4 +- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 9d9f3edf7..4d81c5468 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include /** @@ -97,6 +98,9 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : m_create_template = new QAction(tr("Créer un template", "context menu action"), this); connect(m_create_template, &QAction::triggered, this, &DiagramView::createTemplateFromSelection); + //Filled each time the context menu opens, see updateFolioReportMenu() + m_folio_report_menu = new QMenu(tr("Renvoi de folio"), this); + //setup three separators, to be use in context menu for(int i=0 ; i<3 ; ++i) { @@ -1471,6 +1475,8 @@ QList DiagramView::contextMenuActions() const //level down where a stray click cannot reach them. list << m_paste_here; list << m_separators.at(0); + list << qde->m_insert_last_element; + list << m_folio_report_menu->menuAction(); list << qde->m_add_item_menu->menuAction(); list << m_separators.at(1); list << qde->m_edit_diagram_properties; @@ -1582,6 +1588,7 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e) { m_paste_here_pos = menu_pos; m_paste_here->setEnabled(Diagram::clipboardMayContainDiagram()); + updateFolioReportMenu(); } QList list = contextMenuActions(); @@ -1594,6 +1601,78 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e) } } +/** + @brief DiagramView::updateFolioReportMenu + Fill the "Renvoi de folio" submenu of the context menu with the folio + report elements this project already uses -- the ones in its embedded + collection -- or, when it has none yet, the coming and going arrows of + the common collection. An entry places its element where the context + menu was opened. Left empty, and so hidden, on a read-only diagram. +*/ +void DiagramView::updateFolioReportMenu() +{ + m_folio_report_menu->clear(); + if (m_diagram->isReadOnly()) { + return; + } + + QList locations; + QETProject *project = m_diagram->project(); + XmlElementCollection *collection = + project ? project->embeddedElementCollection() : nullptr; + if (collection) + { + QSet names; + const QDomNodeList definitions = + collection->root().elementsByTagName(QStringLiteral("definition")); + for (int i = 0 ; i < definitions.count() ; ++i) + { + const QDomElement definition = definitions.at(i).toElement(); + const QString link_type = definition.attribute(QStringLiteral("link_type")); + if (link_type != QLatin1String("next_report") + && link_type != QLatin1String("previous_report")) { + continue; + } + const ElementsLocation location = + collection->domToLocation(definition.parentNode().toElement()); + //A project keeps dated copies of the same element + //(01previous_folio-20140521204742.elmt), so list each name once. + if (!location.exist() || names.contains(location.name())) { + continue; + } + names.insert(location.name()); + locations << location; + } + } + + if (locations.isEmpty()) + { + for (const auto path : { + "common://10_electric/10_allpole/100_folio_referencing/01coming_arrow.elmt", + "common://10_electric/10_allpole/100_folio_referencing/02going_arrow.elmt"}) + { + const ElementsLocation location(QString::fromLatin1(path)); + if (location.exist()) { + locations << location; + } + } + } + + std::sort(locations.begin(), locations.end(), + [](const ElementsLocation &a, const ElementsLocation &b) { + return a.name().localeAwareCompare(b.name()) < 0; + }); + + for (const ElementsLocation &location : std::as_const(locations)) + { + QAction *action = m_folio_report_menu->addAction(location.icon(), + location.name()); + connect(action, &QAction::triggered, this, [this, location]() { + startElementPlacement(location, mapToScene(m_paste_here_pos)); + }); + } +} + /** * @brief DiagramView::createTemplateFromSelection * Triggered from the context menu to create a new template (macro) from the current selection. diff --git a/sources/diagramview.h b/sources/diagramview.h index 03eb067d5..ae7b6af33 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -31,6 +31,7 @@ class QETDiagramEditor; class DVEventInterface; class QInputEvent; class QGestureEvent; +class QMenu; /** This class provides a widget to render an electric diagram in an editable, @@ -55,6 +56,7 @@ class DiagramView : public PaletteGraphicsView QAction *m_paste_here = nullptr; QAction *m_multi_paste = nullptr; QAction *m_create_template = nullptr; + QMenu *m_folio_report_menu = nullptr; QPoint m_paste_here_pos; QPoint m_last_mouse_pos = QPoint(-1, -1); QPointF m_drag_last_pos; @@ -118,6 +120,7 @@ class DiagramView : public PaletteGraphicsView private: void handleElementDrop(QDropEvent *); + void updateFolioReportMenu(); void handleTitleBlockDrop(QDropEvent *); void handleTextDrop(QDropEvent *); void scrollOnMovement(QKeyEvent *); diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 3cb2659ec..fd1542c9a 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -177,7 +177,8 @@ class QETDiagramEditor : public QETMainWindow *m_edit_diagram_properties, ///< Show a dialog to edit diagram properties *m_conductor_reset, ///< Reset paths of selected conductors *m_cut, ///< Cut selection to clipboard - *m_copy; ///< Copy selection to clipboard + *m_copy, ///< Copy selection to clipboard + *m_insert_last_element = nullptr; ///< Place the last placed element again QActionGroup m_row_column_actions_group, /// Action related to add/remove rows/column in diagram @@ -274,7 +275,6 @@ class QETDiagramEditor : public QETMainWindow ElementsCollectionWidget *m_element_collection_widget; /// Last element placed from the collection, for "insert last" ElementsLocation m_last_inserted_element; - QAction *m_insert_last_element = nullptr; DiagramPropertiesEditorDockWidget *m_selection_properties_editor; /// Elements panel From 73ad43bd8fb277bd2b37f64c0c69d4f5aab6a07b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 8 Aug 2026 17:25:07 +1200 Subject: [PATCH 09/24] Show collection search results as a ranked list The Collections search hid non-matching rows, so the hits stayed spread through the expanded folders. Searching "diode" against the shipped collection left five levels of tree open, and the first visible hit was "Avalanche diode bidirectional". The same matches are now shown as a flat list that takes the tree's place while a search is active, best match first: exact name 1000 name starts with 800 name contains 600, less the match position info field only 300 less the name length, so "Diode" precedes "Diode Zener bidirectional" "diode" now gives Diode, diode-tube, Diode bridge, Diodes Module Pilot Wire, Photodiode. No new index: this ranks what match() already returns against Qt::UserRole+1, the string built at startup from the name and every element-info field. An element matching several "+" terms is listed once, and each row's tooltip is its folder, so similar names can be told apart. Double click or Enter on a result places it, as in the tree. Down from the search field moves into the results, so searching and placing needs no mouse. Emptying the field brings the tree back. The query and ranking live in rankedSearch(), which returns hits without model indexes, so another list can reuse them. Discussion #676. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 3d6a280f9608758527ef0ca2de7223a19dfbfa45) --- .../elementscollectionwidget.cpp | 217 ++++++++++++++++-- .../elementscollectionwidget.h | 25 ++ 2 files changed, 228 insertions(+), 14 deletions(-) diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp index 511dd0ef8..f8d073b77 100644 --- a/sources/ElementsCollection/elementscollectionwidget.cpp +++ b/sources/ElementsCollection/elementscollectionwidget.cpp @@ -37,6 +37,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -219,8 +223,25 @@ void ElementsCollectionWidget::setUpWidget() m_tab_widget->addTab(m_tree_view, tr("Collections")); m_tab_widget->addTab(m_macros_tree_view, tr("Modèles")); + //Flat ranked search results. + //The tree search hides non-matching rows, so hits stay scattered + //through five levels of expanded folders -- searching "diode" leaves + //you scrolling a tree to find them. This shows the same matches as a + //ranked list instead, and takes the tab widget's place while a search + //is active. + m_search_model = new QStandardItemModel(this); + m_search_results = new QListView(this); + m_search_results->setModel(m_search_model); + m_search_results->setIconSize(QSize(50, 50)); + m_search_results->setUniformItemSizes(false); + m_search_results->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_search_results->setContextMenuPolicy(Qt::CustomContextMenu); + m_search_results->setMouseTracking(true); + m_search_results->hide(); + m_main_vlayout->addWidget(m_search_field); m_main_vlayout->addWidget(m_tab_widget); + m_main_vlayout->addWidget(m_search_results); m_progress_bar = new QProgressBar(this); m_progress_bar->setFormat(QObject::tr("chargement %p% (%v sur %m)")); @@ -290,6 +311,41 @@ void ElementsCollectionWidget::setUpConnection() }); } + //The flat results list carries the collection path directly, so it + //does not go through activateIndex() -- there is no tree index behind + //a row to look an ElementCollectionItem up from. + auto place_from_results = [this](const QModelIndex &index) { + const QString path = index.data(Qt::UserRole + 2).toString(); + if (path.isEmpty()) { + return; + } + ElementsLocation location(path); + if (location.exist()) { + emit insertElementRequested(location); + } + }; + connect(m_search_results, &QListView::doubleClicked, this, place_from_results); + for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) { + auto *sc = new QShortcut(QKeySequence(key), m_search_results); + sc->setContext(Qt::WidgetShortcut); + connect(sc, &QShortcut::activated, this, [this, place_from_results]() { + place_from_results(m_search_results->currentIndex()); + }); + } + //Down from the search field moves into the results, so the whole + //type-then-place run happens without touching the mouse. + auto *to_results = new QShortcut(QKeySequence(Qt::Key_Down), m_search_field); + to_results->setContext(Qt::WidgetShortcut); + connect(to_results, &QShortcut::activated, this, [this]() { + if (!m_search_results->isVisible() || !m_search_model->rowCount()) { + return; + } + m_search_results->setFocus(); + if (!m_search_results->currentIndex().isValid()) { + m_search_results->setCurrentIndex(m_search_model->index(0, 0)); + } + }); + connect(m_macros_tree_view, &QTreeView::customContextMenuRequested, this, &ElementsCollectionWidget::customContextMenu); @@ -993,6 +1049,7 @@ void ElementsCollectionWidget::search() //Reset the search if (text.isEmpty()) { + clearFlatResults(); QModelIndex current_index = m_tree_view->currentIndex(); m_tree_view->reset(); @@ -1019,22 +1076,154 @@ void ElementsCollectionWidget::search() return; } - hideCollection(true); - const QStringList text_list = text.split("+", Qt::SkipEmptyParts); - QModelIndexList match_index; - for (QString txt : text_list) { - match_index << m_model->match(m_showed_index.isValid() - ? m_model->index(0,0,m_showed_index) - : m_model->index(0,0), - Qt::UserRole+1, - QVariant(txt), - -1, - Qt::MatchContains - | Qt::MatchRecursive); + showFlatResults(rankedSearch(text, m_showed_index)); +} + +/** + @brief ElementsCollectionWidget::rankedSearch + Run the collection search for @a text and return the element hits, best + first. The hits carry no model index, so a list that is not backed by the + collection model can show them too. + @param text : search text, "+" separating terms + @param within : restrict the search to this item's children, or the + whole collection when invalid + @return ranked hits, best first +*/ +QVector ElementsCollectionWidget::rankedSearch( + const QString &text, + const QModelIndex &within) +{ + QVector hits; + if (!m_model || text.size() < 3) { + return hits; } - for(QModelIndex index : match_index) - showAndExpandItem(index); + const QStringList terms = text.split("+", Qt::SkipEmptyParts); + QModelIndexList matches; + for (const QString &term : terms) { + matches << m_model->match(within.isValid() + ? m_model->index(0, 0, within) + : m_model->index(0, 0), + Qt::UserRole+1, + QVariant(term), + -1, + Qt::MatchContains + | Qt::MatchRecursive); + } + + const QString needle = terms.value(0).toLower(); + QVector> scored; + QSet seen; + + for (const QModelIndex &index : std::as_const(matches)) + { + ElementCollectionItem *eci = elementCollectionItemForIndex(index); + if (!(eci && eci->isElement())) { + continue; + } + const QString path = eci->collectionPath(); + //match() is run once per "+"-separated term, so the same element + //can arrive several times. + if (path.isEmpty() || seen.contains(path)) { + continue; + } + seen.insert(path); + + ElementSearchHit hit; + hit.path = path; + hit.name = index.data(Qt::DisplayRole).toString(); + hit.icon = qvariant_cast(index.data(Qt::DecorationRole)); + //Where it lives, so two similarly-named symbols are tellable apart + QStringList parts; + for (QModelIndex p = index.parent(); p.isValid(); p = p.parent()) { + parts.prepend(p.data(Qt::DisplayRole).toString()); + } + hit.folder = parts.join(QStringLiteral(" / ")); + + const QString hay = index.data(Qt::UserRole + 1).toString().toLower(); + scored.append({rankMatch(needle, hit.name, hay), hit}); + } + + std::stable_sort(scored.begin(), scored.end(), + [](const QPair &a, + const QPair &b) { + return a.first > b.first; + }); + hits.reserve(scored.size()); + for (const auto &p : std::as_const(scored)) { + hits.append(p.second); + } + return hits; +} + +/** + @brief ElementsCollectionWidget::rankMatch + Score a hit so the list can be ordered by how well it matches. + + The tree search treats every hit equally, which is fine when they stay in + place but useless in a ranked list: typing "diode" should not put an + element whose *description* mentions diodes above one actually called + "Diode". Name beats element-info field, earlier beats later, shorter beats + longer. + @param needle : lower-cased search text + @param name : the element's display name + @param haystack : the full indexed string (name + every info field) + @return a score, higher is better +*/ +int ElementsCollectionWidget::rankMatch(const QString &needle, + const QString &name, + const QString &haystack) +{ + const QString n = name.toLower(); + int score = 0; + + if (n == needle) { + score = 1000; + } else if (n.startsWith(needle)) { + score = 800; + } else if (n.contains(needle)) { + score = 600 - qMin(n.indexOf(needle), 99); + } else if (haystack.contains(needle)) { + //Matched only on an info field -- manufacturer, reference, + //description. Still a real hit, just a weaker one. + score = 300; + } + //Among equally-placed hits prefer the shorter name: "Diode" over + //"Diode Zener bidirectional". + return score - qMin(n.size(), 99); +} + +/** + @brief ElementsCollectionWidget::showFlatResults + Replace the tree with a flat list of @a hits, in the order given. + @param hits : from rankedSearch() +*/ +void ElementsCollectionWidget::showFlatResults(const QVector &hits) +{ + m_search_model->clear(); + + for (const ElementSearchHit &hit : hits) + { + auto *item = new QStandardItem(hit.icon, hit.name); + item->setEditable(false); + item->setToolTip(hit.folder); + item->setData(hit.path, Qt::UserRole + 2); + m_search_model->appendRow(item); + } + + m_tab_widget->hide(); + m_search_results->show(); +} + +/** + @brief ElementsCollectionWidget::clearFlatResults + Put the tree back when the search field is emptied. +*/ +void ElementsCollectionWidget::clearFlatResults() +{ + m_search_model->clear(); + m_search_results->hide(); + m_tab_widget->show(); } /** diff --git a/sources/ElementsCollection/elementscollectionwidget.h b/sources/ElementsCollection/elementscollectionwidget.h index b6997b7a8..7c46a6e51 100644 --- a/sources/ElementsCollection/elementscollectionwidget.h +++ b/sources/ElementsCollection/elementscollectionwidget.h @@ -43,6 +43,22 @@ class ElementsTreeView; and all action needed to use this widget. This is the element collection widget used in the diagram editor. */ +class QListView; +class QStandardItemModel; + +/** + @brief One ranked hit from a collection search. + Carries everything a result row needs, so a list that is not backed by + the collection model can show it. +*/ +struct ElementSearchHit +{ + QString path; ///< collection path, enough to build an ElementsLocation + QString name; ///< display name + QString folder; ///< where it lives, for telling similar names apart + QIcon icon; +}; + class ElementsCollectionWidget : public QWidget { Q_OBJECT @@ -56,6 +72,8 @@ class ElementsCollectionWidget : public QWidget void removeProject (QETProject *project); void highlightUnusedElement(); void setCurrentLocation(const ElementsLocation &location); + QVector rankedSearch(const QString &text, + const QModelIndex &within = QModelIndex()); protected: void leaveEvent(QEvent *event) override; @@ -102,6 +120,10 @@ class ElementsCollectionWidget : public QWidget private: void locationWasSaved(const ElementsLocation& location); void activateIndex(const QModelIndex &index); + void showFlatResults(const QVector &hits); + void clearFlatResults(); + static int rankMatch(const QString &needle, const QString &name, + const QString &haystack); private: @@ -113,6 +135,9 @@ class ElementsCollectionWidget : public QWidget ElementsTreeView *m_tree_view; ElementsTreeView *m_macros_tree_view = nullptr; QTabWidget *m_tab_widget = nullptr; + /// Flat ranked results, shown in place of the tree while searching + QListView *m_search_results = nullptr; + QStandardItemModel *m_search_model = nullptr; QVBoxLayout *m_main_vlayout; QMenu *m_context_menu; QModelIndex m_index_at_context_menu; From 07501d604e277d994a640646b171ed041f8baf2b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 8 Aug 2026 17:50:43 +1200 Subject: [PATCH 10/24] Add an element picker that opens at the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert opens a small picker where the mouse is, with its search field focused. Type to search the whole collection, Enter to place the best hit (it is preselected), Up/Down to choose another, Esc to close. The chosen element goes into the usual placement mode, so it can be placed several times and repeated with A. With the field empty the picker shows a palette: the elements of a folder, as an icon grid. The palette is a folder rather than a setting or a file format. Subfolders are read in, the 01_/02_ filename prefixes the shipped collection already uses give the order, and sharing it is putting it in the company collection. Only the path is stored, "elementscollection/palette-path", defaulting to the user collection. It is read each time the picker opens, capped at 60 entries and three folder levels. The picker builds no second collection model. It asks the Collections panel's rankedSearch(), so both give the same results in the same order and startup is unchanged. "Insérer un élément…" is in the Édition menu, registered with ShortcutManager on Insert, which nothing else uses, and disabled with no folio open or on a read-only project. Discussion #676. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 01e1e0303b96380e710da8d4bb2300b21d01e938) --- cmake/qet_compilation_vars.cmake | 2 + .../ElementsCollection/elementpickerpopup.cpp | 331 ++++++++++++++++++ .../ElementsCollection/elementpickerpopup.h | 76 ++++ sources/qetdiagrameditor.cpp | 42 +++ sources/qetdiagrameditor.h | 4 + 5 files changed, 455 insertions(+) create mode 100644 sources/ElementsCollection/elementpickerpopup.cpp create mode 100644 sources/ElementsCollection/elementpickerpopup.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 1bf68b48d..0a6a14d75 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -451,6 +451,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.h ${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.cpp ${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.h + ${QET_DIR}/sources/ElementsCollection/elementpickerpopup.cpp + ${QET_DIR}/sources/ElementsCollection/elementpickerpopup.h ${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.cpp ${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.h ${QET_DIR}/sources/ElementsCollection/elementslocation.cpp diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp new file mode 100644 index 000000000..553aa3d5c --- /dev/null +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -0,0 +1,331 @@ +/* + 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 "elementpickerpopup.h" + +#include "elementscollectionwidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../qetapp.h" +#include "elementslocation.h" + + //The palette is read from disk each time the picker opens. It is meant as + //a shortlist, and a large custom collection would otherwise make opening + //slow and the grid unusable. +static const int max_palette_entries = 60; + +/** + @brief ElementPickerPopup::ElementPickerPopup + @param source : the collection widget whose model the search runs against + @param parent +*/ +ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, + QWidget *parent) : + QFrame(parent, Qt::Popup), + m_source(source) +{ + setFrameShape(QFrame::StyledPanel); + setFrameShadow(QFrame::Raised); + setMinimumWidth(340); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(4); + + m_search = new QLineEdit(this); + m_search->setPlaceholderText(tr("Rechercher un élément…")); + m_search->setClearButtonEnabled(true); + + m_model = new QStandardItemModel(this); + m_view = new QListView(this); + m_view->setModel(m_model); + m_view->setIconSize(QSize(40, 40)); + m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setMinimumHeight(260); + + m_hint = new QLabel(tr("Entrée pour insérer · Échap pour fermer"), this); + m_hint->setEnabled(false); + + layout->addWidget(m_search); + layout->addWidget(m_view); + layout->addWidget(m_hint); + + //Search as you type, after a short idle: shorter than the dock's + //500 ms, since the popup is opened and closed in a few seconds, but + //the collection is large enough that every keystroke would show. + auto *timer = new QTimer(this); + timer->setSingleShot(true); + timer->setInterval(300); + connect(m_search, &QLineEdit::textChanged, this, + [timer]() { timer->start(); }); + connect(timer, &QTimer::timeout, this, &ElementPickerPopup::runSearch); + + connect(m_view, &QListView::doubleClicked, this, + [this](const QModelIndex &) { chooseCurrent(); }); +} + +/** + @brief ElementPickerPopup::popUpAt + Show the picker at @a global_pos, kept on screen, with the search field + focused and any previous query cleared. + @param global_pos +*/ +void ElementPickerPopup::popUpAt(const QPoint &global_pos) +{ + m_search->clear(); + m_model->clear(); + showPalette(); + + adjustSize(); + QPoint pos = global_pos; + + //Keep it fully on the screen the cursor is on: opening at the cursor + //near a right or bottom edge would otherwise push it off. + if (QScreen *screen = QGuiApplication::screenAt(global_pos)) { + const QRect avail = screen->availableGeometry(); + pos.setX(qBound(avail.left(), + pos.x(), avail.right() - width())); + pos.setY(qBound(avail.top(), + pos.y(), avail.bottom() - height())); + } + + move(pos); + show(); + m_search->setFocus(); +} + +/** + @brief ElementPickerPopup::runSearch +*/ +void ElementPickerPopup::runSearch() +{ + m_model->clear(); + if (!m_source) { + return; + } + + if (m_search->text().isEmpty()) { + showPalette(); + return; + } + + m_palette_mode = false; + m_view->setViewMode(QListView::ListMode); + m_view->setGridSize(QSize()); + m_view->setIconSize(QSize(40, 40)); + + const QVector hits = m_source->rankedSearch(m_search->text()); + for (const ElementSearchHit &hit : hits) + { + auto *item = new QStandardItem(hit.name); + item->setIcon(hit.icon); + item->setEditable(false); + item->setToolTip(hit.folder); + item->setData(hit.path, Qt::UserRole + 2); + m_model->appendRow(item); + } + + //Preselect the best hit so Enter works straight from the search field + //without arrowing down first -- that is the whole point of the popup. + if (m_model->rowCount()) { + m_view->setCurrentIndex(m_model->index(0, 0)); + } + m_hint->setText(hits.isEmpty() + ? tr("Aucun résultat") + : tr("Entrée pour insérer · Échap pour fermer")); +} + +/** + @brief ElementPickerPopup::chooseCurrent + Emit the highlighted element and close. +*/ +void ElementPickerPopup::chooseCurrent() +{ + const QModelIndex index = m_view->currentIndex(); + if (!index.isValid()) { + return; + } + const QString path = index.data(Qt::UserRole + 2).toString(); + if (path.isEmpty()) { + return; + } + + ElementsLocation location(path); + if (!location.exist()) { + return; + } + + //Close before emitting: placement mode wants the focus, and a popup + //still up would keep the grab. + hide(); + emit elementChosen(location); +} + +/** + @brief ElementPickerPopup::keyPressEvent + Drive the list from the search field, so the hands never leave the + keyboard: Up/Down move the selection, Enter places, Esc closes. + @param event +*/ +void ElementPickerPopup::keyPressEvent(QKeyEvent *event) +{ + switch (event->key()) + { + case Qt::Key_Escape: + hide(); + return; + case Qt::Key_Return: + case Qt::Key_Enter: + chooseCurrent(); + return; + case Qt::Key_Down: + case Qt::Key_Up: + case Qt::Key_PageDown: + case Qt::Key_PageUp: + if (m_model->rowCount()) { + //Forwarded rather than focus-switched, so typing carries + //on going to the search field. + QCoreApplication::sendEvent(m_view, event); + return; + } + break; + default: + break; + } + QFrame::keyPressEvent(event); +} + +/** + @brief ElementPickerPopup::showPalette + With an empty search field, show the quick palette as an icon grid. + + The palette is a folder, not a config file. QET already has a user-owned, + drag-populated, icon-rendering collection -- so a palette is just a + directory whose subfolders are categories and whose contents are entries. + That gives set-up (drag into the folder), ordering (the 01_/02_ filename + convention the shipped collection already uses), team sharing + (companyElementsDir) and version control for free, with no schema, parser, + merge rules or "reset to defaults" to build or maintain. + + Only the path lives in QSettings, defaulting to the user's custom + collection. +*/ +void ElementPickerPopup::showPalette() +{ + m_palette_mode = true; + m_model->clear(); + + m_view->setViewMode(QListView::IconMode); + m_view->setIconSize(QSize(48, 48)); + m_view->setGridSize(QSize(92, 84)); + m_view->setResizeMode(QListView::Adjust); + m_view->setWordWrap(true); + m_view->setMovement(QListView::Static); + + QSettings settings; + const QString path = settings.value( + QStringLiteral("elementscollection/palette-path"), + QETApp::customElementsDir()).toString(); + + const int count = loadPaletteDir(path, QString(), 0); + + if (!count) { + m_hint->setText( + tr("Palette vide — glissez des éléments dans votre collection " + "personnelle, ou tapez pour rechercher")); + } else { + m_hint->setText(tr("Entrée pour insérer · Échap pour fermer")); + m_view->setCurrentIndex(m_model->index(0, 0)); + } +} + +/** + @brief ElementPickerPopup::loadPaletteDir + Add every .elmt under @a dir_path to the grid, recursing into subfolders. + @param dir_path + @param prefix : folder path so far, shown as the entry's tooltip + @param depth : recursion guard -- a palette is a shortlist, and the whole + shipped collection would not be usable as a grid anyway + @return how many entries were added +*/ +int ElementPickerPopup::loadPaletteDir(const QString &dir_path, + const QString &prefix, int depth) +{ + if (depth > 3 || m_model->rowCount() >= max_palette_entries) { + return 0; + } + QDir dir(dir_path); + if (!dir.exists()) { + return 0; + } + + int added = 0; + //Sorted by name, which is what makes the 01_/02_ filename convention + //work as the ordering mechanism. + const QFileInfoList entries = dir.entryInfoList( + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + + for (const QFileInfo &info : entries) + { + if (m_model->rowCount() >= max_palette_entries) { + break; + } + if (info.isDir()) { + added += loadPaletteDir(info.absoluteFilePath(), + prefix.isEmpty() + ? info.fileName() + : prefix + QStringLiteral(" / ") + + info.fileName(), + depth + 1); + continue; + } + if (info.suffix().compare(QStringLiteral("elmt"), + Qt::CaseInsensitive) != 0) { + continue; + } + + ElementsLocation location(info.absoluteFilePath()); + if (!location.exist()) { + continue; + } + + auto *item = new QStandardItem(location.name().isEmpty() + ? info.completeBaseName() + : location.name()); + item->setIcon(location.icon()); + item->setEditable(false); + item->setToolTip(prefix.isEmpty() ? info.completeBaseName() : prefix); + item->setData(info.absoluteFilePath(), Qt::UserRole + 2); + item->setTextAlignment(Qt::AlignHCenter | Qt::AlignTop); + m_model->appendRow(item); + ++added; + } + return added; +} diff --git a/sources/ElementsCollection/elementpickerpopup.h b/sources/ElementsCollection/elementpickerpopup.h new file mode 100644 index 000000000..ab5213579 --- /dev/null +++ b/sources/ElementsCollection/elementpickerpopup.h @@ -0,0 +1,76 @@ +/* + 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 ELEMENTPICKERPOPUP_H +#define ELEMENTPICKERPOPUP_H + +#include "elementslocation.h" + +#include + +class ElementsCollectionWidget; +class QLineEdit; +class QListView; +class QStandardItemModel; +class QLabel; + +/** + @brief A cursor-anchored element picker. + + Opens where the mouse is, focused on its search field: type to filter, + Enter to place, Esc to close. It is a recall tool, as opposed to the + Collections dock, which is a browsing tool. + + It deliberately does not build its own ElementsCollectionModel. Loading the + collection is already the slow part of startup and a second copy would + double it, so the picker asks the dock's widget to run the query -- see + ElementsCollectionWidget::rankedSearch(). That also means the two always + agree on results and ranking. +*/ +class ElementPickerPopup : public QFrame +{ + Q_OBJECT + + public: + explicit ElementPickerPopup(ElementsCollectionWidget *source, + QWidget *parent = nullptr); + + void popUpAt(const QPoint &global_pos); + + signals: + /// Emitted when the user picks an element; the popup has closed + void elementChosen(const ElementsLocation &location); + + protected: + void keyPressEvent(QKeyEvent *event) override; + + private: + void runSearch(); + void chooseCurrent(); + void showPalette(); + int loadPaletteDir(const QString &dir_path, const QString &prefix, + int depth); + + ElementsCollectionWidget *m_source = nullptr; + QLineEdit *m_search = nullptr; + QListView *m_view = nullptr; + QStandardItemModel *m_model = nullptr; + QLabel *m_hint = nullptr; + bool m_palette_mode = true; +}; + +#endif // ELEMENTPICKERPOPUP_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 540907430..eaedcfc6e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -22,6 +22,7 @@ #include #include #include "ElementsCollection/elementscollectionwidget.h" +#include "ElementsCollection/elementpickerpopup.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" @@ -823,6 +824,22 @@ void QETDiagramEditor::setUpActions() this, &QETDiagramEditor::insertLastElement); addAction(m_insert_last_element); + //Cursor-anchored picker. Insert is unbound anywhere in the tree and + //reads correctly for the action, which keeps A free for the far more + //frequent "place the same symbol again". + m_show_element_picker = new QAction(QET::Icons::Add, + tr("Insérer un élément…"), this); + m_show_element_picker->setStatusTip( + tr("Ouvre le sélecteur d'éléments à la position du curseur", + "status bar tip")); + m_show_element_picker->setData("show_element_picker"); + ShortcutManager::instance().registerAction( + m_show_element_picker, "diagrameditor.show_element_picker", + tr("Éditeur de schémas"), Qt::Key_Insert); + connect(m_show_element_picker, &QAction::triggered, + this, &QETDiagramEditor::showElementPicker); + addAction(m_show_element_picker); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -1108,6 +1125,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); menu_edition -> addAction(m_insert_last_element); + menu_edition -> addAction(m_show_element_picker); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a @@ -2016,6 +2034,7 @@ void QETDiagramEditor::slot_updateActions() m_select_actions_group. setEnabled(opened_diagram); m_add_item_actions_group. setEnabled(editable_project); m_insert_last_element-> setEnabled(opened_diagram && editable_project && !m_last_inserted_element.isNull()); + m_show_element_picker-> setEnabled(opened_diagram && editable_project); m_row_column_actions_group. setEnabled(editable_project); m_background_color_button-> setEnabled(opened_diagram); m_draw_grid-> setEnabled(opened_diagram); @@ -3067,6 +3086,29 @@ void QETDiagramEditor::insertLastElement() insertElementFromCollection(m_last_inserted_element); } +/** + @brief QETDiagramEditor::showElementPicker + Open the element picker where the mouse is. + + Built lazily: most sessions of the diagram editor never open it, and it + holds a list view and a model of its own. +*/ +void QETDiagramEditor::showElementPicker() +{ + if (!currentDiagramView()) { + return; + } + + if (!m_element_picker) + { + m_element_picker = new ElementPickerPopup(m_element_collection_widget, + this); + connect(m_element_picker, &ElementPickerPopup::elementChosen, + this, &QETDiagramEditor::insertElementFromCollection); + } + m_element_picker->popUpAt(QCursor::pos()); +} + /** @brief QETDiagramEditor::generateTerminalBlock */ diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 3cb2659ec..657d56d22 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -45,6 +45,7 @@ class ElementsLocation; class RecentFiles; class DiagramPropertiesEditorDockWidget; class ElementsCollectionWidget; +class ElementPickerPopup; class AutoNumberingDockWidget; class TerminalNumberingDialog; @@ -130,6 +131,7 @@ class QETDiagramEditor : public QETMainWindow void insertElementFromCollection(const ElementsLocation &location); void insertLastElement(); void rememberPlacedElement(const ElementsLocation &location); + void showElementPicker(); void generateTerminalBlock(); void setWindowedMode(); void setTabbedMode(); @@ -271,6 +273,8 @@ class QETDiagramEditor : public QETMainWindow *m_qdw_elmt_collection, *qdw_undo; /// Dock for the undo list + QAction *m_show_element_picker = nullptr; + ElementPickerPopup *m_element_picker = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; /// Last element placed from the collection, for "insert last" ElementsLocation m_last_inserted_element; From 2da101ca90ce127c54766d01c71f9c942de8bca9 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 12:38:40 +1200 Subject: [PATCH 11/24] Add a shortcut bar that opens at the cursor with S Pressing S on a folio opens the element picker at the cursor with a row of commands above it, chosen by what is selected, like the SolidWorks shortcut bar: nothing selected insert last element, element picker, text, line, rectangle, terminal strip plan, paste, folio properties elements selected rotate, rotate texts, edit, copy, cut, delete only conductors reset path, edit, delete Each row is a list of ShortcutManager ids, so any registered command can go on it and the bar carries no command list of its own. The lists are in QSettings (diagrameditor/shortcut_bar/); a context the user has not changed follows the defaults. A disabled command keeps its place, greyed, so a row looks the same each time. Clicking a button closes the bar and triggers the action. A new configuration page, "Barre de raccourcis", edits the three lists: add, remove and reorder any diagram editor command. To make that possible: - ShortcutManager::action(id, owner) returns the action a given window registered under an id, since each editor window registers its own. - The add-item actions (text, image, shapes, terminal strip plan) are registered as diagrameditor.add_, with no default key. They also appear in the Shortcuts page and can now be bound. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 53c1e213282b9f7226ca1c09e81703113236bb88) --- cmake/qet_compilation_vars.cmake | 4 + .../ElementsCollection/elementpickerpopup.cpp | 62 ++++- .../ElementsCollection/elementpickerpopup.h | 8 +- sources/qetapp.cpp | 2 + sources/qetdiagrameditor.cpp | 91 ++++++- sources/qetdiagrameditor.h | 3 + sources/shortcutbarsettings.cpp | 122 +++++++++ sources/shortcutbarsettings.h | 50 ++++ sources/shortcutmanager.cpp | 33 +++ sources/shortcutmanager.h | 2 + .../ui/configpage/shortcutbarconfigpage.cpp | 231 ++++++++++++++++++ sources/ui/configpage/shortcutbarconfigpage.h | 64 +++++ 12 files changed, 659 insertions(+), 13 deletions(-) create mode 100644 sources/shortcutbarsettings.cpp create mode 100644 sources/shortcutbarsettings.h create mode 100644 sources/ui/configpage/shortcutbarconfigpage.cpp create mode 100644 sources/ui/configpage/shortcutbarconfigpage.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 0a6a14d75..ddc1ba0e0 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -285,6 +285,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/recentfiles.h ${QET_DIR}/sources/shortcutmanager.cpp ${QET_DIR}/sources/shortcutmanager.h + ${QET_DIR}/sources/shortcutbarsettings.cpp + ${QET_DIR}/sources/shortcutbarsettings.h ${QET_DIR}/sources/titleblockcell.cpp ${QET_DIR}/sources/titleblockcell.h ${QET_DIR}/sources/titleblockproperties.cpp @@ -799,6 +801,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/configpage/guidespropertieswidget.h ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.cpp ${QET_DIR}/sources/ui/configpage/shortcutsconfigpage.h + ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.cpp + ${QET_DIR}/sources/ui/configpage/shortcutbarconfigpage.h ${QET_DIR}/sources/undocommand/addelementtextcommand.cpp ${QET_DIR}/sources/undocommand/addelementtextcommand.h diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index 553aa3d5c..d3c9e9a39 100644 --- a/sources/ElementsCollection/elementpickerpopup.cpp +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -19,7 +19,10 @@ #include "elementscollectionwidget.h" +#include #include +#include +#include #include #include #include @@ -58,6 +61,13 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, layout->setContentsMargins(6, 6, 6, 6); layout->setSpacing(4); + //Command row, shown when the picker is opened as the shortcut bar + m_commands = new QWidget(this); + m_commands_layout = new QHBoxLayout(m_commands); + m_commands_layout->setContentsMargins(0, 0, 0, 0); + m_commands_layout->setSpacing(2); + m_commands->hide(); + m_search = new QLineEdit(this); m_search->setPlaceholderText(tr("Rechercher un élément…")); m_search->setClearButtonEnabled(true); @@ -73,6 +83,7 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_hint = new QLabel(tr("Entrée pour insérer · Échap pour fermer"), this); m_hint->setEnabled(false); + layout->addWidget(m_commands); layout->addWidget(m_search); layout->addWidget(m_view); layout->addWidget(m_hint); @@ -97,8 +108,10 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, focused and any previous query cleared. @param global_pos */ -void ElementPickerPopup::popUpAt(const QPoint &global_pos) +void ElementPickerPopup::popUpAt(const QPoint &global_pos, + const QList &commands) { + setCommands(commands); m_search->clear(); m_model->clear(); showPalette(); @@ -121,6 +134,53 @@ void ElementPickerPopup::popUpAt(const QPoint &global_pos) m_search->setFocus(); } +/** + @brief ElementPickerPopup::setCommands + Show @a commands as a row of buttons above the search field, or hide the + row when there are none. A disabled command keeps its place, greyed out, + so the row looks the same every time for a given selection. + + Clicking a button closes the picker first, then triggers the action: a + command such as "add a line" starts a mode on the folio, which needs the + focus the popup holds. + @param commands +*/ +void ElementPickerPopup::setCommands(const QList &commands) +{ + while (QLayoutItem *item = m_commands_layout->takeAt(0)) { + delete item->widget(); + delete item; + } + + for (QAction *action : commands) + { + auto *button = new QToolButton(m_commands); + button->setAutoRaise(true); + button->setIconSize(QSize(24, 24)); + const QString text = action->text().remove(QLatin1Char('&')); + if (action->icon().isNull()) { + button->setText(text); + button->setToolButtonStyle(Qt::ToolButtonTextOnly); + } else { + button->setIcon(action->icon()); + } + const QKeySequence key = action->shortcut(); + button->setToolTip(key.isEmpty() + ? text + : QStringLiteral("%1 (%2)").arg( + text, key.toString(QKeySequence::NativeText))); + button->setEnabled(action->isEnabled()); + button->setFocusPolicy(Qt::NoFocus); + connect(button, &QToolButton::clicked, this, [this, action]() { + hide(); + action->trigger(); + }); + m_commands_layout->addWidget(button); + } + m_commands_layout->addStretch(); + m_commands->setVisible(!commands.isEmpty()); +} + /** @brief ElementPickerPopup::runSearch */ diff --git a/sources/ElementsCollection/elementpickerpopup.h b/sources/ElementsCollection/elementpickerpopup.h index ab5213579..ae3200a83 100644 --- a/sources/ElementsCollection/elementpickerpopup.h +++ b/sources/ElementsCollection/elementpickerpopup.h @@ -27,6 +27,8 @@ class QLineEdit; class QListView; class QStandardItemModel; class QLabel; +class QAction; +class QHBoxLayout; /** @brief A cursor-anchored element picker. @@ -49,7 +51,8 @@ class ElementPickerPopup : public QFrame explicit ElementPickerPopup(ElementsCollectionWidget *source, QWidget *parent = nullptr); - void popUpAt(const QPoint &global_pos); + void popUpAt(const QPoint &global_pos, + const QList &commands = {}); signals: /// Emitted when the user picks an element; the popup has closed @@ -62,6 +65,7 @@ class ElementPickerPopup : public QFrame void runSearch(); void chooseCurrent(); void showPalette(); + void setCommands(const QList &commands); int loadPaletteDir(const QString &dir_path, const QString &prefix, int depth); @@ -70,6 +74,8 @@ class ElementPickerPopup : public QFrame QListView *m_view = nullptr; QStandardItemModel *m_model = nullptr; QLabel *m_hint = nullptr; + QWidget *m_commands = nullptr; + QHBoxLayout *m_commands_layout = nullptr; bool m_palette_mode = true; }; diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 48d9001b6..f7c086756 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -40,6 +40,7 @@ #include "ui/aboutqetdialog.h" #include "ui/configpage/generalconfigurationpage.h" #include "ui/configpage/shortcutsconfigpage.h" +#include "ui/configpage/shortcutbarconfigpage.h" #include "machine_info.h" #include "TerminalStrip/ui/terminalstripeditorwindow.h" #include "qetversion.h" @@ -2213,6 +2214,7 @@ void QETApp::configureQET() cd.addPage(new ExportConfigPage()); cd.addPage(new PrintConfigPage()); cd.addPage(new ShortcutsConfigPage()); + cd.addPage(new ShortcutBarConfigPage()); #ifdef QET_SPACEMOUSE_SUPPORT cd.addPage(new SpaceMouseConfigPage()); #endif diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index eaedcfc6e..08571a337 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -23,6 +23,8 @@ #include #include "ElementsCollection/elementscollectionwidget.h" #include "ElementsCollection/elementpickerpopup.h" +#include "shortcutbarsettings.h" +#include "qetgraphicsitem/conductor.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" @@ -76,6 +78,7 @@ #include #include #include +#include #ifdef BUILD_WITHOUT_KF # include "ui/nokde/kautosavefile.h" #else @@ -840,6 +843,20 @@ void QETDiagramEditor::setUpActions() this, &QETDiagramEditor::showElementPicker); addAction(m_show_element_picker); + //The picker with a row of commands above it, chosen by what is + //selected -- the SolidWorks "S" shortcut bar. S is unbound in this + //editor. + m_show_shortcut_bar = new QAction(tr("Barre de raccourcis"), this); + m_show_shortcut_bar->setStatusTip( + tr("Ouvre à la position du curseur les commandes utiles pour la sélection, et le sélecteur d'éléments", + "status bar tip")); + ShortcutManager::instance().registerAction( + m_show_shortcut_bar, "diagrameditor.show_shortcut_bar", + tr("Éditeur de schémas"), Qt::Key_S); + connect(m_show_shortcut_bar, &QAction::triggered, + this, &QETDiagramEditor::showShortcutBar); + addAction(m_show_shortcut_bar); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -971,6 +988,13 @@ void QETDiagramEditor::setUpActions() add_path->setCheckable(true); connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered); + //No default key, but an id: they can then be bound in the Shortcuts + //page and placed on the shortcut bar, like every other command. + for (QAction *action : m_add_item_actions_group.actions()) { + ShortcutManager::instance().registerAction( + action, "diagrameditor.add_" + action->data().toString(), + tr("Éditeur de schémas"), QKeySequence()); + } //Depth action m_depth_action_group = QET::depthActionGroup(this); @@ -1126,6 +1150,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_configure_duplicate); menu_edition -> addAction(m_insert_last_element); menu_edition -> addAction(m_show_element_picker); + menu_edition -> addAction(m_show_shortcut_bar); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a @@ -2035,6 +2060,7 @@ void QETDiagramEditor::slot_updateActions() m_add_item_actions_group. setEnabled(editable_project); m_insert_last_element-> setEnabled(opened_diagram && editable_project && !m_last_inserted_element.isNull()); m_show_element_picker-> setEnabled(opened_diagram && editable_project); + m_show_shortcut_bar-> setEnabled(opened_diagram && editable_project); m_row_column_actions_group. setEnabled(editable_project); m_background_color_button-> setEnabled(opened_diagram); m_draw_grid-> setEnabled(opened_diagram); @@ -3087,18 +3113,12 @@ void QETDiagramEditor::insertLastElement() } /** - @brief QETDiagramEditor::showElementPicker - Open the element picker where the mouse is. - - Built lazily: most sessions of the diagram editor never open it, and it - holds a list view and a model of its own. + @brief QETDiagramEditor::elementPicker + @return the element picker, built on first use: most sessions never open + it, and it holds a list view and a model of its own. */ -void QETDiagramEditor::showElementPicker() +ElementPickerPopup *QETDiagramEditor::elementPicker() { - if (!currentDiagramView()) { - return; - } - if (!m_element_picker) { m_element_picker = new ElementPickerPopup(m_element_collection_widget, @@ -3106,7 +3126,56 @@ void QETDiagramEditor::showElementPicker() connect(m_element_picker, &ElementPickerPopup::elementChosen, this, &QETDiagramEditor::insertElementFromCollection); } - m_element_picker->popUpAt(QCursor::pos()); + return m_element_picker; +} + +/** + @brief QETDiagramEditor::showElementPicker + Open the element picker where the mouse is. +*/ +void QETDiagramEditor::showElementPicker() +{ + if (!currentDiagramView()) { + return; + } + + elementPicker()->popUpAt(QCursor::pos()); +} + +/** + @brief QETDiagramEditor::showShortcutBar + Open the element picker where the mouse is, with a row of commands above + it chosen by what is selected: the folio's commands with nothing + selected, conductor commands when only conductors are, and selection + commands otherwise. The commands are ShortcutManager ids, listed in + ShortcutBarSettings and editable in the configuration dialog. +*/ +void QETDiagramEditor::showShortcutBar() +{ + DiagramView *dv = currentDiagramView(); + if (!dv) { + return; + } + + const QList selection = dv->diagram()->selectedItems(); + ShortcutBarSettings::Context context = ShortcutBarSettings::Canvas; + if (!selection.isEmpty()) + { + const bool only_conductors = std::all_of( + selection.cbegin(), selection.cend(), + [](QGraphicsItem *item) { return item->type() == Conductor::Type; }); + context = only_conductors ? ShortcutBarSettings::Conductor + : ShortcutBarSettings::Selection; + } + + QList commands; + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (QAction *action = ShortcutManager::instance().action(id, this)) { + commands << action; + } + } + + elementPicker()->popUpAt(QCursor::pos(), commands); } /** diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 657d56d22..7a6abc53a 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -132,6 +132,7 @@ class QETDiagramEditor : public QETMainWindow void insertLastElement(); void rememberPlacedElement(const ElementsLocation &location); void showElementPicker(); + void showShortcutBar(); void generateTerminalBlock(); void setWindowedMode(); void setTabbedMode(); @@ -273,7 +274,9 @@ class QETDiagramEditor : public QETMainWindow *m_qdw_elmt_collection, *qdw_undo; /// Dock for the undo list + ElementPickerPopup *elementPicker(); QAction *m_show_element_picker = nullptr; + QAction *m_show_shortcut_bar = nullptr; ElementPickerPopup *m_element_picker = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; /// Last element placed from the collection, for "insert last" diff --git a/sources/shortcutbarsettings.cpp b/sources/shortcutbarsettings.cpp new file mode 100644 index 000000000..95072076a --- /dev/null +++ b/sources/shortcutbarsettings.cpp @@ -0,0 +1,122 @@ +/* + 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 "shortcutbarsettings.h" + +#include +#include + +namespace { +QString settingsKey(ShortcutBarSettings::Context context) +{ + switch (context) + { + case ShortcutBarSettings::Canvas: + return QStringLiteral("diagrameditor/shortcut_bar/canvas"); + case ShortcutBarSettings::Selection: + return QStringLiteral("diagrameditor/shortcut_bar/selection"); + case ShortcutBarSettings::Conductor: + return QStringLiteral("diagrameditor/shortcut_bar/conductor"); + } + return QString(); +} +} + +/** + @return every context, in the order they are shown to the user +*/ +QList ShortcutBarSettings::contexts() +{ + return {Canvas, Selection, Conductor}; +} + +/** + @return the name of @a context, for the configuration page +*/ +QString ShortcutBarSettings::title(Context context) +{ + switch (context) + { + case Canvas: + return QCoreApplication::translate("ShortcutBarSettings", "Folio, rien de sélectionné"); + case Selection: + return QCoreApplication::translate("ShortcutBarSettings", "Éléments sélectionnés"); + case Conductor: + return QCoreApplication::translate("ShortcutBarSettings", "Conducteurs sélectionnés"); + } + return QString(); +} + +/** + @return the ids to show for @a context: the user's list if they saved + one, the defaults otherwise. A saved empty list stays empty. +*/ +QStringList ShortcutBarSettings::ids(Context context) +{ + QSettings settings; + const QString key = settingsKey(context); + if (!settings.contains(key)) { + return defaultIds(context); + } + return settings.value(key).toStringList(); +} + +/** + @return the commands a new user sees for @a context +*/ +QStringList ShortcutBarSettings::defaultIds(Context context) +{ + switch (context) + { + case Canvas: + return {QStringLiteral("diagrameditor.insert_last_element"), + QStringLiteral("diagrameditor.show_element_picker"), + QStringLiteral("diagrameditor.add_text"), + QStringLiteral("diagrameditor.add_line"), + QStringLiteral("diagrameditor.add_rectangle"), + QStringLiteral("diagrameditor.add_terminal_strip"), + QStringLiteral("diagrameditor.paste"), + QStringLiteral("diagrameditor.edit_diagram_properties")}; + case Selection: + return {QStringLiteral("diagrameditor.rotate_selection"), + QStringLiteral("diagrameditor.rotate_texts"), + QStringLiteral("diagrameditor.edit_selection"), + QStringLiteral("diagrameditor.copy"), + QStringLiteral("diagrameditor.cut"), + QStringLiteral("diagrameditor.delete_selection")}; + case Conductor: + return {QStringLiteral("diagrameditor.conductor_reset"), + QStringLiteral("diagrameditor.edit_selection"), + QStringLiteral("diagrameditor.delete_selection")}; + } + return {}; +} + +/** + @brief ShortcutBarSettings::setIds + Save @a ids for @a context. Saving the defaults removes the key, so a + later change of defaults still reaches this user. +*/ +void ShortcutBarSettings::setIds(Context context, const QStringList &ids) +{ + QSettings settings; + if (ids == defaultIds(context)) { + settings.remove(settingsKey(context)); + } else { + settings.setValue(settingsKey(context), ids); + } +} diff --git a/sources/shortcutbarsettings.h b/sources/shortcutbarsettings.h new file mode 100644 index 000000000..753698e75 --- /dev/null +++ b/sources/shortcutbarsettings.h @@ -0,0 +1,50 @@ +/* + 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 SHORTCUTBARSETTINGS_H +#define SHORTCUTBARSETTINGS_H + +#include +#include +#include + +/** + @brief The commands shown on the diagram editor's shortcut bar. + + The bar opens at the cursor and shows a different row of commands + depending on what is selected. Each row is a list of ShortcutManager ids, + so any registered command can go on it and the bar needs no command list + of its own. Stored in QSettings, one key per context; a context the user + never changed uses the defaults below. +*/ +class ShortcutBarSettings +{ + public: + enum Context { + Canvas, ///< nothing selected + Selection, ///< elements, texts or shapes selected + Conductor ///< only conductors selected + }; + + static QList contexts(); + static QString title(Context context); + static QStringList ids(Context context); + static QStringList defaultIds(Context context); + static void setIds(Context context, const QStringList &ids); +}; + +#endif // SHORTCUTBARSETTINGS_H diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 6f8e856a4..56d43b228 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -197,3 +197,36 @@ bool ShortcutManager::trigger(const QString &id) const } return false; } + +/** + @return the QAction registered under @a id that belongs to @a owner -- + that is, has @a owner among its ancestors -- or nullptr. Several windows + of the same kind each register their own action under one id, so a + window asking for "its" action has to say which window it is. + @param id + @param owner : the window, or nullptr for the first live action +*/ +QAction *ShortcutManager::action(const QString &id, const QObject *owner) const +{ + auto it = m_entries.find(id); + if (it == m_entries.end()) { + return nullptr; + } + + for (const QPointer &target : qAsConst(it->targets)) + { + auto *action = qobject_cast(target.data()); + if (!action) { + continue; + } + if (!owner) { + return action; + } + for (const QObject *o = action->parent(); o; o = o->parent()) { + if (o == owner) { + return action; + } + } + } + return nullptr; +} diff --git a/sources/shortcutmanager.h b/sources/shortcutmanager.h index d40448c52..b42cd0d44 100644 --- a/sources/shortcutmanager.h +++ b/sources/shortcutmanager.h @@ -26,6 +26,7 @@ #include class QObject; +class QAction; /** @brief The ShortcutManager class @@ -84,6 +85,7 @@ class ShortcutManager /// multi-window case, not a guaranteed-correct dispatch. /// @return whether a live target was found and triggered. bool trigger(const QString &id) const; + QAction *action(const QString &id, const QObject *owner) const; private: ShortcutManager() = default; diff --git a/sources/ui/configpage/shortcutbarconfigpage.cpp b/sources/ui/configpage/shortcutbarconfigpage.cpp new file mode 100644 index 000000000..d805aaa2d --- /dev/null +++ b/sources/ui/configpage/shortcutbarconfigpage.cpp @@ -0,0 +1,231 @@ +/* + 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 "shortcutbarconfigpage.h" + +#include "../../qeticons.h" +#include "../../shortcutmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + /// The bar can hold any diagram editor command, except the one that + /// opens it. +bool offerable(const QString &id) +{ + return id.startsWith(QLatin1String("diagrameditor.")) + && id != QLatin1String("diagrameditor.show_shortcut_bar"); +} +} + +/** + @brief ShortcutBarConfigPage::ShortcutBarConfigPage + @param parent +*/ +ShortcutBarConfigPage::ShortcutBarConfigPage(QWidget *parent) : + ConfigPage(parent) +{ + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) { + if (offerable(info.id)) { + m_descriptions.insert(info.id, info.description); + } + } + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_pending.insert(c, ShortcutBarSettings::ids(c)); + } + + auto *explanation = new QLabel( + tr("La barre de raccourcis s'ouvre à la position du curseur " + "(touche S par défaut). Elle montre les commandes choisies " + "ici selon ce qui est sélectionné, puis le sélecteur " + "d'éléments."), this); + explanation->setWordWrap(true); + + m_context = new QComboBox(this); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + m_context->addItem(ShortcutBarSettings::title(c), c); + } + + m_available = new QListWidget(this); + m_available->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_available->setSortingEnabled(true); + m_chosen = new QListWidget(this); + m_chosen->setSelectionMode(QAbstractItemView::ExtendedSelection); + m_chosen->setDragDropMode(QAbstractItemView::InternalMove); + + auto *add = new QPushButton(tr("Ajouter →"), this); + auto *remove = new QPushButton(tr("← Retirer"), this); + auto *up = new QPushButton(tr("Monter"), this); + auto *down = new QPushButton(tr("Descendre"), this); + auto *reset = new QPushButton(tr("Valeurs par défaut"), this); + + auto *buttons = new QVBoxLayout(); + buttons->addStretch(); + buttons->addWidget(add); + buttons->addWidget(remove); + buttons->addSpacing(12); + buttons->addWidget(up); + buttons->addWidget(down); + buttons->addStretch(); + + auto *grid = new QGridLayout(); + grid->addWidget(new QLabel(tr("Commandes disponibles"), this), 0, 0); + grid->addWidget(new QLabel(tr("Dans la barre, dans l'ordre"), this), 0, 2); + grid->addWidget(m_available, 1, 0); + grid->addLayout(buttons, 1, 1); + grid->addWidget(m_chosen, 1, 2); + + auto *context_row = new QHBoxLayout(); + context_row->addWidget(new QLabel(tr("Contexte :"), this)); + context_row->addWidget(m_context, 1); + context_row->addWidget(reset); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(explanation); + layout->addLayout(context_row); + layout->addLayout(grid); + + connect(m_context, qOverload(&QComboBox::currentIndexChanged), this, [this]() { + storeContext(); + showContext(); + }); + connect(add, &QPushButton::clicked, this, &ShortcutBarConfigPage::addSelected); + connect(remove, &QPushButton::clicked, this, &ShortcutBarConfigPage::removeSelected); + connect(up, &QPushButton::clicked, this, [this]() { moveSelected(-1); }); + connect(down, &QPushButton::clicked, this, [this]() { moveSelected(1); }); + connect(reset, &QPushButton::clicked, this, &ShortcutBarConfigPage::resetContext); + connect(m_available, &QListWidget::itemDoubleClicked, this, &ShortcutBarConfigPage::addSelected); + connect(m_chosen, &QListWidget::itemDoubleClicked, this, &ShortcutBarConfigPage::removeSelected); + + showContext(); +} + +/** + @brief ShortcutBarConfigPage::applyConf + Save every context's list. +*/ +void ShortcutBarConfigPage::applyConf() +{ + storeContext(); + for (const ShortcutBarSettings::Context c : ShortcutBarSettings::contexts()) { + ShortcutBarSettings::setIds(c, m_pending.value(c)); + } +} + +QString ShortcutBarConfigPage::title() const +{ + return tr("Barre de raccourcis", "configuration page title"); +} + +QIcon ShortcutBarConfigPage::icon() const +{ + return QET::Icons::ConfigureShortcuts; +} + +/** + @brief ShortcutBarConfigPage::showContext + Fill both lists for the context chosen in the combo box. +*/ +void ShortcutBarConfigPage::showContext() +{ + m_shown = static_cast( + m_context->currentData().toInt()); + const QStringList chosen = m_pending.value(m_shown); + + m_available->clear(); + m_chosen->clear(); + for (const QString &id : chosen) { + appendItem(m_chosen, id); + } + for (auto it = m_descriptions.cbegin(); it != m_descriptions.cend(); ++it) { + if (!chosen.contains(it.key())) { + appendItem(m_available, it.key()); + } + } +} + +/** + @brief ShortcutBarConfigPage::storeContext + Keep the shown context's list, in the order on screen. +*/ +void ShortcutBarConfigPage::storeContext() +{ + QStringList ids; + for (int i = 0 ; i < m_chosen->count() ; ++i) { + ids << m_chosen->item(i)->data(Qt::UserRole).toString(); + } + m_pending.insert(m_shown, ids); +} + +void ShortcutBarConfigPage::addSelected() +{ + for (QListWidgetItem *item : m_available->selectedItems()) { + m_chosen->addItem(m_available->takeItem(m_available->row(item))); + } +} + +void ShortcutBarConfigPage::removeSelected() +{ + for (QListWidgetItem *item : m_chosen->selectedItems()) { + m_available->addItem(m_chosen->takeItem(m_chosen->row(item))); + } +} + +/** + @brief ShortcutBarConfigPage::moveSelected + Move the selected command @a step rows, keeping it selected. +*/ +void ShortcutBarConfigPage::moveSelected(int step) +{ + const int row = m_chosen->currentRow(); + const int target = row + step; + if (row < 0 || target < 0 || target >= m_chosen->count()) { + return; + } + QListWidgetItem *item = m_chosen->takeItem(row); + m_chosen->insertItem(target, item); + m_chosen->setCurrentRow(target); +} + +void ShortcutBarConfigPage::resetContext() +{ + m_pending.insert(m_shown, ShortcutBarSettings::defaultIds(m_shown)); + showContext(); +} + +/** + @brief ShortcutBarConfigPage::appendItem + Add @a id to @a list with the command's text and icon. An id no live + action carries (a command from a build without it) is still listed, by + its id, so saving does not silently drop it. +*/ +void ShortcutBarConfigPage::appendItem(QListWidget *list, const QString &id) +{ + QAction *action = ShortcutManager::instance().action(id, nullptr); + const QString text = m_descriptions.value(id, id); + auto *item = new QListWidgetItem(action ? action->icon() : QIcon(), text); + item->setData(Qt::UserRole, id); + list->addItem(item); +} diff --git a/sources/ui/configpage/shortcutbarconfigpage.h b/sources/ui/configpage/shortcutbarconfigpage.h new file mode 100644 index 000000000..9a15537dd --- /dev/null +++ b/sources/ui/configpage/shortcutbarconfigpage.h @@ -0,0 +1,64 @@ +/* + 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 SHORTCUTBARCONFIGPAGE_H +#define SHORTCUTBARCONFIGPAGE_H + +#include "configpage.h" +#include "../../shortcutbarsettings.h" + +#include + +class QComboBox; +class QListWidget; + +/** + @brief The ShortcutBarConfigPage class + Choose which commands the diagram editor's shortcut bar shows, and in + what order, for each selection context. Any command registered with + ShortcutManager by the diagram editor can be added. Changes are kept + per context while the dialog is open and saved by applyConf(). +*/ +class ShortcutBarConfigPage : public ConfigPage +{ + Q_OBJECT + + public: + explicit ShortcutBarConfigPage(QWidget *parent = nullptr); + + void applyConf() override; + QString title() const override; + QIcon icon() const override; + + private: + void showContext(); + void storeContext(); + void addSelected(); + void removeSelected(); + void moveSelected(int step); + void resetContext(); + void appendItem(QListWidget *list, const QString &id); + + QComboBox *m_context; + QListWidget *m_available; + QListWidget *m_chosen; + ShortcutBarSettings::Context m_shown = ShortcutBarSettings::Canvas; + QHash m_pending; + QHash m_descriptions; +}; + +#endif // SHORTCUTBARCONFIGPAGE_H From 6d42556d9f74c577ea5aad8b34c7179d6f66a296 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 13:11:22 +1200 Subject: [PATCH 12/24] Customise the shortcut bar in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click the shortcut bar, or click the "…" button at its end, and it turns into a small window holding two lists: the bar's commands, left to right, and every other command. Drag a command onto the bar, off it, or to another place on it; a double click moves it to the other list. Terminé saves and shows the bar again where it was, with the result; Annuler, Esc or closing the window leaves it as it was. "Valeurs par défaut" puts back the defaults for this context. A Qt::Popup closes on a press outside it and holds the mouse grab, so the bar is re-shown as a Qt::Tool window for the time of the edit. Only QSettings is written, through ShortcutBarSettings, never the project's undo stack. The popup now looks the commands up itself (popUpShortcutBar(pos, context)) instead of being handed actions, since it has to rebuild them after an edit. ShortcutBarSettings::availableIds() lists what can go on the bar, shared with the configuration page. An empty bar still shows its "…" button, so it can be filled again. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 4145d4bec1d403126b2a6587105aea47092db8a2) --- .../ElementsCollection/elementpickerpopup.cpp | 284 +++++++++++++++++- .../ElementsCollection/elementpickerpopup.h | 24 +- sources/qetdiagrameditor.cpp | 9 +- sources/shortcutbarsettings.cpp | 20 ++ sources/shortcutbarsettings.h | 1 + .../ui/configpage/shortcutbarconfigpage.cpp | 13 +- 6 files changed, 323 insertions(+), 28 deletions(-) diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index d3c9e9a39..870c3894d 100644 --- a/sources/ElementsCollection/elementpickerpopup.cpp +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -22,7 +22,11 @@ #include #include #include +#include +#include +#include #include +#include #include #include #include @@ -36,6 +40,7 @@ #include #include "../qetapp.h" +#include "../shortcutmanager.h" #include "elementslocation.h" //The palette is read from disk each time the picker opens. It is meant as @@ -67,6 +72,90 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_commands_layout->setContentsMargins(0, 0, 0, 0); m_commands_layout->setSpacing(2); m_commands->hide(); + //SolidWorks: right-click the bar to customise it + m_commands->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_commands, &QWidget::customContextMenuRequested, this, [this](const QPoint &pos) { + QMenu menu; + menu.addAction(tr("Personnaliser la barre…"), this, &ElementPickerPopup::startCustomising); + menu.exec(m_commands->mapToGlobal(pos)); + }); + + //Customising, shown instead of everything else. Two lists that + //commands are dragged between: the bar, left to right, and the + //commands that are not on it. + m_editor = new QWidget(this); + auto *editor_layout = new QVBoxLayout(m_editor); + editor_layout->setContentsMargins(0, 0, 0, 0); + auto *help = new QLabel( + tr("Glissez les commandes dans la barre, hors de la barre, ou " + "d'une place à l'autre. Un double-clic fait passer une commande " + "d'une liste à l'autre."), m_editor); + help->setWordWrap(true); + m_edit_row = new QListWidget(m_editor); + m_edit_row->setFlow(QListView::LeftToRight); + m_edit_row->setWrapping(false); + m_edit_row->setIconSize(QSize(24, 24)); + m_edit_row->setFixedHeight(44); + m_edit_available = new QListWidget(m_editor); + m_edit_available->setIconSize(QSize(20, 20)); + m_edit_available->setMinimumHeight(220); + for (QListWidget *list : {m_edit_row, m_edit_available}) { + list->setDragDropMode(QAbstractItemView::DragDrop); + list->setDefaultDropAction(Qt::MoveAction); + list->setSelectionMode(QAbstractItemView::SingleSelection); + } + //A drop copies the item as it was in the other list, so a command + //dragged onto the bar would keep its text and one dragged off would + //have none. Set the text for the list it has landed in. + //Deferred: a drop inserts the row first and copies the item's data + //into it afterwards. + auto relabel = [this](QListWidget *list, bool icon_only) { + return [this, list, icon_only]() { + QTimer::singleShot(0, this, [list, icon_only]() { + for (int i = 0 ; i < list->count() ; ++i) { + QListWidgetItem *item = list->item(i); + item->setText(icon_only && !item->icon().isNull() + ? QString() : item->toolTip()); + } + }); + }; + }; + connect(m_edit_row->model(), &QAbstractItemModel::rowsInserted, + this, relabel(m_edit_row, true)); + connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted, + this, relabel(m_edit_available, false)); + + connect(m_edit_row, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { + const QString id = item->data(Qt::UserRole).toString(); + delete item; + m_edit_available->addItem(commandItem(id, false)); + }); + connect(m_edit_available, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { + const QString id = item->data(Qt::UserRole).toString(); + delete item; + m_edit_row->addItem(commandItem(id, true)); + }); + auto *defaults = new QPushButton(tr("Valeurs par défaut"), m_editor); + auto *cancel = new QPushButton(tr("Annuler"), m_editor); + auto *done = new QPushButton(tr("Terminé"), m_editor); + done->setDefault(true); + connect(defaults, &QPushButton::clicked, this, [this]() { + fillCustomising(ShortcutBarSettings::defaultIds(m_context)); + }); + connect(cancel, &QPushButton::clicked, this, [this]() { finishCustomising(false); }); + connect(done, &QPushButton::clicked, this, [this]() { finishCustomising(true); }); + auto *editor_buttons = new QHBoxLayout(); + editor_buttons->addWidget(defaults); + editor_buttons->addStretch(); + editor_buttons->addWidget(cancel); + editor_buttons->addWidget(done); + editor_layout->addWidget(help); + editor_layout->addWidget(new QLabel(tr("Dans la barre :"), m_editor)); + editor_layout->addWidget(m_edit_row); + editor_layout->addWidget(new QLabel(tr("Autres commandes :"), m_editor)); + editor_layout->addWidget(m_edit_available); + editor_layout->addLayout(editor_buttons); + m_editor->hide(); m_search = new QLineEdit(this); m_search->setPlaceholderText(tr("Rechercher un élément…")); @@ -87,6 +176,7 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, layout->addWidget(m_search); layout->addWidget(m_view); layout->addWidget(m_hint); + layout->addWidget(m_editor); //Search as you type, after a short idle: shorter than the dock's //500 ms, since the popup is opened and closed in a few seconds, but @@ -108,10 +198,51 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, focused and any previous query cleared. @param global_pos */ -void ElementPickerPopup::popUpAt(const QPoint &global_pos, - const QList &commands) +void ElementPickerPopup::popUpAt(const QPoint &global_pos) { + m_bar_mode = false; + setCommands({}); + show(global_pos); +} + +/** + @brief ElementPickerPopup::popUpShortcutBar + Show the picker at @a global_pos with the shortcut bar's commands for + @a context above it. + @param global_pos + @param context +*/ +void ElementPickerPopup::popUpShortcutBar(const QPoint &global_pos, + ShortcutBarSettings::Context context) +{ + m_bar_mode = true; + m_context = context; + QList commands; + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (QAction *action = commandAction(id)) { + commands << action; + } + } setCommands(commands); + show(global_pos); +} + +/** + @brief ElementPickerPopup::commandAction + @return the action registered under @a id by the window this popup + belongs to, or nullptr +*/ +QAction *ElementPickerPopup::commandAction(const QString &id) const +{ + return ShortcutManager::instance().action(id, parentWidget()); +} + +/** + @brief ElementPickerPopup::show + Reset the search and show at @a global_pos, kept on screen. +*/ +void ElementPickerPopup::show(const QPoint &global_pos) +{ m_search->clear(); m_model->clear(); showPalette(); @@ -130,7 +261,7 @@ void ElementPickerPopup::popUpAt(const QPoint &global_pos, } move(pos); - show(); + QFrame::show(); m_search->setFocus(); } @@ -178,7 +309,138 @@ void ElementPickerPopup::setCommands(const QList &commands) m_commands_layout->addWidget(button); } m_commands_layout->addStretch(); - m_commands->setVisible(!commands.isEmpty()); + if (m_bar_mode) + { + auto *customise = new QToolButton(m_commands); + customise->setAutoRaise(true); + customise->setText(QStringLiteral("…")); + customise->setToolTip(tr("Personnaliser la barre…")); + customise->setFocusPolicy(Qt::NoFocus); + connect(customise, &QToolButton::clicked, this, &ElementPickerPopup::startCustomising); + m_commands_layout->addWidget(customise); + } + //An empty bar still shows, so it can be customised back + m_commands->setVisible(m_bar_mode || !commands.isEmpty()); +} + +/** + @brief ElementPickerPopup::commandItem + @return a list item for command @a id: icon only, for the bar row, or + icon and text, for the list of other commands. The id is in UserRole. +*/ +QListWidgetItem *ElementPickerPopup::commandItem(const QString &id, bool icon_only) const +{ + QAction *action = commandAction(id); + const QString text = action ? action->text().remove(QLatin1Char('&')) : id; + auto *item = new QListWidgetItem(); + item->setData(Qt::UserRole, id); + item->setToolTip(text); + if (action && !action->icon().isNull()) { + item->setIcon(action->icon()); + } + if (!icon_only || item->icon().isNull()) { + item->setText(text); + } + return item; +} + +/** + @brief ElementPickerPopup::startCustomising + Turn the bar into a small window holding the two lists. A Qt::Popup + closes as soon as the mouse is pressed outside it and keeps the mouse + grab during a drag, so the window is re-shown as a tool window for the + time of the edit. +*/ +void ElementPickerPopup::startCustomising() +{ + const QPoint where = pos(); + m_customising = true; + hide(); + setWindowFlags(Qt::Tool); + setWindowTitle(tr("Personnaliser la barre de raccourcis : %1") + .arg(ShortcutBarSettings::title(m_context))); + + fillCustomising(ShortcutBarSettings::ids(m_context)); + m_commands->hide(); + setPickerVisible(false); + m_editor->show(); + + adjustSize(); + move(where); + QFrame::show(); + activateWindow(); +} + +/** + @brief ElementPickerPopup::fillCustomising + Put @a ids on the bar row, and every other available command in the + list below it. +*/ +void ElementPickerPopup::fillCustomising(const QStringList &ids) +{ + m_edit_row->clear(); + m_edit_available->clear(); + for (const QString &id : ids) { + m_edit_row->addItem(commandItem(id, true)); + } + for (const QString &id : ShortcutBarSettings::availableIds()) { + if (!ids.contains(id)) { + m_edit_available->addItem(commandItem(id, false)); + } + } + m_edit_available->sortItems(); +} + +/** + @brief ElementPickerPopup::finishCustomising + Leave the edit, saving the bar row when @a save, and show the bar again + where it was so the result can be seen and used straight away. +*/ +void ElementPickerPopup::finishCustomising(bool save) +{ + if (save) + { + QStringList ids; + for (int i = 0 ; i < m_edit_row->count() ; ++i) { + ids << m_edit_row->item(i)->data(Qt::UserRole).toString(); + } + ShortcutBarSettings::setIds(m_context, ids); + } + + const QPoint where = pos(); + m_customising = false; + hide(); + m_editor->hide(); + setPickerVisible(true); + setWindowFlags(Qt::Popup); + setWindowTitle(QString()); + popUpShortcutBar(where, m_context); +} + +/** + @brief ElementPickerPopup::closeEvent + Closing the customising window from its title bar cancels the edit, + like the Annuler button, rather than leaving the bar half-edited. +*/ +void ElementPickerPopup::closeEvent(QCloseEvent *event) +{ + if (m_customising) { + event->ignore(); + finishCustomising(false); + return; + } + QFrame::closeEvent(event); +} + +/** + @brief ElementPickerPopup::setPickerVisible + Show or hide the search field, the results and the hint line. +*/ +void ElementPickerPopup::setPickerVisible(bool visible) +{ + m_search->setVisible(visible); + m_view->setVisible(visible); + m_hint->setVisible(visible); } /** @@ -259,17 +521,25 @@ void ElementPickerPopup::keyPressEvent(QKeyEvent *event) switch (event->key()) { case Qt::Key_Escape: - hide(); + if (m_customising) { + finishCustomising(false); + } else { + hide(); + } return; case Qt::Key_Return: case Qt::Key_Enter: - chooseCurrent(); + if (m_customising) { + finishCustomising(true); + } else { + chooseCurrent(); + } return; case Qt::Key_Down: case Qt::Key_Up: case Qt::Key_PageDown: case Qt::Key_PageUp: - if (m_model->rowCount()) { + if (!m_customising && m_model->rowCount()) { //Forwarded rather than focus-switched, so typing carries //on going to the search field. QCoreApplication::sendEvent(m_view, event); diff --git a/sources/ElementsCollection/elementpickerpopup.h b/sources/ElementsCollection/elementpickerpopup.h index ae3200a83..b57fa32a1 100644 --- a/sources/ElementsCollection/elementpickerpopup.h +++ b/sources/ElementsCollection/elementpickerpopup.h @@ -19,6 +19,7 @@ #define ELEMENTPICKERPOPUP_H #include "elementslocation.h" +#include "../shortcutbarsettings.h" #include @@ -29,6 +30,8 @@ class QStandardItemModel; class QLabel; class QAction; class QHBoxLayout; +class QListWidget; +class QListWidgetItem; /** @brief A cursor-anchored element picker. @@ -51,8 +54,9 @@ class ElementPickerPopup : public QFrame explicit ElementPickerPopup(ElementsCollectionWidget *source, QWidget *parent = nullptr); - void popUpAt(const QPoint &global_pos, - const QList &commands = {}); + void popUpAt(const QPoint &global_pos); + void popUpShortcutBar(const QPoint &global_pos, + ShortcutBarSettings::Context context); signals: /// Emitted when the user picks an element; the popup has closed @@ -60,12 +64,20 @@ class ElementPickerPopup : public QFrame protected: void keyPressEvent(QKeyEvent *event) override; + void closeEvent(QCloseEvent *event) override; private: void runSearch(); void chooseCurrent(); void showPalette(); + void show(const QPoint &global_pos); void setCommands(const QList &commands); + QAction *commandAction(const QString &id) const; + QListWidgetItem *commandItem(const QString &id, bool icon_only) const; + void startCustomising(); + void fillCustomising(const QStringList &ids); + void finishCustomising(bool save); + void setPickerVisible(bool visible); int loadPaletteDir(const QString &dir_path, const QString &prefix, int depth); @@ -76,6 +88,14 @@ class ElementPickerPopup : public QFrame QLabel *m_hint = nullptr; QWidget *m_commands = nullptr; QHBoxLayout *m_commands_layout = nullptr; + /// Opened as the shortcut bar, as opposed to the plain picker + bool m_bar_mode = false; + ShortcutBarSettings::Context m_context = ShortcutBarSettings::Canvas; + /// Customising the bar in place + bool m_customising = false; + QWidget *m_editor = nullptr; + QListWidget *m_edit_row = nullptr; + QListWidget *m_edit_available = nullptr; bool m_palette_mode = true; }; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 08571a337..e5f5f81cb 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -3168,14 +3168,7 @@ void QETDiagramEditor::showShortcutBar() : ShortcutBarSettings::Selection; } - QList commands; - for (const QString &id : ShortcutBarSettings::ids(context)) { - if (QAction *action = ShortcutManager::instance().action(id, this)) { - commands << action; - } - } - - elementPicker()->popUpAt(QCursor::pos(), commands); + elementPicker()->popUpShortcutBar(QCursor::pos(), context); } /** diff --git a/sources/shortcutbarsettings.cpp b/sources/shortcutbarsettings.cpp index 95072076a..fa66300a3 100644 --- a/sources/shortcutbarsettings.cpp +++ b/sources/shortcutbarsettings.cpp @@ -17,6 +17,8 @@ */ #include "shortcutbarsettings.h" +#include "shortcutmanager.h" + #include #include @@ -120,3 +122,21 @@ void ShortcutBarSettings::setIds(Context context, const QStringList &ids) settings.setValue(settingsKey(context), ids); } } + +/** + @return every command that can go on the bar: those the diagram editor + registered with ShortcutManager, except the one that opens the bar. +*/ +QStringList ShortcutBarSettings::availableIds() +{ + QStringList ids; + for (const ShortcutManager::ShortcutInfo &info : + ShortcutManager::instance().allShortcuts()) + { + if (info.id.startsWith(QLatin1String("diagrameditor.")) + && info.id != QLatin1String("diagrameditor.show_shortcut_bar")) { + ids << info.id; + } + } + return ids; +} diff --git a/sources/shortcutbarsettings.h b/sources/shortcutbarsettings.h index 753698e75..8e83b9232 100644 --- a/sources/shortcutbarsettings.h +++ b/sources/shortcutbarsettings.h @@ -45,6 +45,7 @@ class ShortcutBarSettings static QStringList ids(Context context); static QStringList defaultIds(Context context); static void setIds(Context context, const QStringList &ids); + static QStringList availableIds(); }; #endif // SHORTCUTBARSETTINGS_H diff --git a/sources/ui/configpage/shortcutbarconfigpage.cpp b/sources/ui/configpage/shortcutbarconfigpage.cpp index d805aaa2d..2dee5dccb 100644 --- a/sources/ui/configpage/shortcutbarconfigpage.cpp +++ b/sources/ui/configpage/shortcutbarconfigpage.cpp @@ -29,16 +29,6 @@ #include #include -namespace { - /// The bar can hold any diagram editor command, except the one that - /// opens it. -bool offerable(const QString &id) -{ - return id.startsWith(QLatin1String("diagrameditor.")) - && id != QLatin1String("diagrameditor.show_shortcut_bar"); -} -} - /** @brief ShortcutBarConfigPage::ShortcutBarConfigPage @param parent @@ -46,9 +36,10 @@ bool offerable(const QString &id) ShortcutBarConfigPage::ShortcutBarConfigPage(QWidget *parent) : ConfigPage(parent) { + const QStringList available = ShortcutBarSettings::availableIds(); for (const ShortcutManager::ShortcutInfo &info : ShortcutManager::instance().allShortcuts()) { - if (offerable(info.id)) { + if (available.contains(info.id)) { m_descriptions.insert(info.id, info.description); } } From b80d6e7cd384f258ae4fbf9468c450bb45ce37f8 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 13:18:34 +1200 Subject: [PATCH 13/24] Show the selection's commands beside the cursor after a click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a click that selects something on a folio, a small row of commands appears just above and to the right of the cursor, as the SolidWorks context toolbar does. It fades as the mouse moves away and is gone past 200 pixels; a click elsewhere, the wheel, a key press or an emptied selection hide it too. Clicking a command leaves it up, so rotate can be clicked again. The commands are the shortcut bar's for that selection -- elements or conductors -- at most eight, so customising the bar customises this as well. It is a child of the view's viewport, never a window, and never takes the focus. It is not shown after a drag (moving items, a rubber band), while placing or drawing (Diagram::eventInterfaceIsRunning()), on a read-only folio, or when switched off with the new General option "Afficher les commandes près de la sélection" (diagrameditor/ context_toolbar, on by default). ShortcutBarSettings::contextFor() now decides the context for both the bar and this toolbar. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 78f3c1a2bc5c3cf1f7beae4bd60aefec20847d6d) --- cmake/qet_compilation_vars.cmake | 2 + sources/diagramcontexttoolbar.cpp | 164 ++++++++++++++++++ sources/diagramcontexttoolbar.h | 60 +++++++ sources/diagramview.cpp | 53 ++++++ sources/diagramview.h | 4 + sources/qetdiagrameditor.cpp | 12 +- sources/shortcutbarsettings.cpp | 18 ++ sources/shortcutbarsettings.h | 3 + .../configpage/generalconfigurationpage.cpp | 2 + .../ui/configpage/generalconfigurationpage.ui | 10 ++ 10 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 sources/diagramcontexttoolbar.cpp create mode 100644 sources/diagramcontexttoolbar.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index ddc1ba0e0..b3d973e29 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -287,6 +287,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/shortcutmanager.h ${QET_DIR}/sources/shortcutbarsettings.cpp ${QET_DIR}/sources/shortcutbarsettings.h + ${QET_DIR}/sources/diagramcontexttoolbar.cpp + ${QET_DIR}/sources/diagramcontexttoolbar.h ${QET_DIR}/sources/titleblockcell.cpp ${QET_DIR}/sources/titleblockcell.h ${QET_DIR}/sources/titleblockproperties.cpp diff --git a/sources/diagramcontexttoolbar.cpp b/sources/diagramcontexttoolbar.cpp new file mode 100644 index 000000000..c8e8ab3be --- /dev/null +++ b/sources/diagramcontexttoolbar.cpp @@ -0,0 +1,164 @@ +/* + 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 "diagramcontexttoolbar.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + /// Fully opaque within this distance of the toolbar, in pixels... +const int opaque_distance = 30; + /// ...fading to hidden at this one +const int hide_distance = 200; +} + +/** + @brief DiagramContextToolbar::DiagramContextToolbar + @param viewport : the view's viewport, which it is drawn on and watches +*/ +DiagramContextToolbar::DiagramContextToolbar(QWidget *viewport) : + QFrame(viewport) +{ + setFrameShape(QFrame::StyledPanel); + setAutoFillBackground(true); + setFocusPolicy(Qt::NoFocus); + + m_layout = new QHBoxLayout(this); + m_layout->setContentsMargins(2, 2, 2, 2); + m_layout->setSpacing(1); + + m_opacity = new QGraphicsOpacityEffect(this); + setGraphicsEffect(m_opacity); + hide(); + + viewport->installEventFilter(this); + //Key presses go to the view, not its viewport + if (viewport->parentWidget()) { + viewport->parentWidget()->installEventFilter(this); + } +} + +/** + @return whether the toolbar should be shown at all (a preference) +*/ +bool DiagramContextToolbar::isEnabled() +{ + return QSettings().value(QStringLiteral("diagrameditor/context_toolbar"), + true).toBool(); +} + +/** + @brief DiagramContextToolbar::showAt + Show @a actions just above and to the right of @a viewport_pos, kept + inside the viewport. Clicking a button triggers the action and leaves + the toolbar up, so a command such as rotate can be clicked again. +*/ +void DiagramContextToolbar::showAt(const QPoint &viewport_pos, + const QList &actions) +{ + while (QLayoutItem *item = m_layout->takeAt(0)) { + delete item->widget(); + delete item; + } + for (QAction *action : actions.mid(0, max_actions)) + { + auto *button = new QToolButton(this); + button->setAutoRaise(true); + button->setFocusPolicy(Qt::NoFocus); + button->setIconSize(QSize(20, 20)); + const QString text = action->text().remove(QLatin1Char('&')); + if (action->icon().isNull()) { + button->setText(text); + } else { + button->setIcon(action->icon()); + } + button->setToolTip(text); + button->setEnabled(action->isEnabled()); + connect(button, &QToolButton::clicked, action, &QAction::trigger); + m_layout->addWidget(button); + } + if (actions.isEmpty()) { + hide(); + return; + } + + adjustSize(); + const QRect area = parentWidget()->rect(); + QPoint pos = viewport_pos + QPoint(16, -height() - 16); + pos.setX(qBound(area.left(), pos.x(), area.right() - width())); + pos.setY(qBound(area.top(), pos.y(), area.bottom() - height())); + move(pos); + m_opacity->setOpacity(1.0); + show(); + raise(); +} + +/** + @brief DiagramContextToolbar::followMouse + Fade with the mouse's distance from the toolbar, and hide past + hide_distance. +*/ +void DiagramContextToolbar::followMouse(const QPoint &viewport_pos) +{ + const QRect r = geometry(); + const int dx = qMax(qMax(r.left() - viewport_pos.x(), 0), viewport_pos.x() - r.right()); + const int dy = qMax(qMax(r.top() - viewport_pos.y(), 0), viewport_pos.y() - r.bottom()); + const int distance = qMax(dx, dy); + + if (distance >= hide_distance) { + hide(); + return; + } + const qreal fade = qreal(distance - opaque_distance) + / (hide_distance - opaque_distance); + m_opacity->setOpacity(qBound(0.15, 1.0 - fade, 1.0)); +} + +/** + @brief DiagramContextToolbar::eventFilter + Watch the viewport and the view while shown: follow the mouse, and hide + on a press elsewhere, a wheel turn or a key press. +*/ +bool DiagramContextToolbar::eventFilter(QObject *watched, QEvent *event) +{ + Q_UNUSED(watched) + if (!isVisible()) { + return false; + } + + switch (event->type()) + { + case QEvent::MouseMove: + followMouse(static_cast(event)->position().toPoint()); + break; + case QEvent::MouseButtonPress: + case QEvent::Wheel: + case QEvent::KeyPress: + case QEvent::Leave: + hide(); + break; + default: + break; + } + return false; +} diff --git a/sources/diagramcontexttoolbar.h b/sources/diagramcontexttoolbar.h new file mode 100644 index 000000000..5f550a93f --- /dev/null +++ b/sources/diagramcontexttoolbar.h @@ -0,0 +1,60 @@ +/* + 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 DIAGRAMCONTEXTTOOLBAR_H +#define DIAGRAMCONTEXTTOOLBAR_H + +#include +#include + +class QAction; +class QGraphicsOpacityEffect; +class QHBoxLayout; + +/** + @brief A small row of commands shown beside the cursor after a click + selects something on a folio, as SolidWorks does. + + It lives on the view's viewport, so it never takes the focus or opens a + window. It fades as the mouse moves away and hides once the mouse is far + enough, on a click elsewhere, a wheel turn or a key press. The commands + are the shortcut bar's for the selection (ShortcutBarSettings). +*/ +class DiagramContextToolbar : public QFrame +{ + Q_OBJECT + + public: + explicit DiagramContextToolbar(QWidget *viewport); + + void showAt(const QPoint &viewport_pos, const QList &actions); + static bool isEnabled(); + + /// At most this many commands, so it stays small + static const int max_actions = 8; + + protected: + bool eventFilter(QObject *watched, QEvent *event) override; + + private: + void followMouse(const QPoint &viewport_pos); + + QHBoxLayout *m_layout = nullptr; + QGraphicsOpacityEffect *m_opacity = nullptr; +}; + +#endif // DIAGRAMCONTEXTTOOLBAR_H diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 9d9f3edf7..39feac21c 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -37,6 +37,9 @@ #include "utils/conductorcreator.h" #include "undocommand/addgraphicsobjectcommand.h" #include "diagram.h" +#include "diagramcontexttoolbar.h" +#include "shortcutbarsettings.h" +#include "shortcutmanager.h" #include "ElementsCollection/xmlelementcollection.h" #include "NameList/nameslist.h" #include "elementdialog.h" @@ -104,6 +107,13 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : m_separators.last()->setSeparator(true); } + m_context_toolbar = new DiagramContextToolbar(viewport()); + connect(m_diagram, &QGraphicsScene::selectionChanged, this, [this]() { + if (m_diagram->selectedItems().isEmpty()) { + m_context_toolbar->hide(); + } + }); + connect(m_diagram, &Diagram::showDiagram, this, &DiagramView::showDiagram); connect(m_diagram, &QGraphicsScene::sceneRectChanged, this, &DiagramView::adjustSceneRect); connect(&(m_diagram -> border_and_titleblock), &BorderTitleBlock::informationChanged, this, &DiagramView::updateWindowTitle); @@ -621,6 +631,10 @@ void DiagramView::mousePressEvent(QMouseEvent *e) if (m_event_interface && m_event_interface->mousePressEvent(e)) return; + if (e->button() == Qt::LeftButton) { + m_press_pos = e->position().toPoint(); + } + //Start drag view when hold the middle button if (e->button() == Qt::MiddleButton) { @@ -777,7 +791,46 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e) e->accept(); } else + { QGraphicsView::mouseReleaseEvent(e); + + //A click, not a drag: moving items or a rubber band selection + //should not be followed by a toolbar under the mouse. + const QPoint pos = e->position().toPoint(); + if (e->button() == Qt::LeftButton + && (pos - m_press_pos).manhattanLength() < QApplication::startDragDistance()) { + showContextToolbar(pos); + } + } +} + +/** + @brief DiagramView::showContextToolbar + After a click that leaves something selected, show the shortcut bar's + commands for that selection beside the cursor. Not while placing or + drawing, nor on a read-only folio, nor when switched off in the + configuration. + @param viewport_pos : where the click was +*/ +void DiagramView::showContextToolbar(const QPoint &viewport_pos) +{ + const QList selection = m_diagram->selectedItems(); + QETDiagramEditor *qde = diagramEditor(); + if (selection.isEmpty() || !qde + || m_diagram->isReadOnly() || m_diagram->eventInterfaceIsRunning() + || !DiagramContextToolbar::isEnabled()) { + m_context_toolbar->hide(); + return; + } + + QList actions; + const auto context = ShortcutBarSettings::contextFor(selection); + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (QAction *action = ShortcutManager::instance().action(id, qde)) { + actions << action; + } + } + m_context_toolbar->showAt(viewport_pos, actions); } /** diff --git a/sources/diagramview.h b/sources/diagramview.h index 03eb067d5..a0c311cd1 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -27,6 +27,7 @@ class CellRuler; class Conductor; class Diagram; +class DiagramContextToolbar; class QETDiagramEditor; class DVEventInterface; class QInputEvent; @@ -56,6 +57,8 @@ class DiagramView : public PaletteGraphicsView QAction *m_multi_paste = nullptr; QAction *m_create_template = nullptr; QPoint m_paste_here_pos; + QPoint m_press_pos; + DiagramContextToolbar *m_context_toolbar = nullptr; QPoint m_last_mouse_pos = QPoint(-1, -1); QPointF m_drag_last_pos; bool m_fresh_focus_in, @@ -127,6 +130,7 @@ class DiagramView : public PaletteGraphicsView bool gestures() const; void updateCellRulers(); void placeCellRulers(); + void showContextToolbar(const QPoint &viewport_pos); /// Lowest and highest allowed value of the view transform scale (m11). /// Prevents wheel-zoom from driving the transform to overflow, which diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index e5f5f81cb..516d13c16 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -3157,16 +3157,8 @@ void QETDiagramEditor::showShortcutBar() return; } - const QList selection = dv->diagram()->selectedItems(); - ShortcutBarSettings::Context context = ShortcutBarSettings::Canvas; - if (!selection.isEmpty()) - { - const bool only_conductors = std::all_of( - selection.cbegin(), selection.cend(), - [](QGraphicsItem *item) { return item->type() == Conductor::Type; }); - context = only_conductors ? ShortcutBarSettings::Conductor - : ShortcutBarSettings::Selection; - } + const ShortcutBarSettings::Context context = + ShortcutBarSettings::contextFor(dv->diagram()->selectedItems()); elementPicker()->popUpShortcutBar(QCursor::pos(), context); } diff --git a/sources/shortcutbarsettings.cpp b/sources/shortcutbarsettings.cpp index fa66300a3..7832c6ee5 100644 --- a/sources/shortcutbarsettings.cpp +++ b/sources/shortcutbarsettings.cpp @@ -17,9 +17,11 @@ */ #include "shortcutbarsettings.h" +#include "qetgraphicsitem/conductor.h" #include "shortcutmanager.h" #include +#include #include namespace { @@ -46,6 +48,22 @@ QList ShortcutBarSettings::contexts() return {Canvas, Selection, Conductor}; } +/** + @return the context for @a selection: Canvas when empty, Conductor when + it holds only conductors, Selection otherwise +*/ +ShortcutBarSettings::Context ShortcutBarSettings::contextFor( + const QList &selection) +{ + if (selection.isEmpty()) { + return Canvas; + } + const bool only_conductors = std::all_of( + selection.cbegin(), selection.cend(), + [](QGraphicsItem *item) { return item->type() == Conductor::Type; }); + return only_conductors ? Conductor : Selection; +} + /** @return the name of @a context, for the configuration page */ diff --git a/sources/shortcutbarsettings.h b/sources/shortcutbarsettings.h index 8e83b9232..adf533359 100644 --- a/sources/shortcutbarsettings.h +++ b/sources/shortcutbarsettings.h @@ -19,6 +19,8 @@ #define SHORTCUTBARSETTINGS_H #include + +class QGraphicsItem; #include #include @@ -41,6 +43,7 @@ class ShortcutBarSettings }; static QList contexts(); + static Context contextFor(const QList &selection); static QString title(Context context); static QStringList ids(Context context); static QStringList defaultIds(Context context); diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index c59390c24..bd58a2e2a 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -70,6 +70,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : //Stored as "inserts" but presented as "edits", so the default (insert) //is the unchecked state -- a preference reads better as an opt-out. ui->m_collection_dblclick_edits->setChecked(!settings.value("elementscollection/double-click-inserts", true).toBool()); + ui->m_context_toolbar_cb->setChecked(settings.value("diagrameditor/context_toolbar", true).toBool()); ui->DiagramEditor_xGrid_sb->setValue(settings.value("diagrameditor/Xgrid", 10).toInt()); ui->DiagramEditor_yGrid_sb->setValue(settings.value("diagrameditor/Ygrid", 10).toInt()); for (const qreal divisor : TextGrid::divisors) @@ -298,6 +299,7 @@ void GeneralConfigurationPage::applyConf() settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked()); settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked()); settings.setValue("elementscollection/double-click-inserts", !ui->m_collection_dblclick_edits->isChecked()); + settings.setValue("diagrameditor/context_toolbar", ui->m_context_toolbar_cb->isChecked()); //Grid step and key navigation settings.setValue("diagrameditor/Xgrid", ui->DiagramEditor_xGrid_sb->value()); settings.setValue("diagrameditor/Ygrid", ui->DiagramEditor_yGrid_sb->value()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 2ab58cdc2..de4b7445c 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -87,6 +87,16 @@ + + + + Après un clic qui sélectionne un élément ou un conducteur, les commandes de la barre de raccourcis apparaissent près du curseur et s'effacent quand la souris s'éloigne. + + + Afficher les commandes près de la sélection + + + From 329831d98697b8c72c522766c3c2a05a6ce98d69 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 13:29:16 +1200 Subject: [PATCH 14/24] Repeat the last drawing or placing command with Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter on a folio runs the last drawing or placing command again, as it does in SolidWorks: the last tool from the add-item family (text, line, rectangle, terminal strip plan...) or the last element placed, however it was placed -- dragged, double-clicked, picked, or from the shortcut bar. Enter is handled in DiagramView::keyPressEvent, not bound as a shortcut, so it keeps working everywhere else: search fields, the collection tree, dialogs. On the folio it is left alone while a tool is running or an item has the focus (a text being edited), and with any modifier held. The same command is in Édition, named after what it will do -- "Répéter : Ajouter une ligne", "Répéter : insérer « Diode »" -- and registered with ShortcutManager (diagrameditor.repeat_last_command, no default key), so it can be bound or put on the shortcut bar. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 1e06ce97961ee18734ddaaa9b72b0303a169b9b2) --- sources/diagramview.cpp | 12 ++++++++ sources/qetdiagrameditor.cpp | 55 ++++++++++++++++++++++++++++++++++++ sources/qetdiagrameditor.h | 6 ++++ 3 files changed, 73 insertions(+) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 39feac21c..e6be0675a 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -963,6 +963,18 @@ void DiagramView::keyPressEvent(QKeyEvent *e) focusNextChild(); } return; + case Qt::Key_Return: + case Qt::Key_Enter: + //Repeat the last drawing or placing command, as SolidWorks + //does. Not while a tool is running or a text has the focus: + //both use Enter themselves. + if (e->modifiers() == Qt::NoModifier + && !m_diagram->eventInterfaceIsRunning() + && !m_diagram->focusItem() + && diagramEditor()->repeatLastCommand()) { + return; + } + break; case Qt::Key_PageUp: current_project->changeTabUp(); return; diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 516d13c16..28124445e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -857,6 +857,21 @@ void QETDiagramEditor::setUpActions() this, &QETDiagramEditor::showShortcutBar); addAction(m_show_shortcut_bar); + //Enter on the folio repeats it (DiagramView::keyPressEvent). That is + //handled by the view rather than bound here, so Enter keeps working + //in the search fields, the collection tree and every dialog. + m_repeat_last_command = new QAction(tr("Répéter la dernière commande"), this); + m_repeat_last_command->setStatusTip( + tr("Relance le dernier outil de dessin ou la dernière insertion d'élément (Entrée sur le folio)", + "status bar tip")); + m_repeat_last_command->setEnabled(false); + ShortcutManager::instance().registerAction( + m_repeat_last_command, "diagrameditor.repeat_last_command", + tr("Éditeur de schémas"), QKeySequence()); + connect(m_repeat_last_command, &QAction::triggered, + this, &QETDiagramEditor::repeatLastCommand); + addAction(m_repeat_last_command); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -1151,6 +1166,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_insert_last_element); menu_edition -> addAction(m_show_element_picker); menu_edition -> addAction(m_show_shortcut_bar); + menu_edition -> addAction(m_repeat_last_command); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a @@ -1865,6 +1881,8 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action) return; } + setLastCommand(action); + DiagramEventInterface *diagram_event = nullptr; if (value == "line") @@ -2061,6 +2079,8 @@ void QETDiagramEditor::slot_updateActions() m_insert_last_element-> setEnabled(opened_diagram && editable_project && !m_last_inserted_element.isNull()); m_show_element_picker-> setEnabled(opened_diagram && editable_project); m_show_shortcut_bar-> setEnabled(opened_diagram && editable_project); + m_repeat_last_command-> setEnabled(opened_diagram && editable_project + && m_last_command && m_last_command->isEnabled()); m_row_column_actions_group. setEnabled(editable_project); m_background_color_button-> setEnabled(opened_diagram); m_draw_grid-> setEnabled(opened_diagram); @@ -3096,6 +3116,41 @@ void QETDiagramEditor::rememberPlacedElement(const ElementsLocation &location) { m_last_inserted_element = location; m_insert_last_element->setEnabled(true); + setLastCommand(m_insert_last_element); +} + +/** + @brief QETDiagramEditor::setLastCommand + Remember @a action as the command Enter repeats, and name it in the + Édition menu entry so it is clear what will happen. +*/ +void QETDiagramEditor::setLastCommand(QAction *action) +{ + m_last_command = action; + m_repeat_last_command->setText( + action == m_insert_last_element + ? tr("Répéter : insérer « %1 »").arg(m_last_inserted_element.name()) + : tr("Répéter : %1").arg(action->text().remove(QLatin1Char('&')))); + slot_updateActions(); +} + +/** + @brief QETDiagramEditor::repeatLastCommand + Run the last drawing or placing command again -- a line, a text field, + the last element -- as Enter does in SolidWorks. + @return true if a command was run +*/ +bool QETDiagramEditor::repeatLastCommand() +{ + if (!m_last_command || !m_repeat_last_command->isEnabled()) { + return false; + } + //A tool that is still on would be switched off by triggering it + if (m_last_command->isCheckable() && m_last_command->isChecked()) { + return false; + } + m_last_command->trigger(); + return true; } /** diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 7a6abc53a..161be3709 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -133,6 +134,7 @@ class QETDiagramEditor : public QETMainWindow void rememberPlacedElement(const ElementsLocation &location); void showElementPicker(); void showShortcutBar(); + bool repeatLastCommand(); void generateTerminalBlock(); void setWindowedMode(); void setTabbedMode(); @@ -277,6 +279,10 @@ class QETDiagramEditor : public QETMainWindow ElementPickerPopup *elementPicker(); QAction *m_show_element_picker = nullptr; QAction *m_show_shortcut_bar = nullptr; + QAction *m_repeat_last_command = nullptr; + /// What Enter on the folio repeats + QPointer m_last_command; + void setLastCommand(QAction *action); ElementPickerPopup *m_element_picker = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; /// Last element placed from the collection, for "insert last" From b57c37ca6226f77a323dcb32fb0b8d3bb03cd704 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 26 Sep 2026 13:49:27 +1200 Subject: [PATCH 15/24] Run commands with right-drag mouse gestures Hold the right button on a folio and drag: a ring appears around the point where the button went down, showing up to eight commands, and the one in the mouse's direction is highlighted with its name underneath. Releasing runs it; releasing near the centre cancels. These are SolidWorks' mouse gestures. The commands are the shortcut bar's for the selection, placed clockwise from the top, so customising the bar customises the ring. As with the context menu, a right press selects what is under the mouse first. A plain right click still opens the context menu, now on release on every platform. The view tracks the right button and opens the menu itself; the platform's own right-click event (sent on press on X11, on release on Windows) is ignored while gestures are on, so the menu is never opened twice. The keyboard menu is unchanged. The view keeps out of the way while a tool or a placement is running, since a right click cancels or finishes those, and while a text is being edited. The new General option "Gestes de la souris avec le bouton droit" (diagrameditor/mouse_gestures, on by default) turns it off and restores the previous right button exactly. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit a625142f36e619bb4d4f3551b6086f2dd8eabc86) --- cmake/qet_compilation_vars.cmake | 2 + sources/diagramgestureoverlay.cpp | 185 ++++++++++++++++++ sources/diagramgestureoverlay.h | 60 ++++++ sources/diagramview.cpp | 112 ++++++++++- sources/diagramview.h | 10 + .../configpage/generalconfigurationpage.cpp | 2 + .../ui/configpage/generalconfigurationpage.ui | 10 + 7 files changed, 373 insertions(+), 8 deletions(-) create mode 100644 sources/diagramgestureoverlay.cpp create mode 100644 sources/diagramgestureoverlay.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index b3d973e29..69e987e9b 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -289,6 +289,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/shortcutbarsettings.h ${QET_DIR}/sources/diagramcontexttoolbar.cpp ${QET_DIR}/sources/diagramcontexttoolbar.h + ${QET_DIR}/sources/diagramgestureoverlay.cpp + ${QET_DIR}/sources/diagramgestureoverlay.h ${QET_DIR}/sources/titleblockcell.cpp ${QET_DIR}/sources/titleblockcell.h ${QET_DIR}/sources/titleblockproperties.cpp diff --git a/sources/diagramgestureoverlay.cpp b/sources/diagramgestureoverlay.cpp new file mode 100644 index 000000000..03d2606d1 --- /dev/null +++ b/sources/diagramgestureoverlay.cpp @@ -0,0 +1,185 @@ +/* + 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 "diagramgestureoverlay.h" + +#include +#include +#include +#include +#include + +namespace { +const int outer_radius = 84; + /// Releasing closer than this to the centre cancels +const int inner_radius = 22; +const int icon_radius = 56; +const int icon_size = 22; + /// Wider than the ring, so a long command name under it is not cut +const int overlay_width = 280; +} + +/** + @brief DiagramGestureOverlay::DiagramGestureOverlay + @param viewport : the view's viewport, which it is drawn on. It lets the + mouse through, so the view keeps receiving the drag. +*/ +DiagramGestureOverlay::DiagramGestureOverlay(QWidget *viewport) : + QWidget(viewport) +{ + setAttribute(Qt::WA_TransparentForMouseEvents); + setAttribute(Qt::WA_TranslucentBackground); + setFixedSize(overlay_width, 2 * outer_radius + 2 + 24); + hide(); +} + +/** + @return whether right-drag gestures are on (a preference) +*/ +bool DiagramGestureOverlay::isEnabled() +{ + return QSettings().value(QStringLiteral("diagrameditor/mouse_gestures"), + true).toBool(); +} + +/** + @brief DiagramGestureOverlay::showAt + Show the ring centred on @a center with @a actions, at most eight, + clockwise from the top. +*/ +void DiagramGestureOverlay::showAt(const QPoint ¢er, + const QList &actions) +{ + m_center = center; + m_actions = actions.mid(0, sectors); + m_active = -1; + move(center - QPoint(overlay_width / 2, outer_radius + 1)); + show(); + raise(); +} + +/** + @brief DiagramGestureOverlay::sectorAt + @return the sector in the direction of @a viewport_pos from the centre, + 0 at the top then clockwise, or -1 within the cancel radius +*/ +int DiagramGestureOverlay::sectorAt(const QPoint &viewport_pos) const +{ + const QPoint d = viewport_pos - m_center; + if (qHypot(d.x(), d.y()) < inner_radius) { + return -1; + } + //Angle clockwise from the top, in degrees + qreal angle = qRadiansToDegrees(qAtan2(d.x(), -d.y())); + if (angle < 0) { + angle += 360; + } + return int(qRound(angle / (360.0 / sectors))) % sectors; +} + +/** + @return the command in the direction of @a viewport_pos, or nullptr when + there is none there or it is disabled +*/ +QAction *DiagramGestureOverlay::actionAt(const QPoint &viewport_pos) const +{ + const int sector = sectorAt(viewport_pos); + if (sector < 0 || sector >= m_actions.count()) { + return nullptr; + } + QAction *action = m_actions.at(sector); + return action->isEnabled() ? action : nullptr; +} + +void DiagramGestureOverlay::setPointer(const QPoint &viewport_pos) +{ + const int sector = sectorAt(viewport_pos); + if (sector != m_active) { + m_active = sector; + update(); + } +} + +void DiagramGestureOverlay::paintEvent(QPaintEvent *event) +{ + Q_UNUSED(event) + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QPointF c(overlay_width / 2, outer_radius + 1); + const qreal step = 360.0 / sectors; + + QColor base = palette().color(QPalette::Window); + base.setAlpha(225); + painter.setPen(palette().color(QPalette::Mid)); + painter.setBrush(base); + painter.drawEllipse(c, outer_radius, outer_radius); + + //Highlight the sector the mouse points at, if it holds a command + if (m_active >= 0 && m_active < m_actions.count() + && m_actions.at(m_active)->isEnabled()) + { + QPainterPath wedge; + wedge.moveTo(c); + //Qt angles run counter-clockwise from 3 o'clock + const qreal start = 90 - m_active * step - step / 2; + wedge.arcTo(QRectF(c.x() - outer_radius, c.y() - outer_radius, + 2 * outer_radius, 2 * outer_radius), start, step); + wedge.closeSubpath(); + painter.setPen(Qt::NoPen); + painter.setBrush(palette().color(QPalette::Highlight)); + painter.drawPath(wedge); + } + + painter.setPen(palette().color(QPalette::Mid)); + painter.setBrush(palette().color(QPalette::Window)); + painter.drawEllipse(c, inner_radius, inner_radius); + + for (int i = 0 ; i < m_actions.count() ; ++i) + { + QAction *action = m_actions.at(i); + const qreal a = qDegreesToRadians(i * step); + const QPointF p(c.x() + icon_radius * qSin(a), c.y() - icon_radius * qCos(a)); + const QRect r(int(p.x()) - icon_size / 2, int(p.y()) - icon_size / 2, + icon_size, icon_size); + const QIcon::Mode mode = action->isEnabled() ? QIcon::Normal : QIcon::Disabled; + if (!action->icon().isNull()) { + action->icon().paint(&painter, r, Qt::AlignCenter, mode); + } else { + painter.setPen(palette().color(action->isEnabled() ? QPalette::Active : QPalette::Disabled, + QPalette::WindowText)); + painter.drawText(r.adjusted(-12, 0, 12, 0), Qt::AlignCenter, + action->text().remove(QLatin1Char('&')).left(3)); + } + } + + //Name of the highlighted command, under the ring + if (m_active >= 0 && m_active < m_actions.count()) + { + const QString name = m_actions.at(m_active)->text().remove(QLatin1Char('&')); + const QRect label(0, 2 * outer_radius + 4, width(), 20); + QFont f = font(); + f.setBold(true); + painter.setFont(f); + const QRect text = painter.fontMetrics().boundingRect(label, Qt::AlignCenter, name) + .adjusted(-6, -2, 6, 2); + painter.setPen(Qt::NoPen); + painter.setBrush(base); + painter.drawRoundedRect(text, 4, 4); + painter.setPen(palette().color(QPalette::WindowText)); + painter.drawText(label, Qt::AlignCenter, name); + } +} diff --git a/sources/diagramgestureoverlay.h b/sources/diagramgestureoverlay.h new file mode 100644 index 000000000..5fca8dfc2 --- /dev/null +++ b/sources/diagramgestureoverlay.h @@ -0,0 +1,60 @@ +/* + 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 DIAGRAMGESTUREOVERLAY_H +#define DIAGRAMGESTUREOVERLAY_H + +#include +#include + +class QAction; + +/** + @brief The ring shown while right-dragging on a folio: up to eight + commands around the point where the button went down, the one in the + mouse's direction highlighted. Releasing runs it, releasing near the + centre cancels -- SolidWorks' mouse gestures. + + Commands are placed clockwise from the top. The ring only draws; the + view tracks the mouse and asks sectorAt() on release. +*/ +class DiagramGestureOverlay : public QWidget +{ + Q_OBJECT + + public: + explicit DiagramGestureOverlay(QWidget *viewport); + + void showAt(const QPoint ¢er, const QList &actions); + void setPointer(const QPoint &viewport_pos); + int sectorAt(const QPoint &viewport_pos) const; + QAction *actionAt(const QPoint &viewport_pos) const; + + static bool isEnabled(); + + static const int sectors = 8; + + protected: + void paintEvent(QPaintEvent *event) override; + + private: + QPoint m_center; + QList m_actions; + int m_active = -1; +}; + +#endif // DIAGRAMGESTUREOVERLAY_H diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index e6be0675a..5e2ef56fa 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -38,6 +38,7 @@ #include "undocommand/addgraphicsobjectcommand.h" #include "diagram.h" #include "diagramcontexttoolbar.h" +#include "diagramgestureoverlay.h" #include "shortcutbarsettings.h" #include "shortcutmanager.h" #include "ElementsCollection/xmlelementcollection.h" @@ -108,6 +109,7 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) : } m_context_toolbar = new DiagramContextToolbar(viewport()); + m_gesture_overlay = new DiagramGestureOverlay(viewport()); connect(m_diagram, &QGraphicsScene::selectionChanged, this, [this]() { if (m_diagram->selectedItems().isEmpty()) { m_context_toolbar->hide(); @@ -635,6 +637,36 @@ void DiagramView::mousePressEvent(QMouseEvent *e) m_press_pos = e->position().toPoint(); } + //Right button: a click opens the context menu on release, a drag is + //a gesture (DiagramGestureOverlay). Left alone while a tool runs -- + //a right click cancels or finishes it -- and while a text is edited. + m_swallow_native_menu = false; + if (e->button() == Qt::RightButton + && DiagramGestureOverlay::isEnabled() + && !m_diagram->eventInterfaceIsRunning() + && !m_diagram->focusItem()) + { + m_gesture_tracking = true; + m_swallow_native_menu = true; + m_gesture_origin = e->position().toPoint(); + m_context_toolbar->hide(); + + //Select what is under the mouse, as the context menu does, so + //a gesture acts on it + if (QGraphicsItem *item = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) { + if (!item->isSelected()) { + m_diagram->clearSelection(); + //Clearing the selection can delete handler items, so + //look the item up again (see contextMenuEvent) + if (QGraphicsItem *again = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) { + again->setSelected(true); + } + } + } + e->accept(); + return; + } + //Start drag view when hold the middle button if (e->button() == Qt::MiddleButton) { @@ -686,6 +718,20 @@ void DiagramView::mouseMoveEvent(QMouseEvent *e) setToolTip(tr("X: %1 Y: %2").arg(e->pos().x()).arg(e->pos().y())); if (m_event_interface && m_event_interface->mouseMoveEvent(e)) return; + if (m_gesture_tracking) + { + const QPoint pos = e->position().toPoint(); + if (!m_gesture_overlay->isVisible() + && (pos - m_gesture_origin).manhattanLength() > 2 * QApplication::startDragDistance()) { + m_gesture_overlay->showAt(m_gesture_origin, selectionCommands()); + } + if (m_gesture_overlay->isVisible()) { + m_gesture_overlay->setPointer(pos); + } + e->accept(); + return; + } + // Drag the view if (e->buttons() == Qt::MiddleButton) { @@ -747,6 +793,34 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e) { if (m_event_interface && m_event_interface->mouseReleaseEvent(e)) return; + if (m_gesture_tracking && e->button() == Qt::RightButton) + { + m_gesture_tracking = false; + const QPoint pos = e->position().toPoint(); + if (m_gesture_overlay->isVisible()) + { + //A gesture: run the command it points at, if any + QAction *action = m_gesture_overlay->actionAt(pos); + m_gesture_overlay->hide(); + if (action) { + action->trigger(); + } + } + else + { + //A plain right click: the context menu, opened here on + //release on every platform + QContextMenuEvent menu_event(QContextMenuEvent::Mouse, pos, + e->globalPosition().toPoint(), + e->modifiers()); + m_menu_from_gesture = true; + contextMenuEvent(&menu_event); + m_menu_from_gesture = false; + } + e->accept(); + return; + } + // Stop drag view if (e->button() == Qt::MiddleButton) { @@ -804,6 +878,27 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e) } } +/** + @brief DiagramView::selectionCommands + @return the shortcut bar's commands for the current selection, as this + window's actions +*/ +QList DiagramView::selectionCommands() const +{ + QList actions; + QETDiagramEditor *qde = diagramEditor(); + if (!qde) { + return actions; + } + const auto context = ShortcutBarSettings::contextFor(m_diagram->selectedItems()); + for (const QString &id : ShortcutBarSettings::ids(context)) { + if (QAction *action = ShortcutManager::instance().action(id, qde)) { + actions << action; + } + } + return actions; +} + /** @brief DiagramView::showContextToolbar After a click that leaves something selected, show the shortcut bar's @@ -823,14 +918,7 @@ void DiagramView::showContextToolbar(const QPoint &viewport_pos) return; } - QList actions; - const auto context = ShortcutBarSettings::contextFor(selection); - for (const QString &id : ShortcutBarSettings::ids(context)) { - if (QAction *action = ShortcutManager::instance().action(id, qde)) { - actions << action; - } - } - m_context_toolbar->showAt(viewport_pos, actions); + m_context_toolbar->showAt(viewport_pos, selectionCommands()); } /** @@ -1599,6 +1687,14 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e) //right-click gets. const bool from_keyboard = e->reason() == QContextMenuEvent::Keyboard; + //With gestures on, a right press is tracked by mousePressEvent and + //the menu opened on release; the platform's own event (sent on press + //on X11, on release on Windows) would open it a second time. + if (!from_keyboard && m_swallow_native_menu && !m_menu_from_gesture) { + e->accept(); + return; + } + if (from_keyboard) { //Aim at the selection when there is one, so the menu appears diff --git a/sources/diagramview.h b/sources/diagramview.h index a0c311cd1..18e4d3ddc 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -28,6 +28,7 @@ class CellRuler; class Conductor; class Diagram; class DiagramContextToolbar; +class DiagramGestureOverlay; class QETDiagramEditor; class DVEventInterface; class QInputEvent; @@ -59,6 +60,14 @@ class DiagramView : public PaletteGraphicsView QPoint m_paste_here_pos; QPoint m_press_pos; DiagramContextToolbar *m_context_toolbar = nullptr; + /// Right-drag gestures: tracking since the right button went down + bool m_gesture_tracking = false; + /// The platform's own right-click menu event is to be ignored: + /// the view opens the menu itself on release + bool m_swallow_native_menu = false; + bool m_menu_from_gesture = false; + QPoint m_gesture_origin; + DiagramGestureOverlay *m_gesture_overlay = nullptr; QPoint m_last_mouse_pos = QPoint(-1, -1); QPointF m_drag_last_pos; bool m_fresh_focus_in, @@ -131,6 +140,7 @@ class DiagramView : public PaletteGraphicsView void updateCellRulers(); void placeCellRulers(); void showContextToolbar(const QPoint &viewport_pos); + QList selectionCommands() const; /// Lowest and highest allowed value of the view transform scale (m11). /// Prevents wheel-zoom from driving the transform to overflow, which diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index bd58a2e2a..1340b9b66 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -71,6 +71,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : //is the unchecked state -- a preference reads better as an opt-out. ui->m_collection_dblclick_edits->setChecked(!settings.value("elementscollection/double-click-inserts", true).toBool()); ui->m_context_toolbar_cb->setChecked(settings.value("diagrameditor/context_toolbar", true).toBool()); + ui->m_mouse_gestures_cb->setChecked(settings.value("diagrameditor/mouse_gestures", true).toBool()); ui->DiagramEditor_xGrid_sb->setValue(settings.value("diagrameditor/Xgrid", 10).toInt()); ui->DiagramEditor_yGrid_sb->setValue(settings.value("diagrameditor/Ygrid", 10).toInt()); for (const qreal divisor : TextGrid::divisors) @@ -300,6 +301,7 @@ void GeneralConfigurationPage::applyConf() settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked()); settings.setValue("elementscollection/double-click-inserts", !ui->m_collection_dblclick_edits->isChecked()); settings.setValue("diagrameditor/context_toolbar", ui->m_context_toolbar_cb->isChecked()); + settings.setValue("diagrameditor/mouse_gestures", ui->m_mouse_gestures_cb->isChecked()); //Grid step and key navigation settings.setValue("diagrameditor/Xgrid", ui->DiagramEditor_xGrid_sb->value()); settings.setValue("diagrameditor/Ygrid", ui->DiagramEditor_yGrid_sb->value()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index de4b7445c..177314d93 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -97,6 +97,16 @@ + + + + Maintenir le bouton droit et glisser dans une direction lance une commande de la barre de raccourcis. Un simple clic droit ouvre toujours le menu contextuel, au relâchement du bouton. + + + Gestes de la souris avec le bouton droit + + + From f850d0bee86ac0b56b29418bcfccc64879fa2beb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 01:34:55 +1200 Subject: [PATCH 16/24] Keep mouse gestures working while a tool is running Most commands on the empty-folio ring start a drawing tool, and the view left the right button alone while a tool ran, so the gesture after one that started a tool only cancelled the tool: every other gesture opened the context menu instead of the ring. Now a right drag always shows the ring. A running tool is ended when the drag starts, as picking another tool from the toolbar would. A plain right click still goes to the tool, which cancels or finishes it as before. Discussion #1033. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- sources/diagramview.cpp | 63 +++++++++++++++++++++++++++++------------ sources/diagramview.h | 2 ++ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 423a1ce46..a6f854df8 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -640,33 +640,43 @@ void DiagramView::mousePressEvent(QMouseEvent *e) } //Right button: a click opens the context menu on release, a drag is - //a gesture (DiagramGestureOverlay). Left alone while a tool runs -- - //a right click cancels or finishes it -- and while a text is edited. + //a gesture (DiagramGestureOverlay). Left alone while a text is edited. m_swallow_native_menu = false; + m_gesture_over_tool = false; if (e->button() == Qt::RightButton && DiagramGestureOverlay::isEnabled() - && !m_diagram->eventInterfaceIsRunning() && !m_diagram->focusItem()) { m_gesture_tracking = true; - m_swallow_native_menu = true; m_gesture_origin = e->position().toPoint(); m_context_toolbar->hide(); - //Select what is under the mouse, as the context menu does, so - //a gesture acts on it - if (QGraphicsItem *item = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) { - if (!item->isSelected()) { - m_diagram->clearSelection(); - //Clearing the selection can delete handler items, so - //look the item up again (see contextMenuEvent) - if (QGraphicsItem *again = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) { - again->setSelected(true); + //A tool is running, often one a gesture just started. A right + //click still goes to it -- it cancels or finishes the tool -- so + //the press carries on to the scene. A drag ends the tool and + //shows the ring (see mouseMoveEvent). + if (m_diagram->eventInterfaceIsRunning()) { + m_gesture_over_tool = true; + } + else + { + m_swallow_native_menu = true; + + //Select what is under the mouse, as the context menu does, so + //a gesture acts on it + if (QGraphicsItem *item = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) { + if (!item->isSelected()) { + m_diagram->clearSelection(); + //Clearing the selection can delete handler items, so + //look the item up again (see contextMenuEvent) + if (QGraphicsItem *again = m_diagram->itemAt(mapToScene(m_gesture_origin), transform())) { + again->setSelected(true); + } } } + e->accept(); + return; } - e->accept(); - return; } //Start drag view when hold the middle button @@ -725,13 +735,24 @@ void DiagramView::mouseMoveEvent(QMouseEvent *e) const QPoint pos = e->position().toPoint(); if (!m_gesture_overlay->isVisible() && (pos - m_gesture_origin).manhattanLength() > 2 * QApplication::startDragDistance()) { + if (m_gesture_over_tool) { + //The drag is a gesture: end the tool, and ignore the + //platform's right-click menu like any other gesture + m_diagram->clearEventInterface(); + m_swallow_native_menu = true; + } m_gesture_overlay->showAt(m_gesture_origin, selectionCommands()); } if (m_gesture_overlay->isVisible()) { m_gesture_overlay->setPointer(pos); + e->accept(); + return; + } + //Not a drag yet: a running tool keeps following the mouse + if (!m_gesture_over_tool) { + e->accept(); + return; } - e->accept(); - return; } // Drag the view @@ -795,7 +816,10 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e) { if (m_event_interface && m_event_interface->mouseReleaseEvent(e)) return; - if (m_gesture_tracking && e->button() == Qt::RightButton) + //A plain right click on a running tool falls through: the tool + //handles it, as it always has + if (m_gesture_tracking && e->button() == Qt::RightButton + && !(m_gesture_over_tool && !m_gesture_overlay->isVisible())) { m_gesture_tracking = false; const QPoint pos = e->position().toPoint(); @@ -822,6 +846,9 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e) e->accept(); return; } + if (e->button() == Qt::RightButton) { + m_gesture_tracking = false; + } // Stop drag view if (e->button() == Qt::MiddleButton) diff --git a/sources/diagramview.h b/sources/diagramview.h index f77c3fad2..14ae6df19 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -66,6 +66,8 @@ class DiagramView : public PaletteGraphicsView /// the view opens the menu itself on release bool m_swallow_native_menu = false; bool m_menu_from_gesture = false; + /// The right press went to a running tool; a drag ends the tool + bool m_gesture_over_tool = false; QPoint m_gesture_origin; DiagramGestureOverlay *m_gesture_overlay = nullptr; QPoint m_last_mouse_pos = QPoint(-1, -1); From 6bcdb875244b2bd57e795ec68e24bb0c6ea0e1f3 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 01:35:56 +1200 Subject: [PATCH 17/24] Fix the folio staying in pan mode after a Ctrl+Shift shortcut Holding Ctrl+Shift over a folio pans it, and only a key release seen by the view ends that. A Ctrl+Shift shortcut that opens a window -- the command search, Ctrl+Shift+M -- takes the keyboard before Ctrl and Shift are released, so the view never sees the release. On Windows, where the Shift press itself already reports Ctrl+Shift, the folio then stayed in pan mode after a command was chosen: a hand cursor, and clicks ignored, so a drawing tool picked from the search did nothing. On Linux the Enter key's release happened to reach the view and end it. The view now remembers that it pans because of Ctrl+Shift (not because the hand tool was chosen) and stops when it loses the focus, or at a click made without Ctrl+Shift, instead of waiting for a release it may never get. Reproduced on Linux by holding back Enter's release, as Windows does: the line drawn after "Ctrl+Shift+M, une ligne, Enter" is not saved on master and is with this change. Ctrl+Shift+drag and the hand tool still pan (a dragged step does not move), as on master. Co-Authored-By: Claude Opus 5.5 --- sources/diagramview.cpp | 26 ++++++++++++++++++++++++++ sources/diagramview.h | 4 ++++ 2 files changed, 30 insertions(+) diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 2ccb6daa7..1cd5b5721 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -316,6 +316,7 @@ void DiagramView::handleTextDrop(QDropEvent *e) { */ void DiagramView::setVisualisationMode() { + m_ctrl_shift_panning = false; setDragMode(ScrollHandDrag); applyReadOnly(); setInteractive(false); @@ -327,6 +328,7 @@ void DiagramView::setVisualisationMode() */ void DiagramView::setSelectionMode() { + m_ctrl_shift_panning = false; setDragMode(RubberBandDrag); setInteractive(true); applyReadOnly(); @@ -557,6 +559,12 @@ void DiagramView::mousePressEvent(QMouseEvent *e) { e->ignore(); + //Ctrl+Shift panning whose key release was missed (see + //focusOutEvent()): a click without them ends it + if (m_ctrl_shift_panning && !isCtrlShifting(e)) { + setSelectionMode(); + } + if (m_fresh_focus_in) { switchToVisualisationModeIfNeeded(e); @@ -801,6 +809,23 @@ void DiagramView::focusInEvent(QFocusEvent *e) { } } +/** + @brief DiagramView::focusOutEvent + Leave the Ctrl+Shift panning: without the focus, the view will not see + Ctrl or Shift being released, which is what normally ends it. A + Ctrl+Shift shortcut that opens a window (Ctrl+Shift+M, the command + search) left the view panning, ignoring clicks on the folio, on Windows, + where no other key release reached the view to end it. + @param e +*/ +void DiagramView::focusOutEvent(QFocusEvent *e) +{ + if (m_ctrl_shift_panning) { + setSelectionMode(); + } + PaletteGraphicsView::focusOutEvent(e); +} + /** @brief DiagramView::focusNextPrevChild By default, QWidget intercepts Tab/Shift+Tab to move keyboard focus to @@ -1396,6 +1421,7 @@ bool DiagramView::switchToVisualisationModeIfNeeded(QInputEvent *e) { if (isCtrlShifting(e) && !selectedItemHasFocus()) { if (dragMode() != QGraphicsView::ScrollHandDrag) { setVisualisationMode(); + m_ctrl_shift_panning = true; return(true); } } diff --git a/sources/diagramview.h b/sources/diagramview.h index f2abdd505..1b641d72f 100644 --- a/sources/diagramview.h +++ b/sources/diagramview.h @@ -60,6 +60,9 @@ class DiagramView : public PaletteGraphicsView QPointF m_drag_last_pos; bool m_fresh_focus_in, m_first_activation = true; + /// True while the view pans because Ctrl+Shift is held, as opposed + /// to the visualisation mode chosen from the toolbar. + bool m_ctrl_shift_panning = false; QList m_separators; QPolygonF m_free_rubberband; bool m_free_rubberbanding = false; @@ -93,6 +96,7 @@ class DiagramView : public PaletteGraphicsView void contextMenuEvent(QContextMenuEvent *) override; void wheelEvent(QWheelEvent *) override; void focusInEvent(QFocusEvent *) override; + void focusOutEvent(QFocusEvent *) override; void keyPressEvent(QKeyEvent *) override; void keyReleaseEvent(QKeyEvent *) override; bool event(QEvent *) override; From 926bbcf75fb92e88714864cd0bbddcfc564f81d7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 01:56:46 +1200 Subject: [PATCH 18/24] Let search results be dragged onto a folio like the tree While a collection search is active the tree is replaced by the flat ranked list, and that list had no drag support: dragging a result only moved the selection. Anyone used to searching and then dragging lost the drag as soon as they typed. The tree's drag is moved into a static ElementsTreeView::execElementDrag() taking the source widget, and the results list uses it from the path each row already carries, so the drag content and pixmap are the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- .../elementscollectionwidget.cpp | 29 ++++++++++++++++++- .../ElementsCollection/elementstreeview.cpp | 20 +++++++++++-- sources/ElementsCollection/elementstreeview.h | 2 ++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp index f8d073b77..198e6ddb1 100644 --- a/sources/ElementsCollection/elementscollectionwidget.cpp +++ b/sources/ElementsCollection/elementscollectionwidget.cpp @@ -55,6 +55,32 @@ #include #include +namespace { +/** + @brief The SearchResultsView class + The flat list of search results. Rows are not backed by the collection + model, so the drag is built here from the path each row carries, with + the same content and pixmap as a drag from the tree. +*/ +class SearchResultsView : public QListView +{ + public: + using QListView::QListView; + + protected: + void startDrag(Qt::DropActions supportedActions) override + { + const QString path = + currentIndex().data(Qt::UserRole + 2).toString(); + if (path.isEmpty()) { + QListView::startDrag(supportedActions); + return; + } + ElementsTreeView::execElementDrag(this, ElementsLocation(path)); + } +}; +} + /** @brief ElementsCollectionWidget::ElementsCollectionWidget Default constructor. @@ -230,8 +256,9 @@ void ElementsCollectionWidget::setUpWidget() //ranked list instead, and takes the tab widget's place while a search //is active. m_search_model = new QStandardItemModel(this); - m_search_results = new QListView(this); + m_search_results = new SearchResultsView(this); m_search_results->setModel(m_search_model); + m_search_results->setDragDropMode(QAbstractItemView::DragOnly); m_search_results->setIconSize(QSize(50, 50)); m_search_results->setUniformItemSizes(false); m_search_results->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); diff --git a/sources/ElementsCollection/elementstreeview.cpp b/sources/ElementsCollection/elementstreeview.cpp index 581bc1b64..b04c2726f 100644 --- a/sources/ElementsCollection/elementstreeview.cpp +++ b/sources/ElementsCollection/elementstreeview.cpp @@ -82,13 +82,27 @@ void ElementsTreeView::startDrag(Qt::DropActions supportedActions) @param location : location to use for create the content of the QDrag */ void ElementsTreeView::startElementDrag(const ElementsLocation &location) +{ + execElementDrag(this, location); +} + +/** + @brief ElementsTreeView::execElementDrag + Build and run the QDrag for @a location, from @a source. + Static so that a view which is not an ElementsTreeView -- the flat list + of search results -- starts exactly the same drag as the tree. + @param source : the widget the drag starts from + @param location : location to use for create the content of the QDrag +*/ +void ElementsTreeView::execElementDrag(QWidget *source, + const ElementsLocation &location) { if (! location.exist()) return; #if QT_VERSION < QT_VERSION_CHECK(6, 2, 0) - QDrag* drag = new QDrag(this); + QDrag* drag = new QDrag(source); #else - QScopedPointer drag(new QDrag(this)); + QScopedPointer drag(new QDrag(source)); #endif QString location_str = location.toString(); @@ -209,7 +223,7 @@ void ElementsTreeView::startElementDrag(const ElementsLocation &location) &elmt_creation_state)); if (elmt_creation_state) { return; } - QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), palette())); + QPixmap elmt_pixmap(QET::Palette::forPalette(temp_elmt->pixmap(), source->palette())); QPoint elmt_hotspot(temp_elmt->hotspot()); //Adjust the size of the pixmap if he is too big diff --git a/sources/ElementsCollection/elementstreeview.h b/sources/ElementsCollection/elementstreeview.h index 58c6a3426..8ee65656b 100644 --- a/sources/ElementsCollection/elementstreeview.h +++ b/sources/ElementsCollection/elementstreeview.h @@ -32,6 +32,8 @@ class ElementsTreeView : public QTreeView { public: ElementsTreeView(QWidget *parent = nullptr); + static void execElementDrag(QWidget *source, + const ElementsLocation &location); protected: void startDrag(Qt::DropActions supportedActions) override; From 5bd55801e98f2e3037670c1b4457c541be76eae7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 03:05:54 +1300 Subject: [PATCH 19/24] Pin elements to the shortcut bar The bar's customising window gets an element search next to the command list. Type part of a name, then drag a hit onto the bar, double-click it, or press Enter to pin the best one. A pinned element shows as its icon among the commands, and clicking it places the element, as the picker does. Pinned elements are saved in the same list as the commands, by collection path (common://, custom://, company://), so the row keeps the user's order. Elements embedded in a project are not offered: their path names the project as loaded now. Elements are offered for the empty-folio bar only; with something selected the bar is for acting on it. Once any element is pinned there, the palette folder grid under the bar is hidden, and comes back while typing a search. Dragging a pinned element off the bar onto the commands, or a double click, removes it. The Preferences page shows pinned elements by name and icon, and removing one there drops it instead of listing it as a command. The customising window is now kept on screen, since it is taller than before. Discussion #1033. Co-Authored-By: Claude Opus 5.5 --- .../ElementsCollection/elementpickerpopup.cpp | 226 +++++++++++++++--- .../ElementsCollection/elementpickerpopup.h | 10 +- sources/shortcutbarsettings.cpp | 20 ++ sources/shortcutbarsettings.h | 6 + .../ui/configpage/shortcutbarconfigpage.cpp | 32 ++- 5 files changed, 246 insertions(+), 48 deletions(-) diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index 870c3894d..a2d383a7b 100644 --- a/sources/ElementsCollection/elementpickerpopup.cpp +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -87,9 +87,9 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, auto *editor_layout = new QVBoxLayout(m_editor); editor_layout->setContentsMargins(0, 0, 0, 0); auto *help = new QLabel( - tr("Glissez les commandes dans la barre, hors de la barre, ou " - "d'une place à l'autre. Un double-clic fait passer une commande " - "d'une liste à l'autre."), m_editor); + tr("Glissez les commandes et les éléments dans la barre, hors de la " + "barre, ou d'une place à l'autre. Un double-clic fait passer une " + "commande ou un élément d'une liste à l'autre."), m_editor); help->setWordWrap(true); m_edit_row = new QListWidget(m_editor); m_edit_row->setFlow(QListView::LeftToRight); @@ -124,16 +124,59 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, this, relabel(m_edit_row, true)); connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted, this, relabel(m_edit_available, false)); + //An element dragged off the bar onto the commands is removed from + //the bar, not listed as a command + connect(m_edit_available->model(), &QAbstractItemModel::rowsInserted, this, [this]() { + QTimer::singleShot(0, this, [this]() { + for (int i = m_edit_available->count() - 1 ; i >= 0 ; --i) { + if (ShortcutBarSettings::isElement( + m_edit_available->item(i)->data(Qt::UserRole).toString())) { + delete m_edit_available->takeItem(i); + } + } + }); + }); connect(m_edit_row, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { const QString id = item->data(Qt::UserRole).toString(); delete item; - m_edit_available->addItem(commandItem(id, false)); + if (!ShortcutBarSettings::isElement(id)) { + m_edit_available->addItem(barItem(id, false)); + } }); connect(m_edit_available, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { const QString id = item->data(Qt::UserRole).toString(); delete item; - m_edit_row->addItem(commandItem(id, true)); + m_edit_row->addItem(barItem(id, true)); + }); + + //Elements to pin: the same ranked search as the picker, dragged + //onto the bar. Copied, not moved, so a result can be pinned and + //still be seen in the list. + m_edit_symbols_box = new QWidget(m_editor); + auto *symbols_layout = new QVBoxLayout(m_edit_symbols_box); + symbols_layout->setContentsMargins(0, 0, 0, 0); + m_edit_symbols_search = new QLineEdit(m_edit_symbols_box); + m_edit_symbols_search->setPlaceholderText(tr("Rechercher un élément…")); + m_edit_symbols_search->setClearButtonEnabled(true); + m_edit_symbols = new QListWidget(m_edit_symbols_box); + m_edit_symbols->setIconSize(QSize(32, 32)); + m_edit_symbols->setMinimumHeight(220); + m_edit_symbols->setDragDropMode(QAbstractItemView::DragOnly); + m_edit_symbols->setDefaultDropAction(Qt::CopyAction); + m_edit_symbols->setSelectionMode(QAbstractItemView::SingleSelection); + symbols_layout->addWidget(new QLabel(tr("Éléments :"), m_edit_symbols_box)); + symbols_layout->addWidget(m_edit_symbols_search); + symbols_layout->addWidget(m_edit_symbols); + + auto *symbols_timer = new QTimer(this); + symbols_timer->setSingleShot(true); + symbols_timer->setInterval(300); + connect(m_edit_symbols_search, &QLineEdit::textChanged, this, + [symbols_timer]() { symbols_timer->start(); }); + connect(symbols_timer, &QTimer::timeout, this, &ElementPickerPopup::runSymbolSearch); + connect(m_edit_symbols, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *item) { + m_edit_row->addItem(barItem(item->data(Qt::UserRole).toString(), true)); }); auto *defaults = new QPushButton(tr("Valeurs par défaut"), m_editor); auto *cancel = new QPushButton(tr("Annuler"), m_editor); @@ -152,8 +195,13 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, editor_layout->addWidget(help); editor_layout->addWidget(new QLabel(tr("Dans la barre :"), m_editor)); editor_layout->addWidget(m_edit_row); - editor_layout->addWidget(new QLabel(tr("Autres commandes :"), m_editor)); - editor_layout->addWidget(m_edit_available); + auto *lists = new QHBoxLayout(); + auto *commands_column = new QVBoxLayout(); + commands_column->addWidget(new QLabel(tr("Autres commandes :"), m_editor)); + commands_column->addWidget(m_edit_available); + lists->addLayout(commands_column); + lists->addWidget(m_edit_symbols_box); + editor_layout->addLayout(lists); editor_layout->addLayout(editor_buttons); m_editor->hide(); @@ -217,13 +265,7 @@ void ElementPickerPopup::popUpShortcutBar(const QPoint &global_pos, { m_bar_mode = true; m_context = context; - QList commands; - for (const QString &id : ShortcutBarSettings::ids(context)) { - if (QAction *action = commandAction(id)) { - commands << action; - } - } - setCommands(commands); + setCommands(ShortcutBarSettings::ids(context)); show(global_pos); } @@ -247,11 +289,22 @@ void ElementPickerPopup::show(const QPoint &global_pos) m_model->clear(); showPalette(); + keepOnScreen(global_pos); + QFrame::show(); + m_search->setFocus(); +} + +/** + @brief ElementPickerPopup::keepOnScreen + Fit the popup to its contents and move it to @a global_pos, kept fully on + the screen that point is on: opening at the cursor near a right or bottom + edge would otherwise push it off. +*/ +void ElementPickerPopup::keepOnScreen(const QPoint &global_pos) +{ adjustSize(); QPoint pos = global_pos; - //Keep it fully on the screen the cursor is on: opening at the cursor - //near a right or bottom edge would otherwise push it off. if (QScreen *screen = QGuiApplication::screenAt(global_pos)) { const QRect avail = screen->availableGeometry(); pos.setX(qBound(avail.left(), @@ -261,30 +314,54 @@ void ElementPickerPopup::show(const QPoint &global_pos) } move(pos); - QFrame::show(); - m_search->setFocus(); } /** @brief ElementPickerPopup::setCommands - Show @a commands as a row of buttons above the search field, or hide the - row when there are none. A disabled command keeps its place, greyed out, - so the row looks the same every time for a given selection. + Show @a ids as a row of buttons above the search field, or hide the row + when there are none. A disabled command keeps its place, greyed out, so + the row looks the same every time for a given selection. An id no live + action carries, or a pinned element that no longer exists, is skipped. Clicking a button closes the picker first, then triggers the action: a command such as "add a line" starts a mode on the folio, which needs the - focus the popup holds. - @param commands + focus the popup holds. A pinned element is placed, as if chosen from the + picker. + @param ids : command ids and pinned elements' collection paths */ -void ElementPickerPopup::setCommands(const QList &commands) +void ElementPickerPopup::setCommands(const QStringList &ids) { while (QLayoutItem *item = m_commands_layout->takeAt(0)) { delete item->widget(); delete item; } - for (QAction *action : commands) + for (const QString &id : ids) { + if (ShortcutBarSettings::isElement(id)) + { + const ElementsLocation location(id); + if (!location.exist()) { + continue; + } + auto *button = new QToolButton(m_commands); + button->setAutoRaise(true); + button->setIconSize(QSize(24, 24)); + button->setIcon(location.icon()); + button->setToolTip(location.name()); + button->setFocusPolicy(Qt::NoFocus); + connect(button, &QToolButton::clicked, this, [this, location]() { + hide(); + emit elementChosen(location); + }); + m_commands_layout->addWidget(button); + continue; + } + + QAction *action = commandAction(id); + if (!action) { + continue; + } auto *button = new QToolButton(m_commands); button->setAutoRaise(true); button->setIconSize(QSize(24, 24)); @@ -320,23 +397,34 @@ void ElementPickerPopup::setCommands(const QList &commands) m_commands_layout->addWidget(customise); } //An empty bar still shows, so it can be customised back - m_commands->setVisible(m_bar_mode || !commands.isEmpty()); + m_commands->setVisible(m_bar_mode || !ids.isEmpty()); } /** - @brief ElementPickerPopup::commandItem - @return a list item for command @a id: icon only, for the bar row, or - icon and text, for the list of other commands. The id is in UserRole. + @brief ElementPickerPopup::barItem + @return a list item for @a id, a command or a pinned element: icon only, + for the bar row, or icon and text, for the other lists. The id is in + UserRole. */ -QListWidgetItem *ElementPickerPopup::commandItem(const QString &id, bool icon_only) const +QListWidgetItem *ElementPickerPopup::barItem(const QString &id, bool icon_only) const { - QAction *action = commandAction(id); - const QString text = action ? action->text().remove(QLatin1Char('&')) : id; + QString text = id; + QIcon icon; + if (ShortcutBarSettings::isElement(id)) { + const ElementsLocation location(id); + if (location.exist()) { + text = location.name(); + icon = location.icon(); + } + } else if (QAction *action = commandAction(id)) { + text = action->text().remove(QLatin1Char('&')); + icon = action->icon(); + } auto *item = new QListWidgetItem(); item->setData(Qt::UserRole, id); item->setToolTip(text); - if (action && !action->icon().isNull()) { - item->setIcon(action->icon()); + if (!icon.isNull()) { + item->setIcon(icon); } if (!icon_only || item->icon().isNull()) { item->setText(text); @@ -363,10 +451,14 @@ void ElementPickerPopup::startCustomising() fillCustomising(ShortcutBarSettings::ids(m_context)); m_commands->hide(); setPickerVisible(false); + //Elements are pinned to the empty-folio bar only: with something + //selected, the bar is for acting on it, not for adding more + m_edit_symbols_search->clear(); + m_edit_symbols->clear(); + m_edit_symbols_box->setVisible(m_context == ShortcutBarSettings::Canvas); m_editor->show(); - adjustSize(); - move(where); + keepOnScreen(where); QFrame::show(); activateWindow(); } @@ -381,16 +473,46 @@ void ElementPickerPopup::fillCustomising(const QStringList &ids) m_edit_row->clear(); m_edit_available->clear(); for (const QString &id : ids) { - m_edit_row->addItem(commandItem(id, true)); + m_edit_row->addItem(barItem(id, true)); } for (const QString &id : ShortcutBarSettings::availableIds()) { if (!ids.contains(id)) { - m_edit_available->addItem(commandItem(id, false)); + m_edit_available->addItem(barItem(id, false)); } } m_edit_available->sortItems(); } +/** + @brief ElementPickerPopup::runSymbolSearch + Fill the customising window's element list with the ranked hits for its + search field. Elements embedded in a project are left out: their path + names the project as it is loaded now, so it would not find them again + in a later session. +*/ +void ElementPickerPopup::runSymbolSearch() +{ + m_edit_symbols->clear(); + if (!m_source) { + return; + } + const QVector hits = + m_source->rankedSearch(m_edit_symbols_search->text()); + for (const ElementSearchHit &hit : hits) + { + if (ElementsLocation(hit.path).isProject()) { + continue; + } + auto *item = new QListWidgetItem(hit.icon, hit.name); + item->setData(Qt::UserRole, hit.path); + item->setToolTip(QStringLiteral("%1\n%2").arg(hit.name, hit.folder)); + m_edit_symbols->addItem(item); + if (m_edit_symbols->count() >= max_palette_entries) { + break; + } + } +} + /** @brief ElementPickerPopup::finishCustomising Leave the edit, saving the bar row when @a save, and show the bar again @@ -454,10 +576,18 @@ void ElementPickerPopup::runSearch() } if (m_search->text().isEmpty()) { + const bool was_hidden = m_view->isHidden(); showPalette(); + if (was_hidden != m_view->isHidden()) { + keepOnScreen(pos()); + } return; } + if (m_view->isHidden()) { + m_view->show(); + keepOnScreen(pos()); + } m_palette_mode = false; m_view->setViewMode(QListView::ListMode); m_view->setGridSize(QSize()); @@ -529,7 +659,15 @@ void ElementPickerPopup::keyPressEvent(QKeyEvent *event) return; case Qt::Key_Return: case Qt::Key_Enter: - if (m_customising) { + if (m_customising && m_edit_symbols_search->hasFocus()) { + //Enter in the element search pins the best hit rather + //than closing the window. Searched now, not on the + //timer, so a quick Enter after typing gets this text. + runSymbolSearch(); + if (QListWidgetItem *item = m_edit_symbols->item(0)) { + m_edit_row->addItem(barItem(item->data(Qt::UserRole).toString(), true)); + } + } else if (m_customising) { finishCustomising(true); } else { chooseCurrent(); @@ -572,6 +710,16 @@ void ElementPickerPopup::showPalette() m_palette_mode = true; m_model->clear(); + //Elements pinned to the bar replace the folder grid: the bar already + //shows the user's shortlist, and a second one below it is noise. + //The search still works; the list comes back as soon as one types. + if (m_bar_mode && ShortcutBarSettings::hasElements(m_context)) { + m_view->hide(); + m_hint->setText(tr("Tapez pour rechercher un élément · Échap pour fermer")); + return; + } + m_view->show(); + m_view->setViewMode(QListView::IconMode); m_view->setIconSize(QSize(48, 48)); m_view->setGridSize(QSize(92, 84)); diff --git a/sources/ElementsCollection/elementpickerpopup.h b/sources/ElementsCollection/elementpickerpopup.h index b57fa32a1..fdbd4af95 100644 --- a/sources/ElementsCollection/elementpickerpopup.h +++ b/sources/ElementsCollection/elementpickerpopup.h @@ -71,13 +71,15 @@ class ElementPickerPopup : public QFrame void chooseCurrent(); void showPalette(); void show(const QPoint &global_pos); - void setCommands(const QList &commands); + void setCommands(const QStringList &ids); QAction *commandAction(const QString &id) const; - QListWidgetItem *commandItem(const QString &id, bool icon_only) const; + QListWidgetItem *barItem(const QString &id, bool icon_only) const; void startCustomising(); void fillCustomising(const QStringList &ids); void finishCustomising(bool save); + void runSymbolSearch(); void setPickerVisible(bool visible); + void keepOnScreen(const QPoint &global_pos); int loadPaletteDir(const QString &dir_path, const QString &prefix, int depth); @@ -96,6 +98,10 @@ class ElementPickerPopup : public QFrame QWidget *m_editor = nullptr; QListWidget *m_edit_row = nullptr; QListWidget *m_edit_available = nullptr; + /// Element search, for pinning elements to the bar + QWidget *m_edit_symbols_box = nullptr; + QLineEdit *m_edit_symbols_search = nullptr; + QListWidget *m_edit_symbols = nullptr; bool m_palette_mode = true; }; diff --git a/sources/shortcutbarsettings.cpp b/sources/shortcutbarsettings.cpp index fa66300a3..3a3c1c9ee 100644 --- a/sources/shortcutbarsettings.cpp +++ b/sources/shortcutbarsettings.cpp @@ -22,6 +22,8 @@ #include #include +#include + namespace { QString settingsKey(ShortcutBarSettings::Context context) { @@ -140,3 +142,21 @@ QStringList ShortcutBarSettings::availableIds() } return ids; } + +/** + @return true if @a id is a pinned element's collection path rather than + a command id. Command ids never contain "://". +*/ +bool ShortcutBarSettings::isElement(const QString &id) +{ + return id.contains(QLatin1String("://")); +} + +/** + @return true if the row for @a context holds at least one element +*/ +bool ShortcutBarSettings::hasElements(Context context) +{ + const QStringList list = ids(context); + return std::any_of(list.cbegin(), list.cend(), &ShortcutBarSettings::isElement); +} diff --git a/sources/shortcutbarsettings.h b/sources/shortcutbarsettings.h index 8e83b9232..b655aca76 100644 --- a/sources/shortcutbarsettings.h +++ b/sources/shortcutbarsettings.h @@ -30,6 +30,10 @@ so any registered command can go on it and the bar needs no command list of its own. Stored in QSettings, one key per context; a context the user never changed uses the defaults below. + + A row can also hold elements the user pinned, by collection path + ("common://…", "custom://…", "company://…"), mixed in with the commands. + Clicking one places that element. */ class ShortcutBarSettings { @@ -46,6 +50,8 @@ class ShortcutBarSettings static QStringList defaultIds(Context context); static void setIds(Context context, const QStringList &ids); static QStringList availableIds(); + static bool isElement(const QString &id); + static bool hasElements(Context context); }; #endif // SHORTCUTBARSETTINGS_H diff --git a/sources/ui/configpage/shortcutbarconfigpage.cpp b/sources/ui/configpage/shortcutbarconfigpage.cpp index 2dee5dccb..d8d3f510d 100644 --- a/sources/ui/configpage/shortcutbarconfigpage.cpp +++ b/sources/ui/configpage/shortcutbarconfigpage.cpp @@ -17,6 +17,7 @@ */ #include "shortcutbarconfigpage.h" +#include "../../ElementsCollection/elementslocation.h" #include "../../qeticons.h" #include "../../shortcutmanager.h" @@ -180,7 +181,14 @@ void ShortcutBarConfigPage::addSelected() void ShortcutBarConfigPage::removeSelected() { for (QListWidgetItem *item : m_chosen->selectedItems()) { - m_available->addItem(m_chosen->takeItem(m_chosen->row(item))); + QListWidgetItem *taken = m_chosen->takeItem(m_chosen->row(item)); + //A removed element is not a command to list; it is pinned + //again from the bar's own customising window + if (ShortcutBarSettings::isElement(taken->data(Qt::UserRole).toString())) { + delete taken; + } else { + m_available->addItem(taken); + } } } @@ -208,15 +216,25 @@ void ShortcutBarConfigPage::resetContext() /** @brief ShortcutBarConfigPage::appendItem - Add @a id to @a list with the command's text and icon. An id no live - action carries (a command from a build without it) is still listed, by - its id, so saving does not silently drop it. + Add @a id to @a list with the command's text and icon, or a pinned + element's name and icon. An id no live action carries (a command from a + build without it) is still listed, by its id, so saving does not + silently drop it. */ void ShortcutBarConfigPage::appendItem(QListWidget *list, const QString &id) { - QAction *action = ShortcutManager::instance().action(id, nullptr); - const QString text = m_descriptions.value(id, id); - auto *item = new QListWidgetItem(action ? action->icon() : QIcon(), text); + QString text = m_descriptions.value(id, id); + QIcon icon; + if (ShortcutBarSettings::isElement(id)) { + const ElementsLocation location(id); + if (location.exist()) { + text = location.name(); + icon = location.icon(); + } + } else if (QAction *action = ShortcutManager::instance().action(id, nullptr)) { + icon = action->icon(); + } + auto *item = new QListWidgetItem(icon, text); item->setData(Qt::UserRole, id); list->addItem(item); } From 47e5a8072b81e3c7e328c5864acdf32a683109da Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 03:15:07 +1300 Subject: [PATCH 20/24] Shortcut bar: reopen at the cursor, wrap to a width the user sets After customising, the bar now opens at the cursor like every other time, instead of where the customising window was. A size grip in the bar's corner sets its width. The tiles are laid out in rows that wrap at that width, so a bar with many commands and pinned elements becomes a block rather than one long strip; the height follows the rows, and the width is saved. Until the user sets one, the tiles stay on one row, as before. The grip is driven by hand: QSizeGrip asks the window manager to resize, and on X11 a popup is not managed, so nothing would happen. The hint line wraps rather than being cut off at narrow widths. The customising window remembers the size it was left at. Discussion #1033. Co-Authored-By: Claude Opus 5.5 --- .../ElementsCollection/elementpickerpopup.cpp | 139 +++++++++++++++++- .../ElementsCollection/elementpickerpopup.h | 13 ++ sources/shortcutbarsettings.cpp | 28 ++++ sources/shortcutbarsettings.h | 5 + 4 files changed, 178 insertions(+), 7 deletions(-) diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index a2d383a7b..2cfc1aaa4 100644 --- a/sources/ElementsCollection/elementpickerpopup.cpp +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -27,11 +27,14 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -39,6 +42,8 @@ #include #include +#include + #include "../qetapp.h" #include "../shortcutmanager.h" #include "elementslocation.h" @@ -219,11 +224,22 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_hint = new QLabel(tr("Entrée pour insérer · Échap pour fermer"), this); m_hint->setEnabled(false); + //Wrapped rather than cut off when the bar is made narrow + m_hint->setWordWrap(true); + + //The bar's width is the user's: dragging the grip wraps the tiles + //onto more rows, and the width is kept for next time + m_grip = new QSizeGrip(this); + m_grip->setToolTip(tr("Glisser pour changer la largeur de la barre")); + m_grip->installEventFilter(this); + auto *hint_row = new QHBoxLayout(); + hint_row->addWidget(m_hint, 1); + hint_row->addWidget(m_grip, 0, Qt::AlignBottom | Qt::AlignRight); layout->addWidget(m_commands); layout->addWidget(m_search); layout->addWidget(m_view); - layout->addWidget(m_hint); + layout->addLayout(hint_row); layout->addWidget(m_editor); //Search as you type, after a short idle: shorter than the dock's @@ -303,6 +319,16 @@ void ElementPickerPopup::show(const QPoint &global_pos) void ElementPickerPopup::keepOnScreen(const QPoint &global_pos) { adjustSize(); + //The sizes the user gave the customising window and the bar + if (m_customising) { + const QSize size = ShortcutBarSettings::editorSize(); + if (size.isValid()) { + resize(size.expandedTo(minimumSizeHint())); + } + } else if (m_bar_mode && ShortcutBarSettings::barWidth() > 0) { + resize(qMax(ShortcutBarSettings::barWidth(), minimumSizeHint().width()), + height()); + } QPoint pos = global_pos; if (QScreen *screen = QGuiApplication::screenAt(global_pos)) { @@ -335,6 +361,11 @@ void ElementPickerPopup::setCommands(const QStringList &ids) delete item->widget(); delete item; } + qDeleteAll(m_tiles); + m_tiles.clear(); + m_tile_rows = new QVBoxLayout(); + m_tile_rows->setSpacing(2); + m_commands_layout->addLayout(m_tile_rows); for (const QString &id : ids) { @@ -354,7 +385,7 @@ void ElementPickerPopup::setCommands(const QStringList &ids) hide(); emit elementChosen(location); }); - m_commands_layout->addWidget(button); + m_tiles << button; continue; } @@ -383,7 +414,7 @@ void ElementPickerPopup::setCommands(const QStringList &ids) hide(); action->trigger(); }); - m_commands_layout->addWidget(button); + m_tiles << button; } m_commands_layout->addStretch(); if (m_bar_mode) @@ -394,12 +425,103 @@ void ElementPickerPopup::setCommands(const QStringList &ids) customise->setToolTip(tr("Personnaliser la barre…")); customise->setFocusPolicy(Qt::NoFocus); connect(customise, &QToolButton::clicked, this, &ElementPickerPopup::startCustomising); - m_commands_layout->addWidget(customise); + m_commands_layout->addWidget(customise, 0, Qt::AlignTop); } + layoutTiles(ShortcutBarSettings::barWidth()); + m_grip->setVisible(m_bar_mode); //An empty bar still shows, so it can be customised back m_commands->setVisible(m_bar_mode || !ids.isEmpty()); } +/** + @brief ElementPickerPopup::layoutTiles + Put the bar's tiles in rows no wider than the bar at @a bar_width, in + their order, as many per row as fit. A @a bar_width of 0 or less, the + width the user never set, keeps them on one row. +*/ +void ElementPickerPopup::layoutTiles(int bar_width) +{ + if (!m_tile_rows) { + return; + } + //Emptying a row layout leaves its buttons alone; they go into the + //new rows below + while (QLayoutItem *item = m_tile_rows->takeAt(0)) { + delete item->layout(); + } + + //Room left beside the tiles: the popup's margins and the "…" button + const int limit = bar_width > 0 + ? bar_width - 12 - 30 + : std::numeric_limits::max(); + QHBoxLayout *row = nullptr; + int used = 0; + for (QToolButton *tile : std::as_const(m_tiles)) + { + const int w = tile->sizeHint().width() + m_tile_rows->spacing(); + if (!row || (used + w > limit && used > 0)) { + row = new QHBoxLayout(); + row->setSpacing(2); + row->addStretch(); + m_tile_rows->addLayout(row); + used = 0; + } + //Before the stretch, which stays last so the row is left-aligned + row->insertWidget(row->count() - 1, tile); + used += w; + } +} + +/** + @brief ElementPickerPopup::eventFilter + Drive the size grip by hand. QSizeGrip asks the window manager to do the + resize, and a popup is a window the window manager does not manage, so + on X11 nothing would happen. Only the width follows the mouse: the tiles + re-flow into rows as it changes, the height follows the rows, and the + width is saved when the grip is let go. +*/ +bool ElementPickerPopup::eventFilter(QObject *watched, QEvent *event) +{ + if (watched != m_grip) { + return QFrame::eventFilter(watched, event); + } + switch (event->type()) + { + case QEvent::MouseButtonPress: { + auto *me = static_cast(event); + if (me->button() != Qt::LeftButton) { + break; + } + m_grip_active = true; + m_grip_press_x = me->globalPosition().toPoint().x(); + m_grip_press_width = width(); + return true; + } + case QEvent::MouseMove: { + if (!m_grip_active) { + break; + } + auto *me = static_cast(event); + const int w = qMax(minimumWidth(), m_grip_press_width + + me->globalPosition().toPoint().x() - m_grip_press_x); + layoutTiles(w); + layout()->activate(); + resize(w, sizeHint().height()); + return true; + } + case QEvent::MouseButtonRelease: + if (!m_grip_active) { + break; + } + m_grip_active = false; + ShortcutBarSettings::setBarWidth(width()); + return true; + default: + break; + } + return QFrame::eventFilter(watched, event); +} + /** @brief ElementPickerPopup::barItem @return a list item for @a id, a command or a pinned element: icon only, @@ -451,6 +573,7 @@ void ElementPickerPopup::startCustomising() fillCustomising(ShortcutBarSettings::ids(m_context)); m_commands->hide(); setPickerVisible(false); + m_grip->hide(); //Elements are pinned to the empty-folio bar only: with something //selected, the bar is for acting on it, not for adding more m_edit_symbols_search->clear(); @@ -516,7 +639,8 @@ void ElementPickerPopup::runSymbolSearch() /** @brief ElementPickerPopup::finishCustomising Leave the edit, saving the bar row when @a save, and show the bar again - where it was so the result can be seen and used straight away. + at the cursor so the result can be seen and used straight away. The + window's size is kept either way. */ void ElementPickerPopup::finishCustomising(bool save) { @@ -529,14 +653,15 @@ void ElementPickerPopup::finishCustomising(bool save) ShortcutBarSettings::setIds(m_context, ids); } - const QPoint where = pos(); + ShortcutBarSettings::setEditorSize(size()); m_customising = false; hide(); m_editor->hide(); setPickerVisible(true); setWindowFlags(Qt::Popup); setWindowTitle(QString()); - popUpShortcutBar(where, m_context); + //At the cursor, like any other opening of the bar + popUpShortcutBar(QCursor::pos(), m_context); } /** diff --git a/sources/ElementsCollection/elementpickerpopup.h b/sources/ElementsCollection/elementpickerpopup.h index fdbd4af95..9983661f1 100644 --- a/sources/ElementsCollection/elementpickerpopup.h +++ b/sources/ElementsCollection/elementpickerpopup.h @@ -32,6 +32,9 @@ class QAction; class QHBoxLayout; class QListWidget; class QListWidgetItem; +class QSizeGrip; +class QToolButton; +class QVBoxLayout; /** @brief A cursor-anchored element picker. @@ -65,6 +68,7 @@ class ElementPickerPopup : public QFrame protected: void keyPressEvent(QKeyEvent *event) override; void closeEvent(QCloseEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; private: void runSearch(); @@ -72,6 +76,7 @@ class ElementPickerPopup : public QFrame void showPalette(); void show(const QPoint &global_pos); void setCommands(const QStringList &ids); + void layoutTiles(int bar_width); QAction *commandAction(const QString &id) const; QListWidgetItem *barItem(const QString &id, bool icon_only) const; void startCustomising(); @@ -90,6 +95,14 @@ class ElementPickerPopup : public QFrame QLabel *m_hint = nullptr; QWidget *m_commands = nullptr; QHBoxLayout *m_commands_layout = nullptr; + /// The bar's tiles, commands and elements, in order + QList m_tiles; + QVBoxLayout *m_tile_rows = nullptr; + QSizeGrip *m_grip = nullptr; + /// The size grip is being dragged, from this x at this width + bool m_grip_active = false; + int m_grip_press_x = 0; + int m_grip_press_width = 0; /// Opened as the shortcut bar, as opposed to the plain picker bool m_bar_mode = false; ShortcutBarSettings::Context m_context = ShortcutBarSettings::Canvas; diff --git a/sources/shortcutbarsettings.cpp b/sources/shortcutbarsettings.cpp index 3a3c1c9ee..97e66416d 100644 --- a/sources/shortcutbarsettings.cpp +++ b/sources/shortcutbarsettings.cpp @@ -160,3 +160,31 @@ bool ShortcutBarSettings::hasElements(Context context) const QStringList list = ids(context); return std::any_of(list.cbegin(), list.cend(), &ShortcutBarSettings::isElement); } + +/** + @return the width the user gave the bar with its size grip, or 0 when + they never did: the tiles then stay on one row +*/ +int ShortcutBarSettings::barWidth() +{ + return QSettings().value(QStringLiteral("diagrameditor/shortcut_bar/width"), 0).toInt(); +} + +void ShortcutBarSettings::setBarWidth(int width) +{ + QSettings().setValue(QStringLiteral("diagrameditor/shortcut_bar/width"), width); +} + +/** + @return the size the user left the customising window at, or an invalid + size when it was never opened +*/ +QSize ShortcutBarSettings::editorSize() +{ + return QSettings().value(QStringLiteral("diagrameditor/shortcut_bar/editor_size")).toSize(); +} + +void ShortcutBarSettings::setEditorSize(const QSize &size) +{ + QSettings().setValue(QStringLiteral("diagrameditor/shortcut_bar/editor_size"), size); +} diff --git a/sources/shortcutbarsettings.h b/sources/shortcutbarsettings.h index b655aca76..a56d845ab 100644 --- a/sources/shortcutbarsettings.h +++ b/sources/shortcutbarsettings.h @@ -21,6 +21,7 @@ #include #include #include +#include /** @brief The commands shown on the diagram editor's shortcut bar. @@ -52,6 +53,10 @@ class ShortcutBarSettings static QStringList availableIds(); static bool isElement(const QString &id); static bool hasElements(Context context); + static int barWidth(); + static void setBarWidth(int width); + static QSize editorSize(); + static void setEditorSize(const QSize &size); }; #endif // SHORTCUTBARSETTINGS_H From ff795258c481fb34537a4612c4c3eadacc6b2111 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 09:13:55 +1300 Subject: [PATCH 21/24] Conductor dock: turn it on in the preferences, off by default (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Showing a selected conductor's properties in the selection-properties dock is now a preference, on the General page under Appearance: "Afficher les propriétés d'un conducteur sélectionné dans le panneau Propriétés de la sélection". It is off by default, so selecting a conductor does what it did before unless the user turns it on. This replaces the View-menu toggle, which defaulted to on; that entry is dropped in the merge with master. Same setting key (diagrameditor/conductor_properties_panel). Co-Authored-By: Claude Opus 5.5 --- sources/factory/propertieseditorfactory.cpp | 6 +++--- sources/ui/configpage/generalconfigurationpage.cpp | 2 ++ sources/ui/configpage/generalconfigurationpage.ui | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/sources/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp index 8098f995a..48a39738b 100644 --- a/sources/factory/propertieseditorfactory.cpp +++ b/sources/factory/propertieseditorfactory.cpp @@ -117,11 +117,11 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor( { case Conductor::Type: //1001 { - //Feature toggle (#500): when disabled in the View menu, selecting a - //conductor brings up nothing in the dock. Default enabled. + //Off unless enabled in the preferences (General page): selecting + //a conductor then brings up nothing in the dock, as before. if (!QSettings().value( QStringLiteral("diagrameditor/conductor_properties_panel"), - true).toBool()) { + false).toBool()) { return nullptr; } //Prototype (#500): single-conductor editing in the dock. diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index 06816e113..93bc8d0b8 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -97,6 +97,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : else ui->m_use_windows_mode_rb->setChecked(true); ui->m_zoom_out_beyond_folio->setChecked(settings.value("diagrameditor/zoom-out-beyond-of-folio", false).toBool()); + ui->m_conductor_properties_panel->setChecked(settings.value("diagrameditor/conductor_properties_panel", false).toBool()); ui->m_use_gesture_trackpad->setChecked(settings.value("diagramview/gestures", false).toBool()); ui->m_save_label_paste->setChecked(settings.value("diagramcommands/erase-label-on-copy", true).toBool()); ui->m_enable_scripting->setChecked(QetSettings::scriptingEnabled()); @@ -290,6 +291,7 @@ void GeneralConfigurationPage::applyConf() settings.setValue("diagrameditor/viewmode", view_mode) ; settings.setValue("diagrameditor/highlight-integrated-elements", ui->m_highlight_integrated_elements->isChecked()); settings.setValue("diagrameditor/zoom-out-beyond-of-folio", ui->m_zoom_out_beyond_folio->isChecked()); + settings.setValue("diagrameditor/conductor_properties_panel", ui->m_conductor_properties_panel->isChecked()); settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value()); settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 3bdd12356..9b85aa896 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -63,6 +63,13 @@ + + + + Afficher les propriétés d'un conducteur sélectionné dans le panneau Propriétés de la sélection + + + @@ -1175,6 +1182,7 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments m_use_system_color_cb m_use_gesture_trackpad m_zoom_out_beyond_folio + m_conductor_properties_panel m_use_windows_mode_rb m_use_tab_mode_rb m_save_label_paste From b740508b7f684e7d56bad790930a441bb59390bd Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 14:00:07 +1300 Subject: [PATCH 22/24] Fix read-only queries stopping after the first row Since #1046, QETSql::execReadOnly() runs a query with PRAGMA query_only set and switches it off before returning. Switching it off aborts a statement SQLite is still stepping through ("abort due to ROLLBACK"), and QSQLITE has already stepped to the first row by then. A query that produces its rows as it goes -- a UNION ALL without ORDER BY -- therefore came back with its first row only and no error. A sorted query was not affected, because SQLite has read every row before returning the first. Every query from the SQL box of a table, a saved query and the scripting qet.query() goes through here. The checked run is now finished before query_only is switched off, and a query that passed is run again for the caller. SQLite refuses a write at its first step, so passing that step is what proves a statement reads only; the second run is of a statement already shown to be read-only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- sources/dataBase/sqlreadonly.cpp | 7 +++++++ tests/qttest/tst_sqlreadonly.cpp | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/sources/dataBase/sqlreadonly.cpp b/sources/dataBase/sqlreadonly.cpp index 95996bde5..f61aabbea 100644 --- a/sources/dataBase/sqlreadonly.cpp +++ b/sources/dataBase/sqlreadonly.cpp @@ -86,9 +86,16 @@ QSqlQuery execReadOnly(const QSqlDatabase &db, const QString &query, QString *er QSqlQuery result(db); const bool ok = result.exec(query); + //Switching query_only off aborts a statement SQLite is still + //stepping through -- an unsorted UNION ALL then ends after its + //first row. So the checked run is finished first, and a statement + //that passed is run again for the caller: SQLite refuses a write + //at its first step, so passing that step proves it read-only. + result.finish(); QSqlQuery(db).exec(QStringLiteral("PRAGMA query_only = OFF")); if (ok) { + result.exec(query); return result; } diff --git a/tests/qttest/tst_sqlreadonly.cpp b/tests/qttest/tst_sqlreadonly.cpp index a96735fd9..7159e1c28 100644 --- a/tests/qttest/tst_sqlreadonly.cpp +++ b/tests/qttest/tst_sqlreadonly.cpp @@ -40,6 +40,7 @@ #include #include +#include #include class TstSqlReadOnly : public QObject @@ -63,6 +64,7 @@ class TstSqlReadOnly : public QObject void doesNotExecuteWhatItRefuses(); void refusedQueryCannotBeRunAgain(); void acceptedQueryRunAgainStillReads(); + void returnsEveryRowOfACompoundSelect(); void leavesTheConnectionWritable(); private: @@ -242,6 +244,21 @@ void TstSqlReadOnly::acceptedQueryRunAgainStillReads() QCOMPARE(n, 2); } +void TstSqlReadOnly::returnsEveryRowOfACompoundSelect() +{ + // Switching query_only off aborts a statement SQLite is still + // stepping through, and an unsorted UNION ALL is one: it came back + // with its first row only. + QSqlQuery q = QETSql::execReadOnly( + m_db, "SELECT uuid FROM element UNION ALL SELECT uuid FROM element"); + QStringList uuids; + while (q.next()) { + uuids << q.value(0).toString(); + } + QVERIFY(!q.lastError().isValid()); + QCOMPARE(uuids, QStringList({"a", "b", "a", "b"})); +} + void TstSqlReadOnly::leavesTheConnectionWritable() { // query_only must not outlive the call, whatever its outcome: the From 5120feb41700bbfc439b25cd8e331e1422eec41f Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 16:12:51 +1300 Subject: [PATCH 23/24] Add an optional label to pictures (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picture can now carry a caption, set from its properties panel ("Libellé"). It is drawn centred under the picture at the folio's normal text size whatever the picture's scale, turns with it, and moves, copies and prints with it because the picture itself paints it. Clicking the caption selects the picture. Saved as a "label" attribute on , written only when non-empty: a project without labels saves byte-for-byte as before, and older versions open a labelled project and simply ignore the caption. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- sources/qetgraphicsitem/diagramimageitem.cpp | 96 +++++++++++++++++++- sources/qetgraphicsitem/diagramimageitem.h | 15 +++ sources/ui/imagepropertieswidget.cpp | 24 +++++ sources/ui/imagepropertieswidget.h | 1 + sources/ui/imagepropertieswidget.ui | 18 +++- 5 files changed, 149 insertions(+), 5 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 25f2fc164..8e3b58c71 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -69,7 +70,7 @@ DiagramImageItem::DiagramImageItem(const QPixmap &pixmap, QetGraphicsItem *paren // those are a single uniform scale() float, which is exactly why an // image could never break its own aspect ratio before this class // gained proper independent scaleX/scaleY. - m_transform.pivot = boundingRect().center(); + m_transform.pivot = imageRect().center(); setTransform(m_transform.toMatrix()); setFlags(QGraphicsItem::ItemIsSelectable|QGraphicsItem::ItemIsMovable|QGraphicsItem::ItemSendsGeometryChanges); setAcceptHoverEvents(true); @@ -110,6 +111,45 @@ void DiagramImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter -> drawRect(boundingRect()); painter -> restore(); } + + if (!m_label.isEmpty()) + { + // Undo the picture's own scale so the caption keeps the size of + // every other text on the folio whether the picture is shown at + // 5 % or 500 %. Rotation and skew are applied after scale in + // ShapeTransform::toMatrix(), so the caption still turns with it. + const QRectF r = labelRect(); + painter -> save(); + painter -> translate(r.topLeft()); + painter -> scale(1.0 / m_label_scale.x(), 1.0 / m_label_scale.y()); + painter -> setFont(m_label_font); + painter -> setPen(Qt::black); + painter -> drawText(QRectF(QPointF(0, 0), m_label_size), Qt::AlignCenter, m_label); + painter -> restore(); + } +} + +/** + @brief DiagramImageItem::setLabel + Set the caption drawn under the picture. An empty string removes it. + @param label +*/ +void DiagramImageItem::setLabel(const QString &label) +{ + if (label == m_label) + return; + + prepareGeometryChange(); + m_label = label; + // Font looked up here, not in paint(): diagramTextsFont() reads + // QSettings, far too slow to do on every repaint. + if (!m_label.isEmpty()) + m_label_font = QETApp::diagramTextsFont(); + m_label_size = m_label.isEmpty() + ? QSizeF() + : QFontMetricsF(m_label_font).size(0, m_label); + updateLabelScale(); + emit labelChanged(); } /** @@ -278,7 +318,7 @@ void DiagramImageItem::setPivotRaw(const QPointF &newPivot) void DiagramImageItem::resetPivotToBoundingRectCenter() { m_pivotIsCustom = false; - setPivot(boundingRect().center()); + setPivot(imageRect().center()); } namespace { @@ -1263,6 +1303,11 @@ QVariant DiagramImageItem::itemChange(GraphicsItemChange change, const QVariant } refreshInteractionHints(); } + else if (change == ItemTransformChange && !m_label.isEmpty()) + { + prepareGeometryChange(); + updateLabelScale(); + } else if (change == ItemPositionHasChanged || change == ItemTransformHasChanged) { if (!m_deferHandleReposition) @@ -1308,6 +1353,18 @@ QPixmap DiagramImageItem::computeDisplayPixmap(const QPixmap &base, const QRect @return a QRectF represent the bounding rectangle */ QRectF DiagramImageItem::boundingRect() const +{ + if (m_label.isEmpty()) + return imageRect(); + return imageRect().united(labelRect()); +} + +/** + @brief DiagramImageItem::imageRect + @return the picture's own rectangle, without its label. Handles, the + pivot and every other piece of geometry work on this one. +*/ +QRectF DiagramImageItem::imageRect() const { if (!pixmap_.isNull()) { return (QRectF(pixmap_.rect())); @@ -1317,6 +1374,34 @@ QRectF DiagramImageItem::boundingRect() const } } +/** + @brief DiagramImageItem::labelRect + @return the label's rectangle in item coordinates, centred just under + the picture. Divided by the picture's scale, because paint() draws + the label unscaled. +*/ +QRectF DiagramImageItem::labelRect() const +{ + const QRectF image = imageRect(); + const qreal w = m_label_size.width() / m_label_scale.x(); + const qreal h = m_label_size.height() / m_label_scale.y(); + const qreal gap = 2.0 / m_label_scale.y(); + return QRectF(image.center().x() - w / 2.0, image.bottom() + gap, w, h); +} + +/** + @brief DiagramImageItem::updateLabelScale + The label rect depends on the picture's scale, so boundingRect() + changes with it. m_transform is already updated by the time the item + transform changes, so the scale is cached here and only refreshed + after prepareGeometryChange() has recorded the old rect. +*/ +void DiagramImageItem::updateLabelScale() +{ + const auto safe = [](qreal v) { return qFuzzyIsNull(v) ? 1.0 : qAbs(v); }; + m_label_scale = QPointF(safe(m_transform.scaleX), safe(m_transform.scaleY)); +} + /** @brief DiagramImageItem::name @return the generic name of this item (picture) @@ -1426,7 +1511,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) m_transform.rotation = e.attribute("rotation").toDouble(); m_transform.scaleX = e.attribute("size").toDouble(); m_transform.scaleY = m_transform.scaleX; - m_transform.pivot = boundingRect().center(); + m_transform.pivot = imageRect().center(); m_pivotIsCustom = false; const QDomElement transformElement = e.firstChildElement("transform"); @@ -1447,6 +1532,7 @@ bool DiagramImageItem::fromXml(const QDomElement &e) QGraphicsObject::setPos(e.attribute("x").toDouble(), e.attribute("y").toDouble()); setZValue(e.attribute("z", QString::number(this->zValue())).toDouble()); is_movable_ = (e.attribute("is_movable").toInt()); + setLabel(e.attribute("label")); return (true); } @@ -1477,6 +1563,10 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const result.setAttribute("rotation", QString::number(QET::correctAngle(m_transform.rotation))); result.setAttribute("size", QString::number(m_transform.scaleX)); result.setAttribute("is_movable", bool(is_movable_)); + // An attribute, not a child element: fromXml() of every earlier + // version requires the base64 text to be the first child. + if (!m_label.isEmpty()) + result.setAttribute("label", m_label); //write the pixmap in the xml element after he was been transformed to base64 QByteArray array; diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index afdbaf7b5..ac8d7c9db 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -23,6 +23,7 @@ #include "../ui/imagetransparentcolordialog.h" #include +#include #include #include #include @@ -46,6 +47,7 @@ class DiagramImageItem : public QetGraphicsItem { Q_PROPERTY(qreal skewX READ skewX WRITE setSkewX NOTIFY transformChanged) Q_PROPERTY(qreal skewY READ skewY WRITE setSkewY NOTIFY transformChanged) Q_PROPERTY(QPointF pivot READ pivot WRITE setPivot NOTIFY transformChanged) + Q_PROPERTY(QString label READ label WRITE setLabel NOTIFY labelChanged) // A second, deliberately non-compensating property on the SAME // underlying value -- setPivot() (above) intentionally adjusts // pos() to keep the image visually in place, which is exactly @@ -123,10 +125,13 @@ class DiagramImageItem : public QetGraphicsItem { QPointF pivot() const { return m_transform.pivot; } void setPivot(const QPointF &pivot); void setPivotRaw(const QPointF &pivot); + QString label() const { return m_label; } + void setLabel(const QString &label); signals: void pixmapChanged(); void transformChanged(); + void labelChanged(); protected: void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override; @@ -175,6 +180,9 @@ class DiagramImageItem : public QetGraphicsItem { static QString hintForHandleRole(HandleRole role); void showStatusHint(const QString &text) const; void clearStatusHint() const; + QRectF imageRect() const; + QRectF labelRect() const; + void updateLabelScale(); protected: QPixmap pixmap_; @@ -212,6 +220,13 @@ class DiagramImageItem : public QetGraphicsItem { QPointF m_original_pos; // scene position at the start of a resize/rotate/pivot drag, for Escape-to-cancel ShapeTransform m_original_transform; bool m_deferHandleReposition = false; // see setPivot()'s comment + // Optional caption drawn centred under the picture (issue #349). + // Empty by default, and then neither saved nor painted, so a picture + // without one costs exactly what it did before. + QString m_label; + QFont m_label_font; + QSizeF m_label_size; // in scene units, measured once in setLabel() + QPointF m_label_scale{1.0, 1.0}; // scale the label rect was last computed for -- see updateLabelScale() bool m_resizeCenterAnchored = false; // decided once, at press time -- see handlerMousePressEvent()'s comment for why, mirroring the identical fix already made for shape creation }; #endif diff --git a/sources/ui/imagepropertieswidget.cpp b/sources/ui/imagepropertieswidget.cpp index b2cccca2b..3b0931437 100644 --- a/sources/ui/imagepropertieswidget.cpp +++ b/sources/ui/imagepropertieswidget.cpp @@ -64,16 +64,21 @@ void ImagePropertiesWidget::setImageItem(DiagramImageItem *image) this->setEnabled(true); if (m_image == image) return; if (m_image) + { disconnect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); + disconnect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); + } m_image = image; connect(m_image, &DiagramImageItem::transformChanged, this, &ImagePropertiesWidget::updateUi); + connect(m_image, &DiagramImageItem::labelChanged, this, &ImagePropertiesWidget::updateUi); m_movable = image->isMovable(); m_scaleX = m_image->scaleFactorX(); m_scaleY = m_image->scaleFactorY(); m_rotation = m_image->rotationAngle(); m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); + m_label = m_image->label(); updateUi(); } @@ -101,6 +106,7 @@ void ImagePropertiesWidget::apply() m_rotation = m_image->rotationAngle(); m_skewX = m_image->skewX(); m_skewY = m_image->skewY(); + m_label = m_image->label(); } /** @@ -116,6 +122,7 @@ void ImagePropertiesWidget::reset() m_image->setRotationAngle(m_rotation); m_image->setSkewX(m_skewX); m_image->setSkewY(m_skewY); + m_image->setLabel(m_label); m_image->setMovable(m_movable); updateUi(); } @@ -138,6 +145,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) connect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); connect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + connect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); } else { @@ -146,6 +154,7 @@ bool ImagePropertiesWidget::setLiveEdit(bool live_edit) disconnect (ui->m_angle_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_x_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); disconnect (ui->m_skew_y_sb, &QDoubleSpinBox::editingFinished, this, &ImagePropertiesWidget::apply); + disconnect (ui->m_label_le, &QLineEdit::editingFinished, this, &ImagePropertiesWidget::apply); } return true; @@ -191,6 +200,19 @@ QUndoCommand* ImagePropertiesWidget::associatedUndo() const chain("skewX", m_skewX, newSkewX, tr("Modifier l'inclinaison d'une image")); chain("skewY", m_skewY, newSkewY, tr("Modifier l'inclinaison d'une image")); + // Not through chain(): a string cannot be animated. + const QString newLabel = ui->m_label_le->text(); + if (newLabel != m_label) + { + if (undo) + new QPropertyUndoCommand(m_image, "label", m_label, newLabel, undo); + else + { + undo = new QPropertyUndoCommand(m_image, "label", m_label, newLabel); + undo->setText(tr("Modifier le libellé d'une image")); + } + } + return undo; } @@ -221,6 +243,8 @@ void ImagePropertiesWidget::updateUi() ui->m_skew_x_sb->setValue(m_image->skewX()); ui->m_skew_y_sb->setValue(m_image->skewY()); ui->m_lock_pos_cb->setChecked(!m_image->isMovable()); + if (ui->m_label_le->text() != m_image->label()) + ui->m_label_le->setText(m_image->label()); } /** diff --git a/sources/ui/imagepropertieswidget.h b/sources/ui/imagepropertieswidget.h index 438b5e3b8..c692d7892 100644 --- a/sources/ui/imagepropertieswidget.h +++ b/sources/ui/imagepropertieswidget.h @@ -70,6 +70,7 @@ class ImagePropertiesWidget : public PropertiesEditorWidget qreal m_rotation; qreal m_skewX; qreal m_skewY; + QString m_label; // Guards the width/height spinboxes' mutual updates when // "Conserver les proportions" is checked, so setting one // programmatically in response to the other doesn't re-trigger diff --git a/sources/ui/imagepropertieswidget.ui b/sources/ui/imagepropertieswidget.ui index 36ff7ddf3..4c9954cd6 100644 --- a/sources/ui/imagepropertieswidget.ui +++ b/sources/ui/imagepropertieswidget.ui @@ -152,14 +152,28 @@ - + + + + Libellé + + + + + + + Texte affiché sous l'image. Il suit l'image quand elle est déplacée, copiée ou tournée. + + + + Verrouiller la position - + Qt::Vertical From f6f53e3f526adc41d0cdbf9fae661bbaf91fe20d Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sun, 27 Sep 2026 16:43:11 +1300 Subject: [PATCH 24/24] Stop re-encoding unchanged pictures on every save DiagramImageItem::toXml() PNG-encoded every picture on every save, autosave and copy, whether or not it had changed. That is most of the cost of pictures in a project: resaving one holding 60 of them took 6.2 s and now takes 3.8 s. The PNG bytes are now kept and reused while QPixmap::cacheKey() still matches, so any edit (replace, crop, mirror, transparency) re-encodes without each of those functions having to invalidate anything. On load the cache is filled with the file's own bytes, so the first save encodes nothing either. Output is byte-identical to before on the example projects. A picture whose PNG came from another encoder now keeps its original bytes instead of being re-encoded; the pixels are identical. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- sources/qetgraphicsitem/diagramimageitem.cpp | 58 +++++++++++++++++--- sources/qetgraphicsitem/diagramimageitem.h | 11 ++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp index 25f2fc164..58b77df55 100644 --- a/sources/qetgraphicsitem/diagramimageitem.cpp +++ b/sources/qetgraphicsitem/diagramimageitem.cpp @@ -1326,6 +1326,36 @@ QString DiagramImageItem::name() const return tr("une image"); } +namespace { + // Only PNG bytes read from a file are reused as they are: toXml() + // has always written PNG, and a hand-edited file holding another + // format keeps being converted, exactly as before. + bool isPng(const QByteArray &data) + { + return data.startsWith("\x89PNG\r\n\x1a\n"); + } +} + +/** + @brief DiagramImageItem::encodedPng + @return pixmap as PNG bytes, encoded only when cache does not already + hold them. QPixmap::cacheKey() changes whenever the pixmap is replaced + or modified, which is what makes this safe without every function + that edits the picture having to remember to invalidate the cache. +*/ +const QByteArray &DiagramImageItem::encodedPng(const QPixmap &pixmap, QByteArray &cache, qint64 &cacheKey) +{ + if (cacheKey != pixmap.cacheKey()) + { + cache.clear(); + QBuffer buffer(&cache); + buffer.open(QIODevice::WriteOnly); + pixmap.save(&buffer, "PNG"); + cacheKey = pixmap.cacheKey(); + } + return cache; +} + /** @brief DiagramImageItem::fromXml Load this image from xml element e @@ -1351,6 +1381,17 @@ bool DiagramImageItem::fromXml(const QDomElement &e) QPixmap pixmap; pixmap.loadFromData(array); setPixmap(pixmap); + // From the first text node only, not e.text(): that also collects the + // text of every child element, so on a cropped or colour-keyed picture + // it carries 's bytes too. loadFromData() stops at the end + // of the first PNG and never notices; a cache written back verbatim + // would. + const QByteArray ownArray = QByteArray::fromBase64(image_node.toText().data().toLatin1()); + if (isPng(ownArray) && !pixmap_.isNull()) + { + m_png_cache = ownArray; + m_png_cache_key = pixmap_.cacheKey(); + } // Falls back to treating the loaded result as its own base, with no // remembered crop or colours -- correct both for a genuinely plain @@ -1404,7 +1445,14 @@ bool DiagramImageItem::fromXml(const QDomElement &e) const QByteArray baseArray = QByteArray::fromBase64(baseElement.text().toLatin1()); QPixmap basePixmap; if (basePixmap.loadFromData(baseArray)) + { m_base_pixmap = basePixmap; + if (isPng(baseArray)) + { + m_base_png_cache = baseArray; + m_base_png_cache_key = m_base_pixmap.cacheKey(); + } + } } // m_crop_rect may still refer to a saved file's base image, not // pixmap (used as a fallback above only when nothing better is @@ -1479,10 +1527,7 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const result.setAttribute("is_movable", bool(is_movable_)); //write the pixmap in the xml element after he was been transformed to base64 - QByteArray array; - QBuffer buffer(&array); - buffer.open(QIODevice::ReadWrite); - pixmap_.save(&buffer, "PNG"); + const QByteArray &array = encodedPng(pixmap_, m_png_cache, m_png_cache_key); QDomText base64 = document.createTextNode(array.toBase64()); result.appendChild(base64); @@ -1552,10 +1597,7 @@ QDomElement DiagramImageItem::toXml(QDomDocument &document) const if (hasCrop || hasColors) { - QByteArray baseArray; - QBuffer baseBuffer(&baseArray); - baseBuffer.open(QIODevice::ReadWrite); - m_base_pixmap.save(&baseBuffer, "PNG"); + const QByteArray &baseArray = encodedPng(m_base_pixmap, m_base_png_cache, m_base_png_cache_key); QDomElement baseElement = document.createElement("image_base"); baseElement.appendChild(document.createTextNode(baseArray.toBase64())); result.appendChild(baseElement); diff --git a/sources/qetgraphicsitem/diagramimageitem.h b/sources/qetgraphicsitem/diagramimageitem.h index afdbaf7b5..66383920d 100644 --- a/sources/qetgraphicsitem/diagramimageitem.h +++ b/sources/qetgraphicsitem/diagramimageitem.h @@ -147,6 +147,7 @@ class DiagramImageItem : public QetGraphicsItem { void saveOriginalImageAs(); void saveImagePixmapAs(const QPixmap &pixmap, const QString &dialogTitle, bool hasTransparency); static bool writeRasterAsSvg(const QPixmap &pixmap, const QString &path); + static const QByteArray &encodedPng(const QPixmap &pixmap, QByteArray &cache, qint64 &cacheKey); static QPixmap computeDisplayPixmap(const QPixmap &base, const QRect &cropRect, const QList &colors); void toggleHandleMode(); @@ -196,6 +197,16 @@ class DiagramImageItem : public QetGraphicsItem { QPixmap m_base_pixmap; QRect m_crop_rect; // relative to m_base_pixmap; equals m_base_pixmap.rect() when nothing has been cropped QList m_transparent_colors; + // PNG bytes of pixmap_ and m_base_pixmap as last written, reused by + // toXml() while the pixmap's cacheKey() still matches. PNG encoding + // is the bulk of the time a save spends on a picture, and redoing it + // on every save, autosave and copy of an unchanged picture is pure + // waste. Filled from the file itself on load, so even the first save + // encodes nothing. + mutable QByteArray m_png_cache; + mutable qint64 m_png_cache_key = 0; + mutable QByteArray m_base_png_cache; + mutable qint64 m_base_png_cache_key = 0; // Independent scaleX/scaleY here is the actual point of this whole // member: QGraphicsItem::scale() is a single, uniform float, which