| 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 | |