1/*
2 * This file is part of the KDE Milou Project
3 * SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@broulik.de>
4 * SPDX-FileCopyrightText: 2023 Alexander Lohnau <alexander.lohnau@gmx.de>
5 *
6 * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
7 *
8 */
9
10#include "resultsmodel.h"
11
12#include "runnerresultsmodel_p.h"
13
14#include <QIdentityProxyModel>
15#include <QPointer>
16
17#include <KConfigGroup>
18#include <KDescendantsProxyModel>
19#include <KModelIndexProxyMapper>
20#include <KRunner/AbstractRunner>
21#include <QTimer>
22#include <cmath>
23
24using namespace KRunner;
25
26/**
27 * Sorts the matches and categories by their type and relevance
28 *
29 * A category gets type and relevance of the highest
30 * scoring match within.
31 */
32class SortProxyModel : public QSortFilterProxyModel
33{
34 Q_OBJECT
35
36public:
37 explicit SortProxyModel(QObject *parent)
38 : QSortFilterProxyModel(parent)
39 {
40 setDynamicSortFilter(true);
41 sort(column: 0, order: Qt::DescendingOrder);
42 }
43
44 void setQueryString(const QString &queryString)
45 {
46 const QStringList words = queryString.split(sep: QLatin1Char(' '), behavior: Qt::SkipEmptyParts);
47 if (m_words != words) {
48 m_words = words;
49 invalidate();
50 }
51 }
52
53protected:
54 bool lessThan(const QModelIndex &sourceA, const QModelIndex &sourceB) const override
55 {
56 bool isCategoryComparison = !sourceA.internalId() && !sourceB.internalId();
57 Q_ASSERT((bool)sourceA.internalId() == (bool)sourceB.internalId());
58 // Only check the favorite index if we compare categories. For individual matches, they will always be the same
59 if (isCategoryComparison) {
60 const int favoriteA = sourceA.data(arole: ResultsModel::FavoriteIndexRole).toInt();
61 const int favoriteB = sourceB.data(arole: ResultsModel::FavoriteIndexRole).toInt();
62 bool isFavoriteA = favoriteA != -1;
63 bool isFavoriteB = favoriteB != -1;
64 if (isFavoriteA && !isFavoriteB) {
65 return false;
66 } else if (!isFavoriteA && isFavoriteB) {
67 return true;
68 }
69
70 const int favoritesCount = sourceA.data(arole: ResultsModel::FavoriteCountRole).toInt();
71 const double favoriteAMultiplicationFactor = (favoriteA ? 1 + ((favoritesCount - favoriteA) * 0.2) : 1);
72 const double typeA = sourceA.data(arole: ResultsModel::CategoryRelevanceRole).toReal() * favoriteAMultiplicationFactor;
73 const double favoriteBMultiplicationFactor = (favoriteB ? 1 + ((favoritesCount - favoriteB) * 0.2) : 1);
74 const double typeB = sourceB.data(arole: ResultsModel::CategoryRelevanceRole).toReal() * favoriteBMultiplicationFactor;
75 return typeA < typeB;
76 }
77
78 const qreal relevanceA = sourceA.data(arole: ResultsModel::RelevanceRole).toReal();
79 const qreal relevanceB = sourceB.data(arole: ResultsModel::RelevanceRole).toReal();
80
81 if (!qFuzzyCompare(p1: relevanceA, p2: relevanceB)) {
82 return relevanceA < relevanceB;
83 }
84
85 return QSortFilterProxyModel::lessThan(source_left: sourceA, source_right: sourceB);
86 }
87
88public:
89 QStringList m_words;
90};
91
92/**
93 * Distributes the number of matches shown per category
94 *
95 * Each category may occupy a maximum of 1/(n+1) of the given @c limit,
96 * this means the further down you get, the less matches there are.
97 * There is at least one match shown per category.
98 *
99 * This model assumes the results to already be sorted
100 * descending by their relevance/score.
101 */
102class CategoryDistributionProxyModel : public QSortFilterProxyModel
103{
104 Q_OBJECT
105
106public:
107 explicit CategoryDistributionProxyModel(QObject *parent)
108 : QSortFilterProxyModel(parent)
109 {
110 }
111 void setSourceModel(QAbstractItemModel *sourceModel) override
112 {
113 if (this->sourceModel()) {
114 disconnect(sender: this->sourceModel(), signal: nullptr, receiver: this, member: nullptr);
115 }
116
117 QSortFilterProxyModel::setSourceModel(sourceModel);
118
119 if (sourceModel) {
120 connect(sender: sourceModel, signal: &QAbstractItemModel::rowsInserted, context: this, slot: &CategoryDistributionProxyModel::invalidateFilter);
121 connect(sender: sourceModel, signal: &QAbstractItemModel::rowsMoved, context: this, slot: &CategoryDistributionProxyModel::invalidateFilter);
122 connect(sender: sourceModel, signal: &QAbstractItemModel::rowsRemoved, context: this, slot: &CategoryDistributionProxyModel::invalidateFilter);
123 }
124 }
125
126 int limit() const
127 {
128 return m_limit;
129 }
130
131 void setLimit(int limit)
132 {
133 if (m_limit == limit) {
134 return;
135 }
136 m_limit = limit;
137 invalidateFilter();
138 Q_EMIT limitChanged();
139 }
140
141Q_SIGNALS:
142 void limitChanged();
143
144protected:
145 bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override
146 {
147 if (m_limit <= 0) {
148 return true;
149 }
150
151 if (!sourceParent.isValid()) {
152 return true;
153 }
154
155 const int categoryCount = sourceModel()->rowCount();
156
157 int maxItemsInCategory = m_limit;
158
159 if (categoryCount > 1) {
160 int itemsBefore = 0;
161 for (int i = 0; i <= sourceParent.row(); ++i) {
162 const int itemsInCategory = sourceModel()->rowCount(parent: sourceModel()->index(row: i, column: 0));
163
164 // Take into account that every category gets at least one item shown
165 const int availableSpace = m_limit - itemsBefore - std::ceil(x: m_limit / qreal(categoryCount));
166
167 // The further down the category is the less relevant it is and the less space it my occupy
168 // First category gets max half the total limit, second category a third, etc
169 maxItemsInCategory = std::min(a: availableSpace, b: int(std::ceil(x: m_limit / qreal(i + 2))));
170
171 // At least show one item per category
172 maxItemsInCategory = std::max(a: 1, b: maxItemsInCategory);
173
174 itemsBefore += std::min(a: itemsInCategory, b: maxItemsInCategory);
175 }
176 }
177
178 if (sourceRow >= maxItemsInCategory) {
179 return false;
180 }
181
182 return true;
183 }
184
185private:
186 // if you change this, update the default in resetLimit()
187 int m_limit = 0;
188};
189
190/**
191 * This model hides the root items of data originally in a tree structure
192 *
193 * KDescendantsProxyModel collapses the items but keeps all items in tact.
194 * The root items of the RunnerMatchesModel represent the individual cateories
195 * which we don't want in the resulting flat list.
196 * This model maps the items back to the given @c treeModel and filters
197 * out any item with an invalid parent, i.e. "on the root level"
198 */
199class HideRootLevelProxyModel : public QSortFilterProxyModel
200{
201 Q_OBJECT
202
203public:
204 explicit HideRootLevelProxyModel(QObject *parent)
205 : QSortFilterProxyModel(parent)
206 {
207 }
208
209 QAbstractItemModel *treeModel() const
210 {
211 return m_treeModel;
212 }
213 void setTreeModel(QAbstractItemModel *treeModel)
214 {
215 m_treeModel = treeModel;
216 invalidateFilter();
217 }
218
219protected:
220 bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override
221 {
222 KModelIndexProxyMapper mapper(sourceModel(), m_treeModel);
223 const QModelIndex treeIdx = mapper.mapLeftToRight(index: sourceModel()->index(row: sourceRow, column: 0, parent: sourceParent));
224 return treeIdx.parent().isValid();
225 }
226
227private:
228 QAbstractItemModel *m_treeModel = nullptr;
229};
230
231class KRunner::ResultsModelPrivate
232{
233public:
234 explicit ResultsModelPrivate(const KConfigGroup &configGroup, const KConfigGroup &stateConfigGroup, ResultsModel *q)
235 : q(q)
236 , resultsModel(new RunnerResultsModel(configGroup, stateConfigGroup, q))
237 {
238 }
239
240 ResultsModel *q;
241
242 QPointer<KRunner::AbstractRunner> runner = nullptr;
243
244 RunnerResultsModel *const resultsModel;
245 SortProxyModel *const sortModel = new SortProxyModel(q);
246 CategoryDistributionProxyModel *const distributionModel = new CategoryDistributionProxyModel(q);
247 KDescendantsProxyModel *const flattenModel = new KDescendantsProxyModel(q);
248 HideRootLevelProxyModel *const hideRootModel = new HideRootLevelProxyModel(q);
249 const KModelIndexProxyMapper mapper{q, resultsModel};
250};
251
252ResultsModel::ResultsModel(QObject *parent)
253 : ResultsModel(KConfigGroup(), KConfigGroup(), parent)
254{
255}
256ResultsModel::ResultsModel(const KConfigGroup &configGroup, const KConfigGroup &stateConfigGroup, QObject *parent)
257 : QSortFilterProxyModel(parent)
258 , d(new ResultsModelPrivate(configGroup, stateConfigGroup, this))
259{
260 connect(sender: d->resultsModel, signal: &RunnerResultsModel::queryStringChanged, context: this, slot: &ResultsModel::queryStringChanged);
261 connect(sender: d->resultsModel, signal: &RunnerResultsModel::queryingChanged, context: this, slot: &ResultsModel::queryingChanged);
262 connect(sender: d->resultsModel, signal: &RunnerResultsModel::queryStringChangeRequested, context: this, slot: &ResultsModel::queryStringChangeRequested);
263
264 // The matches for the old query string remain on display until the first set of matches arrive for the new query string.
265 // Therefore we must not update the query string inside RunnerResultsModel exactly when the query string changes, otherwise it would
266 // re-sort the old query string matches based on the new query string.
267 // So we only make it aware of the query string change at the time when we receive the first set of matches for the new query string.
268 connect(sender: d->resultsModel, signal: &RunnerResultsModel::matchesChanged, context: this, slot: [this]() {
269 d->sortModel->setQueryString(queryString());
270 });
271
272 connect(sender: d->distributionModel, signal: &CategoryDistributionProxyModel::limitChanged, context: this, slot: &ResultsModel::limitChanged);
273
274 // The data flows as follows:
275 // - RunnerResultsModel
276 // - SortProxyModel
277 // - CategoryDistributionProxyModel
278 // - KDescendantsProxyModel
279 // - HideRootLevelProxyModel
280
281 d->sortModel->setSourceModel(d->resultsModel);
282
283 d->distributionModel->setSourceModel(d->sortModel);
284
285 d->flattenModel->setSourceModel(d->distributionModel);
286
287 d->hideRootModel->setSourceModel(d->flattenModel);
288 d->hideRootModel->setTreeModel(d->resultsModel);
289
290 setSourceModel(d->hideRootModel);
291
292 // Initialize the runners, this will speed the first query up.
293 // While there were lots of optimizations, instantiating plugins, creating threads and AbstractRunner::init is still heavy work
294 QTimer::singleShot(interval: 0, receiver: this, slot: [this]() {
295 runnerManager()->runners();
296 });
297}
298
299ResultsModel::~ResultsModel() = default;
300
301void ResultsModel::setFavoriteIds(const QStringList &ids)
302{
303 d->resultsModel->m_favoriteIds = ids;
304 Q_EMIT favoriteIdsChanged();
305}
306
307QStringList ResultsModel::favoriteIds() const
308{
309 return d->resultsModel->m_favoriteIds;
310}
311
312QString ResultsModel::queryString() const
313{
314 return d->resultsModel->queryString();
315}
316
317void ResultsModel::setQueryString(const QString &queryString)
318{
319 d->resultsModel->setQueryString(queryString, runner: singleRunner());
320}
321
322int ResultsModel::limit() const
323{
324 return d->distributionModel->limit();
325}
326
327void ResultsModel::setLimit(int limit)
328{
329 d->distributionModel->setLimit(limit);
330}
331
332void ResultsModel::resetLimit()
333{
334 setLimit(0);
335}
336
337bool ResultsModel::querying() const
338{
339 return d->resultsModel->querying();
340}
341
342QString ResultsModel::singleRunner() const
343{
344 return d->runner ? d->runner->id() : QString();
345}
346
347void ResultsModel::setSingleRunner(const QString &runnerId)
348{
349 if (runnerId == singleRunner()) {
350 return;
351 }
352 if (runnerId.isEmpty()) {
353 d->runner = nullptr;
354 } else {
355 d->runner = runnerManager()->runner(pluginId: runnerId);
356 }
357 Q_EMIT singleRunnerChanged();
358}
359
360KPluginMetaData ResultsModel::singleRunnerMetaData() const
361{
362 return d->runner ? d->runner->metadata() : KPluginMetaData();
363}
364
365QHash<int, QByteArray> ResultsModel::roleNames() const
366{
367 auto names = QAbstractProxyModel::roleNames();
368 names[IdRole] = QByteArrayLiteral("matchId"); // "id" is QML-reserved
369 names[EnabledRole] = QByteArrayLiteral("enabled");
370 names[CategoryRole] = QByteArrayLiteral("category");
371 names[SubtextRole] = QByteArrayLiteral("subtext");
372 names[UrlsRole] = QByteArrayLiteral("urls");
373 names[ActionsRole] = QByteArrayLiteral("actions");
374 names[MultiLineRole] = QByteArrayLiteral("multiLine");
375 return names;
376}
377
378void ResultsModel::clear()
379{
380 d->resultsModel->clear();
381}
382
383bool ResultsModel::run(const QModelIndex &idx)
384{
385 KModelIndexProxyMapper mapper(this, d->resultsModel);
386 const QModelIndex resultsIdx = mapper.mapLeftToRight(index: idx);
387 if (!resultsIdx.isValid()) {
388 return false;
389 }
390 return d->resultsModel->run(idx: resultsIdx);
391}
392
393bool ResultsModel::runAction(const QModelIndex &idx, int actionNumber)
394{
395 KModelIndexProxyMapper mapper(this, d->resultsModel);
396 const QModelIndex resultsIdx = mapper.mapLeftToRight(index: idx);
397 if (!resultsIdx.isValid()) {
398 return false;
399 }
400 return d->resultsModel->runAction(idx: resultsIdx, actionNumber);
401}
402
403QMimeData *ResultsModel::getMimeData(const QModelIndex &idx) const
404{
405 if (auto resultIdx = d->mapper.mapLeftToRight(index: idx); resultIdx.isValid()) {
406 return runnerManager()->mimeDataForMatch(match: d->resultsModel->fetchMatch(idx: resultIdx));
407 }
408 return nullptr;
409}
410
411KRunner::RunnerManager *ResultsModel::runnerManager() const
412{
413 return d->resultsModel->runnerManager();
414}
415
416KRunner::QueryMatch ResultsModel::getQueryMatch(const QModelIndex &idx) const
417{
418 const QModelIndex resultIdx = d->mapper.mapLeftToRight(index: idx);
419 return resultIdx.isValid() ? d->resultsModel->fetchMatch(idx: resultIdx) : QueryMatch();
420}
421
422#include "moc_resultsmodel.cpp"
423#include "resultsmodel.moc"
424

source code of krunner/src/model/resultsmodel.cpp