[plasma/union] src: Introduce StylePackage as a class that represents a packaged style

Arjen Hiemstra <[email protected]> Wed, 5 Aug 2026 10:33:23 +0000 (UTC)
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit a372f5dd062b2bf08dcb9dd5b89aab4c1ba993d2 by Arjen Hiemstra.
Committed on 05/08/2026 at 10:24.
Pushed by ahiemstra into branch 'master'.

Introduce StylePackage as a class that represents a packaged style

This encapsulates functionality that handles a style as a package of
assets with certain required files. It also exposes metadata about the
package, that can be used for KCMs and other UIs that want to show
information about a style.

M  +2    -0    src/CMakeLists.txt
M  +10   -1    src/InputPlugin.h
A  +297  -0    src/StylePackage.cpp     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
A  +132  -0    src/StylePackage.h     [License: LGPL(3+eV) LGPL(v3.0) LGPL(v2.1)]
M  +11   -0    src/input/css/CssPlugin.cpp
M  +2    -0    src/input/css/CssPlugin.h

https://invent.kde.org/plasma/union/-/commit/a372f5dd062b2bf08dcb9dd5b89aab4c1ba993d2

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 663d3022..b672e987 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -17,6 +17,7 @@ target_sources(Union PRIVATE
     Color.cpp
     PlatformPlugin.cpp
     StyleCache.cpp
+    StylePackage.cpp
 )
 
 target_sources(Union PUBLIC
@@ -37,6 +38,7 @@ target_sources(Union PUBLIC
     Color.h
     PluginRegistry.h
     PlatformPlugin.h
+    StylePackage.h
 )
 
 ecm_qt_declare_logging_category(Union
diff --git a/src/InputPlugin.h b/src/InputPlugin.h
index 91ecf7cd..e3fe7296 100644
--- a/src/InputPlugin.h
+++ b/src/InputPlugin.h
@@ -4,6 +4,7 @@
 #pragma once
 
 #include "PluginRegistry.h"
+#include "StylePackage.h"
 
 #include "union_export.h"
 
@@ -30,7 +31,15 @@ public:
     InputPlugin(QObject *parent = nullptr);
 
     /*!
-     * Create an instance of a style by name.
+     * Validate if a given package is a valid package for this input.
+     *
+     * This should be reimplemented by subclasses and check whether the given
+     * package has all files required for createStyle() to work correctly.
+     */
+    virtual StylePackage::Error validatePackage(const StylePackage &package) = 0;
+
+    /*!
+     * Create an instance of a style from a package.
      *
      * This should be reimplemented by subclasses and create a new instance of
      * Style using an appropriate StyleLoader and other input specific data.
diff --git a/src/StylePackage.cpp b/src/StylePackage.cpp
new file mode 100644
index 00000000..2db46c7b
--- /dev/null
+++ b/src/StylePackage.cpp
@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Arjen Hiemstra <[email protected]>
+
+#include "StylePackage.h"
+
+#include <QFile>
+#include <QJsonArray>
+
+#include "InputPlugin.h"
+#include "Style.h"
+
+#include "union_logging.h"
+
+using namespace Union;
+using namespace Qt::StringLiterals;
+
+namespace fs = std::filesystem;
+
+struct StyleMetaData {
+    QString name;
+    QString description;
+    QString version;
+    QString license;
+    QUrl url;
+    QStringList authors;
+};
+
+class UNION_NO_EXPORT StylePackage::Private
+{
+public:
+    Error error = Error::None;
+
+    fs::path path;
+    QString inputType;
+
+    std::optional<StyleMetaData> metaData;
+};
+
+StylePackage::StylePackage()
+    : StylePackage(fs::path())
+{
+}
+
+StylePackage::StylePackage(const std::filesystem::path &path)
+    : d(std::make_unique<Private>())
+{
+    d->path = path;
+
+    if (!fs::exists(d->path)) {
+        qCDebug(UNION_GENERAL) << "Package" << path.string() << "was not found";
+        d->error = Error::NotFound;
+    } else if (!fs::exists(d->path / "metadata.json")) {
+        qCDebug(UNION_GENERAL) << "Could not find metadata.json in package" << path.string();
+        d->error = Error::MissingFiles;
+    } else if (!fs::exists(d->path / "contents")) {
+        qCDebug(UNION_GENERAL) << "Could not find contents directory in package" << path.string();
+        d->error = Error::MissingFiles;
+    }
+}
+
+StylePackage::StylePackage(StylePackage &&other)
+    : d(std::move(other.d))
+{
+}
+
+Union::StylePackage::StylePackage(const StylePackage &other)
+    : d(std::make_unique<Private>())
+{
+    *this = other;
+}
+
+StylePackage::~StylePackage() = default;
+
+StylePackage &Union::StylePackage::operator=(StylePackage &&other)
+{
+    std::swap(d, other.d);
+    return *this;
+}
+
+StylePackage &Union::StylePackage::operator=(const StylePackage &other)
+{
+    if (this != &other) {
+        d->error = other.d->error;
+        d->path = other.d->path;
+        d->inputType = other.d->inputType;
+        d->metaData = other.d->metaData;
+    }
+    return *this;
+}
+
+fs::path StylePackage::path() const
+{
+    return d->path;
+}
+
+QString StylePackage::id() const
+{
+    return QString::fromStdString(d->path.filename());
+}
+
+QString StylePackage::inputType() const
+{
+    if (!isValid()) {
+        return {};
+    }
+
+    return d->inputType;
+}
+
+QString StylePackage::name() const
+{
+    loadMetadata();
+
+    if (d->metaData) {
+        return d->metaData->name;
+    }
+
+    return {};
+}
+
+QString StylePackage::description() const
+{
+    loadMetadata();
+
+    if (d->metaData) {
+        return d->metaData->description;
+    }
+
+    return {};
+}
+
+QString StylePackage::version() const
+{
+    loadMetadata();
+
+    if (d->metaData) {
+        return d->metaData->version;
+    }
+
+    return {};
+}
+
+QString StylePackage::license() const
+{
+    loadMetadata();
+
+    if (d->metaData) {
+        return d->metaData->license;
+    }
+
+    return {};
+}
+
+QUrl StylePackage::url() const
+{
+    loadMetadata();
+
+    if (d->metaData) {
+        return d->metaData->url;
+    }
+
+    return {};
+}
+
+QStringList StylePackage::authors() const
+{
+    loadMetadata();
+
+    if (d->metaData) {
+        return d->metaData->authors;
+    }
+
+    return {};
+}
+
+bool StylePackage::isValid() const
+{
+    if (d->error != Error::None) {
+        return false;
+    }
+
+    if (d->inputType.isEmpty()) {
+        validate();
+    }
+
+    return d->error == Error::None;
+}
+
+StylePackage::Error StylePackage::error() const
+{
+    return d->error;
+}
+
+Style::Ptr StylePackage::load()
+{
+    if (!isValid()) {
+        return nullptr;
+    }
+
+    loadMetadata();
+
+    auto inputPlugin = InputPlugin::inputPlugin(d->inputType);
+    if (!inputPlugin) {
+        return nullptr;
+    }
+
+    return inputPlugin->createStyle(*this);
+}
+
+void StylePackage::validate() const
+{
+    if (d->error != Error::None) {
+        return;
+    }
+
+    QFile metaDataFile(d->path / "metadata.json");
+    if (!metaDataFile.open(QIODevice::ReadOnly)) {
+        qCDebug(UNION_GENERAL) << "Could not open metadata.json of package" << d->path.string() << "for reading";
+        d->error = Error::InvalidMetaData;
+        return;
+    }
+
+    QJsonParseError parseError;
+    auto json = QJsonDocument::fromJson(metaDataFile.readAll(), &parseError).object();
+    if (parseError.error != QJsonParseError::NoError) {
+        qCDebug(UNION_GENERAL) << "Could not parse metadata for package" << d->path.string() << parseError.errorString();
+        d->error = Error::InvalidMetaData;
+        return;
+    }
+
+    if (json.isEmpty()) {
+        qCDebug(UNION_GENERAL) << "Metadata for package" << d->path.string() << "is not an object";
+        d->error = Error::InvalidMetaData;
+        return;
+    }
+
+    d->inputType = json.value(u"input-type").toString();
+
+    auto inputPlugin = InputPlugin::inputPlugin(d->inputType);
+    if (!inputPlugin) {
+        qCDebug(UNION_GENERAL) << "Could not find input type" << d->inputType << "for package" << d->path.string();
+        d->error = Error::UnknownInputType;
+        return;
+    }
+
+    if (auto result = inputPlugin->validatePackage(*this); result != Error::None) {
+        qCDebug(UNION_GENERAL) << "Input plugin" << d->inputType << "considers package" << d->path.string() << "invalid";
+        d->error = result;
+        return;
+    }
+}
+
+void StylePackage::loadMetadata() const
+{
+    if (d->metaData) {
+        return;
+    }
+
+    if (!fs::exists(d->path / "metadata.json")) {
+        return;
+    }
+
+    QFile metaDataFile(d->path / "metadata.json");
+    if (!metaDataFile.open(QIODevice::ReadOnly)) {
+        return;
+    }
+
+    auto json = QJsonDocument::fromJson(metaDataFile.readAll()).object();
+
+    auto metaData = StyleMetaData{};
+    metaData.name = json.value(u"name").toString();
+    metaData.description = json.value(u"description").toString();
+    metaData.version = json.value(u"version").toString();
+    metaData.license = json.value(u"license").toString();
+    metaData.url = QUrl{json.value(u"url").toString()};
+
+    const auto authors = json.value(u"authors").toArray();
+    std::ranges::transform(authors, std::back_inserter(metaData.authors), [](const QJsonValue &value) {
+        return value.toString();
+    });
+
+    d->metaData = metaData;
+}
+
+QDebug operator<<(QDebug debug, const StylePackage &package)
+{
+    QDebugStateSaver saver(debug);
+    debug.nospace() << "StylePackage(";
+    debug << package.path().string();
+    if (package.isValid()) {
+        debug.nospace() << ", valid, inputType: " << package.inputType();
+    } else {
+        debug.nospace() << ", invalid: " << int(package.error());
+    }
+    debug << ")";
+    return debug;
+}
diff --git a/src/StylePackage.h b/src/StylePackage.h
new file mode 100644
index 00000000..6555f19e
--- /dev/null
+++ b/src/StylePackage.h
@@ -0,0 +1,132 @@
+// SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
+// SPDX-FileCopyrightText: 2026 Arjen Hiemstra <[email protected]>
+
+#pragma once
+
+#include <filesystem>
+
+#include <QObject>
+
+#include "union_export.h"
+
+namespace Union
+{
+class InputPlugin;
+class Style;
+
+/*!
+ * A bundle of assets that represent a style.
+ *
+ * The StylePackage class is a representation of a package containing style
+ * assets, along with metadata about that style.
+ *
+ * Packages are effectively directories with certain expected contents. At the
+ * very least, a package is expected to contain a "metadata.json" file and a
+ * "contents" directory. Other files may be required depending on the input
+ * format used for the package.
+ */
+class UNION_EXPORT StylePackage
+{
+public:
+    /*!
+     *
+     */
+    enum class Error {
+        None,
+        NotFound,
+        MissingFiles,
+        InvalidMetaData,
+        UnknownInputType,
+    };
+
+    /*!
+     * Default constructor.
+     *
+     * Creates an empty and invalid package.
+     */
+    StylePackage();
+    /*!
+     * Creates a package pointing at a specific directories.
+     */
+    explicit StylePackage(const std::filesystem::path &path);
+    StylePackage(StylePackage &&other);
+    StylePackage(const StylePackage &other);
+    ~StylePackage();
+
+    StylePackage &operator=(StylePackage &&other);
+    StylePackage &operator=(const StylePackage &other);
+
+    /*!
+     * Return the path of this package.
+     */
+    std::filesystem::path path() const;
+    /*!
+     * Returns the ID of this package.
+     *
+     * This is effectively the filename of the path.
+     */
+    QString id() const;
+    /*!
+     * Return the input type that is required for this package.
+     */
+    QString inputType() const;
+    /*!
+     * Returns the name of this package.
+     */
+    QString name() const;
+    /*!
+     * Returns the description of this package.
+     */
+    QString description() const;
+    /*!
+     * Returns the version of this package.
+     *
+     * Note that there are no constraints on the format of the version.
+     */
+    QString version() const;
+    /*!
+     * Returns the license of this package.
+     */
+    QString license() const;
+    /*!
+     * Returns the URL of this package.
+     */
+    QUrl url() const;
+    /*!
+     * Returns the list of authors of this package.
+     *
+     * This is just a list of author names.
+     */
+    QStringList authors() const;
+
+    /*!
+     * Returns if the package is valid.
+     *
+     * This will load the package's metadata file to determine the input type
+     * for this package, then ask the input plugin to validate the package, if
+     * that was not done before.
+     *
+     * You can use the error() method to check what kind of problem an invalid
+     * package has.
+     */
+    bool isValid() const;
+    /*!
+     * Returns the error that caused the package to be invalid.
+     */
+    Error error() const;
+    /*!
+     * Load the package fully and use it to create a new Style instance.
+     */
+    std::shared_ptr<Style> load();
+
+private:
+    void validate() const;
+    void loadMetadata() const;
+
+    class Private;
+    std::unique_ptr<Private> d;
+};
+
+}
+
+UNION_EXPORT QDebug operator<<(QDebug debug, const Union::StylePackage &package);
diff --git a/src/input/css/CssPlugin.cpp b/src/input/css/CssPlugin.cpp
index b7168e5a..467451bb 100644
--- a/src/input/css/CssPlugin.cpp
+++ b/src/input/css/CssPlugin.cpp
@@ -9,11 +9,22 @@
 
 using namespace Qt::StringLiterals;
 
+namespace fs = std::filesystem;
+
 CssPlugin::CssPlugin(QObject *parent)
     : Union::InputPlugin(parent)
 {
 }
 
+Union::StylePackage::Error CssPlugin::validatePackage(const Union::StylePackage &package)
+{
+    if (!fs::exists(package.path() / "contents" / "css" / "style.css")) {
+        return StylePackage::Error::MissingFiles;
+    }
+
+    return StylePackage::Error::None;
+}
+
 std::shared_ptr<Union::Style> CssPlugin::createStyle(const QString &styleName) const
 {
     return Union::Style::create(name(), styleName, std::make_unique<CssLoader>());
diff --git a/src/input/css/CssPlugin.h b/src/input/css/CssPlugin.h
index f8381a58..256ac2c6 100644
--- a/src/input/css/CssPlugin.h
+++ b/src/input/css/CssPlugin.h
@@ -18,5 +18,7 @@ class CssPlugin : public Union::InputPlugin
 public:
     CssPlugin(QObject *parent = nullptr);
 
+    Union::StylePackage::Error validatePackage(const Union::StylePackage &package) override;
+
     std::shared_ptr<Union::Style> createStyle(const QString &styleName) const override;
 };