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.
This commit is contained in:
Marco Allegretti 2026-04-18 12:33:11 +02:00
parent 2a0e4428c7
commit dd7f087fac
7 changed files with 356 additions and 3 deletions

View file

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

View file

@ -46,7 +46,11 @@ ApplicationListModel::~ApplicationListModel() = default;
QHash<int, QByteArray> 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<QString> &mainCategories()
{
static const QSet<QString> 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<QString> 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<ApplicationListModel *>(sourceModel());
const QModelIndex idx = src->index(sourceRow, 0, sourceParent);
auto *del = src->data(idx, ApplicationListModel::DelegateRole).value<FolioDelegate *>();
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;
}

View file

@ -9,6 +9,8 @@
#include <QObject>
#include <QQuickItem>
#include <QSet>
#include <QSortFilterProxyModel>
#include <QStringList>
#include <KService>
@ -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;
};

View file

@ -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<bool>(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;

View file

@ -7,6 +7,7 @@
#include <QObject>
#include <QQuickItem>
#include <QString>
#include <QStringList>
#include <KIO/ApplicationLauncherJob>
#include <KService>
@ -33,6 +34,7 @@ class FolioApplication : public QObject, public std::enable_shared_from_this<Fol
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(QString icon READ icon NOTIFY iconChanged)
Q_PROPERTY(QString storageId READ storageId NOTIFY storageIdChanged)
Q_PROPERTY(QStringList categories READ categories CONSTANT)
public:
typedef std::shared_ptr<FolioApplication> 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};
};

View file

@ -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)
}
}
}
}
}
}

View file

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