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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user