diff options
Diffstat (limited to 'raveos-theme/hyprland/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml')
| -rw-r--r-- | raveos-theme/hyprland/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml | 1837 |
1 files changed, 1837 insertions, 0 deletions
diff --git a/raveos-theme/hyprland/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/raveos-theme/hyprland/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml new file mode 100644 index 0000000..b6e253b --- /dev/null +++ b/raveos-theme/hyprland/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -0,0 +1,1837 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtCore +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Singleton { + id: root + + readonly property bool hasOutputBackend: WlrOutputService.wlrOutputAvailable + readonly property var wlrOutputs: WlrOutputService.outputs + property var outputs: ({}) + property var savedOutputs: ({}) + property var allOutputs: buildAllOutputsMap() + + property var includeStatus: ({ + "exists": false, + "included": false + }) + property bool checkingInclude: false + property bool fixingInclude: false + + property var pendingChanges: ({}) + property var pendingNiriChanges: ({}) + property var pendingHyprlandChanges: ({}) + property var originalNiriSettings: null + property var originalHyprlandSettings: null + property var originalOutputs: null + property string originalDisplayNameMode: "" + property bool formatChanged: originalDisplayNameMode !== "" && originalDisplayNameMode !== SettingsData.displayNameMode + property bool hasPendingChanges: Object.keys(pendingChanges).length > 0 || Object.keys(pendingNiriChanges).length > 0 || Object.keys(pendingHyprlandChanges).length > 0 || formatChanged + + property bool validatingConfig: false + property string validationError: "" + + property var currentOutputSet: [] + property string matchedProfile: "" + property bool profilesLoading: false + property var validatedProfiles: ({}) + property bool manualActivation: false + + signal changesApplied(var changeDescriptions) + signal changesConfirmed + signal changesReverted + signal profileActivated(string profileId, string profileName) + signal profileSaved(string profileId, string profileName) + signal profileDeleted(string profileId) + signal profileError(string message) + + function buildCurrentOutputSet() { + const connected = []; + for (const name in outputs) { + const output = outputs[name]; + connected.push(getOutputIdentifier(output, name)); + } + return connected.sort(); + } + + function getOutputIdentifier(output, outputName) { + if (SettingsData.displayNameMode === "model" && output?.make && output?.model) { + if (CompositorService.isNiri) { + const serial = output.serial || "Unknown"; + return output.make + " " + output.model + " " + serial; + } + return output.make + " " + output.model; + } + return outputName; + } + + function validateProfiles() { + const compositor = CompositorService.compositor; + const profiles = SettingsData.getDisplayProfiles(compositor); + const profilesDir = getProfilesDir(); + const ext = getProfileExtension(); + + if (!profilesDir) { + validatedProfiles = {}; + return; + } + + const profileIds = Object.keys(profiles); + if (profileIds.length === 0) { + validatedProfiles = {}; + return; + } + + const fileChecks = profileIds.map(id => profilesDir + "/" + id + ext).join(" "); + Proc.runCommand("validate-profiles", ["sh", "-c", `for f in ${fileChecks}; do [ -f "$f" ] && echo "$f"; done`], (output, exitCode) => { + const existingFiles = new Set(output.trim().split("\n").filter(f => f)); + const validated = {}; + for (const profileId of profileIds) { + const profileFile = profilesDir + "/" + profileId + ext; + if (existingFiles.has(profileFile)) + validated[profileId] = profiles[profileId]; + else + SettingsData.removeDisplayProfile(compositor, profileId); + } + validatedProfiles = validated; + matchedProfile = findMatchingProfile(); + }); + } + + function findMatchingProfile() { + const profiles = validatedProfiles; + + console.log("[Profile Match] Current outputs:", JSON.stringify(currentOutputSet)); + + let bestMatch = ""; + let bestScore = -1; + let bestUpdatedAt = 0; + + for (const profileId in profiles) { + const profile = profiles[profileId]; + const profileSet = new Set(profile.outputSet); + + console.log("[Profile Match] Checking", profile.name, "outputSet:", JSON.stringify(profile.outputSet)); + + let allCurrentPresent = true; + for (const output of currentOutputSet) { + if (!profileSet.has(output)) { + console.log("[Profile Match] - Missing output:", output); + allCurrentPresent = false; + break; + } + } + if (!allCurrentPresent) { + console.log("[Profile Match] - SKIP: not all current outputs present"); + continue; + } + + const disconnectedCount = profile.outputSet.length - currentOutputSet.length; + const score = currentOutputSet.length * 100 - disconnectedCount; + const updatedAt = profile.updatedAt || profile.createdAt || 0; + console.log("[Profile Match] - MATCH score:", score, "(disconnected:", disconnectedCount, "updatedAt:", updatedAt + ")"); + + if (score > bestScore || (score === bestScore && updatedAt > bestUpdatedAt)) { + bestScore = score; + bestMatch = profileId; + bestUpdatedAt = updatedAt; + } + } + console.log("[Profile Match] Best match:", bestMatch, "score:", bestScore); + return bestMatch; + } + + function getProfilesDir() { + const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + switch (CompositorService.compositor) { + case "niri": + return configDir + "/niri/dms/profiles"; + case "hyprland": + return configDir + "/hypr/dms/profiles"; + case "dwl": + return configDir + "/mango/dms/profiles"; + default: + return ""; + } + } + + function getProfileExtension() { + return CompositorService.compositor === "niri" ? ".kdl" : ".conf"; + } + + function createProfile(name) { + const compositor = CompositorService.compositor; + const profileId = "profile_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6); + const outputSet = buildCurrentOutputSet(); + const now = Date.now(); + + const profileData = { + "id": profileId, + "name": name, + "outputSet": outputSet, + "createdAt": now, + "updatedAt": now + }; + + const profilesDir = getProfilesDir(); + const profileFile = profilesDir + "/" + profileId + getProfileExtension(); + const paths = getConfigPaths(); + if (!paths) { + profileError(I18n.tr("Compositor not supported")); + return; + } + + profilesLoading = true; + Proc.runCommand("create-profile-dir", ["mkdir", "-p", profilesDir], (output, exitCode) => { + if (exitCode !== 0) { + profilesLoading = false; + profileError(I18n.tr("Failed to create profiles directory")); + return; + } + Proc.runCommand("copy-profile", ["cp", "-L", paths.outputsFile, profileFile], (output2, exitCode2) => { + if (exitCode2 !== 0) { + profilesLoading = false; + profileError(I18n.tr("Failed to save profile file")); + return; + } + SettingsData.setDisplayProfile(compositor, profileId, profileData); + SettingsData.setActiveDisplayProfile(compositor, profileId); + const updated = JSON.parse(JSON.stringify(validatedProfiles)); + updated[profileId] = profileData; + validatedProfiles = updated; + Proc.runCommand("link-new-profile", ["ln", "-sf", profileFile, paths.outputsFile], () => { + profilesLoading = false; + currentOutputSet = outputSet; + matchedProfile = profileId; + profileSaved(profileId, name); + }); + }); + }); + } + + function renameProfile(profileId, newName) { + const compositor = CompositorService.compositor; + const profiles = SettingsData.getDisplayProfiles(compositor); + const profile = profiles[profileId]; + if (!profile) { + profileError(I18n.tr("Profile not found")); + return; + } + profile.name = newName; + profile.updatedAt = Date.now(); + SettingsData.setDisplayProfile(compositor, profileId, profile); + } + + function deleteProfile(profileId) { + const compositor = CompositorService.compositor; + const profilesDir = getProfilesDir(); + const profileFile = profilesDir + "/" + profileId + getProfileExtension(); + const isActive = SettingsData.getActiveDisplayProfile(compositor) === profileId; + + profilesLoading = true; + Proc.runCommand("delete-profile", ["rm", "-f", profileFile], (output, exitCode) => { + profilesLoading = false; + SettingsData.removeDisplayProfile(compositor, profileId); + if (isActive) { + SettingsData.setActiveDisplayProfile(compositor, ""); + backendWriteOutputsConfig(allOutputs); + } + const updated = JSON.parse(JSON.stringify(validatedProfiles)); + delete updated[profileId]; + validatedProfiles = updated; + matchedProfile = findMatchingProfile(); + profileDeleted(profileId); + }); + } + + function activateProfile(profileId) { + const compositor = CompositorService.compositor; + const profiles = SettingsData.getDisplayProfiles(compositor); + const profile = profiles[profileId]; + if (!profile) { + profileError(I18n.tr("Profile not found")); + return; + } + + const profilesDir = getProfilesDir(); + const profileFile = profilesDir + "/" + profileId + getProfileExtension(); + const paths = getConfigPaths(); + if (!paths) + return; + + manualActivation = true; + profilesLoading = true; + Proc.runCommand("activate-profile", ["ln", "-sf", profileFile, paths.outputsFile], (output, exitCode) => { + if (exitCode !== 0) { + profilesLoading = false; + manualActivation = false; + profileError(I18n.tr("Failed to activate profile - file not found")); + return; + } + SettingsData.setActiveDisplayProfile(compositor, profileId); + + const reloadCmd = CompositorService.isNiri ? ["niri", "msg", "action", "reload-config-or-panic"] : CompositorService.isHyprland ? ["hyprctl", "reload"] : []; + if (reloadCmd.length > 0) { + Proc.runCommand("reload-compositor", reloadCmd, (output2, exitCode2) => { + profilesLoading = false; + WlrOutputService.requestState(); + profileActivated(profileId, profile.name); + manualActivationTimer.restart(); + }); + } else { + profilesLoading = false; + profileActivated(profileId, profile.name); + manualActivationTimer.restart(); + } + }); + } + + Timer { + id: manualActivationTimer + interval: 2000 + onTriggered: root.manualActivation = false + } + + Timer { + id: autoSelectDebounceTimer + interval: 800 + onTriggered: root.doAutoSelectProfile() + } + + // ! TODO - auto profile switching is buggy on niri and other compositors, might need a longer debounce before updating output configuration idk + function autoSelectProfile() { + return; // disabled + autoSelectDebounceTimer.restart(); + } + + function doAutoSelectProfile() { + return; // disabled + if (!SettingsData.displayProfileAutoSelect || manualActivation) + return; + currentOutputSet = buildCurrentOutputSet(); + const matched = findMatchingProfile(); + matchedProfile = matched; + if (matched && matched !== SettingsData.getActiveDisplayProfile(CompositorService.compositor)) + activateProfile(matched); + } + + function deleteDisconnectedOutput(outputName) { + if (outputs[outputName]?.connected) + return; + + const updated = JSON.parse(JSON.stringify(savedOutputs)); + delete updated[outputName]; + savedOutputs = updated; + + const mergedOutputs = {}; + for (const name in outputs) + mergedOutputs[name] = outputs[name]; + for (const name in updated) + mergedOutputs[name] = updated[name]; + + backendWriteOutputsConfig(mergedOutputs); + } + + function buildAllOutputsMap() { + const result = {}; + for (const name in savedOutputs) { + result[name] = Object.assign({}, savedOutputs[name], { + "connected": false + }); + } + for (const name in outputs) { + result[name] = Object.assign({}, outputs[name], { + "connected": true + }); + } + return result; + } + + onOutputsChanged: { + allOutputs = buildAllOutputsMap(); + currentOutputSet = buildCurrentOutputSet(); + matchedProfile = findMatchingProfile(); + // ! TODO - auto profile switching disabled for now + // if (SettingsData.displayProfileAutoSelect) + // Qt.callLater(autoSelectProfile); + } + onSavedOutputsChanged: allOutputs = buildAllOutputsMap() + + Connections { + target: WlrOutputService + function onStateChanged() { + root.outputs = root.buildOutputsMap(); + root.reloadSavedOutputs(); + } + } + + Component.onCompleted: { + outputs = buildOutputsMap(); + reloadSavedOutputs(); + checkIncludeStatus(); + currentOutputSet = buildCurrentOutputSet(); + validateProfiles(); + } + + function reloadSavedOutputs() { + const paths = getConfigPaths(); + if (!paths) { + savedOutputs = {}; + return; + } + + Proc.runCommand("load-saved-outputs", ["cat", paths.outputsFile], (content, exitCode) => { + if (exitCode !== 0 || !content.trim()) { + savedOutputs = {}; + return; + } + const parsed = parseOutputsConfig(content); + const filtered = filterDisconnectedOnly(parsed); + savedOutputs = filtered; + + if (CompositorService.isHyprland) { + initHyprlandSettingsFromConfig(parsed); + syncHyprlandVrrFromConfig(parsed); + } + if (CompositorService.isNiri) + syncNiriVrrFromConfig(parsed); + }); + } + + function initHyprlandSettingsFromConfig(parsedOutputs) { + const current = JSON.parse(JSON.stringify(SettingsData.hyprlandOutputSettings)); + let changed = false; + + for (const outputName in parsedOutputs) { + const output = parsedOutputs[outputName]; + const settings = output.hyprlandSettings; + if (!settings) + continue; + + if (current[outputName]) + continue; + + const hasSettings = settings.colorManagement || settings.bitdepth || settings.sdrBrightness !== undefined || settings.sdrSaturation !== undefined; + if (!hasSettings) + continue; + + current[outputName] = {}; + if (settings.colorManagement) + current[outputName].colorManagement = settings.colorManagement; + if (settings.bitdepth) + current[outputName].bitdepth = settings.bitdepth; + if (settings.sdrBrightness !== undefined) + current[outputName].sdrBrightness = settings.sdrBrightness; + if (settings.sdrSaturation !== undefined) + current[outputName].sdrSaturation = settings.sdrSaturation; + changed = true; + } + + if (changed) { + SettingsData.hyprlandOutputSettings = current; + SettingsData.saveSettings(); + } + } + + function syncHyprlandVrrFromConfig(parsedOutputs) { + const current = JSON.parse(JSON.stringify(SettingsData.hyprlandOutputSettings)); + let changed = false; + for (const outputName in parsedOutputs) { + const settings = parsedOutputs[outputName]?.hyprlandSettings; + const fromConfig = settings?.vrrFullscreenOnly ?? false; + const stored = current[outputName]?.vrrFullscreenOnly ?? false; + if (fromConfig === stored) + continue; + if (!current[outputName]) + current[outputName] = {}; + if (fromConfig) + current[outputName].vrrFullscreenOnly = true; + else + delete current[outputName].vrrFullscreenOnly; + changed = true; + } + if (changed) { + SettingsData.hyprlandOutputSettings = current; + SettingsData.saveSettings(); + } + } + + function syncNiriVrrFromConfig(parsedOutputs) { + let changed = false; + for (const outputName in parsedOutputs) { + const output = parsedOutputs[outputName]; + const current = SettingsData.getNiriOutputSetting(outputName, "vrrOnDemand", false); + const fromConfig = output.vrr_on_demand ?? false; + if (current === fromConfig) + continue; + SettingsData.setNiriOutputSetting(outputName, "vrrOnDemand", fromConfig || undefined); + changed = true; + } + if (changed) + SettingsData.saveSettings(); + } + + function filterDisconnectedOnly(parsedOutputs) { + const result = {}; + const liveNames = Object.keys(outputs); + const liveByIdentifier = {}; + for (const name of liveNames) { + const o = outputs[name]; + if (o?.make && o?.model) { + const serial = o.serial || "Unknown"; + const id = (o.make + " " + o.model + " " + serial).trim(); + liveByIdentifier[id] = true; + liveByIdentifier[o.make + " " + o.model] = true; + } + liveByIdentifier[name] = true; + } + + for (const savedName in parsedOutputs) { + const trimmed = savedName.trim(); + if (!liveByIdentifier[trimmed]) + result[savedName] = parsedOutputs[savedName]; + } + return result; + } + + function parseOutputsConfig(content) { + switch (CompositorService.compositor) { + case "niri": + return parseNiriOutputs(content); + case "hyprland": + return parseHyprlandOutputs(content); + case "dwl": + return parseMangoOutputs(content); + default: + return {}; + } + } + + function parseNiriOutputs(content) { + const result = {}; + const outputRegex = /output\s+"([^"]+)"\s*\{([^}]*)\}/g; + let match; + while ((match = outputRegex.exec(content)) !== null) { + const name = match[1]; + const body = match[2]; + + const modeMatch = body.match(/mode\s+"(\d+)x(\d+)@([\d.]+)"/); + const posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/); + const scaleMatch = body.match(/scale\s+([\d.]+)/); + const transformMatch = body.match(/transform\s+"([^"]+)"/); + const vrrMatch = body.match(/variable-refresh-rate/); + const vrrOnDemandMatch = body.match(/variable-refresh-rate\s+on-demand=true/); + + result[name] = { + "name": name, + "logical": { + "x": posMatch ? parseInt(posMatch[1]) : 0, + "y": posMatch ? parseInt(posMatch[2]) : 0, + "scale": scaleMatch ? parseFloat(scaleMatch[1]) : 1.0, + "transform": transformMatch ? transformMatch[1] : "Normal" + }, + "modes": modeMatch ? [ + { + "width": parseInt(modeMatch[1]), + "height": parseInt(modeMatch[2]), + "refresh_rate": Math.round(parseFloat(modeMatch[3]) * 1000) + } + ] : [], + "current_mode": 0, + "vrr_enabled": !!vrrMatch, + "vrr_on_demand": !!vrrOnDemandMatch, + "vrr_supported": true + }; + } + return result; + } + + function parseHyprlandOutputs(content) { + const result = {}; + const lines = content.split("\n"); + for (const line of lines) { + const disableMatch = line.match(/^\s*monitor\s*=\s*([^,]+),\s*disable\s*$/); + if (disableMatch) { + const name = disableMatch[1].trim(); + result[name] = { + "name": name, + "logical": { + "x": 0, + "y": 0, + "scale": 1.0, + "transform": "Normal" + }, + "modes": [], + "current_mode": -1, + "vrr_enabled": false, + "vrr_supported": false, + "hyprlandSettings": { + "disabled": true + } + }; + continue; + } + const match = line.match(/^\s*monitor\s*=\s*([^,]+),\s*(\d+)x(\d+)@([\d.]+),\s*(-?\d+)x(-?\d+),\s*([\d.]+)/); + if (!match) + continue; + const name = match[1].trim(); + const rest = line.substring(line.indexOf(match[7]) + match[7].length); + + let transform = 0, vrrMode = 0, bitdepth = undefined, cm = undefined; + let sdrBrightness = undefined, sdrSaturation = undefined; + + const transformMatch = rest.match(/,\s*transform,\s*(\d+)/); + if (transformMatch) + transform = parseInt(transformMatch[1]); + + const vrrMatch = rest.match(/,\s*vrr,\s*(\d+)/); + if (vrrMatch) + vrrMode = parseInt(vrrMatch[1]); + + const bitdepthMatch = rest.match(/,\s*bitdepth,\s*(\d+)/); + if (bitdepthMatch) + bitdepth = parseInt(bitdepthMatch[1]); + + const cmMatch = rest.match(/,\s*cm,\s*(\w+)/); + if (cmMatch) + cm = cmMatch[1]; + + const sdrBrightnessMatch = rest.match(/,\s*sdrbrightness,\s*([\d.]+)/); + if (sdrBrightnessMatch) + sdrBrightness = parseFloat(sdrBrightnessMatch[1]); + + const sdrSaturationMatch = rest.match(/,\s*sdrsaturation,\s*([\d.]+)/); + if (sdrSaturationMatch) + sdrSaturation = parseFloat(sdrSaturationMatch[1]); + + let mirror = ""; + const mirrorMatch = rest.match(/,\s*mirror,\s*([^,\s]+)/); + if (mirrorMatch) + mirror = mirrorMatch[1]; + + result[name] = { + "name": name, + "logical": { + "x": parseInt(match[5]), + "y": parseInt(match[6]), + "scale": parseFloat(match[7]), + "transform": hyprlandToTransform(transform) + }, + "modes": [ + { + "width": parseInt(match[2]), + "height": parseInt(match[3]), + "refresh_rate": Math.round(parseFloat(match[4]) * 1000) + } + ], + "current_mode": 0, + "vrr_enabled": vrrMode >= 1, + "vrr_supported": true, + "hyprlandSettings": { + "bitdepth": bitdepth, + "colorManagement": cm, + "sdrBrightness": sdrBrightness, + "sdrSaturation": sdrSaturation, + "vrrFullscreenOnly": vrrMode === 2 ? true : undefined + }, + "mirror": mirror + }; + } + return result; + } + + function hyprlandToTransform(value) { + switch (value) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } + + function parseMangoOutputs(content) { + const result = {}; + const lines = content.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("monitorrule=")) + continue; + + const params = {}; + for (const pair of trimmed.substring("monitorrule=".length).split(",")) { + const colonIdx = pair.indexOf(":"); + if (colonIdx < 0) + continue; + params[pair.substring(0, colonIdx).trim()] = pair.substring(colonIdx + 1).trim(); + } + + const name = params.name; + if (!name) + continue; + + result[name] = { + "name": name, + "logical": { + "x": parseInt(params.x || "0"), + "y": parseInt(params.y || "0"), + "scale": parseFloat(params.scale || "1"), + "transform": mangoToTransform(parseInt(params.rr || "0")) + }, + "modes": [ + { + "width": parseInt(params.width || "1920"), + "height": parseInt(params.height || "1080"), + "refresh_rate": parseFloat(params.refresh || "60") * 1000 + } + ], + "current_mode": 0, + "vrr_enabled": parseInt(params.vrr || "0") === 1, + "vrr_supported": true + }; + } + return result; + } + + function mangoToTransform(value) { + switch (value) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } + + function getConfigPaths() { + const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + switch (CompositorService.compositor) { + case "niri": + return { + "configFile": configDir + "/niri/config.kdl", + "outputsFile": configDir + "/niri/dms/outputs.kdl", + "grepPattern": 'include.*"dms/outputs.kdl"', + "includeLine": 'include "dms/outputs.kdl"' + }; + case "hyprland": + return { + "configFile": configDir + "/hypr/hyprland.conf", + "outputsFile": configDir + "/hypr/dms/outputs.conf", + "grepPattern": 'source.*dms/outputs.conf', + "includeLine": "source = ./dms/outputs.conf" + }; + case "dwl": + return { + "configFile": configDir + "/mango/config.conf", + "outputsFile": configDir + "/mango/dms/outputs.conf", + "grepPattern": 'source.*dms/outputs.conf', + "includeLine": "source=./dms/outputs.conf" + }; + default: + return null; + } + } + + function checkIncludeStatus() { + const compositor = CompositorService.compositor; + if (compositor !== "niri" && compositor !== "hyprland" && compositor !== "dwl") { + includeStatus = { + "exists": false, + "included": false + }; + return; + } + + const filename = (compositor === "niri") ? "outputs.kdl" : "outputs.conf"; + const compositorArg = (compositor === "dwl") ? "mangowc" : compositor; + + checkingInclude = true; + Proc.runCommand("check-outputs-include", ["dms", "config", "resolve-include", compositorArg, filename], (output, exitCode) => { + checkingInclude = false; + if (exitCode !== 0) { + includeStatus = { + "exists": false, + "included": false + }; + return; + } + try { + includeStatus = JSON.parse(output.trim()); + } catch (e) { + includeStatus = { + "exists": false, + "included": false + }; + } + }); + } + + function fixOutputsInclude() { + const paths = getConfigPaths(); + if (!paths) + return; + + fixingInclude = true; + const outputsDir = paths.outputsFile.substring(0, paths.outputsFile.lastIndexOf("/")); + const unixTime = Math.floor(Date.now() / 1000); + const backupFile = paths.configFile + ".backup" + unixTime; + + Proc.runCommand("fix-outputs-include", ["sh", "-c", `cp "${paths.configFile}" "${backupFile}" 2>/dev/null; ` + `mkdir -p "${outputsDir}" && ` + `touch "${paths.outputsFile}" && ` + `if ! grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" 2>/dev/null | grep -q '${paths.grepPattern}'; then ` + `echo '' >> "${paths.configFile}" && ` + `echo '${paths.includeLine}' >> "${paths.configFile}"; fi`], (output, exitCode) => { + fixingInclude = false; + if (exitCode !== 0) + return; + checkIncludeStatus(); + WlrOutputService.requestState(); + }); + } + + function buildOutputsMap() { + const map = {}; + for (const output of wlrOutputs) { + const normalizedModes = (output.modes || []).map(m => ({ + "id": m.id, + "width": m.width, + "height": m.height, + "refresh_rate": m.refresh, + "preferred": m.preferred ?? false + })); + map[output.name] = { + "name": output.name, + "make": output.make || "", + "model": output.model || "", + "serial": output.serialNumber || "", + "modes": normalizedModes, + "current_mode": normalizedModes.findIndex(m => m.id === output.currentMode?.id), + "vrr_supported": output.adaptiveSyncSupported ?? false, + "vrr_enabled": output.adaptiveSync === 1, + "logical": { + "x": output.x ?? 0, + "y": output.y ?? 0, + "width": output.currentMode?.width ?? 1920, + "height": output.currentMode?.height ?? 1080, + "scale": output.scale ?? 1.0, + "transform": mapWlrTransform(output.transform) + } + }; + } + return map; + } + + function mapWlrTransform(wlrTransform) { + switch (wlrTransform) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } + + function mapTransformToWlr(transform) { + switch (transform) { + case "Normal": + return 0; + case "90": + return 1; + case "180": + return 2; + case "270": + return 3; + case "Flipped": + return 4; + case "Flipped90": + return 5; + case "Flipped180": + return 6; + case "Flipped270": + return 7; + default: + return 0; + } + } + + function backendFetchOutputs() { + WlrOutputService.requestState(); + } + + function backendWriteOutputsConfig(outputsData) { + switch (CompositorService.compositor) { + case "niri": + NiriService.generateOutputsConfig(outputsData); + break; + case "hyprland": + HyprlandService.generateOutputsConfig(outputsData, buildMergedHyprlandSettings()); + break; + case "dwl": + DwlService.generateOutputsConfig(outputsData); + break; + default: + WlrOutputService.applyOutputsConfig(outputsData, outputs); + break; + } + } + + function normalizeOutputPositions(outputsData) { + const names = Object.keys(outputsData); + if (names.length === 0) + return outputsData; + + let minX = Infinity; + let minY = Infinity; + + for (const name of names) { + const output = outputsData[name]; + if (!output.logical) + continue; + minX = Math.min(minX, output.logical.x); + minY = Math.min(minY, output.logical.y); + } + + if (minX === Infinity || (minX === 0 && minY === 0)) + return outputsData; + + const normalized = JSON.parse(JSON.stringify(outputsData)); + for (const name of names) { + if (!normalized[name].logical) + continue; + normalized[name].logical.x -= minX; + normalized[name].logical.y -= minY; + } + + return normalized; + } + + function buildOutputsWithPendingChanges() { + const result = {}; + + for (const outputName in savedOutputs) { + if (!outputs[outputName]) + result[outputName] = JSON.parse(JSON.stringify(savedOutputs[outputName])); + } + + for (const outputName in outputs) { + result[outputName] = JSON.parse(JSON.stringify(outputs[outputName])); + } + + for (const outputName in pendingChanges) { + if (!result[outputName]) + continue; + const changes = pendingChanges[outputName]; + if (changes.position && result[outputName].logical) { + result[outputName].logical.x = changes.position.x; + result[outputName].logical.y = changes.position.y; + } + if (changes.mode !== undefined && result[outputName].modes) { + for (var i = 0; i < result[outputName].modes.length; i++) { + if (formatMode(result[outputName].modes[i]) === changes.mode) { + result[outputName].current_mode = i; + break; + } + } + } + if (changes.scale !== undefined && result[outputName].logical) + result[outputName].logical.scale = changes.scale; + if (changes.transform !== undefined && result[outputName].logical) + result[outputName].logical.transform = changes.transform; + if (changes.vrr !== undefined) + result[outputName].vrr_enabled = changes.vrr; + if (changes.mirror !== undefined) + result[outputName].mirror = changes.mirror; + } + return normalizeOutputPositions(result); + } + + function backendUpdateOutputPosition(outputName, x, y) { + if (!outputs || !outputs[outputName]) + return; + const updatedOutputs = {}; + for (const name in outputs) { + const output = outputs[name]; + if (name === outputName && output.logical) { + updatedOutputs[name] = JSON.parse(JSON.stringify(output)); + updatedOutputs[name].logical.x = x; + updatedOutputs[name].logical.y = y; + } else { + updatedOutputs[name] = output; + } + } + outputs = updatedOutputs; + } + + function backendUpdateOutputScale(outputName, scale) { + if (!outputs || !outputs[outputName]) + return; + const updatedOutputs = {}; + for (const name in outputs) { + const output = outputs[name]; + if (name === outputName && output.logical) { + updatedOutputs[name] = JSON.parse(JSON.stringify(output)); + updatedOutputs[name].logical.scale = scale; + } else { + updatedOutputs[name] = output; + } + } + outputs = updatedOutputs; + } + + function getOutputDisplayName(output, outputName) { + if (SettingsData.displayNameMode === "model" && output?.make && output?.model) { + if (CompositorService.isNiri) { + const serial = output.serial || "Unknown"; + return output.make + " " + output.model + " " + serial; + } + return output.make + " " + output.model; + } + return outputName; + } + + function getNiriOutputIdentifier(output, outputName) { + if (SettingsData.displayNameMode === "model" && output?.make && output?.model) { + const serial = output.serial || "Unknown"; + return output.make + " " + output.model + " " + serial; + } + return outputName; + } + + function getNiriSetting(output, outputName, key, defaultValue) { + if (!CompositorService.isNiri) + return defaultValue; + const identifier = getNiriOutputIdentifier(output, outputName); + const pending = pendingNiriChanges[identifier]; + if (pending && pending[key] !== undefined) + return pending[key]; + return SettingsData.getNiriOutputSetting(identifier, key, defaultValue); + } + + function setNiriSetting(output, outputName, key, value) { + if (!CompositorService.isNiri) + return; + initOriginalNiriSettings(); + const identifier = getNiriOutputIdentifier(output, outputName); + const newPending = JSON.parse(JSON.stringify(pendingNiriChanges)); + if (!newPending[identifier]) + newPending[identifier] = {}; + newPending[identifier][key] = value; + pendingNiriChanges = newPending; + } + + function initOriginalNiriSettings() { + if (originalNiriSettings) + return; + originalNiriSettings = JSON.parse(JSON.stringify(SettingsData.niriOutputSettings)); + } + + function getHyprlandOutputIdentifier(output, outputName) { + if (SettingsData.displayNameMode === "model" && output?.make && output?.model) + return "desc:" + output.make + " " + output.model + " " + (output?.serial || "Unknown"); + return outputName; + } + + function getHyprlandSetting(output, outputName, key, defaultValue) { + if (!CompositorService.isHyprland) + return defaultValue; + const identifier = getHyprlandOutputIdentifier(output, outputName); + const pending = pendingHyprlandChanges[identifier]; + if (pending && (key in pending)) { + const val = pending[key]; + return (val !== null && val !== undefined) ? val : defaultValue; + } + return SettingsData.getHyprlandOutputSetting(identifier, key, defaultValue); + } + + function setHyprlandSetting(output, outputName, key, value) { + if (!CompositorService.isHyprland) + return; + initOriginalHyprlandSettings(); + const identifier = getHyprlandOutputIdentifier(output, outputName); + const newPending = JSON.parse(JSON.stringify(pendingHyprlandChanges)); + if (!newPending[identifier]) + newPending[identifier] = {}; + newPending[identifier][key] = value; + pendingHyprlandChanges = newPending; + } + + function initOriginalHyprlandSettings() { + if (originalHyprlandSettings) + return; + originalHyprlandSettings = JSON.parse(JSON.stringify(SettingsData.hyprlandOutputSettings)); + } + + function initOriginalOutputs() { + if (!originalOutputs) + originalOutputs = JSON.parse(JSON.stringify(outputs)); + } + + function setPendingChange(outputName, key, value) { + initOriginalOutputs(); + const newPending = JSON.parse(JSON.stringify(pendingChanges)); + if (!newPending[outputName]) + newPending[outputName] = {}; + newPending[outputName][key] = value; + pendingChanges = newPending; + + if (key === "scale") { + recalculateAdjacentPositions(outputName, value); + backendUpdateOutputScale(outputName, value); + } + } + + function recalculateAdjacentPositions(changedOutput, newScale) { + const output = outputs[changedOutput]; + if (!output?.logical) + return; + const oldPhys = getPhysicalSize(output); + const oldLogicalW = Math.round(oldPhys.w / (output.logical.scale || 1.0)); + const newLogicalW = Math.round(oldPhys.w / newScale); + + const changedX = getPendingValue(changedOutput, "position")?.x ?? output.logical.x; + const changedY = getPendingValue(changedOutput, "position")?.y ?? output.logical.y; + + for (const name in outputs) { + if (name === changedOutput) + continue; + const other = outputs[name]; + if (!other?.logical) + continue; + const otherX = getPendingValue(name, "position")?.x ?? other.logical.x; + const otherY = getPendingValue(name, "position")?.y ?? other.logical.y; + const otherSize = getLogicalSize(other); + const otherRight = otherX + otherSize.w; + + if (Math.abs(changedX - otherRight) < 5) { + const newX = otherRight; + const newPending = JSON.parse(JSON.stringify(pendingChanges)); + if (!newPending[changedOutput]) + newPending[changedOutput] = {}; + newPending[changedOutput].position = { + "x": newX, + "y": changedY + }; + pendingChanges = newPending; + backendUpdateOutputPosition(changedOutput, newX, changedY); + return; + } + + const changedRight = changedX + oldLogicalW; + if (Math.abs(otherX - changedRight) < 5) { + const newOtherX = changedX + newLogicalW; + const newPending = JSON.parse(JSON.stringify(pendingChanges)); + if (!newPending[name]) + newPending[name] = {}; + newPending[name].position = { + "x": newOtherX, + "y": otherY + }; + pendingChanges = newPending; + backendUpdateOutputPosition(name, newOtherX, otherY); + } + } + } + + function getPendingValue(outputName, key) { + if (!pendingChanges[outputName]) + return undefined; + return pendingChanges[outputName][key]; + } + + function getEffectiveValue(outputName, key, originalValue) { + const pending = getPendingValue(outputName, key); + return pending !== undefined ? pending : originalValue; + } + + function clearPendingChanges() { + pendingChanges = {}; + pendingNiriChanges = {}; + pendingHyprlandChanges = {}; + originalOutputs = null; + originalNiriSettings = null; + originalHyprlandSettings = null; + originalDisplayNameMode = ""; + } + + function discardChanges() { + if (originalDisplayNameMode !== "") { + SettingsData.displayNameMode = originalDisplayNameMode; + SettingsData.saveSettings(); + } + backendFetchOutputs(); + clearPendingChanges(); + } + + function applyChanges() { + if (!hasPendingChanges) + return; + const changeDescriptions = []; + + if (formatChanged) { + const formatLabel = SettingsData.displayNameMode === "model" ? I18n.tr("Model") : I18n.tr("Name"); + changeDescriptions.push(I18n.tr("Config Format") + " → " + formatLabel); + } + + for (const outputName in pendingChanges) { + const changes = pendingChanges[outputName]; + if (changes.position) + changeDescriptions.push(outputName + ": " + I18n.tr("Position") + " → " + changes.position.x + ", " + changes.position.y); + if (changes.mode) + changeDescriptions.push(outputName + ": " + I18n.tr("Mode") + " → " + changes.mode); + if (changes.scale !== undefined) + changeDescriptions.push(outputName + ": " + I18n.tr("Scale") + " → " + changes.scale); + if (changes.transform) + changeDescriptions.push(outputName + ": " + I18n.tr("Transform") + " → " + getTransformLabel(changes.transform)); + if (changes.vrr !== undefined) + changeDescriptions.push(outputName + ": " + I18n.tr("VRR") + " → " + (changes.vrr ? I18n.tr("Enabled") : I18n.tr("Disabled"))); + } + + for (const outputId in pendingNiriChanges) { + const changes = pendingNiriChanges[outputId]; + if (changes.disabled !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Disabled") + " → " + (changes.disabled ? I18n.tr("Yes") : I18n.tr("No"))); + if (changes.vrrOnDemand !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("VRR On-Demand") + " → " + (changes.vrrOnDemand ? I18n.tr("Enabled") : I18n.tr("Disabled"))); + if (changes.focusAtStartup !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Focus at Startup") + " → " + (changes.focusAtStartup ? I18n.tr("Yes") : I18n.tr("No"))); + if (changes.hotCorners !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Hot Corners") + " → " + I18n.tr("Modified")); + if (changes.layout !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Layout") + " → " + I18n.tr("Modified")); + } + + for (const outputId in pendingHyprlandChanges) { + const changes = pendingHyprlandChanges[outputId]; + if (changes.disabled !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Disabled") + " → " + (changes.disabled ? I18n.tr("Yes") : I18n.tr("No"))); + if (changes.bitdepth !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Bit Depth") + " → " + changes.bitdepth); + if (changes.colorManagement !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Color Management") + " → " + changes.colorManagement); + if (changes.sdrBrightness !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("SDR Brightness") + " → " + changes.sdrBrightness); + if (changes.sdrSaturation !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("SDR Saturation") + " → " + changes.sdrSaturation); + if (changes.supportsHdr !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Force HDR") + " → " + (changes.supportsHdr ? I18n.tr("Yes") : I18n.tr("No"))); + if (changes.supportsWideColor !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("Force Wide Color") + " → " + (changes.supportsWideColor ? I18n.tr("Yes") : I18n.tr("No"))); + if (changes.vrrFullscreenOnly !== undefined) + changeDescriptions.push(outputId + ": " + I18n.tr("VRR Fullscreen Only") + " → " + (changes.vrrFullscreenOnly ? I18n.tr("Enabled") : I18n.tr("Disabled"))); + } + + if (CompositorService.isNiri) { + validateAndApplyNiriConfig(changeDescriptions); + return; + } + + changesApplied(changeDescriptions); + + if (formatChanged) + SettingsData.saveSettings(); + + if (CompositorService.isHyprland) + commitHyprlandSettingsChanges(); + + const mergedOutputs = buildOutputsWithPendingChanges(); + backendWriteOutputsConfig(mergedOutputs); + } + + function validateAndApplyNiriConfig(changeDescriptions) { + validatingConfig = true; + validationError = ""; + + const mergedOutputs = buildOutputsWithPendingChanges(); + const mergedNiriSettings = buildMergedNiriSettings(); + const configContent = generateNiriOutputsKdl(mergedOutputs, mergedNiriSettings); + + const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + const tempFile = configDir + "/niri/dms/.outputs-validate-tmp.kdl"; + + Proc.runCommand("niri-validate-write-tmp", ["sh", "-c", `mkdir -p "$(dirname "${tempFile}")" && cat > "${tempFile}" << 'EOF'\n${configContent}EOF`], (output, writeExitCode) => { + if (writeExitCode !== 0) { + validatingConfig = false; + validationError = I18n.tr("Failed to write temp file for validation"); + ToastService.showError(I18n.tr("Config validation failed"), validationError, "", "display-config"); + return; + } + Proc.runCommand("niri-validate-config", ["sh", "-c", `niri validate -c "${tempFile}" 2>&1`], (validateOutput, validateExitCode) => { + validatingConfig = false; + Proc.runCommand("niri-validate-cleanup", ["rm", "-f", tempFile], () => {}); + if (validateExitCode !== 0) { + validationError = validateOutput.trim() || I18n.tr("Invalid configuration"); + ToastService.showError(I18n.tr("Config validation failed"), validationError, "", "display-config"); + return; + } + changesApplied(changeDescriptions); + if (formatChanged) + SettingsData.saveSettings(); + commitNiriSettingsChanges(); + backendWriteOutputsConfig(mergedOutputs); + }); + }); + } + + function buildMergedNiriSettings() { + const merged = JSON.parse(JSON.stringify(SettingsData.niriOutputSettings)); + for (const outputId in pendingNiriChanges) { + if (!merged[outputId]) + merged[outputId] = {}; + for (const key in pendingNiriChanges[outputId]) { + merged[outputId][key] = pendingNiriChanges[outputId][key]; + } + } + return merged; + } + + function commitNiriSettingsChanges() { + for (const outputId in pendingNiriChanges) { + for (const key in pendingNiriChanges[outputId]) { + SettingsData.setNiriOutputSetting(outputId, key, pendingNiriChanges[outputId][key]); + } + } + } + + function buildMergedHyprlandSettings() { + const merged = JSON.parse(JSON.stringify(SettingsData.hyprlandOutputSettings)); + for (const outputId in pendingHyprlandChanges) { + if (!merged[outputId]) + merged[outputId] = {}; + for (const key in pendingHyprlandChanges[outputId]) { + const val = pendingHyprlandChanges[outputId][key]; + if (val === null || val === undefined) + delete merged[outputId][key]; + else + merged[outputId][key] = val; + } + } + return merged; + } + + function commitHyprlandSettingsChanges() { + for (const outputId in pendingHyprlandChanges) { + for (const key in pendingHyprlandChanges[outputId]) { + const val = pendingHyprlandChanges[outputId][key]; + if (val === null || val === undefined) + SettingsData.removeHyprlandOutputSetting(outputId, key); + else + SettingsData.setHyprlandOutputSetting(outputId, key, val); + } + } + } + + function generateNiriOutputsKdl(outputsData, niriSettings) { + let kdlContent = `// Auto-generated by DMS - do not edit manually\n\n`; + const sortedNames = Object.keys(outputsData).sort((a, b) => { + const la = outputsData[a].logical || {}; + const lb = outputsData[b].logical || {}; + return (la.x ?? 0) - (lb.x ?? 0) || (la.y ?? 0) - (lb.y ?? 0); + }); + for (const outputName of sortedNames) { + const output = outputsData[outputName]; + const identifier = getNiriOutputIdentifier(output, outputName); + const settings = niriSettings[identifier] || {}; + kdlContent += `output "${identifier}" {\n`; + if (settings.disabled) { + kdlContent += ` off\n}\n\n`; + continue; + } + if (output.current_mode !== undefined && output.modes && output.modes[output.current_mode]) { + const mode = output.modes[output.current_mode]; + kdlContent += ` mode "${mode.width}x${mode.height}@${(mode.refresh_rate / 1000).toFixed(3)}"\n`; + } + if (output.logical) { + kdlContent += ` scale ${output.logical.scale ?? 1.0}\n`; + if (output.logical.transform && output.logical.transform !== "Normal") { + const transformMap = { + "Normal": "normal", + "90": "90", + "180": "180", + "270": "270", + "Flipped": "flipped", + "Flipped90": "flipped-90", + "Flipped180": "flipped-180", + "Flipped270": "flipped-270" + }; + kdlContent += ` transform "${transformMap[output.logical.transform] || "normal"}"\n`; + } + if (output.logical.x !== undefined && output.logical.y !== undefined) + kdlContent += ` position x=${output.logical.x} y=${output.logical.y}\n`; + } + if (settings.vrrOnDemand) { + kdlContent += ` variable-refresh-rate on-demand=true\n`; + } else if (output.vrr_enabled) { + kdlContent += ` variable-refresh-rate\n`; + } + if (settings.focusAtStartup) + kdlContent += ` focus-at-startup\n`; + if (settings.backdropColor) + kdlContent += ` backdrop-color "${settings.backdropColor}"\n`; + kdlContent += generateHotCornersBlock(settings); + kdlContent += generateLayoutBlock(settings); + kdlContent += `}\n\n`; + } + return kdlContent; + } + + function generateHotCornersBlock(settings) { + if (!settings.hotCorners) + return ""; + const hc = settings.hotCorners; + if (hc.off) + return ` hot-corners {\n off\n }\n`; + const corners = hc.corners || []; + if (corners.length === 0) + return ""; + let block = ` hot-corners {\n`; + for (const corner of corners) + block += ` ${corner}\n`; + block += ` }\n`; + return block; + } + + function generateLayoutBlock(settings) { + if (!settings.layout) + return ""; + const layout = settings.layout; + const hasSettings = layout.gaps !== undefined || layout.defaultColumnWidth || layout.presetColumnWidths || layout.alwaysCenterSingleColumn !== undefined; + if (!hasSettings) + return ""; + let block = ` layout {\n`; + if (layout.gaps !== undefined) + block += ` gaps ${layout.gaps}\n`; + if (layout.defaultColumnWidth?.type === "proportion") { + const val = layout.defaultColumnWidth.value; + const formatted = Number.isInteger(val) ? val.toFixed(1) : val.toString(); + block += ` default-column-width { proportion ${formatted}; }\n`; + } + if (layout.presetColumnWidths && layout.presetColumnWidths.length > 0) { + block += ` preset-column-widths {\n`; + for (const preset of layout.presetColumnWidths) { + if (preset.type === "proportion") { + const val = preset.value; + const formatted = Number.isInteger(val) ? val.toFixed(1) : val.toString(); + block += ` proportion ${formatted}\n`; + } + } + block += ` }\n`; + } + if (layout.alwaysCenterSingleColumn !== undefined) + block += layout.alwaysCenterSingleColumn ? ` always-center-single-column\n` : ` always-center-single-column false\n`; + block += ` }\n`; + return block; + } + + function confirmChanges() { + clearPendingChanges(); + changesConfirmed(); + } + + function revertChanges() { + const hadFormatChange = originalDisplayNameMode !== ""; + const hadNiriChanges = originalNiriSettings !== null; + const hadHyprlandChanges = originalHyprlandSettings !== null; + + if (hadFormatChange) { + SettingsData.displayNameMode = originalDisplayNameMode; + SettingsData.saveSettings(); + } + + if (hadNiriChanges) { + SettingsData.niriOutputSettings = JSON.parse(JSON.stringify(originalNiriSettings)); + SettingsData.saveSettings(); + } + + if (hadHyprlandChanges) { + SettingsData.hyprlandOutputSettings = JSON.parse(JSON.stringify(originalHyprlandSettings)); + SettingsData.saveSettings(); + } + + pendingHyprlandChanges = {}; + pendingNiriChanges = {}; + + if (!originalOutputs && !hadNiriChanges && !hadHyprlandChanges) { + if (hadFormatChange) + backendWriteOutputsConfig(buildOutputsWithPendingChanges()); + clearPendingChanges(); + changesReverted(); + return; + } + + const original = originalOutputs ? JSON.parse(JSON.stringify(originalOutputs)) : buildOutputsWithPendingChanges(); + for (const name in savedOutputs) { + if (!original[name]) + original[name] = JSON.parse(JSON.stringify(savedOutputs[name])); + } + backendWriteOutputsConfig(original); + clearPendingChanges(); + if (originalOutputs) + outputs = original; + changesReverted(); + } + + function getOutputBounds() { + if (!allOutputs || Object.keys(allOutputs).length === 0) + return { + "minX": 0, + "minY": 0, + "maxX": 1920, + "maxY": 1080, + "width": 1920, + "height": 1080 + }; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + for (const name in allOutputs) { + const output = allOutputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + size.w); + maxY = Math.max(maxY, y + size.h); + } + + if (minX === Infinity) + return { + "minX": 0, + "minY": 0, + "maxX": 1920, + "maxY": 1080, + "width": 1920, + "height": 1080 + }; + return { + "minX": minX, + "minY": minY, + "maxX": maxX, + "maxY": maxY, + "width": maxX - minX, + "height": maxY - minY + }; + } + + function isRotated(transform) { + switch (transform) { + case "90": + case "270": + case "Flipped90": + case "Flipped270": + return true; + default: + return false; + } + } + + function getPhysicalSize(output) { + if (!output) + return { + "w": 1920, + "h": 1080 + }; + + let w = 1920, h = 1080; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) { + w = mode.width || 1920; + h = mode.height || 1080; + } + } else if (output.logical) { + const scale = output.logical.scale || 1.0; + w = Math.round((output.logical.width || 1920) * scale); + h = Math.round((output.logical.height || 1080) * scale); + } + + if (output.logical && isRotated(output.logical.transform)) + return { + "w": h, + "h": w + }; + return { + "w": w, + "h": h + }; + } + + function getLogicalSize(output) { + if (!output) + return { + "w": 1920, + "h": 1080 + }; + + const phys = getPhysicalSize(output); + const scale = output.logical?.scale || 1.0; + + return { + "w": Math.round(phys.w / scale), + "h": Math.round(phys.h / scale) + }; + } + + function checkOverlap(testName, testX, testY, testW, testH) { + for (const name in outputs) { + if (name === testName) + continue; + const output = outputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + if (!(testX + testW <= x || testX >= x + size.w || testY + testH <= y || testY >= y + size.h)) + return true; + } + return false; + } + + function snapToEdges(testName, posX, posY, testW, testH) { + const snapThreshold = 200; + let snappedX = posX; + let snappedY = posY; + let bestXDist = snapThreshold; + let bestYDist = snapThreshold; + + for (const name in outputs) { + if (name === testName) + continue; + const output = outputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + + const rightEdge = x + size.w; + const bottomEdge = y + size.h; + const testRight = posX + testW; + const testBottom = posY + testH; + + const xSnaps = [ + { + "val": rightEdge, + "dist": Math.abs(posX - rightEdge) + }, + { + "val": x - testW, + "dist": Math.abs(testRight - x) + }, + { + "val": x, + "dist": Math.abs(posX - x) + }, + { + "val": rightEdge - testW, + "dist": Math.abs(testRight - rightEdge) + } + ]; + + const ySnaps = [ + { + "val": bottomEdge, + "dist": Math.abs(posY - bottomEdge) + }, + { + "val": y - testH, + "dist": Math.abs(testBottom - y) + }, + { + "val": y, + "dist": Math.abs(posY - y) + }, + { + "val": bottomEdge - testH, + "dist": Math.abs(testBottom - bottomEdge) + } + ]; + + for (const snap of xSnaps) { + if (snap.dist < bestXDist) { + bestXDist = snap.dist; + snappedX = snap.val; + } + } + + for (const snap of ySnaps) { + if (snap.dist < bestYDist) { + bestYDist = snap.dist; + snappedY = snap.val; + } + } + } + + if (checkOverlap(testName, snappedX, snappedY, testW, testH)) { + if (!checkOverlap(testName, snappedX, posY, testW, testH)) + return Qt.point(snappedX, posY); + if (!checkOverlap(testName, posX, snappedY, testW, testH)) + return Qt.point(posX, snappedY); + return Qt.point(posX, posY); + } + return Qt.point(snappedX, snappedY); + } + + function findBestSnapPosition(testName, posX, posY, testW, testH) { + const outputNames = Object.keys(outputs).filter(n => n !== testName); + + if (outputNames.length === 0) + return Qt.point(posX, posY); + + let bestPos = null; + let bestDist = Infinity; + + for (const name of outputNames) { + const output = outputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + + const candidates = [ + { + "px": x + size.w, + "py": y + }, + { + "px": x - testW, + "py": y + }, + { + "px": x, + "py": y + size.h + }, + { + "px": x, + "py": y - testH + }, + { + "px": x + size.w, + "py": y + size.h - testH + }, + { + "px": x - testW, + "py": y + size.h - testH + }, + { + "px": x + size.w - testW, + "py": y + size.h + }, + { + "px": x + size.w - testW, + "py": y - testH + } + ]; + + for (const c of candidates) { + if (checkOverlap(testName, c.px, c.py, testW, testH)) + continue; + const dist = Math.hypot(c.px - posX, c.py - posY); + if (dist < bestDist) { + bestDist = dist; + bestPos = Qt.point(c.px, c.py); + } + } + } + + return bestPos || Qt.point(posX, posY); + } + + function formatMode(mode) { + if (!mode) + return ""; + return mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3); + } + + function getTransformLabel(transform) { + switch (transform) { + case "Normal": + return I18n.tr("Normal"); + case "90": + return I18n.tr("90°"); + case "180": + return I18n.tr("180°"); + case "270": + return I18n.tr("270°"); + case "Flipped": + return I18n.tr("Flipped"); + case "Flipped90": + return I18n.tr("Flipped 90°"); + case "Flipped180": + return I18n.tr("Flipped 180°"); + case "Flipped270": + return I18n.tr("Flipped 270°"); + default: + return I18n.tr("Normal"); + } + } + + function getTransformValue(label) { + if (label === I18n.tr("Normal")) + return "Normal"; + if (label === I18n.tr("90°")) + return "90"; + if (label === I18n.tr("180°")) + return "180"; + if (label === I18n.tr("270°")) + return "270"; + if (label === I18n.tr("Flipped")) + return "Flipped"; + if (label === I18n.tr("Flipped 90°")) + return "Flipped90"; + if (label === I18n.tr("Flipped 180°")) + return "Flipped180"; + if (label === I18n.tr("Flipped 270°")) + return "Flipped270"; + return "Normal"; + } + + function setOriginalDisplayNameMode(mode) { + if (originalDisplayNameMode === "") + originalDisplayNameMode = mode; + } +} |