Add monitor arrangement canvas
This commit is contained in:
@@ -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 {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Connected display"
|
||||
|
||||
@@ -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
|
||||
|
||||
+85
@@ -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'
|
||||
Reference in New Issue
Block a user