import QtQuick import qs.config import qs.widgets import "../../services/DisplayLayout.js" as DisplayLayout Item { id: root required property var displayService property string selectedOutput: "" property var draftLayout: [] property bool interactionEnabled: true signal selectionRequested(string output) implicitHeight: content.implicitHeight readonly property var canvasData: DisplayLayout.canvasRects( root.draftLayout, canvas.width, canvas.height, 18) function copied(layout): var { return (layout || []).map(record => Object.assign({}, record)); } function resetDraft(): void { root.draftLayout = root.copied(root.displayService.currentLayout()); } function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool { const next = root.copied(root.draftLayout); const record = next.find(candidate => candidate.name === output); if (!record) return false; record.x = Math.round(x); record.y = Math.round(y); root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next; return true; } function nudge(output: string, dx: int, dy: int): bool { const record = root.draftLayout.find(candidate => candidate.name === output); return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false); } function applyDraft(): bool { return root.displayService.applyLayout(root.copied(root.draftLayout)); } function makePrimary(output: string): bool { const next = root.copied(root.draftLayout); if (!next.some(record => record.name === output)) return false; for (const record of next) record.primary = record.name === output; root.draftLayout = DisplayLayout.normalize(next); return root.applyDraft(); } function canvasSnapshot(): var { return { bounds: root.canvasData.bounds, scale: root.canvasData.scale, rects: root.canvasData.rects.map(record => Object.assign({}, record)) }; } Component.onCompleted: root.resetDraft() Connections { target: root.displayService ignoreUnknownSignals: true function onMonitorsChanged(): void { if (!root.displayService.awaitingConfirmation) root.resetDraft(); } } Column { id: content width: parent.width spacing: 11 Rectangle { id: canvas width: parent.width height: root.width >= 620 ? 232 : 190 radius: Theme.cardRadius color: Theme.alpha(Theme.bgDark, 0.76) border.width: 1 border.color: Theme.alpha(Theme.fg, 0.07) clip: true // A restrained coordinate field makes the topology feel like a // precision instrument without turning it into a technical graph. Repeater { model: 4 Rectangle { required property int index y: (index + 1) * canvas.height / 5 width: canvas.width height: 1 color: Theme.alpha(Theme.fg, 0.025) } } Repeater { model: root.canvasData.rects Rectangle { id: tile required property var modelData readonly property bool selected: root.selectedOutput === modelData.name readonly property var draft: root.draftLayout.find( record => record.name === modelData.name) x: modelData.x y: modelData.y width: Math.max(64, modelData.width) height: Math.max(48, modelData.height) radius: 11 color: tile.selected ? Theme.alpha(Theme.bgHighlight, 0.92) : Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78) border.width: tile.selected || activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accentSecondary : (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14)) opacity: root.interactionEnabled ? 1 : 0.5 activeFocusOnTab: root.interactionEnabled Accessible.role: Accessible.Button Accessible.name: "Move " + tile.modelData.name Accessible.description: tile.draft && tile.draft.primary ? "Primary display. Drag or use the arrow keys to move it." : "Drag or use the arrow keys to move this display." Rectangle { anchors.fill: parent anchors.margins: 2 radius: parent.radius - 2 visible: tile.selected opacity: 0.24 gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0; color: Theme.accent } GradientStop { position: 1; color: Theme.accentSecondary } } } Column { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.margins: 11 spacing: 2 Text { width: parent.width text: tile.modelData.name color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.DemiBold elide: Text.ElideRight } Text { width: parent.width text: tile.draft ? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}` : "" color: Theme.fgDim font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } Rectangle { anchors.top: parent.top anchors.right: parent.right anchors.margins: 7 width: primaryText.implicitWidth + 12 height: 20 radius: Theme.pillRadius visible: tile.draft && tile.draft.primary color: Theme.alpha(Theme.accent, 0.2) Text { id: primaryText anchors.centerIn: parent text: "Primary" color: Theme.accentAlt font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.DemiBold } } HoverHandler { id: hover enabled: root.interactionEnabled cursorShape: Qt.OpenHandCursor } TapHandler { enabled: root.interactionEnabled onTapped: { root.selectionRequested(tile.modelData.name); tile.forceActiveFocus(); } } DragHandler { id: drag target: null enabled: root.interactionEnabled property real initialX: 0 property real initialY: 0 property bool moved: false onActiveChanged: { if (active) { const record = root.draftLayout.find( candidate => candidate.name === tile.modelData.name); initialX = record ? record.x : 0; initialY = record ? record.y : 0; moved = false; root.selectionRequested(tile.modelData.name); tile.forceActiveFocus(); } else if (moved) { const record = root.draftLayout.find( candidate => candidate.name === tile.modelData.name); if (record) { root.setDraftPosition(tile.modelData.name, record.x, record.y, true); root.applyDraft(); } } } onTranslationChanged: { if (!active || root.canvasData.scale <= 0) return; moved = true; root.setDraftPosition( tile.modelData.name, initialX + translation.x / root.canvasData.scale, initialY + translation.y / root.canvasData.scale, false); } } Keys.onPressed: event => { if (!root.interactionEnabled) return; const step = event.modifiers & Qt.ShiftModifier ? 100 : 10; let handled = true; if (event.key === Qt.Key_Left) root.nudge(tile.modelData.name, -step, 0); else if (event.key === Qt.Key_Right) root.nudge(tile.modelData.name, step, 0); else if (event.key === Qt.Key_Up) root.nudge(tile.modelData.name, 0, -step); else if (event.key === Qt.Key_Down) root.nudge(tile.modelData.name, 0, step); else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) root.applyDraft(); else if (event.key === Qt.Key_Escape) root.resetDraft(); else handled = false; event.accepted = handled; } } } } Row { width: parent.width spacing: 8 Text { width: Math.max(0, parent.width - primaryButton.width - applyButton.width - 16) anchors.verticalCenter: parent.verticalCenter text: root.width >= 600 ? "Drag to arrange · arrows move 10 px · Shift moves 100 px" : "Drag or use the arrow keys" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } SettingsButton { id: primaryButton text: "Make primary" enabled: root.interactionEnabled && root.selectedOutput !== "" && !root.draftLayout.find(record => record.name === root.selectedOutput)?.primary onClicked: root.makePrimary(root.selectedOutput) } SettingsButton { id: applyButton text: "Apply" enabled: root.interactionEnabled onClicked: root.applyDraft() } } } }