From 75cd15ac6f6fb0eadcf7a8b68cb7a7d8150e6c48 Mon Sep 17 00:00:00 2001 From: "@mwilkowski" Date: Sat, 17 Oct 2026 14:15:54 +0200 Subject: [PATCH] feat(theme): follow desktop light/dark switches while running --- src/app/CMakeLists.txt | 3 +- src/app/main.cpp | 45 ++++++++-------- src/app/services/SystemColorScheme.cpp | 61 ++++++++++++++++++++++ src/app/services/SystemColorScheme.h | 39 ++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_system_color_scheme.cpp | 71 ++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 src/app/services/SystemColorScheme.cpp create mode 100644 src/app/services/SystemColorScheme.h create mode 100644 tests/test_system_color_scheme.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 397dba0f..b5020dd8 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(logitune-app-lib STATIC services/DeviceCommandHandler.cpp services/ButtonActionDispatcher.cpp services/ProfileOrchestrator.cpp + services/SystemColorScheme.cpp dialogs/GitHubIssueBuilder.cpp dialogs/CrashReportDialog.cpp TrayManager.cpp @@ -23,7 +24,7 @@ target_include_directories(logitune-app-lib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/services ) -target_link_libraries(logitune-app-lib PUBLIC logitune-core Qt6::Quick Qt6::Widgets) +target_link_libraries(logitune-app-lib PUBLIC logitune-core Qt6::Quick Qt6::Widgets Qt6::DBus) target_compile_definitions(logitune-app-lib PUBLIC PROJECT_VERSION="${PROJECT_VERSION}" PROJECT_VERSION_FULL="${LOGITUNE_DISPLAY_VERSION}" diff --git a/src/app/main.cpp b/src/app/main.cpp index 1a9b3069..9ab11797 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -15,9 +15,6 @@ #include #include #include -#include -#include -#include #include "AppRoot.h" #include "models/EditorModel.h" @@ -25,6 +22,7 @@ #include "logging/CrashHandler.h" #include "dialogs/CrashReportDialog.h" #include "TrayManager.h" +#include "services/SystemColorScheme.h" int main(int argc, char *argv[]) { @@ -145,22 +143,12 @@ int main(int argc, char *argv[]) qCInfo(lcApp) << "settings file:" << settings.fileName() << "contains theme/dark:" << settings.contains("theme/dark") << "value:" << settings.value("theme/dark"); + logitune::SystemColorScheme systemScheme; if (settings.contains("theme/dark")) { isDark = settings.value("theme/dark").toBool(); } else { - QDBusMessage msg = QDBusMessage::createMethodCall( - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - "org.freedesktop.portal.Settings", - "Read"); - msg << QStringLiteral("org.freedesktop.appearance") - << QStringLiteral("color-scheme"); - QDBusMessage reply = QDBusConnection::sessionBus().call(msg, QDBus::Block, 250); - if (reply.type() == QDBusMessage::ReplyMessage && !reply.arguments().isEmpty()) { - QVariant value = reply.arguments().first().value().variant(); - if (value.canConvert()) - value = value.value().variant(); - isDark = (value.toUInt() == 1); + if (auto portalDark = systemScheme.readDark()) { + isDark = *portalDark; } else { QColor windowBg = app.palette().window().color(); double lum = windowBg.redF() * 0.299 + windowBg.greenF() * 0.587 @@ -234,18 +222,29 @@ int main(int argc, char *argv[]) } // Set theme via the root QML object which has access to the Theme singleton - qCInfo(lcApp) << "Setting theme dark:" << isDark; - if (!engine.rootObjects().isEmpty()) { + auto applyDark = [&engine](bool dark) { + qCInfo(lcApp) << "Setting theme dark:" << dark; + if (engine.rootObjects().isEmpty()) + return; QObject *root = engine.rootObjects().first(); QQmlExpression expr(QQmlEngine::contextForObject(root), root, - isDark ? QStringLiteral("Theme.dark = true") - : QStringLiteral("Theme.dark = false")); - QVariant result = expr.evaluate(); + dark ? QStringLiteral("Theme.dark = true") + : QStringLiteral("Theme.dark = false")); + expr.evaluate(); if (expr.hasError()) qCWarning(lcApp) << "Theme expression error:" << expr.error().toString(); else - qCInfo(lcApp) << "Theme.dark applied:" << isDark; - } + qCInfo(lcApp) << "Theme.dark applied:" << dark; + }; + applyDark(isDark); + + // Follow later desktop switches (darkman, GNOME's toggle) unless the user + // picked a theme in Settings, which saves theme/dark and takes priority. + QObject::connect(&systemScheme, &logitune::SystemColorScheme::darkChanged, + &engine, [applyDark](bool dark) { + if (!QSettings().contains("theme/dark")) + applyDark(dark); + }); // System tray logitune::TrayManager tray(controller.deviceModel()); diff --git a/src/app/services/SystemColorScheme.cpp b/src/app/services/SystemColorScheme.cpp new file mode 100644 index 00000000..c5f949b9 --- /dev/null +++ b/src/app/services/SystemColorScheme.cpp @@ -0,0 +1,61 @@ +#include "SystemColorScheme.h" +#include +#include +#include + +namespace logitune { + +namespace { +const QString kService = QStringLiteral("org.freedesktop.portal.Desktop"); +const QString kPath = QStringLiteral("/org/freedesktop/portal/desktop"); +const QString kInterface = QStringLiteral("org.freedesktop.portal.Settings"); +const QString kNamespace = QStringLiteral("org.freedesktop.appearance"); +const QString kKey = QStringLiteral("color-scheme"); +} + +SystemColorScheme::SystemColorScheme(QObject *parent) + : QObject(parent) +{ + QDBusConnection::sessionBus().connect(kService, kPath, kInterface, + QStringLiteral("SettingChanged"), this, + SLOT(onSettingChanged(QString,QString,QDBusVariant))); +} + +std::optional SystemColorScheme::readDark() const +{ + QDBusMessage msg = QDBusMessage::createMethodCall(kService, kPath, kInterface, + QStringLiteral("Read")); + msg << kNamespace << kKey; + QDBusMessage reply = QDBusConnection::sessionBus().call(msg, QDBus::Block, 250); + if (reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) + return std::nullopt; + m_lastDark = darkFromPortalValue(reply.arguments().first()); + return m_lastDark; +} + +std::optional SystemColorScheme::darkFromPortalValue(const QVariant &value) +{ + QVariant v = value; + while (v.userType() == qMetaTypeId()) + v = v.value().variant(); + bool ok = false; + const uint scheme = v.toUInt(&ok); + if (!ok) + return std::nullopt; + return scheme == 1; +} + +void SystemColorScheme::onSettingChanged(const QString &ns, const QString &key, + const QDBusVariant &value) +{ + if (ns != kNamespace || key != kKey) + return; + // GNOME sends each switch twice, so repeats are dropped. + auto dark = darkFromPortalValue(value.variant()); + if (!dark || dark == m_lastDark) + return; + m_lastDark = dark; + emit darkChanged(*dark); +} + +} // namespace logitune diff --git a/src/app/services/SystemColorScheme.h b/src/app/services/SystemColorScheme.h new file mode 100644 index 00000000..d95b36c4 --- /dev/null +++ b/src/app/services/SystemColorScheme.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include + +class QDBusVariant; + +namespace logitune { + +/// Follows the desktop's light/dark preference through the XDG desktop +/// portal (org.freedesktop.appearance color-scheme). Reads the value once on +/// request and emits darkChanged whenever the desktop switches, so the window +/// can follow tools like darkman or GNOME's own toggle without a restart. +class SystemColorScheme : public QObject { + Q_OBJECT +public: + explicit SystemColorScheme(QObject *parent = nullptr); + + /// Current preference from the portal, or nullopt if the portal did not + /// answer within the timeout. + std::optional readDark() const; + + /// Maps a portal color-scheme value to dark/light. The value may arrive + /// wrapped in one or two QDBusVariant layers. 1 means "prefer dark"; + /// 0 (no preference) and 2 (prefer light) both mean light. + static std::optional darkFromPortalValue(const QVariant &value); + +signals: + void darkChanged(bool dark); + +public slots: + void onSettingChanged(const QString &ns, const QString &key, const QDBusVariant &value); + +private: + mutable std::optional m_lastDark; +}; + +} // namespace logitune diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4168b173..f978c5a0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(logitune-tests test_desktop_factory.cpp test_profile_apply_behavior.cpp test_settings_model.cpp + test_system_color_scheme.cpp test_json_device.cpp test_device_fetcher.cpp test_dpi_cycle_ring.cpp diff --git a/tests/test_system_color_scheme.cpp b/tests/test_system_color_scheme.cpp new file mode 100644 index 00000000..92fae7a0 --- /dev/null +++ b/tests/test_system_color_scheme.cpp @@ -0,0 +1,71 @@ +/// Tests for SystemColorScheme: portal value parsing and live change filtering. +#include +#include +#include +#include "services/SystemColorScheme.h" +#include "helpers/TestFixtures.h" + +namespace logitune::test { + +TEST(SystemColorScheme, PreferDarkIsDark) { + EXPECT_EQ(SystemColorScheme::darkFromPortalValue(QVariant(1u)), true); +} + +TEST(SystemColorScheme, NoPreferenceAndPreferLightAreLight) { + EXPECT_EQ(SystemColorScheme::darkFromPortalValue(QVariant(0u)), false); + EXPECT_EQ(SystemColorScheme::darkFromPortalValue(QVariant(2u)), false); +} + +TEST(SystemColorScheme, UnwrapsNestedDBusVariants) { + // Read() replies wrap the value twice; SettingChanged wraps it once. + QVariant once = QVariant::fromValue(QDBusVariant(QVariant(1u))); + QVariant twice = QVariant::fromValue(QDBusVariant(once)); + EXPECT_EQ(SystemColorScheme::darkFromPortalValue(once), true); + EXPECT_EQ(SystemColorScheme::darkFromPortalValue(twice), true); +} + +TEST(SystemColorScheme, NonNumericValueIsIgnored) { + EXPECT_FALSE(SystemColorScheme::darkFromPortalValue(QVariant(QStringLiteral("dark"))).has_value()); +} + +TEST(SystemColorScheme, EmitsOnColorSchemeChange) { + ensureApp(); + SystemColorScheme scheme; + QSignalSpy spy(&scheme, &SystemColorScheme::darkChanged); + + scheme.onSettingChanged(QStringLiteral("org.freedesktop.appearance"), + QStringLiteral("color-scheme"), QDBusVariant(QVariant(1u))); + scheme.onSettingChanged(QStringLiteral("org.freedesktop.appearance"), + QStringLiteral("color-scheme"), QDBusVariant(QVariant(0u))); + + ASSERT_EQ(spy.count(), 2); + EXPECT_TRUE(spy.at(0).at(0).toBool()); + EXPECT_FALSE(spy.at(1).at(0).toBool()); +} + +TEST(SystemColorScheme, RepeatedValueEmitsOnce) { + ensureApp(); + SystemColorScheme scheme; + QSignalSpy spy(&scheme, &SystemColorScheme::darkChanged); + + for (int i = 0; i < 2; ++i) + scheme.onSettingChanged(QStringLiteral("org.freedesktop.appearance"), + QStringLiteral("color-scheme"), QDBusVariant(QVariant(1u))); + + EXPECT_EQ(spy.count(), 1); +} + +TEST(SystemColorScheme, IgnoresOtherSettings) { + ensureApp(); + SystemColorScheme scheme; + QSignalSpy spy(&scheme, &SystemColorScheme::darkChanged); + + scheme.onSettingChanged(QStringLiteral("org.freedesktop.appearance"), + QStringLiteral("accent-color"), QDBusVariant(QVariant(1u))); + scheme.onSettingChanged(QStringLiteral("org.gnome.desktop.interface"), + QStringLiteral("color-scheme"), QDBusVariant(QVariant(1u))); + + EXPECT_EQ(spy.count(), 0); +} + +} // namespace logitune::test