From 2613768efd2012c96a4db920a4895d7fd6656e31 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 14:36:49 -0400 Subject: [PATCH] Model multi-monitor layout geometry --- .../dot/quickshell/display-layout-harness.qml | 50 ++++++ .../dot/quickshell/services/DisplayLayout.js | 166 ++++++++++++++++++ tests/quickshell/display-layout-contract.sh | 57 ++++++ 3 files changed, 273 insertions(+) create mode 100644 config/dot/quickshell/display-layout-harness.qml create mode 100644 config/dot/quickshell/services/DisplayLayout.js create mode 100755 tests/quickshell/display-layout-contract.sh diff --git a/config/dot/quickshell/display-layout-harness.qml b/config/dot/quickshell/display-layout-harness.qml new file mode 100644 index 0000000..a33ffa4 --- /dev/null +++ b/config/dot/quickshell/display-layout-harness.qml @@ -0,0 +1,50 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import "services/DisplayLayout.js" as DisplayLayout + +ShellRoot { + readonly property var fixture: [ + { name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true }, + { name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false } + ] + + IpcHandler { + target: "display-layout-test" + + function status(): string { + const normalized = DisplayLayout.normalize(fixture); + const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20); + const near = normalized.map(record => Object.assign({}, record)); + near[1].x = 3016; + const far = normalized.map(record => Object.assign({}, record)); + far[1].x = 3017; + return JSON.stringify({ + valid: DisplayLayout.validate(fixture), + sizes: fixture.map(DisplayLayout.logicalSize), + normalized: normalized.map(record => ({ name: record.name, x: record.x, y: record.y, primary: record.primary })), + bounds: canvas.bounds, + canvasScale: canvas.scale, + canvasRects: canvas.rects, + near: DisplayLayout.snap(near, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x, + far: DisplayLayout.snap(far, "HDMI-A-1", 16).find(record => record.name === "HDMI-A-1").x + }); + } + + function invalid(): string { + const base = fixture.map(record => Object.assign({}, record)); + const cases = []; + const add = layout => cases.push(DisplayLayout.validate(layout)); + add([base[0], Object.assign({}, base[1], { name: "DP-2" })]); + add(base.map(record => Object.assign({}, record, { primary: false }))); + add(base.map(record => Object.assign({}, record, { primary: true }))); + add([Object.assign({}, base[0], { x: 0.5 }), base[1]]); + add([Object.assign({}, base[0], { scale: 0 }), base[1]]); + add([Object.assign({}, base[0], { transform: 4 }), base[1]]); + add([Object.assign({}, base[0], { width: Infinity }), base[1]]); + add([Object.assign({}, base[0], { width: 0 }), base[1]]); + return JSON.stringify(cases); + } + } +} diff --git a/config/dot/quickshell/services/DisplayLayout.js b/config/dot/quickshell/services/DisplayLayout.js new file mode 100644 index 0000000..8641d3b --- /dev/null +++ b/config/dot/quickshell/services/DisplayLayout.js @@ -0,0 +1,166 @@ +function logicalSize(record) { + if (!record) + return { width: 0, height: 0 }; + + const width = Number(record.width); + const height = Number(record.height); + const scale = Number(record.scale); + const transform = Number(record.transform); + if (!Number.isFinite(width) || !Number.isFinite(height) + || !Number.isFinite(scale) || scale <= 0) + return { width: 0, height: 0 }; + + const rotated = transform === 1 || transform === 3; + return { + width: (rotated ? height : width) / scale, + height: (rotated ? width : height) / scale + }; +} + +function validCoordinate(value) { + return Number.isFinite(value) && Number.isInteger(value) + && value >= -100000 && value <= 100000; +} + +function validate(layout) { + if (!Array.isArray(layout) || layout.length === 0) + return false; + + const names = {}; + let primaryCount = 0; + for (const record of layout) { + if (!record || typeof record.name !== "string" + || !/^[A-Za-z0-9_.-]+$/.test(record.name) + || names[record.name]) + return false; + names[record.name] = true; + + if (!Number.isFinite(record.width) || record.width <= 0 + || !Number.isFinite(record.height) || record.height <= 0 + || !Number.isFinite(record.scale) || record.scale <= 0 + || !Number.isInteger(record.transform) + || record.transform < 0 || record.transform > 3 + || !validCoordinate(record.x) || !validCoordinate(record.y) + || typeof record.primary !== "boolean") + return false; + + const size = logicalSize(record); + if (!Number.isFinite(size.width) || size.width <= 0 + || !Number.isFinite(size.height) || size.height <= 0) + return false; + if (record.primary) + primaryCount += 1; + } + return primaryCount === 1; +} + +function cloneLayout(layout) { + return (layout || []).map(record => Object.assign({}, record)); +} + +function normalize(layout) { + const result = cloneLayout(layout); + const primary = result.find(record => record.primary === true); + if (!primary) + return result; + const anchorX = primary.x; + const anchorY = primary.y; + for (const record of result) { + record.x -= anchorX; + record.y -= anchorY; + } + return result; +} + +function bounds(layout) { + if (!Array.isArray(layout) || layout.length === 0) + return { x: 0, y: 0, width: 0, height: 0 }; + + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + for (const record of layout) { + const size = logicalSize(record); + left = Math.min(left, record.x); + top = Math.min(top, record.y); + right = Math.max(right, record.x + size.width); + bottom = Math.max(bottom, record.y + size.height); + } + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +function snap(layout, movingName, threshold) { + const result = cloneLayout(layout); + const moving = result.find(record => record.name === movingName); + if (!moving) + return result; + + const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16; + const movingSize = logicalSize(moving); + const movingXEdges = [moving.x, moving.x + movingSize.width]; + const movingYEdges = [moving.y, moving.y + movingSize.height]; + const stationary = result + .filter(record => record.name !== movingName) + .sort((left, right) => left.name.localeCompare(right.name)); + + let bestX = null; + let bestY = null; + for (const record of stationary) { + const size = logicalSize(record); + const xEdges = [record.x, record.x + size.width]; + const yEdges = [record.y, record.y + size.height]; + for (const source of movingXEdges) { + for (const target of xEdges) { + const delta = target - source; + const distance = Math.abs(delta); + if (distance <= limit && (bestX === null || distance < bestX.distance)) + bestX = { delta, distance }; + } + } + for (const source of movingYEdges) { + for (const target of yEdges) { + const delta = target - source; + const distance = Math.abs(delta); + if (distance <= limit && (bestY === null || distance < bestY.distance)) + bestY = { delta, distance }; + } + } + } + + if (bestX !== null) + moving.x += bestX.delta; + if (bestY !== null) + moving.y += bestY.delta; + return result; +} + +function canvasRects(layout, canvasWidth, canvasHeight, padding) { + const desktopBounds = bounds(layout); + const inset = Math.max(0, Number(padding) || 0); + const availableWidth = Math.max(0, Number(canvasWidth) - inset * 2); + const availableHeight = Math.max(0, Number(canvasHeight) - inset * 2); + const scale = desktopBounds.width > 0 && desktopBounds.height > 0 + ? Math.min(availableWidth / desktopBounds.width, + availableHeight / desktopBounds.height) + : 0; + const contentWidth = desktopBounds.width * scale; + const contentHeight = desktopBounds.height * scale; + const originX = inset + (availableWidth - contentWidth) / 2; + const originY = inset + (availableHeight - contentHeight) / 2; + return { + bounds: desktopBounds, + scale, + rects: (layout || []).map(record => { + const size = logicalSize(record); + return { + name: record.name, + x: originX + (record.x - desktopBounds.x) * scale, + y: originY + (record.y - desktopBounds.y) * scale, + width: size.width * scale, + height: size.height * scale, + primary: record.primary === true + }; + }) + }; +} diff --git a/tests/quickshell/display-layout-contract.sh b/tests/quickshell/display-layout-contract.sh new file mode 100755 index 0000000..8071cc7 --- /dev/null +++ b/tests/quickshell/display-layout-contract.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/display-layout-harness.qml" +state_home="$(mktemp -d /tmp/panama-display-layout-state.XXXXXX)" +shell_log="$state_home/quickshell.log" +harness_pid="" + +fail() { + printf 'display layout contract: %s\n' "$1" >&2 + [[ -s "$shell_log" ]] && sed -n '1,160p' "$shell_log" >&2 + exit 1 +} + +cleanup() { + [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill "$harness_pid" 2>/dev/null || true + rm -rf "$state_home" +} +trap cleanup EXIT + +qs_for_harness() { + if [[ "$harness_pid" =~ ^[0-9]+$ && "${1:-}" == "ipc" ]]; then + XDG_STATE_HOME="$state_home" qs -p "$harness" ipc --pid "$harness_pid" "${@:2}" + else + XDG_STATE_HOME="$state_home" qs -p "$harness" "$@" + fi +} + +qs_for_harness --daemonize >"$shell_log" 2>&1 || fail 'geometry harness did not launch' +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]+$ ]] \ + && qs_for_harness ipc show 2>/dev/null | rg -q '^target display-layout-test$' && break + sleep 0.1 +done +[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'geometry harness process did not start' + +status="$(qs_for_harness ipc call display-layout-test status)" +jq -e '.valid == true + and .sizes == [{"width":3000,"height":2000},{"width":1440,"height":2560}] + and .normalized == [ + {"name":"DP-2","x":0,"y":0,"primary":true}, + {"name":"HDMI-A-1","x":3000,"y":0,"primary":false}] + and .bounds == {"x":0,"y":0,"width":4440,"height":2560} + and .near == 3000 and .far == 3017 + and (.canvasRects | length) == 2 + and ((.bounds.width / .bounds.height) - (4440 / 2560) | fabs) < 0.000001' \ + <<<"$status" >/dev/null || fail "geometry output was wrong: $status" + +invalid="$(qs_for_harness ipc call display-layout-test invalid)" +jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid" + +printf 'display layout contract: PASS\n'