From 05842711f5bbc8fe024592123f70a3c434697479 Mon Sep 17 00:00:00 2001 From: Valentine Omonya Date: Fri, 26 Jun 2026 22:25:12 +0300 Subject: [PATCH 1/3] feat: add nexus displays configuration page This adds the Displays/Monitors settings page to Nexus, allowing users to identify, arrange, scale, rotate, and set refresh rates for their monitors. Includes: - Monitors service (services/Monitors.qml) wrapping Hyprland keyword command batching. - Hyprctl service (services/Hyprctl.qml) for querying active monitor status. - Monitor identifier overlay (modules/MonitorIdentifier.qml). --- modules/MonitorIdentifier.qml | 84 +++ modules/dashboard/Monitors.qml | 250 ++++++++ modules/nexus/NexusState.qml | 1 + modules/nexus/PageCompRegistry.qml | 12 + modules/nexus/PageRegistry.qml | 13 +- .../nexus/pages/monitors/MonitorDetail.qml | 541 ++++++++++++++++++ modules/nexus/pages/monitors/MonitorsPane.qml | 359 ++++++++++++ services/Hyprctl.qml | 49 ++ services/Monitors.qml | 149 +++++ shell.qml | 3 + 10 files changed, 1454 insertions(+), 7 deletions(-) create mode 100644 modules/MonitorIdentifier.qml create mode 100644 modules/dashboard/Monitors.qml create mode 100644 modules/nexus/pages/monitors/MonitorDetail.qml create mode 100644 modules/nexus/pages/monitors/MonitorsPane.qml create mode 100644 services/Hyprctl.qml create mode 100644 services/Monitors.qml diff --git a/modules/MonitorIdentifier.qml b/modules/MonitorIdentifier.qml new file mode 100644 index 000000000..ff0b6bfc5 --- /dev/null +++ b/modules/MonitorIdentifier.qml @@ -0,0 +1,84 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Caelestia.Config +import qs.components +import qs.components.containers +import qs.services + +Variants { + id: root + + readonly property bool active: Monitors.identifying + + model: Quickshell.screens + + StyledWindow { + id: win + + required property ShellScreen modelData + readonly property var monitor: Hypr.monitorFor(modelData) + + screen: modelData + name: "monitor-identifier" + visible: root.active || identifierRect.opacity > 0 + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.exclusionMode: ExclusionMode.Ignore + + anchors.top: true + anchors.bottom: true + anchors.left: true + anchors.right: true + + // Click anywhere on the overlay to dismiss + MouseArea { + anchors.fill: parent + onClicked: Monitors.stopIdentification() + } + + StyledRect { + id: identifierRect + + anchors.centerIn: parent + implicitWidth: Tokens.padding.large * 14 + implicitHeight: Tokens.padding.large * 14 + radius: Tokens.rounding.large + color: Colours.tPalette.m3surfaceContainer + opacity: root.active ? 0.92 : 0 + + // Prevent the MouseArea behind from stealing this click + MouseArea { + anchors.fill: parent + onClicked: Monitors.stopIdentification() + } + + ColumnLayout { + anchors.centerIn: parent + spacing: Tokens.spacing.small + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: win.monitor?.id ?? "?" + font.pointSize: 96 + font.bold: true + color: Colours.palette.m3primary + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: win.monitor?.name ?? "" + font: Tokens.font.body.medium + color: Colours.palette.m3onSurfaceVariant + } + } + + Behavior on opacity { + Anim {} + } + } + } +} diff --git a/modules/dashboard/Monitors.qml b/modules/dashboard/Monitors.qml new file mode 100644 index 000000000..bbf969082 --- /dev/null +++ b/modules/dashboard/Monitors.qml @@ -0,0 +1,250 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +ColumnLayout { + id: root + + spacing: Tokens.spacing.large + + RowLayout { + Layout.fillWidth: true + Layout.margins: Tokens.padding.medium + + StyledText { + text: qsTr("Monitors") + font: Tokens.font.title.large + Layout.fillWidth: true + } + + IconTextButton { + icon: "info" + text: qsTr("Identify") + isToggle: true + checked: Monitors.identifying + onClicked: Monitors.toggleIdentification() + } + } + + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: monitorsLayout.implicitHeight + clip: true + + ColumnLayout { + id: monitorsLayout + + anchors.left: parent.left + anchors.right: parent.right + spacing: Tokens.spacing.medium + + Repeater { + model: Hyprctl.monitors + + delegate: StyledRect { + id: monitorDelegate + + required property var modelData + + readonly property var mon: monitorDelegate.modelData + readonly property var brightnessMon: Brightness.getMonitor(monitorDelegate.mon.name) + + Layout.fillWidth: true + implicitHeight: monitorContent.implicitHeight + Tokens.padding.large * 2 + color: Colours.tPalette.m3surfaceContainerHigh + radius: Tokens.rounding.large + + ColumnLayout { + id: monitorContent + + anchors.fill: parent + anchors.margins: Tokens.padding.large + spacing: Tokens.spacing.medium + + RowLayout { + Layout.fillWidth: true + MaterialIcon { + text: "monitor" + color: Colours.palette.m3primary + } + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + text: `${monitorDelegate.mon.name} - ${monitorDelegate.mon.make} ${monitorDelegate.mon.model}` + font: Tokens.font.title.medium + Layout.fillWidth: true + } + StyledText { + text: `${monitorDelegate.mon.width}x${monitorDelegate.mon.height}@${(monitorDelegate.mon.refreshRate ?? 0).toFixed(2)}Hz` + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + } + StyledText { + text: `ID: ${monitorDelegate.mon.id}` + color: Colours.palette.m3onSurfaceVariant + } + } + + // Brightness + RowLayout { + Layout.fillWidth: true + visible: !!monitorDelegate.brightnessMon + + MaterialIcon { + text: "brightness_medium" + fontStyle: Tokens.font.icon.medium + } + + StyledSlider { + Layout.fillWidth: true + value: monitorDelegate.brightnessMon?.brightness ?? 0 + onMoved: if (monitorDelegate.brightnessMon) + monitorDelegate.brightnessMon.setBrightness(value) + } + + StyledText { + text: `${Math.round((monitorDelegate.brightnessMon?.brightness ?? 0) * 100)}%` + Layout.preferredWidth: 40 + } + } + + // Scaling + RowLayout { + Layout.fillWidth: true + + MaterialIcon { + text: "zoom_in" + fontStyle: Tokens.font.icon.medium + } + + StyledSlider { + Layout.fillWidth: true + from: 0.5 + to: 3.0 + value: monitorDelegate.mon.scale + onMoved: Monitors.setScale(monitorDelegate.mon.name, value) + } + + StyledText { + text: `${monitorDelegate.mon.scale.toFixed(2)}x` + Layout.preferredWidth: 40 + } + } + + // Refresh Rate + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Refresh Rate") + Layout.fillWidth: true + } + + CustomSpinBox { + id: rrSelector + + min: 10 + max: 1000 + step: 1 + value: monitorDelegate.mon.refreshRate + onValueModified: val => Monitors.setRefreshRate(monitorDelegate.mon.name, val) + } + } + + // Rotation + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Rotation") + Layout.fillWidth: true + } + + Repeater { + model: [ + { + label: "0°", + val: 0, + icon: "screen_rotation" + }, + { + label: "90°", + val: 1, + icon: "screen_rotation" + }, + { + label: "180°", + val: 2, + icon: "screen_rotation" + }, + { + label: "270°", + val: 3, + icon: "screen_rotation" + } + ] + + delegate: IconButton { + required property var modelData + required property int index + + icon: modelData.icon + isToggle: true + checked: monitorDelegate.mon.transform === modelData.val + onClicked: Monitors.rotate(monitorDelegate.mon.name, modelData.val * 90) + } + } + } + + // Arrangement + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + StyledText { + text: qsTr("Position relative to:") + Layout.fillWidth: true + } + + CustomSpinBox { + id: targetMonSelector + + min: 0 + max: Math.max(0, (Hyprctl.monitors.length ?? 1) - 1) + value: 0 + } + + IconButton { + icon: "arrow_back" + onClicked: Monitors.arrange(monitorDelegate.mon.name, "left", targetMonSelector.value) + } + IconButton { + icon: "arrow_forward" + onClicked: Monitors.arrange(monitorDelegate.mon.name, "right", targetMonSelector.value) + } + IconButton { + icon: "arrow_upward" + onClicked: Monitors.arrange(monitorDelegate.mon.name, "top", targetMonSelector.value) + } + IconButton { + icon: "arrow_downward" + onClicked: Monitors.arrange(monitorDelegate.mon.name, "bottom", targetMonSelector.value) + } + } + } + } + } + } + } +} diff --git a/modules/nexus/NexusState.qml b/modules/nexus/NexusState.qml index 807aa756b..adccc3964 100644 --- a/modules/nexus/NexusState.qml +++ b/modules/nexus/NexusState.qml @@ -14,6 +14,7 @@ QtObject { property BluetoothDevice selectedBtDevice property DesktopEntry selectedApp property string selectedEthernetInterface + property var selectedMonitor signal close signal subPageOpened(idx: int) diff --git a/modules/nexus/PageCompRegistry.qml b/modules/nexus/PageCompRegistry.qml index 66730b2a4..c81c77842 100644 --- a/modules/nexus/PageCompRegistry.qml +++ b/modules/nexus/PageCompRegistry.qml @@ -10,6 +10,7 @@ import qs.modules.nexus.pages import qs.modules.nexus.pages.apps import qs.modules.nexus.pages.audio import qs.modules.nexus.pages.bluetooth +import qs.modules.nexus.pages.monitors import qs.modules.nexus.pages.network import qs.modules.nexus.pages.panels import qs.modules.nexus.pages.services @@ -38,6 +39,17 @@ QtObject { } } }, + Component { + // Display / Monitors + StackPage { + Component { + MonitorsPane {} + } + Component { + MonitorDetail {} + } + } + }, // Connectivity Component { diff --git a/modules/nexus/PageRegistry.qml b/modules/nexus/PageRegistry.qml index 4ed0d3ac0..73bc8a8ed 100644 --- a/modules/nexus/PageRegistry.qml +++ b/modules/nexus/PageRegistry.qml @@ -15,13 +15,12 @@ QtObject { }, // Connectivity - // TODO - // { - // label: qsTr("Display"), - // icon: "monitor", - // description: qsTr("Output configuration"), - // category: "connectivity" - // }, + { + label: qsTr("Display"), + icon: "monitor", + description: qsTr("Output configuration"), + category: "connectivity" + }, { label: qsTr("Network"), icon: "wifi", diff --git a/modules/nexus/pages/monitors/MonitorDetail.qml b/modules/nexus/pages/monitors/MonitorDetail.qml new file mode 100644 index 000000000..5a2c6d278 --- /dev/null +++ b/modules/nexus/pages/monitors/MonitorDetail.qml @@ -0,0 +1,541 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + readonly property var mon: nState.selectedMonitor + readonly property var brightnessMon: mon ? Brightness.getMonitor(mon.name) : null + + property var availableResolutions: [] + property var availableRefreshRates: [] + + readonly property list rotationItems: [ + MenuItem { + text: qsTr("0°") + }, + MenuItem { + text: "90°" + }, + MenuItem { + text: "180°" + }, + MenuItem { + text: "270°" + } + ] + + readonly property list rotationValues: [0, 90, 180, 270] + + readonly property list scaleItems: [ + MenuItem { + text: "1.0×" + }, + MenuItem { + text: "1.25×" + }, + MenuItem { + text: "1.5×" + }, + MenuItem { + text: "2.0×" + } + ] + + readonly property list scaleValues: [1.0, 1.25, 1.5, 2.0] + + function updateModes(): void { + const res = []; + const rates = []; + if (root.mon && root.mon.availableModes) { + const modes = root.mon.availableModes; + for (let i = 0; i < modes.length; i++) { + const mode = modes[i]; + const parts = mode.split("@"); + if (parts.length === 2) { + const rStr = parts[0]; + if (res.indexOf(rStr) === -1) { + res.push(rStr); + } + const rateStr = parts[1].replace("Hz", ""); + const rate = parseFloat(rateStr); + if (!isNaN(rate)) { + const roundedRate = Math.round(rate * 100) / 100; + if (rates.indexOf(roundedRate) === -1) { + rates.push(roundedRate); + } + } + } + } + // Sort resolutions descending by total pixels + res.sort((a, b) => { + const aParts = a.split("x").map(Number); + const bParts = b.split("x").map(Number); + return (bParts[0] * bParts[1]) - (aParts[0] * aParts[1]); + }); + // Sort rates descending + rates.sort((a, b) => b - a); + } else if (root.mon) { + // Fallback if no availableModes + const currentRes = `${root.mon.width}x${root.mon.height}`; + res.push(currentRes); + rates.push(Math.round(root.mon.refreshRate ?? 60)); + } + + root.availableResolutions = res; + root.availableRefreshRates = rates; + } + + function getRefreshItem(): var { + if (!root.mon) + return null; + const rate = root.mon.refreshRate ?? 60; + let minDiff = 999999; + let bestIdx = -1; + for (let i = 0; i < root.availableRefreshRates.length; i++) { + const diff = Math.abs(root.availableRefreshRates[i] - rate); + if (diff < minDiff) { + minDiff = diff; + bestIdx = i; + } + } + return bestIdx >= 0 ? refreshItemsInstantiator.objects[bestIdx] : null; + } + + function getResolutionItem(): var { + if (!root.mon) + return null; + const currentRes = `${root.mon.width}x${root.mon.height}`; + const idx = root.availableResolutions.indexOf(currentRes); + return idx >= 0 ? resolutionItemsInstantiator.objects[idx] : null; + } + + function getScaleItem(): var { + const s = root.mon?.scale ?? 1.0; + const idx = root.scaleValues.findIndex(v => Math.abs(v - s) < 0.01); + return idx >= 0 ? root.scaleItems[idx] : null; + } + + title: mon?.name ?? qsTr("Monitor") + isSubPage: true + + onMonChanged: { + updateModes(); + if (!mon) { + nState.closeSubPage(); + } + } + + Component.onCompleted: updateModes() + + resources: [ + Instantiator { + id: refreshItemsInstantiator + + model: root.availableRefreshRates + delegate: MenuItem { + text: modelData + " Hz" + onClicked: { + if (root.mon) + Monitors.setRefreshRate(root.mon.name, modelData); + } + } + }, + Instantiator { + id: resolutionItemsInstantiator + + model: root.availableResolutions + delegate: MenuItem { + text: modelData + onClicked: { + if (root.mon) + Monitors.setResolution(root.mon.name, modelData); + } + } + } + ] + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + // ── Hero Section ────────────────────────────────────── + ConnectedRect { + Layout.fillWidth: true + first: true + last: true + implicitHeight: hero.implicitHeight + Tokens.padding.extraLarge * 2 + + ColumnLayout { + id: hero + + anchors.centerIn: parent + width: parent.width - Tokens.padding.largeIncreased * 2 + spacing: Tokens.spacing.small + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "monitor" + fontStyle: Tokens.font.icon.extraLarge + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.spacing.small + text: root.mon?.name ?? qsTr("Unknown Monitor") + font: Tokens.font.headline.builders.small.build() + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: { + const w = root.mon?.width ?? 0; + const h = root.mon?.height ?? 0; + const r = root.mon?.refreshRate ?? 0; + if (w && h && r) + return qsTr("%1 × %2 @ %3 Hz").arg(w).arg(h).arg(r.toFixed(0)); + return qsTr("Unavailable"); + } + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.medium + } + } + } + + // ── Settings ─────────────────────────────────────── + SectionHeader { + text: qsTr("Configuration") + } + + SliderRow { + Layout.fillWidth: true + first: true + visible: root.brightnessMon !== null && root.brightnessMon !== undefined + icon: (root.brightnessMon?.brightness ?? 0) > 0.5 ? "brightness_high" : "brightness_low" + label: qsTr("Brightness") + valueLabel: Math.round((root.brightnessMon?.brightness ?? 0) * 100) + "%" + value: root.brightnessMon?.brightness ?? 0 + onMoved: v => { + if (root.brightnessMon) + root.brightnessMon.setBrightness(v); + } + } + + SelectRow { + Layout.fillWidth: true + first: root.brightnessMon === null || root.brightnessMon === undefined + label: qsTr("Resolution") + subtext: qsTr("Display resolution") + menuItems: resolutionItemsInstantiator.objects + active: root.getResolutionItem() + fallbackText: root.mon ? qsTr("%1×%2").arg(root.mon.width).arg(root.mon.height) : qsTr("Unknown") + fallbackIcon: "aspect_ratio" + onSelected: item => { + const idx = resolutionItemsInstantiator.objects.indexOf(item); + if (idx >= 0 && root.mon) + Monitors.setResolution(root.mon.name, root.availableResolutions[idx]); + } + } + + SelectRow { + Layout.fillWidth: true + first: false + label: qsTr("Refresh rate") + subtext: qsTr("Maximum refresh rate") + menuItems: refreshItemsInstantiator.objects + active: root.getRefreshItem() + fallbackText: root.mon?.refreshRate ? qsTr("%1 Hz").arg((root.mon.refreshRate).toFixed(0)) : qsTr("Unknown") + fallbackIcon: "speed" + onSelected: item => { + const idx = refreshItemsInstantiator.objects.indexOf(item); + if (idx >= 0 && root.mon) + Monitors.setRefreshRate(root.mon.name, root.availableRefreshRates[idx]); + } + } + + SelectRow { + Layout.fillWidth: true + label: qsTr("Rotation") + subtext: qsTr("Screen orientation") + menuItems: root.rotationItems + active: { + const t = root.mon?.transform ?? 0; + return root.rotationItems[t] ?? root.rotationItems[0]; + } + onSelected: item => { + const idx = root.rotationItems.indexOf(item); + if (idx >= 0 && root.mon) + Monitors.rotate(root.mon.name, root.rotationValues[idx]); + } + } + + SelectRow { + Layout.fillWidth: true + last: true + label: qsTr("Scale") + subtext: qsTr("UI scaling factor") + menuItems: root.scaleItems + active: root.getScaleItem() + fallbackText: qsTr("%1×").arg((root.mon?.scale ?? 1.0).toFixed(2)) + fallbackIcon: "zoom_in" + onSelected: item => { + const idx = root.scaleItems.indexOf(item); + if (idx >= 0 && root.mon) + Monitors.setScale(root.mon.name, root.scaleValues[idx]); + } + } + + // ── Arrangement ────────────────────────────────────── + ColumnLayout { + id: arrangementLayout + + readonly property var otherMons: { + if (!root.mon || !Hyprctl.monitors) + return []; + const res = []; + for (let i = 0; i < Hyprctl.monitors.length; i++) { + if (Hyprctl.monitors[i].id !== root.mon.id) + res.push(Hyprctl.monitors[i]); + } + return res; + } + + Layout.fillWidth: true + visible: otherMons.length > 0 + spacing: Tokens.spacing.extraSmall / 2 + + SectionHeader { + text: qsTr("Arrangement") + } + + Repeater { + model: arrangementLayout.otherMons + + delegate: ConnectedRect { + id: targetSection + + required property var modelData + required property int index + + Layout.fillWidth: true + first: index === 0 + last: index === arrangementLayout.otherMons.length - 1 + implicitHeight: arrangeLayout.implicitHeight + arrangeLayout.anchors.margins * 2 + + ColumnLayout { + id: arrangeLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.small + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + + MaterialIcon { + text: "tv" + fontStyle: Tokens.font.icon.medium + color: Colours.palette.m3onSurfaceVariant + } + StyledText { + Layout.fillWidth: true + text: qsTr("Relative to Monitor %1 (%2)").arg(targetSection.modelData.id ?? 0).arg(targetSection.modelData.name ?? "") + font: Tokens.font.body.medium + } + } + + GridLayout { + Layout.fillWidth: true + columns: 4 + columnSpacing: Tokens.spacing.small + rowSpacing: Tokens.spacing.small + + Repeater { + model: [ + { + label: qsTr("Left"), + pos: "left", + icon: "arrow_back" + }, + { + label: qsTr("Right"), + pos: "right", + icon: "arrow_forward" + }, + { + label: qsTr("Above"), + pos: "top", + icon: "arrow_upward" + }, + { + label: qsTr("Below"), + pos: "bottom", + icon: "arrow_downward" + } + ] + + delegate: ArrangeButton { + required property var modelData + required property int index + Layout.fillWidth: true + + btnIcon: modelData.icon + btnLabel: modelData.label + onClicked: { + if (root.mon) + Monitors.arrange(root.mon.name, modelData.pos, targetSection.modelData.id); + } + } + } + } + } + } + } + } + + // ── Display information ─────────────────────────────── + SectionHeader { + text: qsTr("Display information") + } + + InfoRow { + Layout.fillWidth: true + first: true + label: qsTr("Position") + value: root.mon != null ? qsTr("x: %1, y: %2").arg(root.mon.x ?? 0).arg(root.mon.y ?? 0) : qsTr("N/A") + } + InfoRow { + Layout.fillWidth: true + label: qsTr("Monitor ID") + value: root.mon != null ? String(root.mon.id ?? "—") : "—" + } + InfoRow { + Layout.fillWidth: true + label: qsTr("Make / Model") + value: { + const parts = [root.mon?.make, root.mon?.model].filter(v => v && v.length > 0); + return parts.length > 0 ? parts.join(" ") : qsTr("Unknown"); + } + } + InfoRow { + Layout.fillWidth: true + label: qsTr("Serial") + value: root.mon?.serial || qsTr("Unknown") + } + InfoRow { + Layout.fillWidth: true + last: true + label: qsTr("Focused") + value: (root.mon?.focused ?? false) ? qsTr("Yes") : qsTr("No") + } + } + + // ── Reusable sub-components ─────────────────────────────────────── + + component RotationChip: StyledRect { + id: chip + + required property string chipLabel + required property int chipAngle + required property bool isActive + + signal clicked + + implicitHeight: 72 + radius: Tokens.rounding.large + color: chip.isActive ? Colours.palette.m3secondaryContainer : Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) + + StateLayer { + function onClicked(): void { + chip.clicked(); + } + + color: chip.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + } + + ColumnLayout { + id: chipContent + + anchors.centerIn: parent + spacing: 2 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: "screen_rotation" + rotation: chip.chipAngle + fontStyle: Tokens.font.icon.medium + color: chip.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + + Behavior on rotation { + Anim {} + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: chip.chipLabel + font: Tokens.font.body.small + color: chip.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant + } + } + + Behavior on color { + CAnim {} + } + } + + component ArrangeButton: StyledRect { + id: arrangeBtn + + required property string btnIcon + required property string btnLabel + + signal clicked + + implicitHeight: 64 + radius: Tokens.rounding.medium + color: Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) + + StateLayer { + function onClicked(): void { + arrangeBtn.clicked(); + } + + color: Colours.palette.m3onSurfaceVariant + } + + ColumnLayout { + id: btnContent + + anchors.centerIn: parent + spacing: 2 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: arrangeBtn.btnIcon + fontStyle: Tokens.font.icon.medium + color: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: arrangeBtn.btnLabel + font: Tokens.font.body.small + color: Colours.palette.m3onSurfaceVariant + } + } + } +} diff --git a/modules/nexus/pages/monitors/MonitorsPane.qml b/modules/nexus/pages/monitors/MonitorsPane.qml new file mode 100644 index 000000000..62d2f4da6 --- /dev/null +++ b/modules/nexus/pages/monitors/MonitorsPane.qml @@ -0,0 +1,359 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.services +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Displays") + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.large + + StyledClippingRect { + id: previewContainer + + property real minX: 0 + property real minY: 0 + property real maxX: 0 + property real maxY: 0 + property real spanX: 0 + property real spanY: 0 + property real scaleFactor: 1.0 + property real offsetX: 0 + property real offsetY: 0 + property real padding: 24 + + function updateBoundaries(): void { + const mons = Hyprctl.monitors; + if (!mons || mons.length === 0) + return; + + let min_x = Infinity; + let min_y = Infinity; + let max_x = -Infinity; + let max_y = -Infinity; + + for (let i = 0; i < mons.length; i++) { + const m = mons[i]; + const w = m.width / (m.scale || 1.0); + const h = m.height / (m.scale || 1.0); + if (m.x < min_x) + min_x = m.x; + if (m.y < min_y) + min_y = m.y; + if (m.x + w > max_x) + max_x = m.x + w; + if (m.y + h > max_y) + max_y = m.y + h; + } + + minX = min_x; + minY = min_y; + maxX = max_x; + maxY = max_y; + spanX = Math.max(1, maxX - minX); + spanY = Math.max(1, maxY - minY); + + const availW = previewContainer.width - 2 * padding; + const availH = previewContainer.height - 2 * padding; + scaleFactor = Math.min(availW / spanX, availH / spanY); + + offsetX = padding + (availW - spanX * scaleFactor) / 2; + offsetY = padding + (availH - spanY * scaleFactor) / 2; + } + + function getX(mon: var): real { + return offsetX + (mon.x - minX) * scaleFactor; + } + + function getY(mon: var): real { + return offsetY + (mon.y - minY) * scaleFactor; + } + + function getWidth(mon: var): real { + return (mon.width / (mon.scale || 1.0)) * scaleFactor; + } + + function getHeight(mon: var): real { + return (mon.height / (mon.scale || 1.0)) * scaleFactor; + } + + function snapMonitor(mon: var, currentX: real, currentY: real): void { + const mons = Hyprctl.monitors; + if (!mons || mons.length <= 1) + return; + + const otherMons = []; + for (let i = 0; i < mons.length; i++) { + if (mons[i].id !== mon.id) { + otherMons.push({ + id: mons[i].id, + name: mons[i].name, + previewX: getX(mons[i]), + previewY: getY(mons[i]), + previewW: getWidth(mons[i]), + previewH: getHeight(mons[i]) + }); + } + } + + const w = getWidth(mon); + const h = getHeight(mon); + + let minDistance = Infinity; + let bestTarget = null; + let bestPos = ""; + + for (let i = 0; i < otherMons.length; i++) { + const other = otherMons[i]; + + const snaps = [ + { + pos: "left", + x: other.previewX - w, + y: other.previewY + (other.previewH - h) / 2 + }, + { + pos: "right", + x: other.previewX + other.previewW, + y: other.previewY + (other.previewH - h) / 2 + }, + { + pos: "top", + x: other.previewX + (other.previewW - w) / 2, + y: other.previewY - h + }, + { + pos: "bottom", + x: other.previewX + (other.previewW - w) / 2, + y: other.previewY + other.previewH + } + ]; + + for (let j = 0; j < snaps.length; j++) { + const snap = snaps[j]; + const dx = currentX - snap.x; + const dy = currentY - snap.y; + const dist = dx * dx + dy * dy; + if (dist < minDistance) { + minDistance = dist; + bestTarget = other; + bestPos = snap.pos; + } + } + } + + if (bestTarget && bestPos !== "") { + Monitors.arrange(mon.name, bestPos, bestTarget.id); + } + } + + Layout.fillWidth: true + implicitHeight: 220 + color: Colours.tPalette.m3surfaceContainer + radius: Tokens.rounding.large + + onWidthChanged: updateBoundaries() + Component.onCompleted: updateBoundaries() + + Connections { + function onMonitorsChanged(): void { + previewContainer.updateBoundaries(); + } + + target: Hyprctl + } + + Repeater { + model: Hyprctl.monitors + + delegate: StyledRect { + id: monitorBox + + required property var modelData + required property int index + + readonly property real targetX: previewContainer.getX(modelData) + readonly property real targetY: previewContainer.getY(modelData) + readonly property real targetW: previewContainer.getWidth(modelData) + readonly property real targetH: previewContainer.getHeight(modelData) + + x: targetX + y: targetY + width: targetW + height: targetH + color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3primaryContainer : Colours.palette.m3surfaceVariant + radius: Tokens.rounding.medium + border.color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3primary : "transparent" + border.width: 2 + + onTargetXChanged: { + if (!dragArea.pressed) { + x = targetX; + } + } + onTargetYChanged: { + if (!dragArea.pressed) { + y = targetY; + } + } + + Behavior on color { + CAnim {} + } + + ColumnLayout { + anchors.centerIn: parent + spacing: 2 + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: monitorBox.modelData.name + font: Tokens.font.body.small + color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: `${monitorBox.modelData.width}x${monitorBox.modelData.height}` + font: Tokens.font.label.small + color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + opacity: 0.8 + } + } + + MouseArea { + id: dragArea + + property real startX + property real startY + + anchors.fill: parent + + onPressed: mouse => { + startX = mouse.x; + startY = mouse.y; + monitorBox.z = 100; + root.nState.selectedMonitor = monitorBox.modelData; + } + + onPositionChanged: mouse => { + if (pressed) { + const newX = monitorBox.x + mouse.x - startX; + const newY = monitorBox.y + mouse.y - startY; + monitorBox.x = Math.max(0, Math.min(previewContainer.width - monitorBox.width, newX)); + monitorBox.y = Math.max(0, Math.min(previewContainer.height - monitorBox.height, newY)); + } + } + + onReleased: { + monitorBox.z = 1; + previewContainer.snapMonitor(monitorBox.modelData, monitorBox.x, monitorBox.y); + monitorBox.x = monitorBox.targetX; + monitorBox.y = monitorBox.targetY; + } + + onDoubleClicked: { + root.nState.selectedMonitor = monitorBox.modelData; + root.nState.openSubPage(1); + } + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall / 2 + + ToggleRow { + Layout.fillWidth: true + first: true + text: qsTr("Identify displays") + font: Tokens.font.body.medium + horizontalPadding: Tokens.padding.largeIncreased + checked: Monitors.identifying + onToggled: Monitors.toggleIdentification() + } + + Repeater { + model: Hyprctl.monitors + + delegate: ConnectedRect { + id: monitorItem + + required property var modelData + required property int index + + Layout.fillWidth: true + implicitHeight: itemLayout.implicitHeight + itemLayout.anchors.margins * 2 + first: false + last: index === Hyprctl.monitors.length - 1 + + StateLayer { + onClicked: { + root.nState.selectedMonitor = monitorItem.modelData; + root.nState.openSubPage(1); + } + } + + RowLayout { + id: itemLayout + + anchors.fill: parent + anchors.margins: Tokens.padding.medium + anchors.leftMargin: Tokens.padding.largeIncreased + anchors.rightMargin: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + MaterialIcon { + text: "monitor" + fontStyle: Tokens.font.icon.medium + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + Layout.fillWidth: true + text: monitorItem.modelData.name + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + text: { + const m = monitorItem.modelData; + if (!m || !m.width || !m.height) + return qsTr("Unavailable"); + const rr = m.refreshRate ?? 0; + return qsTr("%1×%2 @ %3 Hz").arg(m.width).arg(m.height).arg(rr.toFixed(0)); + } + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + MaterialIcon { + text: (monitorItem.modelData.focused ?? false) ? "settings" : "chevron_right" + color: (monitorItem.modelData.focused ?? false) ? Colours.palette.m3primary : Colours.palette.m3onSurfaceVariant + fontStyle: Tokens.font.icon.medium + } + } + } + } + } + } +} diff --git a/services/Hyprctl.qml b/services/Hyprctl.qml new file mode 100644 index 000000000..3f803d666 --- /dev/null +++ b/services/Hyprctl.qml @@ -0,0 +1,49 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io + +Singleton { + id: root + + property list monitors: [] + + readonly property Process proc: Process { + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + root.monitors = JSON.parse(text); + } catch (e) { + console.error("Hyprctl: failed to parse monitors JSON", e); + } + } + } + } + + function update(): void { + proc.running = true; + } + + Component.onCompleted: update() + + Timer { + interval: 2000 + running: true + repeat: true + onTriggered: root.update() + } + + // Refresh when Hyprland reports changes + Connections { + function onRawEvent(event: HyprlandEvent): void { + if (event.name.includes("mon")) { + root.update(); + } + } + + target: Hyprland + } +} diff --git a/services/Monitors.qml b/services/Monitors.qml new file mode 100644 index 000000000..36064181d --- /dev/null +++ b/services/Monitors.qml @@ -0,0 +1,149 @@ +pragma Singleton + +import QtQuick +import Quickshell +import qs.services + +Singleton { + id: root + + property bool identifying: false + + function toggleIdentification(): void { + identifying = !identifying; + if (identifying) + identifyTimer.restart(); + else + identifyTimer.stop(); + } + + function stopIdentification(): void { + identifying = false; + identifyTimer.stop(); + } + + function sourceMonitors(): var { + if ((Hyprctl.monitors.length ?? 0) > 0) + return Hyprctl.monitors; + return Hypr.monitors.values ?? []; + } + + // Safely iterate monitor data — .find() doesn't work on UntypedObjectModel + function findMonitorByName(name: string): var { + const monitors = sourceMonitors(); + for (let i = 0; i < monitors.length; i++) { + if (monitors[i].name === name) + return monitors[i]; + } + return null; + } + + function findMonitorById(id: int): var { + const monitors = sourceMonitors(); + for (let i = 0; i < monitors.length; i++) { + if (monitors[i].id === id) + return monitors[i]; + } + return null; + } + + // Build the monitor string Hyprland expects: + // NAME,WIDTHxHEIGHT@RATE,XxY,SCALE[,transform,N] + function monitorStr(mon: var, overrideScale: real, overrideTransform: int, overrideRefreshRate: real, overrideRes: string): string { + const scale = overrideScale >= 0 ? overrideScale : (mon.scale || 1); + const transform = overrideTransform >= 0 ? overrideTransform : (mon.transform || 0); + const rr = (overrideRefreshRate > 0 ? overrideRefreshRate : (mon.refreshRate || 60)).toFixed(3); + const res = (overrideRes !== undefined && overrideRes !== "") ? overrideRes : `${mon.width}x${mon.height}`; + let s = `${mon.name},${res}@${rr},${mon.x}x${mon.y},${scale}`; + if (transform !== 0) + s += `,transform,${transform}`; + return s; + } + + // Use batchMessage (hyprctl keyword), NOT dispatch (hyprctl dispatch) + // "keyword" is a config command, not a dispatcher action. + function sendKeyword(monStr: string): void { + Hypr.extras.batchMessage([`keyword monitor ${monStr}`]); + Hyprctl.update(); + } + + function arrange(monitorName: string, pos: string, relativeToId: int): void { + const target = findMonitorById(relativeToId); + const moving = findMonitorByName(monitorName); + if (!target || !moving) + return; + + let x = target.x; + let y = target.y; + + const targetW = Math.round(target.width / (target.scale || 1)); + const targetH = Math.round(target.height / (target.scale || 1)); + const movingW = Math.round(moving.width / (moving.scale || 1)); + const movingH = Math.round(moving.height / (moving.scale || 1)); + + if (pos === "left") + x -= movingW; + else if (pos === "right") + x += targetW; + else if (pos === "top") + y -= movingH; + else if (pos === "bottom") + y += targetH; + + const scale = moving.scale || 1; + const transform = moving.transform || 0; + const rr = (moving.refreshRate || 60).toFixed(3); + + let s = `${moving.name},${moving.width}x${moving.height}@${rr},${Math.round(x)}x${Math.round(y)},${scale}`; + if (transform !== 0) + s += `,transform,${transform}`; + + sendKeyword(s); + } + + function rotate(monitorName: string, angle: int): void { + const mon = findMonitorByName(monitorName); + if (!mon) + return; + + let transform = 0; + if (angle === 90) + transform = 1; + else if (angle === 180) + transform = 2; + else if (angle === 270) + transform = 3; + + sendKeyword(monitorStr(mon, mon.scale || 1, transform, mon.refreshRate || 60, "")); + } + + function setScale(monitorName: string, scale: real): void { + const mon = findMonitorByName(monitorName); + if (!mon) + return; + const s = Math.max(0.5, Math.min(3.0, scale)); + sendKeyword(monitorStr(mon, s, mon.transform || 0, mon.refreshRate || 60, "")); + } + + function setRefreshRate(monitorName: string, refreshRate: real): void { + const mon = findMonitorByName(monitorName); + if (!mon) + return; + sendKeyword(monitorStr(mon, mon.scale || 1, mon.transform || 0, Math.max(1, refreshRate), "")); + } + + function setResolution(monitorName: string, resolution: string): void { + const mon = findMonitorByName(monitorName); + if (!mon) + return; + sendKeyword(monitorStr(mon, mon.scale || 1, mon.transform || 0, mon.refreshRate || 60, resolution)); + } + + // Auto-dismiss identify overlay after 5 seconds + Timer { + id: identifyTimer + + interval: 5000 + onTriggered: root.identifying = false + } +} diff --git a/shell.qml b/shell.qml index 273d541cc..4adb62b9a 100644 --- a/shell.qml +++ b/shell.qml @@ -29,4 +29,7 @@ ShellRoot { IdleMonitors { lock: lock } + MonitorIdentifier { + id: monitorIdentifier + } } From 81690550b48eec22fd2c678ee2719eceafd377fa Mon Sep 17 00:00:00 2001 From: Valentine Omonya Date: Sun, 28 Jun 2026 01:24:29 +0300 Subject: [PATCH 2/3] displays: add draggable layout with hot-zone snapping and auto-recovery This change implements a draggable layout editor for displays configuration: - Implemented an angular-offset hot-zone snapping algorithm based on relative vector direction to neighboring monitors - Added a Wayland configuration recovery mechanism using internally-debounced Hyprctl updates and onMonitorsChanged refresh callbacks - Upgraded layout design to dark containers with display icons, highlight indicators, and locked/current statuses - Added support for styling and disabling dragging on disconnected display items - Resolved an issue where simple click/selection actions would trigger coordinate re-evaluation and flicker surfaces --- modules/nexus/NexusState.qml | 16 + .../nexus/pages/monitors/MonitorDetail.qml | 225 +------------- modules/nexus/pages/monitors/MonitorsPane.qml | 286 ++++++++++++------ services/Hyprctl.qml | 29 +- services/Monitors.qml | 19 +- 5 files changed, 264 insertions(+), 311 deletions(-) diff --git a/modules/nexus/NexusState.qml b/modules/nexus/NexusState.qml index adccc3964..fa25be3f1 100644 --- a/modules/nexus/NexusState.qml +++ b/modules/nexus/NexusState.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell import Quickshell.Bluetooth +import qs.services QtObject { property ShellScreen screen @@ -15,6 +16,21 @@ QtObject { property DesktopEntry selectedApp property string selectedEthernetInterface property var selectedMonitor + property Connections monitorsConnection: Connections { + function onMonitorsChanged(): void { + if (selectedMonitor) { + for (let i = 0; i < Hyprctl.monitors.length; i++) { + if (Hyprctl.monitors[i].name === selectedMonitor.name) { + selectedMonitor = Hyprctl.monitors[i]; + return; + } + } + selectedMonitor = null; + } + } + + target: Hyprctl + } signal close signal subPageOpened(idx: int) diff --git a/modules/nexus/pages/monitors/MonitorDetail.qml b/modules/nexus/pages/monitors/MonitorDetail.qml index 5a2c6d278..386f2b0cd 100644 --- a/modules/nexus/pages/monitors/MonitorDetail.qml +++ b/modules/nexus/pages/monitors/MonitorDetail.qml @@ -94,7 +94,9 @@ PageBase { } function getRefreshItem(): var { - if (!root.mon) + if (!root.mon || !root.availableRefreshRates || root.availableRefreshRates.length === 0) + return null; + if (!refreshItemsInstantiator.objects || refreshItemsInstantiator.objects.length === 0) return null; const rate = root.mon.refreshRate ?? 60; let minDiff = 999999; @@ -110,7 +112,9 @@ PageBase { } function getResolutionItem(): var { - if (!root.mon) + if (!root.mon || !root.availableResolutions || root.availableResolutions.length === 0) + return null; + if (!resolutionItemsInstantiator.objects || resolutionItemsInstantiator.objects.length === 0) return null; const currentRes = `${root.mon.width}x${root.mon.height}`; const idx = root.availableResolutions.indexOf(currentRes); @@ -141,6 +145,9 @@ PageBase { model: root.availableRefreshRates delegate: MenuItem { + required property var modelData + required property int index + text: modelData + " Hz" onClicked: { if (root.mon) @@ -153,6 +160,9 @@ PageBase { model: root.availableResolutions delegate: MenuItem { + required property var modelData + required property int index + text: modelData onClicked: { if (root.mon) @@ -236,7 +246,7 @@ PageBase { first: root.brightnessMon === null || root.brightnessMon === undefined label: qsTr("Resolution") subtext: qsTr("Display resolution") - menuItems: resolutionItemsInstantiator.objects + menuItems: resolutionItemsInstantiator.objects || [] active: root.getResolutionItem() fallbackText: root.mon ? qsTr("%1×%2").arg(root.mon.width).arg(root.mon.height) : qsTr("Unknown") fallbackIcon: "aspect_ratio" @@ -252,7 +262,7 @@ PageBase { first: false label: qsTr("Refresh rate") subtext: qsTr("Maximum refresh rate") - menuItems: refreshItemsInstantiator.objects + menuItems: refreshItemsInstantiator.objects || [] active: root.getRefreshItem() fallbackText: root.mon?.refreshRate ? qsTr("%1 Hz").arg((root.mon.refreshRate).toFixed(0)) : qsTr("Unknown") fallbackIcon: "speed" @@ -295,117 +305,6 @@ PageBase { } } - // ── Arrangement ────────────────────────────────────── - ColumnLayout { - id: arrangementLayout - - readonly property var otherMons: { - if (!root.mon || !Hyprctl.monitors) - return []; - const res = []; - for (let i = 0; i < Hyprctl.monitors.length; i++) { - if (Hyprctl.monitors[i].id !== root.mon.id) - res.push(Hyprctl.monitors[i]); - } - return res; - } - - Layout.fillWidth: true - visible: otherMons.length > 0 - spacing: Tokens.spacing.extraSmall / 2 - - SectionHeader { - text: qsTr("Arrangement") - } - - Repeater { - model: arrangementLayout.otherMons - - delegate: ConnectedRect { - id: targetSection - - required property var modelData - required property int index - - Layout.fillWidth: true - first: index === 0 - last: index === arrangementLayout.otherMons.length - 1 - implicitHeight: arrangeLayout.implicitHeight + arrangeLayout.anchors.margins * 2 - - ColumnLayout { - id: arrangeLayout - - anchors.fill: parent - anchors.margins: Tokens.padding.medium - anchors.leftMargin: Tokens.padding.largeIncreased - anchors.rightMargin: Tokens.padding.largeIncreased - spacing: Tokens.spacing.small - - RowLayout { - Layout.fillWidth: true - spacing: Tokens.spacing.small - - MaterialIcon { - text: "tv" - fontStyle: Tokens.font.icon.medium - color: Colours.palette.m3onSurfaceVariant - } - StyledText { - Layout.fillWidth: true - text: qsTr("Relative to Monitor %1 (%2)").arg(targetSection.modelData.id ?? 0).arg(targetSection.modelData.name ?? "") - font: Tokens.font.body.medium - } - } - - GridLayout { - Layout.fillWidth: true - columns: 4 - columnSpacing: Tokens.spacing.small - rowSpacing: Tokens.spacing.small - - Repeater { - model: [ - { - label: qsTr("Left"), - pos: "left", - icon: "arrow_back" - }, - { - label: qsTr("Right"), - pos: "right", - icon: "arrow_forward" - }, - { - label: qsTr("Above"), - pos: "top", - icon: "arrow_upward" - }, - { - label: qsTr("Below"), - pos: "bottom", - icon: "arrow_downward" - } - ] - - delegate: ArrangeButton { - required property var modelData - required property int index - Layout.fillWidth: true - - btnIcon: modelData.icon - btnLabel: modelData.label - onClicked: { - if (root.mon) - Monitors.arrange(root.mon.name, modelData.pos, targetSection.modelData.id); - } - } - } - } - } - } - } - } - // ── Display information ─────────────────────────────── SectionHeader { text: qsTr("Display information") @@ -442,100 +341,4 @@ PageBase { value: (root.mon?.focused ?? false) ? qsTr("Yes") : qsTr("No") } } - - // ── Reusable sub-components ─────────────────────────────────────── - - component RotationChip: StyledRect { - id: chip - - required property string chipLabel - required property int chipAngle - required property bool isActive - - signal clicked - - implicitHeight: 72 - radius: Tokens.rounding.large - color: chip.isActive ? Colours.palette.m3secondaryContainer : Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) - - StateLayer { - function onClicked(): void { - chip.clicked(); - } - - color: chip.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant - } - - ColumnLayout { - id: chipContent - - anchors.centerIn: parent - spacing: 2 - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: "screen_rotation" - rotation: chip.chipAngle - fontStyle: Tokens.font.icon.medium - color: chip.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant - - Behavior on rotation { - Anim {} - } - } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: chip.chipLabel - font: Tokens.font.body.small - color: chip.isActive ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurfaceVariant - } - } - - Behavior on color { - CAnim {} - } - } - - component ArrangeButton: StyledRect { - id: arrangeBtn - - required property string btnIcon - required property string btnLabel - - signal clicked - - implicitHeight: 64 - radius: Tokens.rounding.medium - color: Qt.alpha(Colours.palette.m3surfaceVariant, 0.5) - - StateLayer { - function onClicked(): void { - arrangeBtn.clicked(); - } - - color: Colours.palette.m3onSurfaceVariant - } - - ColumnLayout { - id: btnContent - - anchors.centerIn: parent - spacing: 2 - - MaterialIcon { - Layout.alignment: Qt.AlignHCenter - text: arrangeBtn.btnIcon - fontStyle: Tokens.font.icon.medium - color: Colours.palette.m3onSurfaceVariant - } - - StyledText { - Layout.alignment: Qt.AlignHCenter - text: arrangeBtn.btnLabel - font: Tokens.font.body.small - color: Colours.palette.m3onSurfaceVariant - } - } - } } diff --git a/modules/nexus/pages/monitors/MonitorsPane.qml b/modules/nexus/pages/monitors/MonitorsPane.qml index 62d2f4da6..8dc1ae872 100644 --- a/modules/nexus/pages/monitors/MonitorsPane.qml +++ b/modules/nexus/pages/monitors/MonitorsPane.qml @@ -31,6 +31,8 @@ PageBase { property real offsetX: 0 property real offsetY: 0 property real padding: 24 + // Track monitor to be refreshed after layout updates + property string pendingRefreshName: "" function updateBoundaries(): void { const mons = Hyprctl.monitors; @@ -63,103 +65,139 @@ PageBase { spanX = Math.max(1, maxX - minX); spanY = Math.max(1, maxY - minY); - const availW = previewContainer.width - 2 * padding; - const availH = previewContainer.height - 2 * padding; - scaleFactor = Math.min(availW / spanX, availH / spanY); + const availW = Math.max(0, previewContainer.width - 2 * padding); + const availH = Math.max(0, previewContainer.height - 2 * padding); + + if (spanX > 0 && spanY > 0) { + scaleFactor = Math.min(availW / spanX, availH / spanY); + } else { + scaleFactor = 1.0; + } offsetX = padding + (availW - spanX * scaleFactor) / 2; offsetY = padding + (availH - spanY * scaleFactor) / 2; } function getX(mon: var): real { - return offsetX + (mon.x - minX) * scaleFactor; + if (!mon) + return 0; + return offsetX + ((mon.x ?? 0) - minX) * scaleFactor; } function getY(mon: var): real { - return offsetY + (mon.y - minY) * scaleFactor; + if (!mon) + return 0; + return offsetY + ((mon.y ?? 0) - minY) * scaleFactor; } function getWidth(mon: var): real { + if (!mon) + return 0; return (mon.width / (mon.scale || 1.0)) * scaleFactor; } function getHeight(mon: var): real { + if (!mon) + return 0; return (mon.height / (mon.scale || 1.0)) * scaleFactor; } - function snapMonitor(mon: var, currentX: real, currentY: real): void { + // Overlap detection + function rectsOverlap(ax: real, ay: real, aw: real, ah: real, bx: real, by: real, bw: real, bh: real): bool { + return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; + } + + // Snap logic based on angular offset from neighbor center + function snapMonitor(mon: var, dropCX: real, dropCY: real): void { + if (!mon) + return; const mons = Hyprctl.monitors; - if (!mons || mons.length <= 1) + if (!mons || mons.length === 0) return; - const otherMons = []; - for (let i = 0; i < mons.length; i++) { - if (mons[i].id !== mon.id) { - otherMons.push({ - id: mons[i].id, - name: mons[i].name, - previewX: getX(mons[i]), - previewY: getY(mons[i]), - previewW: getWidth(mons[i]), - previewH: getHeight(mons[i]) - }); - } - } + const lw = Math.round(mon.width / (mon.scale || 1)); + const lh = Math.round(mon.height / (mon.scale || 1)); + + let bestLX = 0; + let bestLY = 0; + let bestDist = Infinity; + let found = false; - const w = getWidth(mon); - const h = getHeight(mon); - - let minDistance = Infinity; - let bestTarget = null; - let bestPos = ""; - - for (let i = 0; i < otherMons.length; i++) { - const other = otherMons[i]; - - const snaps = [ - { - pos: "left", - x: other.previewX - w, - y: other.previewY + (other.previewH - h) / 2 - }, - { - pos: "right", - x: other.previewX + other.previewW, - y: other.previewY + (other.previewH - h) / 2 - }, - { - pos: "top", - x: other.previewX + (other.previewW - w) / 2, - y: other.previewY - h - }, - { - pos: "bottom", - x: other.previewX + (other.previewW - w) / 2, - y: other.previewY + other.previewH + for (let i = 0; i < mons.length; i++) { + const o = mons[i]; + if (o.id === mon.id || (o.disabled ?? false)) + continue; + + const ow = Math.round(o.width / (o.scale || 1)); + const oh = Math.round(o.height / (o.scale || 1)); + + // Center of the other monitor in preview pixel coordinates + const ocx = getX(o) + getWidth(o) * 0.5; + const ocy = getY(o) + getHeight(o) * 0.5; + + // Vector from other's center to where the user dropped + const dx = dropCX - ocx; + const dy = dropCY - ocy; + + // Determine target side using aspect ratio comparison + let snapLX, snapLY; + if (Math.abs(dx) * oh > Math.abs(dy) * ow) { + // ── Horizontal snap ──────────────────────────────── + if (dx > 0) { + snapLX = o.x + ow; // right of other + snapLY = o.y + Math.round((oh - lh) / 2); + } else { + snapLX = o.x - lw; // left of other + snapLY = o.y + Math.round((oh - lh) / 2); } - ]; - - for (let j = 0; j < snaps.length; j++) { - const snap = snaps[j]; - const dx = currentX - snap.x; - const dy = currentY - snap.y; - const dist = dx * dx + dy * dy; - if (dist < minDistance) { - minDistance = dist; - bestTarget = other; - bestPos = snap.pos; + } else { + // ── Vertical snap ────────────────────────────────── + if (dy > 0) { + snapLX = o.x + Math.round((ow - lw) / 2); // below other + snapLY = o.y + oh; + } else { + snapLX = o.x + Math.round((ow - lw) / 2); // above other + snapLY = o.y - lh; } } + + const dist = dx * dx + dy * dy; + if (dist < bestDist) { + bestDist = dist; + bestLX = Math.round(snapLX); + bestLY = Math.round(snapLY); + found = true; + } } - if (bestTarget && bestPos !== "") { - Monitors.arrange(mon.name, bestPos, bestTarget.id); + if (!found) + return; + + // Avoid overlapping existing displays + for (let i = 0; i < mons.length; i++) { + const o = mons[i]; + if (o.id === mon.id) + continue; + const ow = Math.round(o.width / (o.scale || 1)); + const oh = Math.round(o.height / (o.scale || 1)); + if (rectsOverlap(bestLX, bestLY, lw, lh, o.x, o.y, ow, oh)) + return; } + + // Apply changes to Hyprland + const scale = mon.scale || 1; + const transform = mon.transform || 0; + const rr = (mon.refreshRate || 60).toFixed(3); + let s = `${mon.name},${mon.width}x${mon.height}@${rr},${bestLX}x${bestLY},${scale}`; + if (transform !== 0) + s += `,transform,${transform}`; + Monitors.sendKeyword(s); } Layout.fillWidth: true - implicitHeight: 220 - color: Colours.tPalette.m3surfaceContainer + Layout.preferredHeight: 280 + implicitHeight: 280 + color: "transparent" radius: Tokens.rounding.large onWidthChanged: updateBoundaries() @@ -168,6 +206,12 @@ PageBase { Connections { function onMonitorsChanged(): void { previewContainer.updateBoundaries(); + // Trigger refresh on the moved monitor + if (previewContainer.pendingRefreshName !== "") { + const name = previewContainer.pendingRefreshName; + previewContainer.pendingRefreshName = ""; + Monitors.refresh(name); + } } target: Hyprctl @@ -176,58 +220,88 @@ PageBase { Repeater { model: Hyprctl.monitors - delegate: StyledRect { + delegate: Item { id: monitorBox required property var modelData required property int index - readonly property real targetX: previewContainer.getX(modelData) - readonly property real targetY: previewContainer.getY(modelData) - readonly property real targetW: previewContainer.getWidth(modelData) - readonly property real targetH: previewContainer.getHeight(modelData) + readonly property real targetX: previewContainer.getX(monitorBox.modelData) + readonly property real targetY: previewContainer.getY(monitorBox.modelData) + readonly property real targetW: previewContainer.getWidth(monitorBox.modelData) + readonly property real targetH: previewContainer.getHeight(monitorBox.modelData) + readonly property bool isCurrentScreen: monitorBox.modelData != null && root.nState.screen != null && monitorBox.modelData.name === root.nState.screen.name + readonly property bool isSelected: root.nState.selectedMonitor != null && root.nState.selectedMonitor.id === monitorBox.modelData.id + readonly property bool isDisabled: monitorBox.modelData != null && (monitorBox.modelData.disabled ?? false) x: targetX y: targetY width: targetW height: targetH - color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3primaryContainer : Colours.palette.m3surfaceVariant - radius: Tokens.rounding.medium - border.color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3primary : "transparent" - border.width: 2 + visible: monitorBox.modelData != null && targetW > 0 && targetH > 0 onTargetXChanged: { - if (!dragArea.pressed) { + if (!dragArea.pressed) x = targetX; - } } onTargetYChanged: { - if (!dragArea.pressed) { + if (!dragArea.pressed) y = targetY; - } } - Behavior on color { - CAnim {} + // Box background + Rectangle { + anchors.fill: parent + radius: Tokens.rounding.medium + color: monitorBox.isSelected ? Colours.palette.m3primaryContainer : Colours.palette.m3surfaceContainerHigh + opacity: monitorBox.isDisabled ? 0.45 : 1.0 + border.color: monitorBox.isSelected ? Colours.palette.m3primary : monitorBox.isCurrentScreen ? Colours.palette.m3secondary : "transparent" + border.width: monitorBox.isSelected || monitorBox.isCurrentScreen ? 2 : 0 + + Behavior on color { + CAnim {} + } + Behavior on border.color { + CAnim {} + } } + // Content: icon + name + resolution ColumnLayout { anchors.centerIn: parent - spacing: 2 + spacing: Tokens.spacing.extraSmall / 2 + + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + text: monitorBox.isDisabled ? "desktop_access_disabled" : "monitor" + fontStyle: Tokens.font.icon.medium + color: monitorBox.isSelected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + opacity: monitorBox.isDisabled ? 0.5 : 1.0 + } StyledText { Layout.alignment: Qt.AlignHCenter - text: monitorBox.modelData.name + text: monitorBox.modelData.name + " - " + monitorBox.modelData.id font: Tokens.font.body.small - color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + color: monitorBox.isSelected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant } StyledText { Layout.alignment: Qt.AlignHCenter - text: `${monitorBox.modelData.width}x${monitorBox.modelData.height}` + text: monitorBox.isDisabled ? qsTr("Disconnected") : `${monitorBox.modelData.width}x${monitorBox.modelData.height}` font: Tokens.font.label.small - color: (root.nState.selectedMonitor && root.nState.selectedMonitor.id === modelData.id) ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant - opacity: 0.8 + color: monitorBox.isSelected ? Colours.palette.m3onPrimaryContainer : monitorBox.isDisabled ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant + opacity: 0.85 + } + + // Indicate hosting screen + MaterialIcon { + Layout.alignment: Qt.AlignHCenter + visible: monitorBox.isCurrentScreen + text: "lock" + fontStyle: Tokens.font.icon.small + color: monitorBox.isSelected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurfaceVariant + opacity: 0.55 } } @@ -236,14 +310,20 @@ PageBase { property real startX property real startY + // Only true if the user dragged the monitor + property bool dragged: false + enabled: !monitorBox.isDisabled anchors.fill: parent + cursorShape: enabled ? Qt.SizeAllCursor : Qt.ArrowCursor onPressed: mouse => { startX = mouse.x; startY = mouse.y; + dragged = false; monitorBox.z = 100; root.nState.selectedMonitor = monitorBox.modelData; + root.flickable.interactive = false; } onPositionChanged: mouse => { @@ -252,14 +332,29 @@ PageBase { const newY = monitorBox.y + mouse.y - startY; monitorBox.x = Math.max(0, Math.min(previewContainer.width - monitorBox.width, newX)); monitorBox.y = Math.max(0, Math.min(previewContainer.height - monitorBox.height, newY)); + // Register drag only after moving past threshold + if (Math.abs(monitorBox.x - monitorBox.targetX) + Math.abs(monitorBox.y - monitorBox.targetY) > 5) + dragged = true; } } onReleased: { monitorBox.z = 1; - previewContainer.snapMonitor(monitorBox.modelData, monitorBox.x, monitorBox.y); + if (dragged) { + previewContainer.pendingRefreshName = monitorBox.modelData ? monitorBox.modelData.name : ""; + previewContainer.snapMonitor(monitorBox.modelData, monitorBox.x + monitorBox.width * 0.5, monitorBox.y + monitorBox.height * 0.5); + } + monitorBox.x = monitorBox.targetX; + monitorBox.y = monitorBox.targetY; + root.flickable.interactive = true; + root.nState.selectedMonitor = monitorBox.modelData; + } + + onCanceled: { + monitorBox.z = 1; monitorBox.x = monitorBox.targetX; monitorBox.y = monitorBox.targetY; + root.flickable.interactive = true; } onDoubleClicked: { @@ -294,8 +389,11 @@ PageBase { required property var modelData required property int index + readonly property bool isDisabled: monitorItem.modelData != null && (monitorItem.modelData.disabled ?? false) + Layout.fillWidth: true - implicitHeight: itemLayout.implicitHeight + itemLayout.anchors.margins * 2 + implicitHeight: monitorItem.modelData != null ? itemLayout.implicitHeight + itemLayout.anchors.margins * 2 : 0 + visible: monitorItem.modelData != null first: false last: index === Hyprctl.monitors.length - 1 @@ -316,8 +414,10 @@ PageBase { spacing: Tokens.spacing.medium MaterialIcon { - text: "monitor" + text: monitorItem.isDisabled ? "desktop_access_disabled" : "monitor" fontStyle: Tokens.font.icon.medium + color: monitorItem.isDisabled ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant + opacity: monitorItem.isDisabled ? 0.7 : 1.0 } ColumnLayout { @@ -335,12 +435,16 @@ PageBase { Layout.fillWidth: true text: { const m = monitorItem.modelData; - if (!m || !m.width || !m.height) + if (!m) + return ""; + if (monitorItem.isDisabled) + return qsTr("Disconnected"); + if (!m.width || !m.height) return qsTr("Unavailable"); const rr = m.refreshRate ?? 0; return qsTr("%1×%2 @ %3 Hz").arg(m.width).arg(m.height).arg(rr.toFixed(0)); } - color: Colours.palette.m3outline + color: monitorItem.isDisabled ? Colours.palette.m3error : Colours.palette.m3outline font: Tokens.font.label.small elide: Text.ElideRight } diff --git a/services/Hyprctl.qml b/services/Hyprctl.qml index 3f803d666..589c778f7 100644 --- a/services/Hyprctl.qml +++ b/services/Hyprctl.qml @@ -23,12 +23,35 @@ Singleton { } } + // Debounce updates to prevent rapid IPC events or commands from causing render issues. function update(): void { - proc.running = true; + debounce.restart(); } - Component.onCompleted: update() + // Run immediately at startup + Component.onCompleted: proc.running = true + Timer { + id: debounce + + interval: 200 + repeat: false + onTriggered: { + proc.running = true; + // Schedule recovery pass to handle Wayland geometry changes + recovery.restart(); + } + } + + Timer { + id: recovery + + interval: 600 + repeat: false + onTriggered: proc.running = true + } + + // Periodic polling fallback Timer { interval: 2000 running: true @@ -36,7 +59,7 @@ Singleton { onTriggered: root.update() } - // Refresh when Hyprland reports changes + // Listen for Hyprland monitor events Connections { function onRawEvent(event: HyprlandEvent): void { if (event.name.includes("mon")) { diff --git a/services/Monitors.qml b/services/Monitors.qml index 36064181d..0824c3db9 100644 --- a/services/Monitors.qml +++ b/services/Monitors.qml @@ -28,7 +28,7 @@ Singleton { return Hypr.monitors.values ?? []; } - // Safely iterate monitor data — .find() doesn't work on UntypedObjectModel + // Find monitor by name function findMonitorByName(name: string): var { const monitors = sourceMonitors(); for (let i = 0; i < monitors.length; i++) { @@ -41,7 +41,7 @@ Singleton { function findMonitorById(id: int): var { const monitors = sourceMonitors(); for (let i = 0; i < monitors.length; i++) { - if (monitors[i].id === id) + if (monitors[i].id == id) return monitors[i]; } return null; @@ -60,21 +60,28 @@ Singleton { return s; } - // Use batchMessage (hyprctl keyword), NOT dispatch (hyprctl dispatch) - // "keyword" is a config command, not a dispatcher action. + // Use batchMessage (hyprctl keyword) to configure monitor settings function sendKeyword(monStr: string): void { Hypr.extras.batchMessage([`keyword monitor ${monStr}`]); Hyprctl.update(); } + // Re-send settled monitor keyword to trigger a Wayland configure cycle + function refresh(monitorName: string): void { + const mon = findMonitorByName(monitorName); + if (!mon) + return; + sendKeyword(monitorStr(mon, mon.scale || 1, mon.transform || 0, mon.refreshRate || 60, "")); + } + function arrange(monitorName: string, pos: string, relativeToId: int): void { const target = findMonitorById(relativeToId); const moving = findMonitorByName(monitorName); if (!target || !moving) return; - let x = target.x; - let y = target.y; + let x = target.x ?? 0; + let y = target.y ?? 0; const targetW = Math.round(target.width / (target.scale || 1)); const targetH = Math.round(target.height / (target.scale || 1)); From c0d0f5df69f69ded437d11c396d3538a748c5aac Mon Sep 17 00:00:00 2001 From: Valentine Omonya Date: Sun, 28 Jun 2026 01:31:05 +0300 Subject: [PATCH 3/3] nexus: fix unqualified property warnings --- modules/nexus/NexusState.qml | 10 ++++++---- services/Hyprctl.qml | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/modules/nexus/NexusState.qml b/modules/nexus/NexusState.qml index fa25be3f1..0834b41e7 100644 --- a/modules/nexus/NexusState.qml +++ b/modules/nexus/NexusState.qml @@ -4,6 +4,8 @@ import Quickshell.Bluetooth import qs.services QtObject { + id: state + property ShellScreen screen property bool isWindow property bool animatingContainer @@ -18,14 +20,14 @@ QtObject { property var selectedMonitor property Connections monitorsConnection: Connections { function onMonitorsChanged(): void { - if (selectedMonitor) { + if (state.selectedMonitor) { for (let i = 0; i < Hyprctl.monitors.length; i++) { - if (Hyprctl.monitors[i].name === selectedMonitor.name) { - selectedMonitor = Hyprctl.monitors[i]; + if (Hyprctl.monitors[i].name === state.selectedMonitor.name) { + state.selectedMonitor = Hyprctl.monitors[i]; return; } } - selectedMonitor = null; + state.selectedMonitor = null; } } diff --git a/services/Hyprctl.qml b/services/Hyprctl.qml index 589c778f7..9b325e90b 100644 --- a/services/Hyprctl.qml +++ b/services/Hyprctl.qml @@ -29,7 +29,7 @@ Singleton { } // Run immediately at startup - Component.onCompleted: proc.running = true + Component.onCompleted: root.proc.running = true Timer { id: debounce @@ -37,7 +37,7 @@ Singleton { interval: 200 repeat: false onTriggered: { - proc.running = true; + root.proc.running = true; // Schedule recovery pass to handle Wayland geometry changes recovery.restart(); } @@ -48,7 +48,7 @@ Singleton { interval: 600 repeat: false - onTriggered: proc.running = true + onTriggered: root.proc.running = true } // Periodic polling fallback