mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-14 17:52:10 -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:
@@ -519,6 +519,10 @@ Singleton {
|
|||||||
property var showOnLastDisplay: ({})
|
property var showOnLastDisplay: ({})
|
||||||
property var niriOutputSettings: ({})
|
property var niriOutputSettings: ({})
|
||||||
property var hyprlandOutputSettings: ({})
|
property var hyprlandOutputSettings: ({})
|
||||||
|
property var displayProfiles: ({})
|
||||||
|
property var activeDisplayProfile: ({})
|
||||||
|
property bool displayProfileAutoSelect: false
|
||||||
|
property bool displayShowDisconnected: false
|
||||||
|
|
||||||
property var barConfigs: [
|
property var barConfigs: [
|
||||||
{
|
{
|
||||||
@@ -2256,6 +2260,39 @@ Singleton {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDisplayProfiles(compositor) {
|
||||||
|
return displayProfiles[compositor] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDisplayProfile(compositor, profileId, data) {
|
||||||
|
const updated = JSON.parse(JSON.stringify(displayProfiles));
|
||||||
|
if (!updated[compositor])
|
||||||
|
updated[compositor] = {};
|
||||||
|
updated[compositor][profileId] = data;
|
||||||
|
displayProfiles = updated;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDisplayProfile(compositor, profileId) {
|
||||||
|
if (!displayProfiles[compositor] || !displayProfiles[compositor][profileId])
|
||||||
|
return;
|
||||||
|
const updated = JSON.parse(JSON.stringify(displayProfiles));
|
||||||
|
delete updated[compositor][profileId];
|
||||||
|
displayProfiles = updated;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveDisplayProfile(compositor) {
|
||||||
|
return activeDisplayProfile[compositor] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveDisplayProfile(compositor, profileId) {
|
||||||
|
const updated = JSON.parse(JSON.stringify(activeDisplayProfile));
|
||||||
|
updated[compositor] = profileId;
|
||||||
|
activeDisplayProfile = updated;
|
||||||
|
saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
ListModel {
|
ListModel {
|
||||||
id: leftWidgetsModel
|
id: leftWidgetsModel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -347,6 +347,10 @@ var SPEC = {
|
|||||||
showOnLastDisplay: { def: {} },
|
showOnLastDisplay: { def: {} },
|
||||||
niriOutputSettings: { def: {} },
|
niriOutputSettings: { def: {} },
|
||||||
hyprlandOutputSettings: { def: {} },
|
hyprlandOutputSettings: { def: {} },
|
||||||
|
displayProfiles: { def: {} },
|
||||||
|
activeDisplayProfile: { def: {} },
|
||||||
|
displayProfileAutoSelect: { def: false },
|
||||||
|
displayShowDisconnected: { def: false },
|
||||||
|
|
||||||
barConfigs: {
|
barConfigs: {
|
||||||
def: [{
|
def: [{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Quickshell.Hyprland
|
|||||||
import Quickshell.Wayland
|
import Quickshell.Wayland
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
import qs.Modules.Settings.DisplayConfig
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
@@ -1452,4 +1453,81 @@ Item {
|
|||||||
|
|
||||||
target: "window-rules"
|
target: "window-rules"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
function listProfiles(): string {
|
||||||
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
|
||||||
|
const matchedId = DisplayConfigState.matchedProfile;
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
for (const id in profiles) {
|
||||||
|
const p = profiles[id];
|
||||||
|
const flags = [];
|
||||||
|
if (id === activeId)
|
||||||
|
flags.push("active");
|
||||||
|
if (id === matchedId)
|
||||||
|
flags.push("matched");
|
||||||
|
const flagStr = flags.length > 0 ? " [" + flags.join(",") + "]" : "";
|
||||||
|
lines.push(p.name + flagStr + " -> " + JSON.stringify(p.outputSet));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0)
|
||||||
|
return "No profiles configured";
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProfile(profileName: string): string {
|
||||||
|
if (!profileName)
|
||||||
|
return "ERROR: No profile name specified";
|
||||||
|
|
||||||
|
if (SettingsData.displayProfileAutoSelect)
|
||||||
|
return "ERROR: Auto profile selection is enabled. Use toggleAuto first";
|
||||||
|
|
||||||
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
let profileId = null;
|
||||||
|
|
||||||
|
for (const id in profiles) {
|
||||||
|
if (profiles[id].name === profileName) {
|
||||||
|
profileId = id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileId)
|
||||||
|
return `ERROR: Profile not found: ${profileName}`;
|
||||||
|
|
||||||
|
DisplayConfigState.activateProfile(profileId);
|
||||||
|
return `PROFILE_SET_SUCCESS: ${profileName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ! TODO - auto profile switching is buggy on niri and other compositors
|
||||||
|
function toggleAuto(): string {
|
||||||
|
return "ERROR: Auto profile selection is temporarily disabled due to compositor bugs";
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const auto = "off"; // disabled for now
|
||||||
|
const activeId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
|
||||||
|
const matchedId = DisplayConfigState.matchedProfile;
|
||||||
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
const activeName = profiles[activeId]?.name || "none";
|
||||||
|
const matchedName = profiles[matchedId]?.name || "none";
|
||||||
|
const currentOutputs = JSON.stringify(DisplayConfigState.currentOutputSet);
|
||||||
|
|
||||||
|
return `auto: ${auto}\nactive: ${activeName}\nmatched: ${matchedName}\noutputs: ${currentOutputs}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function current(): string {
|
||||||
|
return JSON.stringify(DisplayConfigState.currentOutputSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): string {
|
||||||
|
DisplayConfigState.currentOutputSet = DisplayConfigState.buildCurrentOutputSet();
|
||||||
|
DisplayConfigState.validateProfiles();
|
||||||
|
return "Refreshed output state";
|
||||||
|
}
|
||||||
|
|
||||||
|
target: "outputs"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,303 @@ Singleton {
|
|||||||
property bool validatingConfig: false
|
property bool validatingConfig: false
|
||||||
property string validationError: ""
|
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 changesApplied(var changeDescriptions)
|
||||||
signal changesConfirmed
|
signal changesConfirmed
|
||||||
signal changesReverted
|
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() {
|
function buildAllOutputsMap() {
|
||||||
const result = {};
|
const result = {};
|
||||||
@@ -55,7 +349,14 @@ Singleton {
|
|||||||
return result;
|
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()
|
onSavedOutputsChanged: allOutputs = buildAllOutputsMap()
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
@@ -70,6 +371,8 @@ Singleton {
|
|||||||
outputs = buildOutputsMap();
|
outputs = buildOutputsMap();
|
||||||
reloadSavedOutputs();
|
reloadSavedOutputs();
|
||||||
checkIncludeStatus();
|
checkIncludeStatus();
|
||||||
|
currentOutputSet = buildCurrentOutputSet();
|
||||||
|
validateProfiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadSavedOutputs() {
|
function reloadSavedOutputs() {
|
||||||
|
|||||||
@@ -4,6 +4,45 @@ import qs.Common
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
id: root
|
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 w = output.logical.width || 1920;
|
||||||
|
const h = output.logical.height || 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
|
width: parent.width
|
||||||
height: 280
|
height: 280
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
@@ -16,7 +55,7 @@ Rectangle {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Theme.spacingL
|
anchors.margins: Theme.spacingL
|
||||||
|
|
||||||
property var bounds: DisplayConfigState.getOutputBounds()
|
property var bounds: root.filteredBounds
|
||||||
property real scaleFactor: {
|
property real scaleFactor: {
|
||||||
if (bounds.width === 0 || bounds.height === 0)
|
if (bounds.width === 0 || bounds.height === 0)
|
||||||
return 0.1;
|
return 0.1;
|
||||||
@@ -27,15 +66,8 @@ Rectangle {
|
|||||||
}
|
}
|
||||||
property point offset: Qt.point((width - bounds.width * scaleFactor) / 2 - bounds.minX * scaleFactor, (height - bounds.height * scaleFactor) / 2 - bounds.minY * scaleFactor)
|
property point offset: Qt.point((width - bounds.width * scaleFactor) / 2 - bounds.minX * scaleFactor, (height - bounds.height * scaleFactor) / 2 - bounds.minY * scaleFactor)
|
||||||
|
|
||||||
Connections {
|
|
||||||
target: DisplayConfigState
|
|
||||||
function onAllOutputsChanged() {
|
|
||||||
canvas.bounds = DisplayConfigState.getOutputBounds();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: DisplayConfigState.allOutputs ? Object.keys(DisplayConfigState.allOutputs) : []
|
model: root.filteredOutputs
|
||||||
|
|
||||||
delegate: MonitorRect {
|
delegate: MonitorRect {
|
||||||
required property string modelData
|
required property string modelData
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ StyledRect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
width: parent.width - Theme.iconSize - Theme.spacingM - (disconnectedBadge.visible ? disconnectedBadge.width + Theme.spacingS : 0)
|
width: parent.width - Theme.iconSize - Theme.spacingM - (disconnectedBadge.visible ? disconnectedBadge.width + deleteButton.width + Theme.spacingS * 2 : 0)
|
||||||
spacing: 2
|
spacing: 2
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
@@ -70,6 +70,31 @@ StyledRect {
|
|||||||
anchors.centerIn: parent
|
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 {
|
DankDropdown {
|
||||||
|
|||||||
@@ -8,6 +8,39 @@ import qs.Modules.Settings.DisplayConfig
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
property string selectedProfileId: SettingsData.getActiveDisplayProfile(CompositorService.compositor)
|
||||||
|
property bool showNewProfileDialog: false
|
||||||
|
property bool showDeleteConfirmDialog: false
|
||||||
|
property bool showRenameDialog: false
|
||||||
|
property string newProfileName: ""
|
||||||
|
property string renameProfileName: ""
|
||||||
|
|
||||||
|
function getProfileOptions() {
|
||||||
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
const options = [];
|
||||||
|
for (const id in profiles)
|
||||||
|
options.push(profiles[id].name);
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileIds() {
|
||||||
|
return Object.keys(DisplayConfigState.validatedProfiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileIdByName(name) {
|
||||||
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
for (const id in profiles) {
|
||||||
|
if (profiles[id].name === name)
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProfileNameById(id) {
|
||||||
|
const profiles = DisplayConfigState.validatedProfiles;
|
||||||
|
return profiles[id]?.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: DisplayConfigState
|
target: DisplayConfigState
|
||||||
function onChangesApplied(changeDescriptions) {
|
function onChangesApplied(changeDescriptions) {
|
||||||
@@ -18,6 +51,21 @@ Item {
|
|||||||
}
|
}
|
||||||
function onChangesReverted() {
|
function onChangesReverted() {
|
||||||
}
|
}
|
||||||
|
function onProfileActivated(profileId, profileName) {
|
||||||
|
root.selectedProfileId = profileId;
|
||||||
|
ToastService.showInfo(I18n.tr("Profile activated: %1").arg(profileName));
|
||||||
|
}
|
||||||
|
function onProfileSaved(profileId, profileName) {
|
||||||
|
root.selectedProfileId = profileId;
|
||||||
|
ToastService.showInfo(I18n.tr("Profile saved: %1").arg(profileName));
|
||||||
|
}
|
||||||
|
function onProfileDeleted(profileId) {
|
||||||
|
root.selectedProfileId = SettingsData.getActiveDisplayProfile(CompositorService.compositor);
|
||||||
|
ToastService.showInfo(I18n.tr("Profile deleted"));
|
||||||
|
}
|
||||||
|
function onProfileError(message) {
|
||||||
|
ToastService.showError(I18n.tr("Profile error"), message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DankFlickable {
|
DankFlickable {
|
||||||
@@ -38,6 +86,241 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StyledRect {
|
||||||
|
width: parent.width
|
||||||
|
height: profileSection.implicitHeight + Theme.spacingL * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
|
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||||
|
border.width: 0
|
||||||
|
visible: DisplayConfigState.hasOutputBackend
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: profileSection
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingL
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "tune"
|
||||||
|
size: Theme.iconSize
|
||||||
|
color: Theme.primary
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width - Theme.iconSize - Theme.spacingM - autoSelectColumn.width - Theme.spacingM
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Display Profiles")
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Save and switch between display configurations")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
width: parent.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ! TODO - auto profile switching is buggy on niri and other compositors
|
||||||
|
Column {
|
||||||
|
id: autoSelectColumn
|
||||||
|
visible: false // disabled for now
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Auto")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
DankToggle {
|
||||||
|
id: autoSelectToggle
|
||||||
|
checked: false // disabled for now
|
||||||
|
enabled: false
|
||||||
|
onToggled: checked => {
|
||||||
|
// disabled for now
|
||||||
|
// SettingsData.displayProfileAutoSelect = checked;
|
||||||
|
// SettingsData.saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: !root.showNewProfileDialog && !root.showDeleteConfirmDialog && !root.showRenameDialog
|
||||||
|
|
||||||
|
DankDropdown {
|
||||||
|
id: profileDropdown
|
||||||
|
width: parent.width - newButton.width - deleteButton.width - Theme.spacingS * 2
|
||||||
|
compactMode: true
|
||||||
|
dropdownWidth: width
|
||||||
|
options: root.getProfileOptions()
|
||||||
|
currentValue: root.getProfileNameById(root.selectedProfileId)
|
||||||
|
emptyText: I18n.tr("No profiles")
|
||||||
|
onValueChanged: value => {
|
||||||
|
const profileId = root.getProfileIdByName(value);
|
||||||
|
if (profileId && profileId !== root.selectedProfileId)
|
||||||
|
DisplayConfigState.activateProfile(profileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
id: newButton
|
||||||
|
iconName: "add"
|
||||||
|
text: ""
|
||||||
|
buttonHeight: 40
|
||||||
|
horizontalPadding: Theme.spacingM
|
||||||
|
backgroundColor: Theme.surfaceContainer
|
||||||
|
textColor: Theme.surfaceText
|
||||||
|
onClicked: {
|
||||||
|
root.newProfileName = "";
|
||||||
|
root.showNewProfileDialog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
id: deleteButton
|
||||||
|
iconName: "delete"
|
||||||
|
text: ""
|
||||||
|
buttonHeight: 40
|
||||||
|
horizontalPadding: Theme.spacingM
|
||||||
|
backgroundColor: Theme.surfaceContainer
|
||||||
|
textColor: Theme.error
|
||||||
|
enabled: root.selectedProfileId !== ""
|
||||||
|
onClicked: root.showDeleteConfirmDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: newProfileRow.height + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
visible: root.showNewProfileDialog
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: newProfileRow
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - Theme.spacingM * 2
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankTextField {
|
||||||
|
id: newProfileField
|
||||||
|
width: parent.width - createButton.width - cancelNewButton.width - Theme.spacingS * 2
|
||||||
|
placeholderText: I18n.tr("Profile name")
|
||||||
|
text: root.newProfileName
|
||||||
|
onTextChanged: root.newProfileName = text
|
||||||
|
onAccepted: {
|
||||||
|
if (text.trim())
|
||||||
|
DisplayConfigState.createProfile(text.trim());
|
||||||
|
root.showNewProfileDialog = false;
|
||||||
|
}
|
||||||
|
Component.onCompleted: forceActiveFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
id: createButton
|
||||||
|
text: I18n.tr("Create")
|
||||||
|
enabled: root.newProfileName.trim() !== ""
|
||||||
|
onClicked: {
|
||||||
|
DisplayConfigState.createProfile(root.newProfileName.trim());
|
||||||
|
root.showNewProfileDialog = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
id: cancelNewButton
|
||||||
|
text: I18n.tr("Cancel")
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
textColor: Theme.surfaceText
|
||||||
|
onClicked: root.showNewProfileDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: deleteConfirmColumn.height + Theme.spacingM * 2
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.surfaceContainer
|
||||||
|
visible: root.showDeleteConfirmDialog
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: deleteConfirmColumn
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - Theme.spacingM * 2
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Delete profile \"%1\"?").arg(root.getProfileNameById(root.selectedProfileId))
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
width: parent.width
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
text: I18n.tr("Delete")
|
||||||
|
backgroundColor: Theme.error
|
||||||
|
textColor: Theme.primaryText
|
||||||
|
onClicked: {
|
||||||
|
DisplayConfigState.deleteProfile(root.selectedProfileId);
|
||||||
|
root.showDeleteConfirmDialog = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
text: I18n.tr("Cancel")
|
||||||
|
backgroundColor: "transparent"
|
||||||
|
textColor: Theme.surfaceText
|
||||||
|
onClicked: root.showDeleteConfirmDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: DisplayConfigState.matchedProfile !== ""
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
name: "check_circle"
|
||||||
|
size: 16
|
||||||
|
color: Theme.success
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: I18n.tr("Matches profile: %1").arg(root.getProfileNameById(DisplayConfigState.matchedProfile))
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.success
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StyledRect {
|
StyledRect {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: monitorConfigSection.implicitHeight + Theme.spacingL * 2
|
height: monitorConfigSection.implicitHeight + Theme.spacingL * 2
|
||||||
@@ -128,8 +411,52 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: Theme.spacingS
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: {
|
||||||
|
const all = DisplayConfigState.allOutputs || {};
|
||||||
|
const disconnected = Object.keys(all).filter(k => !all[k]?.connected);
|
||||||
|
return disconnected.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: {
|
||||||
|
const all = DisplayConfigState.allOutputs || {};
|
||||||
|
const disconnected = Object.keys(all).filter(k => !all[k]?.connected);
|
||||||
|
if (SettingsData.displayShowDisconnected)
|
||||||
|
return I18n.tr("%1 disconnected").arg(disconnected.length);
|
||||||
|
return I18n.tr("%1 disconnected (hidden)").arg(disconnected.length);
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: SettingsData.displayShowDisconnected ? I18n.tr("Hide") : I18n.tr("Show")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.primary
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
SettingsData.displayShowDisconnected = !SettingsData.displayShowDisconnected;
|
||||||
|
SettingsData.saveSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: DisplayConfigState.allOutputs ? Object.keys(DisplayConfigState.allOutputs) : []
|
model: {
|
||||||
|
const keys = Object.keys(DisplayConfigState.allOutputs || {});
|
||||||
|
if (SettingsData.displayShowDisconnected)
|
||||||
|
return keys;
|
||||||
|
return keys.filter(k => DisplayConfigState.allOutputs[k]?.connected);
|
||||||
|
}
|
||||||
|
|
||||||
delegate: OutputCard {
|
delegate: OutputCard {
|
||||||
required property string modelData
|
required property string modelData
|
||||||
|
|||||||
Reference in New Issue
Block a user