[plasma/union] /: Implement a create command in styletool and PackageHandler

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

Implement a create command in styletool and PackageHandler

This will create a barebones style package that contains everything that
is needed for a valid package of a certain input type. This makes it
easy to get started creating new styles.

M  +7    -0    src/InputPlugin.h
M  +67   -0    src/PackageHandler.cpp
M  +25   -0    src/PackageHandler.h
M  +49   -0    src/input/css/CssPlugin.cpp
M  +2    -0    src/input/css/CssPlugin.h
M  +84   -0    tools/styletool/styletool.cpp

https://invent.kde.org/plasma/union/-/commit/0b8f605d7dec63eddad9c299677bb139f339f258

diff --git a/src/InputPlugin.h b/src/InputPlugin.h
index 15aa0f7a..18cf2a11 100644
--- a/src/InputPlugin.h
+++ b/src/InputPlugin.h
@@ -3,6 +3,7 @@
 
 #pragma once
 
+#include "PackageHandler.h"
 #include "PluginRegistry.h"
 #include "StylePackage.h"
 
@@ -12,6 +13,7 @@ namespace Union
 {
 
 class Style;
+
 /*!
  * \class Union::InputPlugin
  * \inmodule core
@@ -38,6 +40,11 @@ public:
      */
     virtual StylePackage::Error validatePackage(const StylePackage &package) = 0;
 
+    /*!
+     * Create any files required for the package to be valid.
+     */
+    virtual PackageHandler::Error createPackage(const StylePackage &package) = 0;
+
     /*!
      * Create an instance of a style from a package.
      *
diff --git a/src/PackageHandler.cpp b/src/PackageHandler.cpp
index c93e3249..e544b163 100644
--- a/src/PackageHandler.cpp
+++ b/src/PackageHandler.cpp
@@ -71,6 +71,73 @@ QList<StylePackage> PackageHandler::allPackages()
     return result;
 }
 
+PackageHandler::Error PackageHandler::create(StylePackage &destination, const PackageHandler::CreateInfo &info)
+{
+    if (fs::exists(info.path)) {
+        return Error::PackageExists;
+    }
+
+    if (!fs::create_directories(info.path)) {
+        return Error::FilesystemError;
+    }
+
+    if (!fs::create_directories(info.path / "contents")) {
+        return Error::FilesystemError;
+    }
+
+    auto inputPlugin = InputPlugin::inputPlugin(info.inputType);
+    if (!inputPlugin) {
+        return Error::UnknownInputType;
+    }
+
+    QFile metaDataFile(info.path / "metadata.json");
+    if (!metaDataFile.open(QIODevice::WriteOnly)) {
+        return Error::FilesystemError;
+    }
+
+    QTextStream stream{&metaDataFile};
+    stream << "{\n";
+    stream << R"(    "input-type": ")" << info.inputType << "\",\n";
+    stream << R"(    "name": ")" << info.name << "\"";
+    if (!info.description.isEmpty()) {
+        stream << ",\n";
+        stream << R"(    "description": ")" << info.description << "\"";
+    }
+
+    if (!info.version.isEmpty()) {
+        stream << ",\n";
+        stream << R"(    "version": ")" << info.version << "\"";
+    }
+    if (!info.license.isEmpty()) {
+        stream << ",\n";
+        stream << R"(    "license": ")" << info.license << "\"";
+    }
+    if (!info.url.isEmpty()) {
+        stream << ",\n";
+        stream << R"(    "url": ")" << info.url.toString() << "\"";
+    }
+    if (!info.authors.isEmpty()) {
+        stream << ",\n";
+        stream << R"(    "authors": [)" << "\n";
+        for (int i = 0; i < info.authors.size(); ++i) {
+            stream << "        \"" << info.authors.at(i) << "\"";
+            if (i < info.authors.size() - 1) {
+                stream << ",";
+            }
+            stream << "\n";
+        }
+        stream << "    ]";
+    }
+    stream << "\n";
+    stream << "}\n";
+
+    stream.flush();
+    metaDataFile.close();
+
+    destination = StylePackage{info.path};
+    return inputPlugin->createPackage(destination);
+}
+
 PackageHandler::Error PackageHandler::install(const StylePackage &package)
 {
     if (!package.isValid()) {
diff --git a/src/PackageHandler.h b/src/PackageHandler.h
index a631243c..9c52447b 100644
--- a/src/PackageHandler.h
+++ b/src/PackageHandler.h
@@ -5,6 +5,8 @@
 
 #include <filesystem>
 
+#include <QUrl>
+
 #include "StylePackage.h"
 
 #include "union_export.h"
@@ -43,6 +45,12 @@ public:
      * \value NotAnUpdate
      *      The operation failed because the provided package is not considered
      *      to be an update for the existing installed package.
+     * \value PackageExists
+     *      The operation failed because a package already exists at the
+     *      location provided. Note that this differs from AlreadyInstalled in
+     *      that this may also occur for non-install paths.
+     * \value UnknownInputType
+     *      The operation failed because an unknown input type was requested.
      */
     enum class Error {
         None,
@@ -51,6 +59,19 @@ public:
         FilesystemError,
         NotInstalled,
         NotAnUpdate,
+        PackageExists,
+        UnknownInputType,
+    };
+
+    struct CreateInfo {
+        std::filesystem::path path;
+        QString inputType;
+        QString name;
+        QString description;
+        QString version;
+        QString license;
+        QUrl url;
+        QStringList authors;
     };
 
     PackageHandler(const std::shared_ptr<PlatformPlugin> &platformPlugin);
@@ -66,6 +87,10 @@ public:
      */
     QList<StylePackage> allPackages();
 
+    /*!
+     * Create a new package.
+     */
+    Error create(StylePackage &destination, const CreateInfo &info);
     /*!
      * Install a package.
      *
diff --git a/src/input/css/CssPlugin.cpp b/src/input/css/CssPlugin.cpp
index 2e880924..0d2df655 100644
--- a/src/input/css/CssPlugin.cpp
+++ b/src/input/css/CssPlugin.cpp
@@ -3,6 +3,10 @@
 
 #include "CssPlugin.h"
 
+#include <fstream>
+
+#include <QFile>
+
 #include <Style.h>
 #include <StylePackage.h>
 
@@ -26,6 +30,51 @@ Union::StylePackage::Error CssPlugin::validatePackage(const Union::StylePackage
     return StylePackage::Error::None;
 }
 
+Union::PackageHandler::Error CssPlugin::createPackage(const Union::StylePackage &package)
+{
+    auto path = package.path();
+
+    if (!fs::create_directories(path / "contents" / "css")) {
+        return Union::PackageHandler::Error::FilesystemError;
+    }
+
+    auto year = QDate::currentDate().year();
+    auto author = package.authors().isEmpty() ? u"Unknown"_s : package.authors().first();
+
+    {
+        std::ofstream stream{path / "contents" / "css" / "style.css", std::ios::out};
+        // REUSE-IgnoreStart
+        stream << "/* SPDX-License-Identifier: " << (package.license().isEmpty() ? "Unknown" : qPrintable(package.license())) << " */\n";
+        stream << "/* SPDX-FileCopyrightText: " << year << " " << qPrintable(author) << " */\n";
+        stream << "\n";
+        stream << "/* This is the main CSS file for the Union style " << qPrintable(package.name()) << " */\n";
+        stream << "\n";
+        stream << "/* It is recommended to not place any style rules in this file, but instead\n";
+        stream << "   create separate files for different categories of rules and use @import\n";
+        stream << "   here to include them in the style. */\n";
+        // REUSE-IgnoreEnd
+
+        if (stream.fail()) {
+            return Union::PackageHandler::Error::FilesystemError;
+        }
+    }
+
+    if (!fs::create_directories(path / "contents" / "images")) {
+        return Union::PackageHandler::Error::FilesystemError;
+    }
+
+    {
+        std::ofstream stream{path / "contents" / "images" / "README", std::ios::out};
+        stream << "This directory should contain any images your style uses.\n";
+
+        if (stream.fail()) {
+            return Union::PackageHandler::Error::FilesystemError;
+        }
+    }
+
+    return Union::PackageHandler::Error::None;
+}
+
 std::shared_ptr<Union::Style> CssPlugin::createStyle(const Union::StylePackage &package) const
 {
     return Union::Style::create(package.path(), std::make_unique<CssLoader>());
diff --git a/src/input/css/CssPlugin.h b/src/input/css/CssPlugin.h
index 8a31ef95..d26636ad 100644
--- a/src/input/css/CssPlugin.h
+++ b/src/input/css/CssPlugin.h
@@ -21,5 +21,7 @@ public:
 
     Union::StylePackage::Error validatePackage(const Union::StylePackage &package) override;
 
+    Union::PackageHandler::Error createPackage(const Union::StylePackage &package) override;
+
     std::shared_ptr<Union::Style> createStyle(const Union::StylePackage &package) const override;
 };
