diff --git a/config/dot/quickshell/display-arrangement-harness.qml b/config/dot/quickshell/display-arrangement-harness.qml new file mode 100644 index 0000000..72832ae --- /dev/null +++ b/config/dot/quickshell/display-arrangement-harness.qml @@ -0,0 +1,75 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.modules.settings + +ShellRoot { + QtObject { + id: fixtureService + + property var monitors: [ + { + name: "DP-2", description: "Primary display", width: 4500, height: 3000, + refreshRate: 60, mode: "4500x3000@60.00", scale: 1.5, + transform: 0, x: 0, y: 0, primary: true + }, + { + name: "HDMI-A-1", description: "Second display", width: 2560, height: 1440, + refreshRate: 60, mode: "2560x1440@60.00", scale: 1, + transform: 0, x: 3000, y: 0, primary: false + } + ] + property var applied: [] + + function currentLayout(): var { + return monitors.map(record => Object.assign({}, record)); + } + + function applyLayout(layout: var): bool { + applied = layout.map(record => Object.assign({}, record)); + return true; + } + } + + DisplayArrangement { + id: arrangement + width: 800 + displayService: fixtureService + selectedOutput: "HDMI-A-1" + } + + IpcHandler { + target: "display-arrangement-test" + + function status(width: int): string { + arrangement.width = width; + arrangement.resetDraft(); + return JSON.stringify(arrangement.canvasSnapshot()); + } + + function dragFixture(): string { + arrangement.resetDraft(); + arrangement.setDraftPosition("HDMI-A-1", 3016, 0, true); + arrangement.applyDraft(); + return JSON.stringify(fixtureService.applied); + } + + function keyboardFixture(): string { + arrangement.resetDraft(); + arrangement.nudge("HDMI-A-1", -10, 0); + const afterArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x; + arrangement.nudge("HDMI-A-1", -100, 0); + const afterShiftArrow = arrangement.draftLayout.find(record => record.name === "HDMI-A-1").x; + return JSON.stringify({ afterArrow, afterShiftArrow }); + } + + function primaryFixture(): string { + arrangement.resetDraft(); + arrangement.makePrimary("HDMI-A-1"); + return JSON.stringify(fixtureService.applied.map(record => ({ + name: record.name, x: record.x, y: record.y, primary: record.primary + }))); + } + } +} diff --git a/config/dot/quickshell/modules/settings/DisplayArrangement.qml b/config/dot/quickshell/modules/settings/DisplayArrangement.qml new file mode 100644 index 0000000..9e43963 --- /dev/null +++ b/config/dot/quickshell/modules/settings/DisplayArrangement.qml @@ -0,0 +1,310 @@ +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() + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/DisplaysPage.qml b/config/dot/quickshell/modules/settings/DisplaysPage.qml index eff3b72..81d79b1 100644 --- a/config/dot/quickshell/modules/settings/DisplaysPage.qml +++ b/config/dot/quickshell/modules/settings/DisplaysPage.qml @@ -106,6 +106,20 @@ SettingsPage { } } + SettingsCard { + visible: Displays.monitors.length > 1 + title: "Arrange displays" + subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0." + + DisplayArrangement { + width: parent.width + displayService: Displays + selectedOutput: root.selectedOutput + interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy + onSelectionRequested: output => root.selectedOutput = output + } + } + SettingsCard { visible: Displays.monitors.length > 1 title: "Connected display" diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 67b11fe..75f779a 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -42,6 +42,7 @@ DockAppPicker 1.0 DockAppPicker.qml ShortcutCapture 1.0 ShortcutCapture.qml ChoiceGrid 1.0 ChoiceGrid.qml DisplayModePicker 1.0 DisplayModePicker.qml +DisplayArrangement 1.0 DisplayArrangement.qml WifiPanel 1.0 WifiPanel.qml BluetoothPanel 1.0 BluetoothPanel.qml PasswordField 1.0 PasswordField.qml diff --git a/tests/quickshell/display-arrangement-contract.sh b/tests/quickshell/display-arrangement-contract.sh new file mode 100755 index 0000000..89a2a31 --- /dev/null +++ b/tests/quickshell/display-arrangement-contract.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +component="$repo_dir/config/dot/quickshell/modules/settings/DisplayArrangement.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/DisplaysPage.qml" +harness="$repo_dir/config/dot/quickshell/display-arrangement-harness.qml" + +fail() { + printf 'display arrangement contract: %s\n' "$1" >&2 + exit 1 +} + +[[ -f "$component" ]] || fail 'DisplayArrangement.qml is missing' +for contract in \ + 'required property var displayService' \ + 'property var draftLayout:' \ + 'function setDraftPosition(' \ + 'function nudge(' \ + 'function applyDraft(' \ + 'function makePrimary(' \ + 'Accessible.name: "Move "' \ + 'DragHandler {' \ + 'Keys.onPressed:'; do + rg -Fq "$contract" "$component" \ + || fail "arrangement interaction is missing: $contract" +done +rg -Fq 'DisplayArrangement {' "$page" \ + || fail 'Displays page does not expose the arrangement canvas' +rg -Fq 'visible: Displays.monitors.length > 1' "$page" \ + || fail 'arrangement is shown for a single display' + +state_home="$(mktemp -d /tmp/panama-display-arrangement-state.XXXXXX)" +harness_pid="" +cleanup() { + [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true + rm -rf "$state_home" +} +trap cleanup EXIT + +XDG_STATE_HOME="$state_home" qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 60); do + harness_pid="$(qs list --all 2>/dev/null | awk -v expected="$harness" ' + /^Instance / {pid=""} /^[[:space:]]*Process ID:/ {pid=$3} + /^[[:space:]]*Config path:/ {path=$0; sub(/^[[:space:]]*Config path: /,"",path); if(path==expected) print pid}' | head -1)" + [[ "$harness_pid" =~ ^[0-9]+$ ]] \ + && XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" show 2>/dev/null \ + | rg -q '^target display-arrangement-test$' && break + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'arrangement harness did not start' + +ipc() { + XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" \ + call display-arrangement-test "$@" +} + +wide="$(ipc status 800)" +narrow="$(ipc status 500)" +for snapshot in "$wide" "$narrow"; do + jq -e '(.rects | length) == 2 + and .scale > 0 + and (.rects[0].width / .rects[0].height - 1.5 | fabs) < 0.0001 + and (.rects[1].width / .rects[1].height - (2560 / 1440) | fabs) < 0.0001' \ + <<<"$snapshot" >/dev/null \ + || fail "canvas lost monitor geometry at a supported width: $snapshot" +done + +drag="$(ipc dragFixture)" +jq -e '.[0].x == 0 and .[1].x == 3000' <<<"$drag" >/dev/null \ + || fail "drag release did not snap and apply the complete layout: $drag" + +keyboard="$(ipc keyboardFixture)" +jq -e '.afterArrow == 2990 and .afterShiftArrow == 2890' <<<"$keyboard" >/dev/null \ + || fail "keyboard movement did not use 10/100 logical-pixel steps: $keyboard" + +primary="$(ipc primaryFixture)" +jq -e '. == [ + {"name":"DP-2","x":-3000,"y":0,"primary":false}, + {"name":"HDMI-A-1","x":0,"y":0,"primary":true} +]' <<<"$primary" >/dev/null \ + || fail "Make primary did not normalize the selected output to 0,0: $primary" + +printf 'display arrangement contract: PASS\n'