mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-27 20:44:13 +02:00
Show collection search results as a ranked list
The Collections search hid non-matching rows, so the hits stayed spread through the expanded folders. Searching "diode" against the shipped collection left five levels of tree open, and the first visible hit was "Avalanche diode bidirectional". The same matches are now shown as a flat list that takes the tree's place while a search is active, best match first: exact name 1000 name starts with 800 name contains 600, less the match position info field only 300 less the name length, so "Diode" precedes "Diode Zener bidirectional" "diode" now gives Diode, diode-tube, Diode bridge, Diodes Module Pilot Wire, Photodiode. No new index: this ranks what match() already returns against Qt::UserRole+1, the string built at startup from the name and every element-info field. An element matching several "+" terms is listed once, and each row's tooltip is its folder, so similar names can be told apart. Double click or Enter on a result places it, as in the tree. Down from the search field moves into the results, so searching and placing needs no mouse. Emptying the field brings the tree back. The query and ranking live in rankedSearch(), which returns hits without model indexes, so another list can reuse them. Discussion #676. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG (cherry picked from commit 3d6a280f9608758527ef0ca2de7223a19dfbfa45)
This commit is contained in:
@@ -37,6 +37,10 @@
|
||||
#include <QDesktopServices>
|
||||
#include <QSettings>
|
||||
#include <QShortcut>
|
||||
#include <QListView>
|
||||
#include <QStandardItemModel>
|
||||
#include <QSet>
|
||||
#include <algorithm>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
@@ -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<ElementSearchHit> ElementsCollectionWidget::rankedSearch(
|
||||
const QString &text,
|
||||
const QModelIndex &within)
|
||||
{
|
||||
QVector<ElementSearchHit> 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<QPair<int, ElementSearchHit>> scored;
|
||||
QSet<QString> 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<QIcon>(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<int, ElementSearchHit> &a,
|
||||
const QPair<int, ElementSearchHit> &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<ElementSearchHit> &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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user