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;