From de91b78943d7ce811def71139a43e92aea24d3dd Mon Sep 17 00:00:00 2001 From: bbedward Date: Sat, 20 Jun 2026 15:23:19 -0400 Subject: [PATCH] theme/icons: add separate light/dark path, hot-reload, detect external DMS maangedand reset fixes #608 closes #2674 --- quickshell/Common/Paths.qml | 34 ++-- quickshell/Common/SettingsData.qml | 100 ++++++++++- quickshell/Common/settings/SettingsSpec.js | 5 +- .../Modules/Settings/ThemeColorsTab.qml | 61 ++++++- quickshell/Modules/Toast.qml | 7 - quickshell/Services/IconThemeService.qml | 157 ++++++++++++++++++ .../translations/settings_search_index.json | 90 +++++++++- 7 files changed, 417 insertions(+), 37 deletions(-) create mode 100644 quickshell/Services/IconThemeService.qml diff --git a/quickshell/Common/Paths.qml b/quickshell/Common/Paths.qml index 7dc756b2..2d6e23fe 100644 --- a/quickshell/Common/Paths.qml +++ b/quickshell/Common/Paths.qml @@ -74,6 +74,15 @@ Singleton { return appId; } + function themedIconPath(name: string): string { + if (!name) + return ""; + const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(name) : ""; + if (themed) + return themed; + return Quickshell.iconPath(name, true); + } + function resolveIconPath(iconName: string): string { if (!iconName) return ""; @@ -83,23 +92,24 @@ Singleton { return toFileUrl(expandTilde(moddedId)); if (moddedId.startsWith("file://")) return moddedId; - return Quickshell.iconPath(moddedId, true); + return themedIconPath(moddedId); } - return Quickshell.iconPath(iconName, true) || DesktopService.resolveIconPath(iconName); + return themedIconPath(iconName) || DesktopService.resolveIconPath(iconName); } function resolveIconUrl(iconName: string): string { if (!iconName) return ""; const moddedId = moddedAppId(iconName); - if (moddedId !== iconName) { - if (moddedId.startsWith("~") || moddedId.startsWith("/")) - return toFileUrl(expandTilde(moddedId)); - if (moddedId.startsWith("file://")) - return moddedId; - return "image://icon/" + moddedId; - } - return "image://icon/" + iconName; + const target = (moddedId !== iconName) ? moddedId : iconName; + if (target.startsWith("~") || target.startsWith("/")) + return toFileUrl(expandTilde(target)); + if (target.startsWith("file://")) + return target; + const themed = (typeof IconThemeService !== "undefined") ? IconThemeService.resolve(target) : ""; + if (themed) + return themed; + return "image://icon/" + target; } function getAppIcon(appId: string, desktopEntry: var): string { @@ -113,10 +123,10 @@ Singleton { return resolveIconPath(appId); if (desktopEntry && desktopEntry.icon) { - return Quickshell.iconPath(desktopEntry.icon, true); + return themedIconPath(desktopEntry.icon); } - const icon = Quickshell.iconPath(appId, true); + const icon = themedIconPath(appId); if (icon && icon !== "") return icon; diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index ca9774d1..6e266847 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -487,7 +487,11 @@ Singleton { property string networkPreference: "auto" - property string iconTheme: "System Default" + property string iconThemeDark: "System Default" + property string iconThemeLight: "System Default" + property bool iconThemePerMode: false + property string lastAppliedIconTheme: "" + readonly property string iconTheme: resolveIconTheme() property var availableIconThemes: ["System Default"] property string systemDefaultIconTheme: "" property bool qt5ctAvailable: false @@ -1279,14 +1283,67 @@ Singleton { MangoService.generateLayoutConfig(); } + function resolveIconTheme() { + if (iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode) + return iconThemeLight; + return iconThemeDark; + } + function applyStoredIconTheme() { updateGtkIconTheme(); updateQtIconTheme(); updateCosmicIconTheme(); } + function setIconThemeUnmanaged() { + iconThemePerMode = false; + iconThemeDark = "System Default"; + iconThemeLight = "System Default"; + lastAppliedIconTheme = ""; + saveSettings(); + } + + function checkIconThemeDrift() { + if (isGreeterMode) + return; + if (resolveIconTheme() === "System Default") + return; + if (!lastAppliedIconTheme) + return; + const script = `if command -v gsettings >/dev/null 2>&1; then + gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" + elif command -v dconf >/dev/null 2>&1; then + dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g" + fi`; + + Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => { + const platform = (output || "").trim(); + if (!platform) + return; + if (platform === root.lastAppliedIconTheme || platform === root.iconThemeDark || platform === root.iconThemeLight) + return; + root.setIconThemeUnmanaged(); + ToastService.showWarning(I18n.tr("Icon theme changed outside DMS; switched to System Default", "shown when an external tool overrides the icon theme DMS applied")); + }); + } + + Connections { + target: typeof SessionData !== "undefined" ? SessionData : null + function onIsLightModeChanged() { + if (!SessionData.isSwitchingMode) + return; + if (!root.iconThemePerMode) + return; + if (root.iconThemeLight === root.iconThemeDark) + return; + root.applyStoredIconTheme(); + root.saveSettings(); + } + } + function updateCosmicIconTheme() { - let cosmicThemeName = (iconTheme === "System Default") ? systemDefaultIconTheme : iconTheme; + const resolved = resolveIconTheme(); + let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved; if (!cosmicThemeName || cosmicThemeName === "System Default") { const detectScript = `if command -v gsettings >/dev/null 2>&1; then gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" @@ -1322,9 +1379,11 @@ Singleton { } function updateGtkIconTheme() { - const gtkThemeName = (iconTheme === "System Default") ? systemDefaultIconTheme : iconTheme; + const resolved = resolveIconTheme(); + const gtkThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved; if (gtkThemeName === "System Default" || gtkThemeName === "") return; + lastAppliedIconTheme = gtkThemeName; if (typeof DMSService !== "undefined" && DMSService.apiVersion >= 3 && typeof PortalService !== "undefined") { PortalService.setSystemIconTheme(gtkThemeName); } @@ -1349,13 +1408,20 @@ Singleton { fi done + if command -v gsettings >/dev/null 2>&1; then + gsettings set org.gnome.desktop.interface icon-theme '${gtkThemeName}' 2>/dev/null || true + elif command -v dconf >/dev/null 2>&1; then + dconf write /org/gnome/desktop/interface/icon-theme "'${gtkThemeName}'" 2>/dev/null || true + fi + pkill -HUP -f 'gtk' 2>/dev/null || true`; Quickshell.execDetached(["sh", "-lc", configScript]); } function updateQtIconTheme() { - const qtThemeName = (iconTheme === "System Default") ? "" : iconTheme; + const resolved = resolveIconTheme(); + const qtThemeName = (resolved === "System Default") ? "" : resolved; if (!qtThemeName) return; const home = _homeUrl.replace("file://", "").replace(/'/g, "'\\''"); @@ -1442,6 +1508,9 @@ Singleton { if (obj?.directionalAnimationMode === 3 && frameMode !== "connected") frameMode = "connected"; + if (obj?.iconTheme !== undefined && obj?.iconThemeDark === undefined) + iconThemeDark = obj.iconTheme; + if (obj?.weatherLocation !== undefined) _legacyWeatherLocation = obj.weatherLocation; if (obj?.weatherCoordinates !== undefined) @@ -1457,6 +1526,7 @@ Singleton { applyStoredTheme(); updateCompositorCursor(); Processes.detectQtTools(); + Qt.callLater(checkIconThemeDrift); _checkSettingsWritable(); } catch (e) { @@ -2464,10 +2534,24 @@ Singleton { } function setIconTheme(themeName) { - iconTheme = themeName; - updateGtkIconTheme(); - updateQtIconTheme(); - updateCosmicIconTheme(); + const light = iconThemePerMode && typeof SessionData !== "undefined" && SessionData.isLightMode; + setIconThemeForMode(themeName, light); + } + + function setIconThemeForMode(themeName, light) { + if (light) + iconThemeLight = themeName; + else + iconThemeDark = themeName; + applyStoredIconTheme(); + saveSettings(); + if (typeof Theme !== "undefined" && Theme.currentTheme === Theme.dynamic) + Theme.generateSystemThemesFromCurrentTheme(); + } + + function setIconThemePerMode(enabled) { + iconThemePerMode = enabled; + applyStoredIconTheme(); saveSettings(); if (typeof Theme !== "undefined" && Theme.currentTheme === Theme.dynamic) Theme.generateSystemThemesFromCurrentTheme(); diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 0636687a..d01f30c0 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -244,7 +244,10 @@ var SPEC = { networkPreference: { def: "auto" }, - iconTheme: { def: "System Default", onChange: "applyStoredIconTheme" }, + iconThemeDark: { def: "System Default", onChange: "applyStoredIconTheme" }, + iconThemeLight: { def: "System Default", onChange: "applyStoredIconTheme" }, + iconThemePerMode: { def: false, onChange: "applyStoredIconTheme" }, + lastAppliedIconTheme: { def: "" }, availableIconThemes: { def: ["System Default"], persist: false }, systemDefaultIconTheme: { def: "", persist: false }, qt5ctAvailable: { def: false, persist: false }, diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index 556c0d4b..6b15ba55 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -191,6 +191,12 @@ Item { PopoutService.colorPickerModal.show(); } + function warnIfMissingQtTheme() { + if (Quickshell.env("QT_QPA_PLATFORMTHEME") === "gtk3" || Quickshell.env("QT_QPA_PLATFORMTHEME") === "qt6ct" || Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") === "qt6ct") + return; + ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set either:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct\nas environment variables, and then restart the shell.\n\nqt6ct requires qt6ct-kde to be installed.", "qt theme env error body")); + } + function formatThemeAutoTime(isoString) { if (!isoString) return ""; @@ -2264,22 +2270,67 @@ Item { settingKey: "iconTheme" iconName: "interests" + SettingsToggleRow { + tab: "theme" + tags: ["icon", "theme", "light", "dark", "mode"] + settingKey: "iconThemePerMode" + text: I18n.tr("Separate Light & Dark Themes") + description: I18n.tr("Use different icon themes for light and dark mode") + checked: SettingsData.iconThemePerMode + onToggled: checked => SettingsData.setIconThemePerMode(checked) + } + SettingsDropdownRow { tab: "theme" tags: ["icon", "theme", "system"] settingKey: "iconTheme" text: I18n.tr("Icon Theme") description: I18n.tr("DankShell & System Icons (requires restart)") - currentValue: SettingsData.iconTheme + visible: !SettingsData.iconThemePerMode + currentValue: SettingsData.iconThemeDark enableFuzzySearch: true popupWidthOffset: 100 maxPopupHeight: 236 options: cachedIconThemes onValueChanged: value => { - SettingsData.setIconTheme(value); - if (Quickshell.env("QT_QPA_PLATFORMTHEME") != "gtk3" && Quickshell.env("QT_QPA_PLATFORMTHEME") != "qt6ct" && Quickshell.env("QT_QPA_PLATFORMTHEME_QT6") != "qt6ct") { - ToastService.showError(I18n.tr("Missing Environment Variables", "qt theme env error title"), I18n.tr("You need to set either:\nQT_QPA_PLATFORMTHEME=gtk3 OR\nQT_QPA_PLATFORMTHEME=qt6ct\nas environment variables, and then restart the shell.\n\nqt6ct requires qt6ct-kde to be installed.", "qt theme env error body")); - } + SettingsData.setIconThemeForMode(value, false); + warnIfMissingQtTheme(); + } + } + + SettingsDropdownRow { + tab: "theme" + tags: ["icon", "theme", "system", "dark"] + settingKey: "iconThemeDark" + text: I18n.tr("Dark Mode Icon Theme") + description: I18n.tr("DankShell & System Icons (requires restart)") + visible: SettingsData.iconThemePerMode + currentValue: SettingsData.iconThemeDark + enableFuzzySearch: true + popupWidthOffset: 100 + maxPopupHeight: 236 + options: cachedIconThemes + onValueChanged: value => { + SettingsData.setIconThemeForMode(value, false); + warnIfMissingQtTheme(); + } + } + + SettingsDropdownRow { + tab: "theme" + tags: ["icon", "theme", "system", "light"] + settingKey: "iconThemeLight" + text: I18n.tr("Light Mode Icon Theme") + description: I18n.tr("DankShell & System Icons (requires restart)") + visible: SettingsData.iconThemePerMode + currentValue: SettingsData.iconThemeLight + enableFuzzySearch: true + popupWidthOffset: 100 + maxPopupHeight: 236 + options: cachedIconThemes + onValueChanged: value => { + SettingsData.setIconThemeForMode(value, true); + warnIfMissingQtTheme(); } } } diff --git a/quickshell/Modules/Toast.qml b/quickshell/Modules/Toast.qml index b441294f..bd061af3 100644 --- a/quickshell/Modules/Toast.qml +++ b/quickshell/Modules/Toast.qml @@ -422,13 +422,6 @@ PanelWindow { } } - Behavior on color { - ColorAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing - } - } - Behavior on height { enabled: false } diff --git a/quickshell/Services/IconThemeService.qml b/quickshell/Services/IconThemeService.qml new file mode 100644 index 00000000..27d6b6a0 --- /dev/null +++ b/quickshell/Services/IconThemeService.qml @@ -0,0 +1,157 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtCore +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Singleton { + id: root + readonly property var log: Log.scoped("IconThemeService") + + readonly property string managedTheme: { + if (typeof SettingsData === "undefined") + return ""; + const t = SettingsData.resolveIconTheme(); + return (!t || t === "System Default") ? "" : t; + } + + property var _searchDirs: [] + property string _dirsForTheme: "" + property var _cache: ({}) + property int revision: 0 + property bool _bumpPending: false + + readonly property var _baseDirs: { + const xdg = Quickshell.env("XDG_DATA_DIRS") || ""; + const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation)); + const home = Paths.strip(StandardPaths.writableLocation(StandardPaths.HomeLocation)); + const dataDirs = xdg.trim() !== "" ? xdg.split(":").concat([localData]) : ["/usr/share", "/usr/local/share", localData]; + return dataDirs.map(d => d + "/icons").concat([home + "/.icons"]); + } + + onManagedThemeChanged: _rebuild() + Component.onCompleted: _rebuild() + + function _bumpRevision() { + if (_bumpPending) + return; + _bumpPending = true; + Qt.callLater(() => { + _bumpPending = false; + revision++; + }); + } + + function _rebuild() { + _cache = ({}); + if (!managedTheme) { + _searchDirs = []; + _dirsForTheme = ""; + _bumpRevision(); + return; + } + const theme = managedTheme; + const bases = _baseDirs.join(" "); + const script = `BASES="${bases}" +find_index() { for b in $BASES; do [ -f "$b/$1/index.theme" ] && { echo "$b/$1/index.theme"; return 0; }; done; return 1; } +visited=""; queue="${theme}"; order="" +while [ -n "$queue" ]; do + cur=\${queue%% *}; rest=\${queue#"$cur"}; queue=\${rest# } + [ -z "$cur" ] && continue + case " $visited " in *" $cur "*) continue;; esac + visited="$visited $cur"; order="$order $cur" + idx=$(find_index "$cur") || continue + inh=$(sed -n 's/^Inherits=//p' "$idx" | head -1 | tr -d '"' | tr ',' ' ') + queue="$queue $inh" +done +case " $visited " in *" hicolor "*) ;; *) order="$order hicolor";; esac +for t in $order; do for b in $BASES; do d="$b/$t"; [ -d "$d" ] && echo "$d"; done; done`; + + Proc.runCommand("iconChain:" + theme, ["sh", "-c", script], (out, code) => { + if (root.managedTheme !== theme) + return; + root._searchDirs = (out || "").trim().split("\n").filter(s => s); + root._dirsForTheme = theme; + root._cache = ({}); + root._bumpRevision(); + }); + } + + function resolve(name) { + const _dep = revision; + if (!managedTheme || !name) + return ""; + if (name.startsWith("/") || name.startsWith("file://") || name.startsWith("image://") || name.startsWith("~")) + return ""; + if (!/^[\w.+-]+$/.test(name)) + return ""; + if (_dirsForTheme !== managedTheme || _searchDirs.length === 0) + return ""; + if (name in _cache) + return _cache[name] || ""; + _cache[name] = null; + _resolveAsync(name); + return ""; + } + + function _resolveAsync(name) { + const dirs = _searchDirs.join(" "); + const script = `find -L ${dirs} \\( -name '${name}.svg' -o -name '${name}.png' \\) 2>/dev/null`; + Proc.runCommand("iconResolve:" + name, ["sh", "-c", script], (out, code) => { + const paths = (out || "").trim().split("\n").filter(s => s); + const best = root._pickBest(paths); + const c = root._cache; + c[name] = best ? Paths.toFileUrl(best) : ""; + root._cache = c; + root._bumpRevision(); + }, 0); + } + + function _pickBest(paths) { + let best = ""; + let bestScore = -1; + for (let i = 0; i < paths.length; i++) { + const s = _score(paths[i]); + if (s > bestScore) { + bestScore = s; + best = paths[i]; + } + } + return best; + } + + function _chainIndex(path) { + for (let i = 0; i < _searchDirs.length; i++) { + if (path.startsWith(_searchDirs[i] + "/")) + return i; + } + return _searchDirs.length; + } + + function _score(path) { + let s = 0; + if (path.includes("/apps/")) + s += 3000000000; + else if (path.includes("/categories/")) + s += 1000000000; + else if (path.includes("/places/") || path.includes("/devices/") || path.includes("/mimetypes/") || path.includes("/status/") || path.includes("/actions/")) + s += 100000000; + + s += Math.max(0, (64 - _chainIndex(path))) * 1000000; + + if (path.endsWith(".svg")) + s += 100000; + + if (path.includes("/scalable/")) { + s += 1000; + } else { + const m = path.match(/\/(\d+)(?:x\d+)?(?:@\d+x)?\//); + if (m) + s += Math.min(parseInt(m[1]), 999); + } + return s; + } +} diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 47a69ea3..656c60bb 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3207,6 +3207,30 @@ ], "description": "Blend between Surface High and the selected custom color" }, + { + "section": "iconThemeDark", + "label": "Dark Mode Icon Theme", + "tabIndex": 10, + "category": "Theme & Colors", + "keywords": [ + "appearance", + "colors", + "colour", + "dankshell", + "dark", + "dark mode", + "icon", + "icons", + "look", + "mode", + "night", + "scheme", + "style", + "system", + "theme" + ], + "description": "DankShell & System Icons (requires restart)" + }, { "section": "matugenTemplateNeovimSettings", "label": "Dark mode base", @@ -3446,17 +3470,24 @@ "appearance", "colors", "colour", - "dankshell", + "dark", + "dark mode", + "day", + "different", "icon", - "icons", + "light", + "light mode", "look", + "mode", + "night", "scheme", "style", "system", - "theme" + "theme", + "themes" ], "icon": "interests", - "description": "DankShell & System Icons (requires restart)" + "description": "Use different icon themes for light and dark mode" }, { "section": "matugenTemplateKcolorscheme", @@ -3564,6 +3595,30 @@ ], "description": "Use light theme instead of dark theme" }, + { + "section": "iconThemeLight", + "label": "Light Mode Icon Theme", + "tabIndex": 10, + "category": "Theme & Colors", + "keywords": [ + "appearance", + "colors", + "colour", + "dankshell", + "day", + "icon", + "icons", + "light", + "light mode", + "look", + "mode", + "scheme", + "style", + "system", + "theme" + ], + "description": "DankShell & System Icons (requires restart)" + }, { "section": "matugenContrast", "label": "Matugen Contrast", @@ -3783,6 +3838,33 @@ "user" ] }, + { + "section": "iconThemePerMode", + "label": "Separate Light & Dark Themes", + "tabIndex": 10, + "category": "Theme & Colors", + "keywords": [ + "appearance", + "colors", + "colour", + "dark", + "dark mode", + "day", + "different", + "icon", + "light", + "light mode", + "look", + "mode", + "night", + "scheme", + "separate", + "style", + "theme", + "themes" + ], + "description": "Use different icon themes for light and dark mode" + }, { "section": "m3ElevationColorMode", "label": "Shadow Color",