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.
This commit is contained in:
Marco Allegretti 2026-05-31 14:06:05 +02:00
parent 4af811c97c
commit 08264e5759
9 changed files with 938 additions and 17 deletions

View file

@ -12,6 +12,7 @@ target_link_libraries(shellsettingsplugin PRIVATE
Qt::DBus Qt::DBus
Qt::Gui Qt::Gui
Qt::Quick Qt::Quick
KF6::ConfigCore
Plasma::KWaylandClient Plasma::KWaylandClient
KF6::Service KF6::Service
KF6::Package KF6::Package

View file

@ -15,16 +15,163 @@
#include <QDBusMessage> #include <QDBusMessage>
#include <QDBusPendingCall> #include <QDBusPendingCall>
#include <QDebug> #include <QDebug>
#include <QDir>
#include <QFile>
#include <QProcess>
#include <QStandardPaths>
#include <QtMath>
const QString CONFIG_FILE = QStringLiteral("plasmamobilerc"); const QString CONFIG_FILE = QStringLiteral("plasmamobilerc");
const QString GENERAL_CONFIG_GROUP = QStringLiteral("General"); const QString GENERAL_CONFIG_GROUP = QStringLiteral("General");
const QString KDE_GLOBALS_CONFIG_GROUP = QStringLiteral("General");
const QString LOCKSCREEN_CONFIG_GROUP = QStringLiteral("Lockscreen"); const QString LOCKSCREEN_CONFIG_GROUP = QStringLiteral("Lockscreen");
const QString QUICKSETTINGS_CONFIG_GROUP = QStringLiteral("QuickSettings"); 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) MobileShellSettings::MobileShellSettings(QObject *parent)
: QObject{parent} : QObject{parent}
, m_config{KSharedConfig::openConfig(CONFIG_FILE)} , 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); m_configWatcher = KConfigWatcher::create(m_config);
connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group, const QByteArrayList &names) -> void { connect(m_configWatcher.data(), &KConfigWatcher::configChanged, this, [this](const KConfigGroup &group, const QByteArrayList &names) -> void {
Q_UNUSED(names) Q_UNUSED(names)
@ -53,6 +200,7 @@ MobileShellSettings::MobileShellSettings(QObject *parent)
Q_EMIT dynamicTilingLayoutStateChanged(); Q_EMIT dynamicTilingLayoutStateChanged();
Q_EMIT snapLayoutsEnabledChanged(); Q_EMIT snapLayoutsEnabledChanged();
Q_EMIT allowLogoutChanged(); Q_EMIT allowLogoutChanged();
Q_EMIT wallpaperThemeEnabledChanged();
} }
if (group.name() == LOCKSCREEN_CONFIG_GROUP) { if (group.name() == LOCKSCREEN_CONFIG_GROUP) {
Q_EMIT lockscreenLeftButtonActionChanged(); Q_EMIT lockscreenLeftButtonActionChanged();
@ -62,6 +210,24 @@ MobileShellSettings::MobileShellSettings(QObject *parent)
Q_EMIT quickSettingsColumnsChanged(); 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 bool MobileShellSettings::vibrationsEnabled() const
@ -243,6 +409,312 @@ void MobileShellSettings::setConvergenceModeEnabled(bool enabled)
job->start(); 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 bool MobileShellSettings::autoHidePanelsEnabled() const
{ {
auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP}; auto group = KConfigGroup{m_config, GENERAL_CONFIG_GROUP};

View file

@ -9,9 +9,11 @@
#include <KConfigGroup> #include <KConfigGroup>
#include <KConfigWatcher> #include <KConfigWatcher>
#include <KSharedConfig> #include <KSharedConfig>
#include <QColor>
#include <QDBusConnection> #include <QDBusConnection>
#include <QObject> #include <QObject>
#include <QStringList> #include <QStringList>
#include <QTimer>
#include <qqmlregistration.h> #include <qqmlregistration.h>
/** /**
@ -50,6 +52,14 @@ class MobileShellSettings : public QObject
// convergence mode // convergence mode
Q_PROPERTY(bool convergenceModeEnabled READ convergenceModeEnabled WRITE setConvergenceModeEnabled NOTIFY convergenceModeEnabledChanged) 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 // Auto Hide Panels
Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged) Q_PROPERTY(bool autoHidePanelsEnabled READ autoHidePanelsEnabled WRITE setAutoHidePanelsEnabled NOTIFY autoHidePanelsEnabledChanged)
@ -274,6 +284,19 @@ public:
*/ */
void setConvergenceModeEnabled(bool enabled); 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. * Whether Auto Hide Panels is enabled.
*/ */
@ -368,6 +391,12 @@ Q_SIGNALS:
void actionDrawerTopRightModeChanged(); void actionDrawerTopRightModeChanged();
void quickSettingsColumnsChanged(); void quickSettingsColumnsChanged();
void convergenceModeEnabledChanged(); void convergenceModeEnabledChanged();
void colorSchemeChanged();
void darkThemeEnabledChanged();
void wallpaperThemeEnabledChanged();
void wallpaperThemeColorChanged();
void accentColorChanged();
void wallpaperAccentEnabledChanged();
void autoHidePanelsEnabledChanged(); void autoHidePanelsEnabledChanged();
void gamingModeEnabledChanged(); void gamingModeEnabledChanged();
void gamingDismissHintEnabledChanged(); void gamingDismissHintEnabledChanged();
@ -383,7 +412,17 @@ Q_SIGNALS:
private: private:
void updateNavigationBarsInPlasma(); 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_configWatcher;
KConfigWatcher::Ptr m_kdeGlobalsConfigWatcher;
KSharedConfig::Ptr m_config; KSharedConfig::Ptr m_config;
KSharedConfig::Ptr m_kdeGlobalsConfig;
QTimer m_wallpaperThemeTimer;
bool m_pendingWallpaperThemeDark = false;
QColor m_lastWallpaperThemeColor;
}; };

View file

@ -82,6 +82,7 @@
"Description[zh_TW]": "設定系統殼層介面", "Description[zh_TW]": "設定系統殼層介面",
"EnabledByDefault": true, "EnabledByDefault": true,
"FormFactors": [ "FormFactors": [
"desktop",
"handset", "handset",
"tablet" "tablet"
], ],
@ -130,7 +131,7 @@
"Name[zh_TW]": "殼層", "Name[zh_TW]": "殼層",
"Website": "https://invent.kde.org/marcoa/shift-shell" "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[ar]": "نظام,صدفة,لوحة",
"X-KDE-Keywords[az]": "sistem,örtük,panel", "X-KDE-Keywords[az]": "sistem,örtük,panel",
"X-KDE-Keywords[ca@valencia]": "sistema,intèrpret d'ordres,quadro", "X-KDE-Keywords[ca@valencia]": "sistema,intèrpret d'ordres,quadro",

View file

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

View file

@ -28,6 +28,19 @@ KCM.SimpleKCM {
} }
ColumnLayout { 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 { FormCard.FormHeader {
title: i18n("General") title: i18n("General")
} }
@ -314,7 +327,6 @@ KCM.SimpleKCM {
Component.onCompleted: { Component.onCompleted: {
currentIndex = indexOfValue(ShellSettings.Settings.statusBarScaleFactor); currentIndex = indexOfValue(ShellSettings.Settings.statusBarScaleFactor);
dialog.parent = root;
} }
onCurrentValueChanged: ShellSettings.Settings.statusBarScaleFactor = currentValue onCurrentValueChanged: ShellSettings.Settings.statusBarScaleFactor = currentValue
} }
@ -354,7 +366,6 @@ KCM.SimpleKCM {
Component.onCompleted: { Component.onCompleted: {
currentIndex = indexOfValue(ShellSettings.Settings.actionDrawerTopLeftMode); currentIndex = indexOfValue(ShellSettings.Settings.actionDrawerTopLeftMode);
dialog.parent = root;
} }
onCurrentValueChanged: ShellSettings.Settings.actionDrawerTopLeftMode = currentValue onCurrentValueChanged: ShellSettings.Settings.actionDrawerTopLeftMode = currentValue
} }
@ -376,7 +387,6 @@ KCM.SimpleKCM {
Component.onCompleted: { Component.onCompleted: {
currentIndex = indexOfValue(ShellSettings.Settings.actionDrawerTopRightMode); currentIndex = indexOfValue(ShellSettings.Settings.actionDrawerTopRightMode);
dialog.parent = root
} }
onCurrentValueChanged: ShellSettings.Settings.actionDrawerTopRightMode = currentValue onCurrentValueChanged: ShellSettings.Settings.actionDrawerTopRightMode = currentValue
} }
@ -407,7 +417,6 @@ KCM.SimpleKCM {
Component.onCompleted: { Component.onCompleted: {
currentIndex = indexOfValue(ShellSettings.Settings.lockscreenLeftButtonAction); currentIndex = indexOfValue(ShellSettings.Settings.lockscreenLeftButtonAction);
dialog.parent = root;
} }
onCurrentValueChanged: ShellSettings.Settings.lockscreenLeftButtonAction = currentValue onCurrentValueChanged: ShellSettings.Settings.lockscreenLeftButtonAction = currentValue
} }
@ -429,7 +438,6 @@ KCM.SimpleKCM {
Component.onCompleted: { Component.onCompleted: {
currentIndex = indexOfValue(ShellSettings.Settings.lockscreenRightButtonAction); currentIndex = indexOfValue(ShellSettings.Settings.lockscreenRightButtonAction);
dialog.parent = root;
} }
onCurrentValueChanged: ShellSettings.Settings.lockscreenRightButtonAction = currentValue onCurrentValueChanged: ShellSettings.Settings.lockscreenRightButtonAction = currentValue
} }

