diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake
index b822eb726..60fddcf55 100644
--- a/cmake/qet_compilation_vars.cmake
+++ b/cmake/qet_compilation_vars.cmake
@@ -285,6 +285,12 @@ 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/diagramcontexttoolbar.cpp
+ ${QET_DIR}/sources/diagramcontexttoolbar.h
+ ${QET_DIR}/sources/diagramgestureoverlay.cpp
+ ${QET_DIR}/sources/diagramgestureoverlay.h
${QET_DIR}/sources/commandsearchpopup.cpp
${QET_DIR}/sources/commandsearchpopup.h
${QET_DIR}/sources/titleblockcell.cpp
@@ -453,6 +459,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
@@ -708,6 +716,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/contactgroupselectiondialog.cpp
${QET_DIR}/sources/ui/contactgroupselectiondialog.h
${QET_DIR}/sources/ui/conductorpropertiesdialog.cpp
@@ -799,6 +809,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/lang/qet_en.ts b/lang/qet_en.ts
index 7a899eabf..6f4dae9f4 100644
--- a/lang/qet_en.ts
+++ b/lang/qet_en.ts
@@ -912,6 +912,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
diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp
new file mode 100644
index 000000000..2cfc1aaa4
--- /dev/null
+++ b/sources/ElementsCollection/elementpickerpopup.cpp
@@ -0,0 +1,934 @@
+/*
+ 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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#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
+ //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);
+
+ //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();
+ //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 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);
+ 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));
+ //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;
+ 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(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);
+ 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);
+ 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();
+
+ 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);
+ //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->addLayout(hint_row);
+ 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
+ //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_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;
+ setCommands(ShortcutBarSettings::ids(context));
+ 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();
+
+ 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();
+ //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)) {
+ 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);
+}
+
+/**
+ @brief ElementPickerPopup::setCommands
+ 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. A pinned element is placed, as if chosen from the
+ picker.
+ @param ids : command ids and pinned elements' collection paths
+*/
+void ElementPickerPopup::setCommands(const QStringList &ids)
+{
+ while (QLayoutItem *item = m_commands_layout->takeAt(0)) {
+ 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)
+ {
+ 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_tiles << button;
+ continue;
+ }
+
+ QAction *action = commandAction(id);
+ if (!action) {
+ continue;
+ }
+ 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_tiles << button;
+ }
+ m_commands_layout->addStretch();
+ 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, 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,
+ for the bar row, or icon and text, for the other lists. The id is in
+ UserRole.
+*/
+QListWidgetItem *ElementPickerPopup::barItem(const QString &id, bool icon_only) const
+{
+ 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 (!icon.isNull()) {
+ item->setIcon(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_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();
+ m_edit_symbols->clear();
+ m_edit_symbols_box->setVisible(m_context == ShortcutBarSettings::Canvas);
+ m_editor->show();
+
+ keepOnScreen(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(barItem(id, true));
+ }
+ for (const QString &id : ShortcutBarSettings::availableIds()) {
+ if (!ids.contains(id)) {
+ 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
+ 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)
+{
+ 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);
+ }
+
+ ShortcutBarSettings::setEditorSize(size());
+ m_customising = false;
+ hide();
+ m_editor->hide();
+ setPickerVisible(true);
+ setWindowFlags(Qt::Popup);
+ setWindowTitle(QString());
+ //At the cursor, like any other opening of the bar
+ popUpShortcutBar(QCursor::pos(), 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);
+}
+
+/**
+ @brief ElementPickerPopup::runSearch
+*/
+void ElementPickerPopup::runSearch()
+{
+ m_model->clear();
+ if (!m_source) {
+ return;
+ }
+
+ 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());
+ 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:
+ if (m_customising) {
+ finishCustomising(false);
+ } else {
+ hide();
+ }
+ return;
+ case Qt::Key_Return:
+ case Qt::Key_Enter:
+ 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();
+ }
+ return;
+ case Qt::Key_Down:
+ case Qt::Key_Up:
+ case Qt::Key_PageDown:
+ case Qt::Key_PageUp:
+ 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);
+ 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();
+
+ //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));
+ 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..9983661f1
--- /dev/null
+++ b/sources/ElementsCollection/elementpickerpopup.h
@@ -0,0 +1,121 @@
+/*
+ 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 "../shortcutbarsettings.h"
+
+#include
+
+class ElementsCollectionWidget;
+class QLineEdit;
+class QListView;
+class QStandardItemModel;
+class QLabel;
+class QAction;
+class QHBoxLayout;
+class QListWidget;
+class QListWidgetItem;
+class QSizeGrip;
+class QToolButton;
+class QVBoxLayout;
+
+/**
+ @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);
+ void popUpShortcutBar(const QPoint &global_pos,
+ ShortcutBarSettings::Context context);
+
+ signals:
+ /// Emitted when the user picks an element; the popup has closed
+ void elementChosen(const ElementsLocation &location);
+
+ protected:
+ void keyPressEvent(QKeyEvent *event) override;
+ void closeEvent(QCloseEvent *event) override;
+ bool eventFilter(QObject *watched, QEvent *event) override;
+
+ private:
+ void runSearch();
+ void chooseCurrent();
+ 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();
+ 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);
+
+ ElementsCollectionWidget *m_source = nullptr;
+ QLineEdit *m_search = nullptr;
+ QListView *m_view = nullptr;
+ QStandardItemModel *m_model = nullptr;
+ 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;
+ /// Customising the bar in place
+ bool m_customising = false;
+ 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;
+};
+
+#endif // ELEMENTPICKERPOPUP_H
diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp
index 3c015c716..198e6ddb1 100644
--- a/sources/ElementsCollection/elementscollectionwidget.cpp
+++ b/sources/ElementsCollection/elementscollectionwidget.cpp
@@ -35,6 +35,12 @@
#include
#include
+#include
+#include
+#include
+#include
+#include
+#include
#include
#include
#include
@@ -49,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.
@@ -217,8 +249,26 @@ 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 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);
+ 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)"));
@@ -266,15 +316,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 +326,58 @@ 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());
+ });
+ }
+
+ //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);
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 +486,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 +550,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);
@@ -932,6 +1076,7 @@ void ElementsCollectionWidget::search()
//Reset the search
if (text.isEmpty())
{
+ clearFlatResults();
QModelIndex current_index = m_tree_view->currentIndex();
m_tree_view->reset();
@@ -958,22 +1103,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 bf00f37d6..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;
@@ -86,9 +104,26 @@ 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);
+ void showFlatResults(const QVector &hits);
+ void clearFlatResults();
+ static int rankMatch(const QString &needle, const QString &name,
+ const QString &haystack);
private:
@@ -100,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;
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;
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/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 2ccb6daa7..e3996bf66 100644
--- a/sources/diagramview.cpp
+++ b/sources/diagramview.cpp
@@ -38,6 +38,10 @@
#include "utils/conductorcreator.h"
#include "undocommand/addgraphicsobjectcommand.h"
#include "diagram.h"
+#include "diagramcontexttoolbar.h"
+#include "diagramgestureoverlay.h"
+#include "shortcutbarsettings.h"
+#include "shortcutmanager.h"
#include "ElementsCollection/xmlelementcollection.h"
#include "NameList/nameslist.h"
#include "elementdialog.h"
@@ -45,6 +49,7 @@
#include
#include
#include
+#include
#include
/**
@@ -98,6 +103,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)
{
@@ -105,6 +113,14 @@ DiagramView::DiagramView(Diagram *diagram, QWidget *parent) :
m_separators.last()->setSeparator(true);
}
+ 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();
+ }
+ });
+
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);
@@ -234,14 +250,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());
}
/**
@@ -316,6 +390,7 @@ void DiagramView::handleTextDrop(QDropEvent *e) {
*/
void DiagramView::setVisualisationMode()
{
+ m_ctrl_shift_panning = false;
setDragMode(ScrollHandDrag);
applyReadOnly();
setInteractive(false);
@@ -327,6 +402,7 @@ void DiagramView::setVisualisationMode()
*/
void DiagramView::setSelectionMode()
{
+ m_ctrl_shift_panning = false;
setDragMode(RubberBandDrag);
setInteractive(true);
applyReadOnly();
@@ -557,6 +633,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);
@@ -565,6 +647,50 @@ 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();
+ }
+
+ //Right button: a click opens the context menu on release, a drag is
+ //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->focusItem())
+ {
+ m_gesture_tracking = true;
+ m_gesture_origin = e->position().toPoint();
+ m_context_toolbar->hide();
+
+ //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;
+ }
+ }
+
//Start drag view when hold the middle button
if (e->button() == Qt::MiddleButton)
{
@@ -616,6 +742,31 @@ 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()) {
+ 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;
+ }
+ }
+
// Drag the view
if (e->buttons() == Qt::MiddleButton)
{
@@ -677,6 +828,40 @@ void DiagramView::mouseReleaseEvent(QMouseEvent *e)
{
if (m_event_interface && m_event_interface->mouseReleaseEvent(e)) return;
+ //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();
+ 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;
+ }
+ if (e->button() == Qt::RightButton) {
+ m_gesture_tracking = false;
+ }
+
// Stop drag view
if (e->button() == Qt::MiddleButton)
{
@@ -721,7 +906,60 @@ 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::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
+ 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;
+ }
+
+ m_context_toolbar->showAt(viewport_pos, selectionCommands());
}
/**
@@ -801,6 +1039,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
@@ -854,6 +1109,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;
@@ -1396,6 +1663,7 @@ bool DiagramView::switchToVisualisationModeIfNeeded(QInputEvent *e) {
if (isCtrlShifting(e) && !selectedItemHasFocus()) {
if (dragMode() != QGraphicsView::ScrollHandDrag) {
setVisualisationMode();
+ m_ctrl_shift_panning = true;
return(true);
}
}
@@ -1497,6 +1765,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;
@@ -1560,6 +1830,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
@@ -1608,6 +1886,7 @@ void DiagramView::contextMenuEvent(QContextMenuEvent *e)
{
m_paste_here_pos = menu_pos;
m_paste_here->setEnabled(Diagram::clipboardMayContainDiagram());
+ updateFolioReportMenu();
}
QList list = contextMenuActions();
@@ -1620,6 +1899,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 f2abdd505..9ac5a5ad8 100644
--- a/sources/diagramview.h
+++ b/sources/diagramview.h
@@ -27,10 +27,13 @@
class CellRuler;
class Conductor;
class Diagram;
+class DiagramContextToolbar;
+class DiagramGestureOverlay;
class QETDiagramEditor;
class DVEventInterface;
class QInputEvent;
class QGestureEvent;
+class QMenu;
/**
This class provides a widget to render an electric diagram in an editable,
@@ -55,11 +58,27 @@ 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_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;
+ /// 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);
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;
@@ -85,6 +104,10 @@ class DiagramView : public PaletteGraphicsView
/// cursor query (QCursor::pos()/setPos() are silently ignored by
/// 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;
void setCellRulersShown(bool shown);
void setCellLinesShown(bool shown);
@@ -93,6 +116,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;
@@ -117,6 +141,7 @@ class DiagramView : public PaletteGraphicsView
private:
void handleElementDrop(QDropEvent *);
+ void updateFolioReportMenu();
void handleTitleBlockDrop(QDropEvent *);
void handleTextDrop(QDropEvent *);
void scrollOnMovement(QKeyEvent *);
@@ -126,6 +151,8 @@ class DiagramView : public PaletteGraphicsView
bool gestures() const;
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
@@ -145,6 +172,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/factory/propertieseditorfactory.cpp b/sources/factory/propertieseditorfactory.cpp
index 6a18db924..48a39738b 100644
--- a/sources/factory/propertieseditorfactory.cpp
+++ b/sources/factory/propertieseditorfactory.cpp
@@ -22,6 +22,8 @@
#include "../qetgraphicsitem/ViewItem/qetgraphicstableitem.h"
#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"
@@ -30,11 +32,13 @@
#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"
#include
+#include
/**
@brief PropertiesEditorFactory::propertiesEditor
@@ -83,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
@@ -100,6 +115,28 @@ PropertiesEditorWidget *PropertiesEditorFactory::propertiesEditor(
switch (type_)
{
+ case Conductor::Type: //1001
+ {
+ //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"),
+ false).toBool()) {
+ return nullptr;
+ }
+ //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/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 c319b0be9..d97d08b24 100644
--- a/sources/qetdiagrameditor.cpp
+++ b/sources/qetdiagrameditor.cpp
@@ -22,6 +22,9 @@
#include
#include
#include "ElementsCollection/elementscollectionwidget.h"
+#include "ElementsCollection/elementpickerpopup.h"
+#include "shortcutbarsettings.h"
+#include "qetgraphicsitem/conductor.h"
#include "commandsearchpopup.h"
#include "QWidgetAnimation/qwidgetanimation.h"
#include "autoNum/ui/autonumberingdockwidget.h"
@@ -76,6 +79,7 @@
#include
#include
#include
+#include
#ifdef BUILD_WITHOUT_KF
# include "ui/nokde/kautosavefile.h"
#else
@@ -229,6 +233,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);
}
@@ -809,6 +820,25 @@ 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);
//Type to find and run any command, as SolidWorks' "Search Commands"
//and the command palette of many editors. Ctrl+Shift+P, the key those
//editors use, is taken by the autonumbering dock; M for "menu".
@@ -830,6 +860,51 @@ void QETDiagramEditor::setUpActions()
});
addAction(m_command_search);
+ //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);
+
+ //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);
+
+ //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"));
@@ -962,7 +1037,8 @@ void QETDiagramEditor::setUpActions()
connect(&m_add_item_actions_group, &QActionGroup::triggered, this, &QETDiagramEditor::addItemGroupTriggered);
//No default key, but an id: they can then be found by the command
- //search and bound in the Shortcuts page, like every other command.
+ //search, 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(),
@@ -1121,6 +1197,10 @@ 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 -> addAction(m_show_element_picker);
+ menu_edition -> addAction(m_show_shortcut_bar);
+ menu_edition -> addAction(m_repeat_last_command);
menu_edition -> addAction(m_command_search);
menu_edition -> addSeparator();
//The same actions the "Ajouter" toolbar holds. They were toolbar-only,
@@ -1837,6 +1917,8 @@ void QETDiagramEditor::addItemGroupTriggered(QAction *action)
return;
}
+ setLastCommand(action);
+
DiagramEventInterface *diagram_event = nullptr;
if (value == "line")
@@ -2030,6 +2112,11 @@ 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_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);
@@ -2925,6 +3012,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);
}
/**
@@ -3034,6 +3122,139 @@ 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);
+ 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;
+}
+
+/**
+ @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::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.
+*/
+ElementPickerPopup *QETDiagramEditor::elementPicker()
+{
+ if (!m_element_picker)
+ {
+ m_element_picker = new ElementPickerPopup(m_element_collection_widget,
+ this);
+ connect(m_element_picker, &ElementPickerPopup::elementChosen,
+ this, &QETDiagramEditor::insertElementFromCollection);
+ }
+ 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 ShortcutBarSettings::Context context =
+ ShortcutBarSettings::contextFor(dv->diagram()->selectedItems());
+
+ elementPicker()->popUpShortcutBar(QCursor::pos(), context);
+}
+
/**
@brief QETDiagramEditor::generateTerminalBlock
*/
diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h
index 0e1735296..c4ac40835 100644
--- a/sources/qetdiagrameditor.h
+++ b/sources/qetdiagrameditor.h
@@ -25,6 +25,7 @@
#include
#include
#include
+#include
#include
#include
@@ -45,6 +46,7 @@ class ElementsLocation;
class RecentFiles;
class DiagramPropertiesEditorDockWidget;
class ElementsCollectionWidget;
+class ElementPickerPopup;
class CommandSearchPopup;
class AutoNumberingDockWidget;
class TerminalNumberingDialog;
@@ -128,6 +130,12 @@ 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 showElementPicker();
+ void showShortcutBar();
+ bool repeatLastCommand();
void generateTerminalBlock();
void setWindowedMode();
void setTabbedMode();
@@ -175,7 +183,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
@@ -270,9 +279,19 @@ 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;
+ 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
QAction *m_command_search = nullptr;
CommandSearchPopup *m_command_search_popup = nullptr; ///< Built on first use
ElementsCollectionWidget *m_element_collection_widget;
+ /// Last element placed from the collection, for "insert last"
+ ElementsLocation m_last_inserted_element;
DiagramPropertiesEditorDockWidget *m_selection_properties_editor;
/// Elements panel
diff --git a/sources/qetgraphicsitem/diagramimageitem.cpp b/sources/qetgraphicsitem/diagramimageitem.cpp
index ac6d27555..ade3d987c 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)
@@ -1326,6 +1411,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
@@ -1354,6 +1469,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
@@ -1407,7 +1533,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
@@ -1429,7 +1562,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");
@@ -1450,6 +1583,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);
}
@@ -1481,12 +1615,13 @@ 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;
- 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);
@@ -1556,10 +1691,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 e214bb4f1..7509fa137 100644
--- a/sources/qetgraphicsitem/diagramimageitem.h
+++ b/sources/qetgraphicsitem/diagramimageitem.h
@@ -23,6 +23,7 @@
#include "../ui/imagetransparentcolordialog.h"
#include
+#include
#include
#include
#include
@@ -47,6 +48,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
@@ -127,11 +129,14 @@ 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 uuidChanged();
+ void labelChanged();
protected:
void paint(QPainter *, const QStyleOptionGraphicsItem *, QWidget *) override;
@@ -152,6 +157,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();
@@ -180,6 +186,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:
QUuid m_uuid = QUuid::createUuid();
@@ -202,6 +211,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
@@ -218,6 +237,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/shortcutbarsettings.cpp b/sources/shortcutbarsettings.cpp
new file mode 100644
index 000000000..69083c496
--- /dev/null
+++ b/sources/shortcutbarsettings.cpp
@@ -0,0 +1,208 @@
+/*
+ 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 "qetgraphicsitem/conductor.h"
+#include "shortcutmanager.h"
+
+#include
+#include
+#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 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
+*/
+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);
+ }
+}
+
+/**
+ @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;
+}
+
+/**
+ @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);
+}
+
+/**
+ @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
new file mode 100644
index 000000000..470b81254
--- /dev/null
+++ b/sources/shortcutbarsettings.h
@@ -0,0 +1,65 @@
+/*
+ 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
+
+class QGraphicsItem;
+#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.
+
+ 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
+{
+ public:
+ enum Context {
+ Canvas, ///< nothing selected
+ Selection, ///< elements, texts or shapes selected
+ Conductor ///< only conductors selected
+ };
+
+ static QList contexts();
+ static Context contextFor(const QList &selection);
+ static QString title(Context context);
+ static QStringList ids(Context context);
+ 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);
+ static int barWidth();
+ static void setBarWidth(int width);
+ static QSize editorSize();
+ static void setEditorSize(const QSize &size);
+};
+
+#endif // SHORTCUTBARSETTINGS_H
diff --git a/sources/ui/conductorpropertieseditorwidget.cpp b/sources/ui/conductorpropertieseditorwidget.cpp
new file mode 100644
index 000000000..384e43f11
--- /dev/null
+++ b/sources/ui/conductorpropertieseditorwidget.cpp
@@ -0,0 +1,287 @@
+/*
+ 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 "../qtextorientationspinboxwidget.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#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))
+{
+ // 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.
+ // "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).
+ // Reuse the modal dialog's exact wording for consistency (and so the
+ // existing translation applies).
+ m_apply_all_cb = new QCheckBox(
+ 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) {
+ 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
+ // 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);
+}
+
+ConductorPropertiesEditorWidget::~ConductorPropertiesEditorWidget()
+{}
+
+/**
+ @brief ConductorPropertiesEditorWidget::setConductor
+ Set (or change) the conductor whose properties are edited.
+ @param conductor
+*/
+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();
+}
+
+/**
+ @brief ConductorPropertiesEditorWidget::apply
+ Push the edit onto the diagram's undo stack.
+*/
+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.
+*/
+void ConductorPropertiesEditorWidget::reset()
+{
+ if (!m_conductor) return;
+ m_updating = true;
+ m_cpw->setProperties(m_initial);
+ m_updating = false;
+}
+
+/**
+ @brief ConductorPropertiesEditorWidget::updateUi
+ Reload the widget from the conductor (e.g. when the selection changes).
+*/
+void ConductorPropertiesEditorWidget::updateUi()
+{
+ if (!m_conductor) return;
+ m_updating = true;
+ m_initial = m_conductor->properties();
+ m_cpw->setProperties(m_initial);
+ m_updating = false;
+}
+
+/**
+ @brief ConductorPropertiesEditorWidget::associatedUndo
+ @return the edit as a QPropertyUndoCommand, or nullptr if unchanged.
+
+ 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
+{
+ 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"));
+
+ // 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;
+}
+
+/**
+ @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..0479a6bee
--- /dev/null
+++ b/sources/ui/conductorpropertieseditorwidget.h
@@ -0,0 +1,70 @@
+/*
+ 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"
+
+#include
+#include
+
+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).
+ 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
+{
+ 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;
+ bool setLiveEdit(bool live_edit) override;
+
+ private:
+ void connectChangeSignals();
+ void disconnectChangeSignals();
+
+ private:
+ ConductorPropertiesWidget *m_cpw = nullptr;
+ QCheckBox *m_apply_all_cb = nullptr;
+ Conductor *m_conductor = nullptr;
+ ConductorProperties m_initial;
+ QList m_live_connections;
+ bool m_updating = false;
+};
+
+#endif // CONDUCTORPROPERTIESEDITORWIDGET_H
diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp
index 06816e113..41fa915bd 100644
--- a/sources/ui/configpage/generalconfigurationpage.cpp
+++ b/sources/ui/configpage/generalconfigurationpage.cpp
@@ -67,6 +67,11 @@ 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->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)
@@ -97,6 +102,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,10 +296,14 @@ 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());
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 3bdd12356..cfd57dd5c 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
+
+
+
@@ -77,6 +84,36 @@
+
+
+
+ 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
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ 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
+
+
+
@@ -1175,6 +1212,7 @@ Vous pouvez spécifier ici la valeur par défaut de ce champ pour les éléments
m_use_system_color_cbm_use_gesture_trackpadm_zoom_out_beyond_folio
+ m_conductor_properties_panelm_use_windows_mode_rbm_use_tab_mode_rbm_save_label_paste
diff --git a/sources/ui/configpage/shortcutbarconfigpage.cpp b/sources/ui/configpage/shortcutbarconfigpage.cpp
new file mode 100644
index 000000000..d8d3f510d
--- /dev/null
+++ b/sources/ui/configpage/shortcutbarconfigpage.cpp
@@ -0,0 +1,240 @@
+/*
+ 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 "../../ElementsCollection/elementslocation.h"
+#include "../../qeticons.h"
+#include "../../shortcutmanager.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/**
+ @brief ShortcutBarConfigPage::ShortcutBarConfigPage
+ @param parent
+*/
+ShortcutBarConfigPage::ShortcutBarConfigPage(QWidget *parent) :
+ ConfigPage(parent)
+{
+ const QStringList available = ShortcutBarSettings::availableIds();
+ for (const ShortcutManager::ShortcutInfo &info :
+ ShortcutManager::instance().allShortcuts()) {
+ if (available.contains(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()) {
+ 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);
+ }
+ }
+}
+
+/**
+ @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, 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)
+{
+ 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);
+}
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
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