From dd7f087fac9df188356daf789abf80bb5420697f Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 18 Apr 2026 12:33:11 +0200 Subject: [PATCH] Add category filter panel to the app drawer A panel between the drawer and the power panel lists the XDG categories installed on the device as icon-and-label tiles. Tapping one filters the app list; text search still applies on top. The filter resets each time the drawer opens. Audio, Video and Settings fold into their parent groups to match Kickoff. --- containments/homescreens/folio/CMakeLists.txt | 1 + .../folio/applicationlistmodel.cpp | 98 ++++++++- .../homescreens/folio/applicationlistmodel.h | 20 ++ .../homescreens/folio/folioapplication.cpp | 6 + .../homescreens/folio/folioapplication.h | 4 + .../homescreens/folio/qml/CategoryPanel.qml | 187 ++++++++++++++++++ containments/homescreens/folio/qml/main.qml | 43 +++- 7 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 containments/homescreens/folio/qml/CategoryPanel.qml diff --git a/containments/homescreens/folio/CMakeLists.txt b/containments/homescreens/folio/CMakeLists.txt index 4d62e0d3..2acc25a9 100644 --- a/containments/homescreens/folio/CMakeLists.txt +++ b/containments/homescreens/folio/CMakeLists.txt @@ -8,6 +8,7 @@ plasma_add_applet(org.kde.plasma.mobile.homescreen.folio qml/AppDrawer.qml qml/AppDrawerGrid.qml qml/AppDrawerHeader.qml + qml/CategoryPanel.qml qml/DelegateDragItem.qml qml/DelegateDropArea.qml qml/FavouritesBar.qml diff --git a/containments/homescreens/folio/applicationlistmodel.cpp b/containments/homescreens/folio/applicationlistmodel.cpp index 2943aa2a..130e1a1c 100644 --- a/containments/homescreens/folio/applicationlistmodel.cpp +++ b/containments/homescreens/folio/applicationlistmodel.cpp @@ -46,7 +46,11 @@ ApplicationListModel::~ApplicationListModel() = default; QHash ApplicationListModel::roleNames() const { - return {{DelegateRole, QByteArrayLiteral("delegate")}}; + return { + {DelegateRole, QByteArrayLiteral("delegate")}, + {NameRole, QByteArrayLiteral("name")}, + {CategoriesRole, QByteArrayLiteral("categories")}, + }; } void ApplicationListModel::sycocaDbChanged() @@ -157,6 +161,11 @@ QVariant ApplicationListModel::data(const QModelIndex &index, int role) const return QVariant(); } return delegate->application()->name(); + case CategoriesRole: + if (!delegate->application()) { + return QVariant(); + } + return QVariant::fromValue(delegate->application()->categories()); default: return QVariant(); } @@ -171,8 +180,54 @@ int ApplicationListModel::rowCount(const QModelIndex &parent) const return m_delegates.count(); } +// Sub-categories merged into their canonical parent, mirroring Kickoff's grouping. +static QString normalizeCategory(const QString &cat) +{ + if (cat == QLatin1String("Audio") || cat == QLatin1String("Video")) + return QStringLiteral("AudioVideo"); + if (cat == QLatin1String("Settings")) + return QStringLiteral("System"); + return cat; +} + +static const QSet &mainCategories() +{ + static const QSet s = { + QStringLiteral("AudioVideo"), + QStringLiteral("Development"), + QStringLiteral("Education"), + QStringLiteral("Game"), + QStringLiteral("Graphics"), + QStringLiteral("Network"), + QStringLiteral("Office"), + QStringLiteral("Science"), + QStringLiteral("System"), + QStringLiteral("Utility"), + }; + return s; +} + +QStringList ApplicationListModel::allCategories() const +{ + QSet found; + for (const auto &del : m_delegates) { + if (!del->application()) + continue; + for (const QString &raw : del->application()->categories()) { + const QString cat = normalizeCategory(raw); + if (mainCategories().contains(cat)) + found.insert(cat); + } + } + + QStringList result = found.values(); + result.sort(); + return result; +} + ApplicationListSearchModel::ApplicationListSearchModel(HomeScreen *parent, ApplicationListModel *model) : QSortFilterProxyModel(parent) + , m_homeScreen{parent} { setSourceModel(model); @@ -185,3 +240,44 @@ ApplicationListSearchModel::ApplicationListSearchModel(HomeScreen *parent, Appli sort(0, Qt::AscendingOrder); } + +QString ApplicationListSearchModel::categoryFilter() const +{ + return m_categoryFilter; +} + +void ApplicationListSearchModel::setCategoryFilter(const QString &filter) +{ + if (m_categoryFilter == filter) + return; + m_categoryFilter = filter; + Q_EMIT categoryFilterChanged(); + beginFilterChange(); + endFilterChange(QSortFilterProxyModel::Direction::Rows); +} + +bool ApplicationListSearchModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + if (!QSortFilterProxyModel::filterAcceptsRow(sourceRow, sourceParent)) + return false; + + if (m_categoryFilter.isEmpty()) + return true; + + auto *src = static_cast(sourceModel()); + const QModelIndex idx = src->index(sourceRow, 0, sourceParent); + auto *del = src->data(idx, ApplicationListModel::DelegateRole).value(); + if (!del || !del->application()) + return false; + + if (m_categoryFilter == QLatin1String("__favorites__")) + return m_homeScreen->favouritesModel()->containsApplication(del->application()->storageId()); + + // Match both the canonical name and any raw aliases it absorbs. + const QStringList &cats = del->application()->categories(); + for (const QString &raw : cats) { + if (normalizeCategory(raw) == m_categoryFilter) + return true; + } + return false; +} diff --git a/containments/homescreens/folio/applicationlistmodel.h b/containments/homescreens/folio/applicationlistmodel.h index 8f5d5384..1a6a1109 100644 --- a/containments/homescreens/folio/applicationlistmodel.h +++ b/containments/homescreens/folio/applicationlistmodel.h @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include @@ -31,6 +33,7 @@ public: enum Roles { DelegateRole = Qt::UserRole + 1, NameRole, + CategoriesRole, }; ApplicationListModel(HomeScreen *parent = nullptr); @@ -42,6 +45,8 @@ public: void load(); + Q_INVOKABLE QStringList allCategories() const; + Q_SIGNALS: // Emitted when an application was detected to have been removed from the system void applicationRemoved(QString storageId); @@ -64,6 +69,21 @@ class ApplicationListSearchModel : public QSortFilterProxyModel QML_ELEMENT QML_UNCREATABLE("") + Q_PROPERTY(QString categoryFilter READ categoryFilter WRITE setCategoryFilter NOTIFY categoryFilterChanged) + public: ApplicationListSearchModel(HomeScreen *parent = nullptr, ApplicationListModel *model = nullptr); + + QString categoryFilter() const; + void setCategoryFilter(const QString &filter); + +Q_SIGNALS: + void categoryFilterChanged(); + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + +private: + HomeScreen *m_homeScreen{nullptr}; + QString m_categoryFilter; }; diff --git a/containments/homescreens/folio/folioapplication.cpp b/containments/homescreens/folio/folioapplication.cpp index f0d002ac..660869f8 100644 --- a/containments/homescreens/folio/folioapplication.cpp +++ b/containments/homescreens/folio/folioapplication.cpp @@ -15,6 +15,7 @@ FolioApplication::FolioApplication(KService::Ptr service, QObject *parent) , m_name{service->name()} , m_icon{service->icon()} , m_storageId{service->storageId()} + , m_categories{service->categories()} { if (service->property(QStringLiteral("X-KDE-PlasmaMobile-UseGenericName"))) { m_name = service->genericName(); @@ -76,6 +77,11 @@ QString FolioApplication::storageId() const return m_storageId; } +QStringList FolioApplication::categories() const +{ + return m_categories; +} + KWayland::Client::PlasmaWindow *FolioApplication::window() const { return m_window; diff --git a/containments/homescreens/folio/folioapplication.h b/containments/homescreens/folio/folioapplication.h index d34098f3..cd9822a5 100644 --- a/containments/homescreens/folio/folioapplication.h +++ b/containments/homescreens/folio/folioapplication.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -33,6 +34,7 @@ class FolioApplication : public QObject, public std::enable_shared_from_this Ptr; @@ -46,6 +48,7 @@ public: QString name() const; QString icon() const; QString storageId() const; + QStringList categories() const; KWayland::Client::PlasmaWindow *window() const; void setName(QString &name); @@ -67,5 +70,6 @@ private: QString m_name; QString m_icon; QString m_storageId; + QStringList m_categories; KWayland::Client::PlasmaWindow *m_window{nullptr}; }; diff --git a/containments/homescreens/folio/qml/CategoryPanel.qml b/containments/homescreens/folio/qml/CategoryPanel.qml new file mode 100644 index 00000000..49ef6418 --- /dev/null +++ b/containments/homescreens/folio/qml/CategoryPanel.qml @@ -0,0 +1,187 @@ +// SPDX-FileCopyrightText: Marco Allegretti +// SPDX-License-Identifier: EUPL 1.2 + +import QtQuick +import QtQuick.Controls as QQC2 + +import org.kde.kirigami as Kirigami +import org.kde.plasma.components 3.0 as PlasmaComponents + +import plasma.applet.org.kde.plasma.mobile.homescreen.folio as Folio + +Rectangle { + id: root + + required property Folio.HomeScreen folio + + // Emitted when the user taps a tile. + signal categorySelected(string categoryId) + + color: Kirigami.Theme.backgroundColor + radius: Kirigami.Units.cornerRadius + + // Swallow clicks so the dismiss area underneath is not triggered. + MouseArea { anchors.fill: parent } + + // ---------- helpers ---------- + + function catDisplayName(cat) { + switch (cat) { + case "AudioVideo": return i18n("Multimedia") + case "Development": return i18n("Development") + case "Education": return i18n("Education") + case "Game": return i18n("Games") + case "Graphics": return i18n("Graphics") + case "Network": return i18n("Internet") + case "Office": return i18n("Office") + case "Science": return i18n("Science") + case "System": return i18n("System") + case "Utility": return i18n("Utilities") + default: return cat + } + } + + function catIconName(cat) { + switch (cat) { + case "AudioVideo": return "applications-multimedia" + case "Development": return "applications-development" + case "Education": return "applications-education" + case "Game": return "applications-games" + case "Graphics": return "applications-graphics" + case "Network": return "applications-internet" + case "Office": return "applications-office" + case "Science": return "applications-science" + case "System": return "applications-system" + case "Utility": return "applications-utilities" + default: return "applications-other" + } + } + + // ---------- model ---------- + + ListModel { id: categoryModel } + + function populate() { + categoryModel.clear() + categoryModel.append({ catId: "", catName: i18n("All Apps"), catIcon: "applications-all" }) + const cats = folio.ApplicationListModel.allCategories() + for (let i = 0; i < cats.length; i++) { + categoryModel.append({ + catId: cats[i], + catName: root.catDisplayName(cats[i]), + catIcon: root.catIconName(cats[i]), + }) + } + } + + Component.onCompleted: populate() + + Connections { + target: folio.ApplicationListModel + function onRowsInserted() { root.populate() } + function onRowsRemoved() { root.populate() } + } + + // ---------- tile list ---------- + + QQC2.ScrollView { + id: scrollView + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + contentWidth: availableWidth + clip: true + + QQC2.ScrollBar.vertical.policy: QQC2.ScrollBar.AsNeeded + QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff + + Column { + width: scrollView.availableWidth + spacing: Kirigami.Units.smallSpacing + + Repeater { + model: categoryModel + + delegate: Rectangle { + id: tile + + required property string catId + required property string catName + required property string catIcon + + readonly property bool isActive: + folio.ApplicationListSearchModel.categoryFilter === catId + + width: parent.width + height: Kirigami.Units.iconSizes.medium + 2 * Kirigami.Units.largeSpacing + radius: Kirigami.Units.cornerRadius + + color: isActive + ? Qt.rgba(Kirigami.Theme.highlightColor.r, + Kirigami.Theme.highlightColor.g, + Kirigami.Theme.highlightColor.b, 0.2) + : tileArea.containsPress + ? Qt.rgba(Kirigami.Theme.textColor.r, + Kirigami.Theme.textColor.g, + Kirigami.Theme.textColor.b, 0.2) + : tileArea.containsMouse + ? Qt.rgba(Kirigami.Theme.textColor.r, + Kirigami.Theme.textColor.g, + Kirigami.Theme.textColor.b, 0.1) + : "transparent" + + // Active accent bar on left edge + Rectangle { + visible: tile.isActive + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.topMargin: Kirigami.Units.smallSpacing + anchors.bottomMargin: Kirigami.Units.smallSpacing + width: 3 + radius: 2 + color: Kirigami.Theme.highlightColor + } + + Row { + anchors { + fill: parent + leftMargin: Kirigami.Units.largeSpacing + rightMargin: Kirigami.Units.smallSpacing + } + spacing: Kirigami.Units.largeSpacing + + Kirigami.Icon { + anchors.verticalCenter: parent.verticalCenter + width: Kirigami.Units.iconSizes.medium + height: width + source: tile.catIcon + active: tileArea.containsMouse || tile.isActive + } + + PlasmaComponents.Label { + anchors.verticalCenter: parent.verticalCenter + width: parent.width + - Kirigami.Units.iconSizes.medium + - Kirigami.Units.largeSpacing * 2 + - Kirigami.Units.smallSpacing + text: tile.catName + elide: Text.ElideRight + font.weight: tile.isActive ? Font.Medium : Font.Normal + color: tile.isActive + ? Kirigami.Theme.highlightColor + : Kirigami.Theme.textColor + } + } + + MouseArea { + id: tileArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.categorySelected(tile.catId) + } + } + } + } + } +} diff --git a/containments/homescreens/folio/qml/main.qml b/containments/homescreens/folio/qml/main.qml index 0baf678e..7f7ca89c 100644 --- a/containments/homescreens/folio/qml/main.qml +++ b/containments/homescreens/folio/qml/main.qml @@ -347,11 +347,50 @@ ContainmentItem { target: folio.HomeScreenState function onAppDrawerOpened() { - overlayDrawer.forceActiveFocus(); + folio.ApplicationListSearchModel.categoryFilter = "" + overlayDrawer.forceActiveFocus() } } } + // Drop shadow rendered separately so categoryPanel itself needs no + // layer FBO (which would rasterize and blur the icons inside). + Rectangle { + id: categoryPanelShadow + width: categoryPanel.width + height: categoryPanel.height + x: categoryPanel.x + y: categoryPanel.y + radius: categoryPanel.radius + color: categoryPanel.color + opacity: categoryPanel.opacity + layer.enabled: true + layer.effect: DropShadow { + transparentBorder: true + horizontalOffset: 0 + verticalOffset: 2 + radius: 12 + samples: 25 + color: Qt.rgba(0, 0, 0, 0.4) + } + } + + CategoryPanel { + id: categoryPanel + folio: root.folio + + width: Kirigami.Units.gridUnit * 9 + height: overlayDrawer.popupHeight + x: overlayDrawer.x + overlayDrawer.width + Kirigami.Units.smallSpacing + y: overlayDrawer.y + opacity: overlayDrawer.opacity + + onCategorySelected: (catId) => { + folio.ApplicationListSearchModel.categoryFilter = catId + overlayDrawerHeader.clearSearchText() + } + } + // Drop shadow rendered separately so powerPanel itself needs no layer FBO, // which would rasterize and blur the icons inside. Rectangle { @@ -382,7 +421,7 @@ ContainmentItem { width: tileSize height: overlayDrawer.popupHeight - x: overlayDrawer.x + overlayDrawer.width + Kirigami.Units.smallSpacing + x: categoryPanel.x + categoryPanel.width + Kirigami.Units.smallSpacing y: overlayDrawer.y opacity: overlayDrawer.opacity radius: Kirigami.Units.cornerRadius