diff --git a/tools/styletool/styletool.cpp b/tools/styletool/styletool.cpp
index 7af2402f..d0f10e1e 100644
--- a/tools/styletool/styletool.cpp
+++ b/tools/styletool/styletool.cpp
@@ -40,6 +40,90 @@ int handleInstallCommand([[maybe_unused]] const QStringList &arguments)
 
 int handleCreateCommand([[maybe_unused]] const QStringList &arguments)
 {
+    auto parser = parseCommand(u"create"_s, arguments, [](QCommandLineParser *parser) {
+        parser->setApplicationDescription(u"Create a new style. Any missing information will be requested interactively."_s);
+        parser->addOptions({
+            {u"name"_s, u"The name of the style."_s, u"name"_s},
+            {u"id"_s, u"The ID of the style."_s, u"id"_s},
+            {u"author"_s, u"The author of the style."_s, u"author"_s},
+            {u"version"_s, u"The version of the style."_s, u"version"_s},
+            {u"license"_s, u"The license of the style."_s, u"license"_s},
+            {u"output-path"_s, u"The path where the style will be created. If not supplied, the current working directory will be used."_s, u"output-path"_s},
+            {u"input-type"_s, u"The Union input type of the style to create. Currently only supports CSS"_s, u"input-type"_s},
+            {u"install"_s, u"Install the style after creating it."_s},
+        });
+    });
+
+    Union::PackageHandler::CreateInfo info;
+
+    info.inputType = parser->value(u"input-type"_s);
+    if (info.inputType.isEmpty()) {
+        info.inputType = u"css"_s;
+    }
+
+    info.name = parser->value(u"name"_s);
+    readInput(info.name, u"Enter the name of the style:");
+
+    const auto nonIdCharacters = QRegularExpression(u"[^a-zA-Z0-9-_]"_s);
+    const auto suggestedId = info.name.toLower().replace(nonIdCharacters, u"-"_s);
+
+    auto id = parser->value(u"id"_s);
+
+    auto idValidationFunction = [&nonIdCharacters](const QString &input) {
+        if (input.contains(nonIdCharacters)) {
+            std::cout << "The ID can only contain the characters a-z A-Z 0-9 - _\n";
+            return false;
+        } else {
+            return true;
+        }
+    };
+
+    if (!idValidationFunction(id)) {
+        id = QString{};
+    }
+
+    readInput(id, u"Enter the ID of the style:", idValidationFunction, suggestedId);
+
+    info.description = parser->value(u"description"_s);
+    readInput(info.description, u"Enter the description of the style:");
+
+    auto author = parser->value(u"author"_s);
+    readInput(author, u"Enter the name of the author of the style:", u"Unknown Author"_s);
+    info.authors = {author};
+
+    info.version = parser->value(u"version"_s);
+    readInput(info.version, u"Enter the version of the style:", u"0.1"_s);
+
+    info.license = parser->value(u"license"_s);
+    readInput(info.license, u"Enter the license of the style:", u"BSD-2-Clause"_s);
+
+    std::cout << "Creating style:\n";
+    std::cout << "  Input Type:  " << qPrintable(info.inputType) << "\n";
+    std::cout << "  ID:          " << qPrintable(id) << "\n";
+    std::cout << "  Name:        " << qPrintable(info.name) << "\n";
+    std::cout << "  Description: " << qPrintable(info.description) << "\n";
+    std::cout << "  Author:      " << qPrintable(info.authors.join(u", ")) << "\n";
+    std::cout << "  License:     " << qPrintable(info.license) << "\n";
+    std::cout << "  Version:     " << qPrintable(info.version) << "\n";
+
+    if (parser->isSet(u"output-path"_s)) {
+        info.path = fs::path(parser->value(u"output-path"_s).toStdString());
+    } else {
+        info.path = fs::current_path() / id.toStdString();
+    }
+
+    Union::StylePackage package;
+    auto handler = Union::StyleRegistry::instance()->packageHandler();
+    if (auto result = handler->create(package, info); result != Union::PackageHandler::Error::None) {
+        return printHandlerError(package, result);
+    }
+
+    std::cout << "Created style " << package.path() << "\n";
+
+    if (parser->isSet(u"install"_s)) {
+        return handleInstallCommand({QString::fromStdString(package.path())});
+    }
+
     return 0;
 }