1
0
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:
bbedward
2026-01-28 20:51:29 -05:00
parent 2deeab9d08
commit 36b43f93a3
7 changed files with 818 additions and 12 deletions

View File

@@ -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
}

View File

@@ -347,6 +347,10 @@ var SPEC = {
showOnLastDisplay: { def: {} },
niriOutputSettings: { def: {} },
hyprlandOutputSettings: { def: {} },
displayProfiles: { def: {} },
activeDisplayProfile: { def: {} },
displayProfileAutoSelect: { def: false },
displayShowDisconnected: { def: false },
barConfigs: {
def: [{

View File

@@ -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"
}
}

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() {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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