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

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