diff --git a/CMakeLists.txt b/CMakeLists.txt index 21aacc7b14..02cad62346 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -63,6 +63,7 @@ find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS WaylandClient Widgets Sensors + Svg ) find_package(Qt6Test ${QT_MIN_VERSION} CONFIG QUIET) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 7ecd662b92..3419d1645d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -206,7 +206,9 @@ target_sources(kwin PRIVATE tiles/tilemanager.cpp touch_input.cpp useractions.cpp + utils/svgcursorreader.cpp utils/version.cpp + utils/xcursorreader.cpp virtualdesktops.cpp virtualdesktopsdbustypes.cpp virtualkeyboard_dbus.cpp @@ -234,8 +236,9 @@ target_link_libraries(kwin PRIVATE Qt::Concurrent - Qt::Sensors Qt::GuiPrivate + Qt::Sensors + Qt::Svg KF6::ColorScheme KF6::ConfigGui diff --git a/src/utils/svgcursorreader.cpp b/src/utils/svgcursorreader.cpp new file mode 100644 index 0000000000..330977bb8e --- /dev/null +++ b/src/utils/svgcursorreader.cpp @@ -0,0 +1,143 @@ +/* + SPDX-FileCopyrightText: 2024 Vlad Zahorodnii + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "utils/svgcursorreader.h" +#include "utils/common.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace KWin +{ + +struct SvgCursorMetaDataEntry +{ + static std::optional parse(const QJsonObject &object); + + QString fileName; + QPointF hotspot; + std::chrono::milliseconds delay; +}; + +std::optional SvgCursorMetaDataEntry::parse(const QJsonObject &object) +{ + const QJsonValue fileName = object.value(QLatin1String("filename")); + if (!fileName.isString()) { + return std::nullopt; + } + + const QJsonValue hotspotX = object.value(QLatin1String("hotspot_x")); + if (!hotspotX.isDouble()) { + return std::nullopt; + } + + const QJsonValue hotspotY = object.value(QLatin1String("hotspot_y")); + if (!hotspotY.isDouble()) { + return std::nullopt; + } + + const QJsonValue frametime = object.value(QLatin1String("frametime")); + + return SvgCursorMetaDataEntry{ + .fileName = fileName.toString(), + .hotspot = QPointF(hotspotX.toDouble(), hotspotY.toDouble()), + .delay = std::chrono::milliseconds(frametime.toInt()), + }; +} + +struct SvgCursorMetaData +{ + static std::optional parse(const QString &filePath); + + QList entries; +}; + +std::optional SvgCursorMetaData::parse(const QString &filePath) +{ + QFile metaDataFile(filePath); + if (!metaDataFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + return std::nullopt; + } + + QJsonParseError jsonParseError; + const QJsonDocument document = QJsonDocument::fromJson(metaDataFile.readAll(), &jsonParseError); + if (jsonParseError.error) { + return std::nullopt; + } + + QList entries; + if (document.isObject()) { + if (const auto entry = SvgCursorMetaDataEntry::parse(document.object())) { + entries.append(entry.value()); + } else { + return std::nullopt; + } + } else if (document.isArray()) { + const QJsonArray array = document.array(); + for (int i = 0; i < array.size(); ++i) { + const QJsonValue element = array.at(i); + if (!element.isObject()) { + return std::nullopt; + } + if (const auto entry = SvgCursorMetaDataEntry::parse(element.toObject())) { + entries.append(entry.value()); + } else { + return std::nullopt; + } + } + } else { + return std::nullopt; + } + + return SvgCursorMetaData{ + .entries = entries, + }; +} + +QList SvgCursorReader::load(const QString &containerPath, int desiredSize, qreal devicePixelRatio) +{ + const QDir containerDir(containerPath); + + const QString metadataFilePath = containerDir.filePath(QStringLiteral("metadata.json")); + const auto metadata = SvgCursorMetaData::parse(metadataFilePath); + if (!metadata.has_value()) { + qCWarning(KWIN_CORE) << "Failed to parse" << metadataFilePath; + return {}; + } + + const qreal scale = desiredSize / 24.0; + + QList sprites; + for (const SvgCursorMetaDataEntry &entry : metadata->entries) { + const QString filePath = containerDir.filePath(entry.fileName); + + QSvgRenderer renderer(filePath); + if (!renderer.isValid()) { + qCWarning(KWIN_CORE) << "Failed to render" << filePath; + return {}; + } + + const QRect bounds(QPoint(0, 0), renderer.defaultSize() * scale); + QImage image(bounds.size() * devicePixelRatio, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + image.setDevicePixelRatio(devicePixelRatio); + + QPainter painter(&image); + renderer.render(&painter, bounds); + painter.end(); + + sprites.append(KXcursorSprite(image, (entry.hotspot * scale).toPoint(), entry.delay)); + } + + return sprites; +} + +} // namespace KWin diff --git a/src/utils/svgcursorreader.h b/src/utils/svgcursorreader.h new file mode 100644 index 0000000000..06edbde8a4 --- /dev/null +++ b/src/utils/svgcursorreader.h @@ -0,0 +1,20 @@ +/* + SPDX-FileCopyrightText: 2024 Vlad Zahorodnii + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#pragma once + +#include "utils/xcursortheme.h" + +namespace KWin +{ + +class SvgCursorReader +{ +public: + static QList load(const QString &filePath, int desiredSize, qreal devicePixelRatio); +}; + +} // namespace KWin diff --git a/src/utils/xcursorreader.cpp b/src/utils/xcursorreader.cpp new file mode 100644 index 0000000000..4bba84cb07 --- /dev/null +++ b/src/utils/xcursorreader.cpp @@ -0,0 +1,61 @@ +/* + SPDX-FileCopyrightText: 2024 Vlad Zahorodnii + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "utils/xcursorreader.h" +#include "3rdparty/xcursor.h" + +#include + +namespace KWin +{ + +QList XCursorReader::load(const QString &filePath, int desiredSize, qreal devicePixelRatio) +{ + QFile file(filePath); + if (!file.open(QFile::ReadOnly)) { + return {}; + } + + XcursorFile reader { + .closure = &file, + .read = [](XcursorFile *file, uint8_t *buffer, int len) -> int { + QFile *device = static_cast(file->closure); + return device->read(reinterpret_cast(buffer), len); + }, + .skip = [](XcursorFile *file, long offset) -> XcursorBool { + QFile *device = static_cast(file->closure); + return device->skip(offset) != -1; + }, + .seek = [](XcursorFile *file, long offset) -> XcursorBool { + QFile *device = static_cast(file->closure); + return device->seek(offset); + }, + }; + + XcursorImages *images = XcursorXcFileLoadImages(&reader, desiredSize * devicePixelRatio); + if (!images) { + return {}; + } + + QList sprites; + for (int i = 0; i < images->nimage; ++i) { + const XcursorImage *nativeCursorImage = images->images[i]; + const qreal scale = std::max(qreal(1), qreal(nativeCursorImage->size) / desiredSize); + const QPoint hotspot(nativeCursorImage->xhot, nativeCursorImage->yhot); + const std::chrono::milliseconds delay(nativeCursorImage->delay); + + QImage data(nativeCursorImage->width, nativeCursorImage->height, QImage::Format_ARGB32_Premultiplied); + data.setDevicePixelRatio(scale); + memcpy(data.bits(), nativeCursorImage->pixels, data.sizeInBytes()); + + sprites.append(KXcursorSprite(data, hotspot / scale, delay)); + } + + XcursorImagesDestroy(images); + return sprites; +} + +} // namespace KWin diff --git a/src/utils/xcursorreader.h b/src/utils/xcursorreader.h new file mode 100644 index 0000000000..20b34cb899 --- /dev/null +++ b/src/utils/xcursorreader.h @@ -0,0 +1,20 @@ +/* + SPDX-FileCopyrightText: 2024 Vlad Zahorodnii + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#pragma once + +#include "utils/xcursortheme.h" + +namespace KWin +{ + +class XCursorReader +{ +public: + static QList load(const QString &filePath, int desiredSize, qreal devicePixelRatio); +}; + +} // namespace KWin diff --git a/src/utils/xcursortheme.cpp b/src/utils/xcursortheme.cpp index 93754d0e51..acdac2dacd 100644 --- a/src/utils/xcursortheme.cpp +++ b/src/utils/xcursortheme.cpp @@ -4,15 +4,15 @@ SPDX-License-Identifier: GPL-2.0-or-later */ -#include "xcursortheme.h" -#include "3rdparty/xcursor.h" +#include "utils/xcursortheme.h" +#include "utils/svgcursorreader.h" +#include "utils/xcursorreader.h" #include #include #include #include -#include #include #include #include @@ -29,14 +29,27 @@ public: std::chrono::milliseconds delay; }; +struct KXcursorThemeXEntryInfo +{ + QString path; +}; + +struct KXcursorThemeSvgEntryInfo +{ + QString path; +}; + +using KXcursorThemeEntryInfo = std::variant; + class KXcursorThemeEntry { public: - explicit KXcursorThemeEntry(const QString &filePath); + explicit KXcursorThemeEntry(const KXcursorThemeEntryInfo &info); void load(int size, qreal devicePixelRatio); - QString filePath; + KXcursorThemeEntryInfo info; QList sprites; }; @@ -47,7 +60,8 @@ public: KXcursorThemePrivate(const QString &themeName, int size, qreal devicePixelRatio); void discover(const QStringList &searchPaths); - void discoverCursors(const QString &packagePath); + void discoverXCursors(const QString &packagePath); + void discoverSvgCursors(const QString &packagePath); QString name; int size = 0; @@ -111,68 +125,56 @@ KXcursorThemePrivate::KXcursorThemePrivate(const QString &themeName, int size, q { } -static QList loadCursor(const QString &filePath, int desiredSize, qreal devicePixelRatio) +KXcursorThemeEntry::KXcursorThemeEntry(const KXcursorThemeEntryInfo &info) + : info(info) { - QFile file(filePath); - if (!file.open(QFile::ReadOnly)) { - return {}; - } +} - XcursorFile reader { - .closure = &file, - .read = [](XcursorFile *file, uint8_t *buffer, int len) -> int { - QFile *device = static_cast(file->closure); - return device->read(reinterpret_cast(buffer), len); - }, - .skip = [](XcursorFile *file, long offset) -> XcursorBool { - QFile *device = static_cast(file->closure); - return device->skip(offset) != -1; - }, - .seek = [](XcursorFile *file, long offset) -> XcursorBool { - QFile *device = static_cast(file->closure); - return device->seek(offset); - }, - }; - - XcursorImages *images = XcursorXcFileLoadImages(&reader, desiredSize * devicePixelRatio); - if (!images) { - return {}; +void KXcursorThemeEntry::load(int size, qreal devicePixelRatio) +{ + if (!sprites.isEmpty()) { + return; } - QList sprites; - for (int i = 0; i < images->nimage; ++i) { - const XcursorImage *nativeCursorImage = images->images[i]; - const qreal scale = std::max(qreal(1), qreal(nativeCursorImage->size) / desiredSize); - const QPoint hotspot(nativeCursorImage->xhot, nativeCursorImage->yhot); - const std::chrono::milliseconds delay(nativeCursorImage->delay); - - QImage data(nativeCursorImage->width, nativeCursorImage->height, QImage::Format_ARGB32_Premultiplied); - data.setDevicePixelRatio(scale); - memcpy(data.bits(), nativeCursorImage->pixels, data.sizeInBytes()); - - sprites.append(KXcursorSprite(data, hotspot / scale, delay)); + if (const auto raster = std::get_if(&info)) { + sprites = XCursorReader::load(raster->path, size, devicePixelRatio); + } else if (const auto svg = std::get_if(&info)) { + sprites = SvgCursorReader::load(svg->path, size, devicePixelRatio); } - - XcursorImagesDestroy(images); - return sprites; } -KXcursorThemeEntry::KXcursorThemeEntry(const QString &filePath) - : filePath(filePath) +void KXcursorThemePrivate::discoverXCursors(const QString &packagePath) { -} + const QDir dir(packagePath); + QFileInfoList entries = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot); + std::partition(entries.begin(), entries.end(), [](const QFileInfo &fileInfo) { + return !fileInfo.isSymLink(); + }); -void KXcursorThemeEntry::load(int size, qreal devicePixelRatio) -{ - if (sprites.isEmpty()) { - sprites = loadCursor(filePath, size, devicePixelRatio); + for (const QFileInfo &entry : std::as_const(entries)) { + const QByteArray shape = QFile::encodeName(entry.fileName()); + if (registry.contains(shape)) { + continue; + } + if (entry.isSymLink()) { + const QFileInfo symLinkInfo(entry.symLinkTarget()); + if (symLinkInfo.absolutePath() == entry.absolutePath()) { + if (auto alias = registry.value(QFile::encodeName(symLinkInfo.fileName()))) { + registry.insert(shape, alias); + continue; + } + } + } + registry.insert(shape, std::make_shared(KXcursorThemeXEntryInfo{ + .path = entry.absoluteFilePath(), + })); } } -void KXcursorThemePrivate::discoverCursors(const QString &packagePath) +void KXcursorThemePrivate::discoverSvgCursors(const QString &packagePath) { const QDir dir(packagePath); - QFileInfoList entries = dir.entryInfoList(QDir::Files | QDir::NoDotAndDotDot); + QFileInfoList entries = dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); std::partition(entries.begin(), entries.end(), [](const QFileInfo &fileInfo) { return !fileInfo.isSymLink(); }); @@ -191,7 +193,9 @@ void KXcursorThemePrivate::discoverCursors(const QString &packagePath) } } } - registry.insert(shape, std::make_shared(entry.absoluteFilePath())); + registry.insert(shape, std::make_shared(KXcursorThemeSvgEntryInfo{ + .path = entry.absoluteFilePath(), + })); } } @@ -240,7 +244,11 @@ void KXcursorThemePrivate::discover(const QStringList &searchPaths) if (!dir.exists()) { continue; } - discoverCursors(dir.filePath(QStringLiteral("cursors"))); + if (const QDir package = dir.filePath(QLatin1String("cursors_scalable")); package.exists()) { + discoverSvgCursors(package.path()); + } else if (const QDir package = dir.filePath(QLatin1String("cursors")); package.exists()) { + discoverXCursors(package.path()); + } if (inherits.isEmpty()) { const KConfig config(dir.filePath(QStringLiteral("index.theme")), KConfig::NoGlobals); inherits << KConfigGroup(&config, QStringLiteral("Icon Theme")).readEntry("Inherits", QStringList());