Commit b25a0d41 authored by David Redondo's avatar David Redondo 🏎
Browse files

[Plasma Style KCM] Add search filter

Summary:
Addsm the ability to filter themes by name by typing in the search line or
whether they use the current color scheme or if they are light or dark if they
do not. To enable this the model is extracted out into its own class and a
QSortFilterProxyModel is added for the Filtering. This is mostly based on the
same functionality of the Colors KCM and uses the same heuristic to decide if a
theme is light or dark.

Test Plan: {F7821003}

Reviewers: #plasma, #vdg, broulik, ndavis, ngraham, ervin

Reviewed By: #vdg, ndavis, ngraham, ervin

Subscribers: ervin, ndavis, crossi, plasma-devel

Tags: #plasma

Differential Revision: https://phabricator.kde.org/D26039
parent c9eaa0d9
......@@ -3,6 +3,8 @@ add_definitions(-DTRANSLATION_DOMAIN=\"kcm_desktoptheme\")
set(kcm_desktoptheme_SRCS
kcm.cpp
themesmodel.cpp
filterproxymodel.cpp
)
kconfig_add_kcfg_files(kcm_desktoptheme_SRCS desktopthemesettings.kcfgc GENERATE_MOC)
......
/*
* Copyright (C) 2019 Kai Uwe Broulik <kde@privat.broulik.de>
* Copyright (c) 2019 David Redondo <kde@david-redondo.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "filterproxymodel.h"
#include "themesmodel.h"
FilterProxyModel::FilterProxyModel(QObject *parent) : QSortFilterProxyModel(parent)
{
}
FilterProxyModel::~FilterProxyModel() = default;
QString FilterProxyModel::selectedTheme() const
{
return m_selectedTheme;
}
void FilterProxyModel::setSelectedTheme(const QString &pluginName)
{
if (m_selectedTheme == pluginName) {
return;
}
const bool firstTime = m_selectedTheme.isNull();
m_selectedTheme = pluginName;
if (!firstTime) {
emit selectedThemeChanged();
}
emit selectedThemeIndexChanged();
}
int FilterProxyModel::selectedThemeIndex() const
{
// We must search in the source model and then map the index to our proxy model.
const auto results = sourceModel()->match(sourceModel()->index(0, 0), ThemesModel::PluginNameRole, m_selectedTheme);
if (results.count() == 1) {
const QModelIndex result = mapFromSource(results.first());
if (result.isValid()) {
return result.row();
}
}
return -1;
}
QString FilterProxyModel::query() const
{
return m_query;
}
void FilterProxyModel::setQuery(const QString &query)
{
if (m_query != query) {
const int oldIndex = selectedThemeIndex();
m_query = query;
invalidateFilter();
emit queryChanged();
if (selectedThemeIndex() != oldIndex) {
emit selectedThemeIndexChanged();
}
}
}
FilterProxyModel::ThemeFilter FilterProxyModel::filter() const
{
return m_filter;
}
void FilterProxyModel::setFilter(ThemeFilter filter)
{
if (m_filter != filter) {
const int oldIndex = selectedThemeIndex();
m_filter = filter;
invalidateFilter();
emit filterChanged();
if (selectedThemeIndex() != oldIndex) {
emit selectedThemeIndexChanged();
}
}
}
bool FilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
const QModelIndex idx = sourceModel()->index(sourceRow, 0, sourceParent);
if (!m_query.isEmpty()) {
if (!idx.data(Qt::DisplayRole).toString().contains(m_query, Qt::CaseInsensitive)
&& !idx.data(ThemesModel::PluginNameRole).toString().contains(m_query, Qt::CaseInsensitive)) {
return false;
}
}
const auto type = idx.data(ThemesModel::ColorTypeRole).value<ThemesModel::ColorType>();
switch (m_filter) {
case AllThemes:
return true;
case LightThemes:
return type == ThemesModel::LightTheme;
case DarkThemes:
return type == ThemesModel::DarkTheme;
case ThemesFollowingColors:
return type == ThemesModel::FollowsColorTheme;
}
return true;
}
/*
* Copyright (c) 2019 Kai Uwe Broulik <kde@privat.broulik.de>
* Copyright (c) 2019 David Redondo <kde@david-redondo.de>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of
* the License or (at your option) version 3 or any later version
* accepted by the membership of KDE e.V. (or its successor approved
* by the membership of KDE e.V.), which shall act as a proxy
* defined in Section 14 of version 3 of the license.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <QSortFilterProxyModel>
#include "kcm.h"
class FilterProxyModel : public QSortFilterProxyModel
{
Q_OBJECT
public:
enum ThemeFilter {
AllThemes,
LightThemes,
DarkThemes,
ThemesFollowingColors
};
Q_ENUM(ThemeFilter)
Q_PROPERTY(QString selectedTheme READ selectedTheme WRITE setSelectedTheme NOTIFY selectedThemeChanged)
Q_PROPERTY(int selectedThemeIndex READ selectedThemeIndex NOTIFY selectedThemeIndexChanged)
Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged)
Q_PROPERTY(ThemeFilter filter READ filter WRITE setFilter NOTIFY filterChanged)
FilterProxyModel(QObject *parent = nullptr);
~FilterProxyModel() override;
QString selectedTheme() const;
void setSelectedTheme(const QString &pluginName);
int selectedThemeIndex() const;
QString query() const;
void setQuery(const QString &query);
ThemeFilter filter() const;
void setFilter(ThemeFilter filter);
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
Q_SIGNALS:
void filterChanged();
void queryChanged();
void selectedThemeChanged();
void selectedThemeIndexChanged();
private:
QString m_selectedTheme;
QString m_query;
ThemeFilter m_filter = AllThemes;
};
......@@ -25,7 +25,6 @@
#include <KPluginFactory>
#include <KAboutData>
#include <KLocalizedString>
#include <KDesktopFile>
#include <KIO/FileCopyJob>
#include <KIO/JobUiDelegate>
......@@ -44,6 +43,8 @@
#include <KNewStuff3/KNS3/DownloadDialog>
#include "desktopthemesettings.h"
#include "filterproxymodel.h"
#include "themesmodel.h"
Q_LOGGING_CATEGORY(KCM_DESKTOP_THEME, "kcm_desktoptheme")
......@@ -52,10 +53,13 @@ K_PLUGIN_FACTORY_WITH_JSON(KCMDesktopThemeFactory, "kcm_desktoptheme.json", regi
KCMDesktopTheme::KCMDesktopTheme(QObject *parent, const QVariantList &args)
: KQuickAddons::ManagedConfigModule(parent, args)
, m_settings(new DesktopThemeSettings(this))
, m_model(new ThemesModel(this))
, m_filteredModel(new FilterProxyModel(this))
, m_haveThemeExplorerInstalled(false)
{
qmlRegisterType<DesktopThemeSettings>();
qmlRegisterType<QStandardItemModel>();
qmlRegisterUncreatableType<ThemesModel>("org.kde.private.kcms.desktoptheme", 1, 0, "ThemesModel", "Cannot create ThemesModel");
qmlRegisterUncreatableType<FilterProxyModel>("org.kde.private.kcms.desktoptheme", 1, 0, "FilterProxyModel", "Cannot create FilterProxyModel");
KAboutData* about = new KAboutData(QStringLiteral("kcm_desktoptheme"), i18n("Plasma Style"),
QStringLiteral("0.1"), QString(), KAboutLicense::LGPL);
......@@ -63,17 +67,21 @@ KCMDesktopTheme::KCMDesktopTheme(QObject *parent, const QVariantList &args)
setAboutData(about);
setButtons(Apply | Default | Help);
m_model = new QStandardItemModel(this);
QHash<int, QByteArray> roles = m_model->roleNames();
roles[PluginNameRole] = QByteArrayLiteral("pluginName");
roles[ThemeNameRole] = QByteArrayLiteral("themeName");
roles[DescriptionRole] = QByteArrayLiteral("description");
roles[FollowsSystemColorsRole] = QByteArrayLiteral("followsSystemColors");
roles[IsLocalRole] = QByteArrayLiteral("isLocal");
roles[PendingDeletionRole] = QByteArrayLiteral("pendingDeletion");
m_model->setItemRoleNames(roles);
m_haveThemeExplorerInstalled = !QStandardPaths::findExecutable(QStringLiteral("plasmathemeexplorer")).isEmpty();
connect(m_model, &ThemesModel::pendingDeletionsChanged, this, &KCMDesktopTheme::settingsChanged);
connect(m_model, &ThemesModel::selectedThemeChanged, this, [this](const QString &pluginName) {
m_settings->setName(pluginName);
});
connect(m_settings, &DesktopThemeSettings::nameChanged, this, [this] {
m_model->setSelectedTheme(m_settings->name());
});
connect(m_model, &ThemesModel::selectedThemeChanged, m_filteredModel, &FilterProxyModel::setSelectedTheme);
m_filteredModel->setSourceModel(m_model);
}
KCMDesktopTheme::~KCMDesktopTheme()
......@@ -85,19 +93,14 @@ DesktopThemeSettings *KCMDesktopTheme::desktopThemeSettings() const
return m_settings;
}
QStandardItemModel *KCMDesktopTheme::desktopThemeModel() const
ThemesModel *KCMDesktopTheme::desktopThemeModel() const
{
return m_model;
}
int KCMDesktopTheme::pluginIndex(const QString &pluginName) const
FilterProxyModel *KCMDesktopTheme::filteredModel() const
{
const auto results = m_model->match(m_model->index(0, 0), PluginNameRole, pluginName);
if (results.count() == 1) {
return results.first().row();
}
return -1;
return m_filteredModel;
}
bool KCMDesktopTheme::downloadingFile() const
......@@ -105,21 +108,6 @@ bool KCMDesktopTheme::downloadingFile() const
return m_tempCopyJob;
}
void KCMDesktopTheme::setPendingDeletion(int index, bool pending)
{
QModelIndex idx = m_model->index(index, 0);
m_model->setData(idx, pending, PendingDeletionRole);
if (pending && pluginIndex(m_settings->name()) == index) {
// move to the next non-pending theme
const auto nonPending = m_model->match(idx, PendingDeletionRole, false);
m_settings->setName(nonPending.first().data(PluginNameRole).toString());
}
settingsChanged();
}
void KCMDesktopTheme::getNewStuff(QQuickItem *ctx)
{
if (!m_newStuffDialog) {
......@@ -223,63 +211,8 @@ void KCMDesktopTheme::applyPlasmaTheme(QQuickItem *item, const QString &themeNam
void KCMDesktopTheme::load()
{
ManagedConfigModule::load();
m_pendingRemoval.clear();
// Get all desktop themes
QStringList themes;
const QStringList &packs = QStandardPaths::locateAll(QStandardPaths::GenericDataLocation, QStringLiteral("plasma/desktoptheme"), QStandardPaths::LocateDirectory);
Q_FOREACH (const QString &ppath, packs) {
const QDir cd(ppath);
const QStringList &entries = cd.entryList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot);
Q_FOREACH (const QString &pack, entries) {
const QString _metadata = ppath + QLatin1Char('/') + pack + QStringLiteral("/metadata.desktop");
if (QFile::exists(_metadata)) {
themes << _metadata;
}
}
}
m_model->clear();
Q_FOREACH (const QString &theme, themes) {
int themeSepIndex = theme.lastIndexOf(QLatin1Char('/'), -1);
const QString themeRoot = theme.left(themeSepIndex);
int themeNameSepIndex = themeRoot.lastIndexOf(QLatin1Char('/'), -1);
const QString packageName = themeRoot.right(themeRoot.length() - themeNameSepIndex - 1);
KDesktopFile df(theme);
if (df.noDisplay()) {
continue;
}
QString name = df.readName();
if (name.isEmpty()) {
name = packageName;
}
const bool isLocal = QFileInfo(theme).isWritable();
// Plasma Theme creates a KColorScheme out of the "color" file and falls back to system colors if there is none
const bool followsSystemColors = !QFileInfo::exists(themeRoot + QLatin1String("/colors"));
if (m_model->findItems(packageName).isEmpty()) {
QStandardItem *item = new QStandardItem;
item->setText(packageName);
item->setData(packageName, PluginNameRole);
item->setData(name, ThemeNameRole);
item->setData(df.readComment(), DescriptionRole);
item->setData(followsSystemColors, FollowsSystemColorsRole);
item->setData(isLocal, IsLocalRole);
item->setData(false, PendingDeletionRole);
m_model->appendRow(item);
}
}
m_model->setSortRole(ThemeNameRole); // FIXME the model should really be just using Qt::DisplayRole
m_model->sort(0 /*column*/);
// Model has been cleared so pretend the theme name changed to force view update
emit m_settings->nameChanged();
m_model->load();
m_model->setSelectedTheme(m_settings->name());
}
void KCMDesktopTheme::save()
......@@ -294,9 +227,9 @@ void KCMDesktopTheme::defaults()
ManagedConfigModule::defaults();
// can this be done more elegantly?
const auto pendingDeletions = m_model->match(m_model->index(0, 0), PendingDeletionRole, true);
const auto pendingDeletions = m_model->match(m_model->index(0, 0), ThemesModel::PendingDeletionRole, true);
for (const QModelIndex &idx : pendingDeletions) {
m_model->setData(idx, false, PendingDeletionRole);
m_model->setData(idx, false, ThemesModel::PendingDeletionRole);
}
}
......@@ -312,14 +245,14 @@ void KCMDesktopTheme::editTheme(const QString &theme)
bool KCMDesktopTheme::isSaveNeeded() const
{
return !m_model->match(m_model->index(0, 0), PendingDeletionRole, true).isEmpty();
return !m_model->match(m_model->index(0, 0), ThemesModel::PendingDeletionRole, true).isEmpty();
}
void KCMDesktopTheme::processPendingDeletions()
{
const QString program = QStringLiteral("plasmapkg2");
const auto pendingDeletions = m_model->match(m_model->index(0, 0), PendingDeletionRole, true, -1 /*all*/);
const auto pendingDeletions = m_model->match(m_model->index(0, 0), ThemesModel::PendingDeletionRole, true, -1 /*all*/);
QVector<QPersistentModelIndex> persistentPendingDeletions;
// turn into persistent model index so we can delete as we go
std::transform(pendingDeletions.begin(), pendingDeletions.end(),
......@@ -328,7 +261,7 @@ void KCMDesktopTheme::processPendingDeletions()
});
for (const QPersistentModelIndex &idx : persistentPendingDeletions) {
const QString pluginName = idx.data(PluginNameRole).toString();
const QString pluginName = idx.data(ThemesModel::PluginNameRole).toString();
const QString displayName = idx.data(Qt::DisplayRole).toString();
Q_ASSERT(pluginName != m_settings->name());
......@@ -344,7 +277,7 @@ void KCMDesktopTheme::processPendingDeletions()
} else {
emit showErrorMessage(i18n("Removing theme failed: %1",
QString::fromLocal8Bit(process->readAllStandardOutput().trimmed())));
m_model->setData(idx, false, PendingDeletionRole);
m_model->setData(idx, false, ThemesModel::PendingDeletionRole);
}
process->deleteLater();
});
......
......@@ -39,35 +39,28 @@ class FileCopyJob;
}
class QQuickItem;
class QStandardItemModel;
class DesktopThemeSettings;
class FilterProxyModel;
class ThemesModel;
class KCMDesktopTheme : public KQuickAddons::ManagedConfigModule
{
Q_OBJECT
Q_PROPERTY(DesktopThemeSettings *desktopThemeSettings READ desktopThemeSettings CONSTANT)
Q_PROPERTY(QStandardItemModel *desktopThemeModel READ desktopThemeModel CONSTANT)
Q_PROPERTY(FilterProxyModel *filteredModel READ filteredModel CONSTANT)
Q_PROPERTY(ThemesModel *desktopThemeModel READ desktopThemeModel CONSTANT)
Q_PROPERTY(bool downloadingFile READ downloadingFile NOTIFY downloadingFileChanged)
Q_PROPERTY(bool canEditThemes READ canEditThemes CONSTANT)
public:
enum Roles {
PluginNameRole = Qt::UserRole + 1,
ThemeNameRole,
DescriptionRole,
FollowsSystemColorsRole,
IsLocalRole,
PendingDeletionRole
};
Q_ENUM(Roles)
KCMDesktopTheme(QObject *parent, const QVariantList &args);
~KCMDesktopTheme() override;
DesktopThemeSettings *desktopThemeSettings() const;
QStandardItemModel *desktopThemeModel() const;
Q_INVOKABLE int pluginIndex(const QString &pluginName) const;
ThemesModel *desktopThemeModel() const;
FilterProxyModel *filteredModel() const;
bool downloadingFile() const;
......@@ -76,8 +69,6 @@ public:
Q_INVOKABLE void getNewStuff(QQuickItem *ctx);
Q_INVOKABLE void installThemeFromFile(const QUrl &url);
Q_INVOKABLE void setPendingDeletion(int index, bool pending);
Q_INVOKABLE void applyPlasmaTheme(QQuickItem *item, const QString &themeName);
Q_INVOKABLE void editTheme(const QString &themeName);
......@@ -102,8 +93,8 @@ private:
DesktopThemeSettings *m_settings;
QStandardItemModel *m_model;
QStringList m_pendingRemoval;
ThemesModel *m_model;
FilterProxyModel *m_filteredModel;
QHash<QString, Plasma::Theme*> m_themes;
bool m_haveThemeExplorerInstalled;
......
......@@ -20,6 +20,7 @@ import QtQuick.Layouts 1.1
import org.kde.plasma.core 2.0 as PlasmaCore
import org.kde.kirigami 2.4 as Kirigami
import org.kde.plasma.components 2.0 as PlasmaComponents
import org.kde.private.kcms.desktoptheme 1.0 as Private
Item {
id: root
......@@ -176,7 +177,7 @@ Item {
}
}
Kirigami.Icon {
visible: model.followsSystemColors
visible: model.colorType === Private.ThemesModel.FollowsColorTheme
source: "color-profile"
width: Kirigami.Units.iconSizes.smallMedium
height: width
......
......@@ -26,12 +26,26 @@ import QtQuick.Controls 2.3 as QtControls
import org.kde.kirigami 2.4 as Kirigami
import org.kde.kconfig 1.0 // for KAuthorized
import org.kde.kcm 1.1 as KCM
import org.kde.private.kcms.desktoptheme 1.0 as Private
KCM.GridViewKCM {
KCM.ConfigModule.quickHelp: i18n("This module lets you choose the Plasma style.")
view.model: kcm.desktopThemeModel
view.currentIndex: kcm.pluginIndex(kcm.desktopThemeSettings.name)
view.model: kcm.filteredModel
view.currentIndex: kcm.filteredModel.selectedThemeIndex
Binding {
target: kcm.filteredModel
property: "query"
value: searchField.text
}
Binding {
target: kcm.filteredModel
property: "filter"
value: filterCombo.model[filterCombo.currentIndex].filter
}
enabled: !kcm.downloadingFile && !kcm.desktopThemeSettings.isImmutable("name")
......@@ -44,13 +58,81 @@ KCM.GridViewKCM {
}
onDropped: kcm.installThemeFromFile(drop.urls[0])
}
header: RowLayout {
Layout.fillWidth: true
QtControls.TextField {
id: searchField
Layout.fillWidth: true
placeholderText: i18n("Search...")
leftPadding: LayoutMirroring.enabled ? clearButton.width : undefined
rightPadding: LayoutMirroring.enabled ? undefined : clearButton.width
// this could be useful as a component
MouseArea {
id: clearButton
anchors {
top: parent.top
topMargin: parent.topPadding
right: parent.right
// the TextField's padding is taking into account the clear button's size
// so we just use the opposite one for positioning the clear button
rightMargin: LayoutMirroring.enabled ? parent.rightPadding: parent.leftPadding
bottom: parent.bottom
bottomMargin: parent.bottomPadding
}
width: height
opacity: searchField.length > 0 ? 1 : 0
onClicked: searchField.clear()
Kirigami.Icon {
anchors.fill: parent
active: parent.pressed
source: "edit-clear-locationbar-" + (LayoutMirroring.enabled ? "ltr" : "rtl")
}
Behavior on opacity {
NumberAnimation { duration: Kirigami.Units.longDuration }
}
}
}
QtControls.ComboBox {
id: filterCombo
textRole: "text"
model: [
{text: i18n("All Themes"), filter: Private.FilterProxyModel.AllThemes},
{text: i18n("Light Themes"), filter: Private.FilterProxyModel.LightThemes},
{text: i18n("Dark Themes"), filter: Private.FilterProxyModel.DarkThemes},
{text: i18n("Color scheme compatible"), filter: Private.FilterProxyModel.ThemesFollowingColors}
]
// HACK QQC2 doesn't support icons, so we just tamper with the desktop style ComboBox's background
// and inject a nice little filter icon.
Component.onCompleted: {
if (!background || !background.hasOwnProperty("properties")) {
// not a KQuickStyleItem
return;
}
var props = background.properties || {};
background.properties = Qt.binding(function() {