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 | 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 | |
81 | public: |
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 | */ |
95 | class CategoryDistributionProxyModel : public QSortFilterProxyModel |
96 | { |
97 | Q_OBJECT |
98 | |
99 | public: |
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 | |
134 | Q_SIGNALS: |
135 | void limitChanged(); |
136 | |
137 | protected: |
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 | |
178 | private: |
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 | */ |
192 | class HideRootLevelProxyModel : public QSortFilterProxyModel |
193 | { |
194 | Q_OBJECT |
195 | |
196 | public: |
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 | |
212 | protected: |
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 | |
220 | private: |
221 | QAbstractItemModel *m_treeModel = nullptr; |
222 | }; |
223 | |
224 | class KRunner::ResultsModelPrivate |
225 | { |
226 | public: |
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 | |
245 | ResultsModel::ResultsModel(QObject *parent) |
246 | : ResultsModel(KConfigGroup(), KConfigGroup(), parent) |
247 | { |
248 | } |
249 | ResultsModel::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 | |
295 | ResultsModel::~ResultsModel() = default; |
296 | |
297 | void ResultsModel::setFavoriteIds(const QStringList &ids) |
298 | { |
299 | d->resultsModel->m_favoriteIds = ids; |
300 | Q_EMIT favoriteIdsChanged(); |
301 | } |
302 | |
303 | QStringList ResultsModel::favoriteIds() const |
304 | { |
305 | return d->resultsModel->m_favoriteIds; |
306 | } |
307 | |
308 | QString ResultsModel::queryString() const |
309 | { |
310 | return d->resultsModel->queryString(); |
311 | } |
312 | |
313 | void ResultsModel::setQueryString(const QString &queryString) |
314 | { |
315 | d->resultsModel->setQueryString(queryString, runner: singleRunner()); |
316 | } |
317 | |
318 | int ResultsModel::limit() const |
319 | { |
320 | return d->distributionModel->limit(); |
321 | } |
322 | |
323 | void ResultsModel::setLimit(int limit) |
324 | { |
325 | d->distributionModel->setLimit(limit); |
326 | } |
327 | |
328 | void ResultsModel::resetLimit() |
329 | { |
330 | setLimit(0); |
331 | } |
332 | |
333 | bool ResultsModel::querying() const |
334 | { |
335 | return runnerManager()->querying(); |
336 | } |
337 | |
338 | QString ResultsModel::singleRunner() const |
339 | { |
340 | return d->runner ? d->runner->id() : QString(); |
341 | } |
342 | |
343 | void 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 | |
356 | KPluginMetaData ResultsModel::singleRunnerMetaData() const |
357 | { |
358 | return d->runner ? d->runner->metadata() : KPluginMetaData(); |
359 | } |
360 | |
361 | QHash<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 | |
374 | void ResultsModel::clear() |
375 | { |
376 | d->resultsModel->clear(); |
377 | } |
378 | |
379 | bool 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 | |
389 | bool 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 | |
399 | QMimeData *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 | |
407 | KRunner::RunnerManager *ResultsModel::runnerManager() const |
408 | { |
409 | return d->resultsModel->runnerManager(); |
410 | } |
411 | |
412 | KRunner::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 | |
418 | void 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 | |