View file

@ -14,6 +14,7 @@ import org.kde.kirigami as Kirigami
import org.kde.plasma.private.mobileshell as MobileShell import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
Rectangle { Rectangle {
id: root id: root
@ -65,7 +66,7 @@ Rectangle {
Loader { Loader {
id: wallpaperColors id: wallpaperColors
active: desktop.usedInAccentColor && root.containment && root.containment.wallpaper active: (desktop.usedInAccentColor || ShellSettings.Settings.wallpaperThemeEnabled) && root.containment && root.containment.wallpaper
asynchronous: true asynchronous: true
sourceComponent: Kirigami.ImageColors { sourceComponent: Kirigami.ImageColors {
@ -80,26 +81,46 @@ Rectangle {
Kirigami.Theme.backgroundColor: backgroundColor Kirigami.Theme.backgroundColor: backgroundColor
Kirigami.Theme.textColor: textColor 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) onBackgroundColorChanged: Qt.callLater(update)
onTextColorChanged: 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 { property Binding colorBinding: Binding {
target: desktop target: desktop
property: "accentColor" property: "accentColor"
value: { value: imageColors.wallpaperColor
if (!Qt.colorEqual(imageColors.colorFromPlugin, "transparent")) {
return imageColors.colorFromPlugin;
}
if (imageColors.palette.length === 0) {
return "transparent";
}
return imageColors.dominant;
}
when: desktop.usedInAccentColor // Without this, accentColor may still be updated after usedInAccentColor becomes false when: desktop.usedInAccentColor // Without this, accentColor may still be updated after usedInAccentColor becomes false
} }
property Connections repaintConnection: Connections { property Connections repaintConnection: Connections {
target: root.containment.wallpaper target: root.containment.wallpaper
ignoreUnknownSignals: true
function onRepaintNeeded(color) { function onRepaintNeeded(color) {
imageColors.colorFromPlugin = color; imageColors.colorFromPlugin = color;
@ -110,7 +131,10 @@ Rectangle {
} }
} }
onLoaded: item.update() onLoaded: {
item.update();
item.applyWallpaperTheme();
}
} }
Loader { Loader {

View file

@ -25,6 +25,11 @@ if(BASH_EXECUTABLE)
COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-shift-theme-identity.sh 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( add_test(
NAME shift-preview-qml-stubs NAME shift-preview-qml-stubs
COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-preview-qml-stubs.sh COMMAND ${BASH_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/check-preview-qml-stubs.sh

View file

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