Files

274 lines
9.4 KiB
QML

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 settingsTheme: {
if (typeof SettingsData === "undefined")
return "";
const t = SettingsData.resolveIconTheme();
return (!t || t === "System Default") ? "" : t;
}
property string systemProbedTheme: ""
readonly property string managedTheme: settingsTheme || systemProbedTheme
property var _searchDirs: []
property string _dirsForTheme: ""
property var _cache: ({})
property var _looseIconPaths: ({})
property int _cacheGeneration: 0
property int _rebuildGeneration: 0
property int _looseIndexGeneration: 0
property int revision: 0
property bool _bumpPending: false
// XDG icon spec order: user dirs win over system ones, so overrides in ~/.local/share/icons beat installed themes.
readonly property var _userIconRoots: {
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
const home = Paths.strip(StandardPaths.writableLocation(StandardPaths.HomeLocation));
return [home + "/.icons", localData + "/icons"];
}
readonly property var _dataDirs: {
const xdg = Quickshell.env("XDG_DATA_DIRS") || "";
const localData = Paths.strip(StandardPaths.writableLocation(StandardPaths.GenericDataLocation));
const dataDirs = xdg.trim() !== "" ? [localData].concat(xdg.split(":")) : [localData, "/usr/local/share", "/usr/share"];
for (const flatpak of [localData + "/flatpak/exports/share", "/var/lib/flatpak/exports/share"]) {
if (!dataDirs.includes(flatpak))
dataDirs.push(flatpak);
}
return dataDirs.filter((dir, index) => dir && dataDirs.indexOf(dir) === index);
}
readonly property var _baseDirs: {
const bases = [..._userIconRoots];
for (const d of _dataDirs) {
const icons = d + "/icons";
if (!bases.includes(icons))
bases.push(icons);
}
return bases;
}
// Loose icon files (AppImage managers, legacy /usr/share/pixmaps installs) live outside any theme.
readonly property var _looseDirs: {
const dirs = _userIconRoots.concat(_dataDirs.map(d => d + "/pixmaps"));
return dirs.filter((dir, index) => dirs.indexOf(dir) === index);
}
onManagedThemeChanged: _rebuild()
Component.onCompleted: {
Paths.iconResolver = name => resolve(name);
_probeSystemTheme();
_rebuild();
}
Connections {
target: DesktopEntries
function onApplicationsChanged() {
root._indexLooseIcons(() => root._invalidateMisses());
}
}
// "System Default" leaves Qt's lookup, which sees only hicolor unless a Qt platform theme or QS_ICON_THEME is configured.
function _probeSystemTheme() {
if (Quickshell.env("QS_ICON_THEME"))
return;
const script = `v=$(sed -n 's/^gtk-icon-theme-name *= *//p' "\${XDG_CONFIG_HOME:-$HOME/.config}/gtk-3.0/settings.ini" 2>/dev/null | head -1)
[ -z "$v" ] && v=$(${GSettings.getCmd("org.gnome.desktop.interface", "icon-theme")})
printf '%s' "$v" | tr -d "'\\""`;
Proc.runCommand("iconThemeProbe", ["sh", "-c", script], (out, code) => {
const theme = (out || "").trim();
if (!theme)
return;
root.systemProbedTheme = theme;
});
}
function _bumpRevision() {
if (_bumpPending)
return;
_bumpPending = true;
Qt.callLater(() => {
_bumpPending = false;
revision++;
});
}
function _invalidateMisses() {
const c = {};
let changed = false;
for (const name in _cache) {
if (_cache[name])
c[name] = _cache[name];
else
changed = true;
}
if (!changed)
return;
_cacheGeneration++;
_cache = c;
_bumpRevision();
}
function _indexLooseIcons(callback) {
const generation = ++_looseIndexGeneration;
const args = ["find", "-L"].concat(_looseDirs, ["-maxdepth", "1", "(", "-name", "*.svg", "-o", "-name", "*.png", "-o", "-name", "*.xpm", ")"]);
Proc.runCommand("iconLooseIndex", args, (out, code) => {
if (root._looseIndexGeneration !== generation)
return;
const index = {};
const paths = (out || "").trim().split("\n").filter(s => s);
for (const path of paths) {
const fileName = path.substring(path.lastIndexOf("/") + 1);
const name = fileName.replace(/\.(svg|png|xpm)$/, "");
const current = index[name];
index[name] = current ? root._pickBest([current, path]) : path;
}
root._looseIconPaths = index;
if (callback)
callback();
}, 0);
}
function _rebuild() {
const rebuildGeneration = ++_rebuildGeneration;
_cacheGeneration++;
_cache = ({});
_dirsForTheme = "";
_indexLooseIcons(() => root._invalidateMisses());
if (!managedTheme) {
_searchDirs = [];
_bumpRevision();
return;
}
const theme = managedTheme;
const script = `theme=$1
shift
find_index() { target=$1; shift; for b; do [ -f "$b/$target/index.theme" ] && { echo "$b/$target/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; do d="$b/$t"; [ -d "$d" ] && echo "$d"; done; done`;
Proc.runCommand("iconChain:" + theme, ["sh", "-c", script, "icon-chain", theme].concat(_baseDirs), (out, code) => {
if (root.managedTheme !== theme || root._rebuildGeneration !== rebuildGeneration)
return;
root._searchDirs = (out || "").trim().split("\n").filter(s => s);
root._dirsForTheme = theme;
root._cacheGeneration++;
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)
return "";
if (name in _cache)
return _cache[name] || "";
_cache[name] = null;
_resolveAsync(name);
return "";
}
function _resolveAsync(name) {
const generation = _cacheGeneration;
function finish(paths) {
if (root._cacheGeneration !== generation)
return;
const best = paths.length > 0 ? root._pickBest(paths) : root._looseIconPaths[name] || "";
const c = root._cache;
c[name] = best ? Paths.toFileUrl(best) : "";
root._cache = c;
root._bumpRevision();
}
if (_searchDirs.length === 0) {
finish([]);
return;
}
const args = ["find", "-L"].concat(_searchDirs, ["(", "-name", name + ".svg", "-o", "-name", name + ".png", ")"]);
Proc.runCommand("iconResolveTheme:" + name, args, (out, code) => {
const paths = (out || "").trim().split("\n").filter(s => s);
finish(paths);
}, 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;
}
for (let i = 0; i < _looseDirs.length; i++) {
if (path.startsWith(_looseDirs[i] + "/"))
return _searchDirs.length + i;
}
return _searchDirs.length + _looseDirs.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.min(99, Math.max(0, _searchDirs.length + _looseDirs.length - _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;
}
}