From 07501d604e277d994a640646b171ed041f8baf2b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Sat, 8 Aug 2026 17:50:43 +1200 Subject: [PATCH] Add an element picker that opens at the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insert opens a small picker where the mouse is, with its search field focused. Type to search the whole collection, Enter to place the best hit (it is preselected), Up/Down to choose another, Esc to close. The chosen element goes into the usual placement mode, so it can be placed several times and repeated with A. With the field empty the picker shows a palette: the elements of a folder, as an icon grid. The palette is a folder rather than a setting or a file format. Subfolders are read in, the 01_/02_ filename prefixes the shipped collection already uses give the order, and sharing it is putting it in the company collection. Only the path is stored, "elementscollection/palette-path", defaulting to the user collection. It is read each time the picker opens, capped at 60 entries and three folder levels. The picker builds no second collection model. It asks the Collections panel's rankedSearch(), so both give the same results in the same order and startup is unchanged. "Insérer un élément…" is in the Édition menu, registered with ShortcutManager on Insert, which nothing else uses, and disabled with no folio open or on a read-only project. Discussion #676. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 01e1e0303b96380e710da8d4bb2300b21d01e938) --- cmake/qet_compilation_vars.cmake | 2 + .../ElementsCollection/elementpickerpopup.cpp | 331 ++++++++++++++++++ .../ElementsCollection/elementpickerpopup.h | 76 ++++ sources/qetdiagrameditor.cpp | 42 +++ sources/qetdiagrameditor.h | 4 + 5 files changed, 455 insertions(+) create mode 100644 sources/ElementsCollection/elementpickerpopup.cpp create mode 100644 sources/ElementsCollection/elementpickerpopup.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 1bf68b48d..0a6a14d75 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -451,6 +451,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ElementsCollection/elementpreviewdelegate.h ${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.cpp ${QET_DIR}/sources/ElementsCollection/elementscollectionmodel.h + ${QET_DIR}/sources/ElementsCollection/elementpickerpopup.cpp + ${QET_DIR}/sources/ElementsCollection/elementpickerpopup.h ${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.cpp ${QET_DIR}/sources/ElementsCollection/elementscollectionwidget.h ${QET_DIR}/sources/ElementsCollection/elementslocation.cpp diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp new file mode 100644 index 000000000..553aa3d5c --- /dev/null +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -0,0 +1,331 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "elementpickerpopup.h" + +#include "elementscollectionwidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../qetapp.h" +#include "elementslocation.h" + + //The palette is read from disk each time the picker opens. It is meant as + //a shortlist, and a large custom collection would otherwise make opening + //slow and the grid unusable. +static const int max_palette_entries = 60; + +/** + @brief ElementPickerPopup::ElementPickerPopup + @param source : the collection widget whose model the search runs against + @param parent +*/ +ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, + QWidget *parent) : + QFrame(parent, Qt::Popup), + m_source(source) +{ + setFrameShape(QFrame::StyledPanel); + setFrameShadow(QFrame::Raised); + setMinimumWidth(340); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(6, 6, 6, 6); + layout->setSpacing(4); + + m_search = new QLineEdit(this); + m_search->setPlaceholderText(tr("Rechercher un élément…")); + m_search->setClearButtonEnabled(true); + + m_model = new QStandardItemModel(this); + m_view = new QListView(this); + m_view->setModel(m_model); + m_view->setIconSize(QSize(40, 40)); + m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setMinimumHeight(260); + + m_hint = new QLabel(tr("Entrée pour insérer · Échap pour fermer"), this); + m_hint->setEnabled(false); + + layout->addWidget(m_search); + layout->addWidget(m_view); + layout->addWidget(m_hint); + + //Search as you type, after a short idle: shorter than the dock's + //500 ms, since the popup is opened and closed in a few seconds, but + //the collection is large enough that every keystroke would show. + auto *timer = new QTimer(this); + timer->setSingleShot(true); + timer->setInterval(300); + connect(m_search, &QLineEdit::textChanged, this, + [timer]() { timer->start(); }); + connect(timer, &QTimer::timeout, this, &ElementPickerPopup::runSearch); + + connect(m_view, &QListView::doubleClicked, this, + [this](const QModelIndex &) { chooseCurrent(); }); +} + +/** + @brief ElementPickerPopup::popUpAt + Show the picker at @a global_pos, kept on screen, with the search field + focused and any previous query cleared. + @param global_pos +*/ +void ElementPickerPopup::popUpAt(const QPoint &global_pos) +{ + m_search->clear(); + m_model->clear(); + showPalette(); + + adjustSize(); + QPoint pos = global_pos; + + //Keep it fully on the screen the cursor is on: opening at the cursor + //near a right or bottom edge would otherwise push it off. + if (QScreen *screen = QGuiApplication::screenAt(global_pos)) { + const QRect avail = screen->availableGeometry(); + pos.setX(qBound(avail.left(), + pos.x(), avail.right() - width())); + pos.setY(qBound(avail.top(), + pos.y(), avail.bottom() - height())); + } + + move(pos); + show(); + m_search->setFocus(); +} + +/** + @brief ElementPickerPopup::runSearch +*/ +void ElementPickerPopup::runSearch() +{ + m_model->clear(); + if (!m_source) { + return; + } + + if (m_search->text().isEmpty()) { + showPalette(); + return; + } + + m_palette_mode = false; + m_view->setViewMode(QListView::ListMode); + m_view->setGridSize(QSize()); + m_view->setIconSize(QSize(40, 40)); + + const QVector hits = m_source->rankedSearch(m_search->text()); + for (const ElementSearchHit &hit : hits) + { + auto *item = new QStandardItem(hit.name); + item->setIcon(hit.icon); + item->setEditable(false); + item->setToolTip(hit.folder); + item->setData(hit.path, Qt::UserRole + 2); + m_model->appendRow(item); + } + + //Preselect the best hit so Enter works straight from the search field + //without arrowing down first -- that is the whole point of the popup. + if (m_model->rowCount()) { + m_view->setCurrentIndex(m_model->index(0, 0)); + } + m_hint->setText(hits.isEmpty() + ? tr("Aucun résultat") + : tr("Entrée pour insérer · Échap pour fermer")); +} + +/** + @brief ElementPickerPopup::chooseCurrent + Emit the highlighted element and close. +*/ +void ElementPickerPopup::chooseCurrent() +{ + const QModelIndex index = m_view->currentIndex(); + if (!index.isValid()) { + return; + } + const QString path = index.data(Qt::UserRole + 2).toString(); + if (path.isEmpty()) { + return; + } + + ElementsLocation location(path); + if (!location.exist()) { + return; + } + + //Close before emitting: placement mode wants the focus, and a popup + //still up would keep the grab. + hide(); + emit elementChosen(location); +} + +/** + @brief ElementPickerPopup::keyPressEvent + Drive the list from the search field, so the hands never leave the + keyboard: Up/Down move the selection, Enter places, Esc closes. + @param event +*/ +void ElementPickerPopup::keyPressEvent(QKeyEvent *event) +{ + switch (event->key()) + { + case Qt::Key_Escape: + hide(); + return; + case Qt::Key_Return: + case Qt::Key_Enter: + chooseCurrent(); + return; + case Qt::Key_Down: + case Qt::Key_Up: + case Qt::Key_PageDown: + case Qt::Key_PageUp: + if (m_model->rowCount()) { + //Forwarded rather than focus-switched, so typing carries + //on going to the search field. + QCoreApplication::sendEvent(m_view, event); + return; + } + break; + default: + break; + } + QFrame::keyPressEvent(event); +} + +/** + @brief ElementPickerPopup::showPalette + With an empty search field, show the quick palette as an icon grid. + + The palette is a folder, not a config file. QET already has a user-owned, + drag-populated, icon-rendering collection -- so a palette is just a + directory whose subfolders are categories and whose contents are entries. + That gives set-up (drag into the folder), ordering (the 01_/02_ filename + convention the shipped collection already uses), team sharing + (companyElementsDir) and version control for free, with no schema, parser, + merge rules or "reset to defaults" to build or maintain. + + Only the path lives in QSettings, defaulting to the user's custom + collection. +*/ +void ElementPickerPopup::showPalette() +{ + m_palette_mode = true; + m_model->clear(); + + m_view->setViewMode(QListView::IconMode); + m_view->setIconSize(QSize(48, 48)); + m_view->setGridSize(QSize(92, 84)); + m_view->setResizeMode(QListView::Adjust); + m_view->setWordWrap(true); + m_view->setMovement(QListView::Static); + + QSettings settings; + const QString path = settings.value( + QStringLiteral("elementscollection/palette-path"), + QETApp::customElementsDir()).toString(); + + const int count = loadPaletteDir(path, QString(), 0); + + if (!count) { + m_hint->setText( + tr("Palette vide — glissez des éléments dans votre collection " + "personnelle, ou tapez pour rechercher")); + } else { + m_hint->setText(tr("Entrée pour insérer · Échap pour fermer")); + m_view->setCurrentIndex(m_model->index(0, 0)); + } +} + +/** + @brief ElementPickerPopup::loadPaletteDir + Add every .elmt under @a dir_path to the grid, recursing into subfolders. + @param dir_path + @param prefix : folder path so far, shown as the entry's tooltip + @param depth : recursion guard -- a palette is a shortlist, and the whole + shipped collection would not be usable as a grid anyway + @return how many entries were added +*/ +int ElementPickerPopup::loadPaletteDir(const QString &dir_path, + const QString &prefix, int depth) +{ + if (depth > 3 || m_model->rowCount() >= max_palette_entries) { + return 0; + } + QDir dir(dir_path); + if (!dir.exists()) { + return 0; + } + + int added = 0; + //Sorted by name, which is what makes the 01_/02_ filename convention + //work as the ordering mechanism. + const QFileInfoList entries = dir.entryInfoList( + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + + for (const QFileInfo &info : entries) + { + if (m_model->rowCount() >= max_palette_entries) { + break; + } + if (info.isDir()) { + added += loadPaletteDir(info.absoluteFilePath(), + prefix.isEmpty() + ? info.fileName() + : prefix + QStringLiteral(" / ") + + info.fileName(), + depth + 1); + continue; + } + if (info.suffix().compare(QStringLiteral("elmt"), + Qt::CaseInsensitive) != 0) { + continue; + } + + ElementsLocation location(info.absoluteFilePath()); + if (!location.exist()) { + continue; + } + + auto *item = new QStandardItem(location.name().isEmpty() + ? info.completeBaseName() + : location.name()); + item->setIcon(location.icon()); + item->setEditable(false); + item->setToolTip(prefix.isEmpty() ? info.completeBaseName() : prefix); + item->setData(info.absoluteFilePath(), Qt::UserRole + 2); + item->setTextAlignment(Qt::AlignHCenter | Qt::AlignTop); + m_model->appendRow(item); + ++added; + } + return added; +} diff --git a/sources/ElementsCollection/elementpickerpopup.h b/sources/ElementsCollection/elementpickerpopup.h new file mode 100644 index 000000000..ab5213579 --- /dev/null +++ b/sources/ElementsCollection/elementpickerpopup.h @@ -0,0 +1,76 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef ELEMENTPICKERPOPUP_H +#define ELEMENTPICKERPOPUP_H + +#include "elementslocation.h" + +#include + +class ElementsCollectionWidget; +class QLineEdit; +class QListView; +class QStandardItemModel; +class QLabel; + +/** + @brief A cursor-anchored element picker. + + Opens where the mouse is, focused on its search field: type to filter, + Enter to place, Esc to close. It is a recall tool, as opposed to the + Collections dock, which is a browsing tool. + + It deliberately does not build its own ElementsCollectionModel. Loading the + collection is already the slow part of startup and a second copy would + double it, so the picker asks the dock's widget to run the query -- see + ElementsCollectionWidget::rankedSearch(). That also means the two always + agree on results and ranking. +*/ +class ElementPickerPopup : public QFrame +{ + Q_OBJECT + + public: + explicit ElementPickerPopup(ElementsCollectionWidget *source, + QWidget *parent = nullptr); + + void popUpAt(const QPoint &global_pos); + + signals: + /// Emitted when the user picks an element; the popup has closed + void elementChosen(const ElementsLocation &location); + + protected: + void keyPressEvent(QKeyEvent *event) override; + + private: + void runSearch(); + void chooseCurrent(); + void showPalette(); + int loadPaletteDir(const QString &dir_path, const QString &prefix, + int depth); + + ElementsCollectionWidget *m_source = nullptr; + QLineEdit *m_search = nullptr; + QListView *m_view = nullptr; + QStandardItemModel *m_model = nullptr; + QLabel *m_hint = nullptr; + bool m_palette_mode = true; +}; + +#endif // ELEMENTPICKERPOPUP_H diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 540907430..eaedcfc6e 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -22,6 +22,7 @@ #include #include #include "ElementsCollection/elementscollectionwidget.h" +#include "ElementsCollection/elementpickerpopup.h" #include "QWidgetAnimation/qwidgetanimation.h" #include "autoNum/ui/autonumberingdockwidget.h" #include "conductornumexport.h" @@ -823,6 +824,22 @@ void QETDiagramEditor::setUpActions() this, &QETDiagramEditor::insertLastElement); addAction(m_insert_last_element); + //Cursor-anchored picker. Insert is unbound anywhere in the tree and + //reads correctly for the action, which keeps A free for the far more + //frequent "place the same symbol again". + m_show_element_picker = new QAction(QET::Icons::Add, + tr("Insérer un élément…"), this); + m_show_element_picker->setStatusTip( + tr("Ouvre le sélecteur d'éléments à la position du curseur", + "status bar tip")); + m_show_element_picker->setData("show_element_picker"); + ShortcutManager::instance().registerAction( + m_show_element_picker, "diagrameditor.show_element_picker", + tr("Éditeur de schémas"), Qt::Key_Insert); + connect(m_show_element_picker, &QAction::triggered, + this, &QETDiagramEditor::showElementPicker); + addAction(m_show_element_picker); + m_delete_selection->setStatusTip( tr("Enlève les éléments sélectionnés du folio", "status bar tip")); m_rotate_selection->setStatusTip( tr("Pivote les éléments et textes sélectionnés", "status bar tip")); m_rotate_group_selection->setStatusTip( tr("Pivote la sélection comme un groupe autour de son centre, au lieu de chaque élément sur place", "status bar tip")); @@ -1108,6 +1125,7 @@ void QETDiagramEditor::setUpMenu() menu_edition -> addAction(m_duplicate); menu_edition -> addAction(m_configure_duplicate); menu_edition -> addAction(m_insert_last_element); + menu_edition -> addAction(m_show_element_picker); menu_edition -> addSeparator(); //The same actions the "Ajouter" toolbar holds. They were toolbar-only, //which left them unreachable for anyone working without a mouse: a @@ -2016,6 +2034,7 @@ void QETDiagramEditor::slot_updateActions() m_select_actions_group. setEnabled(opened_diagram); m_add_item_actions_group. setEnabled(editable_project); m_insert_last_element-> setEnabled(opened_diagram && editable_project && !m_last_inserted_element.isNull()); + m_show_element_picker-> setEnabled(opened_diagram && editable_project); m_row_column_actions_group. setEnabled(editable_project); m_background_color_button-> setEnabled(opened_diagram); m_draw_grid-> setEnabled(opened_diagram); @@ -3067,6 +3086,29 @@ void QETDiagramEditor::insertLastElement() insertElementFromCollection(m_last_inserted_element); } +/** + @brief QETDiagramEditor::showElementPicker + Open the element picker where the mouse is. + + Built lazily: most sessions of the diagram editor never open it, and it + holds a list view and a model of its own. +*/ +void QETDiagramEditor::showElementPicker() +{ + if (!currentDiagramView()) { + return; + } + + if (!m_element_picker) + { + m_element_picker = new ElementPickerPopup(m_element_collection_widget, + this); + connect(m_element_picker, &ElementPickerPopup::elementChosen, + this, &QETDiagramEditor::insertElementFromCollection); + } + m_element_picker->popUpAt(QCursor::pos()); +} + /** @brief QETDiagramEditor::generateTerminalBlock */ diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index 3cb2659ec..657d56d22 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -45,6 +45,7 @@ class ElementsLocation; class RecentFiles; class DiagramPropertiesEditorDockWidget; class ElementsCollectionWidget; +class ElementPickerPopup; class AutoNumberingDockWidget; class TerminalNumberingDialog; @@ -130,6 +131,7 @@ class QETDiagramEditor : public QETMainWindow void insertElementFromCollection(const ElementsLocation &location); void insertLastElement(); void rememberPlacedElement(const ElementsLocation &location); + void showElementPicker(); void generateTerminalBlock(); void setWindowedMode(); void setTabbedMode(); @@ -271,6 +273,8 @@ class QETDiagramEditor : public QETMainWindow *m_qdw_elmt_collection, *qdw_undo; /// Dock for the undo list + QAction *m_show_element_picker = nullptr; + ElementPickerPopup *m_element_picker = nullptr; ///< Built on first use ElementsCollectionWidget *m_element_collection_widget; /// Last element placed from the collection, for "insert last" ElementsLocation m_last_inserted_element;