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_cb m_use_gesture_trackpad m_zoom_out_beyond_folio + m_conductor_properties_panel m_use_windows_mode_rb m_use_tab_mode_rb m_save_label_paste 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