From 08264e575938a9e5860f46a46bf23e7404479be5 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sun, 31 May 2026 14:06:05 +0200 Subject: [PATCH] Add wallpaper-driven Shift dynamic theming Expose theme and accent controls in MobileShellSettings and the\nAppearance KCM. Generate ShiftWallpaperDark/Light from Shift base\nschemes, apply them through KDE color tools, and debounce wallpaper\ncolor updates. Register a dedicated dynamic-theming regression test. --- components/shellsettingsplugin/CMakeLists.txt | 1 + .../mobileshellsettings.cpp | 472 ++++++++++++++++++ .../shellsettingsplugin/mobileshellsettings.h | 39 ++ kcms/mobileshell/kcm_mobileshell.json | 3 +- kcms/mobileshell/ui/AppearanceForm.qml | 217 ++++++++ kcms/mobileshell/ui/main.qml | 18 +- shell/contents/views/Desktop.qml | 46 +- tests/CMakeLists.txt | 5 + tests/check-shift-dynamic-theming.sh | 154 ++++++ 9 files changed, 938 insertions(+), 17 deletions(-) create mode 100644 kcms/mobileshell/ui/AppearanceForm.qml create mode 100644 tests/check-shift-dynamic-theming.sh diff --git a/components/shellsettingsplugin/CMakeLists.txt b/components/shellsettingsplugin/CMakeLists.txt index 504a22df..ff6032f6 100644 --- a/components/shellsettingsplugin/CMakeLists.txt +++ b/components/shellsettingsplugin/CMakeLists.txt @@ -12,6 +12,7 @@ target_link_libraries(shellsettingsplugin PRIVATE Qt::DBus Qt::Gui Qt::Quick + KF6::ConfigCore Plasma::KWaylandClient KF6::Service KF6::Package diff --git a/components/shellsettingsplugin/mobileshellsettings.cpp b/components/shellsettingsplugin/mobileshellsettings.cpp index f4fddfd1..5ef8b766 100644 --- a/components/shellsettingsplugin/mobileshellsettings.cpp +++ b/components/shellsettingsplugin/mobileshellsettings.cpp @@ -15,16 +15,163 @@ #include #include #include +#include +#include +#include +#include +#include const QString CONFIG_FILE = QStringLiteral("plasmamobilerc"); const QString GENERAL_CONFIG_GROUP = QStringLiteral("General"); +const QString KDE_GLOBALS_CONFIG_GROUP = QStringLiteral("General"); const QString LOCKSCREEN_CONFIG_GROUP = QStringLiteral("Lockscreen"); const QString QUICKSETTINGS_CONFIG_GROUP = QStringLiteral("QuickSettings"); +const QString WALLPAPER_THEME_MANUAL_DARK_KEY = QStringLiteral("wallpaperThemeManualDarkTheme"); +const QString SHIFT_DARK_COLOR_SCHEME = QStringLiteral("ShiftDark"); +const QString SHIFT_LIGHT_COLOR_SCHEME = QStringLiteral("ShiftLight"); +const QString SHIFT_WALLPAPER_DARK_COLOR_SCHEME = QStringLiteral("ShiftWallpaperDark"); +const QString SHIFT_WALLPAPER_LIGHT_COLOR_SCHEME = QStringLiteral("ShiftWallpaperLight"); +const QString SHIFT_DARK_PLASMA_THEME = QStringLiteral("shift-dark"); +const QString SHIFT_LIGHT_PLASMA_THEME = QStringLiteral("shift-light"); +constexpr qreal MIN_NORMAL_CONTRAST = 4.5; +constexpr qreal MIN_INACTIVE_CONTRAST = 3.0; +constexpr qreal MIN_LINK_CONTRAST = 3.5; + +namespace +{ +QString baseColorSchemeName(bool dark) +{ + return dark ? SHIFT_DARK_COLOR_SCHEME : SHIFT_LIGHT_COLOR_SCHEME; +} + +QString wallpaperColorSchemeName(bool dark) +{ + return dark ? SHIFT_WALLPAPER_DARK_COLOR_SCHEME : SHIFT_WALLPAPER_LIGHT_COLOR_SCHEME; +} + +QString wallpaperColorSchemeDisplayName(bool dark) +{ + return dark ? QStringLiteral("SHIFT Wallpaper Dark") : QStringLiteral("SHIFT Wallpaper Light"); +} + +QString rgbString(const QColor &color) +{ + return QStringLiteral("%1,%2,%3").arg(color.red()).arg(color.green()).arg(color.blue()); +} + +qreal relativeLuminance(const QColor &color) +{ + return 0.2126 * color.redF() + 0.7152 * color.greenF() + 0.0722 * color.blueF(); +} + +qreal linearizedChannel(qreal channel) +{ + return channel <= 0.04045 ? channel / 12.92 : qPow((channel + 0.055) / 1.055, 2.4); +} + +qreal wcagLuminance(const QColor &color) +{ + return 0.2126 * linearizedChannel(color.redF()) + 0.7152 * linearizedChannel(color.greenF()) + 0.0722 * linearizedChannel(color.blueF()); +} + +qreal contrastRatio(const QColor &left, const QColor &right) +{ + const qreal leftLum = wcagLuminance(left); + const qreal rightLum = wcagLuminance(right); + const qreal lighter = qMax(leftLum, rightLum); + const qreal darker = qMin(leftLum, rightLum); + return (lighter + 0.05) / (darker + 0.05); +} + +QColor contrastingTextColor(const QColor &color); +QColor blendColors(const QColor &base, const QColor &overlay, qreal overlayAmount); + +QColor ensureContrast(const QColor &foreground, const QColor &background, qreal minimumContrast) +{ + if (contrastRatio(foreground, background) >= minimumContrast) { + return foreground; + } + + const QColor highContrastTarget = contrastingTextColor(background); + for (int i = 1; i <= 12; ++i) { + const qreal amount = i / 12.0; + const QColor candidate = blendColors(foreground, highContrastTarget, amount); + if (contrastRatio(candidate, background) >= minimumContrast) { + return candidate; + } + } + + return highContrastTarget; +} + +QColor contrastingTextColor(const QColor &color) +{ + const QColor white{Qt::white}; + const QColor black{Qt::black}; + return contrastRatio(color, white) >= contrastRatio(color, black) ? white : black; +} + +QColor blendColors(const QColor &base, const QColor &overlay, qreal overlayAmount) +{ + const qreal clampedAmount = qBound(0.0, overlayAmount, 1.0); + const qreal baseAmount = 1.0 - clampedAmount; + return QColor::fromRgbF(base.redF() * baseAmount + overlay.redF() * clampedAmount, + base.greenF() * baseAmount + overlay.greenF() * clampedAmount, + base.blueF() * baseAmount + overlay.blueF() * clampedAmount, + 1.0); +} + +void writeAccentEntries(const KSharedConfig::Ptr &config, const QString &groupName, const QColor &accentColor) +{ + auto group = KConfigGroup{config, groupName}; + group.writeEntry("DecorationFocus", rgbString(accentColor)); + group.writeEntry("DecorationHover", rgbString(accentColor)); + group.writeEntry("ForegroundActive", rgbString(accentColor)); +} + +void tintBackgroundEntries(const KSharedConfig::Ptr &config, const QString &groupName, const QColor &accentColor, bool dark) +{ + auto group = KConfigGroup{config, groupName}; + const QColor defaultBase = dark ? QColor(38, 41, 47) : QColor(244, 246, 251); + const QColor baseNormal = group.readEntry("BackgroundNormal", defaultBase); + const QColor baseAlternate = group.readEntry("BackgroundAlternate", baseNormal); + const qreal normalBlend = dark ? 0.22 : 0.12; + const qreal alternateBlend = dark ? 0.16 : 0.08; + + group.writeEntry("BackgroundNormal", rgbString(blendColors(baseNormal, accentColor, normalBlend))); + group.writeEntry("BackgroundAlternate", rgbString(blendColors(baseAlternate, accentColor, alternateBlend))); +} + +void tuneForegroundEntries(const KSharedConfig::Ptr &config, const QString &groupName, const QColor &accentColor, bool dark) +{ + auto group = KConfigGroup{config, groupName}; + const QColor bgNormal = group.readEntry("BackgroundNormal", dark ? QColor(34, 37, 50) : QColor(252, 252, 252)); + const QColor baseNormal = contrastingTextColor(bgNormal); + const QColor baseInactive = blendColors(baseNormal, bgNormal, dark ? 0.44 : 0.56); + const QColor baseLink = dark ? blendColors(accentColor, QColor(Qt::white), 0.28) : blendColors(accentColor, QColor(Qt::black), 0.24); + const QColor fgNormal = ensureContrast(baseNormal, bgNormal, MIN_NORMAL_CONTRAST); + const QColor fgInactive = ensureContrast(baseInactive, bgNormal, MIN_INACTIVE_CONTRAST); + const QColor fgLink = ensureContrast(baseLink, bgNormal, MIN_LINK_CONTRAST); + const QColor fgActive = ensureContrast(accentColor, bgNormal, MIN_LINK_CONTRAST); + + group.writeEntry("ForegroundNormal", rgbString(fgNormal)); + group.writeEntry("ForegroundInactive", rgbString(fgInactive)); + group.writeEntry("ForegroundActive", rgbString(fgActive)); + group.writeEntry("ForegroundLink", rgbString(fgLink)); +} +} MobileShellSettings::MobileShellSettings(QObject *parent) : QObject{parent} , m_config{KSharedConfig::openConfig(CONFIG_FILE)} + , m_kdeGlobalsConfig{KSharedConfig::openConfig()} { + m_wallpaperThemeTimer.setSingleShot(true); + m_wallpaperThemeTimer.setInterval(450); + connect(&m_wallpaperThemeTimer, &QTimer::timeout, this, [this]() -> void { + setDarkThemeEnabled(m_pendingWallpaperThemeDark); + }); + m_configWatcher = KConfigWatcher::create(m_config); connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group, const QByteArrayList &names) -> void { Q_UNUSED(names) @@ -53,6 +200,7 @@ MobileShellSettings::MobileShellSettings(QObject *parent) Q_EMIT dynamicTilingLayoutStateChanged(); Q_EMIT snapLayoutsEnabledChanged(); Q_EMIT allowLogoutChanged(); + Q_EMIT wallpaperThemeEnabledChanged(); } if (group.name() == LOCKSCREEN_CONFIG_GROUP) { Q_EMIT lockscreenLeftButtonActionChanged(); @@ -62,6 +210,24 @@ MobileShellSettings::MobileShellSettings(QObject *parent) Q_EMIT quickSettingsColumnsChanged(); } }); + + m_kdeGlobalsConfigWatcher = KConfigWatcher::create(m_kdeGlobalsConfig); + connect(m_kdeGlobalsConfigWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group, const QByteArrayList &names) -> void { + if (group.name() != KDE_GLOBALS_CONFIG_GROUP) { + return; + } + + if (names.contains(QByteArrayLiteral("ColorScheme"))) { + Q_EMIT colorSchemeChanged(); + Q_EMIT darkThemeEnabledChanged(); + } + if (names.contains(QByteArrayLiteral("AccentColor")) || names.contains(QByteArrayLiteral("LastUsedCustomAccentColor"))) { + Q_EMIT accentColorChanged(); + } + if (names.contains(QByteArrayLiteral("accentColorFromWallpaper"))) { + Q_EMIT wallpaperAccentEnabledChanged(); + } + }); } bool MobileShellSettings::vibrationsEnabled() const @@ -243,6 +409,312 @@ void MobileShellSettings::setConvergenceModeEnabled(bool enabled) job->start(); } +QString MobileShellSettings::colorScheme() const +{ + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + return group.readEntry("ColorScheme", SHIFT_DARK_COLOR_SCHEME); +} + +bool MobileShellSettings::darkThemeEnabled() const +{ + return isDarkColorScheme(colorScheme()); +} + +void MobileShellSettings::setDarkThemeEnabled(bool enabled) +{ + const QString currentColorScheme = colorScheme(); + const QString nextColorScheme = effectiveColorSchemeName(enabled); + const QString plasmaTheme = enabled ? SHIFT_DARK_PLASMA_THEME : SHIFT_LIGHT_PLASMA_THEME; + QColor accentOverride; + + if (wallpaperAccentEnabled() && m_lastWallpaperThemeColor.isValid() && m_lastWallpaperThemeColor.alpha() != 0) { + accentOverride = m_lastWallpaperThemeColor; + } else if (!wallpaperAccentEnabled()) { + const QColor manualAccent = accentColor(); + if (manualAccent.isValid() && manualAccent.alpha() != 0) { + accentOverride = manualAccent; + } + } + + if (currentColorScheme == nextColorScheme && nextColorScheme == baseColorSchemeName(enabled)) { + return; + } + + if (accentOverride.isValid() && accentOverride.alpha() != 0) { + applyColorScheme({QStringLiteral("--accent-color"), accentOverride.name(QColor::HexRgb), nextColorScheme}); + } else { + QProcess::execute(QStringLiteral("plasma-apply-colorscheme"), {nextColorScheme}); + m_kdeGlobalsConfig->reparseConfiguration(); + } + QProcess::execute(QStringLiteral("plasma-apply-desktoptheme"), {plasmaTheme}); + + Q_EMIT colorSchemeChanged(); + Q_EMIT darkThemeEnabledChanged(); +} + +bool MobileShellSettings::wallpaperThemeEnabled() const +{ + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + return group.readEntry("wallpaperThemeEnabled", false); +} + +void MobileShellSettings::setWallpaperThemeEnabled(bool enabled) +{ + if (wallpaperThemeEnabled() == enabled) { + return; + } + + auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; + if (enabled) { + group.writeEntry(WALLPAPER_THEME_MANUAL_DARK_KEY, darkThemeEnabled(), KConfigGroup::Notify); + } + group.writeEntry("wallpaperThemeEnabled", enabled, KConfigGroup::Notify); + m_config->sync(); + + if (!enabled) { + m_wallpaperThemeTimer.stop(); + setDarkThemeEnabled(group.readEntry(WALLPAPER_THEME_MANUAL_DARK_KEY, darkThemeEnabled())); + } + + Q_EMIT wallpaperThemeEnabledChanged(); +} + +void MobileShellSettings::applyWallpaperThemeColor(const QColor &color) +{ + if (!wallpaperThemeEnabled() || !color.isValid() || color.alpha() == 0) { + return; + } + + QColor opaqueColor = color; + opaqueColor.setAlpha(255); + + const bool colorChanged = m_lastWallpaperThemeColor != opaqueColor; + m_lastWallpaperThemeColor = opaqueColor; + + if (colorChanged) { + Q_EMIT wallpaperThemeColorChanged(); + } + + const qreal luminance = relativeLuminance(opaqueColor); + const bool shouldUseDarkTheme = luminance < 0.5; + const bool sameThemeBucket = darkThemeEnabled() == shouldUseDarkTheme; + + if (wallpaperAccentEnabled() && colorChanged && sameThemeBucket) { + applyColorScheme({QStringLiteral("--accent-color"), opaqueColor.name(QColor::HexRgb), colorScheme()}); + } + + if (!m_wallpaperThemeTimer.isActive() && darkThemeEnabled() == shouldUseDarkTheme && !colorChanged) { + return; + } + + m_pendingWallpaperThemeDark = shouldUseDarkTheme; + m_wallpaperThemeTimer.start(); +} + +QColor MobileShellSettings::wallpaperThemeColor() const +{ + return m_lastWallpaperThemeColor; +} + +QColor MobileShellSettings::accentColor() const +{ + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + return group.readEntry("AccentColor", QColor(Qt::transparent)); +} + +void MobileShellSettings::setAccentColor(const QColor &color) +{ + if (!color.isValid() || color.alpha() == 0) { + resetAccentColor(); + return; + } + + QColor opaqueColor = color; + opaqueColor.setAlpha(255); + + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + group.writeEntry("accentColorFromWallpaper", false, KConfigGroup::Notify); + group.writeEntry("LastUsedCustomAccentColor", opaqueColor, KConfigGroup::Notify); + m_kdeGlobalsConfig->sync(); + + applyColorScheme({QStringLiteral("--accent-color"), opaqueColor.name(QColor::HexRgb), colorScheme()}); + + Q_EMIT wallpaperAccentEnabledChanged(); + Q_EMIT accentColorChanged(); +} + +bool MobileShellSettings::wallpaperAccentEnabled() const +{ + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + return group.readEntry("accentColorFromWallpaper", false); +} + +void MobileShellSettings::setWallpaperAccentEnabled(bool enabled) +{ + if (wallpaperAccentEnabled() == enabled) { + return; + } + + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + group.writeEntry("accentColorFromWallpaper", enabled, KConfigGroup::Notify); + m_kdeGlobalsConfig->sync(); + + if (enabled && m_lastWallpaperThemeColor.isValid() && m_lastWallpaperThemeColor.alpha() != 0) { + applyColorScheme({QStringLiteral("--accent-color"), m_lastWallpaperThemeColor.name(QColor::HexRgb), colorScheme()}); + Q_EMIT accentColorChanged(); + } + + if (!enabled) { + const QColor lastCustomColor = lastUsedCustomAccentColor(); + if (lastCustomColor.isValid() && lastCustomColor.alpha() != 0) { + applyColorScheme({QStringLiteral("--accent-color"), lastCustomColor.name(QColor::HexRgb), colorScheme()}); + } else { + resetAccentColor(); + return; + } + } + + Q_EMIT wallpaperAccentEnabledChanged(); +} + +void MobileShellSettings::resetAccentColor() +{ + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + group.writeEntry("accentColorFromWallpaper", false, KConfigGroup::Notify); + group.deleteEntry("AccentColor", KConfigGroup::Notify); + m_kdeGlobalsConfig->sync(); + + applyColorScheme({colorScheme()}); + + Q_EMIT wallpaperAccentEnabledChanged(); + Q_EMIT accentColorChanged(); +} + +QColor MobileShellSettings::lastUsedCustomAccentColor() const +{ + auto group = KConfigGroup{m_kdeGlobalsConfig, KDE_GLOBALS_CONFIG_GROUP}; + return group.readEntry("LastUsedCustomAccentColor", QColor(Qt::transparent)); +} + +void MobileShellSettings::applyColorScheme(const QStringList &arguments) +{ + QProcess::execute(QStringLiteral("plasma-apply-colorscheme"), arguments); + m_kdeGlobalsConfig->reparseConfiguration(); +} + +QString MobileShellSettings::effectiveColorSchemeName(bool dark) const +{ + if (!wallpaperThemeEnabled() || !m_lastWallpaperThemeColor.isValid() || m_lastWallpaperThemeColor.alpha() == 0) { + return baseColorSchemeName(dark); + } + + if (!ensureWallpaperColorScheme(m_lastWallpaperThemeColor, dark)) { + return baseColorSchemeName(dark); + } + + return wallpaperColorSchemeName(dark); +} + +bool MobileShellSettings::isDarkColorScheme(const QString &schemeName) const +{ + return schemeName == SHIFT_DARK_COLOR_SCHEME || schemeName == SHIFT_WALLPAPER_DARK_COLOR_SCHEME; +} + +bool MobileShellSettings::ensureWallpaperColorScheme(const QColor &accentColor, bool dark) const +{ + if (!accentColor.isValid() || accentColor.alpha() == 0) { + return false; + } + + const QString sourcePath = + QStandardPaths::locate(QStandardPaths::GenericDataLocation, QStringLiteral("color-schemes/%1.colors").arg(baseColorSchemeName(dark))); + if (sourcePath.isEmpty()) { + qWarning() << "Unable to locate base Shift color scheme for wallpaper generation:" << baseColorSchemeName(dark); + return false; + } + + const QString outputDir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QStringLiteral("/color-schemes"); + if (!QDir().mkpath(outputDir)) { + qWarning() << "Unable to create dynamic color-scheme directory:" << outputDir; + return false; + } + + const QString outputPath = outputDir + QStringLiteral("/%1.colors").arg(wallpaperColorSchemeName(dark)); + QFile::remove(outputPath); + if (!QFile::copy(sourcePath, outputPath)) { + qWarning() << "Unable to create wallpaper-derived color scheme:" << outputPath; + return false; + } + + auto generatedConfig = KSharedConfig::openConfig(outputPath, KConfig::SimpleConfig); + auto generalGroup = KConfigGroup{generatedConfig, QStringLiteral("General")}; + generalGroup.writeEntry("ColorScheme", wallpaperColorSchemeName(dark)); + generalGroup.writeEntry("Name", wallpaperColorSchemeDisplayName(dark)); + + writeAccentEntries(generatedConfig, QStringLiteral("Colors:Button"), accentColor); + writeAccentEntries(generatedConfig, QStringLiteral("Colors:Complementary"), accentColor); + writeAccentEntries(generatedConfig, QStringLiteral("Colors:Header"), accentColor); + writeAccentEntries(generatedConfig, QStringLiteral("Colors:Header][Inactive"), accentColor); + writeAccentEntries(generatedConfig, QStringLiteral("Colors:Tooltip"), accentColor); + writeAccentEntries(generatedConfig, QStringLiteral("Colors:View"), accentColor); + writeAccentEntries(generatedConfig, QStringLiteral("Colors:Window"), accentColor); + + tintBackgroundEntries(generatedConfig, QStringLiteral("Colors:Button"), accentColor, dark); + tintBackgroundEntries(generatedConfig, QStringLiteral("Colors:View"), accentColor, dark); + tintBackgroundEntries(generatedConfig, QStringLiteral("Colors:Window"), accentColor, dark); + tintBackgroundEntries(generatedConfig, QStringLiteral("Colors:Header"), accentColor, dark); + tintBackgroundEntries(generatedConfig, QStringLiteral("Colors:Header][Inactive"), accentColor, dark); + tintBackgroundEntries(generatedConfig, QStringLiteral("Colors:Tooltip"), accentColor, dark); + + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:Button"), accentColor, dark); + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:View"), accentColor, dark); + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:Window"), accentColor, dark); + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:Header"), accentColor, dark); + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:Header][Inactive"), accentColor, dark); + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:Tooltip"), accentColor, dark); + + auto complementaryGroup = KConfigGroup{generatedConfig, QStringLiteral("Colors:Complementary")}; + const QColor complementaryBase = complementaryGroup.readEntry("BackgroundNormal", dark ? QColor(24, 27, 38) : QColor(42, 46, 50)); + complementaryGroup.writeEntry("BackgroundNormal", rgbString(blendColors(complementaryBase, accentColor, dark ? 0.18 : 0.14))); + complementaryGroup.writeEntry("BackgroundAlternate", rgbString(blendColors(complementaryBase, accentColor, dark ? 0.12 : 0.09))); + tuneForegroundEntries(generatedConfig, QStringLiteral("Colors:Complementary"), accentColor, dark); + + auto selectionGroup = KConfigGroup{generatedConfig, QStringLiteral("Colors:Selection")}; + const QColor selectionBackground = blendColors(accentColor, dark ? QColor(18, 20, 28) : QColor(255, 255, 255), dark ? 0.82 : 0.9); + const QColor selectionAlternate = blendColors(selectionBackground, dark ? QColor(0, 0, 0) : QColor(255, 255, 255), dark ? 0.16 : 0.24); + const QColor selectionForeground = ensureContrast(contrastingTextColor(selectionBackground), selectionBackground, MIN_NORMAL_CONTRAST); + const QColor selectionInactive = + ensureContrast(blendColors(selectionForeground, selectionBackground, dark ? 0.4 : 0.52), selectionBackground, MIN_INACTIVE_CONTRAST); + const QColor selectionLink = ensureContrast(blendColors(accentColor, selectionForeground, dark ? 0.3 : 0.2), selectionBackground, MIN_LINK_CONTRAST); + selectionGroup.writeEntry("BackgroundNormal", rgbString(selectionBackground)); + selectionGroup.writeEntry("BackgroundAlternate", rgbString(selectionAlternate)); + selectionGroup.writeEntry("DecorationFocus", rgbString(accentColor)); + selectionGroup.writeEntry("DecorationHover", rgbString(accentColor)); + selectionGroup.writeEntry("ForegroundActive", rgbString(selectionForeground)); + selectionGroup.writeEntry("ForegroundNormal", rgbString(selectionForeground)); + selectionGroup.writeEntry("ForegroundInactive", rgbString(selectionInactive)); + selectionGroup.writeEntry("ForegroundLink", rgbString(selectionLink)); + + auto wmGroup = KConfigGroup{generatedConfig, QStringLiteral("WM")}; + const QColor currentActiveBackground = wmGroup.readEntry("activeBackground", accentColor); + const QColor tintedActiveBackground = blendColors(currentActiveBackground, accentColor, dark ? 0.42 : 0.26); + const QColor currentInactiveBackground = wmGroup.readEntry("inactiveBackground", tintedActiveBackground); + const QColor tintedInactiveBackground = blendColors(currentInactiveBackground, accentColor, dark ? 0.24 : 0.14); + const QColor activeForeground = ensureContrast(contrastingTextColor(tintedActiveBackground), tintedActiveBackground, MIN_NORMAL_CONTRAST); + const QColor inactiveForeground = ensureContrast(blendColors(contrastingTextColor(tintedInactiveBackground), tintedInactiveBackground, dark ? 0.36 : 0.52), + tintedInactiveBackground, + MIN_INACTIVE_CONTRAST); + wmGroup.writeEntry("activeBackground", rgbString(tintedActiveBackground)); + wmGroup.writeEntry("activeBlend", rgbString(activeForeground)); + wmGroup.writeEntry("activeForeground", rgbString(activeForeground)); + wmGroup.writeEntry("inactiveBackground", rgbString(tintedInactiveBackground)); + wmGroup.writeEntry("inactiveBlend", rgbString(inactiveForeground)); + wmGroup.writeEntry("inactiveForeground", rgbString(inactiveForeground)); + + generatedConfig->sync(); + return true; +} + bool MobileShellSettings::autoHidePanelsEnabled() const { auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; diff --git a/components/shellsettingsplugin/mobileshellsettings.h b/components/shellsettingsplugin/mobileshellsettings.h index b6133b96..c543b293 100644 --- a/components/shellsettingsplugin/mobileshellsettings.h +++ b/components/shellsettingsplugin/mobileshellsettings.h @@ -9,9 +9,11 @@ #include #include #include +#include #include #include #include +#include #include /** @@ -50,6 +52,14 @@ class MobileShellSettings : public QObject // convergence mode Q_PROPERTY(bool convergenceModeEnabled READ convergenceModeEnabled WRITE setConvergenceModeEnabled NOTIFY convergenceModeEnabledChanged) + // theme + Q_PROPERTY(QString colorScheme READ colorScheme NOTIFY colorSchemeChanged) + Q_PROPERTY(bool darkThemeEnabled READ darkThemeEnabled WRITE setDarkThemeEnabled NOTIFY darkThemeEnabledChanged) + Q_PROPERTY(bool wallpaperThemeEnabled READ wallpaperThemeEnabled WRITE setWallpaperThemeEnabled NOTIFY wallpaperThemeEnabledChanged) + Q_PROPERTY(QColor wallpaperThemeColor READ wallpaperThemeColor NOTIFY wallpaperThemeColorChanged) + Q_PROPERTY(QColor accentColor READ accentColor WRITE setAccentColor NOTIFY accentColorChanged) + Q_PROPERTY(bool wallpaperAccentEnabled READ wallpaperAccentEnabled WRITE setWallpaperAccentEnabled NOTIFY wallpaperAccentEnabledChanged) + // Auto Hide Panels Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged) @@ -274,6 +284,19 @@ public: */ void setConvergenceModeEnabled(bool enabled); + QString colorScheme() const; + bool darkThemeEnabled() const; + void setDarkThemeEnabled(bool enabled); + bool wallpaperThemeEnabled() const; + void setWallpaperThemeEnabled(bool enabled); + Q_INVOKABLE void applyWallpaperThemeColor(const QColor &color); + QColor wallpaperThemeColor() const; + QColor accentColor() const; + void setAccentColor(const QColor &color); + bool wallpaperAccentEnabled() const; + void setWallpaperAccentEnabled(bool enabled); + Q_INVOKABLE void resetAccentColor(); + /** * Whether Auto Hide Panels is enabled. */ @@ -368,6 +391,12 @@ Q_SIGNALS: void actionDrawerTopRightModeChanged(); void quickSettingsColumnsChanged(); void convergenceModeEnabledChanged(); + void colorSchemeChanged(); + void darkThemeEnabledChanged(); + void wallpaperThemeEnabledChanged(); + void wallpaperThemeColorChanged(); + void accentColorChanged(); + void wallpaperAccentEnabledChanged(); void autoHidePanelsEnabledChanged(); void gamingModeEnabledChanged(); void gamingDismissHintEnabledChanged(); @@ -383,7 +412,17 @@ Q_SIGNALS: private: void updateNavigationBarsInPlasma(); + QColor lastUsedCustomAccentColor() const; + void applyColorScheme(const QStringList &arguments); + QString effectiveColorSchemeName(bool dark) const; + bool isDarkColorScheme(const QString &schemeName) const; + bool ensureWallpaperColorScheme(const QColor &accentColor, bool dark) const; KConfigWatcher::Ptr m_configWatcher; + KConfigWatcher::Ptr m_kdeGlobalsConfigWatcher; KSharedConfig::Ptr m_config; + KSharedConfig::Ptr m_kdeGlobalsConfig; + QTimer m_wallpaperThemeTimer; + bool m_pendingWallpaperThemeDark = false; + QColor m_lastWallpaperThemeColor; }; diff --git a/kcms/mobileshell/kcm_mobileshell.json b/kcms/mobileshell/kcm_mobileshell.json index 5a4ec2bc..4613c95d 100644 --- a/kcms/mobileshell/kcm_mobileshell.json +++ b/kcms/mobileshell/kcm_mobileshell.json @@ -82,6 +82,7 @@ "Description[zh_TW]": "設定系統殼層介面", "EnabledByDefault": true, "FormFactors": [ + "desktop", "handset", "tablet" ], @@ -130,7 +131,7 @@ "Name[zh_TW]": "殼層", "Website": "https://invent.kde.org/marcoa/shift-shell" }, - "X-KDE-Keywords": "system,shell,panel", + "X-KDE-Keywords": "system,shell,panel,appearance,theme,wallpaper,dark,light,accent,color scheme", "X-KDE-Keywords[ar]": "نظام,صدفة,لوحة", "X-KDE-Keywords[az]": "sistem,örtük,panel", "X-KDE-Keywords[ca@valencia]": "sistema,intèrpret d'ordres,quadro", diff --git a/kcms/mobileshell/ui/AppearanceForm.qml b/kcms/mobileshell/ui/AppearanceForm.qml new file mode 100644 index 00000000..f51f5ca1 --- /dev/null +++ b/kcms/mobileshell/ui/AppearanceForm.qml @@ -0,0 +1,217 @@ +/* + * SPDX-FileCopyrightText: 2026 Marco Allegretti + * SPDX-License-Identifier: EUPL-1.2 + */ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import QtQuick.Dialogs as Dialogs + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard 1 as FormCard +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings + +FormCard.FormCardPage { + id: root + + title: i18n("Appearance") + + readonly property bool usingSchemeAccent: ShellSettings.Settings.accentColor.a === 0 + readonly property color previewAccentColor: usingSchemeAccent ? Kirigami.Theme.highlightColor : ShellSettings.Settings.accentColor + readonly property bool wallpaperThemeActive: ShellSettings.Settings.wallpaperThemeEnabled && ShellSettings.Settings.colorScheme.indexOf("ShiftWallpaper") === 0 + + Dialogs.ColorDialog { + id: accentColorDialog + + title: i18n("Accent Color") + selectedColor: root.previewAccentColor + onAccepted: ShellSettings.Settings.accentColor = selectedColor + onVisibleChanged: { + if (visible) { + selectedColor = root.previewAccentColor; + } + } + } + + FormCard.FormHeader { + title: i18n("Theme") + } + + FormCard.FormCard { + Layout.topMargin: Kirigami.Units.gridUnit + + FormCard.FormSwitchDelegate { + id: darkThemeSwitch + + text: i18n("Dark Theme") + description: ShellSettings.Settings.wallpaperThemeEnabled + ? i18n("Disabled while Theme from Wallpaper is controlling light and dark mode.") + : i18n("Use the dark Shift theme manually.") + enabled: !ShellSettings.Settings.wallpaperThemeEnabled + checked: ShellSettings.Settings.darkThemeEnabled + onCheckedChanged: { + if (checked != ShellSettings.Settings.darkThemeEnabled) { + ShellSettings.Settings.darkThemeEnabled = checked; + } + } + } + + FormCard.FormDelegateSeparator { above: darkThemeSwitch; below: wallpaperThemeSwitch } + + FormCard.FormSwitchDelegate { + id: wallpaperThemeSwitch + + text: i18n("Theme from Wallpaper") + description: i18n("Automatically switch between light and dark Shift themes and generate a matching wallpaper-derived color scheme.") + checked: ShellSettings.Settings.wallpaperThemeEnabled + onCheckedChanged: { + if (checked != ShellSettings.Settings.wallpaperThemeEnabled) { + ShellSettings.Settings.wallpaperThemeEnabled = checked; + } + } + } + + FormCard.FormDelegateSeparator { above: wallpaperThemeSwitch; below: wallpaperAccentSwitch } + + FormCard.FormSwitchDelegate { + id: wallpaperAccentSwitch + + text: i18n("Accent from Wallpaper") + description: i18n("Use the wallpaper's extracted accent color instead of a manually selected accent.") + checked: ShellSettings.Settings.wallpaperAccentEnabled + onCheckedChanged: { + if (checked != ShellSettings.Settings.wallpaperAccentEnabled) { + ShellSettings.Settings.wallpaperAccentEnabled = checked; + } + } + } + + FormCard.FormDelegateSeparator { above: wallpaperAccentSwitch; below: accentColorDelegate } + + FormCard.AbstractFormDelegate { + id: accentColorDelegate + + enabled: !ShellSettings.Settings.wallpaperAccentEnabled + + contentItem: RowLayout { + spacing: Kirigami.Units.smallSpacing + + QQC2.Label { + Layout.fillWidth: true + text: i18n("Accent Color") + color: accentColorDelegate.enabled ? Kirigami.Theme.textColor : Kirigami.Theme.disabledTextColor + elide: Text.ElideRight + } + + Rectangle { + Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium + radius: Math.min(width, height) / 2 + color: root.previewAccentColor + border.width: 1 + border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.28) + } + + QQC2.ToolButton { + text: i18nc("@action:button", "Choose accent color") + icon.name: "color-picker" + display: QQC2.AbstractButton.IconOnly + enabled: accentColorDelegate.enabled + onClicked: accentColorDialog.open() + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + + QQC2.ToolButton { + text: i18nc("@action:button", "Use scheme accent") + icon.name: "edit-clear" + display: QQC2.AbstractButton.IconOnly + enabled: accentColorDelegate.enabled && !root.usingSchemeAccent + onClicked: ShellSettings.Settings.resetAccentColor() + + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + } + } + + FormCard.FormSectionText { + visible: ShellSettings.Settings.wallpaperThemeEnabled + text: root.wallpaperThemeActive + ? i18n("Wallpaper theming is active. SHIFT is currently using %1.", ShellSettings.Settings.colorScheme) + : i18n("Wallpaper theming is enabled, but SHIFT is still waiting for a usable wallpaper color.") + } + + FormCard.FormCard { + visible: ShellSettings.Settings.wallpaperThemeEnabled + + FormCard.AbstractFormDelegate { + contentItem: RowLayout { + spacing: Kirigami.Units.smallSpacing + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + QQC2.Label { + Layout.fillWidth: true + text: i18n("Active Color Scheme") + elide: Text.ElideRight + } + + QQC2.Label { + Layout.fillWidth: true + text: ShellSettings.Settings.colorScheme + color: Kirigami.Theme.disabledTextColor + elide: Text.ElideRight + } + } + } + } + + FormCard.FormDelegateSeparator {} + + FormCard.AbstractFormDelegate { + contentItem: RowLayout { + spacing: Kirigami.Units.smallSpacing + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + QQC2.Label { + Layout.fillWidth: true + text: i18n("Wallpaper Source Color") + elide: Text.ElideRight + } + + QQC2.Label { + Layout.fillWidth: true + text: ShellSettings.Settings.wallpaperThemeColor.a === 0 + ? i18n("Not available yet") + : ShellSettings.Settings.wallpaperThemeColor.toString() + color: Kirigami.Theme.disabledTextColor + elide: Text.ElideRight + } + } + + Rectangle { + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: Kirigami.Units.iconSizes.medium + Layout.preferredHeight: Kirigami.Units.iconSizes.medium + radius: Kirigami.Units.smallSpacing + color: ShellSettings.Settings.wallpaperThemeColor.a === 0 + ? "transparent" + : ShellSettings.Settings.wallpaperThemeColor + border.width: 1 + border.color: Qt.rgba(Kirigami.Theme.textColor.r, Kirigami.Theme.textColor.g, Kirigami.Theme.textColor.b, 0.28) + } + } + } + } +} \ No newline at end of file diff --git a/kcms/mobileshell/ui/main.qml b/kcms/mobileshell/ui/main.qml index 12b4a9bf..58e4cfda 100644 --- a/kcms/mobileshell/ui/main.qml +++ b/kcms/mobileshell/ui/main.qml @@ -28,6 +28,19 @@ KCM.SimpleKCM { } ColumnLayout { + FormCard.FormHeader { + title: i18n("Appearance") + } + + FormCard.FormCard { + FormCard.FormButtonDelegate { + id: appearanceButton + icon.name: "preferences-desktop-theme-global" + text: i18n("Appearance") + onClicked: kcm.push("AppearanceForm.qml") + } + } + FormCard.FormHeader { title: i18n("General") } @@ -314,7 +327,6 @@ KCM.SimpleKCM { Component.onCompleted: { currentIndex = indexOfValue(ShellSettings.Settings.statusBarScaleFactor); - dialog.parent = root; } onCurrentValueChanged: ShellSettings.Settings.statusBarScaleFactor = currentValue } @@ -354,7 +366,6 @@ KCM.SimpleKCM { Component.onCompleted: { currentIndex = indexOfValue(ShellSettings.Settings.actionDrawerTopLeftMode); - dialog.parent = root; } onCurrentValueChanged: ShellSettings.Settings.actionDrawerTopLeftMode = currentValue } @@ -376,7 +387,6 @@ KCM.SimpleKCM { Component.onCompleted: { currentIndex = indexOfValue(ShellSettings.Settings.actionDrawerTopRightMode); - dialog.parent = root } onCurrentValueChanged: ShellSettings.Settings.actionDrawerTopRightMode = currentValue } @@ -407,7 +417,6 @@ KCM.SimpleKCM { Component.onCompleted: { currentIndex = indexOfValue(ShellSettings.Settings.lockscreenLeftButtonAction); - dialog.parent = root; } onCurrentValueChanged: ShellSettings.Settings.lockscreenLeftButtonAction = currentValue } @@ -429,7 +438,6 @@ KCM.SimpleKCM { Component.onCompleted: { currentIndex = indexOfValue(ShellSettings.Settings.lockscreenRightButtonAction); - dialog.parent = root; } onCurrentValueChanged: ShellSettings.Settings.lockscreenRightButtonAction = currentValue } diff --git a/shell/contents/views/Desktop.qml b/shell/contents/views/Desktop.qml index a6fb4c17..2c5c9002 100644 --- a/shell/contents/views/Desktop.qml +++ b/shell/contents/views/Desktop.qml @@ -14,6 +14,7 @@ import org.kde.kirigami as Kirigami import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings Rectangle { id: root @@ -65,7 +66,7 @@ Rectangle { Loader { id: wallpaperColors - active: desktop.usedInAccentColor && root.containment && root.containment.wallpaper + active: (desktop.usedInAccentColor || ShellSettings.Settings.wallpaperThemeEnabled) && root.containment && root.containment.wallpaper asynchronous: true sourceComponent: Kirigami.ImageColors { @@ -80,26 +81,46 @@ Rectangle { Kirigami.Theme.backgroundColor: backgroundColor Kirigami.Theme.textColor: textColor + readonly property color wallpaperColor: { + if (!Qt.colorEqual(imageColors.colorFromPlugin, "transparent")) { + return imageColors.colorFromPlugin; + } + if (!Qt.colorEqual(imageColors.highlight, "transparent")) { + return imageColors.highlight; + } + if (!Qt.colorEqual(imageColors.dominant, "transparent")) { + return imageColors.dominant; + } + return imageColors.fallbackHighlight; + } + onBackgroundColorChanged: Qt.callLater(update) onTextColorChanged: Qt.callLater(update) + onWallpaperColorChanged: Qt.callLater(applyWallpaperTheme) + + function applyWallpaperTheme() { + ShellSettings.Settings.applyWallpaperThemeColor(imageColors.wallpaperColor); + } + + property Connections settingsConnection: Connections { + target: ShellSettings.Settings + + function onWallpaperThemeEnabledChanged() { + imageColors.applyWallpaperTheme(); + } + } property Binding colorBinding: Binding { target: desktop property: "accentColor" - value: { - if (!Qt.colorEqual(imageColors.colorFromPlugin, "transparent")) { - return imageColors.colorFromPlugin; - } - if (imageColors.palette.length === 0) { - return "transparent"; - } - return imageColors.dominant; - } + value: imageColors.wallpaperColor when: desktop.usedInAccentColor // Without this, accentColor may still be updated after usedInAccentColor becomes false } property Connections repaintConnection: Connections { target: root.containment.wallpaper + ignoreUnknownSignals: true + function onRepaintNeeded(color) { imageColors.colorFromPlugin = color; @@ -110,7 +131,10 @@ Rectangle { } } - onLoaded: item.update() + onLoaded: { + item.update(); + item.applyWallpaperTheme(); + } } Loader { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 84571180..59e57161 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -25,6 +25,11 @@ if(BASH_EXECUTABLE) COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-shift-theme-identity.sh ) + add_test( + NAME shift-dynamic-theming + COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-shift-dynamic-theming.sh + ) + add_test( NAME shift-preview-qml-stubs COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-preview-qml-stubs.sh diff --git a/tests/check-shift-dynamic-theming.sh b/tests/check-shift-dynamic-theming.sh new file mode 100644 index 00000000..45a327de --- /dev/null +++ b/tests/check-shift-dynamic-theming.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Marco Allegretti +# SPDX-License-Identifier: EUPL-1.2 + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_dir" + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +require_line() { + local file="$1" + local pattern="$2" + local message="$3" + + grep -Eq -- "$pattern" "$file" || fail "$message" +} + +settings_header=components/shellsettingsplugin/mobileshellsettings.h +settings_cpp=components/shellsettingsplugin/mobileshellsettings.cpp +settings_cmake=components/shellsettingsplugin/CMakeLists.txt +kcm_main=kcms/mobileshell/ui/main.qml +kcm_appearance=kcms/mobileshell/ui/AppearanceForm.qml +desktop_view=shell/contents/views/Desktop.qml +kcm_metadata=kcms/mobileshell/kcm_mobileshell.json + +require_line "$settings_header" 'Q_PROPERTY\(QString colorScheme READ colorScheme NOTIFY colorSchemeChanged\)' \ + "mobile shell settings must expose the active KDE color scheme" +require_line "$settings_header" 'Q_PROPERTY\(bool darkThemeEnabled READ darkThemeEnabled WRITE setDarkThemeEnabled NOTIFY darkThemeEnabledChanged\)' \ + "mobile shell settings must expose Shift dark/light switching" +require_line "$settings_header" 'Q_PROPERTY\(bool wallpaperThemeEnabled READ wallpaperThemeEnabled WRITE setWallpaperThemeEnabled NOTIFY wallpaperThemeEnabledChanged\)' \ + "mobile shell settings must expose wallpaper-driven theme mode" +require_line "$settings_header" 'Q_PROPERTY\(QColor wallpaperThemeColor READ wallpaperThemeColor NOTIFY wallpaperThemeColorChanged\)' \ + "mobile shell settings must expose the extracted wallpaper theme color for UI inspection" +require_line "$settings_header" 'Q_PROPERTY\(QColor accentColor READ accentColor WRITE setAccentColor NOTIFY accentColorChanged\)' \ + "mobile shell settings must expose manual accent color selection" +require_line "$settings_header" 'Q_PROPERTY\(bool wallpaperAccentEnabled READ wallpaperAccentEnabled WRITE setWallpaperAccentEnabled NOTIFY wallpaperAccentEnabledChanged\)' \ + "mobile shell settings must expose wallpaper-derived accent colors" + +require_line "$settings_cpp" 'KSharedConfig::openConfig\(\)' \ + "dynamic theming must use kdeglobals, not a parallel Shift-only config file" +require_line "$settings_cpp" 'readEntry\("ColorScheme", SHIFT_DARK_COLOR_SCHEME\)' \ + "dark/light mode must be backed by kdeglobals ColorScheme" +require_line "$settings_cpp" 'readEntry\("wallpaperThemeEnabled", false\)' \ + "wallpaper theme mode must be an explicit Shift setting" +require_line "$settings_cpp" 'writeEntry\("wallpaperThemeEnabled", enabled, KConfigGroup::Notify\)' \ + "wallpaper theme mode must persist changes with config notifications" +require_line "$settings_cpp" 'WALLPAPER_THEME_MANUAL_DARK_KEY' \ + "wallpaper theme mode must remember the previous manual dark/light choice" +require_line "$settings_cpp" 'readEntry\(WALLPAPER_THEME_MANUAL_DARK_KEY, darkThemeEnabled\(\)\)' \ + "disabling wallpaper theme mode must restore the previous manual dark/light choice" +require_line "$settings_cpp" 'm_wallpaperThemeTimer\.stop\(\)' \ + "disabling wallpaper theme mode must cancel pending automatic theme changes" +require_line "$settings_cpp" 'applyWallpaperThemeColor' \ + "wallpaper theme mode must expose a shell hook for extracted wallpaper colors" +require_line "$settings_cpp" 'Q_EMIT wallpaperThemeColorChanged\(\)' \ + "wallpaper theme mode must notify the UI when the extracted wallpaper theme color changes" +require_line "$settings_cpp" 'm_wallpaperThemeTimer\.setInterval\(450\)' \ + "wallpaper theme mode must debounce wallpaper color changes" +require_line "$settings_cpp" 'm_wallpaperThemeTimer\.start\(\)' \ + "wallpaper theme mode must apply extracted wallpaper colors through the debounce timer" +require_line "$settings_cpp" '0\.2126 \* color\.redF\(\) \+ 0\.7152 \* color\.greenF\(\) \+ 0\.0722 \* color\.blueF\(\)' \ + "wallpaper theme mode must use relative luminance to choose light or dark Shift schemes" +require_line "$settings_cpp" 'writeEntry\("accentColorFromWallpaper", enabled, KConfigGroup::Notify\)' \ + "wallpaper accent mode must persist KDE's accentColorFromWallpaper key" +require_line "$settings_cpp" 'm_lastWallpaperThemeColor\.name\(QColor::HexRgb\)' \ + "wallpaper accent mode must apply the extracted wallpaper color directly as accent override" +require_line "$settings_cpp" 'writeEntry\("LastUsedCustomAccentColor", opaqueColor, KConfigGroup::Notify\)' \ + "manual accent mode must preserve KDE's LastUsedCustomAccentColor key" +require_line "$settings_cpp" 'deleteEntry\("AccentColor", KConfigGroup::Notify\)' \ + "resetting the accent must clear KDE's AccentColor key" +require_line "$settings_cpp" 'plasma-apply-colorscheme' \ + "color-scheme changes must go through KDE's color applicator" +require_line "$settings_cpp" '--accent-color' \ + "manual accent selection must use KDE's accent-color applicator path" +require_line "$settings_cpp" 'plasma-apply-desktoptheme' \ + "dark/light switching must update the Shift Plasma desktop theme" +require_line "$settings_cpp" 'ShiftDark' \ + "dynamic theming must keep ShiftDark as the dark scheme" +require_line "$settings_cpp" 'ShiftLight' \ + "dynamic theming must keep ShiftLight as the light scheme" +require_line "$settings_cpp" 'ShiftWallpaperDark' \ + "wallpaper theme mode must provide a generated dark Shift wallpaper scheme" +require_line "$settings_cpp" 'ShiftWallpaperLight' \ + "wallpaper theme mode must provide a generated light Shift wallpaper scheme" +require_line "$settings_cpp" 'QStandardPaths::locate\(QStandardPaths::GenericDataLocation,' \ + "wallpaper theme mode must derive generated schemes from the installed Shift base schemes" +require_line "$settings_cpp" 'QStandardPaths::writableLocation\(QStandardPaths::GenericDataLocation\)' \ + "wallpaper theme mode must write generated schemes into the session data location" +require_line "$settings_cpp" 'QFile::copy\(sourcePath, outputPath\)' \ + "wallpaper theme mode must generate a concrete .colors file before applying it" +require_line "$settings_cpp" 'tintBackgroundEntries\(' \ + "wallpaper theme mode must generate tinted background roles in the derived color scheme" +require_line "$settings_cpp" 'tuneForegroundEntries\(' \ + "wallpaper theme mode must generate contrast-aware foreground hierarchy entries" +require_line "$settings_cpp" 'contrastRatio\(' \ + "wallpaper theme mode must use contrast-aware text color selection" +require_line "$settings_cpp" 'ensureContrast\(' \ + "wallpaper theme mode must clamp generated foreground roles to a minimum contrast threshold" +require_line "$settings_cpp" 'MIN_NORMAL_CONTRAST' \ + "wallpaper theme mode must define explicit minimum contrast targets for readability" +require_line "$settings_cpp" 'writeEntry\("BackgroundNormal"' \ + "wallpaper theme mode must update BackgroundNormal in generated scheme groups" +require_line "$settings_cpp" 'writeEntry\("inactiveBackground"' \ + "wallpaper theme mode must generate an inactive window background role" +require_line "$settings_cpp" 'shift-dark' \ + "dynamic theming must keep shift-dark as the dark Plasma theme" +require_line "$settings_cpp" 'shift-light' \ + "dynamic theming must keep shift-light as the light Plasma theme" +require_line "$settings_cmake" 'KF6::ConfigCore' \ + "shell settings plugin must link explicitly to KConfigCore for kdeglobals access" +require_line "$kcm_main" 'kcm\.push\("AppearanceForm\.qml"\)' \ + "mobile shell settings must expose the dynamic theming controls" +if grep -Eq -- 'dialog\.parent[[:space:]]*=' "$kcm_main"; then + fail "mobile shell KCM must not assign dialog.parent on FormComboBoxDelegate (invalid in current runtime)" +fi +require_line "$kcm_metadata" '"FormFactors"[[:space:]]*:[[:space:]]*\[' \ + "shell settings module metadata must declare form factors" +require_line "$kcm_metadata" '"desktop"' \ + "shell settings module must be visible in desktop System Settings form factor" +require_line "$kcm_metadata" '"X-KDE-Keywords"[[:space:]]*:[[:space:]]*"[^"]*appearance[^"]*theme[^"]*wallpaper[^"]*accent' \ + "shell settings module search keywords must include appearance/theme/wallpaper/accent terms" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.darkThemeEnabled' \ + "appearance settings must control Shift dark/light mode" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.wallpaperThemeEnabled' \ + "appearance settings must control wallpaper-driven theme mode" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.colorScheme' \ + "appearance settings must show the active generated wallpaper color scheme" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.wallpaperThemeColor' \ + "appearance settings must show the extracted wallpaper theme color" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.wallpaperAccentEnabled' \ + "appearance settings must control wallpaper-derived accent colors" +require_line "$kcm_appearance" 'Dialogs\.ColorDialog' \ + "appearance settings must provide manual accent color selection" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.accentColor = selectedColor' \ + "appearance settings must apply the selected manual accent color" +require_line "$kcm_appearance" 'ShellSettings\.Settings\.resetAccentColor\(\)' \ + "appearance settings must allow returning to the scheme accent" +require_line "$desktop_view" 'usedInAccentColor' \ + "wallpaper accent mode depends on the Plasma desktop accent extraction hook" +require_line "$desktop_view" 'ShellSettings\.Settings\.wallpaperThemeEnabled' \ + "wallpaper theme mode must reuse the Plasma desktop wallpaper color extraction hook" +require_line "$desktop_view" 'ShellSettings\.Settings\.applyWallpaperThemeColor\(imageColors\.wallpaperColor\)' \ + "desktop wallpaper color extraction must feed Shift's wallpaper theme mode" +require_line "$desktop_view" 'onWallpaperThemeEnabledChanged' \ + "desktop wallpaper color extraction must react when wallpaper theme mode is toggled" +require_line tests/CMakeLists.txt 'NAME shift-dynamic-theming' \ + "dynamic theming regression test must be registered with CTest" + +printf '%s\n' 'shift-dynamic-theming-ok' \ No newline at end of file