mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-01-31 17:02:51 -05: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 niriOutputSettings: ({})
|
||||
property var hyprlandOutputSettings: ({})
|
||||
property var displayProfiles: ({})
|
||||
property var activeDisplayProfile: ({})
|
||||
property bool displayProfileAutoSelect: false
|
||||
property bool displayShowDisconnected: false
|
||||
|
||||
property var barConfigs: [
|
||||
{
|
||||
@@ -2256,6 +2260,39 @@ Singleton {
|
||||
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 {
|
||||
id: leftWidgetsModel
|
||||
}
|
||||
|
||||
@@ -347,6 +347,10 @@ var SPEC = {
|
||||
showOnLastDisplay: { def: {} },
|
||||
niriOutputSettings: { def: {} },
|
||||
hyprlandOutputSettings: { def: {} },
|
||||
displayProfiles: { def: {} },
|
||||
activeDisplayProfile: { def: {} },
|
||||
displayProfileAutoSelect: { def: false },
|
||||
displayShowDisconnected: { def: false },
|
||||
|
||||
barConfigs: {
|
||||
def: [{
|
||||
|
||||
@@ -4,6 +4,7 @@ import Quickshell.Hyprland
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Modules.Settings.DisplayConfig
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -1452,4 +1453,81 @@ Item {
|
||||
|
||||
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 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() {
|
||||
|
||||
@@ -4,6 +4,45 @@ 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 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
|
||||
height: 280
|
||||
radius: Theme.cornerRadius
|
||||
@@ -16,7 +55,7 @@ Rectangle {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacingL
|
||||
|
||||
property var bounds: DisplayConfigState.getOutputBounds()
|
||||
property var bounds: root.filteredBounds
|
||||
property real scaleFactor: {
|
||||
if (bounds.width === 0 || bounds.height === 0)
|
||||
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)
|
||||
|
||||
Connections {
|
||||
target: DisplayConfigState
|
||||
function onAllOutputsChanged() {
|
||||
canvas.bounds = DisplayConfigState.getOutputBounds();
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: DisplayConfigState.allOutputs ? Object.keys(DisplayConfigState.allOutputs) : []
|
||||
model: root.filteredOutputs
|
||||
|
||||
delegate: MonitorRect {
|
||||
required property string modelData
|
||||
|
||||
@@ -36,7 +36,7 @@ StyledRect {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
StyledText {
|
||||
@@ -70,6 +70,31 @@ StyledRect {
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,39 @@ import qs.Modules.Settings.DisplayConfig
|
||||
Item {
|
||||
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 {
|
||||
target: DisplayConfigState
|
||||
function onChangesApplied(changeDescriptions) {
|
||||
@@ -18,6 +51,21 @@ Item {
|
||||
}
|
||||
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 {
|
||||
@@ -38,6 +86,241 @@ Item {
|
||||
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 {
|
||||
width: parent.width
|
||||
height: monitorConfigSection.implicitHeight + Theme.spacingL * 2
|
||||
@@ -128,8 +411,52 @@ Item {
|
||||
width: parent.width
|
||||
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 {
|
||||
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 {
|
||||
required property string modelData
|
||||
|
||||
Reference in New Issue
Block a user