diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp index 511dd0ef8..f8d073b77 100644 --- a/sources/ElementsCollection/elementscollectionwidget.cpp +++ b/sources/ElementsCollection/elementscollectionwidget.cpp @@ -37,6 +37,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -219,8 +223,25 @@ void ElementsCollectionWidget::setUpWidget() m_tab_widget->addTab(m_tree_view, tr("Collections")); m_tab_widget->addTab(m_macros_tree_view, tr("Modèles")); + //Flat ranked search results. + //The tree search hides non-matching rows, so hits stay scattered + //through five levels of expanded folders -- searching "diode" leaves + //you scrolling a tree to find them. This shows the same matches as a + //ranked list instead, and takes the tab widget's place while a search + //is active. + m_search_model = new QStandardItemModel(this); + m_search_results = new QListView(this); + m_search_results->setModel(m_search_model); + m_search_results->setIconSize(QSize(50, 50)); + m_search_results->setUniformItemSizes(false); + m_search_results->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_search_results->setContextMenuPolicy(Qt::CustomContextMenu); + m_search_results->setMouseTracking(true); + m_search_results->hide(); + m_main_vlayout->addWidget(m_search_field); m_main_vlayout->addWidget(m_tab_widget); + m_main_vlayout->addWidget(m_search_results); m_progress_bar = new QProgressBar(this); m_progress_bar->setFormat(QObject::tr("chargement %p% (%v sur %m)")); @@ -290,6 +311,41 @@ void ElementsCollectionWidget::setUpConnection() }); } + //The flat results list carries the collection path directly, so it + //does not go through activateIndex() -- there is no tree index behind + //a row to look an ElementCollectionItem up from. + auto place_from_results = [this](const QModelIndex &index) { + const QString path = index.data(Qt::UserRole + 2).toString(); + if (path.isEmpty()) { + return; + } + ElementsLocation location(path); + if (location.exist()) { + emit insertElementRequested(location); + } + }; + connect(m_search_results, &QListView::doubleClicked, this, place_from_results); + for (const auto key : {Qt::Key_Return, Qt::Key_Enter}) { + auto *sc = new QShortcut(QKeySequence(key), m_search_results); + sc->setContext(Qt::WidgetShortcut); + connect(sc, &QShortcut::activated, this, [this, place_from_results]() { + place_from_results(m_search_results->currentIndex()); + }); + } + //Down from the search field moves into the results, so the whole + //type-then-place run happens without touching the mouse. + auto *to_results = new QShortcut(QKeySequence(Qt::Key_Down), m_search_field); + to_results->setContext(Qt::WidgetShortcut); + connect(to_results, &QShortcut::activated, this, [this]() { + if (!m_search_results->isVisible() || !m_search_model->rowCount()) { + return; + } + m_search_results->setFocus(); + if (!m_search_results->currentIndex().isValid()) { + m_search_results->setCurrentIndex(m_search_model->index(0, 0)); + } + }); + connect(m_macros_tree_view, &QTreeView::customContextMenuRequested, this, &ElementsCollectionWidget::customContextMenu); @@ -993,6 +1049,7 @@ void ElementsCollectionWidget::search() //Reset the search if (text.isEmpty()) { + clearFlatResults(); QModelIndex current_index = m_tree_view->currentIndex(); m_tree_view->reset(); @@ -1019,22 +1076,154 @@ void ElementsCollectionWidget::search() return; } - hideCollection(true); - const QStringList text_list = text.split("+", Qt::SkipEmptyParts); - QModelIndexList match_index; - for (QString txt : text_list) { - match_index << m_model->match(m_showed_index.isValid() - ? m_model->index(0,0,m_showed_index) - : m_model->index(0,0), - Qt::UserRole+1, - QVariant(txt), - -1, - Qt::MatchContains - | Qt::MatchRecursive); + showFlatResults(rankedSearch(text, m_showed_index)); +} + +/** + @brief ElementsCollectionWidget::rankedSearch + Run the collection search for @a text and return the element hits, best + first. The hits carry no model index, so a list that is not backed by the + collection model can show them too. + @param text : search text, "+" separating terms + @param within : restrict the search to this item's children, or the + whole collection when invalid + @return ranked hits, best first +*/ +QVector ElementsCollectionWidget::rankedSearch( + const QString &text, + const QModelIndex &within) +{ + QVector hits; + if (!m_model || text.size() < 3) { + return hits; } - for(QModelIndex index : match_index) - showAndExpandItem(index); + const QStringList terms = text.split("+", Qt::SkipEmptyParts); + QModelIndexList matches; + for (const QString &term : terms) { + matches << m_model->match(within.isValid() + ? m_model->index(0, 0, within) + : m_model->index(0, 0), + Qt::UserRole+1, + QVariant(term), + -1, + Qt::MatchContains + | Qt::MatchRecursive); + } + + const QString needle = terms.value(0).toLower(); + QVector> scored; + QSet seen; + + for (const QModelIndex &index : std::as_const(matches)) + { + ElementCollectionItem *eci = elementCollectionItemForIndex(index); + if (!(eci && eci->isElement())) { + continue; + } + const QString path = eci->collectionPath(); + //match() is run once per "+"-separated term, so the same element + //can arrive several times. + if (path.isEmpty() || seen.contains(path)) { + continue; + } + seen.insert(path); + + ElementSearchHit hit; + hit.path = path; + hit.name = index.data(Qt::DisplayRole).toString(); + hit.icon = qvariant_cast(index.data(Qt::DecorationRole)); + //Where it lives, so two similarly-named symbols are tellable apart + QStringList parts; + for (QModelIndex p = index.parent(); p.isValid(); p = p.parent()) { + parts.prepend(p.data(Qt::DisplayRole).toString()); + } + hit.folder = parts.join(QStringLiteral(" / ")); + + const QString hay = index.data(Qt::UserRole + 1).toString().toLower(); + scored.append({rankMatch(needle, hit.name, hay), hit}); + } + + std::stable_sort(scored.begin(), scored.end(), + [](const QPair &a, + const QPair &b) { + return a.first > b.first; + }); + hits.reserve(scored.size()); + for (const auto &p : std::as_const(scored)) { + hits.append(p.second); + } + return hits; +} + +/** + @brief ElementsCollectionWidget::rankMatch + Score a hit so the list can be ordered by how well it matches. + + The tree search treats every hit equally, which is fine when they stay in + place but useless in a ranked list: typing "diode" should not put an + element whose *description* mentions diodes above one actually called + "Diode". Name beats element-info field, earlier beats later, shorter beats + longer. + @param needle : lower-cased search text + @param name : the element's display name + @param haystack : the full indexed string (name + every info field) + @return a score, higher is better +*/ +int ElementsCollectionWidget::rankMatch(const QString &needle, + const QString &name, + const QString &haystack) +{ + const QString n = name.toLower(); + int score = 0; + + if (n == needle) { + score = 1000; + } else if (n.startsWith(needle)) { + score = 800; + } else if (n.contains(needle)) { + score = 600 - qMin(n.indexOf(needle), 99); + } else if (haystack.contains(needle)) { + //Matched only on an info field -- manufacturer, reference, + //description. Still a real hit, just a weaker one. + score = 300; + } + //Among equally-placed hits prefer the shorter name: "Diode" over + //"Diode Zener bidirectional". + return score - qMin(n.size(), 99); +} + +/** + @brief ElementsCollectionWidget::showFlatResults + Replace the tree with a flat list of @a hits, in the order given. + @param hits : from rankedSearch() +*/ +void ElementsCollectionWidget::showFlatResults(const QVector &hits) +{ + m_search_model->clear(); + + for (const ElementSearchHit &hit : hits) + { + auto *item = new QStandardItem(hit.icon, hit.name); + item->setEditable(false); + item->setToolTip(hit.folder); + item->setData(hit.path, Qt::UserRole + 2); + m_search_model->appendRow(item); + } + + m_tab_widget->hide(); + m_search_results->show(); +} + +/** + @brief ElementsCollectionWidget::clearFlatResults + Put the tree back when the search field is emptied. +*/ +void ElementsCollectionWidget::clearFlatResults() +{ + m_search_model->clear(); + m_search_results->hide(); + m_tab_widget->show(); } /** diff --git a/sources/ElementsCollection/elementscollectionwidget.h b/sources/ElementsCollection/elementscollectionwidget.h index b6997b7a8..7c46a6e51 100644 --- a/sources/ElementsCollection/elementscollectionwidget.h +++ b/sources/ElementsCollection/elementscollectionwidget.h @@ -43,6 +43,22 @@ class ElementsTreeView; and all action needed to use this widget. This is the element collection widget used in the diagram editor. */ +class QListView; +class QStandardItemModel; + +/** + @brief One ranked hit from a collection search. + Carries everything a result row needs, so a list that is not backed by + the collection model can show it. +*/ +struct ElementSearchHit +{ + QString path; ///< collection path, enough to build an ElementsLocation + QString name; ///< display name + QString folder; ///< where it lives, for telling similar names apart + QIcon icon; +}; + class ElementsCollectionWidget : public QWidget { Q_OBJECT @@ -56,6 +72,8 @@ class ElementsCollectionWidget : public QWidget void removeProject (QETProject *project); void highlightUnusedElement(); void setCurrentLocation(const ElementsLocation &location); + QVector rankedSearch(const QString &text, + const QModelIndex &within = QModelIndex()); protected: void leaveEvent(QEvent *event) override; @@ -102,6 +120,10 @@ class ElementsCollectionWidget : public QWidget private: void locationWasSaved(const ElementsLocation& location); void activateIndex(const QModelIndex &index); + void showFlatResults(const QVector &hits); + void clearFlatResults(); + static int rankMatch(const QString &needle, const QString &name, + const QString &haystack); private: @@ -113,6 +135,9 @@ class ElementsCollectionWidget : public QWidget ElementsTreeView *m_tree_view; ElementsTreeView *m_macros_tree_view = nullptr; QTabWidget *m_tab_widget = nullptr; + /// Flat ranked results, shown in place of the tree while searching + QListView *m_search_results = nullptr; + QStandardItemModel *m_search_model = nullptr; QVBoxLayout *m_main_vlayout; QMenu *m_context_menu; QModelIndex m_index_at_context_menu;