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 | |
24 | using 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 | */ |
32 | class SortProxyModel : public QSortFilterProxyModel |
33 | { |
34 | Q_OBJECT |
35 | |
36 | public: |
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 | |
53 | protected: |
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 | |
88 | public: |
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 | */ |
102 | class CategoryDistributionProxyModel : public QSortFilterProxyModel |
103 | { |
104 | Q_OBJECT |
105 | |
106 | public: |
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 | |
141 | Q_SIGNALS: |
142 | void limitChanged(); |
143 | |
144 | protected: |
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 | |
185 | private: |
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 | */ |
199 | class HideRootLevelProxyModel : public QSortFilterProxyModel |
200 | { |
201 | Q_OBJECT |
202 | |
203 | public: |
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 | |
219 | protected: |
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 | |
227 | private: |
228 | QAbstractItemModel *m_treeModel = nullptr; |
229 | }; |
230 | |
231 | class KRunner::ResultsModelPrivate |
232 | { |
233 | public: |
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 | |
252 | ResultsModel::ResultsModel(QObject *parent) |
253 | : ResultsModel(KConfigGroup(), KConfigGroup(), parent) |
254 | { |
255 | } |
256 | ResultsModel::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 | |
299 | ResultsModel::~ResultsModel() = default; |
300 | |
301 | void ResultsModel::setFavoriteIds(const QStringList &ids) |
302 | { |
303 | d->resultsModel->m_favoriteIds = ids; |
304 | Q_EMIT favoriteIdsChanged(); |
305 | } |
306 | |
307 | QStringList ResultsModel::favoriteIds() const |
308 | { |
309 | return d->resultsModel->m_favoriteIds; |
310 | } |
311 | |
312 | QString ResultsModel::queryString() const |
313 | { |
314 | return d->resultsModel->queryString(); |
315 | } |
316 | |
317 | void ResultsModel::setQueryString(const QString &queryString) |
318 | { |
319 | d->resultsModel->setQueryString(queryString, runner: singleRunner()); |
320 | } |
321 | |
322 | int ResultsModel::limit() const |
323 | { |
324 | return d->distributionModel->limit(); |
325 | } |
326 | |
327 | void ResultsModel::setLimit(int limit) |
328 | { |
329 | d->distributionModel->setLimit(limit); |
330 | } |
331 | |
332 | void ResultsModel::resetLimit() |
333 | { |
334 | setLimit(0); |
335 | } |
336 | |
337 | bool ResultsModel::querying() const |
338 | { |
339 | return d->resultsModel->querying(); |
340 | } |
341 | |
342 | QString ResultsModel::singleRunner() const |
343 | { |
344 | return d->runner ? d->runner->id() : QString(); |
345 | } |
346 | |
347 | void 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 | |
360 | KPluginMetaData ResultsModel::singleRunnerMetaData() const |
361 | { |
362 | return d->runner ? d->runner->metadata() : KPluginMetaData(); |
363 | } |
364 | |
365 | QHash<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 | |
378 | void ResultsModel::clear() |
379 | { |
380 | d->resultsModel->clear(); |
381 | } |
382 | |
383 | bool 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 | |
393 | bool 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 | |
403 | QMimeData *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 | |
411 | KRunner::RunnerManager *ResultsModel::runnerManager() const |
412 | { |
413 | return d->resultsModel->runnerManager(); |
414 | } |
415 | |
416 | KRunner::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 | |