diff --git a/plugin/src/Caelestia/Config/CMakeLists.txt b/plugin/src/Caelestia/Config/CMakeLists.txt index 92ee2da9..a77a1fe9 100644 --- a/plugin/src/Caelestia/Config/CMakeLists.txt +++ b/plugin/src/Caelestia/Config/CMakeLists.txt @@ -3,6 +3,7 @@ qml_module(caelestia-config SOURCES config.cpp configobject.cpp + rootconfig.cpp configscope.cpp appearanceconfig.cpp tokens.cpp diff --git a/plugin/src/Caelestia/Config/config.hpp b/plugin/src/Caelestia/Config/config.hpp index d683df11..1a989143 100644 --- a/plugin/src/Caelestia/Config/config.hpp +++ b/plugin/src/Caelestia/Config/config.hpp @@ -13,6 +13,7 @@ #include "lockconfig.hpp" #include "notifsconfig.hpp" #include "osdconfig.hpp" +#include "rootconfig.hpp" #include "serviceconfig.hpp" #include "sessionconfig.hpp" #include "sidebarconfig.hpp" diff --git a/plugin/src/Caelestia/Config/configobject.cpp b/plugin/src/Caelestia/Config/configobject.cpp index 4e198a41..cc38d7e5 100644 --- a/plugin/src/Caelestia/Config/configobject.cpp +++ b/plugin/src/Caelestia/Config/configobject.cpp @@ -1,10 +1,6 @@ #include "configobject.hpp" -#include -#include -#include #include -#include #include #include #include @@ -15,6 +11,8 @@ namespace caelestia::config { Q_LOGGING_CATEGORY(lcConfig, "caelestia.config", QtInfoMsg) +// ConfigObject + ConfigObject::ConfigObject(QObject* parent) : QObject(parent) {} @@ -169,24 +167,6 @@ void ConfigObject::syncFromGlobal(ConfigObject* global) { } } -void ConfigObject::markPropertyLoaded(const QString& name) { - m_loadedKeys.insert(name); -} - -void ConfigObject::resetOption(const QString& name) { - m_loadedKeys.remove(name); - - // If synced from global, re-copy the global value - if (m_global) { - int idx = metaObject()->indexOfProperty(name.toUtf8().constData()); - if (idx >= 0) { - auto prop = metaObject()->property(idx); - if (prop.isWritable()) - prop.write(this, prop.read(m_global)); - } - } -} - void ConfigObject::resyncFromGlobal() { if (!m_global) return; @@ -214,6 +194,24 @@ void ConfigObject::resyncFromGlobal() { } } +void ConfigObject::markPropertyLoaded(const QString& name) { + m_loadedKeys.insert(name); +} + +void ConfigObject::resetOption(const QString& name) { + m_loadedKeys.remove(name); + + // If synced from global, re-copy the global value + if (m_global) { + int idx = metaObject()->indexOfProperty(name.toUtf8().constData()); + if (idx >= 0) { + auto prop = metaObject()->property(idx); + if (prop.isWritable()) + prop.write(this, prop.read(m_global)); + } + } +} + void ConfigObject::onGlobalPropertiesChanged(const QMap& changed) { for (auto it = changed.begin(); it != changed.end(); ++it) { if (m_loadedKeys.contains(it.key())) @@ -222,9 +220,7 @@ void ConfigObject::onGlobalPropertiesChanged(const QMap& chan int idx = metaObject()->indexOfProperty(it.key().toUtf8().constData()); if (idx >= 0) { metaObject()->property(idx).write(this, it.value()); - // Remove the key that was added by markPropertyLoaded in the setter — - // this is a synced value, not an explicit override - m_loadedKeys.remove(it.key()); + m_loadedKeys.remove(it.key()); // setter added it — remove since this is a synced value qCDebug(lcConfig) << metaObject()->className() << "synced" << it.key() << "=" << it.value() << "from global change"; } @@ -253,134 +249,4 @@ void ConfigObject::emitBatchedChanges() { emit propertiesChanged(changes); } -void ConfigObject::setupFileBackend(const QString& path) { - m_filePath = path; - - m_watcher = new QFileSystemWatcher(this); - m_saveTimer = new QTimer(this); - m_cooldownTimer = new QTimer(this); - m_retryTimer = new QTimer(this); - - m_retryTimer->setSingleShot(true); - m_retryTimer->setInterval(50); - connect(m_retryTimer, &QTimer::timeout, this, &ConfigObject::reloadFromFile); - - m_saveTimer->setSingleShot(true); - m_saveTimer->setInterval(500); - connect(m_saveTimer, &QTimer::timeout, this, [this] { - QDir().mkpath(QFileInfo(m_filePath).absolutePath()); - - QFile file(m_filePath); - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(lcConfig, "Failed to write %s", qUtf8Printable(m_filePath)); - if (auto* root = qobject_cast(this)) - emit root->saveFailed(QStringLiteral("Failed to open file for writing")); - return; - } - - auto json = toJsonObject(); - file.write(QJsonDocument(json).toJson(QJsonDocument::Indented)); - file.close(); - if (auto* root = qobject_cast(this)) - emit root->saved(); - }); - - m_cooldownTimer->setSingleShot(true); - m_cooldownTimer->setInterval(2000); - connect(m_cooldownTimer, &QTimer::timeout, this, [this] { - m_recentlySaved = false; - }); - - connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &ConfigObject::onFileChanged); - - qCDebug(lcConfig) << "Setting up file backend for" << metaObject()->className() << "at" << path; - - reloadFromFile(); - - if (QFile::exists(m_filePath)) - m_watcher->addPath(m_filePath); -} - -void ConfigObject::saveToFile() { - if (!m_saveTimer) - return; - m_saveTimer->start(); - m_recentlySaved = true; - m_cooldownTimer->start(); -} - -bool ConfigObject::reloadFromFile() { - QFile file(m_filePath); - - if (!file.open(QIODevice::ReadOnly)) { - qCDebug(lcConfig, "Failed to open %s", qUtf8Printable(m_filePath)); - return false; - } - - QJsonParseError error{}; - auto doc = QJsonDocument::fromJson(file.readAll(), &error); - - if (error.error != QJsonParseError::NoError) { - if (m_retryTimer && m_parseRetries < 3) { - m_parseRetries++; - qCDebug(lcConfig, "Failed to parse %s: %s - retrying (%d/3)", qUtf8Printable(m_filePath), - qUtf8Printable(error.errorString()), m_parseRetries); - m_retryTimer->start(); - } else { - qCWarning( - lcConfig, "Failed to parse %s: %s", qUtf8Printable(m_filePath), qUtf8Printable(error.errorString())); - m_parseRetries = 0; - } - return false; - } - - m_parseRetries = 0; - - qCDebug(lcConfig) << "Reloading" << metaObject()->className() << "from" << m_filePath; - - clearLoadedKeys(); - loadFromJson(doc.object()); - - // Re-sync non-loaded properties from global after reload - if (m_global) { - qCDebug(lcConfig) << "Re-syncing" << metaObject()->className() << "from global after reload"; - resyncFromGlobal(); - } - - return true; -} - -void ConfigObject::onFileChanged() { - if (!m_watcher->files().contains(m_filePath)) - m_watcher->addPath(m_filePath); - - if (!m_recentlySaved) { - m_parseRetries = 0; - if (m_retryTimer) - m_retryTimer->stop(); - - bool ok = reloadFromFile(); - if (auto* root = qobject_cast(this)) { - if (ok) - emit root->loaded(); - else - emit root->loadFailed(QStringLiteral("Failed to load config file")); - } - } -} - -// RootConfig - -RootConfig::RootConfig(QObject* parent) - : ConfigObject(parent) {} - -void RootConfig::save() { - saveToFile(); -} - -void RootConfig::reload() { - if (reloadFromFile()) - emit loaded(); -} - } // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/configobject.hpp b/plugin/src/Caelestia/Config/configobject.hpp index 66df4ac3..4e0cae92 100644 --- a/plugin/src/Caelestia/Config/configobject.hpp +++ b/plugin/src/Caelestia/Config/configobject.hpp @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -55,14 +54,7 @@ public: void loadFromJson(const QJsonObject& obj); [[nodiscard]] QJsonObject toJsonObject() const; - // File-backed config support. Call setupFileBackend() to enable - // automatic file watching, debounced saving, and reload. - void setupFileBackend(const QString& path); - void saveToFile(); - bool reloadFromFile(); - // Per-monitor overlay support (Qt Resolve Mask pattern). - // Eagerly syncs non-overridden properties from a global ConfigObject. void syncFromGlobal(ConfigObject* global); void resyncFromGlobal(); void clearLoadedKeys(); @@ -71,8 +63,6 @@ public: Q_INVOKABLE void resetOption(const QString& name); - [[nodiscard]] bool recentlySaved() const { return m_recentlySaved; } - template static bool updateMember(T& member, const T& value) { if constexpr (std::is_floating_point_v) { if (qFuzzyCompare(member + 1.0, value + 1.0)) @@ -93,20 +83,9 @@ protected: void notifyPropertyChanged(const QString& name, const QVariant& value); private: - void onFileChanged(); void onGlobalPropertiesChanged(const QMap& changed); void emitBatchedChanges(); - QString m_filePath; - bool m_recentlySaved = false; - - // File backend (heap-allocated only when setupFileBackend is called) - QFileSystemWatcher* m_watcher = nullptr; - QTimer* m_saveTimer = nullptr; - QTimer* m_cooldownTimer = nullptr; - QTimer* m_retryTimer = nullptr; - int m_parseRetries = 0; - // Per-monitor overlay state ConfigObject* m_global = nullptr; QSet m_loadedKeys; @@ -114,22 +93,4 @@ private: QTimer* m_batchTimer = nullptr; }; -// Intermediate base for singleton config roots (GlobalConfig, TokenConfig). -// Provides save/reload with file lifecycle signals. -class RootConfig : public ConfigObject { - Q_OBJECT - -public: - explicit RootConfig(QObject* parent = nullptr); - - Q_INVOKABLE void save(); - Q_INVOKABLE void reload(); - -signals: - void loaded(); - void loadFailed(const QString& error); - void saved(); - void saveFailed(const QString& error); -}; - } // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/rootconfig.cpp b/plugin/src/Caelestia/Config/rootconfig.cpp new file mode 100644 index 00000000..841d01f4 --- /dev/null +++ b/plugin/src/Caelestia/Config/rootconfig.cpp @@ -0,0 +1,127 @@ +#include "rootconfig.hpp" + +#include +#include +#include +#include + +namespace caelestia::config { + +RootConfig::RootConfig(QObject* parent) + : ConfigObject(parent) {} + +void RootConfig::setupFileBackend(const QString& path) { + m_filePath = path; + + m_watcher = new QFileSystemWatcher(this); + m_saveTimer = new QTimer(this); + m_cooldownTimer = new QTimer(this); + m_retryTimer = new QTimer(this); + + m_retryTimer->setSingleShot(true); + m_retryTimer->setInterval(50); + connect(m_retryTimer, &QTimer::timeout, this, &RootConfig::reloadFromFile); + + m_saveTimer->setSingleShot(true); + m_saveTimer->setInterval(500); + connect(m_saveTimer, &QTimer::timeout, this, [this] { + QDir().mkpath(QFileInfo(m_filePath).absolutePath()); + + QFile file(m_filePath); + if (!file.open(QIODevice::WriteOnly)) { + qCWarning(lcConfig, "Failed to write %s", qUtf8Printable(m_filePath)); + emit saveFailed(QStringLiteral("Failed to open file for writing")); + return; + } + + auto json = toJsonObject(); + file.write(QJsonDocument(json).toJson(QJsonDocument::Indented)); + file.close(); + emit saved(); + }); + + m_cooldownTimer->setSingleShot(true); + m_cooldownTimer->setInterval(2000); + connect(m_cooldownTimer, &QTimer::timeout, this, [this] { + m_recentlySaved = false; + }); + + connect(m_watcher, &QFileSystemWatcher::fileChanged, this, &RootConfig::onFileChanged); + + qCDebug(lcConfig) << "Setting up file backend for" << metaObject()->className() << "at" << path; + + reloadFromFile(); + + if (QFile::exists(m_filePath)) + m_watcher->addPath(m_filePath); +} + +void RootConfig::saveToFile() { + if (!m_saveTimer) + return; + m_saveTimer->start(); + m_recentlySaved = true; + m_cooldownTimer->start(); +} + +bool RootConfig::reloadFromFile() { + QFile file(m_filePath); + + if (!file.open(QIODevice::ReadOnly)) { + qCDebug(lcConfig, "Failed to open %s", qUtf8Printable(m_filePath)); + return false; + } + + QJsonParseError error{}; + auto doc = QJsonDocument::fromJson(file.readAll(), &error); + + if (error.error != QJsonParseError::NoError) { + if (m_retryTimer && m_parseRetries < 3) { + m_parseRetries++; + qCDebug(lcConfig, "Failed to parse %s: %s - retrying (%d/3)", qUtf8Printable(m_filePath), + qUtf8Printable(error.errorString()), m_parseRetries); + m_retryTimer->start(); + } else { + qCWarning( + lcConfig, "Failed to parse %s: %s", qUtf8Printable(m_filePath), qUtf8Printable(error.errorString())); + m_parseRetries = 0; + } + return false; + } + + m_parseRetries = 0; + + qCDebug(lcConfig) << "Reloading" << metaObject()->className() << "from" << m_filePath; + + clearLoadedKeys(); + loadFromJson(doc.object()); + + return true; +} + +void RootConfig::onFileChanged() { + if (!m_watcher->files().contains(m_filePath)) + m_watcher->addPath(m_filePath); + + if (!m_recentlySaved) { + m_parseRetries = 0; + if (m_retryTimer) + m_retryTimer->stop(); + + if (reloadFromFile()) + emit loaded(); + else + emit loadFailed(QStringLiteral("Failed to load config file")); + } +} + +void RootConfig::save() { + saveToFile(); +} + +void RootConfig::reload() { + if (reloadFromFile()) + emit loaded(); +} + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/rootconfig.hpp b/plugin/src/Caelestia/Config/rootconfig.hpp new file mode 100644 index 00000000..bb644acd --- /dev/null +++ b/plugin/src/Caelestia/Config/rootconfig.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "configobject.hpp" + +#include +#include + +namespace caelestia::config { + +// Intermediate base for singleton config roots (GlobalConfig, TokenConfig). +// Provides file-backed persistence, save/reload, and lifecycle signals. +class RootConfig : public ConfigObject { + Q_OBJECT + +public: + explicit RootConfig(QObject* parent = nullptr); + + void setupFileBackend(const QString& path); + void saveToFile(); + bool reloadFromFile(); + + [[nodiscard]] bool recentlySaved() const { return m_recentlySaved; } + + Q_INVOKABLE void save(); + Q_INVOKABLE void reload(); + +signals: + void loaded(); + void loadFailed(const QString& error); + void saved(); + void saveFailed(const QString& error); + +private: + void onFileChanged(); + + QString m_filePath; + bool m_recentlySaved = false; + + QFileSystemWatcher* m_watcher = nullptr; + QTimer* m_saveTimer = nullptr; + QTimer* m_cooldownTimer = nullptr; + QTimer* m_retryTimer = nullptr; + int m_parseRetries = 0; +}; + +} // namespace caelestia::config diff --git a/plugin/src/Caelestia/Config/tokens.hpp b/plugin/src/Caelestia/Config/tokens.hpp index 69740aab..231cbb52 100644 --- a/plugin/src/Caelestia/Config/tokens.hpp +++ b/plugin/src/Caelestia/Config/tokens.hpp @@ -2,7 +2,7 @@ #include "anim.hpp" #include "appearanceconfig.hpp" -#include "configobject.hpp" +#include "rootconfig.hpp" #include #include