diff --git a/src/io/CMakeLists.txt b/src/io/CMakeLists.txt index 79e6aed..6bb8e70 100644 --- a/src/io/CMakeLists.txt +++ b/src/io/CMakeLists.txt @@ -2,6 +2,7 @@ qt_add_library(quickshell-io STATIC datastream.cpp process.cpp fileview.cpp + jsonadapter.cpp ipccomm.cpp ipc.cpp ipchandler.cpp diff --git a/src/io/fileview.cpp b/src/io/fileview.cpp index c8073af..e2c841e 100644 --- a/src/io/fileview.cpp +++ b/src/io/fileview.cpp @@ -289,6 +289,12 @@ void FileViewWriter::write( } } +FileView::~FileView() { + if (this->mAdapter) { + this->mAdapter->setFileView(nullptr); + } +} + void FileView::loadAsync(bool doStringConversion) { // Writes update via operationFinished, making a read both invalid and outdated. if (!this->liveOperation || this->pathInFlight != this->targetPath) { @@ -636,4 +642,55 @@ void FileView::setBlockAllReads(bool blockAllReads) { } } +FileViewAdapter* FileView::adapter() const { return this->mAdapter; } + +void FileView::setAdapter(FileViewAdapter* adapter) { + if (adapter == this->mAdapter) return; + + if (this->mAdapter) { + this->mAdapter->setFileView(nullptr); + QObject::disconnect(this->mAdapter, nullptr, this, nullptr); + } + + this->mAdapter = adapter; + + if (adapter) { + this->mAdapter->setFileView(this); + QObject::connect(adapter, &FileViewAdapter::adapterUpdated, this, &FileView::adapterUpdated); + QObject::connect(adapter, &QObject::destroyed, this, &FileView::onAdapterDestroyed); + } + + emit this->adapterChanged(); +} + +void FileView::writeAdapter() { + if (!this->mAdapter) { + qmlWarning(this) << "Cannot call writeAdapter without an adapter."; + return; + } + + this->setData(this->mAdapter->serializeAdapter()); +} + +void FileView::onAdapterDestroyed() { this->mAdapter = nullptr; } + +void FileViewAdapter::setFileView(FileView* fileView) { + if (fileView == this->mFileView) return; + + if (this->mFileView) { + QObject::disconnect(this->mFileView, nullptr, this, nullptr); + } + + this->mFileView = fileView; + + if (fileView) { + QObject::connect(fileView, &FileView::dataChanged, this, &FileViewAdapter::onDataChanged); + this->setFileView(fileView); + } else { + this->setFileView(nullptr); + } +} + +void FileViewAdapter::onDataChanged() { this->deserializeAdapter(this->mFileView->data()); } + } // namespace qs::io diff --git a/src/io/fileview.hpp b/src/io/fileview.hpp index 81dd985..4d6128c 100644 --- a/src/io/fileview.hpp +++ b/src/io/fileview.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "../core/doc.hpp" @@ -140,11 +141,13 @@ public: bool doAtomicWrite; }; -///! Simplified reader for small files. +class FileViewAdapter; + +///! Simple accessor for small files. /// A reader for small to medium files that don't need seeking/cursor access, /// suitable for most text files. /// -/// #### Example: Reading a JSON +/// #### Example: Reading a JSON as text /// ```qml /// FileView { /// id: jsonFile @@ -156,6 +159,8 @@ public: /// /// readonly property var jsonData: JSON.parse(jsonFile.text()) /// ``` +/// +/// Also see @@JsonAdapter for an alternative way to handle reading and writing JSON files. class FileView: public QObject { Q_OBJECT; // clang-format off @@ -215,6 +220,14 @@ class FileView: public QObject { /// > } /// > ``` Q_PROPERTY(bool watchChanges READ default WRITE default NOTIFY watchChangesChanged BINDABLE bindableWatchChanges); + /// In addition to directly reading/writing the file as text, *adapters* can be used to + /// expose a file's content in new ways. + /// + /// An adapter will automatically be given the loaded file's content. + /// Its state may be saved with @@writeAdapter(). + /// + /// Currently the only adapter is @@JsonAdapter. + Q_PROPERTY(FileViewAdapter* adapter READ adapter WRITE setAdapter NOTIFY adapterChanged); QSDOC_HIDE Q_PROPERTY(QString __path READ path WRITE setPath NOTIFY pathChanged); QSDOC_HIDE Q_PROPERTY(QString __text READ text NOTIFY internalTextChanged); @@ -230,11 +243,14 @@ class FileView: public QObject { QSDOC_HIDE Q_PROPERTY(bool __blockAllReads READ blockAllReads WRITE setBlockAllReads NOTIFY blockAllReadsChanged); QSDOC_HIDE Q_PROPERTY(bool __printErrors READ default WRITE default NOTIFY printErrorsChanged BINDABLE bindablePrintErrors); // clang-format on + Q_CLASSINFO("DefaultProperty", "adapter"); QML_NAMED_ELEMENT(FileViewInternal); QSDOC_NAMED_ELEMENT(FileView); public: explicit FileView(QObject* parent = nullptr): QObject(parent) {} + ~FileView() override; + Q_DISABLE_COPY_MOVE(FileView); /// Returns the data of the file specified by @@path as text. /// @@ -280,6 +296,8 @@ public: /// This will not block if @@blockLoading is set, only if @@blockAllReads is true. /// It acts the same as changing @@path to a new file, except loading the same file. Q_INVOKABLE void reload(); + /// Write the content of the current @@adapter to the selected file. + Q_INVOKABLE void writeAdapter(); [[nodiscard]] QString path() const; void setPath(const QString& path); @@ -312,6 +330,9 @@ public: [[nodiscard]] QBindable bindablePrintErrors() { return &this->bPrintErrors; } [[nodiscard]] QBindable bindableWatchChanges() { return &this->bWatchChanges; } + [[nodiscard]] FileViewAdapter* adapter() const; + void setAdapter(FileViewAdapter* adapter); + signals: /// Emitted if the file was loaded successfully. void loaded(); @@ -323,6 +344,8 @@ signals: void saveFailed(qs::io::FileViewError::Enum error); /// Emitted if the file changes on disk and @@watchChanges is true. void fileChanged(); + /// Emitted when the active @@adapter$'s data is changed. + void adapterUpdated(); void pathChanged(); QSDOC_HIDE void internalTextChanged(); @@ -337,9 +360,11 @@ signals: void atomicWritesChanged(); void printErrorsChanged(); void watchChangesChanged(); + void adapterChanged(); private slots: void operationFinished(); + void onAdapterDestroyed(); private: void loadAsync(bool doStringConversion); @@ -373,6 +398,7 @@ private: bool mBlockLoading = false; bool mBlockAllReads = false; + FileViewAdapter* mAdapter = nullptr; QFileSystemWatcher* watcher = nullptr; GuardedEmitter<&FileView::internalTextChanged> textChangedEmitter; @@ -406,4 +432,26 @@ public: void setBlockAllReads(bool blockAllReads); }; +/// See @@FileView.adapter. +class FileViewAdapter: public QObject { + Q_OBJECT; + QML_ELEMENT; + QML_UNCREATABLE(""); + +public: + void setFileView(FileView* fileView); + virtual void deserializeAdapter(const QByteArray& data) = 0; + [[nodiscard]] virtual QByteArray serializeAdapter() = 0; + +signals: + /// This signal is fired when data in the adapter changes, and triggers @@FileView.adapterUpdated(s). + void adapterUpdated(); + +private slots: + void onDataChanged(); + +protected: + FileView* mFileView = nullptr; +}; + } // namespace qs::io diff --git a/src/io/jsonadapter.cpp b/src/io/jsonadapter.cpp new file mode 100644 index 0000000..80ac091 --- /dev/null +++ b/src/io/jsonadapter.cpp @@ -0,0 +1,262 @@ +#include "jsonadapter.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace qs::io { + +void JsonAdapter::componentComplete() { this->connectNotifiers(); } + +void JsonAdapter::deserializeAdapter(const QByteArray& data) { + if (data.isEmpty()) return; + + // Importing this makes CI builds fail for some reason. + QJsonParseError error; // NOLINT (misc-include-cleaner) + auto json = QJsonDocument::fromJson(data, &error); + + if (error.error != QJsonParseError::NoError) { + qmlWarning(this) << "Failed to deserialize json: " << error.errorString(); + return; + } + + if (!json.isObject()) { + qmlWarning(this) << "Failed to deserialize json: not an object"; + return; + } + + this->changesBlocked = true; + this->oldCreatedObjects = this->createdObjects; + this->createdObjects.clear(); + + this->deserializeRec(json.object(), this, &JsonAdapter::staticMetaObject); + + for (auto* object: oldCreatedObjects) { + delete object; // FIXME: QMetaType::destroy? + } + + this->oldCreatedObjects.clear(); + this->changesBlocked = false; + + this->connectNotifiers(); +} + +void JsonAdapter::connectNotifiers() { + auto notifySlot = JsonAdapter::staticMetaObject.indexOfSlot("onPropertyChanged()"); + connectNotifiersRec(notifySlot, this, &JsonAdapter::staticMetaObject); +} + +void JsonAdapter::connectNotifiersRec(int notifySlot, QObject* obj, const QMetaObject* base) { + const auto* metaObject = obj->metaObject(); + + for (auto i = base->propertyOffset(); i != metaObject->propertyCount(); i++) { + const auto prop = metaObject->property(i); + + if (prop.isReadable() && prop.hasNotifySignal()) { + QMetaObject::connect(obj, prop.notifySignalIndex(), this, notifySlot, Qt::UniqueConnection); + + auto val = prop.read(obj); + if (val.canView()) { + auto* pobj = prop.read(obj).view(); + if (pobj) connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject); + } else if (val.canConvert>()) { + auto listVal = val.value>(); + + auto len = listVal.count(&listVal); + for (auto i = 0; i != len; i++) { + auto* pobj = listVal.at(&listVal, i); + + if (pobj) connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject); + } + } + } + } +} + +void JsonAdapter::onPropertyChanged() { + if (this->changesBlocked) return; + + this->connectNotifiers(); + this->adapterUpdated(); +} + +QByteArray JsonAdapter::serializeAdapter() { + return QJsonDocument(this->serializeRec(this, &JsonAdapter::staticMetaObject)) + .toJson(QJsonDocument::Indented); +} + +QJsonObject JsonAdapter::serializeRec(const QObject* obj, const QMetaObject* base) const { + QJsonObject json; + const auto* metaObject = obj->metaObject(); + + for (auto i = base->propertyOffset(); i != metaObject->propertyCount(); i++) { + const auto prop = metaObject->property(i); + + if (prop.isReadable() && prop.hasNotifySignal()) { + auto val = prop.read(obj); + if (val.canView()) { + auto* pobj = val.view(); + + if (pobj) { + json.insert(prop.name(), serializeRec(pobj, &JsonObject::staticMetaObject)); + } else { + json.insert(prop.name(), QJsonValue::Null); + } + } else if (val.canConvert>()) { + QJsonArray array; + auto listVal = val.value>(); + + auto len = listVal.count(&listVal); + for (auto i = 0; i != len; i++) { + auto* pobj = listVal.at(&listVal, i); + + if (pobj) { + array.push_back(serializeRec(pobj, &JsonObject::staticMetaObject)); + } else { + array.push_back(QJsonValue::Null); + } + } + + json.insert(prop.name(), array); + } else if (val.canConvert()) { + auto variant = val.value().toVariant(); + auto jv = QJsonValue::fromVariant(variant); + json.insert(prop.name(), jv); + } else { + auto jv = QJsonValue::fromVariant(val); + json.insert(prop.name(), jv); + } + } + } + + return json; +} + +void JsonAdapter::deserializeRec(const QJsonObject& json, QObject* obj, const QMetaObject* base) { + const auto* metaObject = obj->metaObject(); + + for (auto i = base->propertyOffset(); i != metaObject->propertyCount(); i++) { + const auto prop = metaObject->property(i); + if (json.contains(prop.name())) { + auto jval = json.value(prop.name()); + + if (prop.metaType() == QMetaType::fromType()) { + auto variant = jval.toVariant(); + auto oldValue = prop.read(this).value(); + + // Calling prop.write with a new QJSValue will cause a property update + // even if content is identical. + if (jval.toVariant() != oldValue.toVariant()) { + auto jsValue = qmlEngine(this)->fromVariant(jval.toVariant()); + prop.write(this, QVariant::fromValue(jsValue)); + } + } else if (QMetaType::canView(prop.metaType(), QMetaType::fromType())) { + // FIXME: This doesn't support creating descendants of JsonObject, as QMetaType.metaObject() + // returns null for QML types. + + if (jval.isObject()) { + auto* currentValue = prop.read(obj).view(); + auto isNew = currentValue == nullptr; + + if (isNew) { + // metaObject->metaType removes the pointer + currentValue = + static_cast(prop.metaType().metaObject()->metaType().create()); + + currentValue->setParent(this); + this->createdObjects.push_back(currentValue); + } else if (oldCreatedObjects.removeOne(currentValue)) { + createdObjects.push_back(currentValue); + } + + this->deserializeRec(jval.toObject(), currentValue, &JsonObject::staticMetaObject); + + if (isNew) prop.write(obj, QVariant::fromValue(currentValue)); + } else if (jval.isNull()) { + prop.write(obj, QVariant::fromValue(nullptr)); + } else { + qmlWarning(this) << "Failed to deserialize property " << prop.name() << " as object. Got " + << jval.toVariant().typeName(); + } + } else if (QMetaType::canConvert( + prop.metaType(), + QMetaType::fromType>() + )) + { + auto pval = prop.read(this); + + if (pval.canConvert>()) { + auto lp = pval.value>(); + auto array = jval.toArray(); + auto lpCount = lp.count(&lp); + + auto i = 0; + for (; i != array.count(); i++) { + JsonObject* currentValue = nullptr; + auto isNew = i >= lpCount; + + const auto& jsonValue = array.at(i); + if (jsonValue.isObject()) { + if (isNew) { + currentValue = lp.at(&lp, i); + if (oldCreatedObjects.removeOne(currentValue)) { + createdObjects.push_back(currentValue); + } + } else { + // FIXME: should be the type inside the QQmlListProperty but how can we get that? + currentValue = static_cast(QMetaType::fromType().create()); + currentValue->setParent(this); + this->createdObjects.push_back(currentValue); + } + + this->deserializeRec( + jsonValue.toObject(), + currentValue, + &JsonObject::staticMetaObject + ); + } else if (!jsonValue.isNull()) { + qmlWarning(this) << "Failed to deserialize property" << prop.name() + << ": Member of object array is not an object: " + << jsonValue.toVariant().typeName(); + } + + if (isNew) { + lp.append(&lp, currentValue); + } + } + + for (; i < lpCount; i++) { + lp.removeLast(&lp); + } + } else { + qmlWarning(this) << "Failed to deserialize property " << prop.name() + << ": property is a list but contains null."; + } + } else { + auto variant = jval.toVariant(); + + if (variant.convert(prop.metaType())) { + prop.write(obj, variant); + } else { + qmlWarning(this) << "Failed to deserialize property " << prop.name() << ": expected " + << prop.metaType().name() << " but got " << jval.toVariant().typeName(); + } + } + } + } +} + +} // namespace qs::io diff --git a/src/io/jsonadapter.hpp b/src/io/jsonadapter.hpp new file mode 100644 index 0000000..a447c41 --- /dev/null +++ b/src/io/jsonadapter.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fileview.hpp" + +namespace qs::io { + +/// See @@JsonAdapter. +class JsonObject: public QObject { + Q_OBJECT; + QML_ELEMENT; +}; + +///! FileView adapter for accessing JSON files. +/// JsonAdapter is a @@FileView adapter that exposes a JSON file as a set of QML +/// properties that can be read and written to. +/// +/// Each property defined in a JsonAdapter corresponds to a key in the JSON file. +/// Supported property types are: +/// - Primitves (`int`, `bool`, `string`, `real`) +/// - Sub-object adapters (@@JsonObject$) +/// - JSON objects and arrays, as a `var` type +/// - Lists of any of the above (`list` etc) +/// +/// When the @@FileView$'s data is loaded, properties of a JsonAdapter or +/// sub-object adapter (@@JsonObject$) are updated if their values have changed. +/// +/// When properties of a JsonAdapter or sub-object adapter are changed from QML, +/// @@FileView.adapterUpdated(s) is emitted, which may be used to save the file's new +/// state (see @@FileView.writeAdapter()$). +/// +/// ### Example +/// ```qml +/// @@FileView { +/// path: "/path/to/file" +/// +/// // when changes are made on disk, reload the file's content +/// watchChanges: true +/// onFileChanged: reload() +/// +/// // when changes are made to properties in the adapter, save them +/// onAdapterUpdated: writeAdapter() +/// +/// JsonAdapter { +/// property string myStringProperty: "default value" +/// onMyStringPropertyChanged: { +/// console.log("myStringProperty was changed via qml or on disk") +/// } +/// +/// property list stringList: [ "default", "value" ] +/// +/// property JsonObject subObject: JsonObject { +/// property string subObjectProperty: "default value" +/// onSubObjectPropertyChanged: console.log("same as above") +/// } +/// +/// // works the same way as subObject +/// property var inlineJson: { "a": "b" } +/// } +/// } +/// ``` +/// +/// The above snippet produces the JSON document below: +/// ```json +/// { +/// "myStringProperty": "default value", +/// "stringList": [ +/// "default", +/// "value" +/// ], +/// "subObject": { +/// "subObjectProperty": "default value" +/// }, +/// "inlineJson": { +/// "a": "b" +/// } +/// } +/// ``` +class JsonAdapter + : public FileViewAdapter + , public QQmlParserStatus { + Q_OBJECT; + QML_ELEMENT; + +public: + void classBegin() override {} + void componentComplete() override; + + void deserializeAdapter(const QByteArray& data) override; + [[nodiscard]] QByteArray serializeAdapter() override; + +private slots: + void onPropertyChanged(); + +private: + void connectNotifiers(); + void connectNotifiersRec(int notifySlot, QObject* obj, const QMetaObject* base); + void deserializeRec(const QJsonObject& json, QObject* obj, const QMetaObject* base); + [[nodiscard]] QJsonObject serializeRec(const QObject* obj, const QMetaObject* base) const; + + bool changesBlocked = false; + QList createdObjects; + QList oldCreatedObjects; +}; + +} // namespace qs::io diff --git a/src/io/module.md b/src/io/module.md index 8c9e510..2fb8688 100644 --- a/src/io/module.md +++ b/src/io/module.md @@ -5,6 +5,7 @@ headers = [ "socket.hpp", "process.hpp", "fileview.hpp", + "jsonadapter.hpp", "ipchandler.hpp", ] -----