mirror of
https://invent.kde.org/marcoa/shift-shell.git
synced 2026-04-26 14:23:09 +00:00
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:
parent
2a0e4428c7
commit
dd7f087fac
7 changed files with 356 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
};
|
||||
|
|
|
|||
187
containments/homescreens/folio/qml/CategoryPanel.qml
Normal file
187
containments/homescreens/folio/qml/CategoryPanel.qml
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue