/* SPDX-FileCopyrightText: 2016 Eike Hein SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL */ #include "notificationgroupingproxymodel_p.h" #include #include "notifications.h" using namespace NotificationManager; NotificationGroupingProxyModel::NotificationGroupingProxyModel(QObject *parent) : QAbstractProxyModel(parent) { } NotificationGroupingProxyModel::~NotificationGroupingProxyModel() = default; bool NotificationGroupingProxyModel::appsMatch(const QModelIndex &a, const QModelIndex &b) const { const QString aName = a.data(Notifications::ApplicationNameRole).toString(); const QString bName = b.data(Notifications::ApplicationNameRole).toString(); const QString aDesktopEntry = a.data(Notifications::DesktopEntryRole).toString(); const QString bDesktopEntry = b.data(Notifications::DesktopEntryRole).toString(); const QString aOriginName = a.data(Notifications::OriginNameRole).toString(); const QString bOriginName = b.data(Notifications::OriginNameRole).toString(); return !aName.isEmpty() && aName == bName && aDesktopEntry == bDesktopEntry && aOriginName == bOriginName; } bool NotificationGroupingProxyModel::isGroup(int row) const { if (row < 0 || row >= rowMap.count()) { return false; } return (rowMap.at(row)->count() > 1); } bool NotificationGroupingProxyModel::tryToGroup(const QModelIndex &sourceIndex, bool silent) { // Meat of the matter: Try to add this source row to a sub-list with source rows // associated with the same application. for (int i = 0; i < rowMap.count(); ++i) { const QModelIndex &groupRep = sourceModel()->index(rowMap.at(i)->constFirst(), 0); // Don't match a row with itself. if (sourceIndex == groupRep) { continue; } if (appsMatch(sourceIndex, groupRep)) { const QModelIndex parent = index(i, 0); if (!silent) { const int newIndex = rowMap.at(i)->count(); if (newIndex == 1) { beginInsertRows(parent, 0, 1); } else { beginInsertRows(parent, newIndex, newIndex); } } rowMap[i]->append(sourceIndex.row()); if (!silent) { endInsertRows(); Q_EMIT dataChanged(parent, parent); } return true; } } return false; } void NotificationGroupingProxyModel::adjustMap(int anchor, int delta) { for (int i = 0; i < rowMap.count(); ++i) { QList *sourceRows = rowMap.at(i); for (auto it = sourceRows->begin(); it != sourceRows->end(); ++it) { if ((*it) >= anchor) { *it += delta; } } } } void NotificationGroupingProxyModel::rebuildMap() { qDeleteAll(rowMap); rowMap.clear(); const int rows = sourceModel()->rowCount(); rowMap.reserve(rows); for (int i = 0; i < rows; ++i) { rowMap.append(new QList{i}); } checkGrouping(true /* silent */); } void NotificationGroupingProxyModel::checkGrouping(bool silent) { for (int i = (rowMap.count()) - 1; i >= 0; --i) { if (isGroup(i)) { continue; } // FIXME support skip grouping hint, maybe? // The new grouping keeps every notification separate, still, so perhaps we don't need to if (tryToGroup(sourceModel()->index(rowMap.at(i)->constFirst(), 0), silent)) { beginRemoveRows(QModelIndex(), i, i); delete rowMap.takeAt(i); // Safe since we're iterating backwards. endRemoveRows(); } } } void NotificationGroupingProxyModel::formGroupFor(const QModelIndex &index) { // Already in group or a group. if (index.parent().isValid() || isGroup(index.row())) { return; } // We need to grab a source index as we may invalidate the index passed // in through grouping. const QModelIndex &sourceTarget = mapToSource(index); for (int i = (rowMap.count() - 1); i >= 0; --i) { const QModelIndex &sourceIndex = sourceModel()->index(rowMap.at(i)->constFirst(), 0); if (!appsMatch(sourceTarget, sourceIndex)) { continue; } if (tryToGroup(sourceIndex)) { beginRemoveRows(QModelIndex(), i, i); delete rowMap.takeAt(i); // Safe since we're iterating backwards. endRemoveRows(); } } } void NotificationGroupingProxyModel::setSourceModel(QAbstractItemModel *sourceModel) { if (sourceModel == QAbstractProxyModel::sourceModel()) { return; } beginResetModel(); if (QAbstractProxyModel::sourceModel()) { QAbstractProxyModel::sourceModel()->disconnect(this); } QAbstractProxyModel::setSourceModel(sourceModel); if (sourceModel) { rebuildMap(); // FIXME move this stuff into separate slot methods connect(sourceModel, &QAbstractItemModel::rowsInserted, this, [this](const QModelIndex &parent, int start, int end) { if (parent.isValid()) { return; } adjustMap(start, (end - start) + 1); for (int i = start; i <= end; ++i) { if (!tryToGroup(this->sourceModel()->index(i, 0))) { beginInsertRows(QModelIndex(), rowMap.count(), rowMap.count()); rowMap.append(new QList{i}); endInsertRows(); } } checkGrouping(); }); connect(sourceModel, &QAbstractItemModel::rowsAboutToBeRemoved, this, [this](const QModelIndex &parent, int first, int last) { if (parent.isValid()) { return; } for (int i = first; i <= last; ++i) { for (int j = 0; j < rowMap.count(); ++j) { const QList *sourceRows = rowMap.at(j); const int mapIndex = sourceRows->indexOf(i); if (mapIndex != -1) { // Remove top-level item. if (sourceRows->count() == 1) { beginRemoveRows(QModelIndex(), j, j); delete rowMap.takeAt(j); endRemoveRows(); // Dissolve group. } else if (sourceRows->count() == 2) { const QModelIndex parent = index(j, 0); beginRemoveRows(parent, 0, 1); rowMap[j]->remove(mapIndex); endRemoveRows(); // We're no longer a group parent. Q_EMIT dataChanged(parent, parent); // Remove group member. } else { const QModelIndex parent = index(j, 0); beginRemoveRows(parent, mapIndex, mapIndex); rowMap[j]->remove(mapIndex); endRemoveRows(); // Various roles of the parent evaluate child data, and the // child list has changed. Q_EMIT dataChanged(parent, parent); // Signal children count change for all other items in the group. Q_EMIT dataChanged(index(0, 0, parent), index(rowMap.count() - 1, 0, parent), {Notifications::GroupChildrenCountRole}); } break; } } } }); connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, [this](const QModelIndex &parent, int start, int end) { if (parent.isValid()) { return; } adjustMap(start + 1, -((end - start) + 1)); checkGrouping(); }); connect(sourceModel, &QAbstractItemModel::modelAboutToBeReset, this, &NotificationGroupingProxyModel::beginResetModel); connect(sourceModel, &QAbstractItemModel::modelReset, this, [this] { rebuildMap(); endResetModel(); }); connect(sourceModel, &QAbstractItemModel::dataChanged, this, [this](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) { for (int i = topLeft.row(); i <= bottomRight.row(); ++i) { const QModelIndex &sourceIndex = this->sourceModel()->index(i, 0); QModelIndex proxyIndex = mapFromSource(sourceIndex); if (!proxyIndex.isValid()) { return; } const QModelIndex parent = proxyIndex.parent(); // If a child item changes, its parent may need an update as well as many of // the data roles evaluate child data. See data(). // TODO: Some roles do not need to bubble up as they fall through to the first // child in data(); it _might_ be worth adding constraints here later. if (parent.isValid()) { Q_EMIT dataChanged(parent, parent, roles); } Q_EMIT dataChanged(proxyIndex, proxyIndex, roles); } }); } endResetModel(); } QModelIndex NotificationGroupingProxyModel::index(int row, int column, const QModelIndex &parent) const { if (row < 0 || column != 0) { return QModelIndex(); } if (parent.isValid() && row < rowMap.at(parent.row())->count()) { return createIndex(row, column, rowMap.at(parent.row())); } if (row < rowMap.count()) { return createIndex(row, column, nullptr); } return QModelIndex(); } QModelIndex NotificationGroupingProxyModel::parent(const QModelIndex &child) const { if (child.internalPointer() == nullptr) { return QModelIndex(); } else { const int parentRow = rowMap.indexOf(static_cast *>(child.internalPointer())); if (parentRow != -1) { return index(parentRow, 0); } // If we were asked to find the parent for an internalPointer we can't // locate, we have corrupted data: This should not happen. Q_ASSERT(parentRow != -1); } return QModelIndex(); } QModelIndex NotificationGroupingProxyModel::mapFromSource(const QModelIndex &sourceIndex) const { if (!sourceIndex.isValid() || sourceIndex.model() != sourceModel()) { return QModelIndex(); } for (int i = 0; i < rowMap.count(); ++i) { const QList *sourceRows = rowMap.at(i); const int childIndex = sourceRows->indexOf(sourceIndex.row()); const QModelIndex parent = index(i, 0); if (childIndex == 0) { // If the sub-list we found the source row in is larger than 1 (i.e. part // of a group, map to the logical child item instead of the parent item // the source row also stands in for. The parent is therefore unreachable // from mapToSource(). if (isGroup(i)) { return index(0, 0, parent); // Otherwise map to the top-level item. } else { return parent; } } else if (childIndex != -1) { return index(childIndex, 0, parent); } } return QModelIndex(); } QModelIndex NotificationGroupingProxyModel::mapToSource(const QModelIndex &proxyIndex) const { if (!proxyIndex.isValid() || proxyIndex.model() != this || !sourceModel()) { return QModelIndex(); } const QModelIndex &parent = proxyIndex.parent(); if (parent.isValid()) { if (parent.row() < 0 || parent.row() >= rowMap.count()) { return QModelIndex(); } return sourceModel()->index(rowMap.at(parent.row())->at(proxyIndex.row()), 0); } else { // Group parents items therefore equate to the first child item; the source // row logically appears twice in the proxy. // mapFromSource() is not required to handle this well (consider proxies can // filter out rows, too) and opts to map to the child item, as the group parent // has its Qt::DisplayRole mangled by data(), and it's more useful for trans- // lating dataChanged() from the source model. // NOTE we changed that to be last if (rowMap.isEmpty()) { // FIXME // How can this happen? (happens when closing a group) return QModelIndex(); } return sourceModel()->index(rowMap.at(proxyIndex.row())->constLast(), 0); } return QModelIndex(); } int NotificationGroupingProxyModel::rowCount(const QModelIndex &parent) const { if (!sourceModel()) { return 0; } if (parent.isValid() && parent.model() == this) { // Don't return row count for top-level item at child row: Group members // never have further children of their own. if (parent.parent().isValid()) { return 0; } if (parent.row() < 0 || parent.row() >= rowMap.count()) { return 0; } const int rowCount = rowMap.at(parent.row())->count(); // If this sub-list in the map only has one entry, it's a plain item, not // parent to a group. if (rowCount == 1) { return 0; } else { return rowCount; } } return rowMap.count(); } bool NotificationGroupingProxyModel::hasChildren(const QModelIndex &parent) const { if ((parent.model() && parent.model() != this) || !sourceModel()) { return false; } return rowCount(parent); } int NotificationGroupingProxyModel::columnCount(const QModelIndex &parent) const { Q_UNUSED(parent) return 1; } QVariant NotificationGroupingProxyModel::data(const QModelIndex &proxyIndex, int role) const { if (!proxyIndex.isValid() || proxyIndex.model() != this || !sourceModel()) { return QVariant(); } const QModelIndex &parent = proxyIndex.parent(); const bool isGroup = (!parent.isValid() && this->isGroup(proxyIndex.row())); // For group parent items, this will map to the last child task. const QModelIndex &sourceIndex = mapToSource(proxyIndex); if (!sourceIndex.isValid()) { return QVariant(); } if (isGroup) { switch (role) { case Notifications::IsGroupRole: return true; case Notifications::GroupChildrenCountRole: return rowCount(proxyIndex); case Notifications::IsInGroupRole: return false; // Combine all notifications into one for some basic grouping case Notifications::BodyRole: case Qt::AccessibleDescriptionRole: { QString body; for (int i = 0; i < rowCount(proxyIndex); ++i) { const QString stringData = index(i, 0, proxyIndex).data(role).toString(); if (!stringData.isEmpty()) { if (!body.isEmpty()) { body.append(QLatin1String("
")); } body.append(stringData); } } return body; } case Notifications::DesktopEntryRole: case Notifications::NotifyRcNameRole: case Notifications::OriginNameRole: for (int i = 0; i < rowCount(proxyIndex); ++i) { const QString stringData = index(i, 0, proxyIndex).data(role).toString(); if (!stringData.isEmpty()) { return stringData; } } return QString(); case Notifications::ConfigurableRole: // if there is any configurable child item for (int i = 0; i < rowCount(proxyIndex); ++i) { if (index(i, 0, proxyIndex).data(Notifications::ConfigurableRole).toBool()) { return true; } } return false; case Notifications::ClosableRole: // if there is any closable child item for (int i = 0; i < rowCount(proxyIndex); ++i) { if (index(i, 0, proxyIndex).data(Notifications::ClosableRole).toBool()) { return true; } } return false; } } else { switch (role) { case Notifications::IsGroupRole: return false; // So a notification knows with how many other items it is in a group case Notifications::GroupChildrenCountRole: if (proxyIndex.parent().isValid()) { return rowCount(proxyIndex.parent()); } break; case Notifications::IsInGroupRole: return parent.isValid(); } } return sourceIndex.data(role); }