Add monitor arrangement canvas

This commit is contained in:
Gabriel Brown
2026-08-18 15:00:18 -04:00
parent 5cf2943ccf
commit 70a9d27c98
5 changed files with 485 additions and 0 deletions
@@ -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: "[email protected]", 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: "[email protected]", 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
})));
}
}
}
@@ -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()
}
}
}
}
@@ -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 { SettingsCard {
visible: Displays.monitors.length > 1 visible: Displays.monitors.length > 1
title: "Connected display" title: "Connected display"
@@ -42,6 +42,7 @@ DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.qml DisplayModePicker 1.0 DisplayModePicker.qml
DisplayArrangement 1.0 DisplayArrangement.qml
WifiPanel 1.0 WifiPanel.qml WifiPanel 1.0 WifiPanel.qml
BluetoothPanel 1.0 BluetoothPanel.qml BluetoothPanel 1.0 BluetoothPanel.qml
PasswordField 1.0 PasswordField.qml PasswordField 1.0 PasswordField.qml
+85
View File
@@ -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'