From 85be3861ceadba6e3d877a64cade15015ea0c96f Mon Sep 17 00:00:00 2001 From: outfoxxed Date: Mon, 9 Sep 2024 03:15:16 -0700 Subject: [PATCH] io/fileview: add FileView --- src/core/util.hpp | 41 ++++++ src/io/CMakeLists.txt | 8 +- src/io/FileView.qml | 48 +++++++ src/io/fileview.cpp | 302 ++++++++++++++++++++++++++++++++++++++++++ src/io/fileview.hpp | 236 +++++++++++++++++++++++++++++++++ src/io/module.md | 1 + 6 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 src/io/FileView.qml create mode 100644 src/io/fileview.cpp create mode 100644 src/io/fileview.hpp diff --git a/src/core/util.hpp b/src/core/util.hpp index b2599234..3c1a5ac6 100644 --- a/src/core/util.hpp +++ b/src/core/util.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include + // NOLINTBEGIN #define DROP_EMIT(object, func) \ DropEmitter(object, static_cast([](typeof(object) o) { o->func(); })) @@ -72,6 +74,8 @@ private: DECLARE_MEMBER_GET(name); \ DECLARE_MEMBER_SET(name, setter) +#define DECLARE_MEMBER_SETONLY(class, name, setter, member, signal) DECLARE_MEMBER(cl + #define DECLARE_MEMBER_FULL(class, name, setter, member, signal) \ DECLARE_MEMBER(class, name, member, signal); \ DECLARE_MEMBER_GETSET(name, setter) @@ -123,6 +127,8 @@ private: #define DEFINE_MEMBER_GETSET(Class, name, setter) \ DEFINE_MEMBER_GET(Class, name) \ DEFINE_MEMBER_SET(Class, name, setter) + +#define MEMBER_EMIT(name) std::remove_reference_t::M_##name::emitter(this) // NOLINTEND template @@ -154,6 +160,12 @@ public: } else { if (MemberMetadata::get(obj) == value) return DropEmitter(); obj->*member = value; + return MemberMetadata::emitter(obj); + } + } + + static Ret emitter(Class* obj) { + if constexpr (signal != nullptr) { return DropEmitter(obj, &MemberMetadata::emitForObject); } } @@ -170,3 +182,32 @@ public: using Ref = const Type&; using Ret = std::conditional_t; }; + +class GuardedEmitBlocker { +public: + explicit GuardedEmitBlocker(bool* var): var(var) { *this->var = true; } + ~GuardedEmitBlocker() { *this->var = false; } + Q_DISABLE_COPY_MOVE(GuardedEmitBlocker); + +private: + bool* var; +}; + +template +class GuardedEmitter { + using Traits = MemberPointerTraits; + using Class = Traits::Class; + + bool blocked = false; + +public: + GuardedEmitter() = default; + ~GuardedEmitter() = default; + Q_DISABLE_COPY_MOVE(GuardedEmitter); + + void call(Class* obj) { + if (!this->blocked) (obj->*signal)(); + } + + GuardedEmitBlocker block() { return GuardedEmitBlocker(&this->blocked); } +}; diff --git a/src/io/CMakeLists.txt b/src/io/CMakeLists.txt index 23758064..7113cd7d 100644 --- a/src/io/CMakeLists.txt +++ b/src/io/CMakeLists.txt @@ -1,6 +1,7 @@ qt_add_library(quickshell-io STATIC datastream.cpp process.cpp + fileview.cpp ) add_library(quickshell-io-init OBJECT init.cpp) @@ -9,7 +10,12 @@ if (SOCKETS) target_sources(quickshell-io PRIVATE socket.cpp) endif() -qt_add_qml_module(quickshell-io URI Quickshell.Io VERSION 0.1) +qt_add_qml_module(quickshell-io + URI Quickshell.Io + VERSION 0.1 + QML_FILES + FileView.qml +) target_link_libraries(quickshell-io PRIVATE ${QT_DEPS}) target_link_libraries(quickshell-io-init PRIVATE ${QT_DEPS}) diff --git a/src/io/FileView.qml b/src/io/FileView.qml new file mode 100644 index 00000000..97e99c40 --- /dev/null +++ b/src/io/FileView.qml @@ -0,0 +1,48 @@ +import Quickshell.Io + +FileViewInternal { + property bool preload: this.__preload; + property bool blockLoading: this.__blockLoading; + property bool blockAllReads: this.__blockAllReads; + property string path: this.__path; + + onPreloadChanged: this.__preload = preload; + onBlockLoadingChanged: this.__blockLoading = this.blockLoading; + onBlockAllReadsChanged: this.__blockAllReads = this.blockAllReads; + + // Unfortunately path can't be kept as an empty string until the file loads + // without using QQmlPropertyValueInterceptor which is private. If we lean fully + // into using private code in the future, there will be no reason not to do it here. + + onPathChanged: { + if (!this.preload) this.__preload = false; + this.__path = this.path; + if (this.preload) this.__preload = true; + } + + // The C++ side can't force bindings to be resolved in a specific order so + // its done here. Functions are used to avoid the eager loading aspect of properties. + + // Preload is set as it is below to avoid starting an async read from a preload + // if the user wants an initial blocking read. + + function text(): string { + if (!this.preload) this.__preload = false; + this.__blockLoading = this.blockLoading; + this.__blockAllReads = this.blockAllReads; + this.__path = this.path; + const text = this.__text; + if (this.preload) this.__preload = true; + return text; + } + + function data(): string { + if (!this.preload) this.__preload = false; + this.__blockLoading = this.blockLoading; + this.__blockAllReads = this.blockAllReads; + this.__path = this.path; + const data = this.__data; + if (this.preload) this.__preload = true; + return data; + } +} diff --git a/src/io/fileview.cpp b/src/io/fileview.cpp new file mode 100644 index 00000000..40dde6d7 --- /dev/null +++ b/src/io/fileview.cpp @@ -0,0 +1,302 @@ +#include "fileview.hpp" +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/util.hpp" + +namespace qs::io { + +Q_LOGGING_CATEGORY(logFileView, "quickshell.io.fileview", QtWarningMsg); + +FileViewReader::FileViewReader(QString path, bool doStringConversion) + : doStringConversion(doStringConversion) { + this->state.path = std::move(path); + this->setAutoDelete(false); + this->blockMutex.lock(); +} + +void FileViewReader::run() { + FileViewReader::read(this->state, this->doStringConversion); + + this->blockMutex.unlock(); + QMetaObject::invokeMethod(this, &FileViewReader::finished, Qt::QueuedConnection); +} + +void FileViewReader::block() { + // block until a lock can be acauired, then immediately drop it + auto unused = QMutexLocker(&this->blockMutex); +} + +void FileViewReader::finished() { + emit this->done(); + delete this; +} + +void FileViewReader::read(FileViewState& state, bool doStringConversion) { + { + qCDebug(logFileView) << "Reader started for" << state.path; + + auto info = QFileInfo(state.path); + state.exists = info.exists(); + + if (!state.exists) return; + + if (!info.isFile()) { + qCCritical(logFileView) << state.path << "is not a file."; + goto error; + } else if (!info.isReadable()) { + qCCritical(logFileView) << "No permission to read" << state.path; + state.error = true; + goto error; + } + + auto file = QFile(state.path); + + if (!file.open(QFile::ReadOnly)) { + qCCritical(logFileView) << "Failed to open" << state.path; + goto error; + } + + auto& data = state.data; + data = QByteArray(file.size(), Qt::Uninitialized); + + qint64 i = 0; + + while (true) { + auto r = file.read(data.data() + i, data.length() - i); // NOLINT + + if (r == -1) { + qCCritical(logFileView) << "Failed to read" << state.path; + goto error; + } else if (r == 0) { + data.resize(i); + break; + } + + i += r; + } + + if (doStringConversion) { + state.text = QString::fromUtf8(state.data); + state.textDirty = false; + } else { + state.textDirty = true; + } + + return; + } + +error: + state.error = true; +} + +void FileView::loadAsync(bool doStringConversion) { + if (!this->reader || this->pathInFlight != this->targetPath) { + this->cancelAsync(); + this->pathInFlight = this->targetPath; + + if (this->targetPath.isEmpty()) { + auto state = FileViewState(); + this->updateState(state); + } else { + qCDebug(logFileView) << "Starting async load for" << this << "of" << this->targetPath; + this->reader = new FileViewReader(this->targetPath, doStringConversion); + QObject::connect(this->reader, &FileViewReader::done, this, &FileView::readerFinished); + QThreadPool::globalInstance()->start(this->reader); // takes ownership + } + } +} + +void FileView::cancelAsync() { + if (this->reader) { + qCDebug(logFileView) << "Disowning async read for" << this; + QObject::disconnect(this->reader, nullptr, this, nullptr); + this->reader = nullptr; + } +} + +void FileView::readerFinished() { + if (this->sender() != this->reader) { + qCWarning(logFileView) << "got read finished from dropped FileViewReader" << this->sender(); + return; + } + + qCDebug(logFileView) << "Async load finished for" << this; + this->updateState(this->reader->state); + this->reader = nullptr; +} + +void FileView::reload() { this->updatePath(); } + +bool FileView::blockUntilLoaded() { + if (this->reader != nullptr) { + QObject::disconnect(this->reader, nullptr, this, nullptr); + this->reader->block(); + this->updateState(this->reader->state); + this->reader = nullptr; + return true; + } else return false; +} + +void FileView::loadSync() { + if (this->targetPath.isEmpty()) { + auto state = FileViewState(); + this->updateState(state); + } else if (!this->blockUntilLoaded()) { + auto state = FileViewState {.path = this->targetPath}; + FileViewReader::read(state, false); + this->updateState(state); + } +} + +void FileView::updateState(FileViewState& newState) { + DEFINE_DROP_EMIT_IF(newState.path != this->state.path, this, pathChanged); + // assume if the path was changed the data also changed + auto dataChanged = pathChanged || newState.data != this->state.data; + // DEFINE_DROP_EMIT_IF(newState.exists != this->state.exists, this, existsChanged); + + this->mPrepared = true; + auto loadedChanged = this->setLoadedOrAsync(!newState.path.isEmpty() && newState.exists); + + this->state.path = std::move(newState.path); + + if (dataChanged) { + this->state.data = newState.data; + this->state.text = newState.text; + this->state.textDirty = newState.textDirty; + } + + this->state.exists = newState.exists; + this->state.error = newState.error; + + DropEmitter::call( + pathChanged, + // existsChanged, + loadedChanged + ); + + if (dataChanged) this->emitDataChanged(); + + if (this->state.error) emit this->loadFailed(); +} + +void FileView::textConversion() { + if (this->state.textDirty) { + this->state.text = QString::fromUtf8(this->state.data); + this->state.textDirty = false; + } +} + +QString FileView::path() const { return this->state.path; } + +void FileView::setPath(const QString& path) { + auto p = path.startsWith("file://") ? path.sliced(7) : path; + if (p == this->targetPath) return; + this->targetPath = p; + this->updatePath(); +} + +void FileView::updatePath() { + this->mPrepared = false; + + if (this->targetPath.isEmpty()) { + auto state = FileViewState(); + this->updateState(state); + } else if (this->mPreload) { + this->loadAsync(true); + } else { + // loadAsync will do this already + this->cancelAsync(); + this->emitDataChanged(); + } +} + +bool FileView::shouldBlock() const { + return this->mBlockAllReads || (this->mBlockLoading && !this->mLoadedOrAsync); +} + +QByteArray FileView::data() { + auto guard = this->dataChangedEmitter.block(); + + if (!this->mPrepared) { + if (this->shouldBlock()) this->loadSync(); + else this->loadAsync(false); + } + + return this->state.data; +} + +QString FileView::text() { + auto guard = this->textChangedEmitter.block(); + + if (!this->mPrepared) { + if (this->shouldBlock()) this->loadSync(); + else this->loadAsync(true); + } + + this->textConversion(); + return this->state.text; +} + +void FileView::emitDataChanged() { + this->dataChangedEmitter.call(this); + this->textChangedEmitter.call(this); + emit this->dataChanged(); + emit this->textChanged(); +} + +DEFINE_MEMBER_GETSET(FileView, isLoadedOrAsync, setLoadedOrAsync); +DEFINE_MEMBER_GET(FileView, shouldPreload); +DEFINE_MEMBER_GET(FileView, blockLoading); +DEFINE_MEMBER_GET(FileView, blockAllReads); + +void FileView::setPreload(bool preload) { + if (preload != this->mPreload) { + this->mPreload = preload; + emit this->preloadChanged(); + + if (preload) this->emitDataChanged(); + + if (!this->mPrepared && this->mPreload) { + this->loadAsync(false); + } + } +} + +void FileView::setBlockLoading(bool blockLoading) { + if (blockLoading != this->mBlockLoading) { + auto wasBlocking = this->shouldBlock(); + + this->mBlockLoading = blockLoading; + emit this->blockLoadingChanged(); + + if (!wasBlocking && this->shouldBlock()) { + this->emitDataChanged(); + } + } +} + +void FileView::setBlockAllReads(bool blockAllReads) { + if (blockAllReads != this->mBlockAllReads) { + auto wasBlocking = this->shouldBlock(); + + this->mBlockAllReads = blockAllReads; + emit this->blockAllReadsChanged(); + + if (!wasBlocking && this->shouldBlock()) { + this->emitDataChanged(); + } + } +} + +} // namespace qs::io diff --git a/src/io/fileview.hpp b/src/io/fileview.hpp new file mode 100644 index 00000000..04ed421a --- /dev/null +++ b/src/io/fileview.hpp @@ -0,0 +1,236 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../core/doc.hpp" +#include "../core/util.hpp" + +namespace qs::io { + +struct FileViewState { + QString path; + QString text; + QByteArray data; + bool textDirty = false; + bool exists = false; + bool error = false; +}; + +class FileView; + +class FileViewReader + : public QObject + , public QRunnable { + Q_OBJECT; + +public: + explicit FileViewReader(QString path, bool doStringConversion); + + void run() override; + void block(); + + FileViewState state; + + static void read(FileViewState& state, bool doStringConversion); + +signals: + void done(); + +private slots: + void finished(); + +private: + bool doStringConversion; + QMutex blockMutex; +}; + +///! Simplified reader 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 +/// ```qml +/// FileView { +/// id: jsonFile +/// path: Qt.resolvedUrl("./your.json") +/// // Forces the file to be loaded by the time we call JSON.parse(). +/// // see blockLoading's property documentation for details. +/// blockLoading: true +/// } +/// +/// readonly property var jsonData: JSON.parse(jsonFile.text()) +/// ``` +class FileView: public QObject { + Q_OBJECT; + // clang-format off + /// The path to the file that should be read, or an empty string to unload the file. + QSDOC_PROPERTY_OVERRIDE(QString path READ path WRITE setPath NOTIFY pathChanged); + /// If the file should be loaded in the background immediately when set. Defaults to true. + /// + /// This may either increase or decrease the amount of time it takes to load the file + /// depending on how large the file is, how fast its storage is, and how you access its data. + QSDOC_PROPERTY_OVERRIDE(bool preload READ shouldPreload WRITE setPreload NOTIFY preloadChanged); + /// If @@text() and @@data() should block all operations until the file is loaded. Defaults to false. + /// + /// If the file is already loaded, no blocking will occur. + /// If a file was loaded, and @@path was changed to a new file, no blocking will occur. + /// + /// > [!WARNING] Blocking operations should be used carefully to avoid stutters and other performance + /// > degradations. Blocking means that your interface **WILL NOT FUNCTION** during the call. + /// > + /// > **We recommend you use a blocking load ONLY for files loaded before the windows of your shell + /// > are loaded, which happens after `Component.onCompleted` runs for the root component of your shell.** + /// > + /// > The most reasonable use case would be to load things like configuration files that the program + /// > must have available. + QSDOC_PROPERTY_OVERRIDE(bool blockLoading READ blockLoading WRITE setBlockLoading NOTIFY blockLoadingChanged); + /// If @@text() and @@data() should block all operations while a file loads. Defaults to false. + /// + /// This is nearly identical to @@blockLoading, but will additionally block when + /// a file is loaded and @@path changes. + /// + /// > [!WARNING] We cannot think of a valid use case for this. + /// > You almost definitely want @@blockLoading. + QSDOC_PROPERTY_OVERRIDE(bool blockAllReads READ blockAllReads WRITE setBlockAllReads NOTIFY blockAllReadsChanged); + + QSDOC_HIDE Q_PROPERTY(QString __path READ path WRITE setPath NOTIFY pathChanged); + QSDOC_HIDE Q_PROPERTY(QString __text READ text NOTIFY internalTextChanged); + QSDOC_HIDE Q_PROPERTY(QByteArray __data READ data NOTIFY internalDataChanged); + QSDOC_HIDE Q_PROPERTY(bool __preload READ shouldPreload WRITE setPreload NOTIFY preloadChanged); + /// If a file is currently loaded, which may or may not be the one currently specified by @@path. + /// + /// > [!INFO] If a file is loaded, @@path is changed, and a new file is loaded, + /// > this property will stay true the whole time. + /// > If @@path is set to an empty string to unload the file it will become false. + Q_PROPERTY(bool loaded READ isLoadedOrAsync NOTIFY loadedOrAsyncChanged); + QSDOC_HIDE Q_PROPERTY(bool __blockLoading READ blockLoading WRITE setBlockLoading NOTIFY blockLoadingChanged); + QSDOC_HIDE Q_PROPERTY(bool __blockAllReads READ blockAllReads WRITE setBlockAllReads NOTIFY blockAllReadsChanged); + // clang-format on + QML_NAMED_ELEMENT(FileViewInternal); + QSDOC_NAMED_ELEMENT(FileView); + +public: + explicit FileView(QObject* parent = nullptr): QObject(parent) {} + + /// Returns the data of the file specified by @@path as text. + /// + /// If @@blockAllReads is true, all changes to @@path will cause the program to block + /// when this function is called. + /// + /// If @@blockLoading is true, reading this property before the file has been loaded + /// will block, but changing @@path or calling @@reload() will return the old data + /// until the load completes. + /// + /// If neither is true, an empty string will be returned if no file is loaded, + /// otherwise it will behave as in the case above. + /// + /// > [!INFO] Due to technical limitations, @@text() could not be a property, + /// > however you can treat it like a property, it will trigger property updates + /// > as a property would, and the signal `textChanged()` is present. + //@ Q_INVOKABLE QString text(); + /// Returns the data of the file specified by @@path as an [ArrayBuffer]. + /// + /// If @@blockAllReads is true, all changes to @@path will cause the program to block + /// when this function is called. + /// + /// If @@blockLoading is true, reading this property before the file has been loaded + /// will block, but changing @@path or calling @@reload() will return the old data + /// until the load completes. + /// + /// If neither is true, an empty buffer will be returned if no file is loaded, + /// otherwise it will behave as in the case above. + /// + /// > [!INFO] Due to technical limitations, @@data() could not be a property, + /// > however you can treat it like a property, it will trigger property updates + /// > as a property would, and the signal `dataChanged()` is present. + /// + /// [ArrayBuffer]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer + //@ Q_INVOKABLE QByteArray data(); + + /// Block all operations until the currently running load completes. + /// + /// > [!WARNING] See @@blockLoading for an explanation and warning about blocking. + Q_INVOKABLE bool blockUntilLoaded(); + /// Unload the loaded file and reload it, usually in response to changes. + /// + /// 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(); + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] QByteArray data(); + [[nodiscard]] QString text(); + +signals: + ///! Fires if the file failed to load. A warning will be printed in the log. + void loadFailed(); + + void pathChanged(); + QSDOC_HIDE void internalTextChanged(); + QSDOC_HIDE void internalDataChanged(); + QSDOC_HIDE void textChanged(); + QSDOC_HIDE void dataChanged(); + void preloadChanged(); + void loadedOrAsyncChanged(); + void blockLoadingChanged(); + void blockAllReadsChanged(); + +private slots: + void readerFinished(); + +private: + void loadAsync(bool doStringConversion); + void cancelAsync(); + void loadSync(); + void updateState(FileViewState& newState); + void textConversion(); + void updatePath(); + + [[nodiscard]] bool shouldBlock() const; + + FileViewState state; + FileViewReader* reader = nullptr; + QString pathInFlight; + + QString targetPath; + bool mAsyncUpdate = true; + bool mWritable = false; + bool mCreate = false; + bool mPreload = true; + bool mPrepared = false; + bool mLoadedOrAsync = false; + bool mBlockLoading = false; + bool mBlockAllReads = false; + + GuardedEmitter<&FileView::internalTextChanged> textChangedEmitter; + GuardedEmitter<&FileView::internalDataChanged> dataChangedEmitter; + void emitDataChanged(); + + DECLARE_PRIVATE_MEMBER( + FileView, + isLoadedOrAsync, + setLoadedOrAsync, + mLoadedOrAsync, + loadedOrAsyncChanged + ); + +public: + DECLARE_MEMBER_WITH_GET(FileView, shouldPreload, mPreload, preloadChanged); + DECLARE_MEMBER_WITH_GET(FileView, blockLoading, mBlockLoading, blockLoadingChanged); + DECLARE_MEMBER_WITH_GET(FileView, blockAllReads, mBlockAllReads, blockAllReadsChanged); + + void setPreload(bool preload); + void setBlockLoading(bool blockLoading); + void setBlockAllReads(bool blockAllReads); +}; + +} // namespace qs::io diff --git a/src/io/module.md b/src/io/module.md index 676cff73..8af3799e 100644 --- a/src/io/module.md +++ b/src/io/module.md @@ -4,5 +4,6 @@ headers = [ "datastream.hpp", "socket.hpp", "process.hpp", + "fileview.hpp", ] -----