diff --git a/core/internal/proto/wlr_output_management/output_management.go b/core/internal/proto/wlr_output_management/output_management.go index 90673ada..23f934fc 100644 --- a/core/internal/proto/wlr_output_management/output_management.go +++ b/core/internal/proto/wlr_output_management/output_management.go @@ -238,7 +238,7 @@ func (i *ZwlrOutputManagerV1) Dispatch(opcode uint32, fd int, data []byte) { l := 0 objectID := client.Uint32(data[l : l+4]) proxy := i.Context().GetProxy(objectID) - if proxy == nil { + if proxy == nil || proxy.IsZombie() { head := &ZwlrOutputHeadV1{} head.SetContext(i.Context()) head.SetID(objectID) @@ -723,7 +723,7 @@ func (i *ZwlrOutputHeadV1) Dispatch(opcode uint32, fd int, data []byte) { l := 0 objectID := client.Uint32(data[l : l+4]) proxy := i.Context().GetProxy(objectID) - if proxy == nil { + if proxy == nil || proxy.IsZombie() { mode := &ZwlrOutputModeV1{} mode.SetContext(i.Context()) mode.SetID(objectID) @@ -761,8 +761,8 @@ func (i *ZwlrOutputHeadV1) Dispatch(opcode uint32, fd int, data []byte) { l := 0 objectID := client.Uint32(data[l : l+4]) proxy := i.Context().GetProxy(objectID) - if proxy == nil { - // Mode not yet registered, create it + if proxy == nil || proxy.IsZombie() { + // Mode not yet registered or zombie, create fresh mode := &ZwlrOutputModeV1{} mode.SetContext(i.Context()) mode.SetID(objectID) diff --git a/core/internal/server/wlroutput/manager.go b/core/internal/server/wlroutput/manager.go index 22e8aedd..3bfeff88 100644 --- a/core/internal/server/wlroutput/manager.go +++ b/core/internal/server/wlroutput/manager.go @@ -145,6 +145,7 @@ func (m *Manager) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEven handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) { log.Debugf("WlrOutput: Head %d name: %s", headID, e.Name) head.name = e.Name + head.ready = true m.post(func() { m.updateState() }) @@ -251,11 +252,11 @@ func (m *Manager) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEven m.heads.Delete(headID) - m.post(func() { - m.wlMutex.Lock() - handle.Release() - m.wlMutex.Unlock() + m.wlMutex.Lock() + handle.Release() + m.wlMutex.Unlock() + m.post(func() { m.updateState() }) }) @@ -310,11 +311,11 @@ func (m *Manager) handleMode(headID uint32, e wlr_output_management.ZwlrOutputHe m.modes.Delete(modeID) - m.post(func() { - m.wlMutex.Lock() - handle.Release() - m.wlMutex.Unlock() + m.wlMutex.Lock() + handle.Release() + m.wlMutex.Unlock() + m.post(func() { m.updateState() }) }) @@ -328,6 +329,10 @@ func (m *Manager) updateState() { return true } + if !head.ready { + return true + } + modes := make([]OutputMode, 0) var currentMode *OutputMode diff --git a/core/internal/server/wlroutput/types.go b/core/internal/server/wlroutput/types.go index 1d265ab6..4fcc5416 100644 --- a/core/internal/server/wlroutput/types.go +++ b/core/internal/server/wlroutput/types.go @@ -90,6 +90,7 @@ type headState struct { modeIDs []uint32 adaptiveSync uint32 finished bool + ready bool } type modeState struct { diff --git a/quickshell/Modals/Common/DankModal.qml b/quickshell/Modals/Common/DankModal.qml index cbafe4e4..48f05aae 100644 --- a/quickshell/Modals/Common/DankModal.qml +++ b/quickshell/Modals/Common/DankModal.qml @@ -273,8 +273,8 @@ Item { anchors { left: true top: true - right: root.useSingleWindow ? true : undefined - bottom: root.useSingleWindow ? true : undefined + right: root.useSingleWindow + bottom: root.useSingleWindow } WlrLayershell.margins { @@ -284,8 +284,8 @@ Item { bottom: 0 } - implicitWidth: root.useSingleWindow ? undefined : root.alignedWidth + (shadowBuffer * 2) - implicitHeight: root.useSingleWindow ? undefined : root.alignedHeight + (shadowBuffer * 2) + implicitWidth: root.useSingleWindow ? 0 : root.alignedWidth + (shadowBuffer * 2) + implicitHeight: root.useSingleWindow ? 0 : root.alignedHeight + (shadowBuffer * 2) onVisibleChanged: { if (visible) { diff --git a/quickshell/Modals/DisplayConfirmationModal.qml b/quickshell/Modals/DisplayConfirmationModal.qml index 8809c224..f8a97de9 100644 --- a/quickshell/Modals/DisplayConfirmationModal.qml +++ b/quickshell/Modals/DisplayConfirmationModal.qml @@ -7,10 +7,11 @@ DankModal { id: root property string outputName: "" - property var position: undefined - property var mode: undefined - property var vrr: undefined - property int countdown: 15 + property var changes: [] + property int countdown: 10 + + signal confirmed + signal reverted shouldBeVisible: false allowStacking: true @@ -23,23 +24,27 @@ DankModal { repeat: true running: root.shouldBeVisible onTriggered: { - countdown--; - if (countdown <= 0) { - revert(); + root.countdown--; + if (root.countdown <= 0) { + root.reverted(); + root.close(); } } } onOpened: { - countdown = 15; + countdown = 10; countdownTimer.start(); } - onClosed: { + onDialogClosed: { countdownTimer.stop(); } - onBackgroundClicked: revert + onBackgroundClicked: { + root.reverted(); + root.close(); + } content: Component { FocusScope { @@ -50,12 +55,14 @@ DankModal { implicitHeight: mainColumn.implicitHeight Keys.onEscapePressed: event => { - revert(); + root.reverted(); + root.close(); event.accepted = true; } Keys.onReturnPressed: event => { - confirm(); + root.confirmed(); + root.close(); event.accepted = true; } @@ -69,81 +76,42 @@ DankModal { anchors.topMargin: Theme.spacingM spacing: Theme.spacingM - Column { - width: parent.width - spacing: Theme.spacingXS - - StyledText { - text: I18n.tr("Confirm Display Changes") - font.pixelSize: Theme.fontSizeLarge - color: Theme.surfaceText - font.weight: Font.Medium - } - - StyledText { - text: I18n.tr("Display settings for ") + outputName - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceTextMedium - } + StyledText { + text: I18n.tr("Confirm Display Changes") + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium } Rectangle { width: parent.width - height: 80 + height: 70 radius: Theme.cornerRadius color: Theme.surfaceContainerHighest - Column { + StyledText { anchors.centerIn: parent - spacing: 4 - - StyledText { - text: I18n.tr("Reverting in:") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceTextMedium - anchors.horizontalCenter: parent.horizontalCenter - } - - StyledText { - text: countdown + "s" - font.pixelSize: Theme.fontSizeXLarge * 1.5 - color: Theme.primary - font.weight: Font.Bold - anchors.horizontalCenter: parent.horizontalCenter - } + text: root.countdown + "s" + font.pixelSize: Theme.fontSizeXLarge * 1.5 + color: Theme.primary + font.weight: Font.Bold } } Column { width: parent.width spacing: Theme.spacingXS + visible: root.changes.length > 0 - StyledText { - text: I18n.tr("Changes:") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceTextMedium - font.weight: Font.Medium - } + Repeater { + model: root.changes - StyledText { - visible: position !== undefined && position !== null - text: I18n.tr("Position: ") + (position ? position.x + ", " + position.y : "") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - } - - StyledText { - visible: mode !== undefined && mode !== null && mode !== "" - text: I18n.tr("Mode: ") + (mode || "") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - } - - StyledText { - visible: vrr !== undefined && vrr !== null - text: I18n.tr("VRR: ") + (vrr ? I18n.tr("Enabled") : I18n.tr("Disabled")) - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText + StyledText { + required property var modelData + text: modelData + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } } } @@ -180,7 +148,10 @@ DankModal { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: revert + onClicked: { + root.reverted(); + root.close(); + } } } @@ -206,7 +177,10 @@ DankModal { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: confirm + onClicked: { + root.confirmed(); + root.close(); + } } Behavior on color { @@ -228,18 +202,11 @@ DankModal { iconName: "close" iconSize: Theme.iconSize - 4 iconColor: Theme.surfaceText - onClicked: revert + onClicked: { + root.reverted(); + root.close(); + } } } } - - function confirm() { - displaysTab.confirmChanges(); - close(); - } - - function revert() { - displaysTab.revertChanges(); - close(); - } } diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index 8968dffe..2ef92d13 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -125,13 +125,45 @@ FocusScope { } Loader { - id: displaysLoader + id: displayConfigLoader anchors.fill: parent - active: root.currentIndex === 6 + active: root.currentIndex === 24 visible: active focus: active - sourceComponent: DisplaysTab {} + sourceComponent: DisplayConfigTab {} + + onActiveChanged: { + if (active && item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } + + Loader { + id: gammaControlLoader + anchors.fill: parent + active: root.currentIndex === 25 + visible: active + focus: active + + sourceComponent: GammaControlTab {} + + onActiveChanged: { + if (active && item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } + + Loader { + id: displayWidgetsLoader + anchors.fill: parent + active: root.currentIndex === 26 + visible: active + focus: active + + sourceComponent: DisplayWidgetsTab {} onActiveChanged: { if (active && item) { diff --git a/quickshell/Modals/Settings/SettingsModal.qml b/quickshell/Modals/Settings/SettingsModal.qml index 386c353b..4f7cfeb2 100644 --- a/quickshell/Modals/Settings/SettingsModal.qml +++ b/quickshell/Modals/Settings/SettingsModal.qml @@ -58,7 +58,7 @@ FloatingWindow { objectName: "settingsModal" title: I18n.tr("Settings", "settings window title") minimumSize: Qt.size(500, 400) - implicitWidth: 800 + implicitWidth: 900 implicitHeight: screen ? Math.min(940, screen.height - 100) : 940 color: Theme.surfaceContainer visible: false diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index 694c14ca..b9de3295 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -144,6 +144,32 @@ Rectangle { "tabIndex": 2, "shortcutsOnly": true }, + { + "id": "displays", + "text": I18n.tr("Displays"), + "icon": "monitor", + "collapsedByDefault": true, + "children": [ + { + "id": "display_config", + "text": I18n.tr("Configuration") + " (Beta)", + "icon": "display_settings", + "tabIndex": 24 + }, + { + "id": "display_gamma", + "text": I18n.tr("Gamma Control"), + "icon": "brightness_6", + "tabIndex": 25 + }, + { + "id": "display_widgets", + "text": I18n.tr("Widgets", "settings_displays"), + "icon": "widgets", + "tabIndex": 26 + } + ] + }, { "id": "network", "text": I18n.tr("Network"), @@ -157,12 +183,6 @@ Rectangle { "icon": "computer", "collapsedByDefault": true, "children": [ - { - "id": "displays", - "text": I18n.tr("Displays"), - "icon": "monitor", - "tabIndex": 6 - }, { "id": "printers", "text": I18n.tr("Printers"), diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml new file mode 100644 index 00000000..dc0d7760 --- /dev/null +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -0,0 +1,1702 @@ +import QtCore +import QtQuick +import qs.Common +import qs.Modals +import qs.Services +import qs.Widgets + +Item { + id: root + + readonly property bool hasOutputBackend: WlrOutputService.wlrOutputAvailable + readonly property var wlrOutputs: WlrOutputService.outputs + property var outputs: ({}) + property var savedOutputs: ({}) + property var allOutputs: buildAllOutputsMap() + + property var includeStatus: ({ + exists: false, + included: false + }) + property bool checkingInclude: false + property bool fixingInclude: false + + function buildAllOutputsMap() { + const result = {}; + for (const name in savedOutputs) { + result[name] = Object.assign({}, savedOutputs[name], { + connected: false + }); + } + for (const name in outputs) { + result[name] = Object.assign({}, outputs[name], { + connected: true + }); + } + return result; + } + + onOutputsChanged: allOutputs = buildAllOutputsMap() + onSavedOutputsChanged: allOutputs = buildAllOutputsMap() + + Connections { + target: WlrOutputService + function onStateChanged() { + root.outputs = buildOutputsMap(); + reloadSavedOutputs(); + } + } + + Component.onCompleted: { + outputs = buildOutputsMap(); + reloadSavedOutputs(); + checkIncludeStatus(); + } + + function reloadSavedOutputs() { + const paths = getConfigPaths(); + if (!paths) { + savedOutputs = {}; + return; + } + + Proc.runCommand("load-saved-outputs", ["cat", paths.outputsFile], (content, exitCode) => { + if (exitCode !== 0 || !content.trim()) { + savedOutputs = {}; + return; + } + const parsed = parseOutputsConfig(content); + const filtered = filterDisconnectedOnly(parsed); + savedOutputs = filtered; + }); + } + + function filterDisconnectedOnly(parsedOutputs) { + const result = {}; + const liveNames = Object.keys(outputs); + const liveByIdentifier = {}; + for (const name of liveNames) { + const o = outputs[name]; + if (o?.make && o?.model) { + const serial = o.serial || "Unknown"; + const id = (o.make + " " + o.model + " " + serial).trim(); + liveByIdentifier[id] = true; + liveByIdentifier[o.make + " " + o.model] = true; + } + liveByIdentifier[name] = true; + } + + for (const savedName in parsedOutputs) { + const trimmed = savedName.trim(); + if (!liveByIdentifier[trimmed]) { + result[savedName] = parsedOutputs[savedName]; + } + } + return result; + } + + function parseOutputsConfig(content) { + switch (CompositorService.compositor) { + case "niri": + return parseNiriOutputs(content); + case "hyprland": + return parseHyprlandOutputs(content); + case "dwl": + return parseMangoOutputs(content); + default: + return {}; + } + } + + function parseNiriOutputs(content) { + const result = {}; + const outputRegex = /output\s+"([^"]+)"\s*\{([^}]*)\}/g; + let match; + while ((match = outputRegex.exec(content)) !== null) { + const name = match[1]; + const body = match[2]; + + const modeMatch = body.match(/mode\s+"(\d+)x(\d+)@([\d.]+)"/); + const posMatch = body.match(/position\s+x=(-?\d+)\s+y=(-?\d+)/); + const scaleMatch = body.match(/scale\s+([\d.]+)/); + const transformMatch = body.match(/transform\s+"([^"]+)"/); + const vrrMatch = body.match(/variable-refresh-rate(?:\s+on)?/); + + result[name] = { + name: name, + logical: { + x: posMatch ? parseInt(posMatch[1]) : 0, + y: posMatch ? parseInt(posMatch[2]) : 0, + scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1.0, + transform: transformMatch ? transformMatch[1] : "Normal" + }, + modes: modeMatch ? [ + { + width: parseInt(modeMatch[1]), + height: parseInt(modeMatch[2]), + refresh_rate: Math.round(parseFloat(modeMatch[3]) * 1000) + } + ] : [], + current_mode: 0, + vrr_enabled: !!vrrMatch, + vrr_supported: true + }; + } + return result; + } + + function parseHyprlandOutputs(content) { + const result = {}; + const lines = content.split("\n"); + for (const line of lines) { + const match = line.match(/^\s*monitor\s*=\s*([^,]+),\s*(\d+)x(\d+)@([\d.]+),\s*(-?\d+)x(-?\d+),\s*([\d.]+)(?:,\s*transform,\s*(\d+))?(?:,\s*vrr,\s*(\d+))?/); + if (!match) + continue; + const name = match[1].trim(); + result[name] = { + name: name, + logical: { + x: parseInt(match[5]), + y: parseInt(match[6]), + scale: parseFloat(match[7]), + transform: hyprlandToTransform(parseInt(match[8] || "0")) + }, + modes: [ + { + width: parseInt(match[2]), + height: parseInt(match[3]), + refresh_rate: Math.round(parseFloat(match[4]) * 1000) + } + ], + current_mode: 0, + vrr_enabled: match[9] === "1", + vrr_supported: true + }; + } + return result; + } + + function hyprlandToTransform(value) { + switch (value) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } + + function parseMangoOutputs(content) { + const result = {}; + const lines = content.split("\n"); + for (const line of lines) { + const match = line.match(/^\s*monitorrule=([^,]+),([^,]+),([^,]+),([^,]+),(\d+),([\d.]+),(-?\d+),(-?\d+),(\d+),(\d+),(\d+)/); + if (!match) + continue; + const name = match[1].trim(); + result[name] = { + name: name, + logical: { + x: parseInt(match[7]), + y: parseInt(match[8]), + scale: parseFloat(match[6]), + transform: mangoToTransform(parseInt(match[5])) + }, + modes: [ + { + width: parseInt(match[9]), + height: parseInt(match[10]), + refresh_rate: parseInt(match[11]) * 1000 + } + ], + current_mode: 0, + vrr_enabled: false, + vrr_supported: false + }; + } + return result; + } + + function mangoToTransform(value) { + switch (value) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } + + function getConfigPaths() { + const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + switch (CompositorService.compositor) { + case "niri": + return { + configFile: configDir + "/niri/config.kdl", + outputsFile: configDir + "/niri/dms/outputs.kdl", + grepPattern: 'include.*"dms/outputs.kdl"', + includeLine: 'include "dms/outputs.kdl"' + }; + case "hyprland": + return { + configFile: configDir + "/hypr/hyprland.conf", + outputsFile: configDir + "/hypr/dms/outputs.conf", + grepPattern: 'source.*dms/outputs.conf', + includeLine: "source = ./dms/outputs.conf" + }; + case "dwl": + return { + configFile: configDir + "/mango/config.conf", + outputsFile: configDir + "/mango/dms/outputs.conf", + grepPattern: 'source.*dms/outputs.conf', + includeLine: "source=./dms/outputs.conf" + }; + default: + return null; + } + } + + function checkIncludeStatus() { + const paths = getConfigPaths(); + if (!paths) { + includeStatus = { + exists: false, + included: false + }; + return; + } + + checkingInclude = true; + Proc.runCommand("check-outputs-include", ["sh", "-c", `exists=false; included=false; ` + `[ -f "${paths.outputsFile}" ] && exists=true; ` + `[ -f "${paths.configFile}" ] && grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" | grep -q '${paths.grepPattern}' && included=true; ` + `echo "$exists $included"`], (output, exitCode) => { + checkingInclude = false; + const parts = output.trim().split(" "); + includeStatus = { + exists: parts[0] === "true", + included: parts[1] === "true" + }; + }); + } + + function fixOutputsInclude() { + const paths = getConfigPaths(); + if (!paths) + return; + fixingInclude = true; + const outputsDir = paths.outputsFile.substring(0, paths.outputsFile.lastIndexOf("/")); + const unixTime = Math.floor(Date.now() / 1000); + const backupFile = paths.configFile + ".backup" + unixTime; + + Proc.runCommand("fix-outputs-include", ["sh", "-c", `cp "${paths.configFile}" "${backupFile}" 2>/dev/null; ` + `mkdir -p "${outputsDir}" && ` + `touch "${paths.outputsFile}" && ` + `if ! grep -v '^[[:space:]]*\\(//\\|#\\)' "${paths.configFile}" 2>/dev/null | grep -q '${paths.grepPattern}'; then ` + `echo '' >> "${paths.configFile}" && ` + `echo '${paths.includeLine}' >> "${paths.configFile}"; fi`], (output, exitCode) => { + fixingInclude = false; + if (exitCode !== 0) + return; + checkIncludeStatus(); + WlrOutputService.requestState(); + }); + } + + function buildOutputsMap() { + const map = {}; + for (const output of wlrOutputs) { + const normalizedModes = (output.modes || []).map(m => ({ + "id": m.id, + "width": m.width, + "height": m.height, + "refresh_rate": m.refresh, + "preferred": m.preferred ?? false + })); + map[output.name] = { + "name": output.name, + "make": output.make || "", + "model": output.model || "", + "serial": output.serialNumber || "", + "modes": normalizedModes, + "current_mode": normalizedModes.findIndex(m => m.id === output.currentMode?.id), + "vrr_supported": output.adaptiveSync !== undefined, + "vrr_enabled": output.adaptiveSync === 1, + "logical": { + "x": output.x ?? 0, + "y": output.y ?? 0, + "width": output.currentMode?.width ?? 1920, + "height": output.currentMode?.height ?? 1080, + "scale": output.scale ?? 1.0, + "transform": mapWlrTransform(output.transform) + } + }; + } + return map; + } + + function mapWlrTransform(wlrTransform) { + switch (wlrTransform) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } + + function mapTransformToWlr(transform) { + switch (transform) { + case "Normal": + return 0; + case "90": + return 1; + case "180": + return 2; + case "270": + return 3; + case "Flipped": + return 4; + case "Flipped90": + return 5; + case "Flipped180": + return 6; + case "Flipped270": + return 7; + default: + return 0; + } + } + + function backendFetchOutputs() { + WlrOutputService.requestState(); + } + + function backendWriteOutputsConfig(outputsData) { + switch (CompositorService.compositor) { + case "niri": + NiriService.generateOutputsConfig(outputsData); + break; + case "hyprland": + HyprlandService.generateOutputsConfig(outputsData); + break; + case "dwl": + DwlService.generateOutputsConfig(outputsData); + break; + } + } + + function normalizeOutputPositions(outputsData) { + const names = Object.keys(outputsData); + if (names.length === 0) + return outputsData; + + let minX = Infinity; + let minY = Infinity; + + for (const name of names) { + const output = outputsData[name]; + if (!output.logical) + continue; + minX = Math.min(minX, output.logical.x); + minY = Math.min(minY, output.logical.y); + } + + if (minX === Infinity || (minX === 0 && minY === 0)) + return outputsData; + + const normalized = JSON.parse(JSON.stringify(outputsData)); + for (const name of names) { + if (!normalized[name].logical) + continue; + normalized[name].logical.x -= minX; + normalized[name].logical.y -= minY; + } + + return normalized; + } + + function buildOutputsWithPendingChanges() { + const result = {}; + + for (const outputName in savedOutputs) { + if (!outputs[outputName]) + result[outputName] = JSON.parse(JSON.stringify(savedOutputs[outputName])); + } + + for (const outputName in outputs) { + result[outputName] = JSON.parse(JSON.stringify(outputs[outputName])); + } + + for (const outputName in pendingChanges) { + if (!result[outputName]) + continue; + const changes = pendingChanges[outputName]; + if (changes.position && result[outputName].logical) { + result[outputName].logical.x = changes.position.x; + result[outputName].logical.y = changes.position.y; + } + if (changes.mode !== undefined && result[outputName].modes) { + for (let i = 0; i < result[outputName].modes.length; i++) { + if (formatMode(result[outputName].modes[i]) === changes.mode) { + result[outputName].current_mode = i; + break; + } + } + } + if (changes.scale !== undefined && result[outputName].logical) + result[outputName].logical.scale = changes.scale; + if (changes.transform !== undefined && result[outputName].logical) + result[outputName].logical.transform = changes.transform; + if (changes.vrr !== undefined) + result[outputName].vrr_enabled = changes.vrr; + } + return normalizeOutputPositions(result); + } + + function backendUpdateOutputPosition(outputName, x, y) { + if (!outputs || !outputs[outputName]) + return; + const updatedOutputs = {}; + for (const name in outputs) { + const output = outputs[name]; + if (name === outputName && output.logical) { + updatedOutputs[name] = JSON.parse(JSON.stringify(output)); + updatedOutputs[name].logical.x = x; + updatedOutputs[name].logical.y = y; + } else { + updatedOutputs[name] = output; + } + } + outputs = updatedOutputs; + } + + function backendUpdateOutputScale(outputName, scale) { + if (!outputs || !outputs[outputName]) + return; + const updatedOutputs = {}; + for (const name in outputs) { + const output = outputs[name]; + if (name === outputName && output.logical) { + updatedOutputs[name] = JSON.parse(JSON.stringify(output)); + updatedOutputs[name].logical.scale = scale; + } else { + updatedOutputs[name] = output; + } + } + outputs = updatedOutputs; + } + + property var pendingChanges: ({}) + property var originalOutputs: null + property string originalDisplayNameMode: "" + property bool formatChanged: originalDisplayNameMode !== "" && originalDisplayNameMode !== SettingsData.displayNameMode + property bool hasPendingChanges: Object.keys(pendingChanges).length > 0 || formatChanged + + function getOutputDisplayName(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 initOriginalOutputs() { + if (!originalOutputs) + originalOutputs = JSON.parse(JSON.stringify(outputs)); + } + + function setPendingChange(outputName, key, value) { + initOriginalOutputs(); + const newPending = JSON.parse(JSON.stringify(pendingChanges)); + if (!newPending[outputName]) + newPending[outputName] = {}; + newPending[outputName][key] = value; + pendingChanges = newPending; + + if (key === "scale") { + recalculateAdjacentPositions(outputName, value); + backendUpdateOutputScale(outputName, value); + } + } + + function recalculateAdjacentPositions(changedOutput, newScale) { + const output = outputs[changedOutput]; + if (!output?.logical) + return; + const oldPhys = getPhysicalSize(output); + const oldLogicalW = Math.round(oldPhys.w / (output.logical.scale || 1.0)); + const newLogicalW = Math.round(oldPhys.w / newScale); + + const changedX = getPendingValue(changedOutput, "position")?.x ?? output.logical.x; + const changedY = getPendingValue(changedOutput, "position")?.y ?? output.logical.y; + + for (const name in outputs) { + if (name === changedOutput) + continue; + const other = outputs[name]; + if (!other?.logical) + continue; + const otherX = getPendingValue(name, "position")?.x ?? other.logical.x; + const otherY = getPendingValue(name, "position")?.y ?? other.logical.y; + const otherSize = getLogicalSize(other); + const otherRight = otherX + otherSize.w; + + if (Math.abs(changedX - otherRight) < 5) { + const newX = otherRight; + const newPending = JSON.parse(JSON.stringify(pendingChanges)); + if (!newPending[changedOutput]) + newPending[changedOutput] = {}; + newPending[changedOutput].position = { + x: newX, + y: changedY + }; + pendingChanges = newPending; + backendUpdateOutputPosition(changedOutput, newX, changedY); + return; + } + + const changedRight = changedX + oldLogicalW; + if (Math.abs(otherX - changedRight) < 5) { + const newOtherX = changedX + newLogicalW; + const newPending = JSON.parse(JSON.stringify(pendingChanges)); + if (!newPending[name]) + newPending[name] = {}; + newPending[name].position = { + x: newOtherX, + y: otherY + }; + pendingChanges = newPending; + backendUpdateOutputPosition(name, newOtherX, otherY); + } + } + } + + function getPendingValue(outputName, key) { + if (!pendingChanges[outputName]) + return undefined; + return pendingChanges[outputName][key]; + } + + function getEffectiveValue(outputName, key, originalValue) { + const pending = getPendingValue(outputName, key); + return pending !== undefined ? pending : originalValue; + } + + function clearPendingChanges() { + pendingChanges = {}; + originalOutputs = null; + originalDisplayNameMode = ""; + } + + function discardChanges() { + if (originalDisplayNameMode !== "") { + SettingsData.displayNameMode = originalDisplayNameMode; + SettingsData.saveSettings(); + } + backendFetchOutputs(); + clearPendingChanges(); + } + + function applyChanges() { + if (!hasPendingChanges) + return; + const changeDescriptions = []; + + if (formatChanged) { + const formatLabel = SettingsData.displayNameMode === "model" ? I18n.tr("Model") : I18n.tr("Name"); + changeDescriptions.push(I18n.tr("Config Format") + " → " + formatLabel); + } + + for (const outputName in pendingChanges) { + const changes = pendingChanges[outputName]; + if (changes.position) + changeDescriptions.push(outputName + ": " + I18n.tr("Position") + " → " + changes.position.x + ", " + changes.position.y); + if (changes.mode) + changeDescriptions.push(outputName + ": " + I18n.tr("Mode") + " → " + changes.mode); + if (changes.scale !== undefined) + changeDescriptions.push(outputName + ": " + I18n.tr("Scale") + " → " + changes.scale); + if (changes.transform) + changeDescriptions.push(outputName + ": " + I18n.tr("Transform") + " → " + getTransformLabel(changes.transform)); + if (changes.vrr !== undefined) + changeDescriptions.push(outputName + ": " + I18n.tr("VRR") + " → " + (changes.vrr ? I18n.tr("Enabled") : I18n.tr("Disabled"))); + } + + confirmationModal.changes = changeDescriptions; + confirmationModal.open(); + + if (formatChanged) + SettingsData.saveSettings(); + + const mergedOutputs = buildOutputsWithPendingChanges(); + backendWriteOutputsConfig(mergedOutputs); + } + + function confirmChanges() { + clearPendingChanges(); + } + + function revertChanges() { + const hadFormatChange = originalDisplayNameMode !== ""; + if (hadFormatChange) { + SettingsData.displayNameMode = originalDisplayNameMode; + SettingsData.saveSettings(); + } + + if (originalOutputs) { + const original = JSON.parse(JSON.stringify(originalOutputs)); + backendWriteOutputsConfig(original); + pendingChanges = {}; + originalOutputs = null; + originalDisplayNameMode = ""; + outputs = {}; + Qt.callLater(() => { + root.outputs = original; + }); + } else if (hadFormatChange) { + const currentOutputs = buildOutputsWithPendingChanges(); + backendWriteOutputsConfig(currentOutputs); + clearPendingChanges(); + } else { + clearPendingChanges(); + } + } + + function getOutputBounds() { + if (!allOutputs || Object.keys(allOutputs).length === 0) + return { + "minX": 0, + "minY": 0, + "maxX": 1920, + "maxY": 1080, + "width": 1920, + "height": 1080 + }; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + for (const name in allOutputs) { + const output = allOutputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + size.w); + maxY = Math.max(maxY, y + size.h); + } + + if (minX === Infinity) + return { + "minX": 0, + "minY": 0, + "maxX": 1920, + "maxY": 1080, + "width": 1920, + "height": 1080 + }; + return { + "minX": minX, + "minY": minY, + "maxX": maxX, + "maxY": maxY, + "width": maxX - minX, + "height": maxY - minY + }; + } + + function isRotated(transform) { + switch (transform) { + case "90": + case "270": + case "Flipped90": + case "Flipped270": + return true; + default: + return false; + } + } + + function getPhysicalSize(output) { + if (!output) + return { + w: 1920, + h: 1080 + }; + + let w = 1920, h = 1080; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) { + w = mode.width || 1920; + h = mode.height || 1080; + } + } else if (output.logical) { + const scale = output.logical.scale || 1.0; + w = Math.round((output.logical.width || 1920) * scale); + h = Math.round((output.logical.height || 1080) * scale); + } + + if (output.logical && isRotated(output.logical.transform)) + return { + w: h, + h: w + }; + return { + w: w, + h: h + }; + } + + function getLogicalSize(output) { + if (!output) + return { + w: 1920, + h: 1080 + }; + + const phys = getPhysicalSize(output); + const scale = output.logical?.scale || 1.0; + + return { + w: Math.round(phys.w / scale), + h: Math.round(phys.h / scale) + }; + } + + function checkOverlap(testName, testX, testY, testW, testH) { + for (const name in outputs) { + if (name === testName) + continue; + const output = outputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + if (!(testX + testW <= x || testX >= x + size.w || testY + testH <= y || testY >= y + size.h)) + return true; + } + return false; + } + + function snapToEdges(testName, posX, posY, testW, testH) { + const snapThreshold = 200; + let snappedX = posX; + let snappedY = posY; + let bestXDist = snapThreshold; + let bestYDist = snapThreshold; + + for (const name in outputs) { + if (name === testName) + continue; + const output = outputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + + const rightEdge = x + size.w; + const bottomEdge = y + size.h; + const testRight = posX + testW; + const testBottom = posY + testH; + + const xSnaps = [ + { + val: rightEdge, + dist: Math.abs(posX - rightEdge) + }, + { + val: x - testW, + dist: Math.abs(testRight - x) + }, + { + val: x, + dist: Math.abs(posX - x) + }, + { + val: rightEdge - testW, + dist: Math.abs(testRight - rightEdge) + } + ]; + + const ySnaps = [ + { + val: bottomEdge, + dist: Math.abs(posY - bottomEdge) + }, + { + val: y - testH, + dist: Math.abs(testBottom - y) + }, + { + val: y, + dist: Math.abs(posY - y) + }, + { + val: bottomEdge - testH, + dist: Math.abs(testBottom - bottomEdge) + } + ]; + + for (const snap of xSnaps) { + if (snap.dist < bestXDist) { + bestXDist = snap.dist; + snappedX = snap.val; + } + } + + for (const snap of ySnaps) { + if (snap.dist < bestYDist) { + bestYDist = snap.dist; + snappedY = snap.val; + } + } + } + + if (checkOverlap(testName, snappedX, snappedY, testW, testH)) { + if (!checkOverlap(testName, snappedX, posY, testW, testH)) + return Qt.point(snappedX, posY); + if (!checkOverlap(testName, posX, snappedY, testW, testH)) + return Qt.point(posX, snappedY); + return Qt.point(posX, posY); + } + return Qt.point(snappedX, snappedY); + } + + function findBestSnapPosition(testName, posX, posY, testW, testH) { + const outputNames = Object.keys(outputs).filter(n => n !== testName); + + if (outputNames.length === 0) + return Qt.point(posX, posY); + + let bestPos = null; + let bestDist = Infinity; + + for (const name of outputNames) { + const output = outputs[name]; + if (!output.logical) + continue; + const x = output.logical.x; + const y = output.logical.y; + const size = getLogicalSize(output); + + const candidates = [ + { + px: x + size.w, + py: y + }, + { + px: x - testW, + py: y + }, + { + px: x, + py: y + size.h + }, + { + px: x, + py: y - testH + }, + { + px: x + size.w, + py: y + size.h - testH + }, + { + px: x - testW, + py: y + size.h - testH + }, + { + px: x + size.w - testW, + py: y + size.h + }, + { + px: x + size.w - testW, + py: y - testH + } + ]; + + for (const c of candidates) { + if (checkOverlap(testName, c.px, c.py, testW, testH)) + continue; + const dist = Math.hypot(c.px - posX, c.py - posY); + if (dist < bestDist) { + bestDist = dist; + bestPos = Qt.point(c.px, c.py); + } + } + } + + return bestPos || Qt.point(posX, posY); + } + + function formatMode(mode) { + if (!mode) + return ""; + return mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3); + } + + function getTransformLabel(transform) { + switch (transform) { + case "Normal": + return I18n.tr("Normal"); + case "90": + return I18n.tr("90°"); + case "180": + return I18n.tr("180°"); + case "270": + return I18n.tr("270°"); + case "Flipped": + return I18n.tr("Flipped"); + case "Flipped90": + return I18n.tr("Flipped 90°"); + case "Flipped180": + return I18n.tr("Flipped 180°"); + case "Flipped270": + return I18n.tr("Flipped 270°"); + default: + return I18n.tr("Normal"); + } + } + + function getTransformValue(label) { + if (label === I18n.tr("Normal")) + return "Normal"; + if (label === I18n.tr("90°")) + return "90"; + if (label === I18n.tr("180°")) + return "180"; + if (label === I18n.tr("270°")) + return "270"; + if (label === I18n.tr("Flipped")) + return "Flipped"; + if (label === I18n.tr("Flipped 90°")) + return "Flipped90"; + if (label === I18n.tr("Flipped 180°")) + return "Flipped180"; + if (label === I18n.tr("Flipped 270°")) + return "Flipped270"; + return "Normal"; + } + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + StyledRect { + id: includeWarningBox + width: parent.width + height: includeWarningSection.implicitHeight + Theme.spacingL * 2 + radius: Theme.cornerRadius + + readonly property bool showError: root.includeStatus.exists && !root.includeStatus.included + readonly property bool showSetup: !root.includeStatus.exists && !root.includeStatus.included + + color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.15) : "transparent" + border.color: (showError || showSetup) ? Theme.withAlpha(Theme.primary, 0.3) : "transparent" + border.width: 1 + visible: (showError || showSetup) && hasOutputBackend && !root.checkingInclude + + Column { + id: includeWarningSection + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "warning" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - (includeFixButton.visible ? includeFixButton.width + Theme.spacingM : 0) - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: { + if (includeWarningBox.showSetup) + return I18n.tr("First Time Setup"); + if (includeWarningBox.showError) + return I18n.tr("Outputs Include Missing"); + return ""; + } + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.primary + } + + StyledText { + text: { + if (includeWarningBox.showSetup) + return I18n.tr("Click 'Setup' to create the outputs config and add include to your compositor config."); + if (includeWarningBox.showError) + return I18n.tr("dms/outputs config exists but is not included in your compositor config. Display changes won't persist."); + return ""; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + } + + DankButton { + id: includeFixButton + visible: includeWarningBox.showError || includeWarningBox.showSetup + text: { + if (root.fixingInclude) + return I18n.tr("Fixing..."); + if (includeWarningBox.showSetup) + return I18n.tr("Setup"); + return I18n.tr("Fix Now"); + } + backgroundColor: Theme.primary + textColor: Theme.primaryText + enabled: !root.fixingInclude + anchors.verticalCenter: parent.verticalCenter + onClicked: root.fixOutputsInclude() + } + } + } + } + + StyledRect { + width: parent.width + height: monitorConfigSection.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: hasOutputBackend + + Column { + id: monitorConfigSection + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "monitor" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM - (displayFormatColumn.visible ? displayFormatColumn.width + Theme.spacingM : 0) + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Monitor Configuration") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Arrange displays and configure resolution, refresh rate, and VRR") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + } + + Column { + id: displayFormatColumn + visible: !CompositorService.isDwl + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Config Format") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + DankButtonGroup { + id: displayFormatGroup + model: [I18n.tr("Name"), I18n.tr("Model")] + currentIndex: SettingsData.displayNameMode === "model" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + const newMode = index === 1 ? "model" : "system"; + if (root.originalDisplayNameMode === "") + root.originalDisplayNameMode = SettingsData.displayNameMode; + SettingsData.displayNameMode = newMode; + } + + Connections { + target: SettingsData + function onDisplayNameModeChanged() { + displayFormatGroup.currentIndex = SettingsData.displayNameMode === "model" ? 1 : 0; + } + } + } + } + } + + Rectangle { + width: parent.width + height: 280 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHighest + border.color: Theme.outline + border.width: 1 + + Item { + id: monitorCanvas + anchors.fill: parent + anchors.margins: 16 + + property var bounds: root.getOutputBounds() + property real scaleFactor: { + if (bounds.width === 0 || bounds.height === 0) + return 0.1; + const scaleX = (width - 32) / bounds.width; + const scaleY = (height - 32) / bounds.height; + return Math.min(scaleX, scaleY); + } + property point offset: Qt.point((width - bounds.width * scaleFactor) / 2 - bounds.minX * scaleFactor, (height - bounds.height * scaleFactor) / 2 - bounds.minY * scaleFactor) + + Connections { + target: root + function onAllOutputsChanged() { + monitorCanvas.bounds = root.getOutputBounds(); + } + } + + Repeater { + model: allOutputs ? Object.keys(allOutputs) : [] + + delegate: Rectangle { + id: monitorRect + required property string modelData + property var outputData: allOutputs[modelData] + property bool isConnected: outputData?.connected ?? false + property bool isDragging: false + property point originalLogical: Qt.point(0, 0) + property point snappedLogical: Qt.point(0, 0) + property bool isValidPosition: true + + property var physSize: root.getPhysicalSize(outputData) + property var logicalSize: root.getLogicalSize(outputData) + + x: isDragging ? x : (outputData?.logical?.x ?? 0) * monitorCanvas.scaleFactor + monitorCanvas.offset.x + y: isDragging ? y : (outputData?.logical?.y ?? 0) * monitorCanvas.scaleFactor + monitorCanvas.offset.y + width: logicalSize.w * monitorCanvas.scaleFactor + height: logicalSize.h * monitorCanvas.scaleFactor + radius: Theme.cornerRadius + opacity: isConnected ? 1.0 : 0.5 + color: { + if (!isConnected) + return Theme.surfaceContainerHighest; + if (!isValidPosition) + return Theme.withAlpha(Theme.error, 0.3); + if (isDragging) + return Theme.withAlpha(Theme.primary, 0.4); + if (dragArea.containsMouse) + return Theme.withAlpha(Theme.primary, 0.2); + return Theme.surfaceContainerHigh; + } + border.color: { + if (!isConnected) + return Theme.outline; + if (!isValidPosition) + return Theme.error; + if (isDragging) + return Theme.primary; + if (CompositorService.getFocusedScreen()?.name === modelData) + return Theme.primary; + return Theme.outline; + } + border.width: isDragging ? 3 : 2 + z: isDragging ? 100 : (isConnected ? 1 : 0) + + Rectangle { + id: snapPreview + visible: monitorRect.isDragging && monitorRect.isValidPosition + x: monitorRect.snappedLogical.x * monitorCanvas.scaleFactor + monitorCanvas.offset.x - monitorRect.x + y: monitorRect.snappedLogical.y * monitorCanvas.scaleFactor + monitorCanvas.offset.y - monitorRect.y + width: parent.width + height: parent.height + radius: Theme.cornerRadius + color: "transparent" + border.color: Theme.primary + border.width: 2 + opacity: 0.6 + } + + Column { + anchors.centerIn: parent + spacing: 2 + + DankIcon { + name: monitorRect.isConnected ? "desktop_windows" : "desktop_access_disabled" + size: Math.min(24, Math.min(monitorRect.width * 0.3, monitorRect.height * 0.25)) + color: monitorRect.isConnected ? (monitorRect.isValidPosition ? Theme.primary : Theme.error) : Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: root.getOutputDisplayName(monitorRect.outputData, modelData) + font.pixelSize: Math.max(10, Math.min(14, monitorRect.width * 0.12)) + font.weight: Font.Medium + color: monitorRect.isConnected ? Theme.surfaceText : Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + elide: Text.ElideMiddle + width: Math.min(implicitWidth, monitorRect.width - 8) + } + + StyledText { + text: monitorRect.isConnected ? (monitorRect.physSize.w + "x" + monitorRect.physSize.h) : I18n.tr("Disconnected") + font.pixelSize: Math.max(8, Math.min(11, monitorRect.width * 0.09)) + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + + MouseArea { + id: dragArea + anchors.fill: parent + hoverEnabled: true + enabled: monitorRect.isConnected + cursorShape: !monitorRect.isConnected ? Qt.ArrowCursor : (monitorRect.isDragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor) + drag.target: monitorRect.isConnected ? monitorRect : null + drag.axis: Drag.XAndYAxis + drag.threshold: 0 + + onPressed: mouse => { + if (!monitorRect.isConnected) + return; + monitorRect.isDragging = true; + monitorRect.originalLogical = Qt.point(outputData?.logical?.x ?? 0, outputData?.logical?.y ?? 0); + monitorRect.snappedLogical = monitorRect.originalLogical; + monitorRect.isValidPosition = true; + } + + onPositionChanged: mouse => { + if (!monitorRect.isDragging || !monitorRect.isConnected) + return; + let posX = Math.round((monitorRect.x - monitorCanvas.offset.x) / monitorCanvas.scaleFactor); + let posY = Math.round((monitorRect.y - monitorCanvas.offset.y) / monitorCanvas.scaleFactor); + + const size = root.getLogicalSize(outputData); + + const snapped = root.snapToEdges(modelData, posX, posY, size.w, size.h); + monitorRect.snappedLogical = snapped; + monitorRect.isValidPosition = !root.checkOverlap(modelData, snapped.x, snapped.y, size.w, size.h); + } + + onReleased: { + if (!monitorRect.isDragging || !monitorRect.isConnected) + return; + monitorRect.isDragging = false; + + const size = root.getLogicalSize(outputData); + const finalX = monitorRect.snappedLogical.x; + const finalY = monitorRect.snappedLogical.y; + + if (root.checkOverlap(modelData, finalX, finalY, size.w, size.h)) { + monitorRect.isValidPosition = true; + return; + } + + if (finalX === monitorRect.originalLogical.x && finalY === monitorRect.originalLogical.y) + return; + root.initOriginalOutputs(); + backendUpdateOutputPosition(modelData, finalX, finalY); + root.setPendingChange(modelData, "position", { + "x": finalX, + "y": finalY + }); + } + } + + Drag.active: dragArea.drag.active && monitorRect.isConnected + } + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: allOutputs ? Object.keys(allOutputs) : [] + + delegate: StyledRect { + required property string modelData + property var outputData: allOutputs[modelData] + property bool isConnected: outputData?.connected ?? false + + width: parent.width + height: outputSettingsCol.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, isConnected ? 0.5 : 0.3) + border.color: Theme.withAlpha(Theme.outline, 0.3) + border.width: 1 + opacity: isConnected ? 1.0 : 0.7 + + Column { + id: outputSettingsCol + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: isConnected ? "desktop_windows" : "desktop_access_disabled" + size: Theme.iconSize - 4 + color: isConnected ? Theme.primary : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM - (disconnectedBadge.visible ? disconnectedBadge.width + Theme.spacingS : 0) + spacing: 2 + + StyledText { + text: root.getOutputDisplayName(outputData, modelData) + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: isConnected ? Theme.surfaceText : Theme.surfaceVariantText + } + + StyledText { + text: (outputData?.model ?? "") + (outputData?.make ? " - " + outputData.make : "") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + + Rectangle { + id: disconnectedBadge + visible: !isConnected + width: disconnectedText.implicitWidth + Theme.spacingM + height: disconnectedText.implicitHeight + Theme.spacingXS + radius: height / 2 + color: Theme.withAlpha(Theme.outline, 0.3) + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: disconnectedText + text: I18n.tr("Disconnected") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.centerIn: parent + } + } + } + + DankDropdown { + width: parent.width + text: I18n.tr("Resolution & Refresh") + visible: isConnected + currentValue: { + const pendingMode = root.getPendingValue(modelData, "mode"); + if (pendingMode) + return pendingMode; + const data = root.outputs[modelData]; + if (!data?.modes || data?.current_mode === undefined) + return "Auto"; + const mode = data.modes[data.current_mode]; + return mode ? root.formatMode(mode) : "Auto"; + } + options: { + const data = root.outputs[modelData]; + if (!data?.modes) + return ["Auto"]; + const opts = []; + for (var i = 0; i < data.modes.length; i++) { + opts.push(root.formatMode(data.modes[i])); + } + return opts; + } + onValueChanged: value => root.setPendingChange(modelData, "mode", value) + } + + StyledText { + visible: !isConnected + text: I18n.tr("Configuration will be preserved when this display reconnects") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: isConnected + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Scale") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + Item { + width: parent.width + height: scaleDropdown.visible ? scaleDropdown.height : scaleInput.height + + property bool customMode: false + property string currentScale: { + const pendingScale = root.getPendingValue(modelData, "scale"); + if (pendingScale !== undefined) + return parseFloat(pendingScale.toFixed(2)).toString(); + const scale = root.outputs[modelData]?.logical?.scale ?? 1.0; + return parseFloat(scale.toFixed(2)).toString(); + } + + DankDropdown { + id: scaleDropdown + width: parent.width + dropdownWidth: parent.width + visible: !parent.customMode + currentValue: parent.currentScale + options: { + const standard = ["0.5", "0.75", "1", "1.25", "1.5", "1.75", "2", "2.5", "3", I18n.tr("Custom...")]; + const current = parent.currentScale; + if (standard.slice(0, -1).includes(current)) + return standard; + const opts = [...standard.slice(0, -1), current, standard[standard.length - 1]]; + return opts.sort((a, b) => { + if (a === I18n.tr("Custom...")) + return 1; + if (b === I18n.tr("Custom...")) + return -1; + return parseFloat(a) - parseFloat(b); + }); + } + onValueChanged: value => { + if (value === I18n.tr("Custom...")) { + parent.customMode = true; + scaleInput.text = parent.currentScale; + scaleInput.forceActiveFocus(); + scaleInput.selectAll(); + return; + } + root.setPendingChange(modelData, "scale", parseFloat(value)); + } + } + + DankTextField { + id: scaleInput + width: parent.width + height: 40 + visible: parent.customMode + placeholderText: "0.5 - 4.0" + + function applyValue() { + const val = parseFloat(text); + if (isNaN(val) || val < 0.25 || val > 4) { + text = parent.currentScale; + parent.customMode = false; + return; + } + root.setPendingChange(modelData, "scale", parseFloat(val.toFixed(2))); + parent.customMode = false; + } + + onAccepted: applyValue() + onEditingFinished: applyValue() + Keys.onEscapePressed: { + text = parent.currentScale; + parent.customMode = false; + } + } + } + } + + Column { + width: (parent.width - Theme.spacingM) / 2 + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Transform") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankDropdown { + width: parent.width + dropdownWidth: parent.width + currentValue: { + const pendingTransform = root.getPendingValue(modelData, "transform"); + if (pendingTransform) + return root.getTransformLabel(pendingTransform); + const data = root.outputs[modelData]; + return root.getTransformLabel(data?.logical?.transform ?? "Normal"); + } + options: [I18n.tr("Normal"), I18n.tr("90°"), I18n.tr("180°"), I18n.tr("270°"), I18n.tr("Flipped"), I18n.tr("Flipped 90°"), I18n.tr("Flipped 180°"), I18n.tr("Flipped 270°")] + onValueChanged: value => root.setPendingChange(modelData, "transform", root.getTransformValue(value)) + } + } + } + + DankToggle { + width: parent.width + text: I18n.tr("Variable Refresh Rate") + visible: isConnected && !CompositorService.isDwl && (root.outputs[modelData]?.vrr_supported ?? false) + checked: { + const pendingVrr = root.getPendingValue(modelData, "vrr"); + if (pendingVrr !== undefined) + return pendingVrr; + return root.outputs[modelData]?.vrr_enabled ?? false; + } + onToggled: checked => root.setPendingChange(modelData, "vrr", checked) + } + } + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + visible: root.hasPendingChanges + layoutDirection: Qt.RightToLeft + + DankButton { + text: I18n.tr("Apply Changes") + iconName: "check" + onClicked: root.applyChanges() + } + + DankButton { + text: I18n.tr("Discard") + backgroundColor: "transparent" + textColor: Theme.surfaceText + onClicked: root.discardChanges() + } + } + } + } + + StyledRect { + width: parent.width + height: noBackendMessage.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: !hasOutputBackend + + Column { + id: noBackendMessage + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "monitor" + size: Theme.iconSize + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Monitor Configuration") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Display configuration is not available. WLR output management protocol not supported.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + } + } + } + } + } + } + + DisplayConfirmationModal { + id: confirmationModal + onConfirmed: root.confirmChanges() + onReverted: root.revertChanges() + } +} diff --git a/quickshell/Modules/Settings/DisplayWidgetsTab.qml b/quickshell/Modules/Settings/DisplayWidgetsTab.qml new file mode 100644 index 00000000..16bc58da --- /dev/null +++ b/quickshell/Modules/Settings/DisplayWidgetsTab.qml @@ -0,0 +1,497 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: root + + function getBarComponentsFromSettings() { + const bars = SettingsData.barConfigs || []; + return bars.map(bar => ({ + "id": "bar:" + bar.id, + "name": bar.name || "Bar", + "description": I18n.tr("Individual bar configuration"), + "icon": "toolbar", + "barId": bar.id + })); + } + + property var variantComponents: getVariantComponentsList() + + function getVariantComponentsList() { + return [...getBarComponentsFromSettings(), + { + "id": "dock", + "name": I18n.tr("Application Dock"), + "description": I18n.tr("Bottom dock for pinned and running applications"), + "icon": "dock" + }, + { + "id": "notifications", + "name": I18n.tr("Notification Popups"), + "description": I18n.tr("Notification toast popups"), + "icon": "notifications" + }, + { + "id": "wallpaper", + "name": I18n.tr("Wallpaper"), + "description": I18n.tr("Desktop background images"), + "icon": "wallpaper" + }, + { + "id": "osd", + "name": I18n.tr("On-Screen Displays"), + "description": I18n.tr("Volume, brightness, and other system OSDs"), + "icon": "picture_in_picture" + }, + { + "id": "toast", + "name": I18n.tr("Toast Messages"), + "description": I18n.tr("System toast notifications"), + "icon": "campaign" + }, + { + "id": "notepad", + "name": I18n.tr("Notepad Slideout"), + "description": I18n.tr("Quick note-taking slideout panel"), + "icon": "sticky_note_2" + } + ]; + } + + Connections { + target: SettingsData + function onBarConfigsChanged() { + variantComponents = getVariantComponentsList(); + } + } + + function getScreenPreferences(componentId) { + if (componentId.startsWith("bar:")) { + const barId = componentId.substring(4); + const barConfig = SettingsData.getBarConfig(barId); + return barConfig?.screenPreferences || ["all"]; + } + return SettingsData.screenPreferences && SettingsData.screenPreferences[componentId] || ["all"]; + } + + function setScreenPreferences(componentId, screenNames) { + if (componentId.startsWith("bar:")) { + const barId = componentId.substring(4); + SettingsData.updateBarConfig(barId, { + "screenPreferences": screenNames + }); + return; + } + var prefs = SettingsData.screenPreferences || {}; + var newPrefs = Object.assign({}, prefs); + newPrefs[componentId] = screenNames; + SettingsData.set("screenPreferences", newPrefs); + } + + function getShowOnLastDisplay(componentId) { + if (componentId.startsWith("bar:")) { + const barId = componentId.substring(4); + const barConfig = SettingsData.getBarConfig(barId); + return barConfig?.showOnLastDisplay ?? true; + } + return SettingsData.showOnLastDisplay && SettingsData.showOnLastDisplay[componentId] || false; + } + + function setShowOnLastDisplay(componentId, enabled) { + if (componentId.startsWith("bar:")) { + const barId = componentId.substring(4); + SettingsData.updateBarConfig(barId, { + "showOnLastDisplay": enabled + }); + return; + } + var prefs = SettingsData.showOnLastDisplay || {}; + var newPrefs = Object.assign({}, prefs); + newPrefs[componentId] = enabled; + SettingsData.set("showOnLastDisplay", newPrefs); + } + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + StyledRect { + width: parent.width + height: screensInfoSection.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 + + Column { + id: screensInfoSection + + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: "monitor" + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Connected Displays") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: I18n.tr("Configure which displays show shell components") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + + Column { + width: parent.width + spacing: Theme.spacingXS + + Row { + width: parent.width + spacing: Theme.spacingM + + StyledText { + text: I18n.tr("Available Screens (") + Quickshell.screens.length + ")" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + Item { + width: 1 + height: 1 + Layout.fillWidth: true + } + + Column { + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: I18n.tr("Display Name Format") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + + DankButtonGroup { + id: displayModeGroup + model: [I18n.tr("Name"), I18n.tr("Model")] + currentIndex: SettingsData.displayNameMode === "model" ? 1 : 0 + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.displayNameMode = index === 1 ? "model" : "system"; + SettingsData.saveSettings(); + } + + Connections { + target: SettingsData + function onDisplayNameModeChanged() { + displayModeGroup.currentIndex = SettingsData.displayNameMode === "model" ? 1 : 0; + } + } + } + } + } + } + + Repeater { + model: Quickshell.screens + + delegate: Rectangle { + width: parent.width + height: screenRow.implicitHeight + Theme.spacingS * 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.3) + border.width: 0 + + Row { + id: screenRow + + anchors.fill: parent + anchors.margins: Theme.spacingS + spacing: Theme.spacingM + + DankIcon { + name: "desktop_windows" + size: Theme.iconSize - 4 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM * 2 + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS / 2 + + StyledText { + text: SettingsData.getScreenDisplayName(modelData) + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + Row { + spacing: Theme.spacingS + + property var wlrOutput: WlrOutputService.wlrOutputAvailable ? WlrOutputService.getOutput(modelData.name) : null + property var currentMode: wlrOutput?.currentMode + + StyledText { + text: { + if (parent.currentMode) { + return parent.currentMode.width + "×" + parent.currentMode.height + "@" + Math.round(parent.currentMode.refresh / 1000) + "Hz"; + } + return modelData.width + "×" + modelData.height; + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: "•" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: SettingsData.displayNameMode === "system" ? (modelData.model || "Unknown Model") : modelData.name + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + } + } + } + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingL + + Repeater { + model: root.variantComponents + + delegate: StyledRect { + width: parent.width + height: componentSection.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 + + Column { + id: componentSection + + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + + Row { + width: parent.width + spacing: Theme.spacingM + + DankIcon { + name: modelData.icon + size: Theme.iconSize + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM + spacing: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + + StyledText { + text: modelData.name + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: modelData.description + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingS + + StyledText { + text: I18n.tr("Show on screens:") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Medium + } + + Column { + property string componentId: modelData.id + + width: parent.width + spacing: Theme.spacingXS + + DankToggle { + width: parent.width + text: I18n.tr("All displays") + description: I18n.tr("Show on all connected displays") + checked: { + var prefs = root.getScreenPreferences(parent.componentId); + return prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all"); + } + onToggled: checked => { + if (checked) { + root.setScreenPreferences(parent.componentId, ["all"]); + } else { + root.setScreenPreferences(parent.componentId, []); + const cid = parent.componentId; + if (["dankBar", "dock", "notifications", "osd", "toast"].includes(cid) || cid.startsWith("bar:")) { + root.setShowOnLastDisplay(cid, true); + } + } + } + } + + DankToggle { + width: parent.width + text: I18n.tr("Show on Last Display") + description: I18n.tr("Always show when there's only one connected display") + checked: root.getShowOnLastDisplay(parent.componentId) + visible: { + const prefs = root.getScreenPreferences(parent.componentId); + const isAll = prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all"); + const cid = parent.componentId; + const isRelevantComponent = ["dankBar", "dock", "notifications", "osd", "toast", "notepad"].includes(cid) || cid.startsWith("bar:"); + return !isAll && isRelevantComponent; + } + onToggled: checked => { + root.setShowOnLastDisplay(parent.componentId, checked); + } + } + + Rectangle { + width: parent.width + height: 1 + color: Theme.outline + opacity: 0.2 + visible: { + var prefs = root.getScreenPreferences(parent.componentId); + return !prefs.includes("all") && !(typeof prefs[0] === "string" && prefs[0] === "all"); + } + } + + Column { + width: parent.width + spacing: Theme.spacingXS + visible: { + var prefs = root.getScreenPreferences(parent.componentId); + return !prefs.includes("all") && !(typeof prefs[0] === "string" && prefs[0] === "all"); + } + + Repeater { + model: Quickshell.screens + + delegate: DankToggle { + property var screenData: modelData + property string componentId: parent.parent.componentId + + width: parent.width + text: SettingsData.getScreenDisplayName(screenData) + description: screenData.width + "×" + screenData.height + " • " + (SettingsData.displayNameMode === "system" ? (screenData.model || "Unknown Model") : screenData.name) + checked: { + var prefs = root.getScreenPreferences(componentId); + if (typeof prefs[0] === "string" && prefs[0] === "all") + return false; + return SettingsData.isScreenInPreferences(screenData, prefs); + } + onToggled: checked => { + var currentPrefs = root.getScreenPreferences(componentId); + if (typeof currentPrefs[0] === "string" && currentPrefs[0] === "all") { + currentPrefs = []; + } + + const screenModelIndex = SettingsData.getScreenModelIndex(screenData); + + var newPrefs = currentPrefs.filter(pref => { + if (typeof pref === "string") + return false; + if (pref.modelIndex !== undefined && screenModelIndex >= 0) { + return !(pref.model === screenData.model && pref.modelIndex === screenModelIndex); + } + return pref.name !== screenData.name || pref.model !== screenData.model; + }); + + if (checked) { + const prefObj = { + "name": screenData.name, + "model": screenData.model || "" + }; + if (screenModelIndex >= 0) { + prefObj.modelIndex = screenModelIndex; + } + newPrefs.push(prefObj); + } + + root.setScreenPreferences(componentId, newPrefs); + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/quickshell/Modules/Settings/DisplaysTab.qml b/quickshell/Modules/Settings/GammaControlTab.qml similarity index 57% rename from quickshell/Modules/Settings/DisplaysTab.qml rename to quickshell/Modules/Settings/GammaControlTab.qml index 4b6144a5..05d91d37 100644 --- a/quickshell/Modules/Settings/DisplaysTab.qml +++ b/quickshell/Modules/Settings/GammaControlTab.qml @@ -1,12 +1,10 @@ import QtQuick -import QtQuick.Layouts -import Quickshell import qs.Common import qs.Services import qs.Widgets Item { - id: displaysTab + id: root function formatGammaTime(isoString) { if (!isoString) @@ -21,113 +19,6 @@ Item { } } - function getBarComponentsFromSettings() { - const bars = SettingsData.barConfigs || []; - return bars.map(bar => ({ - "id": "bar:" + bar.id, - "name": bar.name || "Bar", - "description": I18n.tr("Individual bar configuration"), - "icon": "toolbar", - "barId": bar.id - })); - } - - property var variantComponents: getVariantComponentsList() - - function getVariantComponentsList() { - return [...getBarComponentsFromSettings(), - { - "id": "dock", - "name": I18n.tr("Application Dock"), - "description": I18n.tr("Bottom dock for pinned and running applications"), - "icon": "dock" - }, - { - "id": "notifications", - "name": I18n.tr("Notification Popups"), - "description": I18n.tr("Notification toast popups"), - "icon": "notifications" - }, - { - "id": "wallpaper", - "name": I18n.tr("Wallpaper"), - "description": I18n.tr("Desktop background images"), - "icon": "wallpaper" - }, - { - "id": "osd", - "name": I18n.tr("On-Screen Displays"), - "description": I18n.tr("Volume, brightness, and other system OSDs"), - "icon": "picture_in_picture" - }, - { - "id": "toast", - "name": I18n.tr("Toast Messages"), - "description": I18n.tr("System toast notifications"), - "icon": "campaign" - }, - { - "id": "notepad", - "name": I18n.tr("Notepad Slideout"), - "description": I18n.tr("Quick note-taking slideout panel"), - "icon": "sticky_note_2" - }, - ]; - } - - Connections { - target: SettingsData - function onBarConfigsChanged() { - variantComponents = getVariantComponentsList(); - } - } - - function getScreenPreferences(componentId) { - if (componentId.startsWith("bar:")) { - const barId = componentId.substring(4); - const barConfig = SettingsData.getBarConfig(barId); - return barConfig?.screenPreferences || ["all"]; - } - return SettingsData.screenPreferences && SettingsData.screenPreferences[componentId] || ["all"]; - } - - function setScreenPreferences(componentId, screenNames) { - if (componentId.startsWith("bar:")) { - const barId = componentId.substring(4); - SettingsData.updateBarConfig(barId, { - screenPreferences: screenNames - }); - return; - } - var prefs = SettingsData.screenPreferences || {}; - var newPrefs = Object.assign({}, prefs); - newPrefs[componentId] = screenNames; - SettingsData.set("screenPreferences", newPrefs); - } - - function getShowOnLastDisplay(componentId) { - if (componentId.startsWith("bar:")) { - const barId = componentId.substring(4); - const barConfig = SettingsData.getBarConfig(barId); - return barConfig?.showOnLastDisplay ?? true; - } - return SettingsData.showOnLastDisplay && SettingsData.showOnLastDisplay[componentId] || false; - } - - function setShowOnLastDisplay(componentId, enabled) { - if (componentId.startsWith("bar:")) { - const barId = componentId.substring(4); - SettingsData.updateBarConfig(barId, { - showOnLastDisplay: enabled - }); - return; - } - var prefs = SettingsData.showOnLastDisplay || {}; - var newPrefs = Object.assign({}, prefs); - newPrefs[componentId] = enabled; - SettingsData.set("showOnLastDisplay", newPrefs); - } - DankFlickable { anchors.fill: parent clip: true @@ -678,7 +569,7 @@ Item { } StyledText { - text: displaysTab.formatGammaTime(DisplayService.gammaSunriseTime) + text: root.formatGammaTime(DisplayService.gammaSunriseTime) font.pixelSize: Theme.fontSizeLarge font.weight: Font.Medium color: Theme.surfaceText @@ -714,7 +605,7 @@ Item { } StyledText { - text: displaysTab.formatGammaTime(DisplayService.gammaSunsetTime) + text: root.formatGammaTime(DisplayService.gammaSunsetTime) font.pixelSize: Theme.fontSizeLarge font.weight: Font.Medium color: Theme.surfaceText @@ -761,7 +652,7 @@ Item { } StyledText { - text: displaysTab.formatGammaTime(DisplayService.gammaNextTransition) + text: root.formatGammaTime(DisplayService.gammaNextTransition) font.pixelSize: Theme.fontSizeMedium font.weight: Font.Medium color: Theme.surfaceText @@ -773,371 +664,6 @@ Item { } } } - - StyledRect { - width: parent.width - height: screensInfoSection.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 - - Column { - id: screensInfoSection - - anchors.fill: parent - anchors.margins: Theme.spacingL - spacing: Theme.spacingM - - Row { - width: parent.width - spacing: Theme.spacingM - - DankIcon { - name: "monitor" - size: Theme.iconSize - color: Theme.primary - anchors.verticalCenter: parent.verticalCenter - } - - Column { - width: parent.width - Theme.iconSize - Theme.spacingM - spacing: Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - - StyledText { - text: I18n.tr("Connected Displays") - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.Medium - color: Theme.surfaceText - } - - StyledText { - text: I18n.tr("Configure which displays show shell components") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - wrapMode: Text.WordWrap - width: parent.width - } - } - } - - Column { - width: parent.width - spacing: Theme.spacingS - - Column { - width: parent.width - spacing: Theme.spacingXS - - Row { - width: parent.width - spacing: Theme.spacingM - - StyledText { - text: I18n.tr("Available Screens (") + Quickshell.screens.length + ")" - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium - color: Theme.surfaceText - } - - Item { - width: 1 - height: 1 - Layout.fillWidth: true - } - - Column { - spacing: Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - - StyledText { - text: I18n.tr("Display Name Format") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - anchors.horizontalCenter: parent.horizontalCenter - } - - DankButtonGroup { - id: displayModeGroup - model: [I18n.tr("Name"), I18n.tr("Model")] - currentIndex: SettingsData.displayNameMode === "model" ? 1 : 0 - onSelectionChanged: (index, selected) => { - if (!selected) - return; - SettingsData.displayNameMode = index === 1 ? "model" : "system"; - SettingsData.saveSettings(); - } - - Connections { - target: SettingsData - function onDisplayNameModeChanged() { - displayModeGroup.currentIndex = SettingsData.displayNameMode === "model" ? 1 : 0; - } - } - } - } - } - } - - Repeater { - model: Quickshell.screens - - delegate: Rectangle { - width: parent.width - height: screenRow.implicitHeight + Theme.spacingS * 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.3) - border.width: 0 - - Row { - id: screenRow - - anchors.fill: parent - anchors.margins: Theme.spacingS - spacing: Theme.spacingM - - DankIcon { - name: "desktop_windows" - size: Theme.iconSize - 4 - color: Theme.primary - anchors.verticalCenter: parent.verticalCenter - } - - Column { - width: parent.width - Theme.iconSize - Theme.spacingM * 2 - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS / 2 - - StyledText { - text: SettingsData.getScreenDisplayName(modelData) - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium - color: Theme.surfaceText - } - - Row { - spacing: Theme.spacingS - - property var wlrOutput: WlrOutputService.wlrOutputAvailable ? WlrOutputService.getOutput(modelData.name) : null - property var currentMode: wlrOutput?.currentMode - - StyledText { - text: { - if (parent.currentMode) { - return parent.currentMode.width + "×" + parent.currentMode.height + "@" + Math.round(parent.currentMode.refresh / 1000) + "Hz"; - } - return modelData.width + "×" + modelData.height; - } - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - - StyledText { - text: "•" - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - - StyledText { - text: SettingsData.displayNameMode === "system" ? (modelData.model || "Unknown Model") : modelData.name - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - } - } - } - } - } - } - } - } - } - - Column { - width: parent.width - spacing: Theme.spacingL - - Repeater { - model: displaysTab.variantComponents - - delegate: StyledRect { - width: parent.width - height: componentSection.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 - - Column { - id: componentSection - - anchors.fill: parent - anchors.margins: Theme.spacingL - spacing: Theme.spacingM - - Row { - width: parent.width - spacing: Theme.spacingM - - DankIcon { - name: modelData.icon - size: Theme.iconSize - color: Theme.primary - anchors.verticalCenter: parent.verticalCenter - } - - Column { - width: parent.width - Theme.iconSize - Theme.spacingM - spacing: Theme.spacingXS - anchors.verticalCenter: parent.verticalCenter - - StyledText { - text: modelData.name - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.Medium - color: Theme.surfaceText - } - - StyledText { - text: modelData.description - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - wrapMode: Text.WordWrap - width: parent.width - } - } - } - - Column { - width: parent.width - spacing: Theme.spacingS - - StyledText { - text: I18n.tr("Show on screens:") - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceText - font.weight: Font.Medium - } - - Column { - property string componentId: modelData.id - - width: parent.width - spacing: Theme.spacingXS - - DankToggle { - width: parent.width - text: I18n.tr("All displays") - description: I18n.tr("Show on all connected displays") - checked: { - var prefs = displaysTab.getScreenPreferences(parent.componentId); - return prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all"); - } - onToggled: checked => { - if (checked) { - displaysTab.setScreenPreferences(parent.componentId, ["all"]); - } else { - displaysTab.setScreenPreferences(parent.componentId, []); - const cid = parent.componentId; - if (["dankBar", "dock", "notifications", "osd", "toast"].includes(cid) || cid.startsWith("bar:")) { - displaysTab.setShowOnLastDisplay(cid, true); - } - } - } - } - - DankToggle { - width: parent.width - text: I18n.tr("Show on Last Display") - description: I18n.tr("Always show when there's only one connected display") - checked: displaysTab.getShowOnLastDisplay(parent.componentId) - visible: { - const prefs = displaysTab.getScreenPreferences(parent.componentId); - const isAll = prefs.includes("all") || (typeof prefs[0] === "string" && prefs[0] === "all"); - const cid = parent.componentId; - const isRelevantComponent = ["dankBar", "dock", "notifications", "osd", "toast", "notepad"].includes(cid) || cid.startsWith("bar:"); - return !isAll && isRelevantComponent; - } - onToggled: checked => { - displaysTab.setShowOnLastDisplay(parent.componentId, checked); - } - } - - Rectangle { - width: parent.width - height: 1 - color: Theme.outline - opacity: 0.2 - visible: { - var prefs = displaysTab.getScreenPreferences(parent.componentId); - return !prefs.includes("all") && !(typeof prefs[0] === "string" && prefs[0] === "all"); - } - } - - Column { - width: parent.width - spacing: Theme.spacingXS - visible: { - var prefs = displaysTab.getScreenPreferences(parent.componentId); - return !prefs.includes("all") && !(typeof prefs[0] === "string" && prefs[0] === "all"); - } - - Repeater { - model: Quickshell.screens - - delegate: DankToggle { - property var screenData: modelData - property string componentId: parent.parent.componentId - - width: parent.width - text: SettingsData.getScreenDisplayName(screenData) - description: screenData.width + "×" + screenData.height + " • " + (SettingsData.displayNameMode === "system" ? (screenData.model || "Unknown Model") : screenData.name) - checked: { - var prefs = displaysTab.getScreenPreferences(componentId); - if (typeof prefs[0] === "string" && prefs[0] === "all") - return false; - return SettingsData.isScreenInPreferences(screenData, prefs); - } - onToggled: checked => { - var currentPrefs = displaysTab.getScreenPreferences(componentId); - if (typeof currentPrefs[0] === "string" && currentPrefs[0] === "all") { - currentPrefs = []; - } - - const screenModelIndex = SettingsData.getScreenModelIndex(screenData); - - var newPrefs = currentPrefs.filter(pref => { - if (typeof pref === "string") - return false; - if (pref.modelIndex !== undefined && screenModelIndex >= 0) { - return !(pref.model === screenData.model && pref.modelIndex === screenModelIndex); - } - return pref.name !== screenData.name || pref.model !== screenData.model; - }); - - if (checked) { - const prefObj = { - name: screenData.name, - model: screenData.model || "" - }; - if (screenModelIndex >= 0) { - prefObj.modelIndex = screenModelIndex; - } - newPrefs.push(prefObj); - } - - displaysTab.setScreenPreferences(componentId, newPrefs); - } - } - } - } - } - } - } - } - } - } } } } diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 45ffa93e..ad35caed 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -328,36 +328,21 @@ Item { } } - Rectangle { + DankButton { id: fixButton - width: fixButtonText.implicitWidth + Theme.spacingL * 2 - height: Math.round(Theme.fontSizeMedium * 2.5) - radius: Theme.cornerRadius visible: warningBox.showError || warningBox.showSetup - color: KeybindsService.fixing ? Theme.withAlpha(Theme.error, 0.6) : Theme.error + text: { + if (KeybindsService.fixing) + return I18n.tr("Fixing...") + if (warningBox.showSetup) + return I18n.tr("Setup") + return I18n.tr("Fix Now") + } + backgroundColor: Theme.primary + textColor: Theme.primaryText + enabled: !KeybindsService.fixing anchors.verticalCenter: parent.verticalCenter - - StyledText { - id: fixButtonText - text: { - if (KeybindsService.fixing) - return I18n.tr("Fixing..."); - if (warningBox.showSetup) - return I18n.tr("Setup"); - return I18n.tr("Fix Now"); - } - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - color: Theme.surface - anchors.centerIn: parent - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - enabled: !KeybindsService.fixing - onClicked: KeybindsService.fixDmsBindsInclude() - } + onClicked: KeybindsService.fixDmsBindsInclude() } } } diff --git a/quickshell/Services/DwlService.qml b/quickshell/Services/DwlService.qml index d7c89b87..2de12ff2 100644 --- a/quickshell/Services/DwlService.qml +++ b/quickshell/Services/DwlService.qml @@ -1,13 +1,19 @@ pragma Singleton pragma ComponentBehavior: Bound +import QtCore import QtQuick import Quickshell import Quickshell.Io +import qs.Common Singleton { id: root + readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + readonly property string mangoDmsDir: configDir + "/mango/dms" + readonly property string outputsPath: mangoDmsDir + "/outputs.conf" + property bool dwlAvailable: false property var outputs: ({}) property var tagCount: 9 @@ -263,4 +269,80 @@ Singleton { return Array.from(visibleTags).sort((a, b) => a - b); } + + function generateOutputsConfig(outputsData) { + if (!outputsData || Object.keys(outputsData).length === 0) + return; + let lines = ["# Auto-generated by DMS - do not edit manually", "# VRR is global: set adaptive_sync=1 in config.conf", ""]; + + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (!output) + continue; + let width = 1920; + let height = 1080; + let refreshRate = 60; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) { + width = mode.width || 1920; + height = mode.height || 1080; + refreshRate = Math.round((mode.refresh_rate || 60000) / 1000); + } + } + + const x = output.logical?.x ?? 0; + const y = output.logical?.y ?? 0; + const scale = output.logical?.scale ?? 1.0; + const transform = transformToMango(output.logical?.transform ?? "Normal"); + + const rule = [outputName, "0.55", "1", "tile", transform, scale, x, y, width, height, refreshRate].join(","); + + lines.push("monitorrule=" + rule); + } + + lines.push(""); + + const content = lines.join("\n"); + + Proc.runCommand("mango-write-outputs", ["sh", "-c", `mkdir -p "${mangoDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { + if (exitCode !== 0) { + console.warn("DwlService: Failed to write outputs config:", output); + return; + } + console.info("DwlService: Generated outputs config at", outputsPath); + if (CompositorService.isDwl) + reloadConfig(); + }); + } + + function reloadConfig() { + Proc.runCommand("mango-reload", ["mmsg", "-d", "reload_config"], (output, exitCode) => { + if (exitCode !== 0) + console.warn("DwlService: mmsg reload_config failed:", output); + }); + } + + function transformToMango(transform) { + switch (transform) { + case "Normal": + return 0; + case "90": + return 1; + case "180": + return 2; + case "270": + return 3; + case "Flipped": + return 4; + case "Flipped90": + return 5; + case "Flipped180": + return 6; + case "Flipped270": + return 7; + default: + return 0; + } + } } diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml new file mode 100644 index 00000000..1db07c74 --- /dev/null +++ b/quickshell/Services/HyprlandService.qml @@ -0,0 +1,125 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtCore +import QtQuick +import Quickshell +import qs.Common + +Singleton { + id: root + + readonly property string configDir: Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)) + readonly property string hyprDmsDir: configDir + "/hypr/dms" + readonly property string outputsPath: hyprDmsDir + "/outputs.conf" + + function getOutputIdentifier(output, outputName) { + if (SettingsData.displayNameMode === "model" && output.make && output.model) { + return "desc:" + output.make + " " + output.model; + } + return outputName; + } + + function generateOutputsConfig(outputsData) { + if (!outputsData || Object.keys(outputsData).length === 0) + return; + let lines = ["# Auto-generated by DMS - do not edit manually", ""]; + + for (const outputName in outputsData) { + const output = outputsData[outputName]; + if (!output) + continue; + let resolution = "preferred"; + if (output.modes && output.current_mode !== undefined) { + const mode = output.modes[output.current_mode]; + if (mode) + resolution = mode.width + "x" + mode.height + "@" + (mode.refresh_rate / 1000).toFixed(3); + } + + const x = output.logical?.x ?? 0; + const y = output.logical?.y ?? 0; + const position = x + "x" + y; + + const scale = output.logical?.scale ?? 1.0; + + const identifier = getOutputIdentifier(output, outputName); + let monitorLine = "monitor = " + identifier + ", " + resolution + ", " + position + ", " + scale; + + const transform = transformToHyprland(output.logical?.transform ?? "Normal"); + if (transform !== 0) + monitorLine += ", transform, " + transform; + + if (output.vrr_supported && output.vrr_enabled) + monitorLine += ", vrr, 1"; + + lines.push(monitorLine); + } + + lines.push(""); + + const content = lines.join("\n"); + + Proc.runCommand("hypr-write-outputs", ["sh", "-c", `mkdir -p "${hyprDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${content}EOF`], (output, exitCode) => { + if (exitCode !== 0) { + console.warn("HyprlandService: Failed to write outputs config:", output); + return; + } + console.info("HyprlandService: Generated outputs config at", outputsPath); + if (CompositorService.isHyprland) + reloadConfig(); + }); + } + + function reloadConfig() { + Proc.runCommand("hyprctl-reload", ["hyprctl", "reload"], (output, exitCode) => { + if (exitCode !== 0) + console.warn("HyprlandService: hyprctl reload failed:", output); + }); + } + + function transformToHyprland(transform) { + switch (transform) { + case "Normal": + return 0; + case "90": + return 1; + case "180": + return 2; + case "270": + return 3; + case "Flipped": + return 4; + case "Flipped90": + return 5; + case "Flipped180": + return 6; + case "Flipped270": + return 7; + default: + return 0; + } + } + + function hyprlandToTransform(value) { + switch (value) { + case 0: + return "Normal"; + case 1: + return "90"; + case 2: + return "180"; + case 3: + return "270"; + case 4: + return "Flipped"; + case 5: + return "Flipped90"; + case 6: + return "Flipped180"; + case 7: + return "Flipped270"; + default: + return "Normal"; + } + } +} diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index 482a386b..d45e3191 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -1,5 +1,5 @@ pragma Singleton -pragma ComponentBehavior: Bound +pragma ComponentBehavior import QtCore import QtQuick @@ -23,6 +23,8 @@ Singleton { property var windows: [] property var displayScales: ({}) + property var _realOutputs: ({}) + property bool inOverview: false property int currentKeyboardLayoutIndex: 0 @@ -214,12 +216,12 @@ Singleton { const ws = workspaces[w.workspace_id]; if (!ws) { return { - window: w, - outputX: 999999, - outputY: 999999, - wsIdx: 999999, - col: 999999, - row: 999999 + "window": w, + "outputX": 999999, + "outputY": 999999, + "wsIdx": 999999, + "col": 999999, + "row": 999999 }; } @@ -232,12 +234,12 @@ Singleton { const row = (pos && pos.length >= 2) ? pos[1] : 999999; return { - window: w, - outputX: outputX, - outputY: outputY, - wsIdx: ws.idx, - col: col, - row: row + "window": w, + "outputX": outputX, + "outputY": outputY, + "wsIdx": ws.idx, + "col": col, + "row": row }; }); @@ -598,7 +600,7 @@ Singleton { const editor = Quickshell.env("DMS_SCREENSHOT_EDITOR"); const command = editor === "satty" ? ["satty", "-f", data.path] : ["swappy", "-f", data.path]; Quickshell.execDetached({ - command: command + "command": command }); pendingScreenshotPath = ""; } @@ -993,35 +995,35 @@ Singleton { const gaps = typeof SettingsData !== "undefined" ? Math.max(4, (SettingsData.barConfigs[0]?.spacing ?? 4)) : 4; const dmsWarning = `// ! DO NOT EDIT ! -// ! AUTO-GENERATED BY DMS ! -// ! CHANGES WILL BE OVERWRITTEN ! -// ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE ! + // ! AUTO-GENERATED BY DMS ! + // ! CHANGES WILL BE OVERWRITTEN ! + // ! PLACE YOUR CUSTOM CONFIGURATION ELSEWHERE ! -`; + `; const configContent = dmsWarning + `layout { - gaps ${gaps} + gaps ${gaps} - border { + border { width 2 - } + } - focus-ring { + focus-ring { width 2 - } -} -window-rule { - geometry-corner-radius ${cornerRadius} - clip-to-geometry true - tiled-state true - draw-border-with-background false -}`; + } + } + window-rule { + geometry-corner-radius ${cornerRadius} + clip-to-geometry true + tiled-state true + draw-border-with-background false + }`; const alttabContent = dmsWarning + `recent-windows { - highlight { + highlight { corner-radius ${cornerRadius} - } -}`; + } + }`; const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); const niriDmsDir = configDir + "/niri/dms"; @@ -1054,6 +1056,141 @@ window-rule { writeBlurruleProcess.running = true; } + function updateOutputPosition(outputName, x, y) { + if (!outputs || !outputs[outputName]) + return; + const updatedOutputs = {}; + for (const name in outputs) { + const output = outputs[name]; + if (name === outputName && output.logical) { + updatedOutputs[name] = JSON.parse(JSON.stringify(output)); + updatedOutputs[name].logical.x = x; + updatedOutputs[name].logical.y = y; + } else { + updatedOutputs[name] = output; + } + } + outputs = updatedOutputs; + } + + function applyOutputConfig(outputName, config, callback) { + if (!CompositorService.isNiri || !outputName) { + if (callback) + callback(false, "Invalid config"); + return; + } + + const commands = []; + + if (config.position !== undefined) { + commands.push(`niri msg output "${outputName}" position ${config.position.x} ${config.position.y}`); + } + + if (config.mode !== undefined) { + commands.push(`niri msg output "${outputName}" mode ${config.mode}`); + } + + if (config.vrr !== undefined) { + commands.push(`niri msg output "${outputName}" vrr ${config.vrr ? "on" : "off"}`); + } + + if (config.scale !== undefined) { + commands.push(`niri msg output "${outputName}" scale ${config.scale}`); + } + + if (config.transform !== undefined) { + commands.push(`niri msg output "${outputName}" transform "${config.transform}"`); + } + + if (commands.length === 0) { + if (callback) + callback(true, "No changes"); + return; + } + + const fullCommand = commands.join(" && "); + Proc.runCommand("niri-output-config", ["sh", "-c", fullCommand], (output, exitCode) => { + if (exitCode !== 0) { + console.warn("NiriService: Failed to apply output config:", output); + if (callback) + callback(false, output); + return; + } + console.info("NiriService: Applied output config for", outputName); + fetchOutputs(); + if (callback) + callback(true, "Success"); + }); + } + + function getOutputIdentifier(output, outputName) { + if (SettingsData.displayNameMode === "model" && output.make && output.model) { + const serial = output.serial || "Unknown"; + return output.make + " " + output.model + " " + serial; + } + return outputName; + } + + function generateOutputsConfig(outputsData) { + const data = outputsData || outputs; + if (!data || Object.keys(data).length === 0) + return; + let kdlContent = `// Auto-generated by DMS - do not edit manually\n\n`; + + for (const outputName in data) { + const output = data[outputName]; + const identifier = getOutputIdentifier(output, outputName); + kdlContent += `output "${identifier}" {\n`; + + if (output.current_mode !== undefined && output.modes && output.modes[output.current_mode]) { + const mode = output.modes[output.current_mode]; + kdlContent += ` mode "${mode.width}x${mode.height}@${(mode.refresh_rate / 1000).toFixed(3)}"\n`; + } + + if (output.logical) { + if (output.logical.scale && output.logical.scale !== 1.0) { + kdlContent += ` scale ${output.logical.scale}\n`; + } + + if (output.logical.transform && output.logical.transform !== "Normal") { + const transformMap = { + "Normal": "normal", + "90": "90", + "180": "180", + "270": "270", + "Flipped": "flipped", + "Flipped90": "flipped-90", + "Flipped180": "flipped-180", + "Flipped270": "flipped-270" + }; + kdlContent += ` transform "${transformMap[output.logical.transform] || "normal"}"\n`; + } + + if (output.logical.x !== undefined && output.logical.y !== undefined) { + kdlContent += ` position x=${output.logical.x} y=${output.logical.y}\n`; + } + } + + if (output.vrr_enabled) { + kdlContent += ` variable-refresh-rate\n`; + } + + kdlContent += `}\n\n`; + } + + const configDir = Paths.strip(StandardPaths.writableLocation(StandardPaths.ConfigLocation)); + const niriDmsDir = configDir + "/niri/dms"; + const outputsPath = niriDmsDir + "/outputs.kdl"; + + Proc.runCommand("niri-write-outputs", ["sh", "-c", `mkdir -p "${niriDmsDir}" && cat > "${outputsPath}" << 'EOF'\n${kdlContent}EOF`], (output, exitCode) => { + if (exitCode !== 0) { + console.warn("NiriService: Failed to write outputs config:", output); + return; + } + console.info("NiriService: Generated outputs config at", outputsPath); + }); + } + IpcHandler { function screenshot(): string { if (!CompositorService.isNiri) {