1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 21:02:06 -04:00

displays: support for multiple output profiles

- add support for deleting unplugged configs
- Option to hide disconnected displays
fixes #1453
This commit is contained in:
bbedward
2026-01-28 20:51:29 -05:00
parent 2deeab9d08
commit 36b43f93a3
7 changed files with 818 additions and 12 deletions

View File

@@ -36,9 +36,303 @@ Singleton {
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();
profilesLoading = true;
Proc.runCommand("delete-profile", ["rm", "-f", profileFile], (output, exitCode) => {
profilesLoading = false;
SettingsData.removeDisplayProfile(compositor, profileId);
if (SettingsData.getActiveDisplayProfile(compositor) === profileId)
SettingsData.setActiveDisplayProfile(compositor, "");
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 = {};
@@ -55,7 +349,14 @@ Singleton {
return result;
}
onOutputsChanged: allOutputs = buildAllOutputsMap()
onOutputsChanged: {
allOutputs = buildAllOutputsMap();
currentOutputSet = buildCurrentOutputSet();
matchedProfile = findMatchingProfile();
// ! TODO - auto profile switching disabled for now
// if (SettingsData.displayProfileAutoSelect)
// Qt.callLater(autoSelectProfile);
}
onSavedOutputsChanged: allOutputs = buildAllOutputsMap()
Connections {
@@ -70,6 +371,8 @@ Singleton {
outputs = buildOutputsMap();
reloadSavedOutputs();
checkIncludeStatus();
currentOutputSet = buildCurrentOutputSet();
validateProfiles();
}
function reloadSavedOutputs() {