diff options
| author | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
|---|---|---|
| committer | AlexanderCurl <alexc@alexc.hu> | 2026-04-18 17:46:06 +0100 |
| commit | 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 (patch) | |
| tree | ab1123d4067c1b086dd6faa7ee4ea643236b565a /raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig | |
| parent | 5d94c0a7d44a2255b81815a52a7056a94a39842d (diff) | |
| download | RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.tar.gz RaveOS-PKGBUILD-70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69.zip | |
Replaced file structures for themes in the correct format
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig')
8 files changed, 3262 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml new file mode 100644 index 0000000..b6e253b --- /dev/null +++ b/raveos-hyprland-theme/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; + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml new file mode 100644 index 0000000..7c36e4b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/HyprlandOutputSettings.qml @@ -0,0 +1,310 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Column { + id: root + + property string outputName: "" + property var outputData: null + property bool expanded: false + + width: parent.width + spacing: 0 + + Rectangle { + width: parent.width + height: headerRow.implicitHeight + Theme.spacingS * 2 + color: headerMouse.containsMouse ? Theme.withAlpha(Theme.primary, 0.1) : "transparent" + radius: Theme.cornerRadius / 2 + + Row { + id: headerRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: root.expanded ? "expand_more" : "chevron_right" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Compositor Settings") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: headerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.expanded = !root.expanded + } + } + + Column { + id: settingsColumn + width: parent.width + spacing: Theme.spacingS + visible: root.expanded + topPadding: Theme.spacingS + + property int currentBitdepth: { + DisplayConfigState.pendingHyprlandChanges; + return DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "bitdepth", 8); + } + property bool is10Bit: currentBitdepth === 10 + + property string currentCm: { + DisplayConfigState.pendingHyprlandChanges; + return DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "colorManagement", "auto"); + } + property bool isHdrMode: currentCm === "hdr" || currentCm === "hdredid" + + DankToggle { + width: parent.width + text: I18n.tr("Disable Output") + checked: DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "disabled", false) + onToggled: checked => DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "disabled", checked) + } + + DankDropdown { + width: parent.width + text: I18n.tr("Mirror Display") + addHorizontalPadding: true + + property var otherOutputs: { + const list = [I18n.tr("None")]; + for (const name in DisplayConfigState.outputs) { + if (name !== root.outputName) + list.push(name); + } + return list; + } + options: otherOutputs + + currentValue: { + DisplayConfigState.pendingChanges; + const pending = DisplayConfigState.getPendingValue(root.outputName, "mirror"); + const val = pending !== undefined ? pending : (root.outputData.mirror || ""); + return val === "" ? I18n.tr("None") : val; + } + + onValueChanged: value => { + const realVal = value === I18n.tr("None") ? "" : value; + DisplayConfigState.setPendingChange(root.outputName, "mirror", realVal); + } + } + + DankToggle { + width: parent.width + text: I18n.tr("10-bit Color") + description: I18n.tr("Enable 10-bit color depth for wider color gamut and HDR support") + checked: settingsColumn.is10Bit + onToggled: checked => { + if (checked) { + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "bitdepth", 10); + } else { + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "bitdepth", null); + if (settingsColumn.isHdrMode) + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "colorManagement", "auto"); + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + visible: settingsColumn.is10Bit + + Rectangle { + width: parent.width + height: 1 + color: Theme.withAlpha(Theme.outline, 0.15) + } + + DankDropdown { + width: parent.width + text: I18n.tr("Color Gamut") + addHorizontalPadding: true + currentValue: { + DisplayConfigState.pendingHyprlandChanges; + const val = DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "colorManagement", "auto"); + return cmLabelMap[val] || I18n.tr("Auto (Wide)"); + } + options: [I18n.tr("Auto (Wide)"), I18n.tr("Wide (BT2020)"), "DCI-P3", "Apple P3", "Adobe RGB", "EDID", "HDR", I18n.tr("HDR (EDID)")] + + property var cmValueMap: ({ + [I18n.tr("Auto (Wide)")]: "auto", + [I18n.tr("Wide (BT2020)")]: "wide", + "DCI-P3": "dcip3", + "Apple P3": "dp3", + "Adobe RGB": "adobe", + "EDID": "edid", + "HDR": "hdr", + [I18n.tr("HDR (EDID)")]: "hdredid" + }) + + property var cmLabelMap: ({ + "auto": I18n.tr("Auto (Wide)"), + "wide": I18n.tr("Wide (BT2020)"), + "dcip3": "DCI-P3", + "dp3": "Apple P3", + "adobe": "Adobe RGB", + "edid": "EDID", + "hdr": "HDR", + "hdredid": I18n.tr("HDR (EDID)") + }) + + onValueChanged: value => { + const cmValue = cmValueMap[value] || "auto"; + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "colorManagement", cmValue); + } + } + + Rectangle { + width: parent.width - Theme.spacingM * 2 + anchors.horizontalCenter: parent.horizontalCenter + height: warningColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius / 2 + color: Theme.withAlpha(Theme.warning, 0.15) + border.color: Theme.withAlpha(Theme.warning, 0.3) + border.width: 1 + visible: settingsColumn.isHdrMode + + Column { + id: warningColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingXS + + Row { + spacing: Theme.spacingS + DankIcon { + name: "warning" + size: Theme.iconSize - 4 + color: Theme.warning + anchors.verticalCenter: parent.verticalCenter + } + StyledText { + text: I18n.tr("Experimental Feature") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.warning + anchors.verticalCenter: parent.verticalCenter + } + } + StyledText { + text: I18n.tr("HDR mode is experimental. Verify your monitor supports HDR before enabling.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + visible: settingsColumn.isHdrMode + + Rectangle { + width: parent.width + height: 1 + color: Theme.withAlpha(Theme.outline, 0.15) + } + + StyledText { + text: I18n.tr("HDR Tone Mapping") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + leftPadding: Theme.spacingM + } + + Row { + width: parent.width - Theme.spacingM * 2 + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingM + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("SDR Brightness") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + width: parent.width + height: 40 + placeholderText: "1.0 - 2.0" + text: { + DisplayConfigState.pendingHyprlandChanges; + const val = DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "sdrBrightness", null); + return val !== null ? val.toString() : ""; + } + onEditingFinished: { + const trimmed = text.trim(); + if (!trimmed) { + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "sdrBrightness", null); + return; + } + const val = parseFloat(trimmed); + if (isNaN(val) || val < 0.1 || val > 5.0) + return; + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "sdrBrightness", parseFloat(val.toFixed(2))); + } + } + } + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("SDR Saturation") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + width: parent.width + height: 40 + placeholderText: "0.5 - 1.5" + text: { + DisplayConfigState.pendingHyprlandChanges; + const val = DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "sdrSaturation", null); + return val !== null ? val.toString() : ""; + } + onEditingFinished: { + const trimmed = text.trim(); + if (!trimmed) { + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "sdrSaturation", null); + return; + } + const val = parseFloat(trimmed); + if (isNaN(val) || val < 0.0 || val > 3.0) + return; + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "sdrSaturation", parseFloat(val.toFixed(2))); + } + } + } + } + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml new file mode 100644 index 0000000..f5f270b --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/IncludeWarningBox.qml @@ -0,0 +1,94 @@ +import QtQuick +import qs.Common +import qs.Widgets + +StyledRect { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + width: parent.width + height: warningContent.implicitHeight + Theme.spacingL * 2 + radius: Theme.cornerRadius + + readonly property bool showError: DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included + readonly property bool showSetup: !DisplayConfigState.includeStatus.exists && !DisplayConfigState.includeStatus.included + + color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent" + border.color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" + border.width: 1 + visible: (showError || showSetup) && DisplayConfigState.hasOutputBackend && !DisplayConfigState.checkingInclude + + Column { + id: warningContent + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "warning" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - (fixButton.visible ? fixButton.width + Theme.spacingM : 0) - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: { + if (root.showSetup) + return I18n.tr("First Time Setup"); + if (root.showError) + return I18n.tr("Outputs Include Missing"); + return ""; + } + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.primary + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: { + if (root.showSetup) + return I18n.tr("Click 'Setup' to create the outputs config and add include to your compositor config."); + if (root.showError) + return I18n.tr("dms/outputs config exists but is not included in your compositor config. Display changes won't persist."); + return ""; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + + DankButton { + id: fixButton + visible: root.showError || root.showSetup + text: { + if (DisplayConfigState.fixingInclude) + return I18n.tr("Fixing..."); + if (root.showSetup) + return I18n.tr("Setup"); + return I18n.tr("Fix Now"); + } + backgroundColor: Theme.primary + textColor: Theme.primaryText + enabled: !DisplayConfigState.fixingInclude + anchors.verticalCenter: parent.verticalCenter + onClicked: DisplayConfigState.fixOutputsInclude() + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/MonitorCanvas.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/MonitorCanvas.qml new file mode 100644 index 0000000..accdfd7 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/MonitorCanvas.qml @@ -0,0 +1,82 @@ +import QtQuick +import qs.Common + +Rectangle { + id: root + + property var filteredOutputs: { + const all = DisplayConfigState.allOutputs || {}; + const keys = Object.keys(all); + if (SettingsData.displayShowDisconnected) + return keys; + return keys.filter(k => all[k]?.connected); + } + + property var filteredBounds: { + const all = DisplayConfigState.allOutputs || {}; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const name of filteredOutputs) { + const output = all[name]; + if (!output?.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = DisplayConfigState.getLogicalSize(output); + const w = size.w || 1920; + const h = size.h || 1080; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + } + if (minX === Infinity) + return { + minX: 0, + minY: 0, + width: 1920, + height: 1080 + }; + return { + minX: minX, + minY: minY, + width: maxX - minX, + height: maxY - minY + }; + } + + width: parent.width + height: 280 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHighest + border.color: Theme.outline + border.width: 1 + + Item { + id: canvas + anchors.fill: parent + anchors.margins: Theme.spacingL + + property var bounds: root.filteredBounds + property real scaleFactor: { + if (bounds.width === 0 || bounds.height === 0) + return 0.1; + const padding = Theme.spacingL * 2; + const scaleX = (width - padding) / bounds.width; + const scaleY = (height - padding) / bounds.height; + return Math.min(scaleX, scaleY); + } + property point offset: Qt.point((width - bounds.width * scaleFactor) / 2 - bounds.minX * scaleFactor, (height - bounds.height * scaleFactor) / 2 - bounds.minY * scaleFactor) + + Repeater { + model: root.filteredOutputs + + delegate: MonitorRect { + required property string modelData + outputName: modelData + outputData: DisplayConfigState.allOutputs[modelData] + canvasScaleFactor: canvas.scaleFactor + canvasOffset: canvas.offset + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/MonitorRect.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/MonitorRect.qml new file mode 100644 index 0000000..0209e5f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/MonitorRect.qml @@ -0,0 +1,159 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Rectangle { + id: root + + required property string outputName + required property var outputData + required property real canvasScaleFactor + required property point canvasOffset + + property bool isConnected: outputData?.connected ?? false + property bool isDragging: false + property point originalLogical: Qt.point(0, 0) + property point snappedLogical: Qt.point(0, 0) + property bool isValidPosition: true + + property var physSize: DisplayConfigState.getPhysicalSize(outputData) + property var logicalSize: DisplayConfigState.getLogicalSize(outputData) + + x: isDragging ? x : (outputData?.logical?.x ?? 0) * canvasScaleFactor + canvasOffset.x + y: isDragging ? y : (outputData?.logical?.y ?? 0) * canvasScaleFactor + canvasOffset.y + width: logicalSize.w * canvasScaleFactor + height: logicalSize.h * canvasScaleFactor + radius: Theme.cornerRadius + opacity: isConnected ? 1.0 : 0.5 + + color: { + if (!isConnected) + return Theme.surfaceContainerHighest; + if (!isValidPosition) + return Theme.withAlpha(Theme.error, 0.3); + if (isDragging) + return Theme.withAlpha(Theme.primary, 0.4); + if (dragArea.containsMouse) + return Theme.withAlpha(Theme.primary, 0.2); + return Theme.surfaceContainerHigh; + } + + border.color: { + if (!isConnected) + return Theme.outline; + if (!isValidPosition) + return Theme.error; + if (isDragging) + return Theme.primary; + if (CompositorService.getFocusedScreen()?.name === outputName) + return Theme.primary; + return Theme.outline; + } + border.width: isDragging ? 3 : 2 + z: isDragging ? 100 : (isConnected ? 1 : 0) + + Rectangle { + id: snapPreview + visible: root.isDragging && root.isValidPosition && SettingsData.displaySnapToEdge + x: root.snappedLogical.x * root.canvasScaleFactor + root.canvasOffset.x - root.x + y: root.snappedLogical.y * root.canvasScaleFactor + root.canvasOffset.y - root.y + width: parent.width + height: parent.height + radius: Theme.cornerRadius + color: "transparent" + border.color: Theme.primary + border.width: 2 + opacity: 0.6 + } + + Column { + anchors.centerIn: parent + spacing: 2 + + DankIcon { + name: root.isConnected ? "desktop_windows" : "desktop_access_disabled" + size: Math.min(24, Math.min(root.width * 0.3, root.height * 0.25)) + color: root.isConnected ? (root.isValidPosition ? Theme.primary : Theme.error) : Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: DisplayConfigState.getOutputDisplayName(root.outputData, root.outputName) + font.pixelSize: Math.max(10, Math.min(14, root.width * 0.12)) + font.weight: Font.Medium + color: root.isConnected ? Theme.surfaceText : Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter + elide: Text.ElideMiddle + width: Math.min(implicitWidth, root.width - 8) + } + + StyledText { + text: root.isConnected ? (root.physSize.w + "x" + root.physSize.h) : I18n.tr("Disconnected") + font.pixelSize: Math.max(8, Math.min(11, root.width * 0.09)) + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + + MouseArea { + id: dragArea + anchors.fill: parent + hoverEnabled: true + enabled: root.isConnected + cursorShape: !root.isConnected ? Qt.ArrowCursor : (root.isDragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor) + drag.target: root.isConnected ? root : null + drag.axis: Drag.XAndYAxis + drag.threshold: 0 + + onPressed: mouse => { + if (!root.isConnected) + return; + root.isDragging = true; + root.originalLogical = Qt.point(root.outputData?.logical?.x ?? 0, root.outputData?.logical?.y ?? 0); + root.snappedLogical = root.originalLogical; + root.isValidPosition = true; + } + + onPositionChanged: mouse => { + if (!root.isDragging || !root.isConnected) + return; + let posX = Math.round((root.x - root.canvasOffset.x) / root.canvasScaleFactor); + let posY = Math.round((root.y - root.canvasOffset.y) / root.canvasScaleFactor); + + const size = DisplayConfigState.getLogicalSize(root.outputData); + + const snapped = SettingsData.displaySnapToEdge ? DisplayConfigState.snapToEdges(root.outputName, posX, posY, size.w, size.h) : Qt.point(posX, posY); + root.snappedLogical = snapped; + root.isValidPosition = SettingsData.displaySnapToEdge ? !DisplayConfigState.checkOverlap(root.outputName, snapped.x, snapped.y, size.w, size.h) : true; + } + + onReleased: { + if (!root.isDragging || !root.isConnected) + return; + root.isDragging = false; + + const size = DisplayConfigState.getLogicalSize(root.outputData); + const finalX = root.snappedLogical.x; + const finalY = root.snappedLogical.y; + + if (SettingsData.displaySnapToEdge && DisplayConfigState.checkOverlap(root.outputName, finalX, finalY, size.w, size.h)) { + root.isValidPosition = true; + return; + } + + if (finalX === root.originalLogical.x && finalY === root.originalLogical.y) + return; + + DisplayConfigState.initOriginalOutputs(); + DisplayConfigState.backendUpdateOutputPosition(root.outputName, finalX, finalY); + DisplayConfigState.setPendingChange(root.outputName, "position", { + "x": finalX, + "y": finalY + }); + } + } + + Drag.active: dragArea.drag.active && root.isConnected +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml new file mode 100644 index 0000000..da9a4a7 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/NiriOutputSettings.qml @@ -0,0 +1,368 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Column { + id: root + + property string outputName: "" + property var outputData: null + property bool expanded: false + + width: parent.width + spacing: 0 + + Rectangle { + width: parent.width + height: headerRow.implicitHeight + Theme.spacingS * 2 + color: headerMouse.containsMouse ? Theme.withAlpha(Theme.primary, 0.1) : "transparent" + radius: Theme.cornerRadius / 2 + + Row { + id: headerRow + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: root.expanded ? "expand_more" : "chevron_right" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: I18n.tr("Compositor Settings") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: headerMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.expanded = !root.expanded + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + visible: root.expanded + topPadding: Theme.spacingS + + DankToggle { + width: parent.width + text: I18n.tr("Disable Output") + checked: DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "disabled", false) + onToggled: checked => DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "disabled", checked) + } + + DankToggle { + width: parent.width + text: I18n.tr("Focus at Startup") + checked: DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "focusAtStartup", false) + onToggled: checked => DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "focusAtStartup", checked) + } + + DankDropdown { + width: parent.width + text: I18n.tr("Hot Corners") + addHorizontalPadding: true + + property var hotCornersData: { + void (DisplayConfigState.pendingNiriChanges); + return DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "hotCorners", null); + } + + currentValue: { + if (!hotCornersData) + return I18n.tr("Inherit"); + if (hotCornersData.off) + return I18n.tr("Off"); + const corners = hotCornersData.corners || []; + if (corners.length === 0) + return I18n.tr("Inherit"); + if (corners.length === 4) + return I18n.tr("All"); + return I18n.tr("Select..."); + } + options: [I18n.tr("Inherit"), I18n.tr("Off"), I18n.tr("All"), I18n.tr("Select...")] + + onValueChanged: value => { + switch (value) { + case I18n.tr("Inherit"): + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "hotCorners", null); + break; + case I18n.tr("Off"): + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "hotCorners", { + "off": true + }); + break; + case I18n.tr("All"): + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "hotCorners", { + "corners": ["top-left", "top-right", "bottom-left", "bottom-right"] + }); + break; + case I18n.tr("Select..."): + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "hotCorners", { + "corners": [] + }); + break; + } + } + } + + Item { + width: parent.width + height: hotCornersGroup.implicitHeight + clip: true + + property var hotCornersData: { + void (DisplayConfigState.pendingNiriChanges); + return DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "hotCorners", null); + } + + visible: hotCornersData && !hotCornersData.off && hotCornersData.corners !== undefined + + DankButtonGroup { + id: hotCornersGroup + anchors.horizontalCenter: parent.horizontalCenter + selectionMode: "multi" + checkEnabled: false + buttonHeight: 32 + buttonPadding: parent.width < 400 ? Theme.spacingXS : Theme.spacingM + minButtonWidth: parent.width < 400 ? 28 : 56 + textSize: parent.width < 400 ? 11 : Theme.fontSizeMedium + model: [I18n.tr("Top Left"), I18n.tr("Top Right"), I18n.tr("Bottom Left"), I18n.tr("Bottom Right")] + + property var cornerKeys: ["top-left", "top-right", "bottom-left", "bottom-right"] + + currentSelection: { + const hcData = parent.hotCornersData; + if (!hcData?.corners) + return []; + return hcData.corners.map(key => { + const idx = cornerKeys.indexOf(key); + return idx >= 0 ? model[idx] : null; + }).filter(v => v !== null); + } + + onSelectionChanged: (index, selected) => { + const corners = currentSelection.map(label => { + const idx = model.indexOf(label); + return idx >= 0 ? cornerKeys[idx] : null; + }).filter(v => v !== null); + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "hotCorners", { + "corners": corners + }); + } + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.withAlpha(Theme.outline, 0.15) + } + + Item { + width: parent.width + height: layoutColumn.implicitHeight + + Column { + id: layoutColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + spacing: Theme.spacingS + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Layout Overrides") + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + color: Theme.surfaceVariantText + } + + StyledText { + text: I18n.tr("Override global layout settings for this output") + font.pixelSize: Theme.fontSizeSmall + color: Theme.withAlpha(Theme.surfaceVariantText, 0.7) + wrapMode: Text.WordWrap + width: parent.width + } + } + + Row { + width: parent.width + spacing: Theme.spacingM + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Window Gaps (px)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + width: parent.width + height: 40 + placeholderText: I18n.tr("Inherit") + text: { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", null); + if (layout?.gaps === undefined) + return ""; + return layout.gaps.toString(); + } + onEditingFinished: { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", {}) || {}; + const trimmed = text.trim(); + if (!trimmed) { + delete layout.gaps; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", Object.keys(layout).length > 0 ? layout : null); + return; + } + const val = parseInt(trimmed); + if (isNaN(val) || val < 0) + return; + layout.gaps = val; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", layout); + } + } + } + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Default Width (%)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + width: parent.width + height: 40 + placeholderText: I18n.tr("Inherit") + text: { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", null); + if (!layout?.defaultColumnWidth) + return ""; + if (layout.defaultColumnWidth.type !== "proportion") + return ""; + const percent = layout.defaultColumnWidth.value * 100; + return parseFloat(percent.toFixed(4)).toString(); + } + onEditingFinished: { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", {}) || {}; + const trimmed = text.trim().replace("%", ""); + if (!trimmed) { + delete layout.defaultColumnWidth; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", Object.keys(layout).length > 0 ? layout : null); + return; + } + const val = parseFloat(trimmed); + if (isNaN(val) || val <= 0 || val > 100) + return; + layout.defaultColumnWidth = { + "type": "proportion", + "value": parseFloat((val / 100).toFixed(6)) + }; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", layout); + } + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Preset Widths (%)") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: "e.g. 33.33, 50, 66.67" + font.pixelSize: Theme.fontSizeSmall + color: Theme.withAlpha(Theme.surfaceVariantText, 0.7) + } + + DankTextField { + width: parent.width + height: 40 + placeholderText: I18n.tr("Inherit") + text: { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", null); + const presets = layout?.presetColumnWidths || []; + if (presets.length === 0) + return ""; + return presets.filter(p => p.type === "proportion").map(p => parseFloat((p.value * 100).toFixed(4))).join(", "); + } + onEditingFinished: { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", {}) || {}; + const trimmed = text.trim(); + if (!trimmed) { + delete layout.presetColumnWidths; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", Object.keys(layout).length > 0 ? layout : null); + return; + } + const parts = trimmed.split(/[,\s]+/).filter(s => s); + const presets = []; + for (const part of parts) { + const val = parseFloat(part.replace("%", "")); + if (!isNaN(val) && val > 0 && val <= 100) + presets.push({ + "type": "proportion", + "value": parseFloat((val / 100).toFixed(6)) + }); + } + if (presets.length === 0) { + delete layout.presetColumnWidths; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", Object.keys(layout).length > 0 ? layout : null); + return; + } + presets.sort((a, b) => a.value - b.value); + layout.presetColumnWidths = presets; + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", layout); + } + } + } + } + } + + DankToggle { + width: parent.width + text: I18n.tr("Center Single Column") + property var layoutData: DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", null) + checked: layoutData?.alwaysCenterSingleColumn ?? false + onToggled: checked => { + const layout = DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "layout", {}) || {}; + if (checked) { + layout.alwaysCenterSingleColumn = true; + } else { + delete layout.alwaysCenterSingleColumn; + } + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "layout", Object.keys(layout).length > 0 ? layout : null); + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/NoBackendMessage.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/NoBackendMessage.qml new file mode 100644 index 0000000..93ab124 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/NoBackendMessage.qml @@ -0,0 +1,60 @@ +import QtQuick +import qs.Common +import qs.Widgets + +StyledRect { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + width: parent.width + height: messageContent.implicitHeight + Theme.spacingL * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: 0 + + Column { + id: messageContent + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "monitor" + size: Theme.iconSize + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Monitor Configuration") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: I18n.tr("Display configuration is not available. WLR output management protocol not supported.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml new file mode 100644 index 0000000..0da1175 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Settings/DisplayConfig/OutputCard.qml @@ -0,0 +1,352 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +StyledRect { + id: root + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + required property string outputName + required property var outputData + property bool isConnected: outputData?.connected ?? false + + width: parent.width + height: settingsColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, isConnected ? 0.5 : 0.3) + border.color: Theme.withAlpha(Theme.outline, 0.3) + border.width: 1 + opacity: isConnected ? 1.0 : 0.7 + + Column { + id: settingsColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: root.isConnected ? "desktop_windows" : "desktop_access_disabled" + size: Theme.iconSize - 4 + color: root.isConnected ? Theme.primary : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM - (disconnectedBadge.visible ? disconnectedBadge.width + deleteButton.width + Theme.spacingS * 2 : 0) + spacing: 2 + + StyledText { + text: DisplayConfigState.getOutputDisplayName(root.outputData, root.outputName) + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: root.isConnected ? Theme.surfaceText : Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + StyledText { + text: (root.outputData?.model ?? "") + (root.outputData?.make ? " - " + root.outputData.make : "") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + } + + Rectangle { + id: disconnectedBadge + visible: !root.isConnected + width: disconnectedText.implicitWidth + Theme.spacingM + height: disconnectedText.implicitHeight + Theme.spacingXS + radius: height / 2 + color: Theme.withAlpha(Theme.outline, 0.3) + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: disconnectedText + text: I18n.tr("Disconnected") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.centerIn: parent + } + } + + Rectangle { + id: deleteButton + visible: !root.isConnected + width: 28 + height: 28 + radius: Theme.cornerRadius + color: deleteArea.containsMouse ? Theme.errorHover : "transparent" + anchors.verticalCenter: parent.verticalCenter + + DankIcon { + anchors.centerIn: parent + name: "delete" + size: 18 + color: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText + } + + MouseArea { + id: deleteArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: DisplayConfigState.deleteDisconnectedOutput(root.outputName) + } + } + } + + DankDropdown { + width: parent.width + text: I18n.tr("Resolution & Refresh") + visible: root.isConnected + currentValue: { + const pendingMode = DisplayConfigState.getPendingValue(root.outputName, "mode"); + if (pendingMode) + return pendingMode; + const data = DisplayConfigState.outputs[root.outputName]; + if (!data?.modes || data?.current_mode === undefined) + return "Auto"; + const mode = data.modes[data.current_mode]; + return mode ? DisplayConfigState.formatMode(mode) : "Auto"; + } + options: { + const data = DisplayConfigState.outputs[root.outputName]; + if (!data?.modes) + return ["Auto"]; + const opts = []; + for (var i = 0; i < data.modes.length; i++) { + opts.push(DisplayConfigState.formatMode(data.modes[i])); + } + return opts; + } + onValueChanged: value => DisplayConfigState.setPendingChange(root.outputName, "mode", value) + } + + StyledText { + visible: !root.isConnected + text: I18n.tr("Configuration will be preserved when this display reconnects") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: root.isConnected + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Scale") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + Item { + id: scaleContainer + width: parent.width + height: scaleDropdown.visible ? scaleDropdown.height : scaleInput.height + + property bool customMode: false + property string currentScale: { + const pendingScale = DisplayConfigState.getPendingValue(root.outputName, "scale"); + if (pendingScale !== undefined) + return parseFloat(pendingScale.toFixed(2)).toString(); + const scale = DisplayConfigState.outputs[root.outputName]?.logical?.scale ?? 1.0; + return parseFloat(scale.toFixed(2)).toString(); + } + + DankDropdown { + id: scaleDropdown + width: parent.width + dropdownWidth: parent.width + visible: !scaleContainer.customMode + currentValue: scaleContainer.currentScale + options: { + const standard = ["0.5", "0.75", "1", "1.25", "1.5", "1.75", "2", "2.5", "3", I18n.tr("Custom...")]; + const current = scaleContainer.currentScale; + if (standard.slice(0, -1).includes(current)) + return standard; + const opts = [...standard.slice(0, -1), current, standard[standard.length - 1]]; + return opts.sort((a, b) => { + if (a === I18n.tr("Custom...")) + return 1; + if (b === I18n.tr("Custom...")) + return -1; + return parseFloat(a) - parseFloat(b); + }); + } + onValueChanged: value => { + if (value === I18n.tr("Custom...")) { + scaleContainer.customMode = true; + scaleInput.text = scaleContainer.currentScale; + scaleInput.forceActiveFocus(); + scaleInput.selectAll(); + return; + } + DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(value)); + } + } + + DankTextField { + id: scaleInput + width: parent.width + height: 40 + visible: scaleContainer.customMode + placeholderText: "0.5 - 4.0" + + function applyValue() { + const val = parseFloat(text); + if (isNaN(val) || val < 0.25 || val > 4) { + text = scaleContainer.currentScale; + scaleContainer.customMode = false; + return; + } + DisplayConfigState.setPendingChange(root.outputName, "scale", parseFloat(val.toFixed(2))); + scaleContainer.customMode = false; + } + + onAccepted: applyValue() + onEditingFinished: applyValue() + Keys.onEscapePressed: { + text = scaleContainer.currentScale; + scaleContainer.customMode = false; + } + } + } + } + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Transform") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + horizontalAlignment: Text.AlignLeft + } + + DankDropdown { + width: parent.width + dropdownWidth: parent.width + currentValue: { + const pendingTransform = DisplayConfigState.getPendingValue(root.outputName, "transform"); + if (pendingTransform) + return DisplayConfigState.getTransformLabel(pendingTransform); + const data = DisplayConfigState.outputs[root.outputName]; + return DisplayConfigState.getTransformLabel(data?.logical?.transform ?? "Normal"); + } + options: [I18n.tr("Normal"), I18n.tr("90°"), I18n.tr("180°"), I18n.tr("270°"), I18n.tr("Flipped"), I18n.tr("Flipped 90°"), I18n.tr("Flipped 180°"), I18n.tr("Flipped 270°")] + onValueChanged: value => DisplayConfigState.setPendingChange(root.outputName, "transform", DisplayConfigState.getTransformValue(value)) + } + } + } + + DankToggle { + width: parent.width + text: I18n.tr("Variable Refresh Rate") + visible: root.isConnected && !CompositorService.isDwl && !CompositorService.isHyprland && !CompositorService.isNiri && (DisplayConfigState.outputs[root.outputName]?.vrr_supported ?? false) + checked: { + const pendingVrr = DisplayConfigState.getPendingValue(root.outputName, "vrr"); + if (pendingVrr !== undefined) + return pendingVrr; + return DisplayConfigState.outputs[root.outputName]?.vrr_enabled ?? false; + } + onToggled: checked => DisplayConfigState.setPendingChange(root.outputName, "vrr", checked) + } + + DankDropdown { + width: parent.width + text: I18n.tr("Variable Refresh Rate") + visible: root.isConnected && CompositorService.isHyprland && (DisplayConfigState.outputs[root.outputName]?.vrr_supported ?? false) + options: [I18n.tr("Off"), I18n.tr("On"), I18n.tr("Fullscreen Only")] + currentValue: { + DisplayConfigState.pendingHyprlandChanges; + if (DisplayConfigState.getHyprlandSetting(root.outputData, root.outputName, "vrrFullscreenOnly", false)) + return I18n.tr("Fullscreen Only"); + const pendingVrr = DisplayConfigState.getPendingValue(root.outputName, "vrr"); + const vrrEnabled = pendingVrr !== undefined ? pendingVrr : (DisplayConfigState.outputs[root.outputName]?.vrr_enabled ?? false); + if (vrrEnabled) + return I18n.tr("On"); + return I18n.tr("Off"); + } + onValueChanged: value => { + const off = I18n.tr("Off"); + const fullscreen = I18n.tr("Fullscreen Only"); + DisplayConfigState.setPendingChange(root.outputName, "vrr", value !== off); + DisplayConfigState.setHyprlandSetting(root.outputData, root.outputName, "vrrFullscreenOnly", value === fullscreen || null); + } + } + + DankDropdown { + width: parent.width + text: I18n.tr("Variable Refresh Rate") + visible: root.isConnected && CompositorService.isNiri && (DisplayConfigState.outputs[root.outputName]?.vrr_supported ?? false) + options: [I18n.tr("Off"), I18n.tr("On"), I18n.tr("On-Demand")] + currentValue: { + DisplayConfigState.pendingNiriChanges; + if (DisplayConfigState.getNiriSetting(root.outputData, root.outputName, "vrrOnDemand", false)) + return I18n.tr("On-Demand"); + const pendingVrr = DisplayConfigState.getPendingValue(root.outputName, "vrr"); + const vrrEnabled = pendingVrr !== undefined ? pendingVrr : (DisplayConfigState.outputs[root.outputName]?.vrr_enabled ?? false); + if (!vrrEnabled) + return I18n.tr("Off"); + return I18n.tr("On"); + } + onValueChanged: value => { + const off = I18n.tr("Off"); + const onDemand = I18n.tr("On-Demand"); + DisplayConfigState.setPendingChange(root.outputName, "vrr", value !== off); + DisplayConfigState.setNiriSetting(root.outputData, root.outputName, "vrrOnDemand", value === onDemand || null); + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.withAlpha(Theme.outline, 0.2) + visible: compositorSettingsLoader.active + } + + Loader { + id: compositorSettingsLoader + width: parent.width + active: root.isConnected && compositorSettingsSource !== "" + source: compositorSettingsSource + + property string compositorSettingsSource: { + switch (CompositorService.compositor) { + case "niri": + return "NiriOutputSettings.qml"; + case "hyprland": + return "HyprlandOutputSettings.qml"; + default: + return ""; + } + } + + onLoaded: { + item.outputName = root.outputName; + item.outputData = root.outputData; + } + } + } +} |