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