io/fileview: add adapter support and JsonAdapter

This commit is contained in:
outfoxxed 2025-05-13 17:07:51 -07:00
parent cb69c2d016
commit fee4942771
Signed by: outfoxxed
GPG key ID: 4C88A185FB89301E
6 changed files with 487 additions and 2 deletions

View file

@ -2,6 +2,7 @@ qt_add_library(quickshell-io STATIC
datastream.cpp
process.cpp
fileview.cpp
jsonadapter.cpp
ipccomm.cpp
ipc.cpp
ipchandler.cpp

View file

@ -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

View file

@ -14,6 +14,7 @@
#include <qqmlparserstatus.h>
#include <qrunnable.h>
#include <qstringview.h>
#include <qtclasshelpermacros.h>
#include <qtmetamacros.h>
#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<bool> bindablePrintErrors() { return &this->bPrintErrors; }
[[nodiscard]] QBindable<bool> 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

262
src/io/jsonadapter.cpp Normal file
View file

@ -0,0 +1,262 @@
#include "jsonadapter.hpp"
#include <qcontainerfwd.h>
#include <qjsonarray.h>
#include <qjsondocument.h>
#include <qjsonobject.h>
#include <qjsonvalue.h>
#include <qjsvalue.h>
#include <qmetaobject.h>
#include <qnamespace.h>
#include <qobject.h>
#include <qobjectdefs.h>
#include <qqml.h>
#include <qqmlengine.h>
#include <qqmlinfo.h>
#include <qqmllist.h>
#include <qstringview.h>
#include <qvariant.h>
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<JsonObject*>()) {
auto* pobj = prop.read(obj).view<JsonObject*>();
if (pobj) connectNotifiersRec(notifySlot, pobj, &JsonObject::staticMetaObject);
} else if (val.canConvert<QQmlListProperty<JsonObject>>()) {
auto listVal = val.value<QQmlListProperty<JsonObject>>();
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<JsonObject*>()) {
auto* pobj = val.view<JsonObject*>();
if (pobj) {
json.insert(prop.name(), serializeRec(pobj, &JsonObject::staticMetaObject));
} else {
json.insert(prop.name(), QJsonValue::Null);
}
} else if (val.canConvert<QQmlListProperty<JsonObject>>()) {
QJsonArray array;
auto listVal = val.value<QQmlListProperty<JsonObject>>();
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<QJSValue>()) {
auto variant = val.value<QJSValue>().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<QVariant>()) {
auto variant = jval.toVariant();
auto oldValue = prop.read(this).value<QJSValue>();
// 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<QJSValue>(jval.toVariant());
prop.write(this, QVariant::fromValue(jsValue));
}
} else if (QMetaType::canView(prop.metaType(), QMetaType::fromType<JsonObject*>())) {
// 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<JsonObject*>();
auto isNew = currentValue == nullptr;
if (isNew) {
// metaObject->metaType removes the pointer
currentValue =
static_cast<JsonObject*>(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<QQmlListProperty<JsonObject>>()
))
{
auto pval = prop.read(this);
if (pval.canConvert<QQmlListProperty<JsonObject>>()) {
auto lp = pval.value<QQmlListProperty<JsonObject>>();
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<JsonObject*>(QMetaType::fromType<JsonObject>().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<JsonObject> 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

116
src/io/jsonadapter.hpp Normal file
View file

@ -0,0 +1,116 @@
#pragma once
#include <qjsondocument.h>
#include <qjsonobject.h>
#include <qjsonvalue.h>
#include <qjsvalue.h>
#include <qlist.h>
#include <qobjectdefs.h>
#include <qqmlintegration.h>
#include <qqmlparserstatus.h>
#include <qstringview.h>
#include <qtmetamacros.h>
#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<string>` 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<string> 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<JsonObject*> createdObjects;
QList<JsonObject*> oldCreatedObjects;
};
} // namespace qs::io

View file

@ -5,6 +5,7 @@ headers = [
"socket.hpp",
"process.hpp",
"fileview.hpp",
"jsonadapter.hpp",
"ipchandler.hpp",
]
-----