From 54fa04f303a563dfd5698ce85a719c2828536d03 Mon Sep 17 00:00:00 2001 From: ItsLemmy Date: Sat, 11 Oct 2025 10:29:28 -0400 Subject: [PATCH] Compositor: proper monitor scaling detection and display in settings + fixes blurry wallpapers on compositor scaled monitors. --- Assets/Translations/en.json | 2 +- Modules/Background/Background.qml | 99 +++++++++++++--------------- Modules/Settings/Tabs/DisplayTab.qml | 14 ++-- Services/CompositorService.qml | 85 ++++++++++++++++++++++++ Services/HyprlandService.qml | 71 +++++++++++++++++++- Services/NiriService.qml | 62 ++++++++++++++++- Services/SwayService.qml | 67 +++++++++++++++++++ 7 files changed, 337 insertions(+), 63 deletions(-) diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index c4ff50ae..a90e24a7 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -1394,7 +1394,7 @@ "system": { "uptime": "Uptime: {uptime}", "welcome-back": "Welcome back,", - "monitor-description": "{model} ({width}x{height})", + "monitor-description": "{model} ({width}x{height} @ {scale}x)", "scaling-percentage": "{percentage}%", "location-display": "{name} ({coordinates})", "signal-strength": "{signal}%", diff --git a/Modules/Background/Background.qml b/Modules/Background/Background.qml index 4b917522..2bcbb04f 100644 --- a/Modules/Background/Background.qml +++ b/Modules/Background/Background.qml @@ -44,19 +44,6 @@ Variants { property real fillMode: WallpaperService.getFillModeUniform() property vector4d fillColor: Qt.vector4d(Settings.data.wallpaper.fillColor.r, Settings.data.wallpaper.fillColor.g, Settings.data.wallpaper.fillColor.b, 1.0) - property int monitoredWidth: modelData.width - property int monitoredHeight: modelData.height - - onMonitoredWidthChanged: { - Logger.log("Background", "Screen width changed to:", monitoredWidth, "for", modelData.name) - recalculateImageSizes() - } - - onMonitoredHeightChanged: { - Logger.log("Background", "Screen height changed to:", monitoredHeight, "for", modelData.name) - recalculateImageSizes() - } - Component.onCompleted: setWallpaperInitial() Component.onDestruction: { @@ -87,6 +74,13 @@ Variants { } } + Connections { + target: CompositorService + function onDisplayScalesChanged() { + setWallpaperInitial() + } + } + color: Color.transparent screen: modelData WlrLayershell.layer: WlrLayer.Background @@ -122,38 +116,21 @@ Variants { cache: false asynchronous: true sourceSize: undefined - onStatusChanged: { if (status === Image.Error) { Logger.warn("Current wallpaper failed to load:", source) } else if (status === Image.Ready && !dimensionsCalculated) { dimensionsCalculated = true - calculateSourceSize() + const optimalSize = calculateOptimalWallpaperSize(implicitWidth, implicitHeight) + if (optimalSize !== false) { + sourceSize = optimalSize + } } } - onSourceChanged: { dimensionsCalculated = false sourceSize = undefined } - - function calculateSourceSize() { - if (implicitWidth === modelData.width || implicitHeight === modelData.height) { - // Do not resize if one of the dimensions fits perfectly on the screen - return - } - - if (implicitWidth > 0 && implicitHeight > 0) { - const imageAspectRatio = implicitWidth / implicitHeight - if (modelData.width >= modelData.height) { - const w = Math.min(modelData.width, implicitWidth) - sourceSize = Qt.size(w, w / imageAspectRatio) - } else { - const h = Math.min(modelData.height, implicitHeight) - sourceSize = Qt.size(h * imageAspectRatio, h) - } - } - } } Image { @@ -168,38 +145,21 @@ Variants { cache: false asynchronous: true sourceSize: undefined - onStatusChanged: { if (status === Image.Error) { Logger.warn("Next wallpaper failed to load:", source) } else if (status === Image.Ready && !dimensionsCalculated) { dimensionsCalculated = true - calculateSourceSize() + const optimalSize = calculateOptimalWallpaperSize(implicitWidth, implicitHeight) + if (optimalSize !== false) { + sourceSize = optimalSize + } } } - onSourceChanged: { dimensionsCalculated = false sourceSize = undefined } - - function calculateSourceSize() { - if (implicitWidth === modelData.width || implicitHeight === modelData.height) { - // Do not resize if one of the dimensions fits perfectly on the screen - return - } - - if (implicitWidth > 0 && implicitHeight > 0) { - const imageAspectRatio = implicitWidth / implicitHeight - if (modelData.width >= modelData.height) { - const w = Math.min(modelData.width, implicitWidth) - sourceSize = Qt.size(w, w / imageAspectRatio) - } else { - const h = Math.min(modelData.height, implicitHeight) - sourceSize = Qt.size(h * imageAspectRatio, h) - } - } - } } // Dynamic shader loader - only loads the active transition shader @@ -356,6 +316,31 @@ Variants { } } + // ------------------------------------------------------ + function calculateOptimalWallpaperSize(wpWidth, wpHeight) { + const compositorScale = CompositorService.getDisplayScale(modelData.name) + const screenWidth = modelData.width * compositorScale + const screenHeight = modelData.height * compositorScale + if (wpWidth <= screenWidth || wpHeight <= screenHeight || wpWidth <= 0 || wpHeight <= 0) { + // Do not resize if wallpaper is smaller than one of the screen dimension + return + } + + const imageAspectRatio = wpWidth / wpHeight + var dim = Qt.size(0, 0) + if (screenWidth >= screenHeight) { + const w = Math.min(screenWidth, wpWidth) + dim = Qt.size(w, w / imageAspectRatio) + } else { + const h = Math.min(screenHeight, wpHeight) + dim = Qt.size(h * imageAspectRatio, h) + } + + Logger.log("Background", `Wallpaper resized on ${modelData.name} ${screenWidth}x${screenHeight} @ ${compositorScale}x`, "src:", wpWidth, wpHeight, "dst:", dim.width, dim.height) + return dim + } + + // ------------------------------------------------------ function recalculateImageSizes() { if (currentWallpaper.status === Image.Ready) { currentWallpaper.calculateSourceSize() @@ -365,6 +350,7 @@ Variants { } } + // ------------------------------------------------------ function setWallpaperInitial() { // On startup, defer assigning wallpaper until the service cache is ready, retries every tick if (!WallpaperService || !WallpaperService.isInitialized) { @@ -375,6 +361,7 @@ Variants { setWallpaperImmediate(WallpaperService.getWallpaper(modelData.name)) } + // ------------------------------------------------------ function setWallpaperImmediate(source) { transitionAnimation.stop() transitionProgress = 0.0 @@ -388,6 +375,7 @@ Variants { }) } + // ------------------------------------------------------ function setWallpaperWithTransition(source) { if (source === currentWallpaper.source) { return @@ -421,6 +409,7 @@ Variants { transitionAnimation.start() } + // ------------------------------------------------------ // Main method that actually trigger the wallpaper change function changeWallpaper() { // Get the transitionType from the settings diff --git a/Modules/Settings/Tabs/DisplayTab.qml b/Modules/Settings/Tabs/DisplayTab.qml index d6ae8420..6eed0a7b 100644 --- a/Modules/Settings/Tabs/DisplayTab.qml +++ b/Modules/Settings/Tabs/DisplayTab.qml @@ -90,11 +90,15 @@ ColumnLayout { NLabel { label: modelData.name || "Unknown" - description: I18n.tr("system.monitor-description", { - "model": modelData.model, - "width": modelData.width, - "height": modelData.height - }) + description: { + const compositorScale = CompositorService.getDisplayScale(modelData.name) + I18n.tr("system.monitor-description", { + "model": modelData.model, + "width": modelData.width * compositorScale, + "height": modelData.height * compositorScale, + "scale": compositorScale + }) + } } // Scale diff --git a/Services/CompositorService.qml b/Services/CompositorService.qml index fdbde343..a9dd46e6 100644 --- a/Services/CompositorService.qml +++ b/Services/CompositorService.qml @@ -2,6 +2,7 @@ pragma Singleton import QtQuick import Quickshell +import Quickshell.Io import qs.Commons import qs.Services @@ -18,6 +19,10 @@ Singleton { property ListModel windows: ListModel {} property int focusedWindowIndex: -1 + // Display scale data + property var displayScales: ({}) + property bool displayScalesLoaded: false + // Generic events signal workspaceChanged signal activeWindowChanged @@ -26,7 +31,18 @@ Singleton { // Backend service loader property var backend: null + // Cache file path + property string displayCachePath: "" + Component.onCompleted: { + // Setup cache path (needs Settings to be available) + Qt.callLater(() => { + if (typeof Settings !== 'undefined' && Settings.cacheDir) { + displayCachePath = Settings.cacheDir + "display.json" + displayCacheFileView.path = displayCachePath + } + }) + detectCompositor() } @@ -69,6 +85,31 @@ Singleton { } } + // Cache FileView for display scales + FileView { + id: displayCacheFileView + printErrors: false + watchChanges: false + + adapter: JsonAdapter { + id: displayCacheAdapter + property var displays: ({}) + } + + onLoaded: { + // Load cached display scales + displayScales = displayCacheAdapter.displays || {} + displayScalesLoaded = true + // Logger.log("CompositorService", "Loaded display scales from cache:", JSON.stringify(displayScales)) + } + + onLoadFailed: { + // Cache doesn't exist yet, will be created on first update + displayScalesLoaded = true + // Logger.log("CompositorService", "No display cache found, will create on first update") + } + } + // Hyprland backend component Component { id: hyprlandComponent @@ -151,6 +192,50 @@ Singleton { windowListChanged() } + // Update display scales from backend + function updateDisplayScales() { + if (!backend || !backend.queryDisplayScales) { + Logger.warn("CompositorService", "Backend does not support display scale queries") + return + } + + backend.queryDisplayScales() + } + + // Called by backend when display scales are ready + function onDisplayScalesUpdated(scales) { + displayScales = scales + saveDisplayScalesToCache() + displayScalesChanged() + Logger.log("CompositorService", "Display scales updated") + } + + // Save display scales to cache + function saveDisplayScalesToCache() { + if (!displayCachePath) { + return + } + + displayCacheAdapter.displays = displayScales + displayCacheFileView.writeAdapter() + } + + // Public function to get scale for a specific display + function getDisplayScale(displayName) { + if (!displayName || !displayScales[displayName]) { + return 1.0 + } + return displayScales[displayName].scale || 1.0 + } + + // Public function to get all display info for a specific display + function getDisplayInfo(displayName) { + if (!displayName || !displayScales[displayName]) { + return null + } + return displayScales[displayName] + } + // Get focused window function getFocusedWindow() { if (focusedWindowIndex >= 0 && focusedWindowIndex < windows.count) { diff --git a/Services/HyprlandService.qml b/Services/HyprlandService.qml index 81d09549..0cafa965 100644 --- a/Services/HyprlandService.qml +++ b/Services/HyprlandService.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Hyprland +import Quickshell.Io import qs.Commons Item { @@ -15,6 +16,7 @@ Item { signal workspaceChanged signal activeWindowChanged signal windowListChanged + signal displayScalesChanged // Hyprland-specific properties property bool initialized: false @@ -40,6 +42,7 @@ Item { Qt.callLater(() => { safeUpdateWorkspaces() safeUpdateWindows() + queryDisplayScales() }) initialized = true Logger.log("HyprlandService", "Initialized successfully") @@ -48,6 +51,67 @@ Item { } } + // Query display scales + function queryDisplayScales() { + hyprlandMonitorsProcess.running = true + } + + // Hyprland monitors process for display scale detection + // Hyprland monitors process for display scale detection + Process { + id: hyprlandMonitorsProcess + running: false + command: ["hyprctl", "monitors", "-j"] + + property string accumulatedOutput: "" + + stdout: SplitParser { + onRead: function (line) { + // Accumulate lines instead of parsing each one + hyprlandMonitorsProcess.accumulatedOutput += line + } + } + + onExited: function (exitCode) { + if (exitCode !== 0 || !accumulatedOutput) { + Logger.error("HyprlandService", "Failed to query monitors, exit code:", exitCode) + accumulatedOutput = "" + return + } + + try { + const monitorsData = JSON.parse(accumulatedOutput) + const scales = {} + + for (const monitor of monitorsData) { + if (monitor.name) { + scales[monitor.name] = { + "name": monitor.name, + "scale": monitor.scale || 1.0, + "width": monitor.width || 0, + "height": monitor.height || 0, + "refresh_rate": monitor.refreshRate || 0, + "x": monitor.x || 0, + "y": monitor.y || 0, + "active_workspace": monitor.activeWorkspace ? monitor.activeWorkspace.id : -1, + "vrr": monitor.vrr || false, + "focused": monitor.focused || false + } + } + } + + // Notify CompositorService (it will emit displayScalesChanged) + if (CompositorService && CompositorService.onDisplayScalesUpdated) { + CompositorService.onDisplayScalesUpdated(scales) + } + } catch (e) { + Logger.error("HyprlandService", "Failed to parse monitors:", e) + } finally { + // Clear accumulated output for next query + accumulatedOutput = "" + } + } + } // Safe update wrapper function safeUpdate() { safeUpdateWindows() @@ -188,7 +252,7 @@ Item { "id": windowId, "title": title, "appId": appId, - "workspaceId": wsId, + "workspaceId": wsId || -1, "isFocused": focused, "output": output } @@ -268,6 +332,11 @@ Item { safeUpdateWorkspaces() workspaceChanged() updateTimer.restart() + + const monitorsEvents = ["configreloaded", "monitoradded", "monitorremoved", "monitoraddedv2", "monitorremovedv2"] + if (monitorsEvents.includes(event.name)) { + Qt.callLater(queryDisplayScales) + } } } diff --git a/Services/NiriService.qml b/Services/NiriService.qml index 9f3df88b..16226348 100644 --- a/Services/NiriService.qml +++ b/Services/NiriService.qml @@ -20,12 +20,14 @@ Item { signal workspaceChanged signal activeWindowChanged signal windowListChanged + signal displayScalesChanged // Initialization function initialize() { niriEventStream.running = true updateWorkspaces() updateWindows() + queryDisplayScales() Logger.log("NiriService", "Initialized successfully") } @@ -39,6 +41,60 @@ Item { niriWindowsProcess.running = true } + // Query display scales + function queryDisplayScales() { + niriOutputsProcess.running = true + } + + // Niri outputs process for display scale detection + Process { + id: niriOutputsProcess + running: false + command: ["niri", "msg", "--json", "outputs"] + + stdout: SplitParser { + onRead: function (line) { + try { + const outputsData = JSON.parse(line) + const scales = {} + + // Niri returns an object with display names as keys + for (const outputName in outputsData) { + const output = outputsData[outputName] + if (output && output.name) { + const logical = output.logical || {} + const currentModeIdx = output.current_mode || 0 + const modes = output.modes || [] + const currentMode = modes[currentModeIdx] || {} + + scales[output.name] = { + "name": output.name, + "scale": logical.scale || 1.0, + "width": logical.width || 0, + "height": logical.height || 0, + "x": logical.x || 0, + "y": logical.y || 0, + "physical_width": (output.physical_size && output.physical_size[0]) || 0, + "physical_height": (output.physical_size && output.physical_size[1]) || 0, + "refresh_rate": currentMode.refresh_rate || 0, + "vrr_supported": output.vrr_supported || false, + "vrr_enabled": output.vrr_enabled || false, + "transform": logical.transform || "Normal" + } + } + } + + // Notify CompositorService (it will emit displayScalesChanged) + if (CompositorService && CompositorService.onDisplayScalesUpdated) { + CompositorService.onDisplayScalesUpdated(scales) + } + } catch (e) { + Logger.error("NiriService", "Failed to parse outputs:", e, line) + } + } + } + } + // Niri workspace process Process { id: niriWorkspaceProcess @@ -86,7 +142,7 @@ Item { } } - // Niri windows process (for initial load) + // Niri windows process Process { id: niriWindowsProcess running: false @@ -131,6 +187,10 @@ Item { handleWindowLayoutsChanged(event.WindowLayoutsChanged) } else if (event.OverviewOpenedOrClosed) { handleOverviewOpenedOrClosed(event.OverviewOpenedOrClosed) + } else if (event.OutputsChanged) { + queryDisplayScales() + } else if (event.ConfigLoaded) { + queryDisplayScales() } } catch (e) { Logger.error("NiriService", "Error parsing event stream:", e, data) diff --git a/Services/SwayService.qml b/Services/SwayService.qml index ad54125c..9e1ddd71 100644 --- a/Services/SwayService.qml +++ b/Services/SwayService.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.I3 import Quickshell.Wayland +import Quickshell.Io import qs.Commons Item { @@ -16,6 +17,7 @@ Item { signal workspaceChanged signal activeWindowChanged signal windowListChanged + signal displayScalesChanged // I3-specific properties property bool initialized: false @@ -38,6 +40,7 @@ Item { Qt.callLater(() => { safeUpdateWorkspaces() safeUpdateWindows() + queryDisplayScales() }) initialized = true Logger.log("SwayService", "Initialized successfully") @@ -46,6 +49,66 @@ Item { } } + // Query display scales + function queryDisplayScales() { + swayOutputsProcess.running = true + } + + // Sway outputs process for display scale detection + Process { + id: swayOutputsProcess + running: false + command: ["swaymsg", "-t", "get_outputs", "-r"] + + property string accumulatedOutput: "" + + stdout: SplitParser { + onRead: function (line) { + swayOutputsProcess.accumulatedOutput += line + } + } + + onExited: function (exitCode) { + if (exitCode !== 0 || !accumulatedOutput) { + Logger.error("SwayService", "Failed to query outputs, exit code:", exitCode) + accumulatedOutput = "" + return + } + + try { + const outputsData = JSON.parse(accumulatedOutput) + const scales = {} + + for (const output of outputsData) { + if (output.name) { + scales[output.name] = { + "name": output.name, + "scale": output.scale || 1.0, + "width": output.current_mode ? output.current_mode.width : 0, + "height": output.current_mode ? output.current_mode.height : 0, + "refresh_rate": output.current_mode ? output.current_mode.refresh : 0, + "x": output.rect ? output.rect.x : 0, + "y": output.rect ? output.rect.y : 0, + "active": output.active || false, + "focused": output.focused || false, + "current_workspace": output.current_workspace || "" + } + } + } + + // Notify CompositorService (it will emit displayScalesChanged) + if (CompositorService && CompositorService.onDisplayScalesUpdated) { + CompositorService.onDisplayScalesUpdated(scales) + } + } catch (e) { + Logger.error("SwayService", "Failed to parse outputs:", e) + } finally { + // Clear accumulated output for next query + accumulatedOutput = "" + } + } + } + // Safe update wrapper function safeUpdate() { safeUpdateWindows() @@ -197,6 +260,10 @@ Item { safeUpdateWorkspaces() workspaceChanged() updateTimer.restart() + + if (event.type === "output") { + Qt.callLater(queryDisplayScales) + } } }