diff --git a/Assets/settings-default.json b/Assets/settings-default.json index c8cf85a8..6d0a31f4 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -102,20 +102,20 @@ "calendar": { "cards": [ { - "id": "banner-card", - "enabled": true + "enabled": true, + "id": "banner-card" }, { - "id": "calendar-card", - "enabled": true + "enabled": true, + "id": "calendar-card" }, { - "id": "timer-card", - "enabled": true + "enabled": true, + "id": "timer-card" }, { - "id": "weather-card", - "enabled": true + "enabled": true, + "id": "weather-card" } ] }, diff --git a/Modules/Panels/Settings/Tabs/AboutTab.qml b/Modules/Panels/Settings/Tabs/AboutTab.qml index 0eeb2162..4834e00b 100644 --- a/Modules/Panels/Settings/Tabs/AboutTab.qml +++ b/Modules/Panels/Settings/Tabs/AboutTab.qml @@ -194,11 +194,20 @@ ColumnLayout { Item { anchors.fill: parent - // Simple image + // Simple circular image (pre-rendered, no shaders) Image { anchors.fill: parent - source: root.contributors[index].avatar_url || "" - fillMode: Image.PreserveAspectCrop + source: { + // Try cached circular version first + var username = root.contributors[index].login; + var cached = GitHubService.cachedCircularAvatars[username]; + if (cached) + return cached; + + // Fall back to original avatar URL + return root.contributors[index].avatar_url || ""; + } + fillMode: Image.PreserveAspectFit // Fit since image is already circular with transparency mipmap: true smooth: true asynchronous: true @@ -245,7 +254,7 @@ ColumnLayout { NIcon { icon: "git-commit" pointSize: Style.fontSizeXS - color: Color.mOnSurfaceVariant + color: contributorArea.containsMouse ? Color.mOnHover : Color.mOnSurfaceVariant } NText { diff --git a/Modules/Renderer/CircularAvatarRenderer.qml b/Modules/Renderer/CircularAvatarRenderer.qml new file mode 100644 index 00000000..5ab01355 --- /dev/null +++ b/Modules/Renderer/CircularAvatarRenderer.qml @@ -0,0 +1,96 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import Quickshell.Widgets +import qs.Commons + +/** +* CircularAvatarRenderer - Hidden window for rendering circular avatars +* +* This component safely uses ClippingRectangle in a separate hidden window to +* pre-render circular avatar images. The rendered images are saved as PNGs +* with transparent backgrounds, which can then be used in the UI without +* any shader effects (avoiding Qt 6.8 crashes). +* +* Usage: +* var renderer = component.createObject(null, { +* imagePath: "file:///path/to/avatar.png", +* outputPath: "/path/to/output_circular.png", +* username: "ItsLemmy" +* }); +* renderer.renderComplete.connect(function(success) { +* if (success) console.log("Rendered!"); +* renderer.destroy(); +* }); +*/ +PanelWindow { + id: root + + // Input properties + property string imagePath: "" + property string outputPath: "" + property string username: "" + + // Hidden window configuration + implicitWidth: 256 + implicitHeight: 256 + visible: true // Must be visible for grabToImage to work + color: "transparent" + + // Wayland configuration - hide it from user view + WlrLayershell.layer: WlrLayer.Bottom // Render below everything + WlrLayershell.exclusionMode: ExclusionMode.Ignore + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // Position it off-screen or behind everything + anchors { + left: true + top: true + } + margins { + left: -512// Off-screen to the left + top: -512 // Off-screen to the top + } + + signal renderComplete(bool success) + + // Use ClippingRectangle safely (not in GridView, not visible) + ClippingRectangle { + id: clipper + anchors.fill: parent + radius: width * 0.5 // Make it circular + color: "transparent" + + Image { + id: sourceImage + anchors.fill: parent + source: root.imagePath + fillMode: Image.PreserveAspectCrop + smooth: true + mipmap: true + asynchronous: true + + onStatusChanged: { + if (status === Image.Ready) { + // Image loaded successfully, capture it on next frame + Qt.callLater(captureCircular); + } else if (status === Image.Error) { + Logger.e("CircularAvatarRenderer", "Failed to load image for", root.username); + root.renderComplete(false); + } + } + } + } + + function captureCircular() { + clipper.grabToImage(function (result) { + if (result.saveToFile(root.outputPath)) { + Logger.d("CircularAvatarRenderer", "Saved circular avatar for", root.username, "to", root.outputPath); + root.renderComplete(true); + } else { + Logger.e("CircularAvatarRenderer", "Failed to save circular avatar for", root.username); + root.renderComplete(false); + } + }, Qt.size(root.width, root.height)); + } +} diff --git a/Services/Noctalia/GitHubService.qml b/Services/Noctalia/GitHubService.qml index 77343272..963173cc 100644 --- a/Services/Noctalia/GitHubService.qml +++ b/Services/Noctalia/GitHubService.qml @@ -18,6 +18,17 @@ Singleton { property string latestVersion: I18n.tr("system.unknown-version") property var contributors: [] + // Avatar caching properties + property var cachedCircularAvatars: ({}) // username → file:// path + property var cacheMetadata: ({}) // Loaded from metadata.json + property var avatarQueue: [] + property bool isProcessingAvatars: false + property bool metadataLoaded: false + property bool avatarsCached: false // Track if we've already processed avatars + + readonly property string avatarCacheDir: Settings.cacheDirImages + "contributors/" + readonly property string metadataPath: avatarCacheDir + "metadata.json" + FileView { id: githubDataFileView path: githubDataFile @@ -25,6 +36,7 @@ Singleton { onFileChanged: reload() onAdapterUpdated: writeAdapter() Component.onCompleted: { + loadCacheMetadata(); reload(); } onLoaded: { @@ -48,6 +60,12 @@ Singleton { } } + // -------------------------------- + function init() { + Logger.i("GitHub", "Service started"); + loadFromCache(); + } + // -------------------------------- function loadFromCache() { const now = Time.timestamp; @@ -118,6 +136,284 @@ Singleton { fetchFromGitHub(); } + // -------------------------------- + // Avatar Caching Functions + // -------------------------------- + + function loadCacheMetadata() { + var loadProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["cat", "${metadataPath}"] + } + `, root, "LoadMetadata"); + + loadProcess.stdout = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + StdioCollector {} + `, loadProcess, "StdoutCollector"); + + loadProcess.stdout.onStreamFinished.connect(function () { + try { + var text = loadProcess.stdout.text; + if (text && text.trim()) { + cacheMetadata = JSON.parse(text); + Logger.d("GitHubService", "Loaded cache metadata:", Object.keys(cacheMetadata).length, "entries"); + + // Populate cachedCircularAvatars from metadata + for (var username in cacheMetadata) { + var entry = cacheMetadata[username]; + cachedCircularAvatars[username] = "file://" + entry.cached_path; + } + + metadataLoaded = true; + Logger.d("GitHubService", "Cache metadata loaded successfully"); + } else { + Logger.d("GitHubService", "No existing cache metadata found (empty response)"); + cacheMetadata = {}; + metadataLoaded = true; + } + } catch (e) { + Logger.w("GitHubService", "Failed to parse cache metadata:", e); + cacheMetadata = {}; + metadataLoaded = true; + } + loadProcess.destroy(); + }); + + loadProcess.exited.connect(function (exitCode) { + if (exitCode !== 0) { + // File doesn't exist, initialize empty + cacheMetadata = {}; + metadataLoaded = true; + Logger.d("GitHubService", "Initializing empty cache metadata"); + } + }); + + loadProcess.running = true; + } + + function saveCacheMetadata() { + Quickshell.execDetached(["mkdir", "-p", avatarCacheDir]); + + var jsonContent = JSON.stringify(cacheMetadata, null, 2); + + // Use printf with base64 encoding to safely handle special characters + var base64Content = Qt.btoa(jsonContent); // Base64 encode + + var saveProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "echo '${base64Content}' | base64 -d > '${metadataPath}'"] + } + `, root, "SaveMetadata_" + Date.now()); + + saveProcess.exited.connect(function (exitCode) { + if (exitCode === 0) { + Logger.d("GitHubService", "Saved cache metadata"); + } else { + Logger.e("GitHubService", "Failed to save cache metadata, exit code:", exitCode); + } + saveProcess.destroy(); + }); + + saveProcess.running = true; + } + + function cacheTopContributorAvatars() { + if (contributors.length === 0) + return; + + // Mark that we've processed avatars for this contributor set + avatarsCached = true; + + Quickshell.execDetached(["mkdir", "-p", avatarCacheDir]); + + // Build queue of avatars that need processing + avatarQueue = []; + var currentTop20 = {}; + + for (var i = 0; i < Math.min(contributors.length, 20); i++) { + var contributor = contributors[i]; + var username = contributor.login; + var avatarUrl = contributor.avatar_url; + var circularPath = avatarCacheDir + username + "_circular.png"; + + currentTop20[username] = true; + + // Check if we need to process this avatar + var needsProcessing = false; + var reason = ""; + + if (!cacheMetadata[username]) { + // New user in top 20 + needsProcessing = true; + reason = "new user"; + } else if (cacheMetadata[username].avatar_url !== avatarUrl) { + // Avatar URL changed (user updated their GitHub avatar) + needsProcessing = true; + reason = "avatar URL changed"; + } else { + // Already cached - add to map + cachedCircularAvatars[username] = "file://" + circularPath; + } + + if (needsProcessing) { + Logger.d("GitHubService", "Queueing avatar for", username, "-", reason); + avatarQueue.push({ + username: username, + avatarUrl: avatarUrl, + circularPath: circularPath + }); + } + } + + // Cleanup: Remove metadata for users no longer in top 20 + var removedUsers = []; + for (var cachedUsername in cacheMetadata) { + if (!currentTop20[cachedUsername]) { + removedUsers.push(cachedUsername); + + // Delete cached circular file + var pathToDelete = cacheMetadata[cachedUsername].cached_path; + Quickshell.execDetached(["rm", "-f", pathToDelete]); + + delete cacheMetadata[cachedUsername]; + delete cachedCircularAvatars[cachedUsername]; + } + } + + if (removedUsers.length > 0) { + Logger.d("GitHubService", "Cleaned up avatars for users no longer in top 20:", removedUsers.join(", ")); + saveCacheMetadata(); + } + + // Start processing queue + if (avatarQueue.length > 0) { + Logger.i("GitHubService", "Processing", avatarQueue.length, "avatar(s)"); + processNextAvatar(); + } else { + Logger.d("GitHubService", "All avatars already cached"); + cachedCircularAvatarsChanged(); // Notify AboutTab + } + } + + function processNextAvatar() { + if (avatarQueue.length === 0 || isProcessingAvatars) + return; + + isProcessingAvatars = true; + var item = avatarQueue.shift(); + + Logger.d("GitHubService", "Downloading avatar for", item.username); + + // Download original avatar + var tempPath = avatarCacheDir + item.username + "_temp.png"; + downloadAvatar(item.avatarUrl, tempPath, function (success) { + if (success) { + // Render circular version + renderCircularAvatar(tempPath, item.circularPath, item.username, item.avatarUrl); + } else { + Logger.e("GitHubService", "Failed to download avatar for", item.username); + isProcessingAvatars = false; + processNextAvatar(); + } + }); + } + + function downloadAvatar(url, destPath, callback) { + var downloadCmd = `curl -L -s -o '${destPath}' '${url}' || wget -q -O '${destPath}' '${url}'`; + + var downloadProcess = Qt.createQmlObject(` + import QtQuick + import Quickshell.Io + Process { + command: ["sh", "-c", "${downloadCmd}"] + } + `, root, "Download_" + Date.now()); + + downloadProcess.exited.connect(function (exitCode) { + callback(exitCode === 0); + downloadProcess.destroy(); + }); + + downloadProcess.running = true; + } + + function renderCircularAvatar(inputPath, outputPath, username, avatarUrl) { + var rendererComponent = Qt.createComponent(Quickshell.shellDir + "/Modules/Renderer/CircularAvatarRenderer.qml"); + if (rendererComponent.status === Component.Ready) { + var renderer = rendererComponent.createObject(root, { + imagePath: "file://" + inputPath, + outputPath: outputPath, + username: username + }); + + renderer.renderComplete.connect(function (success) { + if (success) { + // Update cache metadata + cacheMetadata[username] = { + avatar_url: avatarUrl, + cached_path: outputPath, + cached_at: Date.now() + }; + + cachedCircularAvatars[username] = "file://" + outputPath; + cachedCircularAvatarsChanged(); + + saveCacheMetadata(); + + Logger.d("GitHubService", "Cached circular avatar for", username); + } else { + Logger.e("GitHubService", "Failed to render circular avatar for", username); + } + + renderer.destroy(); + + // Clean up temp file + Quickshell.execDetached(["rm", "-f", inputPath]); + + // Process next in queue + isProcessingAvatars = false; + processNextAvatar(); + }); + } else { + Logger.e("GitHubService", "Failed to create CircularAvatarRenderer"); + isProcessingAvatars = false; + processNextAvatar(); + } + } + + // -------------------------------- + // Hook into contributors change - only process once + onContributorsChanged: { + if (contributors.length > 0 && !avatarsCached) { + // Wait for metadata to load before processing + if (metadataLoaded) { + Qt.callLater(cacheTopContributorAvatars); + } else { + // Metadata not loaded yet, wait for it + metadataLoadedWatcher.start(); + } + } + } + + // Wait for metadata to be loaded before caching avatars + Timer { + id: metadataLoadedWatcher + interval: 100 + repeat: true + onTriggered: { + if (metadataLoaded && contributors.length > 0 && !avatarsCached) { + stop(); + Qt.callLater(cacheTopContributorAvatars); + } + } + } + Process { id: versionProcess diff --git a/shell.qml b/shell.qml index 8f750704..00fd3f15 100644 --- a/shell.qml +++ b/shell.qml @@ -97,6 +97,7 @@ ShellRoot { PowerProfileService.init(); HostService.init(); FontService.init(); + GitHubService.init(); UpdateService.init(); UpdateService.showLatestChangelog();