diff --git a/src/core/iconimageprovider.cpp b/src/core/iconimageprovider.cpp index 1dbe3e76..1bdd2717 100644 --- a/src/core/iconimageprovider.cpp +++ b/src/core/iconimageprovider.cpp @@ -2,43 +2,228 @@ #include #include +#include +#include #include #include #include #include #include #include +#include +#include +#include -QPixmap -IconImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) { +namespace { + +struct IconRequest { QString iconName; QString fallbackName; - QString path; - - auto splitIdx = id.indexOf("?path="); - if (splitIdx != -1) { - iconName = id.sliced(0, splitIdx); - path = id.sliced(splitIdx + 6); - path = QString("/%1/%2").arg(path, iconName.sliced(iconName.lastIndexOf('/') + 1)); - } else { - splitIdx = id.indexOf("?fallback="); - if (splitIdx != -1) { - iconName = id.sliced(0, splitIdx); - fallbackName = id.sliced(splitIdx + 10); - } else { - iconName = id; + QString themePath; +}; + +QSize normalizedRequestedSize(const QSize& requestedSize) { + auto targetSize = requestedSize.isValid() ? requestedSize : QSize(100, 100); + if (targetSize.width() == 0 || targetSize.height() == 0) targetSize = QSize(2, 2); + return targetSize; +} + +QString normalizedLocalFile(const QString& value) { + if (value.isEmpty()) return QString(); + + auto url = QUrl(value); + if (url.isLocalFile()) return url.toLocalFile(); + + return value; +} + +QString iconNameWithoutExtension(const QString& iconName) { + auto info = QFileInfo(iconName); + auto suffix = info.suffix(); + if (suffix.isEmpty()) return iconName; + + static const auto iconSuffixes = QStringList { + "png", + "svg", + "svgz", + "xpm", + "ico", + }; + + if (!iconSuffixes.contains(suffix, Qt::CaseInsensitive)) return iconName; + + auto baseName = iconName; + baseName.chop(suffix.length() + 1); + return baseName; +} + +QStringList iconNameCandidates(const QString& iconName) { + auto name = normalizedLocalFile(iconName.trimmed()); + if (name.isEmpty()) return {}; + + auto candidates = QStringList {name}; + auto withoutExtension = iconNameWithoutExtension(name); + if (withoutExtension != name) candidates.push_back(withoutExtension); + + auto baseName = QFileInfo(name).fileName(); + if (!baseName.isEmpty() && baseName != name) { + candidates.push_back(baseName); + auto baseWithoutExtension = iconNameWithoutExtension(baseName); + if (baseWithoutExtension != baseName) candidates.push_back(baseWithoutExtension); + } + + candidates.removeDuplicates(); + return candidates; +} + +QStringList iconThemeSearchPaths(const QString& rawPath) { + auto path = normalizedLocalFile(rawPath.trimmed()); + if (path.isEmpty()) return {}; + + auto info = QFileInfo(path); + if (info.isFile()) path = info.absolutePath(); + + auto paths = QStringList {path}; + auto dir = QFileInfo(path); + for (auto i = 0; i < 8 && dir.exists(); ++i) { + if (QFileInfo(dir.absoluteFilePath() + "/index.theme").exists()) { + auto parent = dir.dir().absolutePath(); + if (!parent.isEmpty()) paths.push_front(parent); + break; } + + auto parent = dir.dir(); + if (parent.absolutePath() == dir.absoluteFilePath()) break; + dir = QFileInfo(parent.absolutePath()); } - auto icon = QIcon::fromTheme(iconName); - if (icon.isNull() && !fallbackName.isEmpty()) icon = QIcon::fromTheme(fallbackName); - if (icon.isNull() && !path.isEmpty()) icon = QPixmap(path); + paths.removeDuplicates(); + return paths; +} - auto targetSize = requestedSize.isValid() ? requestedSize : QSize(100, 100); - if (targetSize.width() == 0 || targetSize.height() == 0) targetSize = QSize(2, 2); - auto pixmap = icon.pixmap(targetSize.width(), targetSize.height()); +class IconThemeSearchPathScope { +public: + explicit IconThemeSearchPathScope(const QString& themePath) + : oldSearchPaths(QIcon::themeSearchPaths()) { + auto searchPaths = iconThemeSearchPaths(themePath); + if (searchPaths.isEmpty()) return; + + auto newSearchPaths = this->oldSearchPaths; + for (const auto& path: searchPaths) { + if (!newSearchPaths.contains(path)) newSearchPaths.push_back(path); + } + + QIcon::setThemeSearchPaths(newSearchPaths); + this->active = true; + } + + ~IconThemeSearchPathScope() { + if (this->active) QIcon::setThemeSearchPaths(this->oldSearchPaths); + } + +private: + QStringList oldSearchPaths; + bool active = false; +}; + +QStringList directFileCandidates(const QString& iconName, const QString& themePath) { + auto names = iconNameCandidates(iconName); + auto themePathValue = normalizedLocalFile(themePath.trimmed()); + auto candidates = QStringList(); + + for (const auto& name: names) { + if (QFileInfo(name).isAbsolute()) candidates.push_back(name); + } + + if (!themePathValue.isEmpty()) { + auto themeInfo = QFileInfo(themePathValue); + auto baseDir = themeInfo.isFile() ? themeInfo.absolutePath() : themePathValue; + for (const auto& name: names) { + candidates.push_back(QDir(baseDir).absoluteFilePath(name)); + candidates.push_back(QDir(baseDir).absoluteFilePath(QFileInfo(name).fileName())); + } + + auto dir = QDir(baseDir); + for (const auto& name: names) { + auto exact = QFileInfo(name).fileName(); + auto stem = iconNameWithoutExtension(exact); + auto entries = dir.entryInfoList( + QStringList {exact, stem + ".*"}, + QDir::Files | QDir::Readable, + QDir::Name + ); + for (const auto& entry: entries) { + candidates.push_back(entry.absoluteFilePath()); + } + } + } + + candidates.removeDuplicates(); + return candidates; +} + +QIcon iconFromFileCandidates(const QString& iconName, const QString& themePath) { + for (const auto& file: directFileCandidates(iconName, themePath)) { + if (!QFileInfo(file).isFile()) continue; + auto icon = QIcon(file); + if (!icon.isNull()) return icon; + } + + return QIcon(); +} + +QPixmap pixmapFromThemedIcon(const QString& iconName, const QString& themePath, const QSize& size) { + IconThemeSearchPathScope searchPathScope(themePath); + return QIcon::fromTheme(iconName).pixmap(size.width(), size.height()); +} + +QPixmap +pixmapFromFileCandidates(const QString& iconName, const QString& themePath, const QSize& size) { + auto icon = iconFromFileCandidates(iconName, themePath); + if (icon.isNull()) return QPixmap(); + + return icon.pixmap(size.width(), size.height()); +} + +IconRequest parseIconRequest(const QString& id) { + auto request = IconRequest(); + auto splitIdx = id.indexOf('?'); + + if (splitIdx == -1) { + request.iconName = id; + return request; + } + + request.iconName = id.sliced(0, splitIdx); + auto queryString = id.sliced(splitIdx + 1); + + // Older request strings used "?path=... ?fallback=..." instead of '&'. + auto malformedFallbackIdx = queryString.indexOf("?fallback="); + if (malformedFallbackIdx != -1) { + auto fallback = queryString.sliced(malformedFallbackIdx + 10); + queryString = queryString.sliced(0, malformedFallbackIdx) + "&fallback=" + fallback; + } + + auto query = QUrlQuery(queryString); + request.themePath = query.queryItemValue("path", QUrl::FullyDecoded); + request.fallbackName = query.queryItemValue("fallback", QUrl::FullyDecoded); + return request; +} + +} // namespace + +QPixmap +IconImageProvider::requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) { + auto request = parseIconRequest(id); + auto pixmap = IconImageProvider::requestPixmapForIcon( + request.iconName, + request.themePath, + request.fallbackName, + requestedSize + ); if (pixmap.isNull()) { + auto targetSize = normalizedRequestedSize(requestedSize); qWarning() << "Could not load icon" << id << "at size" << targetSize << "from request"; pixmap = IconImageProvider::missingPixmap(targetSize); } @@ -65,19 +250,49 @@ QPixmap IconImageProvider::missingPixmap(const QSize& size) { return pixmap; } +QPixmap IconImageProvider::requestPixmapForIcon( + const QString& icon, + const QString& path, + const QString& fallback, + const QSize& requestedSize +) { + auto targetSize = normalizedRequestedSize(requestedSize); + + for (const auto& candidate: iconNameCandidates(icon)) { + auto themedPixmap = pixmapFromThemedIcon(candidate, path, targetSize); + if (!themedPixmap.isNull()) return themedPixmap; + } + + for (const auto& candidate: iconNameCandidates(fallback)) { + auto fallbackPixmap = pixmapFromThemedIcon(candidate, path, targetSize); + if (!fallbackPixmap.isNull()) return fallbackPixmap; + } + + auto filePixmap = pixmapFromFileCandidates(icon, path, targetSize); + if (!filePixmap.isNull()) return filePixmap; + + return pixmapFromFileCandidates(fallback, path, targetSize); +} + QString IconImageProvider::requestString( const QString& icon, const QString& path, const QString& fallback ) { auto req = "image://icon/" + icon; + auto query = QUrlQuery(); if (!path.isEmpty()) { - req += "?path=" + path; + query.addQueryItem("path", path); } if (!fallback.isEmpty()) { - req += "?fallback=" + fallback; + query.addQueryItem("fallback", fallback); + } + + auto queryString = query.toString(QUrl::FullyEncoded); + if (!queryString.isEmpty()) { + req += "?" + queryString; } return req; diff --git a/src/core/iconimageprovider.hpp b/src/core/iconimageprovider.hpp index 57e26049..2a1f94de 100644 --- a/src/core/iconimageprovider.hpp +++ b/src/core/iconimageprovider.hpp @@ -10,6 +10,12 @@ class IconImageProvider: public QQuickImageProvider { QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) override; static QPixmap missingPixmap(const QSize& size); + static QPixmap requestPixmapForIcon( + const QString& icon, + const QString& path, + const QString& fallback, + const QSize& requestedSize + ); static QString requestString( const QString& icon, diff --git a/src/services/status_notifier/item.cpp b/src/services/status_notifier/item.cpp index 17404e19..b43523a5 100644 --- a/src/services/status_notifier/item.cpp +++ b/src/services/status_notifier/item.cpp @@ -1,4 +1,6 @@ #include "item.hpp" +#include +#include #include #include @@ -37,6 +39,60 @@ using namespace qs::menu::platform; QS_LOGGING_CATEGORY(logStatusNotifierItem, "quickshell.service.sni.item", QtWarningMsg); namespace qs::service::sni { +namespace { + +const DBusSniIconPixmap* closestPixmap(const QSize& size, const DBusSniIconPixmapList& pixmaps) { + const DBusSniIconPixmap* ret = nullptr; + + for (const auto& pixmap: pixmaps) { + if (pixmap.width <= 0 || pixmap.height <= 0 || pixmap.data.isEmpty()) continue; + + if (ret == nullptr) { + ret = &pixmap; + continue; + } + + auto existingAdequate = ret->width >= size.width() && ret->height >= size.height(); + auto pixmapAdequate = pixmap.width >= size.width() && pixmap.height >= size.height(); + auto existingArea = qint64(ret->width) * ret->height; + auto pixmapArea = qint64(pixmap.width) * pixmap.height; + + if ((pixmapAdequate && !existingAdequate) + || (pixmapAdequate == existingAdequate + && ((pixmapAdequate && pixmapArea < existingArea) + || (!pixmapAdequate && pixmapArea > existingArea)))) + { + ret = &pixmap; + } + } + + return ret; +} + +QPixmap pixmapFromSniPixmaps(const QSize& size, const DBusSniIconPixmapList& pixmaps) { + const auto* icon = closestPixmap(size, pixmaps); + if (icon == nullptr) return QPixmap(); + + const auto image = + icon->createImage().scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + return QPixmap::fromImage(image); +} + +QPixmap pixmapFromIconName(const QString& name, const QString& themePath, const QSize& size) { + if (name.isEmpty()) return QPixmap(); + + return IconImageProvider::requestPixmapForIcon(name, themePath, QString(), size); +} + +QPixmap firstValidPixmap(std::initializer_list pixmaps) { + for (const auto& pixmap: pixmaps) { + if (!pixmap.isNull()) return pixmap; + } + + return QPixmap(); +} + +} // namespace StatusNotifierItem::StatusNotifierItem(const QString& address, QObject* parent) : QObject(parent) @@ -132,80 +188,36 @@ bool StatusNotifierItem::isReady() const { return this->mReady; } QPixmap StatusNotifierItem::createPixmap(const QSize& size) const { auto needsAttention = this->bStatus.value() == Status::NeedsAttention; - - auto closestPixmap = [](const QSize& size, const DBusSniIconPixmapList& pixmaps) { - const DBusSniIconPixmap* ret = nullptr; - - for (const auto& pixmap: pixmaps) { - if (ret == nullptr) { - ret = &pixmap; - continue; - } - - auto existingAdequate = ret->width >= size.width() && ret->height >= size.height(); - auto newAdequite = pixmap.width >= size.width() && pixmap.height >= size.height(); - auto newSmaller = pixmap.width < ret->width || pixmap.height < ret->height; - - if ((existingAdequate && newAdequite && newSmaller) || (!existingAdequate && !newSmaller)) { - ret = &pixmap; - } - } - - return ret; - }; + auto themePath = this->bIconThemePath.value(); + auto targetSize = size.isValid() ? size : QSize(100, 100); + if (targetSize.width() == 0 || targetSize.height() == 0) targetSize = QSize(2, 2); QPixmap pixmap; if (needsAttention) { - if (!this->bAttentionIconName.value().isEmpty()) { - auto icon = QIcon::fromTheme(this->bAttentionIconName.value()); - pixmap = icon.pixmap(size.width(), size.height()); - } else { - const auto* icon = closestPixmap(size, this->bAttentionIconPixmaps.value()); - - if (icon == nullptr) { - icon = closestPixmap(size, this->bIconPixmaps.value()); - } - - if (icon != nullptr) { - const auto image = - icon->createImage().scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - - pixmap = QPixmap::fromImage(image); - } - } + pixmap = firstValidPixmap({ + pixmapFromIconName(this->bAttentionIconName.value(), themePath, targetSize), + pixmapFromSniPixmaps(targetSize, this->bAttentionIconPixmaps.value()), + pixmapFromIconName(this->bIconName.value(), themePath, targetSize), + pixmapFromSniPixmaps(targetSize, this->bIconPixmaps.value()), + }); } else { - if (!this->bIconName.value().isEmpty()) { - auto icon = QIcon::fromTheme(this->bIconName.value()); - pixmap = icon.pixmap(size.width(), size.height()); - } else { - const auto* icon = closestPixmap(size, this->bIconPixmaps.value()); - - if (icon != nullptr) { - const auto image = - icon->createImage().scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - - pixmap = QPixmap::fromImage(image); - } - } + pixmap = firstValidPixmap({ + pixmapFromIconName(this->bIconName.value(), themePath, targetSize), + pixmapFromSniPixmaps(targetSize, this->bIconPixmaps.value()), + }); QPixmap overlay; - if (!this->bOverlayIconName.value().isEmpty()) { - auto icon = QIcon::fromTheme(this->bOverlayIconName.value()); - overlay = icon.pixmap(pixmap.width(), pixmap.height()); - } else { - const auto* icon = closestPixmap(pixmap.size(), this->bOverlayIconPixmaps.value()); - - if (icon != nullptr) { - const auto image = - icon->createImage().scaled(size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + auto overlaySize = + QSize(std::max(1, targetSize.width() * 2 / 5), std::max(1, targetSize.height() * 2 / 5)); - overlay = QPixmap::fromImage(image); - } - } + overlay = firstValidPixmap({ + pixmapFromIconName(this->bOverlayIconName.value(), themePath, overlaySize), + pixmapFromSniPixmaps(overlaySize, this->bOverlayIconPixmaps.value()), + }); - if (!overlay.isNull()) { + if (!pixmap.isNull() && !overlay.isNull()) { auto painter = QPainter(&pixmap); - painter.drawPixmap(QRect(0, 0, pixmap.width(), pixmap.height()), overlay); + painter.drawPixmap(QRect(0, 0, overlay.width(), overlay.height()), overlay); painter.end(); } }