1
0
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:
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 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
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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